commit fe3ad20fe28ebe2b8a70e7840ac4949d180a6d75 Author: huangjin <1603612708@qq.com> Date: Mon Jun 29 17:34:59 2026 +0800 'init' diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid new file mode 100644 index 0000000..0ec4bb4 --- /dev/null +++ b/.codegraph/daemon.pid @@ -0,0 +1,6 @@ +{ + "pid": 23452, + "version": "0.9.7", + "socketPath": "\\\\.\\pipe\\codegraph-8d231922d5519a75", + "startedAt": 1782091277662 +} diff --git a/.reasonix/desktop-topic-created-at.json b/.reasonix/desktop-topic-created-at.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.reasonix/desktop-topic-created-at.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/.reasonix/desktop-topic-title-sources.json b/.reasonix/desktop-topic-title-sources.json new file mode 100644 index 0000000..0d64756 --- /dev/null +++ b/.reasonix/desktop-topic-title-sources.json @@ -0,0 +1,3 @@ +{ + "topic_20260623-052546_02ce107c7f58bd23": "auto" +} \ No newline at end of file diff --git a/.reasonix/desktop-topic-titles.json b/.reasonix/desktop-topic-titles.json new file mode 100644 index 0000000..2a76853 --- /dev/null +++ b/.reasonix/desktop-topic-titles.json @@ -0,0 +1,3 @@ +{ + "topic_20260623-052546_02ce107c7f58bd23": "因为项目中想要使用到审批流相关的内容…" +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..50157ad --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "go后端", + "type": "go", + "request": "launch", + "mode": "auto", + "cwd": "./sc-lktx-backend", + "program": "./sc-lktx-backend/cmd/server" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a12ce5 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# 本单位访客预约及权限管理系统 + +## 项目概述 + +基于微信小程序的通用权限底座 + 访客预约模块,支持动态角色扩展和模块化功能开发。 + +## 技术栈 + +### 后端 +- Go 1.21 + Gin + GORM (PostgreSQL 16) +- Casbin (gorm-adapter) 动态权限 +- JWT 认证 +- go-redis + MinIO + RabbitMQ +- Zap + Viper + snowflake + +### 前端 +- Uniapp (Vue3 组合式 API) +- uView Plus 组件库 +- Pinia 状态管理 + +## 快速开始 + +### 1. 启动基础设施 + +```bash +cd sc-lktx-backend +docker-compose up -d +``` + +### 2. 配置 + +编辑 `sc-lktx-backend/config/config.yaml`: +- 设置 `admin.openid` 为管理员微信 openid(生产环境务必配置) +- 配置微信 AppID 和 AppSecret + +### 3. 启动后端 + +```bash +cd sc-lktx-backend +go mod tidy +go run ./cmd/server +``` + +### 4. 启动前端 + +```bash +cd sc-lktx-mp-frontend +npm install +npm run dev:h5 +``` + +## 默认角色 + +通用权限底座提供以下内置角色,支持动态增加: + +| 角色 | Code | 说明 | +|------|------|------| +| 管理员 | admin | 系统管理员,拥有所有权限 | +| 员工 | employee | 园区正式员工,可审核预约、生成邀请 | +| 非员工 | non_employee | 外包/临时工/合作方等非正式员工 | +| 保安 | guard | 门卫保安,核验进出 | +| 访客 | visitor | 默认角色,可创建预约 | + +> **设计说明**: +> - **多角色支持**:一个用户可同时拥有多个角色(如既是员工又是保安) +> - **员工(领导)**:领导不再作为独立角色,而是通过 `Employee.IsLeader` 属性在业务层区分。拥有 `employee` 角色 + `IsLeader=true` 即具备领导权限 +> - **动态扩展**:管理员可通过后台管理界面动态创建新角色、分配权限,无需修改代码 +> - **通用 Token**:最外层小程序签发 JWT Token(包含所有角色),各功能模块通过 Casbin 中间件验证权限 + +## 默认模块 + +| 模块 | Code | 说明 | +|------|------|------| +| 访客预约 | appointment | 访客预约管理 | +| 门禁管理 | gate | 门禁进出管理 | +| 食堂管理 | canteen | 食堂订餐管理(预留) | +| 系统管理 | admin | 系统后台管理 | + +## 扩展新模块 + +1. 后端在 `internal/modules/` 下新建包,实现 Handler +2. 在 `main.go` 路由中注册模块接口,使用 Casbin 中间件挂载权限 +3. 管理员在后管界面创建模块并分配权限策略 +4. 前端添加对应页面,通过 `userStore.hasRole('xxx')` 控制可见性 + +### 模块内权限设计模式 + +```go +// 在 RegisterRoutes 中使用 Casbin 中间件 +moduleGroup := r.Group("/module-name") +moduleGroup.Use(middleware.CasbinAuth(enforcer, "module_domain", "resource:action")) +// 管理员在后台添加策略: some_role, module_domain, resource:action, allow +``` + +### 前端角色判断(动态,无需硬编码) + +```typescript +const userStore = useUserStore() +// 通用判断 +userStore.hasRole('employee') // 是否拥有某角色 +// 便捷 getter +userStore.isAdmin // 管理员 +userStore.isEmployee // 员工 +userStore.isGuard // 保安 +userStore.isVisitor // 访客 +``` + +## 项目结构 + +``` +├── sc-lktx-backend/ # 后端 Go 项目 +│ ├── cmd/server/main.go # 入口 +│ ├── config/ # 配置文件 +│ ├── internal/ +│ │ ├── common/ # 公共模块 +│ │ │ ├── middleware/ # 中间件 +│ │ │ ├── model/ # GORM 模型 +│ │ │ ├── response/ # 统一响应 +│ │ │ └── utils/ # 工具函数 +│ │ ├── modules/ # 业务模块 +│ │ │ ├── appointment/ # 访客预约 +│ │ │ └── role/ # 角色申请 +│ │ ├── auth/ # 认证模块 +│ │ ├── admin/ # 管理模块 +│ │ └── pkg/ # 基础设施封装 +│ └── docker-compose.yml # Docker 编排 +│ +└── sc-lktx-mp-frontend/ # 前端 Uniapp 项目 + ├── pages/ # 页面 + ├── store/ # Pinia 状态管理 + ├── utils/ # 工具函数 + └── static/ # 静态资源 +``` diff --git a/_通讯录样本.json b/_通讯录样本.json new file mode 100644 index 0000000..9c84c4d --- /dev/null +++ b/_通讯录样本.json @@ -0,0 +1,122 @@ +[ + { + "name": "'刘一新'", + "phone": "'+86-18601293697'", + "dept": "'陕西凌空-总经理办公室'", + "position": "'副总经理'" + }, + { + "name": "'沈海滨'", + "phone": "'+86-13911115908'", + "dept": "'北京凌空-公司办公室'", + "position": "'秘书'" + }, + { + "name": "'王毓栋'", + "phone": "'+86-13810156839'", + "dept": "'北京凌空-公司办公室'", + "position": "'董事长'" + }, + { + "name": "'杨巍'", + "phone": "'+86-15811470796'", + "dept": "'蚌埠凌空,蚌埠凌空-总经理办公室'", + "position": "'总经理'" + }, + { + "name": "'孙洁'", + "phone": "'+86-13488893909'", + "dept": "'北京凌空-公司办公室'", + "position": "'副总经理'" + }, + { + "name": "'傅琢砂'", + "phone": "'+86-15267009157'", + "dept": "'四川凌空-总经理办公室'", + "position": "'副总经理'" + }, + { + "name": "'程晓倩'", + "phone": "'+86-13121230368'", + "dept": "'四川凌空-总体研发部'", + "position": "'总体研发部副部长'" + }, + { + "name": "'梁冰冰'", + "phone": "'+86-18612005917'", + "dept": "'北京凌空-公司办公室'", + "position": "'总经理'" + }, + { + "name": "'梁巨峰'", + "phone": "'+86-13910932517'", + "dept": "'四川凌空-总经理办公室'", + "position": "'总经理'" + }, + { + "name": "'付海华'", + "phone": "'+86-15001357033'", + "dept": "'新疆尉辰-航管安全部,新疆尉辰-技术研发部,尉臻华品-总经理办公室,新疆尉辰,新疆尉辰-总经理办公室,四川凌空-市场开发部,四川凌空-总经理办公室'", + "position": "'总经理'" + }, + { + "name": "'杨舒'", + "phone": "'+86-13811737369'", + "dept": "'四川凌空-商务交流部,四川凌空-市场开发部'", + "position": "'商务交流部部长'" + }, + { + "name": "'陈仁越'", + "phone": "'+86-18710092365'", + "dept": "'北京凌空-公司办公室'", + "position": "'副总经理'" + }, + { + "name": "'刘馨爻'", + "phone": "'+86-15313248218'", + "dept": "'四川凌空-总经理办公室,北京凌空-公司办公室'", + "position": "'秘书'" + }, + { + "name": "'许龙飞'", + "phone": "'+86-13401147346'", + "dept": "'四川凌空-总经理办公室,四川凌空-体系管理部,四川凌空-绵阳凌空,四川凌空-智能制造部'", + "position": "'副总经理'" + }, + { + "name": "'韩洋'", + "phone": "'+86-13811462118'", + "dept": "'北京凌空-飞控技术部'", + "position": "'飞控技术部副部长'" + }, + { + "name": "'刘帅'", + "phone": "'+86-15811373122'", + "dept": "'北京凌空-软件工程部'", + "position": "'软件工程部部长'" + }, + { + "name": "'王兆义'", + "phone": "'+86-18511559792'", + "dept": "'北京凌空-发射事业部-系统工程部,北京凌空-发射事业部-系统工程部-系统工程部(北京),北京凌空-发射事业部-系统工程部-系统工程部(陕西),北京凌空-发射事业部'", + "position": "'发射事业部副总经理'" + }, + { + "name": "'王刚'", + "phone": "'+86-18811213147'", + "dept": "'四川凌空-交付中心'", + "position": "'交付中心副部长(总装)'" + }, + { + "name": "'白海'", + "phone": "'+86-18165316216'", + "dept": "'陕西凌空-固发产线部'", + "position": "'推进剂配方设计'" + }, + { + "name": "'王春光'", + "phone": "'+86-15829338146'", + "dept": "'陕西凌空-总经理办公室,陕西凌空-固发产线部,陕西凌空-综合管理部'", + "position": "'总经理'" + } +] \ No newline at end of file diff --git a/mp-sc-admin/.env b/mp-sc-admin/.env new file mode 100644 index 0000000..8c6e46b --- /dev/null +++ b/mp-sc-admin/.env @@ -0,0 +1 @@ +VITE_API_BASEURL=http://localhost:28175 diff --git a/mp-sc-admin/.env.prod b/mp-sc-admin/.env.prod new file mode 100644 index 0000000..f4d0fb7 --- /dev/null +++ b/mp-sc-admin/.env.prod @@ -0,0 +1 @@ +VITE_API_BASEURL=https://mp.sclktx.com/api diff --git a/mp-sc-admin/.gitignore b/mp-sc-admin/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/mp-sc-admin/.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/mp-sc-admin/index.html b/mp-sc-admin/index.html new file mode 100644 index 0000000..80054c7 --- /dev/null +++ b/mp-sc-admin/index.html @@ -0,0 +1,13 @@ + + + + + + + 凌空天行后台管理 + + +
+ + + diff --git a/mp-sc-admin/package-lock.json b/mp-sc-admin/package-lock.json new file mode 100644 index 0000000..f8e4278 --- /dev/null +++ b/mp-sc-admin/package-lock.json @@ -0,0 +1,2835 @@ +{ + "name": "mp-sc-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mp-sc-admin", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "@iconify/json": "^2.2.0", + "alova": "^3.3.0", + "element-plus": "^2.9.0", + "pinia": "^2.2.0", + "unplugin-icons": "^0.20.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "vue3-json-viewer": "^2.4.1" + }, + "devDependencies": { + "@iconify/tailwind": "^1.2.0", + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.0", + "tailwindcss": "^4.0.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.1.0" + } + }, + "node_modules/@alova/shared": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/@alova/shared/-/shared-1.3.2.tgz", + "integrity": "sha512-1XvDLWgYpVZ99MmLl1f3Fw4T6S6pPYk5afz5cwRVjuq8JXEGsDn9IygDKfvRyWqkqCBx7Jif07LIct1O+MVEow==", + "license": "MIT" + }, + "node_modules/@antfu/install-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-0.5.0.tgz", + "integrity": "sha512-dKnk2xlAyC7rvTkpkHmu+Qy/2Zc3Vm/l8PtNyIOGDBtXPY3kThfU4ORNEp3V7SXw5XSOb+tOJaUYpfquPzL/Tg==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^0.2.5", + "tinyexec": "^0.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "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/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@iconify/json": { + "version": "2.2.486", + "resolved": "https://registry.npmmirror.com/@iconify/json/-/json-2.2.486.tgz", + "integrity": "sha512-ybeEhKPvniErZvczHk0p3xZgG/uAYT2Lo9YwkJr57cr6lr7xq8ezVWzzoAGaxZnsxSF54cyYmyPT/QIi9cc9Uw==", + "license": "MIT", + "dependencies": { + "@iconify/types": "*", + "pathe": "^2.0.3" + } + }, + "node_modules/@iconify/tailwind": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@iconify/tailwind/-/tailwind-1.2.0.tgz", + "integrity": "sha512-KgpIHWOTcRYw1XcoUqyNSrmYyfLLqZYu3AmP8zdfLk0F5TqRO8YerhlvlQmGfn7rJXgPeZN569xPAJnJ53zZxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-2.3.0.tgz", + "integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.0.0", + "@antfu/utils": "^8.1.0", + "@iconify/types": "^2.0.0", + "debug": "^4.4.0", + "globals": "^15.14.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.0.0", + "mlly": "^1.7.4" + } + }, + "node_modules/@iconify/utils/node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@iconify/utils/node_modules/@antfu/utils": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-8.1.1.tgz", + "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@iconify/utils/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/@iconify/utils/node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "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/@iconify/utils/node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/@iconify/utils/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/@iconify/utils/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "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/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.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": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/alova": { + "version": "3.5.1", + "resolved": "https://registry.npmmirror.com/alova/-/alova-3.5.1.tgz", + "integrity": "sha512-avrWPyFFWW51YLoy0S3OleNw1BV0GqNI+DSdWHfFbAoKZp80cXCCc7OtjA6OWeyhCOMglUMwo9O8j5huwnzFtQ==", + "license": "MIT", + "dependencies": { + "@alova/shared": "1.3.2", + "rate-limiter-flexible": "^5.0.3" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "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/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/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/clipboard": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", + "license": "MIT", + "dependencies": { + "good-listener": "^1.2.2", + "select": "^1.1.2", + "tiny-emitter": "^2.0.0" + } + }, + "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/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.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "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.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "license": "MIT" + }, + "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/element-plus": { + "version": "2.14.2", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.2.tgz", + "integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.3" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/good-listener": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", + "license": "MIT", + "dependencies": { + "delegate": "^3.1.2" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "license": "MIT" + }, + "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": "0.5.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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/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/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/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/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.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, + "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/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/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/rate-limiter-flexible": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/rate-limiter-flexible/-/rate-limiter-flexible-5.0.5.tgz", + "integrity": "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==", + "license": "ISC" + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/select": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", + "license": "MIT" + }, + "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/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "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/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-icons": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/unplugin-icons/-/unplugin-icons-0.20.2.tgz", + "integrity": "sha512-Ak6TKAiO812aIUrCelrBSTQbYC4FiqawnFrAusP/hjmB8f9cAug9jr381ItvLl+Asi4IVcjoOiPbpy9CfFGKvQ==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^0.5.0", + "@antfu/utils": "^0.7.10", + "@iconify/utils": "^2.1.33", + "debug": "^4.3.7", + "kolorist": "^1.8.0", + "local-pkg": "^0.5.1", + "unplugin": "^1.16.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@svgr/core": ">=7.0.0", + "@svgx/core": "^1.0.1", + "@vue/compiler-sfc": "^3.0.2 || ^2.7.0", + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", + "vue-template-compiler": "^2.6.12", + "vue-template-es2015-compiler": "^1.9.0" + }, + "peerDependenciesMeta": { + "@svgr/core": { + "optional": true + }, + "@svgx/core": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + }, + "vue-template-es2015-compiler": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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.38", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", + "integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==", + "license": "MIT" + }, + "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-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vue3-json-viewer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/vue3-json-viewer/-/vue3-json-viewer-2.4.1.tgz", + "integrity": "sha512-Z1sunvS58lJ3ZcpNhl3jYQapBVw2wjnXbemigfMWm3QnjCeg3CPMq8R6pxHUYahxMfPKLvrbGve6mUXqhWyLaQ==", + "license": "ISC", + "dependencies": { + "clipboard": "^2.0.10" + }, + "peerDependencies": { + "vue": "^3.5.16" + } + }, + "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" + } + } +} diff --git a/mp-sc-admin/package.json b/mp-sc-admin/package.json new file mode 100644 index 0000000..1c62aa9 --- /dev/null +++ b/mp-sc-admin/package.json @@ -0,0 +1,32 @@ +{ + "name": "mp-sc-admin", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:prod": "vite build --mode prod", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "@iconify/json": "^2.2.0", + "alova": "^3.3.0", + "element-plus": "^2.9.0", + "pinia": "^2.2.0", + "unplugin-icons": "^0.20.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "vue3-json-viewer": "^2.4.1" + }, + "devDependencies": { + "@iconify/tailwind": "^1.2.0", + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.0", + "tailwindcss": "^4.0.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/mp-sc-admin/src/App.vue b/mp-sc-admin/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/mp-sc-admin/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/mp-sc-admin/src/api/index.ts b/mp-sc-admin/src/api/index.ts new file mode 100644 index 0000000..099a2c2 --- /dev/null +++ b/mp-sc-admin/src/api/index.ts @@ -0,0 +1,192 @@ +import request from '@/utils/request' + +// ============ 用户管理 ============ + +export function getUsers(params: { page: number; page_size: number; keyword?: string }) { + return request.Get('/v1/admin/users', { params }) +} + +export function updateUser(id: number, data: Record) { + return request.Put(`/v1/admin/users/${id}`, data) +} + +export function getUserRoles(id: number) { + return request.Get(`/v1/admin/users/${id}/roles`) +} + +// ============ Banner 管理 ============ + +export function getBanners() { + return request.Get('/v1/admin/banners') +} + +export function createBanner(data: Record) { + return request.Post('/v1/admin/banners', data) +} + +export function updateBanner(id: number, data: Record) { + return request.Put(`/v1/admin/banners/${id}`, data) +} + +export function deleteBanner(id: number) { + return request.Delete(`/v1/admin/banners/${id}`) +} + +// ============ 通知管理 ============ + +export function getNotices() { + return request.Get('/v1/admin/notices') +} + +export function createNotice(data: Record) { + return request.Post('/v1/admin/notices', data) +} + +export function updateNotice(id: number, data: Record) { + return request.Put(`/v1/admin/notices/${id}`, data) +} + +export function deleteNotice(id: number) { + return request.Delete(`/v1/admin/notices/${id}`) +} + +// ============ 认证审核 ============ + +export function getCertifications(params?: Record) { + return request.Get('/v1/admin/certifications', { params }) +} + +export function auditCertification(id: number, data: Record) { + return request.Put(`/v1/admin/certifications/${id}/audit`, data) +} + + + +// ============ 员工列表 ============ + +/** 获取员工列表(含部门信息) */ +export function getEmployees() { + return request.Get('/v1/admin/employees') +} + +// ============ 员工-部门关联 ============ + +/** 设置员工所属部门(可选传 position) */ +export function setEmployeeDepartment(userId: number, departmentId: number, position?: string) { + const data: Record = { department_id: departmentId } + if (position !== undefined) data.position = position + return request.Put(`/v1/admin/employees/${userId}/department`, data) +} + +/** 清除员工所属部门 */ +export function clearEmployeeDepartment(userId: number) { + return request.Delete(`/v1/admin/employees/${userId}/department`) +} + +/** 获取所有员工-部门关联 */ +export function getEmployeeDepartments() { + return request.Get('/v1/admin/employee-departments') +} + +// ============ 角色 ============ + +export function getRoles() { + return request.Get('/v1/admin/roles') +} + +// ============ 部门管理 ============ + +export function getDepartmentTree() { + return request.Get('/v1/admin/department-tree') +} + +export function getDepartments() { + return request.Get('/v1/admin/departments') +} + +export function createDepartment(data: Record) { + return request.Post('/v1/admin/departments', data) +} + +export function updateDepartment(id: number, data: Record) { + return request.Put(`/v1/admin/departments/${id}`, data) +} + +export function deleteDepartment(id: number) { + return request.Delete(`/v1/admin/departments/${id}`) +} + +export function listAllDepartments() { + return request.Get('/v1/admin/departments-all') +} + +// ============ 微信素材上传 ============ + +/** 通过文件上传图片到微信服务器 */ +export function uploadImage(file: File) { + const formData = new FormData() + formData.append('file', file) + // 不手动设置 Content-Type:浏览器/ fetch 会自动追加 boundary + return request.Post('/v1/upload/image', formData) +} + +/** 通过链接上传图片到微信服务器 */ +export function uploadImageByURL(url: string) { + return request.Post<{ url: string }>('/v1/upload/image-by-url', { url }) +} + +// ============ 审批流配置(新) ============ + +/** 设置部门的分管领导用户ID列表 */ +export function setDepartmentVPs(departmentId: number, vpUserIds: number[]) { + return request.Put(`/v1/admin/departments/${departmentId}/vp-users`, { vp_user_ids: vpUserIds }) +} + +/** 设置部门指定审批人(可多人) */ +export function setDepartmentApprover(departmentId: number, approverUserIds: number[]) { + return request.Put(`/v1/admin/departments/${departmentId}/approver`, { approver_user_ids: approverUserIds }) +} + +// ============ 员工通讯录导入 ============ + +/** 批量导入待确认员工 */ +export function importPendingEmployees(data: { employees: { real_name: string; phone: string; department_id: number; position?: string }[] }) { + return request.Post('/v1/admin/employees/import', data) +} + +/** 待确认员工列表 */ +export function getPendingEmployees(params?: { page?: number; page_size?: number; status?: string; keyword?: string; department_id?: number }) { + return request.Get('/v1/admin/employees/pending', { params }) +} + +/** 手动匹配待确认员工到用户 */ +export function matchPendingEmployee(id: number) { + return request.Post(`/v1/admin/employees/pending/${id}/match`) +} + +/** 待确认员工统计 */ +export function getPendingEmployeeStats() { + return request.Get('/v1/admin/employees/pending/stats') +} + +/** 一键同步所有待确认员工信息到用户 */ +export function syncAllPendingEmployees() { + return request.Post('/v1/admin/employees/pending/sync-all') +} + +// ============ 全局兜底审批人 ============ + +/** 获取全局兜底审批人列表 */ +export function getGlobalApprovers() { + return request.Get('/v1/admin/global-approvers') +} + +/** 添加全局兜底审批人 */ +export function createGlobalApprover(userId: number) { + return request.Post('/v1/admin/global-approvers', { user_id: userId }) +} + +/** 删除全局兜底审批人 */ +export function deleteGlobalApprover(id: number) { + return request.Delete(`/v1/admin/global-approvers/${id}`) +} diff --git a/mp-sc-admin/src/main.ts b/mp-sc-admin/src/main.ts new file mode 100644 index 0000000..ac56451 --- /dev/null +++ b/mp-sc-admin/src/main.ts @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) +app.mount('#app') diff --git a/mp-sc-admin/src/pages/Appointments.vue b/mp-sc-admin/src/pages/Appointments.vue new file mode 100644 index 0000000..b6c7b3c --- /dev/null +++ b/mp-sc-admin/src/pages/Appointments.vue @@ -0,0 +1,345 @@ + + + + + + diff --git a/mp-sc-admin/src/pages/ApprovalConfig.vue b/mp-sc-admin/src/pages/ApprovalConfig.vue new file mode 100644 index 0000000..49d2472 --- /dev/null +++ b/mp-sc-admin/src/pages/ApprovalConfig.vue @@ -0,0 +1,213 @@ + + + diff --git a/mp-sc-admin/src/pages/Approvals.vue b/mp-sc-admin/src/pages/Approvals.vue new file mode 100644 index 0000000..dd52bda --- /dev/null +++ b/mp-sc-admin/src/pages/Approvals.vue @@ -0,0 +1,252 @@ + + + + + + diff --git a/mp-sc-admin/src/pages/Banners.vue b/mp-sc-admin/src/pages/Banners.vue new file mode 100644 index 0000000..00d31cd --- /dev/null +++ b/mp-sc-admin/src/pages/Banners.vue @@ -0,0 +1,211 @@ + + + diff --git a/mp-sc-admin/src/pages/Dashboard.vue b/mp-sc-admin/src/pages/Dashboard.vue new file mode 100644 index 0000000..bdafa65 --- /dev/null +++ b/mp-sc-admin/src/pages/Dashboard.vue @@ -0,0 +1,33 @@ + + + diff --git a/mp-sc-admin/src/pages/Departments.vue b/mp-sc-admin/src/pages/Departments.vue new file mode 100644 index 0000000..a5f2897 --- /dev/null +++ b/mp-sc-admin/src/pages/Departments.vue @@ -0,0 +1,453 @@ + + + diff --git a/mp-sc-admin/src/pages/Layout.vue b/mp-sc-admin/src/pages/Layout.vue new file mode 100644 index 0000000..a200134 --- /dev/null +++ b/mp-sc-admin/src/pages/Layout.vue @@ -0,0 +1,114 @@ + + + + diff --git a/mp-sc-admin/src/pages/Login.vue b/mp-sc-admin/src/pages/Login.vue new file mode 100644 index 0000000..3ba3d6b --- /dev/null +++ b/mp-sc-admin/src/pages/Login.vue @@ -0,0 +1,54 @@ + + + diff --git a/mp-sc-admin/src/pages/Materials.vue b/mp-sc-admin/src/pages/Materials.vue new file mode 100644 index 0000000..51c609f --- /dev/null +++ b/mp-sc-admin/src/pages/Materials.vue @@ -0,0 +1,192 @@ + + + diff --git a/mp-sc-admin/src/pages/Notices.vue b/mp-sc-admin/src/pages/Notices.vue new file mode 100644 index 0000000..f088111 --- /dev/null +++ b/mp-sc-admin/src/pages/Notices.vue @@ -0,0 +1,6 @@ + diff --git a/mp-sc-admin/src/pages/PendingEmployees.vue b/mp-sc-admin/src/pages/PendingEmployees.vue new file mode 100644 index 0000000..299e2e7 --- /dev/null +++ b/mp-sc-admin/src/pages/PendingEmployees.vue @@ -0,0 +1,254 @@ + + + diff --git a/mp-sc-admin/src/pages/Users.vue b/mp-sc-admin/src/pages/Users.vue new file mode 100644 index 0000000..fbcad35 --- /dev/null +++ b/mp-sc-admin/src/pages/Users.vue @@ -0,0 +1,288 @@ + + + diff --git a/mp-sc-admin/src/pages/VisitTypes.vue b/mp-sc-admin/src/pages/VisitTypes.vue new file mode 100644 index 0000000..3b63934 --- /dev/null +++ b/mp-sc-admin/src/pages/VisitTypes.vue @@ -0,0 +1,122 @@ + + + diff --git a/mp-sc-admin/src/pages/VisitorAreas.vue b/mp-sc-admin/src/pages/VisitorAreas.vue new file mode 100644 index 0000000..b7d0b2f --- /dev/null +++ b/mp-sc-admin/src/pages/VisitorAreas.vue @@ -0,0 +1,122 @@ + + + diff --git a/mp-sc-admin/src/pages/WorkflowDesigner.vue b/mp-sc-admin/src/pages/WorkflowDesigner.vue new file mode 100644 index 0000000..04c10ea --- /dev/null +++ b/mp-sc-admin/src/pages/WorkflowDesigner.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/mp-sc-admin/src/router/index.ts b/mp-sc-admin/src/router/index.ts new file mode 100644 index 0000000..91bd19a --- /dev/null +++ b/mp-sc-admin/src/router/index.ts @@ -0,0 +1,35 @@ +import {createRouter, createWebHashHistory} from 'vue-router' +import Login from '@/pages/Login.vue' +import Layout from '@/pages/Layout.vue' + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/login', component: Login }, + { + path: '/', component: Layout, redirect: '/dashboard', + children: [ + { path: 'dashboard', component: () => import('@/pages/Dashboard.vue'), meta: { title: '概览' } }, + { path: 'users', component: () => import('@/pages/Users.vue'), meta: { title: '用户管理' } }, + { path: 'approvals', component: () => import('@/pages/Approvals.vue'), meta: { title: '认证审核' } }, + { path: 'departments', component: () => import('@/pages/Departments.vue'), meta: { title: '组织架构' } }, + { path: 'banners', component: () => import('@/pages/Banners.vue'), meta: { title: 'Banner管理' } }, + { path: 'notices', component: () => import('@/pages/Notices.vue'), meta: { title: '通知管理' } }, + { path: 'materials', component: () => import('@/pages/Materials.vue'), meta: { title: '素材上传' } }, + { path: 'config', component: () => import('@/pages/ApprovalConfig.vue'), meta: { title: '审批配置' } }, + { path: 'visit-types', component: () => import('@/pages/VisitTypes.vue'), meta: { title: '来访目的' } }, + { path: 'visitor-areas', component: () => import('@/pages/VisitorAreas.vue'), meta: { title: '到访区域' } }, + { path: 'appointments', component: () => import('@/pages/Appointments.vue'), meta: { title: '预约管理' } }, + { path: 'pending-employees', component: () => import('@/pages/PendingEmployees.vue'), meta: { title: '通讯录导入' } }, + { path: 'workflow-designer', component: () => import('@/pages/WorkflowDesigner.vue'), meta: { title: '流程设计器' } }, + ], + }, + ], +}) + +router.beforeEach((to) => { + const token = localStorage.getItem('admin_token') + if (to.path !== '/login' && !token) return '/login' +}) + +export default router diff --git a/mp-sc-admin/src/style.css b/mp-sc-admin/src/style.css new file mode 100644 index 0000000..3312075 --- /dev/null +++ b/mp-sc-admin/src/style.css @@ -0,0 +1,9 @@ +@import "tailwindcss"; +@import "element-plus/dist/index.css"; + +/* 默认边框颜色:避免 Tailwind base reset 导致未显式设置颜色的边框显示为黑色 */ +*, +::before, +::after { + border-color: #e5e7eb; +} diff --git a/mp-sc-admin/src/utils/request.ts b/mp-sc-admin/src/utils/request.ts new file mode 100644 index 0000000..2231c74 --- /dev/null +++ b/mp-sc-admin/src/utils/request.ts @@ -0,0 +1,44 @@ +import { createAlova } from 'alova' +import VueHook from 'alova/vue' +import adapterFetch from 'alova/fetch' +import { ElMessage } from 'element-plus' + +export const request = createAlova({ + baseURL: import.meta.env.VITE_API_BASEURL || '', + // 设置为null即可全局关闭全部请求缓存 + cacheFor: null, + statesHook: VueHook, + requestAdapter: adapterFetch(), + beforeRequest(method) { + const token = localStorage.getItem('admin_token') + if (token) { + method.config.headers = { ...method.config.headers, Authorization: `Bearer ${token}` } + } + }, + responded: { + onSuccess: async (response) => { + const data = await response.json() + if (data.code !== 200) { + if (response.status === 401 || data.code === 401) { + localStorage.removeItem('admin_token') + window.location.href = '/login' + throw new Error('redirect') + } + ElMessage.error(data.msg) + throw new Error(data.msg) + } + return data + }, + onError: (error) => { + if (error?.message === 'redirect') return + if (error?.response?.status === 401) { + localStorage.removeItem('admin_token') + window.location.href = '/login' + return + } + ElMessage.error(error.message || '请求失败') + }, + }, +}) + +export default request diff --git a/mp-sc-admin/src/vite-env.d.ts b/mp-sc-admin/src/vite-env.d.ts new file mode 100644 index 0000000..b6a7768 --- /dev/null +++ b/mp-sc-admin/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/mp-sc-admin/tsconfig.json b/mp-sc-admin/tsconfig.json new file mode 100644 index 0000000..cc754be --- /dev/null +++ b/mp-sc-admin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { "@/*": ["./src/*"] }, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"], + "exclude": ["node_modules"] +} diff --git a/mp-sc-admin/vite.config.ts b/mp-sc-admin/vite.config.ts new file mode 100644 index 0000000..5aeb1ac --- /dev/null +++ b/mp-sc-admin/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' +import tailwindcss from '@tailwindcss/vite' +import Icons from 'unplugin-icons/vite' + +export default defineConfig({ + base: '/admin', + plugins: [vue(), tailwindcss(), Icons({ compiler: 'vue3' })], + resolve: { + alias: { '@': resolve(__dirname, 'src') }, + }, + server: { + port: 3000, + // proxy: { + // '/v1': { target: "http://localhost:28175", rewrite: (path) => path.replace(/^\/v1/, "/api/v1"), changeOrigin: true }, + // }, + }, +}) diff --git a/mp-sc-frontend/.changeset/README.md b/mp-sc-frontend/.changeset/README.md new file mode 100644 index 0000000..654c6d4 --- /dev/null +++ b/mp-sc-frontend/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets). + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md). diff --git a/mp-sc-frontend/.changeset/config.json b/mp-sc-frontend/.changeset/config.json new file mode 100644 index 0000000..0d937a9 --- /dev/null +++ b/mp-sc-frontend/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "base", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/mp-sc-frontend/.commitlintrc.cjs b/mp-sc-frontend/.commitlintrc.cjs new file mode 100644 index 0000000..98ee7df --- /dev/null +++ b/mp-sc-frontend/.commitlintrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], +} diff --git a/mp-sc-frontend/.cursor/rules/api-http-patterns.mdc b/mp-sc-frontend/.cursor/rules/api-http-patterns.mdc new file mode 100644 index 0000000..79026c3 --- /dev/null +++ b/mp-sc-frontend/.cursor/rules/api-http-patterns.mdc @@ -0,0 +1,51 @@ +# API 和 HTTP 请求规范 + +## HTTP 请求封装 +- 可以使用 `简单http` 或者 `alova` 或者 `@tanstack/vue-query` 进行请求管理 +- HTTP 配置在 [src/http/](mdc:src/http/) 目录下 +- `简单http` - [src/http/http.ts](mdc:src/http/http.ts) +- `alova` - [src/http/alova.ts](mdc:src/http/alova.ts) +- `vue-query` - [src/http/vue-query.ts](mdc:src/http/vue-query.ts) +- 请求拦截器在 [src/http/interceptor.ts](mdc:src/http/interceptor.ts) +- 支持请求重试、缓存、错误处理 + +## API 接口规范 +- API 接口定义在 [src/api/](mdc:src/api/) 目录下 +- 按功能模块组织 API 文件 +- 使用 TypeScript 定义请求和响应类型 +- 支持 `简单http`、`alova` 和 `vue-query` 三种请求方式 + + +## 示例代码结构 +```typescript +// API 接口定义 +export interface LoginParams { + username: string + password: string +} + +export interface LoginResponse { + token: string + userInfo: UserInfo +} + +// alova 方式 +export const login = (params: LoginParams) => + http.Post('/api/login', params) + +// vue-query 方式 +export const useLogin = () => { + return useMutation({ + mutationFn: (params: LoginParams) => + http.post('/api/login', params) + }) +} +``` + +## 错误处理 +- 统一错误处理在拦截器中配置 +- 支持网络错误、业务错误、认证错误等 +- 自动处理 token 过期和刷新 +--- +globs: src/api/*.ts,src/http/*.ts +--- diff --git a/mp-sc-frontend/.cursor/rules/development-workflow.mdc b/mp-sc-frontend/.cursor/rules/development-workflow.mdc new file mode 100644 index 0000000..4da3f43 --- /dev/null +++ b/mp-sc-frontend/.cursor/rules/development-workflow.mdc @@ -0,0 +1,43 @@ +# 开发工作流程 + +## 项目启动 +1. 安装依赖:`pnpm install` +2. 开发环境: + - H5: `pnpm dev` 或 `pnpm dev:h5` + - 微信小程序: `pnpm dev:mp` + - 支付宝小程序: `pnpm dev:mp-alipay` + - APP: `pnpm dev:app` + +## 代码规范 +- 使用 ESLint 进行代码检查:`pnpm lint` +- 自动修复代码格式:`pnpm lint:fix` +- 使用 eslint 格式化代码 +- 遵循 TypeScript 严格模式 + +## 构建和部署 +- H5 构建:`pnpm build:h5` +- 微信小程序构建:`pnpm build:mp` +- 支付宝小程序构建:`pnpm build:mp-alipay` +- APP 构建:`pnpm build:app` +- 类型检查:`pnpm type-check` + +## 开发工具 +- 推荐使用 VSCode 编辑器 +- 安装 Vue 和 TypeScript 相关插件 +- 使用 uni-app 开发者工具调试小程序 +- 使用 HBuilderX 调试 APP + +## 调试技巧 +- 使用 console.log 和 uni.showToast 调试 +- 利用 Vue DevTools 调试组件状态 +- 使用网络面板调试 API 请求 +- 平台差异测试和兼容性检查 + +## 性能优化 +- 使用懒加载和代码分割 +- 优化图片和静态资源 +- 减少不必要的重渲染 +- 合理使用缓存策略 +--- +description: 开发工作流程和最佳实践指南 +--- diff --git a/mp-sc-frontend/.cursor/rules/project-overview.mdc b/mp-sc-frontend/.cursor/rules/project-overview.mdc new file mode 100644 index 0000000..f0d613e --- /dev/null +++ b/mp-sc-frontend/.cursor/rules/project-overview.mdc @@ -0,0 +1,36 @@ +--- +alwaysApply: true +--- +# unibest 项目概览 + +这是一个基于 uniapp + Vue3 + TypeScript + Vite5 + UnoCSS 的跨平台开发框架。 + +## 项目特点 +- 支持 H5、小程序、APP 多平台开发 +- 使用最新的前端技术栈 +- 内置约定式路由、layout布局、请求封装、登录拦截、自定义tabbar等功能 +- 无需依赖 HBuilderX,支持命令行开发 + +## 核心配置文件 +- [package.json](mdc:package.json) - 项目依赖和脚本配置 +- [vite.config.ts](mdc:vite.config.ts) - Vite 构建配置 +- [pages.config.ts](mdc:pages.config.ts) - 页面路由配置 +- [manifest.config.ts](mdc:manifest.config.ts) - 应用清单配置 +- [uno.config.ts](mdc:uno.config.ts) - UnoCSS 配置 + +## 主要目录结构 +- `src/pages/` - 页面文件 +- `src/components/` - 组件文件 +- `src/layouts/` - 布局文件 +- `src/api/` - API 接口 +- `src/http/` - HTTP 请求封装 +- `src/store/` - 状态管理 +- `src/tabbar/` - 底部导航栏 +- `src/App.ku.vue` - 全局根组件(类似 App.vue 里面的 template作用) + +## 开发命令 +- `pnpm dev` - 开发 H5 版本 +- `pnpm dev:mp` - 开发微信小程序 +- `pnpm dev:mp-alipay` - 开发支付宝小程序(含钉钉) +- `pnpm dev:app` - 开发 APP 版本 +- `pnpm build` - 构建生产版本 diff --git a/mp-sc-frontend/.cursor/rules/styling-css-patterns.mdc b/mp-sc-frontend/.cursor/rules/styling-css-patterns.mdc new file mode 100644 index 0000000..25f14f2 --- /dev/null +++ b/mp-sc-frontend/.cursor/rules/styling-css-patterns.mdc @@ -0,0 +1,54 @@ +# 样式和 CSS 开发规范 + +## UnoCSS 原子化 CSS +- 项目使用 UnoCSS 作为原子化 CSS 框架 +- 配置在 [uno.config.ts](mdc:uno.config.ts) +- 支持预设和自定义规则 +- 优先使用原子化类名,减少自定义 CSS + +## SCSS 规范 +- 使用 SCSS 预处理器 +- 样式文件使用 `lang="scss"` 和 `scoped` 属性 +- 遵循 BEM 命名规范 +- 使用变量和混入提高复用性 + +## 样式组织 +- 全局样式在 [src/style/](mdc:src/style/) 目录下 +- 组件样式使用 scoped 作用域 +- 图标字体在 [src/style/iconfont.css](mdc:src/style/iconfont.css) +- 主题变量在 [src/uni_modules/uni-scss/](mdc:src/uni_modules/uni-scss/) 目录下 + +## 示例代码结构 +```vue + + + + +## 响应式设计 +- 使用 rpx 单位适配不同屏幕 +- 支持横屏和竖屏布局 +- 使用 flexbox 和 grid 布局 +- 考虑不同平台的样式差异 +--- +globs: *.vue,*.scss,*.css +--- diff --git a/mp-sc-frontend/.cursor/rules/uni-app-patterns.mdc b/mp-sc-frontend/.cursor/rules/uni-app-patterns.mdc new file mode 100644 index 0000000..e10403e --- /dev/null +++ b/mp-sc-frontend/.cursor/rules/uni-app-patterns.mdc @@ -0,0 +1,63 @@ +# uni-app 开发规范 + +## 页面开发 +- 页面文件放在 [src/pages/](mdc:src/pages/) 目录下 +- 使用约定式路由,文件名即路由路径 +- 页面配置在仅需要在 宏`definePage` 中配置标题等内容即可,会自动生成到 `pages.json` 中 +- definePage的顺序在最上面 + +## 组件开发 +- 组件文件放在 [src/components/](mdc:src/components/) 或者 [src/pages/xx/components/](mdc:src/pages/xx/components/) 目录下 +- 使用 uni-app 内置组件和第三方组件库 +- 支持 wot-ui\uview-pro\uv-ui\sard-ui\uview-plus 等多种第三方组件库 和 z-paging 组件 +- 自定义组件遵循 uni-app 组件规范 + +## 平台适配 +- 使用条件编译处理平台差异 +- 支持 H5、小程序、APP 多平台 +- 注意各平台的 API 差异 +- 使用 uni.xxx API 替代原生 API + +## 示例代码结构 +```vue + + + +``` + +## 生命周期 +- 使用 uni-app 页面生命周期 +- onLoad、onShow、onReady、onHide、onUnload +- 组件生命周期遵循 Vue3 规范 +- 注意页面栈和导航管理 +--- +globs: src/pages/*.vue,src/components/*.vue +--- diff --git a/mp-sc-frontend/.cursor/rules/vue-typescript-patterns.mdc b/mp-sc-frontend/.cursor/rules/vue-typescript-patterns.mdc new file mode 100644 index 0000000..d81cc8f --- /dev/null +++ b/mp-sc-frontend/.cursor/rules/vue-typescript-patterns.mdc @@ -0,0 +1,53 @@ +# Vue3 + TypeScript 开发规范 + +## Vue 组件规范 +- 使用 Composition API 和 ` + + + + +--- +globs: *.vue,*.ts,*.tsx +--- diff --git a/mp-sc-frontend/.editorconfig b/mp-sc-frontend/.editorconfig new file mode 100644 index 0000000..7f09864 --- /dev/null +++ b/mp-sc-frontend/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] # 表示所有文件适用 +charset = utf-8 # 设置文件字符集为 utf-8 +indent_style = space # 缩进风格(tab | space) +indent_size = 2 # 缩进大小 +end_of_line = lf # 控制换行类型(lf | cr | crlf) +trim_trailing_whitespace = true # 去除行首的任意空白字符 +insert_final_newline = true # 始终在文件末尾插入一个新行 + +[*.md] # 表示仅 md 文件适用以下规则 +max_line_length = off # 关闭最大行长度限制 +trim_trailing_whitespace = false # 关闭末尾空格修剪 diff --git a/mp-sc-frontend/.gitignore b/mp-sc-frontend/.gitignore new file mode 100644 index 0000000..bac47b4 --- /dev/null +++ b/mp-sc-frontend/.gitignore @@ -0,0 +1,50 @@ +# 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? +.hbuilderx + +.stylelintcache +.eslintcache + +docs/.vitepress/dist +docs/.vitepress/cache + +src/types +# 单独把这个文件排除掉,用以解决部分电脑生成的 auto-import.d.ts 的API不完整导致类型提示报错问题 +!src/types/auto-import.d.ts +src/manifest.json +src/pages.json + +vite.config.ts.* + +# 2025-10-15 by 菲鸽: lock 文件还是需要加入版本管理,今天又遇到版本不一致导致无法运行的问题了。 +# pnpm-lock.yaml +# package-lock.json + +# TIPS:如果某些文件已经加入了版本管理,现在重新加入 .gitignore 是不生效的,需要执行下面的操作 +# `git rm -r --cached .` 然后提交 commit 即可。 + +# git rm -r --cached file1 file2 ## 针对某些文件 +# git rm -r --cached dir1 dir2 ## 针对某些文件夹 +# git rm -r --cached . ## 针对所有文件 + +# 更新 uni-app 官方版本 +# npx @dcloudio/uvm@latest diff --git a/mp-sc-frontend/.npmrc b/mp-sc-frontend/.npmrc new file mode 100644 index 0000000..f47ca59 --- /dev/null +++ b/mp-sc-frontend/.npmrc @@ -0,0 +1,9 @@ +# registry = https://registry.npmjs.org +registry = https://registry.npmmirror.com + +strict-peer-dependencies=false +auto-install-peers=true +shamefully-hoist=true +ignore-workspace-root-check=true +install-workspace-root=true +node-options=--max-old-space-size=8192 diff --git a/mp-sc-frontend/.trae/rules/project_rules.md b/mp-sc-frontend/.trae/rules/project_rules.md new file mode 100644 index 0000000..9436a74 --- /dev/null +++ b/mp-sc-frontend/.trae/rules/project_rules.md @@ -0,0 +1,123 @@ +# unibest 项目概览 + +这是一个基于 uniapp + Vue3 + TypeScript + Vite5 + UnoCSS 的跨平台开发框架。 + +## 项目特点 +- 支持 H5、小程序、APP 多平台开发 +- 使用最新的前端技术栈 +- 内置约定式路由、layout布局、请求封装等功能 +- 无需依赖 HBuilderX,支持命令行开发 + +## 核心配置文件 +- [package.json](mdc:package.json) - 项目依赖和脚本配置 +- [vite.config.ts](mdc:vite.config.ts) - Vite 构建配置 +- [pages.config.ts](mdc:pages.config.ts) - 页面路由配置 +- [manifest.config.ts](mdc:manifest.config.ts) - 应用清单配置 +- [uno.config.ts](mdc:uno.config.ts) - UnoCSS 配置 + +## 主要目录结构 +- `src/pages/` - 页面文件 +- `src/components/` - 组件文件 +- `src/layouts/` - 布局文件 +- `src/api/` - API 接口 +- `src/http/` - HTTP 请求封装 +- `src/store/` - 状态管理 +- `src/tabbar/` - 底部导航栏 +- `src/App.ku.vue` - 全局根组件(类似 App.vue 里面的 template作用) + +## 开发命令 +- `pnpm dev` - 开发 H5 版本 +- `pnpm dev:mp` - 开发微信小程序 +- `pnpm dev:mp-alipay` - 开发支付宝小程序(含钉钉) +- `pnpm dev:app` - 开发 APP 版本 +- `pnpm build` - 构建生产版本 + +## Vue 组件规范 +- 使用 Composition API 和 ` + + +``` + +## 生命周期 +- 使用 uni-app 页面生命周期 +- onLoad、onShow、onReady、onHide、onUnload +- 组件生命周期遵循 Vue3 规范 +- 注意页面栈和导航管理 \ No newline at end of file diff --git a/mp-sc-frontend/.vscode/extensions.json b/mp-sc-frontend/.vscode/extensions.json new file mode 100644 index 0000000..883b74d --- /dev/null +++ b/mp-sc-frontend/.vscode/extensions.json @@ -0,0 +1,15 @@ +{ + "recommendations": [ + "vue.volar", + "dbaeumer.vscode-eslint", + "antfu.unocss", + "antfu.iconify", + "evils.uniapp-vscode", + "uni-helper.uni-helper-vscode", + "uni-helper.uni-app-schemas-vscode", + "uni-helper.uni-highlight-vscode", + "uni-helper.uni-ui-snippets-vscode", + "uni-helper.uni-app-snippets-vscode", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/mp-sc-frontend/.vscode/settings.json b/mp-sc-frontend/.vscode/settings.json new file mode 100644 index 0000000..a5c0481 --- /dev/null +++ b/mp-sc-frontend/.vscode/settings.json @@ -0,0 +1,102 @@ +{ + // 配置语言的文件关联 + "files.associations": { + "pages.json": "jsonc", // pages.json 可以写注释 + "manifest.json": "jsonc" // manifest.json 可以写注释 + }, + + "stylelint.enable": false, // 禁用 stylelint + "css.validate": false, // 禁用 CSS 内置验证 + "scss.validate": false, // 禁用 SCSS 内置验证 + "less.validate": false, // 禁用 LESS 内置验证 + + // 新版本 VsCode 中这个配置已失效 + "typescript.tsdk": "node_modules/typescript/lib", + + // 配置新版本 VsCode 工作区的 TypeScript 的版本 + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.expand": false, + "explorer.fileNesting.patterns": { + "README.md": "index.html,favicon.ico,robots.txt,CHANGELOG.md", + "docker.md": "Dockerfile,docker*.md,nginx*,.dockerignore", + "pages.config.ts": "manifest.config.ts,openapi-ts-request.config.ts", + "package.json": "tsconfig.json,pnpm-lock.yaml,pnpm-workspace.yaml,LICENSE,.gitattributes,.gitignore,.gitpod.yml,CNAME,.npmrc,.browserslistrc", + "eslint.config.mjs": ".commitlintrc.*,.prettier*,.editorconfig,.commitlint.cjs,.eslint*" + }, + + // Disable the default formatter, use eslint instead + "prettier.enable": false, + "editor.formatOnSave": false, + + // Auto fix + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" + }, + + // Silent the stylistic rules in you IDE, but still auto fix them + "eslint.rules.customizations": [ + { "rule": "style/*", "severity": "off", "fixable": true }, + { "rule": "format/*", "severity": "off", "fixable": true }, + { "rule": "*-indent", "severity": "off", "fixable": true }, + { "rule": "*-spacing", "severity": "off", "fixable": true }, + { "rule": "*-spaces", "severity": "off", "fixable": true }, + { "rule": "*-order", "severity": "off", "fixable": true }, + { "rule": "*-dangle", "severity": "off", "fixable": true }, + { "rule": "*-newline", "severity": "off", "fixable": true }, + { "rule": "*quotes", "severity": "off", "fixable": true }, + { "rule": "*semi", "severity": "off", "fixable": true } + ], + + // Enable eslint for all supported languages + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "jsonc", + "yaml", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ], + "cSpell.words": [ + "alova", + "Aplipay", + "attributify", + "chooseavatar", + "climblee", + "commitlint", + "dcloudio", + "iconfont", + "oxlint", + "qrcode", + "refresherrefresh", + "scrolltolower", + "tabbar", + "Toutiao", + "uniapp", + "unibest", + "unocss", + "uview", + "uvui", + "Wechat", + "WechatMiniprogram", + "Weixin" + ] +} diff --git a/mp-sc-frontend/.vscode/vue3.code-snippets b/mp-sc-frontend/.vscode/vue3.code-snippets new file mode 100644 index 0000000..9277498 --- /dev/null +++ b/mp-sc-frontend/.vscode/vue3.code-snippets @@ -0,0 +1,80 @@ +{ + // Place your unibest 工作区 snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and + // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope + // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is + // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: + // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. + // Placeholders with the same ids are connected. + // Example: + // "Print to console": { + // "scope": "javascript,typescript", + // "prefix": "log", + // "body": [ + // "console.log('$1');", + // "$2" + // ], + // "description": "Log output to console" + // } + "Print unibest Vue3 SFC": { + "scope": "vue", + "prefix": "v3", + "body": [ + "\n", + "\n", + "\n", + ], + }, + "Print unibest style": { + "scope": "vue", + "prefix": "st", + "body": [ + "\n" + ], + }, + "Print unibest script with definePage": { + "scope": "vue", + "prefix": "sc", + "body": [ + "\n" + ], + }, + "Print unibest template": { + "scope": "vue", + "prefix": "te", + "body": [ + "\n" + ], + }, +} \ No newline at end of file diff --git a/mp-sc-frontend/LICENSE b/mp-sc-frontend/LICENSE new file mode 100644 index 0000000..9e91d10 --- /dev/null +++ b/mp-sc-frontend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 菲鸽 + +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/mp-sc-frontend/README.md b/mp-sc-frontend/README.md new file mode 100644 index 0000000..7752e70 --- /dev/null +++ b/mp-sc-frontend/README.md @@ -0,0 +1,98 @@ +

+ + + +

+ +

+ unibest - 最好的 uniapp 开发框架 +

+ +
+旧仓库 codercup 进不去了,star 也拿不回来,这里也展示一下那个地址的 star. + +[![GitHub Repo stars](https://img.shields.io/github/stars/codercup/unibest?style=flat&logo=github)](https://github.com/codercup/unibest) +[![GitHub forks](https://img.shields.io/github/forks/codercup/unibest?style=flat&logo=github)](https://github.com/codercup/unibest) + +
+ +
+ +[![GitHub Repo stars](https://img.shields.io/github/stars/feige996/unibest?style=flat&logo=github)](https://github.com/feige996/unibest) +[![GitHub forks](https://img.shields.io/github/forks/feige996/unibest?style=flat&logo=github)](https://github.com/feige996/unibest) +[![star](https://gitee.com/feige996/unibest/badge/star.svg?theme=dark)](https://gitee.com/feige996/unibest/stargazers) +[![fork](https://gitee.com/feige996/unibest/badge/fork.svg?theme=dark)](https://gitee.com/feige996/unibest/members) +![node version](https://img.shields.io/badge/node-%3E%3D18-green) +![pnpm version](https://img.shields.io/badge/pnpm-%3E%3D7.30-green) +![GitHub package.json version (subfolder of monorepo)](https://img.shields.io/github/package-json/v/feige996/unibest) +![GitHub License](https://img.shields.io/github/license/feige996/unibest) + +
+ +`unibest` —— 最好的 `uniapp` 开发模板,由 `uniapp` + `Vue3` + `Ts` + `Vite5` + `UnoCss` + `wot-ui` + `z-paging` 构成,使用了最新的前端技术栈,无需依靠 `HBuilderX`,通过命令行方式运行 `web`、`小程序` 和 `App`(编辑器推荐 `VSCode`,可选 `webstorm`)。 + +`unibest` 内置了 `约定式路由`、`layout布局`、`请求封装`、`请求拦截`、`登录拦截`、`UnoCSS`、`i18n多语言` 等基础功能,提供了 `代码提示`、`自动格式化`、`统一配置`、`代码片段` 等辅助功能,让你编写 `uniapp` 拥有 `best` 体验 ( `unibest 的由来`)。 + +![](https://raw.githubusercontent.com/andreasbm/readme/master/screenshots/lines/rainbow.png) + +

+ 📖 文档地址(new) + | + 📱 DEMO 地址 +

+ +--- + +注意旧的地址 [codercup](https://github.com/codercup/unibest) 我进不去了,使用新的 [feige996](https://github.com/feige996/unibest)。PR和 issue 也请使用新地址,否则无法合并。 + +## 平台兼容性 + +| H5 | IOS | 安卓 | 微信小程序 | 字节小程序 | 快手小程序 | 支付宝小程序 | 钉钉小程序 | 百度小程序 | +| --- | --- | ---- | ---------- | ---------- | ---------- | ------------ | ---------- | ---------- | +| √ | √ | √ | √ | √ | √ | √ | √ | √ | + +注意每种 `UI框架` 支持的平台有所不同,详情请看各 `UI框架` 的官网,也可以看 `unibest` 文档。 + +## ⚙️ 环境 + +- node>=18 +- pnpm>=7.30 +- Vue Official>=2.1.10 +- TypeScript>=5.0 + +## 新版分支 +- main == base +- base --> base-i18n +- base-login --> base-login-i18n + +## 📂 快速开始 + +执行 `pnpm create unibest` 创建项目 +执行 `pnpm i` 安装依赖 +执行 `pnpm dev` 运行 `H5` +执行 `pnpm dev:mp` 运行 `微信小程序` + +## 📦 运行(支持热更新) + +- web平台: `pnpm dev:h5`, 然后打开 [http://localhost:9000/](http://localhost:9000/)。 +- weixin平台:`pnpm dev:mp` 然后打开微信开发者工具,导入本地文件夹,选择本项目的`dist/dev/mp-weixin` 文件。 +- APP平台:`pnpm dev:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/dev/app` 文件夹,选择运行到模拟器(开发时优先使用),或者运行的安卓/ios基座。(如果是 `安卓` 和 `鸿蒙` 平台,则不用这个方式,可以把整个unibest项目导入到hbx,通过hbx的菜单来运行到对应的平台。) + +## 🔗 发布 + +- web平台: `pnpm build:h5`,打包后的文件在 `dist/build/h5`,可以放到web服务器,如nginx运行。如果最终不是放在根目录,可以在 `manifest.config.ts` 文件的 `h5.router.base` 属性进行修改。 +- weixin平台:`pnpm build:mp`, 打包后的文件在 `dist/build/mp-weixin`,然后通过微信开发者工具导入,并点击右上角的“上传”按钮进行上传。 +- APP平台:`pnpm build:app`, 然后打开 `HBuilderX`,导入刚刚生成的`dist/build/app` 文件夹,选择发行 - APP云打包。(如果是 `安卓` 和 `鸿蒙` 平台,则不用这个方式,可以把整个unibest项目导入到hbx,通过hbx的菜单来发行到对应的平台。) + +## 📄 License + +[MIT](https://opensource.org/license/mit/) + +Copyright (c) 2025 菲鸽 + +## 捐赠 + +

+special sponsor appwrite +special sponsor appwrite +

diff --git a/mp-sc-frontend/env/.env b/mp-sc-frontend/env/.env new file mode 100644 index 0000000..a931d21 --- /dev/null +++ b/mp-sc-frontend/env/.env @@ -0,0 +1,42 @@ +VITE_APP_TITLE = '四川凌空天行' +VITE_APP_PORT = 9000 + +VITE_UNI_APPID = '__UNI__D1E5001' +VITE_WX_APPID = 'wx4c5f6fdb5bfa3b3a' + +# 微信开发者工具 CLI 路径,仅当默认安装路径不正确时配置(就是当 pnpm dev:mp 无法自动打开微信开发者工具时,才需要配置,通常是你更改了默认的安装位置导致的,一般出现在windows系统) +# macOS 示例: +# WECHAT_DEVTOOLS_CLI_PATH = '/Applications/wechatwebdevtools.app/Contents/MacOS/cli' +# Windows 示例: +# WECHAT_DEVTOOLS_CLI_PATH = 'C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat' + +# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base +# https://uniapp.dcloud.net.cn/collocation/manifest.html#h5-router +# 比如你要部署到 https://unibest.tech/doc/ ,则配置为 /doc/ +VITE_APP_PUBLIC_BASE=/ + +# 默认后台请求地址 +# 不同命令会按 mode 叠加读取 .env.development / .env.test / .env.production。 +# 微信小程序如果没有配置下面的专用地址,也会回退使用这个值。 +VITE_SERVER_BASEURL = 'http://localhost:28175' +# 备注:如果后台带统一前缀,则也要加到后面,eg: https://ukw0y1.laf.run/api + +# 微信小程序专用后台请求地址,按微信开发者工具 envVersion 区分。 +# 不配置时会回退使用 VITE_SERVER_BASEURL。 +# VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'https://dev.xxx.com' +# VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'https://trial.xxx.com' +# VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'https://prod.xxx.com' + +# h5是否需要配置代理 +VITE_APP_PROXY_ENABLE = false +# 下面的不用修改,只要不跟你后台的统一前缀冲突就行。如果修改了,记得修改 `nginx` 里面的配置 +VITE_APP_PROXY_PREFIX = '/fg-api' + +# 第二个请求地址 (目前alova中可以使用) +VITE_SERVER_BASEURL_SECONDARY = 'http://localhost:28175' + +# 认证模式,'single' | 'double' ==> 单token | 双token +VITE_AUTH_MODE = 'single' + +# 原生插件资源复制开关,启用后 App 构建会把根目录 nativeplugins 复制到 dist +VITE_COPY_NATIVE_RES_ENABLE = true diff --git a/mp-sc-frontend/env/.env.development b/mp-sc-frontend/env/.env.development new file mode 100644 index 0000000..bd3b38d --- /dev/null +++ b/mp-sc-frontend/env/.env.development @@ -0,0 +1,9 @@ +# 变量必须以 VITE_ 为前缀才能暴露给外部读取 +NODE_ENV = 'development' +# 是否去除console 和 debugger +VITE_DELETE_CONSOLE = false +# 是否开启sourcemap +VITE_SHOW_SOURCEMAP = false + +# development mode 后台请求地址 +# VITE_SERVER_BASEURL = 'https://dev.xxx.com' diff --git a/mp-sc-frontend/env/.env.production b/mp-sc-frontend/env/.env.production new file mode 100644 index 0000000..0d2ff4b --- /dev/null +++ b/mp-sc-frontend/env/.env.production @@ -0,0 +1,9 @@ +# 变量必须以 VITE_ 为前缀才能暴露给外部读取 +NODE_ENV = 'production' +# 是否去除console 和 debugger +VITE_DELETE_CONSOLE = true +# 是否开启sourcemap +VITE_SHOW_SOURCEMAP = false + +# production mode 后台请求地址 +# VITE_SERVER_BASEURL = 'https://prod.xxx.com' diff --git a/mp-sc-frontend/env/.env.test b/mp-sc-frontend/env/.env.test new file mode 100644 index 0000000..8290ad2 --- /dev/null +++ b/mp-sc-frontend/env/.env.test @@ -0,0 +1,9 @@ +# 变量必须以 VITE_ 为前缀才能暴露给外部读取 +NODE_ENV = 'development' +# 是否去除console 和 debugger +VITE_DELETE_CONSOLE = false +# 是否开启sourcemap +VITE_SHOW_SOURCEMAP = false + +# test mode 后台请求地址 +# VITE_SERVER_BASEURL = 'https://test.xxx.com' diff --git a/mp-sc-frontend/eslint.config.mjs b/mp-sc-frontend/eslint.config.mjs new file mode 100644 index 0000000..426985a --- /dev/null +++ b/mp-sc-frontend/eslint.config.mjs @@ -0,0 +1,60 @@ +import uniHelper from '@uni-helper/eslint-config' + +export default uniHelper({ + unocss: true, + vue: true, + markdown: false, + ignores: [ + // 忽略uni_modules目录 + '**/uni_modules/', + // 忽略原生插件目录 + '**/nativeplugins/', + 'dist', + // unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用 + 'auto-import.d.ts', + // vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore + 'uni-pages.d.ts', + // 插件生成的文件 + 'src/pages.json', + 'src/manifest.json', + // 忽略自动生成文件 + 'src/service/**', + ], + // https://eslint-config.antfu.me/rules + rules: { + 'no-useless-return': 'off', + 'no-console': 'off', + 'no-unused-vars': 'off', + 'vue/no-unused-refs': 'off', + 'unused-imports/no-unused-vars': 'off', + 'eslint-comments/no-unlimited-disable': 'off', + 'jsdoc/check-param-names': 'off', + 'jsdoc/require-returns-description': 'off', + 'ts/no-empty-object-type': 'off', + 'no-extend-native': 'off', + // uni 条件编译注释可能包裹 import,自动排序会破坏平台条件边界 + 'perfectionist/sort-imports': 'off', + 'vue/singleline-html-element-content-newline': [ + 'error', + { + externalIgnores: ['text'], + }, + ], + // vue SFC 调换顺序改这里 + 'vue/block-order': ['error', { + order: [['script', 'template'], 'style'], + }], + }, + formatters: { + /** + * Format CSS, LESS, SCSS files, also the ` diff --git a/mp-sc-frontend/src/api/appointment.ts b/mp-sc-frontend/src/api/appointment.ts new file mode 100644 index 0000000..2fa09ab --- /dev/null +++ b/mp-sc-frontend/src/api/appointment.ts @@ -0,0 +1,588 @@ +import type { + IAppointment, + IAppointmentDetail, + IApprovalDetail, + IApprovalInstance, + IApprovalRecord, + IApprovalTemplate, + IBanner, + IBannerAdmin, + INotice, + INoticeAdmin, + IEmployee, + IGoodsTemplate, + IInvitation, + ILoginRes, + IPageParams, + IPageResult, + IPermission, + IRoleApplication, + ISystemModule, + ISystemRole, + ITodayStats, + IUserInfo, + IUserAdmin, + IDepartment, + IVisitRecord, + IVisitorVO, + IEmployeeCertification, + IDepartmentItem, + IDepartmentTreeNode, +} from './types/appointment' +import { http } from '@/http/http' +import { getEnvBaseUrl } from '@/utils' + +// ==================== Banner ==================== + +/** 获取首页轮播图 */ +export function getBanners() { + return http.get('/v1/banners') +} + +/** 获取系统通知 */ +export function getNotices() { + return http.get('/v1/notices') +} + +// ==================== 认证 ==================== + +/** 微信登录 */ +export function loginByCode(code: string) { + return http.post('/v1/auth/login', { code }) +} + +/** 获取用户信息 */ +export function getUserInfo() { + return http.get('/v1/auth/userinfo') +} + +/** 更新用户信息 */ +export function updateUserInfo(data: Record) { + return http.put('/v1/auth/userinfo', data) +} + +/** 绑定手机号 */ +export function bindPhone(data: { code: string, encrypted_data: string, iv: string }) { + return http.post('/v1/auth/phone', data) +} + +// ==================== 访客预约 ==================== + +/** 搜索员工 */ +export function searchEmployees(keyword: string) { + return http.post('/v1/appointment/employees/search', { keyword }) +} + +/** 验证员工姓名+手机号 */ +export function verifyEmployee(name: string, phone: string) { + return http.post('/v1/appointment/employees/verify', { name, phone }) +} + +/** 获取当前用户历史访客列表 */ +export function getMyVisitors() { + return http.get('/v1/appointment/visitors/my') +} + +/** 保存为常用访客 */ +export function saveVisitorTemplate(data: { + name: string + phone: string + company?: string + gender?: number + id_type?: string + id_number?: string + license_plate?: string +}) { + return http.post('/v1/appointment/visitor-templates', data) +} + +/** 删除常用访客 */ +export function deleteVisitorTemplate(id: number) { + return http.delete(`/v1/appointment/visitor-templates/${id}`) +} + +/** 获取物品模板列表 */ +export function getGoodsTemplates() { + return http.get('/v1/appointment/goods-templates') +} + +/** 创建物品模板 */ +export function createGoodsTemplate(data: { name: string; quantity?: number; model?: string; remark?: string }) { + return http.post('/v1/appointment/goods-templates', data) +} + +/** 更新物品模板 */ +export function updateGoodsTemplate(id: number, data: Partial) { + return http.put(`/v1/appointment/goods-templates/${id}`, data) +} + +/** 删除物品模板 */ +export function deleteGoodsTemplate(id: number) { + return http.delete(`/v1/appointment/goods-templates/${id}`) +} + +// ==================== 车辆模板 ==================== + +/** 获取车辆模板列表 */ +export function getVehicleTemplates() { + return http.get('/v1/appointment/vehicles') +} + +/** 创建车辆模板 */ +export function createVehicleTemplate(data: { license_plate: string; brand?: string; color?: string }) { + return http.post('/v1/appointment/vehicles', data) +} + +/** 更新车辆模板 */ +export function updateVehicleTemplate(id: number, data: { license_plate: string; brand?: string; color?: string }) { + return http.put(`/v1/appointment/vehicles/${id}`, data) +} + +/** 删除车辆模板 */ +export function deleteVehicleTemplate(id: number) { + return http.delete(`/v1/appointment/vehicles/${id}`) +} + +/** 创建预约 */ +export function createAppointment(data: Record) { + return http.post('/v1/appointment/appointments', data) +} + +/** 我的预约列表 */ +export function getMyAppointments(params: IPageParams) { + return http.get>('/v1/appointment/appointments/my', params) +} + +/** 全部预约列表(员工专属,按申请时间降序) */ +export function getAllAppointments(params: IPageParams & { status?: number }) { + return http.get>('/v1/appointment/appointments/all', params) +} + +/** 预约详情 */ +export function getAppointmentDetail(id: number) { + return http.get('/v1/appointment/appointments/' + id) +} + +/** 审核预约 */ +export function auditAppointment(id: number, data: { status: number, comment?: string }) { + return http.put(`/v1/appointment/appointments/${id}/audit`, data) +} + +/** 获取预约的审批步骤 */ +export function getApprovalSteps(id: number) { + return http.get(`/v1/appointment/appointments/${id}/steps`) +} + +/** 取消预约 */ +export function cancelAppointment(id: number) { + return http.put(`/v1/appointment/appointments/${id}/cancel`) +} + +// ==================== 邀请 ==================== + +/** 生成邀请 */ +export function createInvitation(data: Record) { + return http.post('/v1/appointment/invitations', data) +} + +/** 公开邀请详情 */ +export function getInvitationDetail(code: string) { + return http.get(`/v1/public/invitations/${code}`) +} + +// ==================== 保安 ==================== + +/** 今日预约 */ +export function getTodayAppointments() { + return http.get<{ stats: ITodayStats, appointments: IAppointment[] }>('/v1/guard/today-appointments') +} + +/** 登记进场 */ +export function checkIn(data: { appointment_id: number, remark?: string }) { + return http.post('/v1/guard/check-in', data) +} + +/** 登记出场 */ +export function checkOut(data: { appointment_id: number, remark?: string }) { + return http.post('/v1/guard/check-out', data) +} + +/** 获取预约的进出场记录 */ +export function getVisitRecords(appointmentId: number) { + return http.get('/v1/guard/records', { appointment_id: appointmentId }) +} + +// ==================== 领导 ==================== + +/** 部门预约 */ +export function getLeaderAppointments(params: IPageParams) { + return http.get>('/v1/leader/appointments', params) +} + +// ==================== 管理 ==================== + +/** 角色列表 */ +export function getRoles() { + return http.get('/v1/admin/roles') +} + +/** 创建角色 */ +export function createRole(data: Partial) { + return http.post('/v1/admin/roles', data) +} + +/** 更新角色 */ +export function updateRole(id: number, data: Partial) { + return http.put(`/v1/admin/roles/${id}`, data) +} + +/** 删除角色 */ +export function deleteRole(id: number) { + return http.delete(`/v1/admin/roles/${id}`) +} + +/** 模块列表 */ +export function getModules() { + return http.get('/v1/admin/modules') +} + +/** 创建模块 */ +export function createModule(data: Partial) { + return http.post('/v1/admin/modules', data) +} + +/** 更新模块 */ +export function updateModule(id: number, data: Partial) { + return http.put(`/v1/admin/modules/${id}`, data) +} + +/** 删除模块 */ +export function deleteModule(id: number) { + return http.delete(`/v1/admin/modules/${id}`) +} + +/** 权限列表 */ +export function getPermissions(params?: { roleCode?: string, moduleCode?: string }) { + return http.get('/v1/admin/permissions', params) +} + +/** 添加权限 */ +export function createPermission(data: { roleCode: string, moduleCode: string, resource: string, action: string }) { + return http.post('/v1/admin/permissions', data) +} + +/** 删除权限 */ +export function deletePermission(id: number) { + return http.delete(`/v1/admin/permissions/${id}`) +} + +/** 申请列表 */ +export function getApplications() { + return http.get('/v1/admin/applications') +} + +/** 审批申请 */ +export function auditApplication(id: number, data: { status: number }) { + return http.put(`/v1/admin/applications/${id}/audit`, data) +} + +// ==================== 角色申请 ==================== + +/** 提交角色申请 */ +export function submitRoleApply(data: { apply_role: number, reason: string }) { + return http.post('/v1/role/apply', data) +} + +/** 我的申请记录 */ +export function getMyApplications() { + return http.get('/v1/role/applications') +} + +// ==================== 上传(公开接口,无需权限) ==================== + +/** 上传图片到 MinIO */ +export function uploadImageToMinio(filePath: string) { + return new Promise<{ url: string }>((resolve, reject) => { + uni.uploadFile({ + url: '/v1/upload/minio', + filePath, + name: 'file', + success: (res) => { + try { + const data = JSON.parse(res.data) + if (data.code === 200) { + resolve(data.data) + } else { + reject(new Error(data.msg || '上传失败')) + } + } catch { + reject(new Error('解析响应失败')) + } + }, + fail: (err) => { + reject(err) + }, + }) + }) +} + +/** 上传图片到微信服务器 */ +export function uploadImage(filePath: string) { + return new Promise<{ url: string }>((resolve, reject) => { + uni.uploadFile({ + url: `${getEnvBaseUrl()}/v1/upload/image`, + filePath, + name: 'file', + success: (res) => { + try { + const data = JSON.parse(res.data) + if (data.code === 200) { + resolve(data.data) + } else { + reject(new Error(data.msg || '上传失败')) + } + } catch { + reject(new Error('解析响应失败')) + } + }, + fail: (err) => { + reject(err) + }, + }) + }) +} + +/** 通过图片链接上传到微信服务器 */ +export function uploadImageByURL(url: string) { + return http.post<{ url: string }>('/v1/upload/image-by-url', { url }) +} + +/** 更新用户头像(接受微信图片 URL) */ +export function updateAvatar(userId: number, url: string) { + return http.put(`/v1/user/${userId}/avatar`, { url }) +} + +// ==================== 审批流程 ==================== + +/** 发起审批 */ +export function startApproval(data: { + template_code: string + business_type: string + business_id: number + variables?: Record +}) { + return http.post('/v1/approval/start', data) +} + +/** 审批通过 */ +export function approveRecord(data: { + record_id: number + comment?: string + variables?: Record +}) { + return http.post('/v1/approval/approve', data) +} + +/** 审批拒绝 */ +export function rejectRecord(data: { + record_id: number + comment?: string +}) { + return http.post('/v1/approval/reject', data) +} + +/** 获取待审批列表 */ +export function getPendingApprovals(params: { + business_type?: string + page?: number + page_size?: number +}) { + return http.get>('/v1/approval/pending', params) +} + +/** 获取我的审批列表(按状态筛选) */ +export function getMyApprovals(params: { + status?: number + page?: number + page_size?: number +}) { + return http.get>('/v1/approval/my', params) +} + +/** 获取审批实例详情 */ +export function getApprovalDetail(instanceId: number) { + return http.get(`/v1/approval/instance/${instanceId}`) +} + +/** 获取审批模板列表 */ +export function getApprovalTemplates() { + return http.get('/v1/approval/templates') +} + +// ==================== 员工认证 ==================== + +/** 提交员工认证申请 */ +export function submitCertification(data: { + real_name: string + phone: string + role_type: string + snapshot: string[] + position?: string + department_id?: number | null +}) { + return http.post('/v1/certification/submit', data) +} + +/** 获取我的认证申请 */ +export function getMyCertification() { + return http.get('/v1/certification/my') +} + +/** 撤回认证申请 */ +export function withdrawCertification(id: number) { + return http.put(`/v1/certification/${id}/withdraw`) +} + +// ==================== 管理员:员工认证审核 ==================== + +/** 认证申请列表 */ +export function adminGetCertifications(params?: IPageParams & { status?: number }) { + return http.get>('/v1/admin/certifications', params) +} + +/** 审核认证申请 */ +export function adminAuditCertification(id: number, data: { + status: number + department_id?: number + comment?: string +}) { + return http.put(`/v1/admin/certifications/${id}/audit`, data) +} + +// ==================== 管理员:部门管理 ==================== + +/** 获取部门树 */ +export function adminGetDepartmentTree() { + return http.get('/v1/admin/department-tree') +} + +/** 获取所有部门(扁平列表) */ +export function adminGetAllDepartments() { + return http.get('/v1/admin/departments-all') +} + +/** 创建部门 */ +export function adminCreateDepartment(data: { + name: string + parent_id?: number + leader_name?: string + leader_phone?: string + sort?: number +}) { + return http.post('/v1/admin/departments', data) +} + +/** 更新部门 */ +export function adminUpdateDepartment(id: number, data: Record) { + return http.put(`/v1/admin/departments/${id}`, data) +} + +/** 删除部门 */ +export function adminDeleteDepartment(id: number) { + return http.delete(`/v1/admin/departments/${id}`) +} + +// ==================== 审批配置 ==================== + +interface IApprovalConfig { + id: number + step: number + approver_id: number + approver?: { id: number; name: string; phone: string } + description: string + created_at: string +} + +interface IEmployeeSimple { + id: number + name: string + phone: string + department_name: string + position: string +} + +/** 获取审批配置列表 */ +export function adminGetApprovalConfigs() { + return http.get('/v1/admin/approval-config') +} + +/** 创建审批配置 */ +export function adminCreateApprovalConfig(data: { + step: number + approver_id: number + description?: string +}) { + return http.post('/v1/admin/approval-config', data) +} + +/** 更新审批配置 */ +export function adminUpdateApprovalConfig(id: number, data: { + step?: number + approver_id?: number + description?: string +}) { + return http.put(`/v1/admin/approval-config/${id}`, data) +} + +/** 删除审批配置 */ +export function adminDeleteApprovalConfig(id: number) { + return http.delete(`/v1/admin/approval-config/${id}`) +} + +/** 获取所有员工(用于选择审批人) */ +export function adminGetAllEmployees() { + return http.get<{list: IEmployeeSimple[]}>('/v1/admin/employees', { page: 1, page_size: 1000 }) +} + +/** 获取知会通知配置列表 */ +export function adminGetApprovalNotifies() { + return http.get('/v1/admin/approval-notify') +} + +/** 创建知会通知配置 */ +export function adminCreateApprovalNotify(data: { employee_id: number }) { + return http.post('/v1/admin/approval-notify', data) +} + +/** 删除知会通知配置 */ +export function adminDeleteApprovalNotify(id: number) { + return http.delete(`/v1/admin/approval-notify/${id}`) +} + +/** 为用户添加角色 */ +export function adminAddUserRole(userId: number, roleId: number) { + return http.post(`/v1/admin/users/${userId}/roles`, { role_id: roleId }) +} + +/** 为用户移除角色 */ +export function adminRemoveUserRole(userId: number, roleId: number) { + return http.delete(`/v1/admin/users/${userId}/roles/${roleId}`) +} + +export interface IApprovalNotifyInfo { + id: number + employee_id: number + employee?: { id: number; name: string; phone: string; department_name?: string } +} + +// ==================== 公共数据 ==================== + +/** 获取所有启用的来访目的 */ +export function getVisitTypes() { + return http.get('/v1/public/visit-types') +} + +/** 获取所有启用的到访区域 */ +export function getVisitorAreas() { + return http.get('/v1/public/visitor-areas') +} diff --git a/mp-sc-frontend/src/api/foo-alova.ts b/mp-sc-frontend/src/api/foo-alova.ts new file mode 100644 index 0000000..de35095 --- /dev/null +++ b/mp-sc-frontend/src/api/foo-alova.ts @@ -0,0 +1,17 @@ +import { API_DOMAINS, http } from '@/http/alova' + +export interface IFoo { + id: number + name: string +} + +export function foo() { + return http.Get('/foo', { + params: { + name: '菲鸽', + page: 1, + pageSize: 10, + }, + meta: { domain: API_DOMAINS.SECONDARY }, // 用于切换请求地址 + }) +} diff --git a/mp-sc-frontend/src/api/foo.ts b/mp-sc-frontend/src/api/foo.ts new file mode 100644 index 0000000..a500002 --- /dev/null +++ b/mp-sc-frontend/src/api/foo.ts @@ -0,0 +1,43 @@ +import { http } from '@/http/http' + +export interface IFoo { + id: number + name: string +} + +export function foo() { + return http.Get('/foo', { + params: { + name: '菲鸽', + page: 1, + pageSize: 10, + }, + }) +} + +export interface IFooItem { + id: string + name: string +} + +/** GET 请求 */ +export async function getFooAPI(name: string) { + return await http.get('/foo', { name }) +} +/** GET 请求;支持 传递 header 的范例 */ +export function getFooAPI2(name: string) { + return http.get('/foo', { name }, { 'Content-Type-100': '100' }) +} + +/** POST 请求 */ +export function postFooAPI(name: string) { + return http.post('/foo', { name }) +} +/** POST 请求;需要传递 query 参数的范例;微信小程序经常有同时需要query参数和body参数的场景 */ +export function postFooAPI2(name: string) { + return http.post('/foo', { name }, { a: 1, b: 2 }) +} +/** POST 请求;支持 传递 header 的范例 */ +export function postFooAPI3(name: string) { + return http.post('/foo', { name }, { a: 1, b: 2 }, { 'Content-Type-100': '100' }) +} diff --git a/mp-sc-frontend/src/api/login.ts b/mp-sc-frontend/src/api/login.ts new file mode 100644 index 0000000..7691c9b --- /dev/null +++ b/mp-sc-frontend/src/api/login.ts @@ -0,0 +1,85 @@ +import type { IAuthLoginRes, ICaptcha, IDoubleTokenRes, IUpdateInfo, IUpdatePassword, IUserInfoRes } from './types/login' +import { http } from '@/http/http' + +/** + * 登录表单 + */ +export interface ILoginForm { + username: string + password: string +} + +/** + * 获取验证码 + * @returns ICaptcha 验证码 + */ +export function getCode() { + return http.get('/user/getCode') +} + +/** + * 用户登录 + * @param loginForm 登录表单 + */ +export function login(loginForm: ILoginForm) { + return http.post('/auth/login', loginForm) +} + +/** + * 刷新token + * @param refreshToken 刷新token + */ +export function refreshToken(refreshToken: string) { + return http.post('/auth/refreshToken', { refreshToken }) +} + +/** + * 获取用户信息 + */ +export function getUserInfo() { + return http.get('/user/info') +} + +/** + * 退出登录 + */ +export function logout() { + return http.get('/auth/logout') +} + +/** + * 修改用户信息 + */ +export function updateInfo(data: IUpdateInfo) { + return http.post('/user/updateInfo', data) +} + +/** + * 修改用户密码 + */ +export function updateUserPassword(data: IUpdatePassword) { + return http.post('/user/updatePassword', data) +} + +/** + * 获取微信登录凭证 + * @returns Promise 包含微信登录凭证(code) + */ +export function getWxCode() { + return new Promise((resolve, reject) => { + uni.login({ + provider: 'weixin', + success: res => resolve(res), + fail: err => reject(new Error(err)), + }) + }) +} + +/** + * 微信登录 + * @param params 微信登录参数,包含code + * @returns Promise 包含登录结果 + */ +export function wxLogin(data: { code: string }) { + return http.post('/auth/wxLogin', data) +} diff --git a/mp-sc-frontend/src/api/types/appointment.ts b/mp-sc-frontend/src/api/types/appointment.ts new file mode 100644 index 0000000..ada53e3 --- /dev/null +++ b/mp-sc-frontend/src/api/types/appointment.ts @@ -0,0 +1,428 @@ +// 员工认证信息(用于 UserInfo 中的嵌套字段) +export interface ICertificationInfo { + id: number + real_name: string + phone: string + snapshot: string[] + position?: string + department_id?: number + department?: IDepartmentItem + status: number // 0 待审核 1 通过 2 拒绝 + approver_id?: number + comment: string +} + +// 用户信息(扩展字段,支持多角色) +export interface IUserInfo { + id: number + openid: string + nickname: string + real_name: string + phone: string + phone_verified: boolean + avatar_url: string + face_image_url: string + system_role_id: number + status: number + created_at: string + updated_at: string + role?: string + role_name?: string + role_codes?: string[] // 所有角色编码列表 + roles?: IRoleBrief[] // 所有角色简要信息 + is_formal_employee?: boolean + department_name?: string + position?: string + certification?: ICertificationInfo // 员工认证信息 +} + +// 角色简要信息 +export interface IRoleBrief { + id: number + code: string + name: string +} + +// 员工信息 +export interface IEmployee { + id: number + user_id: number + name: string + phone: string + department: string + is_leader: boolean + employee_type: string // employee / department_leader / hr_leader / boss + parent_id: number + status: number +} + +// 访客信息 +export interface IVisitor { + id: number + user_id: number + company: string + name: string + phone: string + gender: number + id_type: string + id_number: string + license_plate: string + face_verified: boolean + phone_verified: boolean +} + +// 预约区域 +export interface IAppointmentArea { + id?: number + appointment_id?: number + area: string +} + +// 携带物品 +export interface IGoods { + id?: number + appointment_id?: number + name: string + quantity: number + model: string + remark: string +} + +// 预约信息 +export interface IAppointment { + id: number + creator_user_id: number + creator?: IUserInfo + visit_user_id: number + visit_type: string + visit_start_time: string + visit_end_time: string + status: number // 0审核中 1通过 2拒绝 3取消 + remark: string + visit_purpose?: string + visitor_company?: string + qr_code_url: string + visitor_info: string // JSON: 所有访客信息 + creator_info: string // JSON: 创建人信息 {id, name} + employee_info: string // JSON: 被访人信息 {id, name, phone, department} + goods_info: string // JSON: 物品列表 + areas_info: string // JSON: 区域列表 + vehicle_info: string // JSON: 车辆信息 {license_plates: [...]} + created_at: string + updated_at: string +} + +// 预约详情响应(与列表响应格式不同,JSON 字段已解析为对象/数组) +export interface IAppointmentDetail { + id: number + creator_user_id: number + visit_user_id: number + visit_type: string + visit_start_time: string + visit_end_time: string + status: number + remark: string + comment?: string + visit_purpose?: string + visitor_company?: string + qr_code_url: string + visitors: { + name: string + phone: string + company: string + gender: number + id_type?: string + id_number?: string + }[] + creator_info: { id: number; name: string } + employee_info: { id: number; name: string; phone: string; department: string } + goods: { name: string; quantity: number; model: string; remark: string }[] + areas: string[] + vehicles: { brand?: string; plate: string; color?: string }[] + created_at: string + updated_at: string +} + +// 访问记录 +export interface IVisitRecord { + id: number + appointment_id: number + appointment?: IAppointment + guard_id: number + check_type: number // 1 进场 2 出场 + check_time: string + is_valid: boolean + remark: string +} + +// 邀请 +export interface IInvitation { + id: number + employee_id: number + employee?: IEmployee + invite_code: string + visitor_name: string + visitor_phone: string + visit_type: string + visit_area: string + valid_from: string + valid_until: string + max_use_count: number + used_count: number + is_active: boolean +} + +// 系统角色 +export interface ISystemRole { + id: number + code: string + name: string + description: string + is_system: boolean +} + +// 系统模块 +export interface ISystemModule { + id: number + code: string + name: string + description: string + status: number +} + +// 权限 +export interface IPermission { + id: number + role: string + module_code: string + resource: string + action: string +} + +// 角色申请 +export interface IRoleApplication { + id: number + user_id: number + user?: IUserInfo + apply_role: number + apply_role_info?: ISystemRole + reason: string + status: number + approver_id: number + created_at: string +} + +// 登录响应 +export interface ILoginRes { + token: string + user_info: IUserInfo +} + +// 分页参数 +export interface IPageParams { + page: number + page_size: number + [key: string]: any +} + +// 分页响应 +export interface IPageResult { + list: T[] + total: number + page: number + page_size: number +} + +// 今日预约统计 +export interface ITodayStats { + total: number + checked_in: number + checked_out: number +} + +// Banner 轮播图 +export interface IBanner { + image_url: string + jump_path: string + is_jump: boolean +} + +// Banner 管理(含 id、sort、is_valid) +export interface IBannerAdmin { + id: number + image_url: string + jump_path: string + is_jump: boolean + sort: number + is_valid: boolean +} + +// 系统通知 +export interface INotice { + id: number + title: string + content: string +} + +// 通知管理(含 sort、is_valid) +export interface INoticeAdmin { + id: number + title: string + content: string + sort: number + is_valid: boolean +} + +// 用户管理视图 +export interface IUserAdmin { + id: number + openid: string + nickname: string + real_name: string + phone: string + avatar_url: string + status: number + system_role_id: number + roles: IRoleBrief[] + is_super_admin: boolean + created_at: string +} + +// 部门视图 +export interface IDepartment { + name: string + employees: IEmployeeBrief[] +} + +export interface IEmployeeBrief { + id: number + user_id: number + name: string + phone: string + is_leader: boolean +} + +// ==================== 审批流程 ==================== + +// 审批模板 +export interface IApprovalTemplate { + id: number + name: string + code: string + description: string + is_active: boolean +} + +// 审批节点 +export interface IApprovalNode { + id: number + template_id: number + node_name: string + node_type: string + node_order: number + is_required: boolean +} + +// 审批实例 +export interface IApprovalInstance { + id: number + template_id: number + template?: IApprovalTemplate + business_type: string + business_id: number + current_node_id: number + status: number // 0审批中 1已通过 2已拒绝 3已撤销 + starter_id: number + finished_at: string + created_at: string +} + +// 审批记录 +export interface IApprovalRecord { + id: number + instance_id: number + node_id: number + approver_id: number + approver?: IUserInfo + status: number // 0待审批 1通过 2拒绝 + comment: string + approved_at: string + node_order: number + node_name: string + created_at: string +} + +// 审批实例详情 +export interface IApprovalDetail { + instance: IApprovalInstance + records: IApprovalRecord[] +} + +// ==================== 员工认证 ==================== + +// 员工认证申请 +export interface IEmployeeCertification { + id: number + user_id: number + user?: IUserInfo + real_name: string + phone: string + role_type?: string + snapshot: string[] + position?: string + department_id?: number + department?: IDepartmentItem + status: number // 0待审核 1通过 2拒绝 + approver_id?: number + approver?: IUserInfo + comment: string + created_at: string + updated_at?: string +} + +// 物品模板 +export interface IGoodsTemplate { + id?: number + user_id?: number + name: string + quantity: number + model: string + remark: string + created_at?: string +} + +// 访客历史视图 +export interface IVisitorVO { + id: number + company: string + name: string + phone: string + gender: number + id_type: string + id_number: string + license_plate: string +} + +// 部门(扁平列表) +export interface IDepartmentItem { + id: number + name: string + parent_id?: number + leader_name: string + leader_phone: string + sort: number + status: number + created_at: string +} + +// 部门树节点 +export interface IDepartmentTreeNode { + id: number + name: string + parent_id?: number + leader_name: string + leader_phone: string + sort: number + status: number + children: IDepartmentTreeNode[] +} diff --git a/mp-sc-frontend/src/api/types/login.ts b/mp-sc-frontend/src/api/types/login.ts new file mode 100644 index 0000000..b5f49e8 --- /dev/null +++ b/mp-sc-frontend/src/api/types/login.ts @@ -0,0 +1,102 @@ +// 认证模式类型 +export type AuthMode = 'single' | 'double' + +// 单Token响应类型 +export interface ISingleTokenRes { + token: string + expiresIn: number // 有效期(秒) +} + +// 双Token响应类型 +export interface IDoubleTokenRes { + accessToken: string + refreshToken: string + accessExpiresIn: number // 访问令牌有效期(秒) + refreshExpiresIn: number // 刷新令牌有效期(秒) +} + +/** + * 登录返回的信息,其实就是 token 信息 + */ +export type IAuthLoginRes = ISingleTokenRes | IDoubleTokenRes + +/** + * 用户信息 + */ +export type UserRole = string + +export interface IUserInfoRes { + userId: number + username: string + nickname: string + avatar?: string + /** 同时支持单角色和多角色,你自行选择一种就行 */ + role?: UserRole + roles?: UserRole[] + [key: string]: any // 允许其他扩展字段 +} + +// 认证存储数据结构 +export interface AuthStorage { + mode: AuthMode + tokens: ISingleTokenRes | IDoubleTokenRes + userInfo?: IUserInfoRes + loginTime: number // 登录时间戳 +} + +/** + * 获取验证码 + */ +export interface ICaptcha { + captchaEnabled: boolean + uuid: string + image: string +} +/** + * 上传成功的信息 + */ +export interface IUploadSuccessInfo { + fileId: number + originalName: string + fileName: string + storagePath: string + fileHash: string + fileType: string + fileBusinessType: string + fileSize: number +} +/** + * 更新用户信息 + */ +export interface IUpdateInfo { + id: number + name: string + sex: string +} +/** + * 更新用户信息 + */ +export interface IUpdatePassword { + id: number + oldPassword: string + newPassword: string + confirmPassword: string +} + +/** + * 判断是否为单Token响应 + * @param tokenRes 登录响应数据 + * @returns 是否为单Token响应 + */ +export function isSingleTokenRes(tokenRes: IAuthLoginRes): tokenRes is ISingleTokenRes { + return 'token' in tokenRes && !('refreshToken' in tokenRes) +} + +/** + * 判断是否为双Token响应 + * @param tokenRes 登录响应数据 + * @returns 是否为双Token响应 + */ +export function isDoubleTokenRes(tokenRes: IAuthLoginRes): tokenRes is IDoubleTokenRes { + return 'accessToken' in tokenRes && 'refreshToken' in tokenRes +} diff --git a/mp-sc-frontend/src/api/types/workflow.ts b/mp-sc-frontend/src/api/types/workflow.ts new file mode 100644 index 0000000..0f0fa0a --- /dev/null +++ b/mp-sc-frontend/src/api/types/workflow.ts @@ -0,0 +1,79 @@ +// 工作流相关类型定义 + +export interface IProcessDefinition { + id?: number; + process_name: string; // 流程名称 + process_code: string; // 流程编码 + source?: string; // 流程来源 + description?: string; // 流程描述 + is_active?: boolean; // 是否启用 + revoke_events?: string[]; // 撤销事件 + nodes_json?: string; // 节点配置 JSON + nodes?: INode[]; // 节点列表 + created_at?: string; + updated_at?: string; +} + +export interface INode { + node_id: string; // 节点 ID + node_name: string; // 节点名称 + node_type: number; // 节点类型:0-开始,1-审批,2-网关,3-结束 + prev_node_ids?: string[]; // 前置节点 ID 列表 + user_ids?: string[]; // 用户 ID 列表 + roles?: string[]; // 角色列表 + gw_config?: IGatewayConfig; // 网关配置 + is_cosigned?: number; // 是否会签:0-否,1-是 + node_start_events?: string[]; // 节点启动事件 + node_end_events?: string[]; // 节点结束事件 + task_finish_events?: string[]; // 任务完成事件 +} + +export interface IGatewayConfig { + conditions?: ICondition[]; // 条件分支 + inevitable_nodes?: string[]; // 必经节点 + wait_for_all_prev_node?: number; // 等待所有前置节点:0-否,1-是 +} + +export interface ICondition { + expression: string; // 条件表达式(如"$days>=3") + node_id: string; // 满足条件时跳转的节点 ID +} + +export interface IProcessInstance { + id?: number; + process_def_id: number; // 流程定义 ID + process_def?: IProcessDefinition; + business_type: string; // 业务类型 + business_id: number; // 业务 ID + status?: number; // 实例状态:0-运行中,1-已完成,2-已终止,3-已撤销 + starter_id?: number; + starter?: IUser; + current_node_id?: string; // 当前节点 ID + variables?: string; // 流程变量 JSON + finished_at?: string; + created_at?: string; + updated_at?: string; +} + +export interface ITask { + id?: number; + instance_id: number; + instance?: IProcessInstance; + node_id: string; + node_name: string; + status?: number; // 任务状态:0-待处理,1-已完成,2-已拒绝,3-已取消 + assignee_id?: number; + assignee?: IUser; + comment?: string; + handled_at?: string; + created_at?: string; + updated_at?: string; +} + +export interface IUser { + id: number; + openid: string; + nickname?: string; + real_name?: string; + phone?: string; +} diff --git a/mp-sc-frontend/src/api/workflow.ts b/mp-sc-frontend/src/api/workflow.ts new file mode 100644 index 0000000..9ac0dd2 --- /dev/null +++ b/mp-sc-frontend/src/api/workflow.ts @@ -0,0 +1,76 @@ +import { http } from '@/http/http' +import type { IProcessDefinition, IProcessInstance, ITask } from './types/workflow' + +/** + * 流程定义管理 + */ +export const workflowAPI = { + // 获取流程定义列表 + getProcessDefinitions() { + return http.get('/v1/workflow/definitions') + }, + + // 获取流程定义详情 + getProcessDefinition(id: number) { + return http.get(`/v1/workflow/definitions/${id}`) + }, + + // 创建流程定义 + createProcessDefinition(data: Partial) { + return http.post('/v1/workflow/definitions', data) + }, + + // 更新流程定义 + updateProcessDefinition(id: number, data: Partial) { + return http.put(`/v1/workflow/definitions/${id}`, data) + }, + + // 删除流程定义 + deleteProcessDefinition(id: number) { + return http.delete(`/v1/workflow/definitions/${id}`) + }, + + // 启动流程实例 + startInstance(data: { + process_code: string + business_type: string + business_id: number + variables?: Record + }) { + return http.post('/v1/workflow/instances', data) + }, + + // 获取流程实例列表 + getProcessInstances(params?: { + business_type?: string + business_id?: number + status?: string + }) { + return http.get('/v1/workflow/instances', params) + }, + + // 获取流程实例详情 + getInstanceDetail(id: number) { + return http.get<{ + instance: IProcessInstance + tasks: ITask[] + }>(`/v1/workflow/instances/${id}`) + }, + + // 获取待办任务 + getPendingTasks() { + return http.get('/v1/workflow/tasks/pending') + }, + + // 审批通过任务 + approveTask(id: number, comment?: string) { + return http.post(`/v1/workflow/tasks/${id}/approve`, { comment }) + }, + + // 审批拒绝任务 + rejectTask(id: number, comment?: string) { + return http.post(`/v1/workflow/tasks/${id}/reject`, { comment }) + } +} + +export default workflowAPI diff --git a/mp-sc-frontend/src/components/.gitkeep b/mp-sc-frontend/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/mp-sc-frontend/src/env.d.ts b/mp-sc-frontend/src/env.d.ts new file mode 100644 index 0000000..b1a4533 --- /dev/null +++ b/mp-sc-frontend/src/env.d.ts @@ -0,0 +1,41 @@ +/// +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent<{}, {}, any> + export default component +} + +interface ImportMetaEnv { + /** 网站标题,应用名称 */ + readonly VITE_APP_TITLE: string + /** 服务端口号 */ + readonly VITE_SERVER_PORT: string + /** 后台接口地址 */ + readonly VITE_SERVER_BASEURL: string + /** 微信小程序开发版后台接口地址,不配置则使用 VITE_SERVER_BASEURL */ + readonly VITE_SERVER_BASEURL__WEIXIN_DEVELOP?: string + /** 微信小程序体验版后台接口地址,不配置则使用 VITE_SERVER_BASEURL */ + readonly VITE_SERVER_BASEURL__WEIXIN_TRIAL?: string + /** 微信小程序正式版后台接口地址,不配置则使用 VITE_SERVER_BASEURL */ + readonly VITE_SERVER_BASEURL__WEIXIN_RELEASE?: string + /** H5是否需要代理 */ + readonly VITE_APP_PROXY_ENABLE: 'true' | 'false' + /** H5是否需要代理,需要的话有个前缀 */ + readonly VITE_APP_PROXY_PREFIX: string + /** 后端是否有统一前缀 /api */ + readonly VITE_SERVER_HAS_API_PREFIX: 'true' | 'false' + /** 认证模式,'single' | 'double' ==> 单token | 双token */ + readonly VITE_AUTH_MODE: 'single' | 'double' + /** 是否清除console */ + readonly VITE_DELETE_CONSOLE: string + // 更多环境变量... +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare const __VITE_APP_PROXY__: 'true' | 'false' diff --git a/mp-sc-frontend/src/hooks/useNavBar.ts b/mp-sc-frontend/src/hooks/useNavBar.ts new file mode 100644 index 0000000..36eecf9 --- /dev/null +++ b/mp-sc-frontend/src/hooks/useNavBar.ts @@ -0,0 +1,242 @@ +// hooks/useNavBar.ts +import { computed, onMounted, ref } from 'vue' +import type { ComputedRef, Ref } from 'vue' + +// 定义返回的类型 +export interface NavBarInfo { + // 状态栏高度(px) + statusBarHeight: Ref + // 标题栏内容高度(px)- 与胶囊对齐的区域 + titleBarHeight: Ref + // 整个导航栏总高度(px)- 状态栏 + 标题栏 + totalNavHeight: ComputedRef + // 胶囊按钮信息 + menuButtonInfo: Ref + // 是否是小程序环境 + isMiniProgram: Ref + // 重新获取系统信息 + refresh: () => void +} + +// 胶囊按钮信息类型 +export interface MenuButtonInfo { + width: number + height: number + top: number + right: number + bottom: number + left: number +} + +// 系统信息类型 +interface SystemInfo { + statusBarHeight: number + platform: string + system: string + model: string +} + +// 全局缓存 +let cachedNavBarInfo: { + statusBarHeight: number + titleBarHeight: number + menuButtonInfo: MenuButtonInfo | null +} | null = null + +/** + * 自定义导航栏 Hook + * 用于处理 uniapp 自定义导航栏的高度适配和胶囊信息获取 + */ +export function useNavBar(): NavBarInfo { + // 状态栏高度 + const statusBarHeight = ref(0) + + // 标题栏内容高度(与胶囊对齐的区域) + const titleBarHeight = ref(44) + + // 胶囊按钮信息 + const menuButtonInfo = ref(null) + + // 是否是小程序环境 + const isMiniProgram = ref(false) + + // 总高度(计算属性) + const totalNavHeight = computed(() => { + return statusBarHeight.value + titleBarHeight.value + }) + + /** + * 判断是否是小程序环境 + */ + const checkMiniProgramEnv = (): boolean => { + // #ifdef MP-WEIXIN + return true + // #endif + + // #ifndef MP-WEIXIN + return false + // #endif + } + + /** + * 获取胶囊按钮信息(仅小程序有效) + */ + const getMenuButtonInfo = (): MenuButtonInfo | null => { + // #ifdef MP-WEIXIN + try { + const info = uni.getMenuButtonBoundingClientRect() + if (info) { + return { + width: info.width, + height: info.height, + top: info.top, + right: info.right, + bottom: info.bottom, + left: info.left, + } + } + } + catch (error) { + console.warn('获取胶囊信息失败:', error) + } + // #endif + + return null + } + + /** + * 计算标题栏高度(与胶囊居中对齐) + */ + const calculateTitleBarHeight = (statusHeight: number, menuBtn: MenuButtonInfo | null): number => { + // 如果不是小程序环境或没有胶囊信息,返回默认值 + if (!isMiniProgram.value || !menuBtn) { + return 44 + } + + // 核心公式:让自定义标题栏与胶囊垂直居中对齐 + // 标题栏高度 = 胶囊高度 + (胶囊顶部 - 状态栏高度) * 2 + const calculatedHeight = menuBtn.height + (menuBtn.top - statusHeight) * 2 + + // 确保高度为正数且不低于最小高度 + return Math.max(calculatedHeight, 44) + } + + /** + * 获取系统信息 + */ + const getSystemInfo = (): SystemInfo => { + const systemInfo = uni.getSystemInfoSync() + return { + statusBarHeight: systemInfo.statusBarHeight || 20, + platform: systemInfo.platform, + system: systemInfo.system, + model: systemInfo.model, + } + } + + /** + * 初始化导航栏信息 + */ + const initNavBarInfo = (): void => { + // 检查环境 + isMiniProgram.value = checkMiniProgramEnv() + + // 获取系统信息 + const systemInfo = getSystemInfo() + + // 获取胶囊信息(仅小程序) + const menuBtn = getMenuButtonInfo() + + // 计算高度 + const calculatedTitleBarHeight = calculateTitleBarHeight(systemInfo.statusBarHeight, menuBtn) + + // 更新响应式数据 + statusBarHeight.value = systemInfo.statusBarHeight + titleBarHeight.value = calculatedTitleBarHeight + menuButtonInfo.value = menuBtn + + // 缓存结果 + cachedNavBarInfo = { + statusBarHeight: systemInfo.statusBarHeight, + titleBarHeight: calculatedTitleBarHeight, + menuButtonInfo: menuBtn, + } + } + + /** + * 刷新导航栏信息(用于屏幕旋转等场景) + */ + const refresh = (): void => { + // 清除缓存 + cachedNavBarInfo = null + // 重新初始化 + initNavBarInfo() + } + + // 在组件挂载时初始化 + onMounted(() => { + // 如果有缓存,直接使用缓存 + if (cachedNavBarInfo) { + statusBarHeight.value = cachedNavBarInfo.statusBarHeight + titleBarHeight.value = cachedNavBarInfo.titleBarHeight + menuButtonInfo.value = cachedNavBarInfo.menuButtonInfo + isMiniProgram.value = checkMiniProgramEnv() + } + else { + initNavBarInfo() + } + }) + + return { + statusBarHeight, + titleBarHeight, + totalNavHeight, + menuButtonInfo, + isMiniProgram, + refresh, + } +} + +/** + * 获取状态栏高度的快捷方法(仅高度,不包含胶囊逻辑) + */ +export function useStatusBarHeight(): Ref { + const statusBarHeight = ref(0) + + onMounted(() => { + const systemInfo = uni.getSystemInfoSync() + statusBarHeight.value = systemInfo.statusBarHeight || 20 + }) + + return statusBarHeight +} + +/** + * 获取胶囊信息的快捷方法(仅小程序有效) + */ +export function useMenuButtonInfo(): Ref { + const menuButtonInfo = ref(null) + + onMounted(() => { + // #ifdef MP-WEIXIN + try { + const info = uni.getMenuButtonBoundingClientRect() + if (info) { + menuButtonInfo.value = { + width: info.width, + height: info.height, + top: info.top, + right: info.right, + bottom: info.bottom, + left: info.left, + } + } + } + catch (error) { + console.warn('获取胶囊信息失败:', error) + } + // #endif + }) + + return menuButtonInfo +} diff --git a/mp-sc-frontend/src/hooks/useRequest.test.ts b/mp-sc-frontend/src/hooks/useRequest.test.ts new file mode 100644 index 0000000..8a205c7 --- /dev/null +++ b/mp-sc-frontend/src/hooks/useRequest.test.ts @@ -0,0 +1,74 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import useRequest from './useRequest' + +/** + * 在 Vue 应用上下文中运行 composable。 + * composable 的 ref/computed/onMounted 只能在 setup() 内使用, + * withSetup 通过挂载一个临时组件来提供这个上下文。 + */ +function withSetup(composableFn: () => T): T { + let result!: T + const Comp = defineComponent({ + setup() { + result = composableFn() + return () => h('div') + }, + }) + const wrapper = mount(Comp) + wrapper.unmount() + return result +} + +describe('useRequest', () => { + it('初始状态:loading=false, error=false, data=undefined', () => { + const asyncFn = vi.fn().mockResolvedValue('data') + const { loading, error, data } = withSetup(() => useRequest(asyncFn)) + + expect(loading.value).toBe(false) + expect(error.value).toBe(false) + expect(data.value).toBeUndefined() + }) + + it('initialData:初始 data 使用传入的默认值', () => { + const asyncFn = vi.fn().mockResolvedValue('new') + const { data } = withSetup(() => useRequest(asyncFn, { initialData: 'init' })) + + expect(data.value).toBe('init') + }) + + it('run 成功:loading 先变 true 后变 false,data 更新为返回值', async () => { + const asyncFn = vi.fn().mockResolvedValue('result') + const { loading, data, run } = withSetup(() => useRequest(asyncFn)) + + const runPromise = run() + expect(loading.value).toBe(true) + + await runPromise + + expect(loading.value).toBe(false) + expect(data.value).toBe('result') + }) + + it('run 失败:抛出错误,error 被设置,loading 重置为 false', async () => { + const err = new Error('network error') + const asyncFn = vi.fn().mockRejectedValue(err) + const { loading, error, run } = withSetup(() => useRequest(asyncFn)) + + await expect(run()).rejects.toThrow('network error') + + expect(loading.value).toBe(false) + expect(error.value).toBe(err) + }) + + it('immediate=true:组件挂载时立即调用异步函数并更新 data', async () => { + const asyncFn = vi.fn().mockResolvedValue('eager') + const { data } = withSetup(() => useRequest(asyncFn, { immediate: true })) + + expect(asyncFn).toHaveBeenCalledTimes(1) + // 等待 Promise 完成 + await asyncFn.mock.results[0].value + expect(data.value).toBe('eager') + }) +}) diff --git a/mp-sc-frontend/src/hooks/useRequest.ts b/mp-sc-frontend/src/hooks/useRequest.ts new file mode 100644 index 0000000..8ac4bfe --- /dev/null +++ b/mp-sc-frontend/src/hooks/useRequest.ts @@ -0,0 +1,54 @@ +import type { Ref } from 'vue' +import { ref } from 'vue' + +interface IUseRequestOptions { + /** 是否立即执行 */ + immediate?: boolean + /** 初始化数据 */ + initialData?: T +} + +interface IUseRequestReturn { + loading: Ref + error: Ref + data: Ref + run: (args?: P) => Promise +} + +/** + * useRequest是一个定制化的请求钩子,用于处理异步请求和响应。 + * @param func 一个执行异步请求的函数,返回一个包含响应数据的Promise。 + * @param options 包含请求选项的对象 {immediate, initialData}。 + * @param options.immediate 是否立即执行请求,默认为false。 + * @param options.initialData 初始化数据,默认为undefined。 + * @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。 + */ +export default function useRequest( + func: (args?: P) => Promise, + options: IUseRequestOptions = { immediate: false }, +): IUseRequestReturn { + const loading = ref(false) + const error = ref(false) + const data = ref(options.initialData) as Ref + const run = async (args?: P) => { + loading.value = true + return func(args) + .then((res) => { + data.value = res + error.value = false + return data.value + }) + .catch((err) => { + error.value = err + throw err + }) + .finally(() => { + loading.value = false + }) + } + + if (options.immediate) { + (run as (args: P) => Promise)({} as P) + } + return { loading, error, data, run } +} diff --git a/mp-sc-frontend/src/hooks/useScroll.md b/mp-sc-frontend/src/hooks/useScroll.md new file mode 100644 index 0000000..bb2eace --- /dev/null +++ b/mp-sc-frontend/src/hooks/useScroll.md @@ -0,0 +1,116 @@ +# 上拉刷新和下拉加载更多 + +在 unibest 框架中,我们通过组合 `useScroll` Hook 可结合 `scroll-view` 组件来轻松实现上拉刷新和下拉加载更多的功能。 +场景一 页面滚动 + +``` +definePage({ + style: { + navigationBarTitleText: '上拉刷新和下拉加载更多', + enablePullDownRefresh: true, + onReachBottomDistance: 100, + }, +}) +``` + +场景二 局部滚动 结合 `scroll-view` + +## 关键文件 + +- `src/hooks/useScroll.ts`: 提供了核心的滚动逻辑处理 Hook。 +- `src/pages-sub/demo/scroll.vue`: 一个具体的实现示例页面。 + +## `useScroll` Hook + +`useScroll` 是一个 Vue Composition API Hook,它封装了处理下拉刷新和上拉加载的通用逻辑。 + +### 主要功能 + +- **管理加载状态**: 自动处理 `loading`(加载中)、`finished`(已加载全部)和 `error`(加载失败)等状态。 +- **分页逻辑**: 内部维护分页参数(页码 `page` 和每页数量 `pageSize`)。 +- **事件处理**: 提供 `onScrollToLower`(滚动到底部)、`onRefresherRefresh`(下拉刷新)等方法,用于在视图层触发。 +- **数据合并**: 自动将新加载的数据追加到现有列表 `list` 中。 + +### 使用方法 + +```typescript +import { useScroll } from '@/hooks/useScroll' +import { getList } from '@/service/list' // 你的数据请求API + +const { + list, // 响应式的数据列表 + loading, // 是否加载中 + finished, // 是否已全部加载 + error, // 是否加载失败 + onScrollToLower, // 滚动到底部时触发的事件 + onRefresherRefresh, // 下拉刷新时触发的事件 +} = useScroll(getList) // 将获取数据的API函数传入 +``` + +## `scroll-view` 组件 + +`scroll-view` 是 uni-app 提供的可滚动视图区域组件,它提供了一系列属性来支持下拉刷新和上拉加载。 + +### 关键属性 + +- `scroll-y`: 允许纵向滚动。 +- `refresher-enabled`: 启用下拉刷新。 +- `refresher-triggered`: 控制下拉刷新动画的显示与隐藏,通过 `loading` 状态绑定。 +- `@scrolltolower`: 滚动到底部时触发的事件,绑定 `onScrollToLower` 方法。 +- `@refresherrefresh`: 触发下拉刷新时触发的事件,绑定 `onRefresherRefresh` 方法。 + +## 示例代码 + +以下是 `src/pages-sub/demo/scroll.vue` 中的核心代码,展示了如何将 `useScroll` 和 `scroll-view` 结合使用。 + +```vue + + + + + +``` + +## 实现步骤总结 + +1. **创建API**: 确保你有一个返回分页数据的API请求函数(例如 `getList`),它应该接受页码和页面大小作为参数。 +2. **调用 `useScroll`**: 在你的页面脚本中,导入并调用 `useScroll` Hook,将你的API函数作为参数传入。 +3. **模板绑定**: + - 使用 `scroll-view` 组件作为滚动容器。 + - 将其 `refresher-triggered` 属性绑定到 `useScroll` 返回的 `loading` 状态。 + - 将其 `@scrolltolower` 事件绑定到 `onScrollToLower` 方法。 + - 将其 `@refresherrefresh` 事件绑定到 `onRefresherRefresh` 方法。 +4. **渲染列表**: 使用 `v-for` 指令渲染 `useScroll` 返回的 `list` 数组。 +5. **添加加载提示**: 根据 `loading`, `finished`, `error` 状态,在列表底部显示不同的提示信息,提升用户体验。 + +通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。 \ No newline at end of file diff --git a/mp-sc-frontend/src/hooks/useScroll.ts b/mp-sc-frontend/src/hooks/useScroll.ts new file mode 100644 index 0000000..1563223 --- /dev/null +++ b/mp-sc-frontend/src/hooks/useScroll.ts @@ -0,0 +1,74 @@ +import type { Ref } from 'vue' +import { onMounted, ref } from 'vue' + +interface UseScrollOptions { + fetchData: (page: number, pageSize: number) => Promise + pageSize?: number +} + +interface UseScrollReturn { + list: Ref + loading: Ref + finished: Ref + error: Ref + refresh: () => Promise + loadMore: () => Promise +} + +export function useScroll({ + fetchData, + pageSize = 10, +}: UseScrollOptions): UseScrollReturn { + const list = ref([]) as Ref + const loading = ref(false) + const finished = ref(false) + const error = ref(null) + const page = ref(1) + + const loadData = async () => { + if (loading.value || finished.value) + return + + loading.value = true + error.value = null + + try { + const data = await fetchData(page.value, pageSize) + if (data.length < pageSize) { + finished.value = true + } + list.value.push(...data) + page.value++ + } + catch (err) { + error.value = err + } + finally { + loading.value = false + } + } + + const refresh = async () => { + page.value = 1 + finished.value = false + list.value = [] + await loadData() + } + + const loadMore = async () => { + await loadData() + } + + onMounted(() => { + refresh() + }) + + return { + list, + loading, + finished, + error, + refresh, + loadMore, + } +} diff --git a/mp-sc-frontend/src/hooks/useUpload.ts b/mp-sc-frontend/src/hooks/useUpload.ts new file mode 100644 index 0000000..7c9700a --- /dev/null +++ b/mp-sc-frontend/src/hooks/useUpload.ts @@ -0,0 +1,171 @@ +import { ref } from 'vue' +import { getEnvBaseUrl } from '@/utils/index' + +const VITE_UPLOAD_BASEURL = `${getEnvBaseUrl()}/upload` + +type TfileType = 'image' | 'file' +type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*' +type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage + +interface TOptions { + formData?: Record + maxSize?: number + accept?: T extends 'image' ? TImage[] : TFile[] + fileType?: T + success?: (params: any) => void + error?: (err: any) => void +} + +export default function useUpload(options: TOptions = {} as TOptions) { + const { + formData = {}, + maxSize = 5 * 1024 * 1024, + accept = ['*'], + fileType = 'image', + success, + error: onError, + } = options + + const loading = ref(false) + const error = ref(null) + const data = ref(null) + + const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => { + if (size > maxSize) { + uni.showToast({ + title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`, + icon: 'none', + }) + return + } + + // const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase() + // const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension) + + // if (!isTypeValid) { + // uni.showToast({ + // title: `仅支持 ${accept.join(', ')} 格式的文件`, + // icon: 'none', + // }) + // return + // } + + loading.value = true + uploadFile({ + tempFilePath, + formData, + onSuccess: (res) => { + // 修改这里的解析逻辑,适应不同平台的返回格式 + let parsedData = res + try { + // 尝试解析为JSON + const jsonData = JSON.parse(res) + // 检查是否包含data字段 + parsedData = jsonData.data || jsonData + } + catch (e) { + // 如果解析失败,使用原始数据 + console.log('Response is not JSON, using raw data:', res) + } + data.value = parsedData + // console.log('上传成功', res) + success?.(parsedData) + }, + onError: (err) => { + error.value = err + onError?.(err) + }, + onComplete: () => { + loading.value = false + }, + }) + } + + const run = () => { + // 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。 + // 微信小程序在2023年10月17日之后,使用本API需要配置隐私协议 + const chooseFileOptions = { + count: 1, + success: (res: any) => { + console.log('File selected successfully:', res) + // 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]} + // h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]} + // h5的File有以下字段:{name: "girl.jpeg", size: 48976, type: "image/jpeg"} + // App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]} + // App的File有以下字段:{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976} + let tempFilePath = '' + let size = 0 + // #ifdef MP-WEIXIN + tempFilePath = res.tempFiles[0].tempFilePath + size = res.tempFiles[0].size + // #endif + // #ifndef MP-WEIXIN + tempFilePath = res.tempFilePaths[0] + size = res.tempFiles[0].size + // #endif + handleFileChoose({ tempFilePath, size }) + }, + fail: (err: any) => { + console.error('File selection failed:', err) + error.value = err + onError?.(err) + }, + } + + if (fileType === 'image') { + // #ifdef MP-WEIXIN + uni.chooseMedia({ + ...chooseFileOptions, + mediaType: ['image'], + }) + // #endif + + // #ifndef MP-WEIXIN + uni.chooseImage(chooseFileOptions) + // #endif + } + else { + uni.chooseFile({ + ...chooseFileOptions, + type: 'all', + }) + } + } + + return { loading, error, data, run } +} + +async function uploadFile({ + tempFilePath, + formData, + onSuccess, + onError, + onComplete, +}: { + tempFilePath: string + formData: Record + onSuccess: (data: any) => void + onError: (err: any) => void + onComplete: () => void +}) { + uni.uploadFile({ + url: VITE_UPLOAD_BASEURL, + filePath: tempFilePath, + name: 'file', + formData, + success: (uploadFileRes) => { + try { + const data = uploadFileRes.data + onSuccess(data) + } + catch (err) { + onError(err) + } + }, + fail: (err) => { + console.error('Upload failed:', err) + onError(err) + }, + complete: onComplete, + }) +} diff --git a/mp-sc-frontend/src/http/README.md b/mp-sc-frontend/src/http/README.md new file mode 100644 index 0000000..5bb5a4f --- /dev/null +++ b/mp-sc-frontend/src/http/README.md @@ -0,0 +1,13 @@ +# 请求库 + +目前unibest支持3种请求库: +- 菲鸽简单封装的 `简单版本http`,路径(src/http/http.ts),对应的示例在 src/api/foo.ts +- `alova 的 http`,路径(src/http/alova.ts),对应的示例在 src/api/foo-alova.ts +- `vue-query`, 路径(src/http/vue-query.ts), 目前主要用在自动生成接口,详情看(https://unibest.tech/base/17-generate),示例在 src/service/app 文件夹 + +## 如何选择 +如果您以前用过 alova 或者 vue-query,可以优先使用您熟悉的。 +如果您的项目简单,简单版本的http 就够了,也不会增加包体积。(发版的时候可以去掉alova和vue-query,如果没有超过包体积,留着也无所谓 ^_^) + +## roadmap +菲鸽最近在优化脚手架,后续可以选择是否使用第三方的请求库,以及选择什么请求库。还在开发中,大概月底出来(8月31号)。 \ No newline at end of file diff --git a/mp-sc-frontend/src/http/alova.ts b/mp-sc-frontend/src/http/alova.ts new file mode 100644 index 0000000..e89f284 --- /dev/null +++ b/mp-sc-frontend/src/http/alova.ts @@ -0,0 +1,119 @@ +import type { uniappRequestAdapter } from '@alova/adapter-uniapp' +import type { IResponse } from './types' +import AdapterUniapp from '@alova/adapter-uniapp' +import { createAlova } from 'alova' +import { createServerTokenAuthentication } from 'alova/client' +import VueHook from 'alova/vue' +import { toLoginPage } from '@/utils/toLoginPage' +import { ContentTypeEnum, ResultEnum, ShowMessage } from './tools/enum' + +// 配置动态Tag +export const API_DOMAINS = { + DEFAULT: import.meta.env.VITE_SERVER_BASEURL, + SECONDARY: import.meta.env.VITE_SERVER_BASEURL_SECONDARY, +} + +/** + * 创建请求实例 + */ +const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication< + typeof VueHook, + typeof uniappRequestAdapter +>({ + // 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛 + refreshTokenOnError: { + isExpired: (error) => { + return error.response?.status === ResultEnum.Unauthorized + }, + handler: async () => { + try { + // await authLogin(); + } + catch (error) { + // 切换到登录页 + toLoginPage({ mode: 'reLaunch' }) + throw error + } + }, + }, +}) + +/** + * alova 请求实例 + */ +const alovaInstance = createAlova({ + baseURL: API_DOMAINS.DEFAULT, + ...AdapterUniapp(), + timeout: 5000, + statesHook: VueHook, + + beforeRequest: onAuthRequired((method) => { + // 设置默认 Content-Type + method.config.headers = { + ContentType: ContentTypeEnum.JSON, + Accept: 'application/json, text/plain, */*', + ...method.config.headers, + } + + const { config } = method + const ignoreAuth = !config.meta?.ignoreAuth + console.log('ignoreAuth===>', ignoreAuth) + // 处理认证信息 自行处理认证问题 + if (ignoreAuth) { + const token = 'getToken()' + if (!token) { + throw new Error('[请求错误]:未登录') + } + // method.config.headers.token = token; + } + + // 处理动态域名 + if (config.meta?.domain) { + method.baseURL = config.meta.domain + console.log('当前域名', method.baseURL) + } + }), + + responded: onResponseRefreshToken((response, method) => { + const { config } = method + const { requestType } = config + const { + statusCode, + data: rawData, + errMsg, + } = response as UniNamespace.RequestSuccessCallbackResult + + // 处理特殊请求类型(上传/下载) + if (requestType === 'upload' || requestType === 'download') { + return response + } + + // 处理 HTTP 状态码错误 + if (statusCode !== 200) { + const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]` + console.error('errorMessage===>', errorMessage) + uni.showToast({ + title: errorMessage, + icon: 'error', + }) + throw new Error(`${errorMessage}:${errMsg}`) + } + + // 处理业务逻辑错误 + const { code, msg, data } = rawData as IResponse + // 0和200当做成功都很普遍,这里直接兼容两者,见 ResultEnum + if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) { + if (config.meta?.toast !== false) { + uni.showToast({ + title: msg, + icon: 'none', + }) + } + throw new Error(`请求错误[${code}]:${msg}`) + } + // 处理成功响应,返回业务数据 + return data + }), +}) + +export const http = alovaInstance diff --git a/mp-sc-frontend/src/http/http.ts b/mp-sc-frontend/src/http/http.ts new file mode 100644 index 0000000..3fa6d42 --- /dev/null +++ b/mp-sc-frontend/src/http/http.ts @@ -0,0 +1,200 @@ +import type { IDoubleTokenRes } from '@/api/types/login' +import type { CustomRequestOptions, IResponse } from '@/http/types' +import { nextTick } from 'vue' +import { useTokenStore } from '@/store/token' +import { isDoubleTokenMode } from '@/utils' +import { toLoginPage } from '@/utils/toLoginPage' +import { ResultEnum } from './tools/enum' + +// 刷新 token 状态管理 +let refreshing = false // 防止重复刷新 token 标识 +let taskQueue: (() => void)[] = [] // 刷新 token 请求队列 + +export function http(options: CustomRequestOptions) { + // 1. 返回 Promise 对象 + return new Promise((resolve, reject) => { + uni.request({ + ...options, + dataType: 'json', + // #ifndef MP-WEIXIN + responseType: 'json', + // #endif + // 响应成功 + success: async (res) => { + const responseData = res.data as IResponse + const { code } = responseData + + // 检查是否是401错误(包括HTTP状态码401或业务码401) + const isTokenExpired = res.statusCode === 401 || code === 401 + + if (isTokenExpired) { + const tokenStore = useTokenStore() + if (!isDoubleTokenMode) { + // 未启用双token策略,清理用户信息,跳转到登录页 + tokenStore.logout() + toLoginPage() + return reject(res) + } + + /* -------- 无感刷新 token ----------- */ + const { refreshToken } = tokenStore.tokenInfo as IDoubleTokenRes || {} + // token 失效的,且有刷新 token 的,才放到请求队列里 + if (refreshToken) { + taskQueue.push(() => { + resolve(http(options)) + }) + } + + // 如果有 refreshToken 且未在刷新中,发起刷新 token 请求 + if (refreshToken && !refreshing) { + refreshing = true + try { + // 发起刷新 token 请求(使用 store 的 refreshToken 方法) + await tokenStore.refreshToken() + // 刷新 token 成功 + refreshing = false + nextTick(() => { + // 关闭其他弹窗 + uni.hideToast() + uni.showToast({ + title: 'token 刷新成功', + icon: 'none', + }) + }) + // 将任务队列的所有任务重新请求 + taskQueue.forEach(task => task()) + } + catch (refreshErr) { + console.error('刷新 token 失败:', refreshErr) + refreshing = false + // 刷新 token 失败,跳转到登录页 + nextTick(() => { + // 关闭其他弹窗 + uni.hideToast() + uni.showToast({ + title: '登录已过期,请重新登录', + icon: 'none', + }) + }) + // 清除用户信息 + await tokenStore.logout() + // 跳转到登录页 + setTimeout(() => { + toLoginPage() + }, 2000) + } + finally { + // 不管刷新 token 成功与否,都清空任务队列 + taskQueue = [] + } + } + + return reject(res) + } + + // 处理其他成功状态(HTTP状态码200-299) + if (res.statusCode >= 200 && res.statusCode < 300) { + // 处理业务逻辑错误 + if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) { + uni.showToast({ + icon: 'none', + title: responseData.msg || '请求错误', + }) + return reject(responseData.data) + } + return resolve(responseData.data) + } + + // 处理其他错误 + !options.hideErrorToast + && uni.showToast({ + icon: 'none', + title: (res.data as any).msg || '请求错误', + }) + reject(res) + }, + // 响应失败 + fail(err) { + uni.showToast({ + icon: 'none', + title: '网络错误,换个网络试试', + }) + reject(err) + }, + }) + }) +} + +/** + * GET 请求 + * @param url 后台地址 + * @param query 请求query参数 + * @param header 请求头,默认为json格式 + * @returns + */ +export function httpGet(url: string, query?: Record, header?: Record, options?: Partial) { + return http({ + url, + query, + method: 'GET', + header, + ...options, + }) +} + +/** + * POST 请求 + * @param url 后台地址 + * @param data 请求body参数 + * @param query 请求query参数,post请求也支持query,很多微信接口都需要 + * @param header 请求头,默认为json格式 + * @returns + */ +export function httpPost(url: string, data?: Record, query?: Record, header?: Record, options?: Partial) { + return http({ + url, + query, + data, + method: 'POST', + header, + ...options, + }) +} +/** + * PUT 请求 + */ +export function httpPut(url: string, data?: Record, query?: Record, header?: Record, options?: Partial) { + return http({ + url, + data, + query, + method: 'PUT', + header, + ...options, + }) +} + +/** + * DELETE 请求(无请求体,仅 query) + */ +export function httpDelete(url: string, query?: Record, header?: Record, options?: Partial) { + return http({ + url, + query, + method: 'DELETE', + header, + ...options, + }) +} + +// 支持与 axios 类似的API调用 +http.get = httpGet +http.post = httpPost +http.put = httpPut +http.delete = httpDelete + +// 支持与 alovaJS 类似的API调用 +http.Get = httpGet +http.Post = httpPost +http.Put = httpPut +http.Delete = httpDelete diff --git a/mp-sc-frontend/src/http/interceptor.ts b/mp-sc-frontend/src/http/interceptor.ts new file mode 100644 index 0000000..bcd0f87 --- /dev/null +++ b/mp-sc-frontend/src/http/interceptor.ts @@ -0,0 +1,69 @@ +import type { CustomRequestOptions } from '@/http/types' +import { useTokenStore } from '@/store' +import { getEnvBaseUrl } from '@/utils' +import { stringifyQuery } from './tools/queryString' + +// 请求基准地址 +const baseUrl = getEnvBaseUrl() + +// 拦截器配置 +const httpInterceptor = { + // 拦截前触发 + invoke(options: CustomRequestOptions) { + // 如果您使用了alova,则请把下面的代码放开注释 + // alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded + // return options + + // 非 alova 请求,正常执行 + // 接口请求支持通过 query 参数配置 queryString + if (options.query) { + const queryStr = stringifyQuery(options.query) + if (options.url.includes('?')) { + options.url += `&${queryStr}` + } + else { + options.url += `?${queryStr}` + } + } + // 非 http 开头需拼接地址 + if (!options.url.startsWith('http')) { + // #ifdef H5 + if (JSON.parse(import.meta.env.VITE_APP_PROXY_ENABLE)) { + // 自动拼接代理前缀 + options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url + } + else { + options.url = baseUrl + options.url + } + // #endif + // 非H5正常拼接 + // #ifndef H5 + options.url = baseUrl + options.url + // #endif + // TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址 + } + // 1. 请求超时 + options.timeout = 60000 // 60s + // 2. (可选)添加小程序端请求头标识 + options.header = { + ...options.header, + } + // 3. 添加 token 请求头标识 + const tokenStore = useTokenStore() + const token = tokenStore.updateNowTime().validToken + + if (token) { + options.header.Authorization = `Bearer ${token}` + } + return options + }, +} + +export const requestInterceptor = { + install() { + // 拦截 request 请求 + uni.addInterceptor('request', httpInterceptor) + // 拦截 uploadFile 文件上传 + uni.addInterceptor('uploadFile', httpInterceptor) + }, +} diff --git a/mp-sc-frontend/src/http/tools/enum.ts b/mp-sc-frontend/src/http/tools/enum.ts new file mode 100644 index 0000000..806e616 --- /dev/null +++ b/mp-sc-frontend/src/http/tools/enum.ts @@ -0,0 +1,68 @@ +export enum ResultEnum { + // 0和200当做成功都很普遍,这里直接兼容两者(PS:0和200通常都不会当做错误码,但是有的接口会返回0,有的接口会返回200) + Success0 = 0, // 成功 + Success200 = 200, // 成功 + Error = 400, // 错误 + Unauthorized = 401, // 未授权 + Forbidden = 403, // 禁止访问(原为forbidden) + NotFound = 404, // 未找到(原为notFound) + MethodNotAllowed = 405, // 方法不允许(原为methodNotAllowed) + RequestTimeout = 408, // 请求超时(原为requestTimeout) + InternalServerError = 500, // 服务器错误(原为internalServerError) + NotImplemented = 501, // 未实现(原为notImplemented) + BadGateway = 502, // 网关错误(原为badGateway) + ServiceUnavailable = 503, // 服务不可用(原为serviceUnavailable) + GatewayTimeout = 504, // 网关超时(原为gatewayTimeout) + HttpVersionNotSupported = 505, // HTTP版本不支持(原为httpVersionNotSupported) +} +export enum ContentTypeEnum { + JSON = 'application/json;charset=UTF-8', + FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8', + FORM_DATA = 'multipart/form-data;charset=UTF-8', +} +/** + * 根据状态码,生成对应的错误信息 + * @param {number|string} status 状态码 + * @returns {string} 错误信息 + */ +export function ShowMessage(status: number | string): string { + let message: string + switch (status) { + case 400: + message = '请求错误(400)' + break + case 401: + message = '未授权,请重新登录(401)' + break + case 403: + message = '拒绝访问(403)' + break + case 404: + message = '请求出错(404)' + break + case 408: + message = '请求超时(408)' + break + case 500: + message = '服务器错误(500)' + break + case 501: + message = '服务未实现(501)' + break + case 502: + message = '网络错误(502)' + break + case 503: + message = '服务不可用(503)' + break + case 504: + message = '网络超时(504)' + break + case 505: + message = 'HTTP版本不受支持(505)' + break + default: + message = `连接出错(${status})!` + } + return `${message},请检查网络或联系管理员!` +} diff --git a/mp-sc-frontend/src/http/tools/queryString.ts b/mp-sc-frontend/src/http/tools/queryString.ts new file mode 100644 index 0000000..edf973e --- /dev/null +++ b/mp-sc-frontend/src/http/tools/queryString.ts @@ -0,0 +1,29 @@ +/** + * 将对象序列化为URL查询字符串,用于替代第三方的 qs 库,节省宝贵的体积 + * 支持基本类型值和数组,不支持嵌套对象 + * @param obj 要序列化的对象 + * @returns 序列化后的查询字符串 + */ +export function stringifyQuery(obj: Record): string { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) + return '' + + return Object.entries(obj) + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + // 对键进行编码 + const encodedKey = encodeURIComponent(key) + + // 处理数组类型 + if (Array.isArray(value)) { + return value + .filter(item => item !== undefined && item !== null) + .map(item => `${encodedKey}=${encodeURIComponent(item)}`) + .join('&') + } + + // 处理基本类型 + return `${encodedKey}=${encodeURIComponent(value)}` + }) + .join('&') +} diff --git a/mp-sc-frontend/src/http/types.ts b/mp-sc-frontend/src/http/types.ts new file mode 100644 index 0000000..c97ca86 --- /dev/null +++ b/mp-sc-frontend/src/http/types.ts @@ -0,0 +1,39 @@ +/** + * 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数 + */ +export type CustomRequestOptions = UniApp.RequestOptions & { + query?: Record + /** 出错时是否隐藏错误提示 */ + hideErrorToast?: boolean +} & IUniUploadFileOptions // 添加uni.uploadFile参数类型 + +/** 主要提供给 openapi-ts-request 生成的代码使用 */ +export type CustomRequestOptions_ = Omit + +export interface HttpRequestResult { + promise: Promise + requestTask: UniApp.RequestTask +} + +// 通用响应格式 +export type IResponse = { + code: number + data: T + msg: string + [key: string]: any // 允许额外属性 +} + +// 分页请求参数 +export interface PageParams { + page: number + pageSize: number + [key: string]: any +} + +// 分页响应数据 +export interface PageResult { + list: T[] + total: number + page: number + pageSize: number +} diff --git a/mp-sc-frontend/src/http/vue-query.ts b/mp-sc-frontend/src/http/vue-query.ts new file mode 100644 index 0000000..69ca80d --- /dev/null +++ b/mp-sc-frontend/src/http/vue-query.ts @@ -0,0 +1,30 @@ +import type { CustomRequestOptions } from '@/http/types' +import { http } from './http' + +/* + * openapi-ts-request 工具的 request 跨客户端适配方法 + */ +export default function request( + url: string, + options: Omit & { + params?: Record + headers?: Record + }, +) { + const requestOptions = { + url, + ...options, + } + + if (options.params) { + requestOptions.query = requestOptions.params + delete requestOptions.params + } + + if (options.headers) { + requestOptions.header = options.headers + delete requestOptions.headers + } + + return http(requestOptions) +} diff --git a/mp-sc-frontend/src/layouts/default.vue b/mp-sc-frontend/src/layouts/default.vue new file mode 100644 index 0000000..ba4672f --- /dev/null +++ b/mp-sc-frontend/src/layouts/default.vue @@ -0,0 +1,3 @@ + diff --git a/mp-sc-frontend/src/lib/md5.js b/mp-sc-frontend/src/lib/md5.js new file mode 100644 index 0000000..76bcc0c --- /dev/null +++ b/mp-sc-frontend/src/lib/md5.js @@ -0,0 +1,10 @@ +/** + * [js-md5]{@link https://github.com/emn178/js-md5} + * + * @namespace md5 + * @version 0.8.0 + * @author Chen, Yi-Cyuan [emn178@gmail.com] + * @copyright Chen, Yi-Cyuan 2014-2023 + * @license MIT + */ +!function(){"use strict";function t(t){if(t)b[0]=b[16]=b[1]=b[2]=b[3]=b[4]=b[5]=b[6]=b[7]=b[8]=b[9]=b[10]=b[11]=b[12]=b[13]=b[14]=b[15]=0,this.blocks=b,this.buffer8=a;else if(u){var r=new ArrayBuffer(68);this.buffer8=new Uint8Array(r),this.blocks=new Uint32Array(r)}else this.blocks=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];this.h0=this.h1=this.h2=this.h3=this.start=this.bytes=this.hBytes=0,this.finalized=this.hashed=!1,this.first=!0}function r(r,e){var i,s=_(r);if(r=s[0],s[1]){var h,n=[],a=r.length,o=0;for(i=0;i>>6,n[o++]=128|63&h):h<55296||h>=57344?(n[o++]=224|h>>>12,n[o++]=128|h>>>6&63,n[o++]=128|63&h):(h=65536+((1023&h)<<10|1023&r.charCodeAt(++i)),n[o++]=240|h>>>18,n[o++]=128|h>>>12&63,n[o++]=128|h>>>6&63,n[o++]=128|63&h);r=n}r.length>64&&(r=new t(!0).update(r).array());var f=[],u=[];for(i=0;i<64;++i){var c=r[i]||0;f[i]=92^c,u[i]=54^c}t.call(this,e),this.update(u),this.oKeyPad=f,this.inner=!0,this.sharedMemory=e}var e="input is invalid type",i="object"==typeof window,s=i?window:{};s.JS_MD5_NO_WINDOW&&(i=!1);var h=!i&&"object"==typeof self,n=!s.JS_MD5_NO_NODE_JS&&"object"==typeof process&&process.versions&&process.versions.node;n?s=global:h&&(s=self);var a,o=!s.JS_MD5_NO_COMMON_JS&&"object"==typeof module&&module.exports,f="function"==typeof define&&define.amd,u=!s.JS_MD5_NO_ARRAY_BUFFER&&"undefined"!=typeof ArrayBuffer,c="0123456789abcdef".split(""),y=[128,32768,8388608,-2147483648],p=[0,8,16,24],d=["hex","array","digest","buffer","arrayBuffer","base64"],l="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split(""),b=[];if(u){var v=new ArrayBuffer(68);a=new Uint8Array(v),b=new Uint32Array(v)}var w=Array.isArray;!s.JS_MD5_NO_NODE_JS&&w||(w=function(t){return"[object Array]"===Object.prototype.toString.call(t)});var A=ArrayBuffer.isView;!u||!s.JS_MD5_NO_ARRAY_BUFFER_IS_VIEW&&A||(A=function(t){return"object"==typeof t&&t.buffer&&t.buffer.constructor===ArrayBuffer});var _=function(t){var r=typeof t;if("string"===r)return[t,!0];if("object"!==r||null===t)throw new Error(e);if(u&&t.constructor===ArrayBuffer)return[new Uint8Array(t),!1];if(!w(t)&&!A(t))throw new Error(e);return[t,!1]},B=function(r){return function(e){return new t(!0).update(e)[r]()}},g=function(t){var r,i=require("crypto"),h=require("buffer").Buffer;r=h.from&&!s.JS_MD5_NO_BUFFER_FROM?h.from:function(t){return new h(t)};return function(s){if("string"==typeof s)return i.createHash("md5").update(s,"utf8").digest("hex");if(null===s||void 0===s)throw new Error(e);return s.constructor===ArrayBuffer&&(s=new Uint8Array(s)),w(s)||A(s)||s.constructor===h?i.createHash("md5").update(r(s)).digest("hex"):t(s)}},m=function(t){return function(e,i){return new r(e,!0).update(i)[t]()}};t.prototype.update=function(t){if(this.finalized)throw new Error("finalize already called");var r=_(t);t=r[0];for(var e,i,s=r[1],h=0,n=t.length,a=this.blocks,o=this.buffer8;h>>6,o[i++]=128|63&e):e<55296||e>=57344?(o[i++]=224|e>>>12,o[i++]=128|e>>>6&63,o[i++]=128|63&e):(e=65536+((1023&e)<<10|1023&t.charCodeAt(++h)),o[i++]=240|e>>>18,o[i++]=128|e>>>12&63,o[i++]=128|e>>>6&63,o[i++]=128|63&e);else for(i=this.start;h>>2]|=e<>>2]|=(192|e>>>6)<>>2]|=(128|63&e)<=57344?(a[i>>>2]|=(224|e>>>12)<>>2]|=(128|e>>>6&63)<>>2]|=(128|63&e)<>>2]|=(240|e>>>18)<>>2]|=(128|e>>>12&63)<>>2]|=(128|e>>>6&63)<>>2]|=(128|63&e)<>>2]|=t[h]<=64?(this.start=i-64,this.hash(),this.hashed=!0):this.start=i}return this.bytes>4294967295&&(this.hBytes+=this.bytes/4294967296<<0,this.bytes=this.bytes%4294967296),this},t.prototype.finalize=function(){if(!this.finalized){this.finalized=!0;var t=this.blocks,r=this.lastByteIndex;t[r>>>2]|=y[3&r],r>=56&&(this.hashed||this.hash(),t[0]=t[16],t[16]=t[1]=t[2]=t[3]=t[4]=t[5]=t[6]=t[7]=t[8]=t[9]=t[10]=t[11]=t[12]=t[13]=t[14]=t[15]=0),t[14]=this.bytes<<3,t[15]=this.hBytes<<3|this.bytes>>>29,this.hash()}},t.prototype.hash=function(){var t,r,e,i,s,h,n=this.blocks;this.first?r=((r=((t=((t=n[0]-680876937)<<7|t>>>25)-271733879<<0)^(e=((e=(-271733879^(i=((i=(-1732584194^2004318071&t)+n[1]-117830708)<<12|i>>>20)+t<<0)&(-271733879^t))+n[2]-1126478375)<<17|e>>>15)+i<<0)&(i^t))+n[3]-1316259209)<<22|r>>>10)+e<<0:(t=this.h0,r=this.h1,e=this.h2,r=((r+=((t=((t+=((i=this.h3)^r&(e^i))+n[0]-680876936)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[1]-389564586)<<12|i>>>20)+t<<0)&(t^r))+n[2]+606105819)<<17|e>>>15)+i<<0)&(i^t))+n[3]-1044525330)<<22|r>>>10)+e<<0),r=((r+=((t=((t+=(i^r&(e^i))+n[4]-176418897)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[5]+1200080426)<<12|i>>>20)+t<<0)&(t^r))+n[6]-1473231341)<<17|e>>>15)+i<<0)&(i^t))+n[7]-45705983)<<22|r>>>10)+e<<0,r=((r+=((t=((t+=(i^r&(e^i))+n[8]+1770035416)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[9]-1958414417)<<12|i>>>20)+t<<0)&(t^r))+n[10]-42063)<<17|e>>>15)+i<<0)&(i^t))+n[11]-1990404162)<<22|r>>>10)+e<<0,r=((r+=((t=((t+=(i^r&(e^i))+n[12]+1804603682)<<7|t>>>25)+r<<0)^(e=((e+=(r^(i=((i+=(e^t&(r^e))+n[13]-40341101)<<12|i>>>20)+t<<0)&(t^r))+n[14]-1502002290)<<17|e>>>15)+i<<0)&(i^t))+n[15]+1236535329)<<22|r>>>10)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[1]-165796510)<<5|t>>>27)+r<<0)^r))+n[6]-1069501632)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[11]+643717713)<<14|e>>>18)+i<<0)^i))+n[0]-373897302)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[5]-701558691)<<5|t>>>27)+r<<0)^r))+n[10]+38016083)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[15]-660478335)<<14|e>>>18)+i<<0)^i))+n[4]-405537848)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[9]+568446438)<<5|t>>>27)+r<<0)^r))+n[14]-1019803690)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[3]-187363961)<<14|e>>>18)+i<<0)^i))+n[8]+1163531501)<<20|r>>>12)+e<<0,r=((r+=((i=((i+=(r^e&((t=((t+=(e^i&(r^e))+n[13]-1444681467)<<5|t>>>27)+r<<0)^r))+n[2]-51403784)<<9|i>>>23)+t<<0)^t&((e=((e+=(t^r&(i^t))+n[7]+1735328473)<<14|e>>>18)+i<<0)^i))+n[12]-1926607734)<<20|r>>>12)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[5]-378558)<<4|t>>>28)+r<<0))+n[8]-2022574463)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[11]+1839030562)<<16|e>>>16)+i<<0))+n[14]-35309556)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[1]-1530992060)<<4|t>>>28)+r<<0))+n[4]+1272893353)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[7]-155497632)<<16|e>>>16)+i<<0))+n[10]-1094730640)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[13]+681279174)<<4|t>>>28)+r<<0))+n[0]-358537222)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[3]-722521979)<<16|e>>>16)+i<<0))+n[6]+76029189)<<23|r>>>9)+e<<0,r=((r+=((h=(i=((i+=((s=r^e)^(t=((t+=(s^i)+n[9]-640364487)<<4|t>>>28)+r<<0))+n[12]-421815835)<<11|i>>>21)+t<<0)^t)^(e=((e+=(h^r)+n[15]+530742520)<<16|e>>>16)+i<<0))+n[2]-995338651)<<23|r>>>9)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[0]-198630844)<<6|t>>>26)+r<<0)|~e))+n[7]+1126891415)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[14]-1416354905)<<15|e>>>17)+i<<0)|~t))+n[5]-57434055)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[12]+1700485571)<<6|t>>>26)+r<<0)|~e))+n[3]-1894986606)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[10]-1051523)<<15|e>>>17)+i<<0)|~t))+n[1]-2054922799)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[8]+1873313359)<<6|t>>>26)+r<<0)|~e))+n[15]-30611744)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[6]-1560198380)<<15|e>>>17)+i<<0)|~t))+n[13]+1309151649)<<21|r>>>11)+e<<0,r=((r+=((i=((i+=(r^((t=((t+=(e^(r|~i))+n[4]-145523070)<<6|t>>>26)+r<<0)|~e))+n[11]-1120210379)<<10|i>>>22)+t<<0)^((e=((e+=(t^(i|~r))+n[2]+718787259)<<15|e>>>17)+i<<0)|~t))+n[9]-343485551)<<21|r>>>11)+e<<0,this.first?(this.h0=t+1732584193<<0,this.h1=r-271733879<<0,this.h2=e-1732584194<<0,this.h3=i+271733878<<0,this.first=!1):(this.h0=this.h0+t<<0,this.h1=this.h1+r<<0,this.h2=this.h2+e<<0,this.h3=this.h3+i<<0)},t.prototype.hex=function(){this.finalize();var t=this.h0,r=this.h1,e=this.h2,i=this.h3;return c[t>>>4&15]+c[15&t]+c[t>>>12&15]+c[t>>>8&15]+c[t>>>20&15]+c[t>>>16&15]+c[t>>>28&15]+c[t>>>24&15]+c[r>>>4&15]+c[15&r]+c[r>>>12&15]+c[r>>>8&15]+c[r>>>20&15]+c[r>>>16&15]+c[r>>>28&15]+c[r>>>24&15]+c[e>>>4&15]+c[15&e]+c[e>>>12&15]+c[e>>>8&15]+c[e>>>20&15]+c[e>>>16&15]+c[e>>>28&15]+c[e>>>24&15]+c[i>>>4&15]+c[15&i]+c[i>>>12&15]+c[i>>>8&15]+c[i>>>20&15]+c[i>>>16&15]+c[i>>>28&15]+c[i>>>24&15]},t.prototype.toString=t.prototype.hex,t.prototype.digest=function(){this.finalize();var t=this.h0,r=this.h1,e=this.h2,i=this.h3;return[255&t,t>>>8&255,t>>>16&255,t>>>24&255,255&r,r>>>8&255,r>>>16&255,r>>>24&255,255&e,e>>>8&255,e>>>16&255,e>>>24&255,255&i,i>>>8&255,i>>>16&255,i>>>24&255]},t.prototype.array=t.prototype.digest,t.prototype.arrayBuffer=function(){this.finalize();var t=new ArrayBuffer(16),r=new Uint32Array(t);return r[0]=this.h0,r[1]=this.h1,r[2]=this.h2,r[3]=this.h3,t},t.prototype.buffer=t.prototype.arrayBuffer,t.prototype.base64=function(){for(var t,r,e,i="",s=this.array(),h=0;h<15;)t=s[h++],r=s[h++],e=s[h++],i+=l[t>>>2]+l[63&(t<<4|r>>>4)]+l[63&(r<<2|e>>>6)]+l[63&e];return t=s[h],i+=l[t>>>2]+l[t<<4&63]+"=="},(r.prototype=new t).finalize=function(){if(t.prototype.finalize.call(this),this.inner){this.inner=!1;var r=this.array();t.call(this,this.sharedMemory),this.update(this.oKeyPad),this.update(r),t.prototype.finalize.call(this)}};var O=function(){var r=B("hex");n&&(r=g(r)),r.create=function(){return new t},r.update=function(t){return r.create().update(t)};for(var e=0;e 标签的浏览器(别名: esm,module) +// umd - 通用模块定义,生成的包同时支持 amd、cjs 和 iife 三种格式 +//--------------------------------------------------------------------- + +!function(o,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(o="undefined"!=typeof globalThis?globalThis:o||self).UQRCode=e();}(typeof window !== "undefined" ? window : global,(function(){function o(o){this.mode=r.MODE_8BIT_BYTE,this.data=o;}function e(o,e){this.typeNumber=o,this.errorCorrectLevel=e,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=new Array;}o.prototype={getLength:function(o){return this.data.length},write:function(o){for(var e=0;e=7&&this.setupTypeNumber(o),null==this.dataCache&&(this.dataCache=e.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,r);},setupPositionProbePattern:function(o,e){for(var r=-1;r<=7;r++)if(!(o+r<=-1||this.moduleCount<=o+r))for(var t=-1;t<=7;t++)e+t<=-1||this.moduleCount<=e+t||(this.modules[o+r][e+t]=0<=r&&r<=6&&(0==t||6==t)||0<=t&&t<=6&&(0==r||6==r)||2<=r&&r<=4&&2<=t&&t<=4);},getBestMaskPattern:function(){for(var o=0,e=0,r=0;r<8;r++){this.makeImpl(!0,r);var t=h.getLostPoint(this);(0==r||o>t)&&(o=t,e=r);}return e},createMovieClip:function(o,e,r){var t=o.createEmptyMovieClip(e,r);this.make();for(var i=0;i>r&1);this.modules[Math.floor(r/3)][r%3+this.moduleCount-8-3]=t;}for(r=0;r<18;r++){t=!o&&1==(e>>r&1);this.modules[r%3+this.moduleCount-8-3][Math.floor(r/3)]=t;}},setupTypeInfo:function(o,e){for(var r=this.errorCorrectLevel<<3|e,t=h.getBCHTypeInfo(r),i=0;i<15;i++){var n=!o&&1==(t>>i&1);i<6?this.modules[i][8]=n:i<8?this.modules[i+1][8]=n:this.modules[this.moduleCount-15+i][8]=n;}for(i=0;i<15;i++){n=!o&&1==(t>>i&1);i<8?this.modules[8][this.moduleCount-i-1]=n:i<9?this.modules[8][15-i-1+1]=n:this.modules[8][15-i-1]=n;}this.modules[this.moduleCount-8][8]=!o;},mapData:function(o,e){for(var r=-1,t=this.moduleCount-1,i=7,n=0,a=this.moduleCount-1;a>0;a-=2)for(6==a&&a--;;){for(var d=0;d<2;d++)if(null==this.modules[t][a-d]){var u=!1;n>>i&1)),h.getMask(e,t,a-d)&&(u=!u),this.modules[t][a-d]=u,-1==--i&&(n++,i=7);}if((t+=r)<0||this.moduleCount<=t){t-=r,r=-r;break}}}},e.PAD0=236,e.PAD1=17,e.createData=function(o,r,t){for(var i=v.getRSBlocks(o,r),n=new p,a=0;a8*u)throw new Error("code length overflow. ("+n.getLengthInBits()+">"+8*u+")");for(n.getLengthInBits()+4<=8*u&&n.put(0,4);n.getLengthInBits()%8!=0;)n.putBit(!1);for(;!(n.getLengthInBits()>=8*u||(n.put(e.PAD0,8),n.getLengthInBits()>=8*u));)n.put(e.PAD1,8);return e.createBytes(n,i)},e.createBytes=function(o,e){for(var r=0,t=0,i=0,n=new Array(e.length),a=new Array(e.length),d=0;d=0?c.get(m):0;}}var v=0;for(g=0;g=0;)e^=h.G15<=0;)e^=h.G18<>>=1;return e},getPatternPosition:function(o){return h.PATTERN_POSITION_TABLE[o-1]},getMask:function(o,e,r){switch(o){case i:return (e+r)%2==0;case n:return e%2==0;case a:return r%3==0;case d:return (e+r)%3==0;case u:return (Math.floor(e/2)+Math.floor(r/3))%2==0;case s:return e*r%2+e*r%3==0;case g:return (e*r%2+e*r%3)%2==0;case l:return (e*r%3+(e+r)%2)%2==0;default:throw new Error("bad maskPattern:"+o)}},getErrorCorrectPolynomial:function(o){for(var e=new f([1],0),r=0;r5&&(r+=3+n-5);}for(t=0;t=256;)o-=255;return c.EXP_TABLE[o]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},m=0;m<8;m++)c.EXP_TABLE[m]=1<t,set(o){t=Number(o);}},typeNumber:{get:()=>i,set(o){i=Number(o);}},margin:{get:()=>n,set(o){n=Number(o);}},backgroundImageWidth:{get(){return void 0===a?this.dynamicSize:this.useDynamicSize?this.dynamicSize/this.size*a:a},set(o){a=Number(o);}},backgroundImageHeight:{get(){return void 0===d?this.dynamicSize:this.useDynamicSize?this.dynamicSize/this.size*d:d},set(o){d=Number(o);}},backgroundImageX:{get(){return void 0===u?0:this.useDynamicSize?this.dynamicSize/this.size*u:u},set(o){u=Number(o);}},backgroundImageY:{get(){return void 0===s?0:this.useDynamicSize?this.dynamicSize/this.size*s:s},set(o){s=Number(o);}},backgroundPadding:{get:()=>g,set(o){g=o>1?1:o<0?0:o;}},foregroundImageWidth:{get(){return void 0===l?(this.dynamicSize-2*this.margin)/4:this.useDynamicSize?this.dynamicSize/this.size*l:l},set(o){l=Number(o);}},foregroundImageHeight:{get(){return void 0===h?(this.dynamicSize-2*this.margin)/4:this.useDynamicSize?this.dynamicSize/this.size*h:h},set(o){h=Number(o);}},foregroundImageX:{get(){return void 0===c?this.dynamicSize/2-this.foregroundImageWidth/2:this.useDynamicSize?this.dynamicSize/this.size*c:c},set(o){c=Number(o);}},foregroundImageY:{get(){return void 0===m?this.dynamicSize/2-this.foregroundImageHeight/2:this.useDynamicSize?this.dynamicSize/this.size*m:m},set(o){m=Number(o);}},foregroundImagePadding:{get(){return this.useDynamicSize?this.dynamicSize/this.size*f:f},set(o){f=Number(o);}},foregroundImageBorderRadius:{get(){return this.useDynamicSize?this.dynamicSize/this.size*v:v},set(o){v=Number(o);}},foregroundImageShadowOffsetX:{get(){return this.useDynamicSize?this.dynamicSize/this.size*p:p},set(o){p=Number(o);}},foregroundImageShadowOffsetY:{get(){return this.useDynamicSize?this.dynamicSize/this.size*y:y},set(o){y=Number(o);}},foregroundImageShadowBlur:{get(){return this.useDynamicSize?this.dynamicSize/this.size*k:k},set(o){k=Number(o);}},foregroundPadding:{get:()=>w,set(o){w=o>1?1:o<0?0:o;}},positionProbeBackgroundColor:{get(){return I||this.backgroundColor},set(o){I=o;}},positionProbeForegroundColor:{get(){return B||this.foregroundColor},set(o){B=o;}},separatorColor:{get(){return S||this.backgroundColor},set(o){S=o;}},positionAdjustBackgroundColor:{get(){return P||this.backgroundColor},set(o){P=o;}},positionAdjustForegroundColor:{get(){return E||this.foregroundColor},set(o){E=o;}},timingBackgroundColor:{get(){return L||this.backgroundColor},set(o){L=o;}},timingForegroundColor:{get(){return D||this.foregroundColor},set(o){D=o;}},typeNumberBackgroundColor:{get(){return T||this.backgroundColor},set(o){T=o;}},typeNumberForegroundColor:{get(){return A||this.foregroundColor},set(o){A=o;}},darkBlockColor:{get(){return N||this.foregroundColor},set(o){N=o;}},canvasContext:{get(){if(void 0===M)throw console.error("[uQRCode]: use drawCanvas, you need to set the canvasContext!"),new b.Error("use drawCanvas, you need to set the canvasContext!");return M},set(o){M=C(o);}}}),b.plugins.forEach((o=>o(b,this,!1))),o&&this.setOptions(o),e&&(this.canvasContext=C(e));}return f.prototype={get:function(o){return this.num[o]},getLength:function(){return this.num.length},multiply:function(o){for(var e=new Array(this.getLength()+o.getLength()-1),r=0;r>>7-o%8&1)},put:function(o,e){for(var r=0;r>>e-r-1&1));},getLengthInBits:function(){return this.length},putBit:function(o){var e=Math.floor(this.length/8);this.buffer.length<=e&&this.buffer.push(0),o&&(this.buffer[e]|=128>>>this.length%8),this.length++;}},e.errorCorrectLevel=t,b.errorCorrectLevel=e.errorCorrectLevel,b.Error=function(o){this.errMsg="[uQRCode]: "+o;},b.plugins=[],b.use=function(o){"function"==typeof o&&b.plugins.push(o);},b.prototype.loadImage=function(o){return Promise.resolve(o)},b.prototype.setOptions=function(o){var e,r,t,i,n,a,d,u,s,g,l,h,c,m,f,v,p,C,b,y,k,w,I,B,S,P,E,L,D,T,A,N,M,z,_,O,R,x,F,H,X,Y,j,W,G,K,Q,U,$,J,q,V,Z,oo,eo,ro;o&&(Object.keys(o).forEach((e=>{this[e]=o[e];})),function(o={},e={},r=!1){let t;for(var i in t=r?o:{...o},e){var n=e[i];null!=n&&(n.constructor==Object?t[i]=this.deepReplace(t[i],n):n.constructor!=String||n?t[i]=n:t[i]=t[i]);}}(this,{data:o.data||o.text,dataEncode:o.dataEncode,size:o.size,useDynamicSize:o.useDynamicSize,typeNumber:o.typeNumber,errorCorrectLevel:o.errorCorrectLevel,margin:o.margin,areaColor:o.areaColor,backgroundColor:o.backgroundColor||(null===(e=o.background)||void 0===e?void 0:e.color),backgroundImageSrc:o.backgroundImageSrc||(null===(r=o.background)||void 0===r||null===(t=r.image)||void 0===t?void 0:t.src),backgroundImageWidth:o.backgroundImageWidth||(null===(i=o.background)||void 0===i||null===(n=i.image)||void 0===n?void 0:n.width),backgroundImageHeight:o.backgroundImageHeight||(null===(a=o.background)||void 0===a||null===(d=a.image)||void 0===d?void 0:d.height),backgroundImageX:o.backgroundImageX||(null===(u=o.background)||void 0===u||null===(s=u.image)||void 0===s?void 0:s.x),backgroundImageY:o.backgroundImageY||(null===(g=o.background)||void 0===g||null===(l=g.image)||void 0===l?void 0:l.y),backgroundImageAlpha:o.backgroundImageAlpha||(null===(h=o.background)||void 0===h||null===(c=h.image)||void 0===c?void 0:c.alpha),backgroundImageBorderRadius:o.backgroundImageBorderRadius||(null===(m=o.background)||void 0===m||null===(f=m.image)||void 0===f?void 0:f.borderRadius),backgroundPadding:o.backgroundPadding,foregroundColor:o.foregroundColor||(null===(v=o.foreground)||void 0===v?void 0:v.color),foregroundImageSrc:o.foregroundImageSrc||(null===(p=o.foreground)||void 0===p||null===(C=p.image)||void 0===C?void 0:C.src),foregroundImageWidth:o.foregroundImageWidth||(null===(b=o.foreground)||void 0===b||null===(y=b.image)||void 0===y?void 0:y.width),foregroundImageHeight:o.foregroundImageHeight||(null===(k=o.foreground)||void 0===k||null===(w=k.image)||void 0===w?void 0:w.height),foregroundImageX:o.foregroundImageX||(null===(I=o.foreground)||void 0===I||null===(B=I.image)||void 0===B?void 0:B.x),foregroundImageY:o.foregroundImageY||(null===(S=o.foreground)||void 0===S||null===(P=S.image)||void 0===P?void 0:P.y),foregroundImagePadding:o.foregroundImagePadding||(null===(E=o.foreground)||void 0===E||null===(L=E.image)||void 0===L?void 0:L.padding),foregroundImageBackgroundColor:o.foregroundImageBackgroundColor||(null===(D=o.foreground)||void 0===D||null===(T=D.image)||void 0===T?void 0:T.backgroundColor),foregroundImageBorderRadius:o.foregroundImageBorderRadius||(null===(A=o.foreground)||void 0===A||null===(N=A.image)||void 0===N?void 0:N.borderRadius),foregroundImageShadowOffsetX:o.foregroundImageShadowOffsetX||(null===(M=o.foreground)||void 0===M||null===(z=M.image)||void 0===z?void 0:z.shadowOffsetX),foregroundImageShadowOffsetY:o.foregroundImageShadowOffsetY||(null===(_=o.foreground)||void 0===_||null===(O=_.image)||void 0===O?void 0:O.shadowOffsetY),foregroundImageShadowBlur:o.foregroundImageShadowBlur||(null===(R=o.foreground)||void 0===R||null===(x=R.image)||void 0===x?void 0:x.shadowBlur),foregroundImageShadowColor:o.foregroundImageShadowColor||(null===(F=o.foreground)||void 0===F||null===(H=F.image)||void 0===H?void 0:H.shadowColor),foregroundPadding:o.foregroundPadding,positionProbeBackgroundColor:o.positionProbeBackgroundColor||(null===(X=o.positionProbe)||void 0===X?void 0:X.backgroundColor)||(null===(Y=o.positionDetection)||void 0===Y?void 0:Y.backgroundColor),positionProbeForegroundColor:o.positionProbeForegroundColor||(null===(j=o.positionProbe)||void 0===j?void 0:j.foregroundColor)||(null===(W=o.positionDetection)||void 0===W?void 0:W.foregroundColor),separatorColor:o.separatorColor||(null===(G=o.separator)||void 0===G?void 0:G.color),positionAdjustBackgroundColor:o.positionAdjustBackgroundColor||(null===(K=o.positionAdjust)||void 0===K?void 0:K.backgroundColor)||(null===(Q=o.alignment)||void 0===Q?void 0:Q.backgroundColor),positionAdjustForegroundColor:o.positionAdjustForegroundColor||(null===(U=o.positionAdjust)||void 0===U?void 0:U.foregroundColor)||(null===($=o.alignment)||void 0===$?void 0:$.foregroundColor),timingBackgroundColor:o.timingBackgroundColor||(null===(J=o.timing)||void 0===J?void 0:J.backgroundColor),timingForegroundColor:o.timingForegroundColor||(null===(q=o.timing)||void 0===q?void 0:q.foregroundColor),typeNumberBackgroundColor:o.typeNumberBackgroundColor||(null===(V=o.typeNumber)||void 0===V?void 0:V.backgroundColor)||(null===(Z=o.versionInformation)||void 0===Z?void 0:Z.backgroundColor),typeNumberForegroundColor:o.typeNumberForegroundColor||(null===(oo=o.typeNumber)||void 0===oo?void 0:oo.foregroundColor)||(null===(eo=o.versionInformation)||void 0===eo?void 0:eo.foregroundColor),darkBlockColor:o.darkBlockColor||(null===(ro=o.darkBlock)||void 0===ro?void 0:ro.color)},!0));},b.prototype.make=function(){let{foregroundColor:o,backgroundColor:r,typeNumber:t,errorCorrectLevel:i,data:n,dataEncode:a,size:d,margin:u,useDynamicSize:s}=this;if(o===r)throw console.error("[uQRCode]: foregroundColor and backgroundColor cannot be the same!"),new b.Error("foregroundColor and backgroundColor cannot be the same!");a&&(n=function(o){o=o.toString();for(var e,r="",t=0;t=1&&e<=127?r+=o.charAt(t):e>2047?(r+=String.fromCharCode(224|e>>12&15),r+=String.fromCharCode(128|e>>6&63),r+=String.fromCharCode(128|e>>0&63)):(r+=String.fromCharCode(192|e>>6&31),r+=String.fromCharCode(128|e>>0&63));return r}(n));var g=new e(t,i);g.addData(n),g.make(),this.base=g,this.typeNumber=g.typeNumber,this.modules=g.modules,this.moduleCount=g.moduleCount,this.dynamicSize=s?Math.ceil((d-2*u)/g.moduleCount)*g.moduleCount+2*u:d,function(o){let{dynamicSize:e,margin:r,backgroundColor:t,backgroundPadding:i,foregroundColor:n,foregroundPadding:a,modules:d,moduleCount:u}=o;var s=(e-2*r)/u,g=s,l=0;i>0&&(g-=2*(l=g*i/2));var h=s,c=0;a>0&&(h-=2*(c=h*a/2));for(var m=0;m{var r=e[o[0]][o[1]],a=e[o[0]+n][o[1]],d=e[o[0]][o[1]+n];d.type.push("positionProbe"),a.type.push("positionProbe"),r.type.push("positionProbe"),r.color=1==o[2]?i:t,a.color=1==o[2]?i:t,d.color=1==o[2]?i:t;}));}(this),function(o){let{modules:e,moduleCount:r,separatorColor:t}=o;[[7,0],[7,1],[7,2],[7,3],[7,4],[7,5],[7,6],[7,7],[0,7],[1,7],[2,7],[3,7],[4,7],[5,7],[6,7]].forEach((o=>{var i=e[o[0]][o[1]],n=e[r-o[0]-1][o[1]],a=e[o[0]][r-o[1]-1];a.type.push("separator"),n.type.push("separator"),i.type.push("separator"),i.color=t,n.color=t,a.color=t;}));}(this),function(o){let{typeNumber:e,modules:r,moduleCount:t,foregroundColor:i,backgroundColor:n,positionAdjustForegroundColor:a,positionAdjustBackgroundColor:d,timingForegroundColor:u,timingBackgroundColor:s}=o;var g=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]][e-1];if(g)for(var l=[[-2,-2,1],[-1,-2,1],[0,-2,1],[1,-2,1],[2,-2,1],[-2,-1,1],[-1,-1,0],[0,-1,0],[1,-1,0],[2,-1,1],[-2,0,1],[-1,0,0],[0,0,1],[1,0,0],[2,0,1],[-2,1,1],[-1,1,0],[0,1,0],[1,1,0],[2,1,1],[-2,2,1],[-1,2,1],[0,2,1],[1,2,1],[2,2,1]],h=g.length,c=0;ct-9-1&&v<9||v>t-9-1&&f<9||l.forEach((o=>{var e=r[f+o[0]][v+o[1]];e.type.push("positionAdjust"),e.type.includes("timing")?1==o[2]?e.color=a==i?u:a:e.color=a==i&&d==n?s:d:e.color=1==o[2]?a:d;}));}}(this),function(o){let{modules:e,moduleCount:r,timingForegroundColor:t,timingBackgroundColor:i}=o;for(var n=r-16,a=0;a{var t=r[o[0]][o[1]];t.type.push("typeNumber"),t.color="1"==d[e]?n:i;}));}(this),this.isMaked=!0,this.drawModules=[];},b.prototype.getDrawModules=function(){if(this.drawModules&&this.drawModules.length>0)return this.drawModules;let o=this.drawModules=[],{modules:e,moduleCount:r,dynamicSize:t,areaColor:i,backgroundImageSrc:n,backgroundImageX:a,backgroundImageY:d,backgroundImageWidth:u,backgroundImageHeight:s,backgroundImageAlpha:g,backgroundImageBorderRadius:l,foregroundImageSrc:h,foregroundImageX:c,foregroundImageY:m,foregroundImageWidth:f,foregroundImageHeight:v,foregroundImagePadding:p,foregroundImageBackgroundColor:C,foregroundImageBorderRadius:b,foregroundImageShadowOffsetX:y,foregroundImageShadowOffsetY:k,foregroundImageShadowBlur:w,foregroundImageShadowColor:I}=this;i&&o.push({name:"area",type:"area",color:i,x:0,y:0,width:t,height:t}),n&&o.push({name:"backgroundImage",type:"image",imageSrc:n,mappingName:"backgroundImageSrc",x:a,y:d,width:u,height:s,alpha:g,borderRadius:l});for(var B=0;Bo||0>e||o>=r||e>=r)&&this.modules[o][e].isBlack},b.prototype.drawCanvas=function(o){let{isMaked:e,canvasContext:r,useDynamicSize:t,dynamicSize:i,foregroundColor:n,foregroundPadding:a,backgroundColor:d,backgroundPadding:u,drawReserve:s,margin:g}=this;if(!e)return console.error("[uQRCode]: please execute the make method first!"),Promise.reject(new b.Error("please execute the make method first!"));let l=this.getDrawModules(),h=async(e,t)=>{try{r.draw(o);for(var i=0;i0&&(r.beginPath(),r.moveTo(a+c,d),r.arcTo(a+u,d,a+u,d+g,c),r.arcTo(a+u,d+g,a,d+g,c),r.arcTo(a,d+g,a,d,c),r.arcTo(a,d,a+u,d,c),r.closePath(),r.setStrokeStyle("rgba(0,0,0,0)"),r.stroke(),r.clip());try{var h=await this.loadImage(n.imageSrc);r.drawImage(h,a,d,u,g);}catch(o){throw console.error(`[uQRCode]: ${n.mappingName} invalid!`),new b.Error(`${n.mappingName} invalid!`)}}else if("foregroundImage"===n.name){a=Math.round(n.x),d=Math.round(n.y),u=Math.round(n.width),g=Math.round(n.height);var c,m=Math.round(n.padding);u<2*(c=Math.round(n.borderRadius))&&(c=u/2),g<2*c&&(c=g/2);var f=a-m,v=d-m,p=u+2*m,C=g+2*m,y=Math.round(p/u*c);p<2*y&&(y=p/2),C<2*y&&(y=C/2),r.save(),r.setShadow(n.shadowOffsetX,n.shadowOffsetY,n.shadowBlur,n.shadowColor),y>0?(r.beginPath(),r.moveTo(f+y,v),r.arcTo(f+p,v,f+p,v+C,y),r.arcTo(f+p,v+C,f,v+C,y),r.arcTo(f,v+C,f,v,y),r.arcTo(f,v,f+p,v,y),r.closePath(),r.setFillStyle(n.backgroundColor),r.fill()):(r.setFillStyle(n.backgroundColor),r.fillRect(f,v,p,C)),r.restore(),r.save(),y>0?(r.beginPath(),r.moveTo(f+y,v),r.arcTo(f+p,v,f+p,v+C,y),r.arcTo(f+p,v+C,f,v+C,y),r.arcTo(f,v+C,f,v,y),r.arcTo(f,v,f+p,v,y),r.closePath(),r.setFillStyle(m>0?n.backgroundColor:"rgba(0,0,0,0)"),r.fill()):(r.setFillStyle(m>0?n.backgroundColor:"rgba(0,0,0,0)"),r.fillRect(f,v,p,C)),r.restore(),c>0&&(r.beginPath(),r.moveTo(a+c,d),r.arcTo(a+u,d,a+u,d+g,c),r.arcTo(a+u,d+g,a,d+g,c),r.arcTo(a,d+g,a,d,c),r.arcTo(a,d,a+u,d,c),r.closePath(),r.setStrokeStyle("rgba(0,0,0,0)"),r.stroke(),r.clip());try{h=await this.loadImage(n.imageSrc);r.drawImage(h,a,d,u,g);}catch(o){throw console.error(`[uQRCode]: ${n.mappingName} invalid!`),new b.Error(`${n.mappingName} invalid!`)}}}s&&r.draw(!0),r.restore();}r.draw(!0),setTimeout(e,150);}catch(o){t(o);}};return new Promise(((o,e)=>{h(o,e);}))},b.prototype.draw=function(o){return this.drawCanvas(o)},b.prototype.register=function(o){o&&o(b,this,!0);},b})); \ No newline at end of file diff --git a/mp-sc-frontend/src/main.ts b/mp-sc-frontend/src/main.ts new file mode 100644 index 0000000..8ec1058 --- /dev/null +++ b/mp-sc-frontend/src/main.ts @@ -0,0 +1,19 @@ +import { createSSRApp } from 'vue' +import App from './App.vue' +import { requestInterceptor } from './http/interceptor' +import { routeInterceptor } from './router/interceptor' + +import store from './store' +import '@/style/index.scss' +import 'virtual:uno.css' + +export function createApp() { + const app = createSSRApp(App) + app.use(store) + app.use(routeInterceptor) + app.use(requestInterceptor) + + return { + app, + } +} diff --git a/mp-sc-frontend/src/pages/auth/README.md b/mp-sc-frontend/src/pages/auth/README.md new file mode 100644 index 0000000..c851a76 --- /dev/null +++ b/mp-sc-frontend/src/pages/auth/README.md @@ -0,0 +1,20 @@ +# 登录页 +需要输入账号、密码/验证码的登录页。 + +## 适用性 + +本页面主要用于 `h5` 和 `APP`。 + +小程序通常有平台的登录方式 `uni.login` 通常用不到登录页,所以不适用于 `小程序`。(即默认情况下,小程序环境是不会走登录拦截逻辑的。) + +但是如果您的小程序也需要现实的 `登录页` 那也是可以使用的。 + +在 `src/router/config.ts` 中有一个变量 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在小程序中使用 `H5的登录页`。 + +更多信息请看 `src/router` 文件夹的内容。 + +## 登录跳转 + +目前登录的跳转逻辑主要在 `src/router/interceptor.ts` 和 `src/pages/login/login.vue` 里面,默认会在登录后自动重定向到来源/配置的页面。 + +如果与您的业务不符,您可以自行修改。 diff --git a/mp-sc-frontend/src/pages/auth/login.vue b/mp-sc-frontend/src/pages/auth/login.vue new file mode 100644 index 0000000..aad1360 --- /dev/null +++ b/mp-sc-frontend/src/pages/auth/login.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/mp-sc-frontend/src/pages/auth/register.vue b/mp-sc-frontend/src/pages/auth/register.vue new file mode 100644 index 0000000..e9809f0 --- /dev/null +++ b/mp-sc-frontend/src/pages/auth/register.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/mp-sc-frontend/src/pages/index/index.vue b/mp-sc-frontend/src/pages/index/index.vue new file mode 100644 index 0000000..a896de1 --- /dev/null +++ b/mp-sc-frontend/src/pages/index/index.vue @@ -0,0 +1,261 @@ + + + + + \ No newline at end of file diff --git a/mp-sc-frontend/src/pages/me/me.vue b/mp-sc-frontend/src/pages/me/me.vue new file mode 100644 index 0000000..f7f7b3e --- /dev/null +++ b/mp-sc-frontend/src/pages/me/me.vue @@ -0,0 +1,322 @@ + + + + + + diff --git a/mp-sc-frontend/src/pages/public/invitation.vue b/mp-sc-frontend/src/pages/public/invitation.vue new file mode 100644 index 0000000..c75acb0 --- /dev/null +++ b/mp-sc-frontend/src/pages/public/invitation.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/mp-sc-frontend/src/pages/public/webview.vue b/mp-sc-frontend/src/pages/public/webview.vue new file mode 100644 index 0000000..254c412 --- /dev/null +++ b/mp-sc-frontend/src/pages/public/webview.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/mp-sc-frontend/src/router/README.md b/mp-sc-frontend/src/router/README.md new file mode 100644 index 0000000..60e3084 --- /dev/null +++ b/mp-sc-frontend/src/router/README.md @@ -0,0 +1,55 @@ +# 登录 说明 + +## 登录 2种策略 +- 默认无需登录策略: DEFAULT_NO_NEED_LOGIN +- 默认需要登录策略: DEFAULT_NEED_LOGIN + +### 默认无需登录策略: DEFAULT_NO_NEED_LOGIN +进入任何页面都不需要登录,只有进入到黑名单中的页面/或者页面中某些动作需要登录,才需要登录。 + +比如大部分2C的应用,美团、今日头条、抖音等,都可以直接浏览,只有点赞、评论、分享等操作或者去特殊页面(比如个人中心),才需要登录。 + +### 默认需要登录策略: DEFAULT_NEED_LOGIN + +进入任何页面都需要登录,只有进入到白名单中的页面,才不需要登录。默认进入应用需要先去登录页。 + +比如大部分2B和后台管理类的应用,比如企业微信、钉钉、飞书、内部报表系统、CMS系统等,都需要登录,只有登录后,才能使用。 + +### EXCLUDE_LOGIN_PATH_LIST +`EXCLUDE_LOGIN_PATH_LIST` 表示排除的路由列表。 + +在 `默认无需登录策略: DEFAULT_NO_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才需要登录,相当于黑名单。 + +在 `默认需要登录策略: DEFAULT_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才不需要登录,相当于白名单。 + +### excludeLoginPath +definePage 中可以通过 `excludeLoginPath` 来配置路由是否需要登录。(类似过去的 needLogin 的功能) + +```ts +definePage({ + style: { + navigationBarTitleText: '关于', + }, + // 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹 + excludeLoginPath: true, + // 角色授权(可选):如果需要根据角色授权,就配置这个 + roleAuth: { + field: 'role', + value: 'admin', + redirect: '/pages/auth/403', + }, +}) +``` + +## 登录注册页路由 + +登录页 `login.vue` 对应路由是 `/pages/login/login`. +注册页 `register.vue` 对应路由是 `/pages/login/register`. + +## 登录注册页适用性 + +登录注册页主要适用于 `h5` 和 `App`,默认不适用于 `小程序`,因为 `小程序` 通常会使用平台提供的快捷登录。 + +特殊情况例外,如业务需要跨平台复用登录注册页时,也可以用在 `小程序` 上,所以主要还是看业务需求。 + +通过一个参数 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在 `小程序` 中使用 `H5登录页` 的登录逻辑。 diff --git a/mp-sc-frontend/src/router/config.ts b/mp-sc-frontend/src/router/config.ts new file mode 100644 index 0000000..493dafc --- /dev/null +++ b/mp-sc-frontend/src/router/config.ts @@ -0,0 +1,28 @@ +import { getAllPages } from '@/utils' + +export const LOGIN_STRATEGY_MAP = { + DEFAULT_NO_NEED_LOGIN: 0, // 黑名单策略,默认可以进入APP + DEFAULT_NEED_LOGIN: 1, // 白名单策略,默认不可以进入APP,需要强制登录 +} +// 使用白名单策略:默认需要登录才能访问 +export const LOGIN_STRATEGY = LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN +export const isNeedLoginMode = LOGIN_STRATEGY === LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN + +export const LOGIN_PAGE = '/pages/auth/login' +export const REGISTER_PAGE = '/pages/auth/register' + +export const LOGIN_PAGE_LIST = [LOGIN_PAGE, REGISTER_PAGE] + +// 在 definePage 里面配置了 excludeLoginPath 的页面,功能与 EXCLUDE_LOGIN_PATH_LIST 相同 +export const excludeLoginPathList = getAllPages('excludeLoginPath').map(page => page.path) + +// 白名单:这些页面不需要登录即可访问 +export const EXCLUDE_LOGIN_PATH_LIST = [ + '/pages/auth/login', + '/pages/auth/register', + '/pages/public/invitation', + ...excludeLoginPathList, +] + +// 在小程序里面是否使用H5的登录页,默认为 false +export const LOGIN_PAGE_ENABLE_IN_MP = false \ No newline at end of file diff --git a/mp-sc-frontend/src/router/interceptor.ts b/mp-sc-frontend/src/router/interceptor.ts new file mode 100644 index 0000000..9c7a9ac --- /dev/null +++ b/mp-sc-frontend/src/router/interceptor.ts @@ -0,0 +1,145 @@ +import { isMp } from '@uni-helper/uni-env' +/** + * by 菲鸽 on 2025-08-19 + * 路由拦截,通常也是登录拦截 + * 黑、白名单的配置,请看 config.ts 文件, EXCLUDE_LOGIN_PATH_LIST + */ +import { useTokenStore } from '@/store/token' +import { isPageTabbar, tabbarStore } from '@/tabbar/store' +import { getAllPages, getLastPage, HOME_PAGE, parseUrlToObj } from '@/utils/index' +import { EXCLUDE_LOGIN_PATH_LIST, isNeedLoginMode, LOGIN_PAGE, LOGIN_PAGE_ENABLE_IN_MP } from './config' + +export const FG_LOG_ENABLE = false + +export function judgeIsExcludePath(path: string) { + const isDev = import.meta.env.DEV + if (!isDev) { + return EXCLUDE_LOGIN_PATH_LIST.includes(path) + } + const allExcludeLoginPages = getAllPages('excludeLoginPath') // dev 环境下,需要每次都重新获取,否则新配置就不会生效 + return EXCLUDE_LOGIN_PATH_LIST.includes(path) || (isDev && allExcludeLoginPages.some(page => page.path === path)) +} + +export const navigateToInterceptor = { + // 注意,这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同 + // 增加对相对路径的处理,BY 网友 @ideal + invoke({ url, query }: { url: string, query?: Record }) { + if (url === undefined) { + return + } + let { path, query: _query } = parseUrlToObj(url) + + FG_LOG_ENABLE && console.log('\n\n路由拦截器:-------------------------------------') + FG_LOG_ENABLE && console.log('路由拦截器 1: url->', url, ', query ->', query) + const myQuery = { ..._query, ...query } + // /pages/route-interceptor/index?name=feige&age=30 + FG_LOG_ENABLE && console.log('路由拦截器 2: path->', path, ', _query ->', _query) + FG_LOG_ENABLE && console.log('路由拦截器 3: myQuery ->', myQuery) + + // 处理相对路径 + if (!path.startsWith('/')) { + const currentPath = getLastPage()?.route || '' + const normalizedCurrentPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}` + const baseDir = normalizedCurrentPath.substring(0, normalizedCurrentPath.lastIndexOf('/')) + path = `${baseDir}/${path}` + } + + // // 处理路由不存在的情况 + // if (path !== '/' && !getAllPages().some(page => page.path === path)) { + // console.warn('路由不存在:', path) + // return false // 明确表示阻止原路由继续执行 + // } + + // // 插件页面 + // if (url.startsWith('plugin://')) { + // FG_LOG_ENABLE && console.log('路由拦截器 4: plugin:// 路径 ==>', url) + // path = url + // } + + // 处理直接进入路由非首页时,tabbarIndex 不正确的问题 + tabbarStore.setAutoCurIdx(path) + + // 小程序里面使用平台自带的登录,则不走下面的逻辑 + if (isMp && !LOGIN_PAGE_ENABLE_IN_MP) { + return true // 明确表示允许路由继续执行 + } + + const tokenStore = useTokenStore() + FG_LOG_ENABLE && console.log('tokenStore.hasLogin:', tokenStore.hasLogin) + + // 不管黑白名单,登录了就直接去吧(但是当前不能是登录页) + if (tokenStore.hasLogin) { + if (path !== LOGIN_PAGE) { + return true // 明确表示允许路由继续执行 + } + else { + console.log('已经登录,但是还在登录页', myQuery.redirect) + const url = myQuery.redirect || HOME_PAGE + if (isPageTabbar(url)) { + uni.switchTab({ url }) + } + else { + uni.navigateTo({ url }) + } + return false // 明确表示阻止原路由继续执行 + } + } + let fullPath = path + + if (Object.keys(myQuery).length) { + fullPath += `?${Object.keys(myQuery).map(key => `${key}=${myQuery[key]}`).join('&')}` + } + const redirectUrl = `${LOGIN_PAGE}?redirect=${encodeURIComponent(fullPath)}` + + // #region 1/2 默认需要登录的情况(白名单策略) --------------------------- + if (isNeedLoginMode) { + // 需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示白名单,可以直接通过 + if (judgeIsExcludePath(path)) { + return true // 明确表示允许路由继续执行 + } + // 否则需要重定向到登录页 + else { + if (path === LOGIN_PAGE) { + return true // 明确表示允许路由继续执行 + } + FG_LOG_ENABLE && console.log('1 isNeedLogin(白名单策略) redirectUrl:', redirectUrl) + uni.navigateTo({ url: redirectUrl }) + return false // 明确表示阻止原路由继续执行 + } + } + // #endregion 1/2 默认需要登录的情况(白名单策略) --------------------------- + + // #region 2/2 默认不需要登录的情况(黑名单策略) --------------------------- + else { + // 不需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示黑名单,需要重定向到登录页 + if (judgeIsExcludePath(path)) { + FG_LOG_ENABLE && console.log('2 isNeedLogin(黑名单策略) redirectUrl:', redirectUrl) + uni.navigateTo({ url: redirectUrl }) + return false // 修改为false,阻止原路由继续执行 + } + return true // 明确表示允许路由继续执行 + } + // #endregion 2/2 默认不需要登录的情况(黑名单策略) --------------------------- + }, +} + +// 针对 chooseLocation 的特殊处理 +export const chooseLocationInterceptor = { + invoke(options: any) { + // 直接放行 chooseLocation 调用 + FG_LOG_ENABLE && console.log('chooseLocation 调用,直接放行:', options) + return true + }, +} + +export const routeInterceptor = { + install() { + uni.addInterceptor('navigateTo', navigateToInterceptor) + uni.addInterceptor('reLaunch', navigateToInterceptor) + uni.addInterceptor('redirectTo', navigateToInterceptor) + uni.addInterceptor('switchTab', navigateToInterceptor) + + // 添加 chooseLocation 的拦截器,确保直接放行 + uni.addInterceptor('chooseLocation', chooseLocationInterceptor) + }, +} diff --git a/mp-sc-frontend/src/router/permission.ts b/mp-sc-frontend/src/router/permission.ts new file mode 100644 index 0000000..38fa06f --- /dev/null +++ b/mp-sc-frontend/src/router/permission.ts @@ -0,0 +1,11 @@ +import { tabbarStore } from '@/tabbar/store' + +export const permission = { + install(router) { + router.beforeEach((to, from, next) => { + const path = to.path + tabbarStore.setAutoCurIdx(path) + next() + }) + }, +} diff --git a/mp-sc-frontend/src/service/index.ts b/mp-sc-frontend/src/service/index.ts new file mode 100644 index 0000000..3bfbb5b --- /dev/null +++ b/mp-sc-frontend/src/service/index.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +// @ts-ignore +export * from './types'; + +export * from './listAll'; +export * from './info'; diff --git a/mp-sc-frontend/src/service/info.ts b/mp-sc-frontend/src/service/info.ts new file mode 100644 index 0000000..fe09da5 --- /dev/null +++ b/mp-sc-frontend/src/service/info.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +// @ts-ignore +import request from '@/http/vue-query'; +import { CustomRequestOptions_ } from '@/http/types'; + +import * as API from './types'; + +/** 用户信息 GET /user/info */ +export function infoUsingGet({ options }: { options?: CustomRequestOptions_ }) { + return request('/user/info', { + method: 'GET', + ...(options || {}), + }); +} diff --git a/mp-sc-frontend/src/service/listAll.ts b/mp-sc-frontend/src/service/listAll.ts new file mode 100644 index 0000000..bc1c683 --- /dev/null +++ b/mp-sc-frontend/src/service/listAll.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +// @ts-ignore +import request from '@/http/vue-query'; +import { CustomRequestOptions_ } from '@/http/types'; + +import * as API from './types'; + +/** 用户列表 GET /user/listAll */ +export function listAllUsingGet({ + options, +}: { + options?: CustomRequestOptions_; +}) { + return request('/user/listAll', { + method: 'GET', + ...(options || {}), + }); +} diff --git a/mp-sc-frontend/src/service/types.ts b/mp-sc-frontend/src/service/types.ts new file mode 100644 index 0000000..4e46b61 --- /dev/null +++ b/mp-sc-frontend/src/service/types.ts @@ -0,0 +1,29 @@ +/* eslint-disable */ +// @ts-ignore + +export type InfoUsingGetResponse = { + code: number; + msg: string; + data: UserItem; +}; + +export type InfoUsingGetResponses = { + 200: InfoUsingGetResponse; +}; + +export type ListAllUsingGetResponse = { + code: number; + msg: string; + data: UserItem[]; +}; + +export type ListAllUsingGetResponses = { + 200: ListAllUsingGetResponse; +}; + +export type UserItem = { + userId: number; + username: string; + nickname: string; + avatar: string; +}; diff --git a/mp-sc-frontend/src/static/app/icons/1024x1024.png b/mp-sc-frontend/src/static/app/icons/1024x1024.png new file mode 100644 index 0000000..08dbd5f Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/1024x1024.png differ diff --git a/mp-sc-frontend/src/static/app/icons/120x120.png b/mp-sc-frontend/src/static/app/icons/120x120.png new file mode 100644 index 0000000..718ca79 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/120x120.png differ diff --git a/mp-sc-frontend/src/static/app/icons/144x144.png b/mp-sc-frontend/src/static/app/icons/144x144.png new file mode 100644 index 0000000..f78346b Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/144x144.png differ diff --git a/mp-sc-frontend/src/static/app/icons/152x152.png b/mp-sc-frontend/src/static/app/icons/152x152.png new file mode 100644 index 0000000..f979721 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/152x152.png differ diff --git a/mp-sc-frontend/src/static/app/icons/167x167.png b/mp-sc-frontend/src/static/app/icons/167x167.png new file mode 100644 index 0000000..d0aef20 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/167x167.png differ diff --git a/mp-sc-frontend/src/static/app/icons/180x180.png b/mp-sc-frontend/src/static/app/icons/180x180.png new file mode 100644 index 0000000..24bd062 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/180x180.png differ diff --git a/mp-sc-frontend/src/static/app/icons/192x192.png b/mp-sc-frontend/src/static/app/icons/192x192.png new file mode 100644 index 0000000..a8ea1a2 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/192x192.png differ diff --git a/mp-sc-frontend/src/static/app/icons/20x20.png b/mp-sc-frontend/src/static/app/icons/20x20.png new file mode 100644 index 0000000..0abed04 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/20x20.png differ diff --git a/mp-sc-frontend/src/static/app/icons/29x29.png b/mp-sc-frontend/src/static/app/icons/29x29.png new file mode 100644 index 0000000..a20d373 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/29x29.png differ diff --git a/mp-sc-frontend/src/static/app/icons/40x40.png b/mp-sc-frontend/src/static/app/icons/40x40.png new file mode 100644 index 0000000..2b41be6 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/40x40.png differ diff --git a/mp-sc-frontend/src/static/app/icons/58x58.png b/mp-sc-frontend/src/static/app/icons/58x58.png new file mode 100644 index 0000000..8e18b42 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/58x58.png differ diff --git a/mp-sc-frontend/src/static/app/icons/60x60.png b/mp-sc-frontend/src/static/app/icons/60x60.png new file mode 100644 index 0000000..167826b Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/60x60.png differ diff --git a/mp-sc-frontend/src/static/app/icons/72x72.png b/mp-sc-frontend/src/static/app/icons/72x72.png new file mode 100644 index 0000000..ddb91e3 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/72x72.png differ diff --git a/mp-sc-frontend/src/static/app/icons/76x76.png b/mp-sc-frontend/src/static/app/icons/76x76.png new file mode 100644 index 0000000..0d9d28e Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/76x76.png differ diff --git a/mp-sc-frontend/src/static/app/icons/80x80.png b/mp-sc-frontend/src/static/app/icons/80x80.png new file mode 100644 index 0000000..1877042 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/80x80.png differ diff --git a/mp-sc-frontend/src/static/app/icons/87x87.png b/mp-sc-frontend/src/static/app/icons/87x87.png new file mode 100644 index 0000000..251fb24 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/87x87.png differ diff --git a/mp-sc-frontend/src/static/app/icons/96x96.png b/mp-sc-frontend/src/static/app/icons/96x96.png new file mode 100644 index 0000000..eccf396 Binary files /dev/null and b/mp-sc-frontend/src/static/app/icons/96x96.png differ diff --git a/mp-sc-frontend/src/static/fonts/iconfont.ttf b/mp-sc-frontend/src/static/fonts/iconfont.ttf new file mode 100644 index 0000000..9f9ba7a Binary files /dev/null and b/mp-sc-frontend/src/static/fonts/iconfont.ttf differ diff --git a/mp-sc-frontend/src/static/images/avatar.jpg b/mp-sc-frontend/src/static/images/avatar.jpg new file mode 100644 index 0000000..2010a70 Binary files /dev/null and b/mp-sc-frontend/src/static/images/avatar.jpg differ diff --git a/mp-sc-frontend/src/static/images/default-avatar.png b/mp-sc-frontend/src/static/images/default-avatar.png new file mode 100644 index 0000000..4eb5879 Binary files /dev/null and b/mp-sc-frontend/src/static/images/default-avatar.png differ diff --git a/mp-sc-frontend/src/static/login/login-bg.png b/mp-sc-frontend/src/static/login/login-bg.png new file mode 100644 index 0000000..0808076 Binary files /dev/null and b/mp-sc-frontend/src/static/login/login-bg.png differ diff --git a/mp-sc-frontend/src/static/logo.svg b/mp-sc-frontend/src/static/logo.svg new file mode 100644 index 0000000..eaee669 --- /dev/null +++ b/mp-sc-frontend/src/static/logo.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/mp-sc-frontend/src/static/my-icons/copyright.svg b/mp-sc-frontend/src/static/my-icons/copyright.svg new file mode 100644 index 0000000..8e69a8c --- /dev/null +++ b/mp-sc-frontend/src/static/my-icons/copyright.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mp-sc-frontend/src/static/tabbar/example.png b/mp-sc-frontend/src/static/tabbar/example.png new file mode 100644 index 0000000..fd1e942 Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/example.png differ diff --git a/mp-sc-frontend/src/static/tabbar/exampleHL.png b/mp-sc-frontend/src/static/tabbar/exampleHL.png new file mode 100644 index 0000000..7501011 Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/exampleHL.png differ diff --git a/mp-sc-frontend/src/static/tabbar/home.png b/mp-sc-frontend/src/static/tabbar/home.png new file mode 100644 index 0000000..8f82e21 Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/home.png differ diff --git a/mp-sc-frontend/src/static/tabbar/homeHL.png b/mp-sc-frontend/src/static/tabbar/homeHL.png new file mode 100644 index 0000000..26d3761 Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/homeHL.png differ diff --git a/mp-sc-frontend/src/static/tabbar/organization.png b/mp-sc-frontend/src/static/tabbar/organization.png new file mode 100644 index 0000000..8f82e21 Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/organization.png differ diff --git a/mp-sc-frontend/src/static/tabbar/organizationHL.png b/mp-sc-frontend/src/static/tabbar/organizationHL.png new file mode 100644 index 0000000..26d3761 Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/organizationHL.png differ diff --git a/mp-sc-frontend/src/static/tabbar/personal.png b/mp-sc-frontend/src/static/tabbar/personal.png new file mode 100644 index 0000000..0a569a2 Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/personal.png differ diff --git a/mp-sc-frontend/src/static/tabbar/personalHL.png b/mp-sc-frontend/src/static/tabbar/personalHL.png new file mode 100644 index 0000000..8c3e66e Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/personalHL.png differ diff --git a/mp-sc-frontend/src/static/tabbar/scan.png b/mp-sc-frontend/src/static/tabbar/scan.png new file mode 100644 index 0000000..f0f60c2 Binary files /dev/null and b/mp-sc-frontend/src/static/tabbar/scan.png differ diff --git a/mp-sc-frontend/src/store/index.ts b/mp-sc-frontend/src/store/index.ts new file mode 100644 index 0000000..fe2ead1 --- /dev/null +++ b/mp-sc-frontend/src/store/index.ts @@ -0,0 +1,20 @@ +import { createPinia, setActivePinia } from 'pinia' +import { createPersistedState } from 'pinia-plugin-persistedstate' // 数据持久化 + +const store = createPinia() +store.use( + createPersistedState({ + storage: { + getItem: uni.getStorageSync, + setItem: uni.setStorageSync, + }, + }), +) +// 立即激活 Pinia 实例, 这样即使在 app.use(store)之前调用 store 也能正常工作 (解决APP端白屏问题) +setActivePinia(store) + +export default store + +// 模块统一导出 +export * from './token' +export * from './user' diff --git a/mp-sc-frontend/src/store/token.ts b/mp-sc-frontend/src/store/token.ts new file mode 100644 index 0000000..adf599b --- /dev/null +++ b/mp-sc-frontend/src/store/token.ts @@ -0,0 +1,108 @@ +import type { ILoginRes } from '@/api/types/appointment' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { + loginByCode, +} from '@/api/appointment' +import { getWxCode } from '@/api/login' +import { useUserStore } from './user' + +export const useTokenStore = defineStore( + 'token', + () => { + const token = ref('') + const tokenExpireTime = ref(0) + const nowTime = ref(Date.now()) + + const updateNowTime = () => { + nowTime.value = Date.now() + return useTokenStore() + } + + const setToken = (val: string, expiresIn: number = 86400) => { + token.value = val + tokenExpireTime.value = Date.now() + expiresIn * 1000 + } + + const isTokenExpired = computed(() => { + if (!token.value) return true + return nowTime.value >= tokenExpireTime.value + }) + + const hasLogin = computed(() => { + return !!token.value && !isTokenExpired.value + }) + + const validToken = computed(() => { + return hasLogin.value ? token.value : '' + }) + + /** 登录成功后处理 */ + async function _postLogin(res: ILoginRes) { + setToken(res.token, 86400) + const userStore = useUserStore() + if (res.user_info) { + userStore.setUserInfo(res.user_info) + } else { + await userStore.fetchUserInfo() + } + } + + /** 微信登录 */ + const wxLogin = async () => { + try { + const loginRes = await getWxCode() + const res = await loginByCode(loginRes.code) + await _postLogin(res) + uni.showToast({ title: '登录成功', icon: 'success' }) + return res + } catch (error) { + console.error('微信登录失败:', error) + uni.showToast({ title: '登录失败,请重试', icon: 'error' }) + throw error + } finally { + updateNowTime() + } + } + + /** 开发环境模拟登录 */ + const mockLogin = async (code: string) => { + try { + const res = await loginByCode(code) + await _postLogin(res) + uni.showToast({ title: '登录成功', icon: 'success' }) + return res + } catch (error) { + console.error('登录失败:', error) + uni.showToast({ title: '登录失败,请重试', icon: 'error' }) + throw error + } finally { + updateNowTime() + } + } + + /** 退出登录 */ + const logout = async () => { + updateNowTime() + token.value = '' + tokenExpireTime.value = 0 + const userStore = useUserStore() + userStore.clearUserInfo() + } + + return { + token, + tokenExpireTime, + login: mockLogin, + wxLogin, + logout, + hasLogin, + validToken, + setToken, + updateNowTime, + } + }, + { + persist: true, + }, +) \ No newline at end of file diff --git a/mp-sc-frontend/src/store/user.test.ts b/mp-sc-frontend/src/store/user.test.ts new file mode 100644 index 0000000..a2b1f26 --- /dev/null +++ b/mp-sc-frontend/src/store/user.test.ts @@ -0,0 +1,71 @@ +import { getUserInfo } from '@/api/login' +import { describe, expect, it, vi } from 'vitest' +import { useUserStore } from './user' + +vi.mock('@/api/login', () => ({ + getUserInfo: vi.fn(), +})) + +describe('useUserStore', () => { + it('初始状态:userId 为 -1,username 为空,avatar 为默认头像', () => { + const store = useUserStore() + expect(store.userInfo.userId).toBe(-1) + expect(store.userInfo.username).toBe('') + expect(store.userInfo.nickname).toBe('') + expect(store.userInfo.avatar).toBe('/static/images/default-avatar.png') + }) + + it('setUserInfo:正确更新用户信息', () => { + const store = useUserStore() + store.setUserInfo({ + userId: 1, + username: 'testuser', + nickname: 'Test', + avatar: 'https://example.com/avatar.png', + }) + expect(store.userInfo.userId).toBe(1) + expect(store.userInfo.username).toBe('testuser') + expect(store.userInfo.avatar).toBe('https://example.com/avatar.png') + }) + + it('setUserInfo:avatar 为空字符串时使用默认头像', () => { + const store = useUserStore() + store.setUserInfo({ + userId: 2, + username: 'user2', + nickname: 'User2', + avatar: '', + }) + expect(store.userInfo.avatar).toBe('/static/images/default-avatar.png') + }) + + it('setUserAvatar:正确更新头像', () => { + const store = useUserStore() + store.setUserAvatar('https://example.com/new-avatar.png') + expect(store.userInfo.avatar).toBe('https://example.com/new-avatar.png') + }) + + it('clearUserInfo:重置为初始状态并调用 uni.removeStorageSync', () => { + const store = useUserStore() + store.setUserInfo({ userId: 1, username: 'u', nickname: 'U', avatar: 'a' }) + + store.clearUserInfo() + + expect(store.userInfo.userId).toBe(-1) + expect(store.userInfo.username).toBe('') + expect(uni.removeStorageSync).toHaveBeenCalledWith('user') + }) + + it('fetchUserInfo:调用 API 并将结果写入 store', async () => { + const store = useUserStore() + const mockUser = { userId: 42, username: 'api_user', nickname: 'API User', avatar: 'https://x.com/a.png' } + vi.mocked(getUserInfo).mockResolvedValue(mockUser) + + await store.fetchUserInfo() + + expect(store.userInfo.userId).toBe(42) + expect(store.userInfo.username).toBe('api_user') + expect(store.userInfo.nickname).toBe('API User') + expect(store.userInfo.avatar).toBe('https://x.com/a.png') + }) +}) diff --git a/mp-sc-frontend/src/store/user.ts b/mp-sc-frontend/src/store/user.ts new file mode 100644 index 0000000..bd9bf93 --- /dev/null +++ b/mp-sc-frontend/src/store/user.ts @@ -0,0 +1,93 @@ +import type { IUserInfo } from '@/api/types/appointment' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { + getUserInfo as _getUserInfo, + updateUserInfo as _updateUserInfo, +} from '@/api/appointment' + +// 初始化状态 +const userInfoState: IUserInfo = { + id: 0, + openid: '', + nickname: '', + real_name: '', + phone: '', + phone_verified: false, + avatar_url: '', + face_image_url: '', + status: 1, + created_at: '', + updated_at: '', + role: '', + role_name: '', + is_formal_employee: false, + department_name: '', + position: '', + certification: undefined, +} + +export const useUserStore = defineStore( + 'user', + () => { + const userInfo = ref({ ...userInfoState }) + + const roleCode = computed(() => userInfo.value.role || '') + + // 通用角色检查函数 + const hasRole = (code: string): boolean => roleCode.value === code + + // 动态角色 getters + const isEmployee = computed(() => hasRole('employee')) + const isGuard = computed(() => hasRole('guard')) + const isVisitor = computed(() => hasRole('visitor') || !roleCode.value) + const isLeader = computed(() => hasRole('employee')) + + // 是否为正式员工(来自后端 userinfo 返回) + const isFormalEmployee = computed(() => !!userInfo.value.is_formal_employee) + + const setUserInfo = (val: IUserInfo) => { + console.log('设置用户信息', val) + userInfo.value = { ...userInfoState, ...val } + } + + const clearUserInfo = () => { + userInfo.value = { ...userInfoState } + } + + /** + * 获取用户信息 + */ + const fetchUserInfo = async () => { + const res = await _getUserInfo() + setUserInfo(res) + return res + } + + /** + * 更新用户信息(封装 API 调用 + store 同步) + */ + const updateUserInfo = async (data: Record) => { + await _updateUserInfo(data) + await fetchUserInfo() + } + + return { + userInfo, + roleCode, + hasRole, + isEmployee, + isFormalEmployee, + isGuard, + isVisitor, + isLeader, + clearUserInfo, + fetchUserInfo, + setUserInfo, + updateUserInfo, + } + }, + { + persist: true, + }, +) \ No newline at end of file diff --git a/mp-sc-frontend/src/style/iconfont.css b/mp-sc-frontend/src/style/iconfont.css new file mode 100644 index 0000000..35da86c --- /dev/null +++ b/mp-sc-frontend/src/style/iconfont.css @@ -0,0 +1,28 @@ +@font-face { + font-family: 'iconfont'; /* Project id 4543091 */ + src: + url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAOwAAsAAAAAB9AAAANjAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACDHAqDBIJqATYCJAMQCwoABCAFhGcHPRvnBsgusG3kMyE15/44PsBX09waBHv0REDt97oHAQDFrOIyPirRiULQ+TJcXV0hCYTuVFcBC915/2vX/32Q80hkZ5PZGZ9snvwruVLloidKqYN6iKC53bOtbKwVLSIi3W6zCWZbs3VbER3j9JpGX3ySYcc94IQRTK5s4epS/jSqIgvg37qlY2/jwQN7D9ADpfRCmIknQByTscVZPTBr+hnnCKg2o4bjakvXEPjuY65DJGeJNtBUhn1JxOBuB2UZmUpBOXdsFp4oxOv4GHgs3h/+wRDcicqSZJG1q9kK1z/Af9NpqxjpC2QaAdpHlCFh4spcYXs5sMWpSk5wUj31G2dLQKVKkZ/w7f/8/i/A3JVUSZK9f7xIKJeU14IFpBI/Qfkkz46GT/CuaGREfCtKJUougWeQWHvVC5Lcz2BGS+SePR99vj3yjJx7h574tp7uWcOh4yfaTjS/245TT/vkQrN+a7RLkK8+Vd+bz+FSGh+9srDQKPeJ2s29z7ah4+efdoxefRbbGwfy7ht+SuIWukzsu1b6ePP+6kN1aamb47qsPim1Ia3xdEpDcl1dckPKGYnneI23+57r2W1Mmkqs6ajrChRCs5qyQ66rTVWhgZaG7toOeHm5cxn0sSQuNDEgcUTdNTSupKI1JRZih/JssAUKezPeOJJzbNozF6zWJuuVavVU5Tgtkop/SDzHa7ytvnCTq0PhkEfi4xLLtb0PuwyOAYqmrYQApFJyoJjTnfz+ve94vvv2f/yWgxl8Jd8Di2DRDPuob59mU/+VfDCROQyR8xSnmP9fXm7liagmN39OlmbvjqG0sMsJKrU0EFXogaRSH5bNY1CmxhyUq7QC1cY1T67RwuQk5CoM2RUQNLoEUb03kDS6h2XzcyjT7iOUa/QXqq1Hn6/GUBAaGcGcWJFlGUmCoVOp8kLvABHnVczGYiOE2SVEUH5OXj/TSnTCDjHAviAWcE4RZYaGWszNiKoayGSGTASeY+PcrMjNpVMvyREMDRoxBMYRVojFMkQiMOhohubdzxtAiOapMMbERpKMnQT9SL4ceQysVdJZVa9kEbsFogIcRyEUE2kN0mL7CDVIGhBzupWMEHA5bDvipgq5hKJcKef8ivbx1kC15KgcYkghhzLxYNntxoKCReJ82jAHAAA=') + format('woff2'), + url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.woff?t=1715485842402') format('woff'), + url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.ttf?t=1715485842402') format('truetype'); +} + +.iconfont { + font-family: 'iconfont' !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-my:before { + content: '\e78c'; +} + +.icon-package:before { + content: '\e9c2'; +} + +.icon-chat:before { + content: '\e600'; +} diff --git a/mp-sc-frontend/src/style/index.scss b/mp-sc-frontend/src/style/index.scss new file mode 100644 index 0000000..195f84c --- /dev/null +++ b/mp-sc-frontend/src/style/index.scss @@ -0,0 +1,39 @@ +// 测试用的 iconfont,可生效 +// @import './iconfont.css'; + +// Wot Design Uni 图标字体本地化(微信小程序无法加载在线字体) +@import './wd-icon-local.scss'; + +.test { + // 可以通过 @apply 多个样式封装整体样式 + @apply mt-4 ml-4; + + padding-top: 4px; + color: red; +} + +:root, +page { + // 修改按主题色 + // --wot-color-theme: #37c2bc; + + // 修改按钮背景色 + // --wot-button-primary-bg-color: green; +} + +/* +border-t-1 +由于uniapp中无法使用*选择器,使用魔法代替*,加上此规则可以简化border与divide的使用,并提升布局的兼容性 +1. 防止padding和border影响元素宽度。 (https://github.com/mozdevs/cssremedy/issues/4) +2. 允许仅通过添加边框宽度来向元素添加边框。 (https://github.com/tailwindcss/tailwindcss/pull/116) +3. [UnoCSS]: 允许使用css变量'--un-default-border-color'覆盖默认边框颜色 +*/ +// 这个样式有重大BUG,先去掉!!(2025-08-15) +// :not(not), +// ::before, +// ::after { +// box-sizing: border-box; /* 1 */ +// border-width: 0; /* 2 */ +// border-style: solid; /* 2 */ +// border-color: var(--un-default-border-color, #e5e7eb); /* 3 */ +// } diff --git a/mp-sc-frontend/src/style/wd-icon-local.scss b/mp-sc-frontend/src/style/wd-icon-local.scss new file mode 100644 index 0000000..ea6cd56 --- /dev/null +++ b/mp-sc-frontend/src/style/wd-icon-local.scss @@ -0,0 +1,13 @@ +/** + * Wot Design Uni 图标字体本地化覆盖 + * 微信小程序无法加载在线字体,使用本地字体文件替代 + */ + +/* #ifdef MP-WEIXIN */ +@font-face { + font-family: 'wd-icons'; + src: url('/static/fonts/iconfont.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} +/* #endif */ \ No newline at end of file diff --git a/mp-sc-frontend/src/subpackages/appointment/all-appointments.vue b/mp-sc-frontend/src/subpackages/appointment/all-appointments.vue new file mode 100644 index 0000000..70c32b0 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/all-appointments.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/appointment-detail.vue b/mp-sc-frontend/src/subpackages/appointment/appointment-detail.vue new file mode 100644 index 0000000..e16476c --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/appointment-detail.vue @@ -0,0 +1,725 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/appointment-form.vue b/mp-sc-frontend/src/subpackages/appointment/appointment-form.vue new file mode 100644 index 0000000..dca9819 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/appointment-form.vue @@ -0,0 +1,545 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/appointment-list.vue b/mp-sc-frontend/src/subpackages/appointment/appointment-list.vue new file mode 100644 index 0000000..79ac860 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/appointment-list.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/approval-detail.vue b/mp-sc-frontend/src/subpackages/appointment/approval-detail.vue new file mode 100644 index 0000000..5538851 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/approval-detail.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/approval-list.vue b/mp-sc-frontend/src/subpackages/appointment/approval-list.vue new file mode 100644 index 0000000..ef29e63 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/approval-list.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/employee-select.vue b/mp-sc-frontend/src/subpackages/appointment/employee-select.vue new file mode 100644 index 0000000..c07ecd5 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/employee-select.vue @@ -0,0 +1,110 @@ + + + + + \ No newline at end of file diff --git a/mp-sc-frontend/src/subpackages/appointment/goods-select.vue b/mp-sc-frontend/src/subpackages/appointment/goods-select.vue new file mode 100644 index 0000000..4f6a036 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/goods-select.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/guard-workbench.vue b/mp-sc-frontend/src/subpackages/appointment/guard-workbench.vue new file mode 100644 index 0000000..d41d0fd --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/guard-workbench.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/index.vue b/mp-sc-frontend/src/subpackages/appointment/index.vue new file mode 100644 index 0000000..d4ddfb7 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/index.vue @@ -0,0 +1,623 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/leader-trace.vue b/mp-sc-frontend/src/subpackages/appointment/leader-trace.vue new file mode 100644 index 0000000..0e42605 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/leader-trace.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/notice.vue b/mp-sc-frontend/src/subpackages/appointment/notice.vue new file mode 100644 index 0000000..448bcd3 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/notice.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/pending-approvals.vue b/mp-sc-frontend/src/subpackages/appointment/pending-approvals.vue new file mode 100644 index 0000000..2e8b97f --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/pending-approvals.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/vehicle-select.vue b/mp-sc-frontend/src/subpackages/appointment/vehicle-select.vue new file mode 100644 index 0000000..c517448 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/vehicle-select.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/visitor-edit.vue b/mp-sc-frontend/src/subpackages/appointment/visitor-edit.vue new file mode 100644 index 0000000..7a78aee --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/visitor-edit.vue @@ -0,0 +1,71 @@ + + + diff --git a/mp-sc-frontend/src/subpackages/appointment/visitor-select.vue b/mp-sc-frontend/src/subpackages/appointment/visitor-select.vue new file mode 100644 index 0000000..a02a038 --- /dev/null +++ b/mp-sc-frontend/src/subpackages/appointment/visitor-select.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/mp-sc-frontend/src/subpackages/system/certification-apply.vue b/mp-sc-frontend/src/subpackages/system/certification-apply.vue new file mode 100644 index 0000000..7d8ae7b --- /dev/null +++ b/mp-sc-frontend/src/subpackages/system/certification-apply.vue @@ -0,0 +1,225 @@ + + + diff --git a/mp-sc-frontend/src/tabbar/README.md b/mp-sc-frontend/src/tabbar/README.md new file mode 100644 index 0000000..575d87f --- /dev/null +++ b/mp-sc-frontend/src/tabbar/README.md @@ -0,0 +1,78 @@ +# tabbar 说明 + +## tabbar 3种策略 + +`tabbar` 分为 `4 种` 情况: + +- 0 `无 tabbar`,只有一个页面入口,底部无 `tabbar` 显示;常用语临时活动页。 + +- 1 `原生 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。 + - 优势:原生自带的 `tabbar`,最先渲染,有缓存。 + - 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。 + +- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。 + - 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。 + - 劣势:首次点击 `tabbar` 会闪烁。 + +## tabbar 配置说明 + +- 如果使用的是 `原生tabbar`,需要配置 `nativeTabbarList`,每个 `item` 需要配置 `path`、`text`、`iconPath`、`selectedIconPath` 等属性。 +- 如果使用的是 `自定义tabbar`,需要配置 `customTabbarList`,每个 `item` 需要配置 `path`、`text`、`icon` 、`iconType` 等属性(如果是 `image` 图片还需要配置2种图片)。 + +## 文件说明 + +`config.ts` 专门配置 `nativeTabbarList` 和 `customTabbarList` 的相关信息,请按照文件里面的注释配置相关项。 + +使用 `原生tabbar` 时,不需要关心下面2个文件: + +- `store.ts` ,专门给 `自定义 tabbar` 提供状态管理,代码几乎不需要修改。 +- `index.vue` ,专门给 `自定义 tabbar` 提供渲染逻辑,代码可以稍微修改,以符合自己的需求。 + +## 自定义tabbar的不同类型的配置 + +- uiLib 图标 + + ```js + { + // ... 其他配置 + "iconType": "uniUi", + "icon": "home", + } + ``` + +- unocss 图标 + + ```js + { + // ... 其他配置 + // 注意 unocss 图标需要如下处理:(二选一) + // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行) + // 2)配置到 unocss.config.ts 的 safelist 中 + iconType: 'unocss', + icon: 'i-carbon-code', + } + ``` + +- iconfont 图标 + + ```js + { + // ... 其他配置 + // 注意 iconfont 图标需要额外加上 'iconfont',如下 + iconType: 'iconfont', + icon: 'iconfont icon-my', + } + ``` + +- image 本地图片 + + ```js + { + // ... 其他配置 + // 使用 ‘image’时,需要配置 icon + iconActive 2张图片(不推荐) + // 既然已经用了自定义tabbar了,就不建议用图片了,所以不推荐 + iconType: 'image', + icon: '/static/tabbar/home.png', + iconActive: '/static/tabbar/homeHL.png', + } + ``` diff --git a/mp-sc-frontend/src/tabbar/TabbarItem.test.ts b/mp-sc-frontend/src/tabbar/TabbarItem.test.ts new file mode 100644 index 0000000..d6118c7 --- /dev/null +++ b/mp-sc-frontend/src/tabbar/TabbarItem.test.ts @@ -0,0 +1,69 @@ +import type { CustomTabBarItem } from './types' +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import TabbarItem from './TabbarItem.vue' + +// mock tabbar store,避免 uni.getStorageSync 在模块加载时执行 +vi.mock('./store', () => ({ + tabbarStore: { curIdx: 0 }, +})) + +const baseItem: CustomTabBarItem = { + text: '首页', + pagePath: 'pages/index/index', + iconType: 'unocss', + icon: 'i-carbon-home', +} + +describe('TabbarItem', () => { + let wrapper: ReturnType + + afterEach(() => { + wrapper?.unmount() + }) + + it('渲染 text 文本', () => { + wrapper = mount(TabbarItem, { + props: { item: baseItem, index: 0 }, + }) + expect(wrapper.text()).toContain('首页') + }) + + it('isBulge=true 时不渲染文本', () => { + wrapper = mount(TabbarItem, { + props: { item: baseItem, index: 0, isBulge: true }, + }) + expect(wrapper.text()).not.toContain('首页') + }) + + it('iconType=unocss 时渲染图标 class', () => { + wrapper = mount(TabbarItem, { + props: { item: baseItem, index: 0 }, + }) + expect(wrapper.html()).toContain('i-carbon-home') + }) + + it('badge=dot 时渲染小红点(包含 rounded-full 样式)', () => { + const item: CustomTabBarItem = { ...baseItem, badge: 'dot' } + wrapper = mount(TabbarItem, { + props: { item, index: 0 }, + }) + expect(wrapper.html()).toContain('rounded-full') + }) + + it('badge 为数字时渲染数字角标', () => { + const item: CustomTabBarItem = { ...baseItem, badge: 5 } + wrapper = mount(TabbarItem, { + props: { item, index: 0 }, + }) + expect(wrapper.text()).toContain('5') + }) + + it('badge > 99 时显示 99+', () => { + const item: CustomTabBarItem = { ...baseItem, badge: 100 } + wrapper = mount(TabbarItem, { + props: { item, index: 0 }, + }) + expect(wrapper.text()).toContain('99+') + }) +}) diff --git a/mp-sc-frontend/src/tabbar/TabbarItem.vue b/mp-sc-frontend/src/tabbar/TabbarItem.vue new file mode 100644 index 0000000..b3e9453 --- /dev/null +++ b/mp-sc-frontend/src/tabbar/TabbarItem.vue @@ -0,0 +1,50 @@ + + + diff --git a/mp-sc-frontend/src/tabbar/config.ts b/mp-sc-frontend/src/tabbar/config.ts new file mode 100644 index 0000000..e31eb23 --- /dev/null +++ b/mp-sc-frontend/src/tabbar/config.ts @@ -0,0 +1,68 @@ +import type { TabBar } from '@uni-helper/vite-plugin-uni-pages' +import type { CustomTabBarItem, NativeTabBarItem } from './types' + +export const TABBAR_STRATEGY_MAP = { + NO_TABBAR: 0, + NATIVE_TABBAR: 1, + CUSTOM_TABBAR: 2, +} + +// 使用原生 tabbar(兼容性最好) +export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.NATIVE_TABBAR + +export const nativeTabbarList: NativeTabBarItem[] = [ + { + iconPath: 'static/tabbar/home.png', + selectedIconPath: 'static/tabbar/homeHL.png', + pagePath: 'pages/index/index', + text: '首页', + }, + { + iconPath: 'static/tabbar/personal.png', + selectedIconPath: 'static/tabbar/personalHL.png', + pagePath: 'pages/me/me', + text: '我的', + }, +] + +export const customTabbarList: CustomTabBarItem[] = [ + { + text: '首页', + pagePath: 'pages/index/index', + iconType: 'unocss', + icon: 'i-carbon-home', + }, + { + text: '我的', + pagePath: 'pages/me/me', + iconType: 'unocss', + icon: 'i-carbon-user', + }, +] + +export const tabbarCacheEnable + = [TABBAR_STRATEGY_MAP.NATIVE_TABBAR, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy) + +export const customTabbarEnable = [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy) + +export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR + +const _tabbarList = customTabbarEnable ? customTabbarList.map(item => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList +export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList + +export const isNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NATIVE_TABBAR + +const _tabbar: TabBar = { + custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR, + color: '#999999', + selectedColor: '#007AFF', + backgroundColor: '#FFFFFF', + borderStyle: 'black', + height: 50, + fontSize: 10, + iconWidth: 24, + spacing: 3, + list: _tabbarList as unknown as TabBar['list'], +} + +export const tabBar = tabbarCacheEnable ? _tabbar : undefined diff --git a/mp-sc-frontend/src/tabbar/index.vue b/mp-sc-frontend/src/tabbar/index.vue new file mode 100644 index 0000000..089ef34 --- /dev/null +++ b/mp-sc-frontend/src/tabbar/index.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/mp-sc-frontend/src/tabbar/store.ts b/mp-sc-frontend/src/tabbar/store.ts new file mode 100644 index 0000000..8274fc0 --- /dev/null +++ b/mp-sc-frontend/src/tabbar/store.ts @@ -0,0 +1,118 @@ +import type { CustomTabBarItem, CustomTabBarItemBadge } from './types' +import { computed, reactive } from 'vue' +import { useUserStore } from '@/store/user' + +import { tabbarList as _tabbarList, selectedTabbarStrategy, TABBAR_STRATEGY_MAP } from './config' + +/** tabbarList 里面的 path 从 pages.config.ts 得到 */ +const baseTabbarList = reactive(_tabbarList.map(item => ({ + ...item, + pagePath: item.pagePath.startsWith('/') ? item.pagePath : `/${item.pagePath}`, // 统一成 '/' 开头的路径 +}))) + +const userRoles = computed(() => { + const userStore = useUserStore() + const role = userStore.roleCode + return role ? [role] : [] +}) + +const tabbarList = computed(() => { + const roles = userRoles.value + if (roles.length === 0) { + return baseTabbarList.filter(item => !item.roles || item.roles.length === 0) + } + return baseTabbarList.filter(item => !item.roles || item.roles.length === 0 || item.roles.some(role => roles.includes(role))) +}) + +export function isPageTabbar(path: string) { + if (selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NO_TABBAR) { + return false + } + const _path = normalizeRoutePath(path) + return _path === '/' || tabbarList.value.some(item => item.pagePath === _path) +} + +export function normalizeRoutePath(path?: string) { + if (!path) { + return '' + } + const _path = path.split('?')[0] + return _path.startsWith('/') ? _path : `/${_path}` +} + +function getCurrentPagePath() { + const pages = getCurrentPages() + const currentPage = pages[pages.length - 1] + return normalizeRoutePath(currentPage?.route) +} + +function findTabbarIndexByPath(path?: string) { + const normalizedPath = normalizeRoutePath(path) + if (normalizedPath === '/') { + return 0 + } + return tabbarList.value.findIndex(item => item.pagePath === normalizedPath) +} + +/** + * 自定义 tabbar 的状态管理,原生 tabbar 无需关注本文件 + * tabbar 状态,增加 storageSync 保证刷新浏览器时在正确的 tabbar 页面 + * 使用reactive简单状态,而不是 pinia 全局状态 + */ +const tabbarStore = reactive({ + curIdx: uni.getStorageSync('app-tabbar-index') || 0, + prevIdx: uni.getStorageSync('app-tabbar-index') || 0, + setCurIdx(idx: number) { + this.curIdx = idx + uni.setStorageSync('app-tabbar-index', idx) + }, + setTabbarItemBadge(idx: number, badge: CustomTabBarItemBadge) { + const list = tabbarList.value + if (list[idx]) { + list[idx].badge = badge + } + }, + setAutoCurIdx(path: string) { + const list = tabbarList.value + if (list.length === 0) { + this.setCurIdx(0) + return + } + + const index = findTabbarIndexByPath(path) + if (index >= 0) { + this.setCurIdx(index) + return + } + + if (this.curIdx < 0 || this.curIdx >= list.length) { + this.setCurIdx(0) + } + }, + syncCurIdxByCurrentPage() { + const currentPath = getCurrentPagePath() + if (currentPath) { + this.setAutoCurIdx(currentPath) + } + }, + syncCurIdxByCurrentPageAsync() { + setTimeout(() => { + this.syncCurIdxByCurrentPage() + }, 0) + }, + isCurrentRouteTabbarItem(index: number) { + const item = tabbarList.value[index] + if (!item) { + return false + } + return findTabbarIndexByPath(getCurrentPagePath()) === index + }, + restorePrevIdx() { + if (this.prevIdx === this.curIdx) + return + this.setCurIdx(this.prevIdx) + this.prevIdx = uni.getStorageSync('app-tabbar-index') || 0 + }, +}) + +export { tabbarList, tabbarStore } diff --git a/mp-sc-frontend/src/tabbar/types.ts b/mp-sc-frontend/src/tabbar/types.ts new file mode 100644 index 0000000..cfa6a6e --- /dev/null +++ b/mp-sc-frontend/src/tabbar/types.ts @@ -0,0 +1,37 @@ +import type { TabBar } from '@uni-helper/vite-plugin-uni-pages' +import type { UserRole } from '@/api/types/login' +import type { RemoveLeadingSlashFromUnion } from '@/typings' + +/** + * 原生 tabbar 的单个选项配置 + */ +export type NativeTabBarItem = TabBar['list'][number] & { + pagePath: RemoveLeadingSlashFromUnion<_LocationUrl> +} + +/** badge 显示一个数字或 小红点(样式可以直接在 tabbar/index.vue 里面修改) */ +export type CustomTabBarItemBadge = number | 'dot' + +/** 自定义 tabbar 的单个选项配置 */ +export interface CustomTabBarItem { + text: string + pagePath: RemoveLeadingSlashFromUnion<_LocationUrl> + /** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */ + iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image' + /** + * icon 的路径 + * - uiLib: wot-design-uni 图标的 icon prop + * - unocss: unocss 图标的类名 + * - iconfont: iconfont 图标的类名 + * - image: 图片的路径 + */ + icon: string + /** 只有在 image 模式下才需要,传递的是高亮的图片 */ + iconActive?: string + /** badge 显示一个数字或 小红点 */ + badge?: CustomTabBarItemBadge + /** 是否是中间的鼓包tabbarItem */ + isBulge?: boolean + // roles 不写 → 所有用户都能看到;roles 写了 → 只有匹配角色可见 + roles?: UserRole[] +} diff --git a/mp-sc-frontend/src/test-setup.ts b/mp-sc-frontend/src/test-setup.ts new file mode 100644 index 0000000..5a98b0c --- /dev/null +++ b/mp-sc-frontend/src/test-setup.ts @@ -0,0 +1,51 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, vi } from 'vitest' + +// 每个测试前重置 Pinia 实例,避免状态在测试间泄漏 +beforeEach(() => { + // 不注册 pinia-plugin-persistedstate,测试只验证 store 逻辑,不验证持久化行为 + setActivePinia(createPinia()) + // 重置所有 mock 的调用记录(保留实现,仅清空 .mock.calls 等) + vi.clearAllMocks() +}) + +// 全局 mock uni 对象(jsdom 中无此全局,uni-app 特有) +const uniMock = { + showToast: vi.fn(), + hideToast: vi.fn(), + showLoading: vi.fn(), + hideLoading: vi.fn(), + showModal: vi.fn(), + navigateTo: vi.fn(), + redirectTo: vi.fn(), + navigateBack: vi.fn(), + switchTab: vi.fn(), + reLaunch: vi.fn(), + // tabbar/store.ts 在模块初始化时调用 getStorageSync,返回 null 确保不影响初始状态 + getStorageSync: vi.fn().mockReturnValue(null), + setStorageSync: vi.fn(), + removeStorageSync: vi.fn(), + getStorage: vi.fn(), + setStorage: vi.fn(), + removeStorage: vi.fn(), + request: vi.fn(), + uploadFile: vi.fn(), + chooseImage: vi.fn(), + getSystemInfoSync: vi.fn().mockReturnValue({ platform: 'devtools' }), + getSystemInfo: vi.fn(), + onNetworkStatusChange: vi.fn(), + getNetworkType: vi.fn(), +} + +Object.defineProperty(globalThis, 'uni', { + value: uniMock, + writable: true, + configurable: true, +}) + +// getCurrentPages 是 uni-app 的全局函数(不在 uni 对象上) +Object.defineProperty(globalThis, 'getCurrentPages', { + value: vi.fn().mockReturnValue([{ route: '/pages/index/index' }]), + writable: true, + configurable: true, +}) diff --git a/mp-sc-frontend/src/typings.d.ts b/mp-sc-frontend/src/typings.d.ts new file mode 100644 index 0000000..9732451 --- /dev/null +++ b/mp-sc-frontend/src/typings.d.ts @@ -0,0 +1,171 @@ +// 全局要用的类型放到这里 + +declare global { + interface IResData { + code: number + msg: string + data: T + } + + // uni.uploadFile文件上传参数 + interface IUniUploadFileOptions { + file?: File + files?: UniApp.UploadFileOptionFiles[] + filePath?: string + name?: string + formData?: any + } + + interface IUserInfo { + nickname?: string + avatar?: string + /** 微信的 openid,非微信没有这个字段 */ + openid?: string + } + + interface IUserToken { + token: string + refreshToken?: string + refreshExpire?: number + } +} + +// 扩展 @uni-helper/vite-plugin-uni-pages 的 definePage 参数类型 +declare module '@uni-helper/vite-plugin-uni-pages' { + interface UserPageMeta { + /** + * 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page + * + * 尽量保证一个项目 只有一个 这个配置,如果有多个,会按照字母顺序来排列,最终可能不是您想要的效果。 + */ + type?: 'home' + /** + * 页面布局类型, 模板默认只有 default, 如果在 src/layouts 下新增了 layout, 可以扩展当前属性 + * @default 'default' + * + * 当前属性供 https://github.com/uni-helper/vite-plugin-uni-layouts 插件使用 + */ + layout?: 'default' | false + /** + * 是否从需要登录的路径中排除 + * + * 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹 + */ + excludeLoginPath?: boolean + } +} + +// patch uni 类型 +// 1. 补全 uni.hideToast() 的 options 类型 +// 2. 补全 uni.hideLoading() 的 options 类型 +// 3. 使用方式见:https://github.com/unibest-tech/unibest/pull/241 +declare global { + declare namespace UniNamespace { + /** 接口调用结束的回调函数(调用成功、失败都会执行) */ + type HideLoadingCompleteCallback = (res: GeneralCallbackResult) => void + /** 接口调用失败的回调函数 */ + type HideLoadingFailCallback = (res: GeneralCallbackResult) => void + /** 接口调用成功的回调函数 */ + type HideLoadingSuccessCallback = (res: GeneralCallbackResult) => void + + interface HideLoadingOption { + /** 接口调用结束的回调函数(调用成功、失败都会执行) */ + complete?: HideLoadingCompleteCallback + /** 接口调用失败的回调函数 */ + fail?: HideLoadingFailCallback + test: UniNamespace.GeneralCallbackResult + /** + * 微信小程序:需要基础库: `2.22.1` + * + * 微信小程序:目前 toast 和 loading 相关接口可以相互混用,此参数可用于取消混用特性 + */ + noConflict?: boolean + /** 接口调用成功的回调函数 */ + success?: HideLoadingSuccessCallback + } + + // ---------------------------------------------------------- + + /** 接口调用结束的回调函数(调用成功、失败都会执行) */ + type HideToastCompleteCallback = (res: GeneralCallbackResult) => void + /** 接口调用失败的回调函数 */ + type HideToastFailCallback = (res: GeneralCallbackResult) => void + /** 接口调用成功的回调函数 */ + type HideToastSuccessCallback = (res: GeneralCallbackResult) => void + interface HideToastOption { + /** 接口调用结束的回调函数(调用成功、失败都会执行) */ + complete?: HideToastCompleteCallback + /** 接口调用失败的回调函数 */ + fail?: HideToastFailCallback + /** + * 微信小程序:需要基础库: `2.22.1` + * + * 微信小程序:目前 toast 和 loading 相关接口可以相互混用,此参数可用于取消混用特性 + */ + noConflict?: boolean + /** 接口调用成功的回调函数 */ + success?: HideToastSuccessCallback + } + } + interface Uni { + /** + * 隐藏 loading 提示框 + * + * 文档: [http://uniapp.dcloud.io/api/ui/prompt?id=hideloading](http://uniapp.dcloud.io/api/ui/prompt?id=hideloading) + * @example ```typescript + * uni.showLoading({ + * title: '加载中' + * }); + * + * setTimeout(function () { + * uni.hideLoading(); + * }, 2000); + * + * ``` + * @tutorial [](https://uniapp.dcloud.net.cn/api/ui/prompt.html#hideloading) + * @uniPlatform { + * "app": { + * "android": { + * "osVer": "4.4.4", + * "uniVer": "√", + * "unixVer": "3.9.0" + * }, + * "ios": { + * "osVer": "9.0", + * "uniVer": "√", + * "unixVer": "3.9.0" + * } + * } + * } + */ + // eslint-disable-next-line ts/method-signature-style + hideLoading(options?: T): void + /** + * 隐藏消息提示框 + * + * 文档: [http://uniapp.dcloud.io/api/ui/prompt?id=hidetoast](http://uniapp.dcloud.io/api/ui/prompt?id=hidetoast) + * @example ```typescript + * uni.hideToast(); + * ``` + * @tutorial [](https://uniapp.dcloud.net.cn/api/ui/prompt.html#hidetoast) + * @uniPlatform { + * "app": { + * "android": { + * "osVer": "4.4.4", + * "uniVer": "√", + * "unixVer": "3.9.0" + * }, + * "ios": { + * "osVer": "9.0", + * "uniVer": "√", + * "unixVer": "3.9.0" + * } + * } + * } + */ + // eslint-disable-next-line ts/method-signature-style + hideToast(options?: T): void + } +} + +export {} // 防止模块污染 diff --git a/mp-sc-frontend/src/typings.ts b/mp-sc-frontend/src/typings.ts new file mode 100644 index 0000000..fefe1cb --- /dev/null +++ b/mp-sc-frontend/src/typings.ts @@ -0,0 +1,21 @@ +// 枚举定义 + +export enum TestEnum { + A = '1', + B = '2', +} + +// uni.uploadFile文件上传参数 +export interface IUniUploadFileOptions { + file?: File + files?: UniApp.UploadFileOptionFiles[] + filePath?: string + name?: string + formData?: any +} + +/** 工具类型:删除字符串开头的第一个斜杠 */ +export type RemoveLeadingSlash = S extends `/${infer Rest}` ? Rest : S + +/** 工具类型:删除联合类型中每个字符串的第一个斜杠 */ +export type RemoveLeadingSlashFromUnion = T extends any ? RemoveLeadingSlash : never diff --git a/mp-sc-frontend/src/uni.scss b/mp-sc-frontend/src/uni.scss new file mode 100644 index 0000000..21b9e5f --- /dev/null +++ b/mp-sc-frontend/src/uni.scss @@ -0,0 +1,77 @@ +/* stylelint-disable comment-empty-line-before */ +/** + * 这里是uni-app内置的常用样式变量 + * + * uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量 + * 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App + * + */ + +/** + * 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能 + * + * 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件 + */ + +/* 颜色变量 */ + +/* 行为相关颜色 */ +$uni-color-primary: #007aff; +$uni-color-success: #4cd964; +$uni-color-warning: #f0ad4e; +$uni-color-error: #dd524d; + +/* 文字基本颜色 */ +$uni-text-color: #333; // 基本色 +$uni-text-color-inverse: #fff; // 反色 +$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息 +$uni-text-color-placeholder: #808080; +$uni-text-color-disable: #c0c0c0; + +/* 背景颜色 */ +$uni-bg-color: #fff; +$uni-bg-color-grey: #f8f8f8; +$uni-bg-color-hover: #f1f1f1; // 点击状态颜色 +$uni-bg-color-mask: rgb(0 0 0 / 40%); // 遮罩颜色 + +/* 边框颜色 */ +$uni-border-color: #c8c7cc; + +/* 尺寸变量 */ + +/* 文字尺寸 */ +$uni-font-size-sm: 12px; +$uni-font-size-base: 14px; +$uni-font-size-lg: 16; + +/* 图片尺寸 */ +$uni-img-size-sm: 20px; +$uni-img-size-base: 26px; +$uni-img-size-lg: 40px; + +/* Border Radius */ +$uni-border-radius-sm: 2px; +$uni-border-radius-base: 3px; +$uni-border-radius-lg: 6px; +$uni-border-radius-circle: 50%; + +/* 水平间距 */ +$uni-spacing-row-sm: 5px; +$uni-spacing-row-base: 10px; +$uni-spacing-row-lg: 15px; + +/* 垂直间距 */ +$uni-spacing-col-sm: 4px; +$uni-spacing-col-base: 8px; +$uni-spacing-col-lg: 12px; + +/* 透明度 */ +$uni-opacity-disabled: 0.3; // 组件禁用态的透明度 + +/* 文章场景相关 */ +$uni-color-title: #2c405a; // 文章标题颜色 +$uni-font-size-title: 20px; +$uni-color-subtitle: #555; // 二级标题颜色 +$uni-font-size-subtitle: 18px; +$uni-color-paragraph: #3f536e; // 文章段落颜色 +$uni-font-size-paragraph: 15px; diff --git a/mp-sc-frontend/src/uni_modules/.gitkeep b/mp-sc-frontend/src/uni_modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/mp-sc-frontend/src/utils/constants.ts b/mp-sc-frontend/src/utils/constants.ts new file mode 100644 index 0000000..64c3352 --- /dev/null +++ b/mp-sc-frontend/src/utils/constants.ts @@ -0,0 +1,100 @@ +// 预约状态 +export const APPOINTMENT_STATUS = { + PENDING_EMPLOYEE: 0, // 待员工审核 + EMPLOYEE_APPROVED: 1, // 员工通过 + EMPLOYEE_REJECTED: 2, // 员工拒绝 + PENDING_LEADER: 3, // 待领导审核 + APPROVED: 4, // 已通过 + CANCELLED: 5, // 已取消 + COMPLETED: 6, // 已完成 + LEADER_REJECTED: 7, // 领导拒绝 +} as const + +// 预约状态映射 +export const APPOINTMENT_STATUS_MAP: Record = { + 0: { text: '审核中', color: '#2979ff' }, + 1: { text: '已通过', color: '#18bc37' }, + 2: { text: '已拒绝', color: '#dd524d' }, + 3: { text: '已取消', color: '#999999' }, +} + +// 来访目的(wd-action-sheet 要求 actions 包含 name 属性) +export const VISIT_TYPES = [ + { name: '应聘', value: '应聘' }, + { name: '物流', value: '物流' }, + { name: '客户', value: '客户' }, + { name: '供应商', value: '供应商' }, + { name: '银行人员', value: '银行人员' }, + { name: '券商', value: '券商' }, + { name: '外协施工方', value: '外协施工方' }, + { name: '访客', value: '访客' }, + { name: '其他', value: '其他' }, +] + +// 性别 +export const GENDER_OPTIONS = [ + { label: '男', value: 1 }, + { label: '女', value: 2 }, +] + +// 证件类型 +export const ID_TYPES = [ + { label: '身份证', value: '身份证' }, + { label: '护照', value: '护照' }, + { label: '驾驶证', value: '驾驶证' }, + { label: '港澳通行证', value: '港澳通行证' }, +] + +// 到访区域 +export const VISIT_AREAS = [ + { label: 'A区域', value: 'A区域' }, + { label: 'B区域', value: 'B区域' }, + { label: 'C区域', value: 'C区域' }, + { label: 'D区域', value: 'D区域' }, + { label: '办公楼', value: '办公楼' }, + { label: '生产区', value: '生产区' }, + { label: '研发中心', value: '研发中心' }, +] + +// 车辆品牌 +export const VEHICLE_BRANDS = [ + { label: '大众', value: '大众' }, + { label: '丰田', value: '丰田' }, + { label: '本田', value: '本田' }, + { label: '日产', value: '日产' }, + { label: '宝马', value: '宝马' }, + { label: '奔驰', value: '奔驰' }, + { label: '奥迪', value: '奥迪' }, + { label: '比亚迪', value: '比亚迪' }, + { label: '吉利', value: '吉利' }, + { label: '长城', value: '长城' }, + { label: '长安', value: '长安' }, + { label: '五菱', value: '五菱' }, + { label: '其他', value: '其他' }, +] + +// 车辆颜色 +export const VEHICLE_COLORS = ['白色', '黑色', '银色', '灰色', '红色', '蓝色', '绿色', '黄色', '棕色', '橙色', '紫色', '粉色'] + +// 角色申请状态 +export const APPLICATION_STATUS = { + PENDING: 0, + APPROVED: 1, + REJECTED: 2, +} as const + +export const APPLICATION_STATUS_MAP: Record = { + 0: { text: '待审核', color: '#2979ff' }, + 1: { text: '已通过', color: '#18bc37' }, + 2: { text: '已拒绝', color: '#dd524d' }, +} + +// ============ 微信订阅消息模板 ID ============ +/** 来访预约取消通知:访客姓名、访客电话、预约时间、来访事由、取消原因 */ +export const TMPL_CANCEL = 'ZSbJDf3HzOjAhbN3IRvyljS-ffmR9yzxGdt_yEwYxHI' + +/** 访客预约通知:访客姓名、来访时间、拜访事由、来访区域、备注 */ +export const TMPL_VISIT = '0lyq-SVrpFhRIsmxovsGsX02Olye4LUindHVUx_YtWo' + +/** 访客申请结果通知:申请结果、访问企业、访问时间、被访人、访问区域 */ +export const TMPL_RESULT = '7tFHeTmubiHMhAaJGZB5lMLqlhU1VrUUgSjYp1Z05cY' diff --git a/mp-sc-frontend/src/utils/debounce.test.ts b/mp-sc-frontend/src/utils/debounce.test.ts new file mode 100644 index 0000000..8e71356 --- /dev/null +++ b/mp-sc-frontend/src/utils/debounce.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { debounce } from './debounce' + +describe('debounce', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('trailing edge(默认):多次调用后只触发一次,使用最后一次的参数', () => { + const fn = vi.fn() + const debouncedFn = debounce(fn, 100) + + debouncedFn('first') + debouncedFn('second') + debouncedFn('last') + + expect(fn).not.toHaveBeenCalled() + + vi.advanceTimersByTime(100) + + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith('last') + }) + + it('cancel:取消后计时器到期不执行', () => { + const fn = vi.fn() + const debouncedFn = debounce(fn, 100) + + debouncedFn() + debouncedFn.cancel() + + vi.advanceTimersByTime(100) + + expect(fn).not.toHaveBeenCalled() + }) + + it('flush:立即执行待执行的调用并传递参数', () => { + const fn = vi.fn() + const debouncedFn = debounce(fn, 100) + + debouncedFn('arg1') + debouncedFn.flush() + + expect(fn).toHaveBeenCalledWith('arg1') + }) + + it('leading edge:第一次调用立即执行', () => { + const fn = vi.fn() + const debouncedFn = debounce(fn, 100, { edges: ['leading'] }) + + debouncedFn() + + expect(fn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mp-sc-frontend/src/utils/debounce.ts b/mp-sc-frontend/src/utils/debounce.ts new file mode 100644 index 0000000..c13f470 --- /dev/null +++ b/mp-sc-frontend/src/utils/debounce.ts @@ -0,0 +1,166 @@ +// fork from https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts +// 文档可查看:https://es-toolkit.dev/reference/function/debounce.html +// 如需要 throttle 功能,可 copy https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts + +interface DebounceOptions { + /** + * An optional AbortSignal to cancel the debounced function. + */ + signal?: AbortSignal + + /** + * An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * If `edges` includes "leading", the function will be invoked at the start of the delay period. + * If `edges` includes "trailing", the function will be invoked at the end of the delay period. + * If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period. + * @default ["trailing"] + */ + edges?: Array<'leading' | 'trailing'> +} + +export interface DebouncedFunction void> { + (...args: Parameters): void + + /** + * Schedules the execution of the debounced function after the specified debounce delay. + * This method resets any existing timer, ensuring that the function is only invoked + * after the delay has elapsed since the last call to the debounced function. + * It is typically called internally whenever the debounced function is invoked. + * + * @returns {void} + */ + schedule: () => void + + /** + * Cancels any pending execution of the debounced function. + * This method clears the active timer and resets any stored context or arguments. + */ + cancel: () => void + + /** + * Immediately invokes the debounced function if there is a pending execution. + * This method executes the function right away if there is a pending execution. + */ + flush: () => void +} + +/** + * Creates a debounced function that delays invoking the provided function until after `debounceMs` milliseconds + * have elapsed since the last time the debounced function was invoked. The debounced function also has a `cancel` + * method to cancel any pending execution. + * + * @template F - The type of function. + * @param {F} func - The function to debounce. + * @param {number} debounceMs - The number of milliseconds to delay. + * @param {DebounceOptions} options - The options object + * @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function. + * @returns A new debounced function with a `cancel` method. + * + * @example + * const debouncedFunction = debounce(() => { + * console.log('Function executed'); + * }, 1000); + * + * // Will log 'Function executed' after 1 second if not called again in that time + * debouncedFunction(); + * + * // Will not log anything as the previous call is canceled + * debouncedFunction.cancel(); + * + * // With AbortSignal + * const controller = new AbortController(); + * const signal = controller.signal; + * const debouncedWithSignal = debounce(() => { + * console.log('Function executed'); + * }, 1000, { signal }); + * + * debouncedWithSignal(); + * + * // Will cancel the debounced function call + * controller.abort(); + */ +export function debounce void>( + func: F, + debounceMs: number, + { signal, edges }: DebounceOptions = {}, +): DebouncedFunction { + let pendingThis: any + let pendingArgs: Parameters | null = null + + const leading = edges != null && edges.includes('leading') + const trailing = edges == null || edges.includes('trailing') + + const invoke = () => { + if (pendingArgs !== null) { + func.apply(pendingThis, pendingArgs) + pendingThis = undefined + pendingArgs = null + } + } + + const onTimerEnd = () => { + if (trailing) { + invoke() + } + + // eslint-disable-next-line ts/no-use-before-define + cancel() + } + + let timeoutId: ReturnType | null = null + + const schedule = () => { + if (timeoutId != null) { + clearTimeout(timeoutId) + } + + timeoutId = setTimeout(() => { + timeoutId = null + + onTimerEnd() + }, debounceMs) + } + + const cancelTimer = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + const cancel = () => { + cancelTimer() + pendingThis = undefined + pendingArgs = null + } + + const flush = () => { + invoke() + } + + const debounced = function (this: any, ...args: Parameters) { + if (signal?.aborted) { + return + } + + // eslint-disable-next-line ts/no-this-alias + pendingThis = this + pendingArgs = args + + const isFirstCall = timeoutId == null + + schedule() + + if (leading && isFirstCall) { + invoke() + } + } + + debounced.schedule = schedule + debounced.cancel = cancel + debounced.flush = flush + + signal?.addEventListener('abort', cancel, { once: true }) + + return debounced +} diff --git a/mp-sc-frontend/src/utils/index.ts b/mp-sc-frontend/src/utils/index.ts new file mode 100644 index 0000000..cdecd8e --- /dev/null +++ b/mp-sc-frontend/src/utils/index.ts @@ -0,0 +1,179 @@ +import type { PageMetaDatum, SubPackages } from '@uni-helper/vite-plugin-uni-pages' +import { isMpWeixin } from '@uni-helper/uni-env' +import { pages, subPackages } from '@/pages.json' +import { isPageTabbar } from '@/tabbar/store' + +export type PageInstance = Page.PageInstance & { $page: Page.PageInstance & { fullPath: string } } + +export function getLastPage() { + // getCurrentPages() 至少有1个元素,所以不再额外判断 + // const lastPage = getCurrentPages().at(-1) + // 上面那个在低版本安卓中打包会报错,所以改用下面这个【虽然我加了 src/interceptions/prototype.ts,但依然报错】 + const pages = getCurrentPages() + return pages[pages.length - 1] as PageInstance +} + +/** + * 获取当前页面路由的 path 路径和 redirectPath 路径 + * path 如 '/pages/login/login' + * redirectPath 如 '/pages/demo/base/route-interceptor' + */ +export function currRoute() { + const lastPage = getLastPage() as PageInstance + if (!lastPage) { + return { + path: '', + query: {}, + } + } + const currRoute = lastPage.$page + // console.log('lastPage.$page:', currRoute) + // console.log('lastPage.$page.fullpath:', currRoute.fullPath) + // console.log('lastPage.$page.options:', currRoute.options) + // console.log('lastPage.options:', (lastPage as any).options) + // 经过多端测试,只有 fullPath 靠谱,其他都不靠谱 + const { fullPath } = currRoute + // console.log(fullPath) + // eg: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor (小程序) + // eg: /pages/login/login?redirect=%2Fpages%2Froute-interceptor%2Findex%3Fname%3Dfeige%26age%3D30(h5) + return parseUrlToObj(fullPath) +} + +export function ensureDecodeURIComponent(url: string) { + if (url.startsWith('%')) { + return ensureDecodeURIComponent(decodeURIComponent(url)) + } + return url +} +/** + * 解析 url 得到 path 和 query + * 比如输入url: /pages/login/login?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor + * 输出: {path: /pages/login/login, query: {redirect: /pages/demo/base/route-interceptor}} + */ +export function parseUrlToObj(url: string) { + const [path, queryStr] = url.split('?') + // console.log(path, queryStr) + + if (!queryStr) { + return { + path, + query: {}, + } + } + const query: Record = {} + queryStr.split('&').forEach((item) => { + const [key, value] = item.split('=') + // console.log(key, value) + query[key] = ensureDecodeURIComponent(value) // 这里需要统一 decodeURIComponent 一下,可以兼容h5和微信y + }) + return { path, query } +} +/** + * 得到所有的需要登录的 pages,包括主包和分包的 + * 这里设计得通用一点,可以传递 key 作为判断依据,默认是 excludeLoginPath, 与 route-block 配对使用 + * 如果没有传 key,则表示所有的 pages,如果传递了 key, 则表示通过 key 过滤 + */ +export function getAllPages(key?: string) { + // 这里处理主包 + const mainPages = (pages as PageMetaDatum[]) + .filter(page => !key || page[key]) + .map(page => ({ + ...page, + path: `/${page.path}`, + })) + + // 这里处理分包 + const subPages: PageMetaDatum[] = [] + ;(subPackages as SubPackages).forEach((subPageObj) => { + // console.log(subPageObj) + const { root } = subPageObj + + subPageObj.pages + .filter(page => !key || page[key]) + .forEach((page) => { + subPages.push({ + ...page, + path: `/${root}/${page.path}`, + }) + }) + }) + const result = [...mainPages, ...subPages] + // console.log(`getAllPages by ${key} result: `, result) + return result +} + +export function getCurrentPageI18nKey() { + const routeObj = currRoute() + + let currPage = (pages as PageMetaDatum[]).find(page => `/${page.path}` === routeObj.path) + if (!currPage) { + // 在主包中找不到对应的页面,则在分包中找 + const allSubPages: PageMetaDatum[] = [] + subPackages?.forEach((config) => { + config.pages?.forEach((cur) => { + allSubPages.push({ + ...cur, + path: `/${config.root}/${cur.path}`, + }) + }) + }) + currPage = allSubPages.find(page => page.path === routeObj.path) + if (!currPage) { + console.warn('路由不正确') + return '' + } + } + console.log(currPage) + console.log(currPage.style.navigationBarTitleText) + return currPage.style?.navigationBarTitleText || '' +} + +export function isCurrentPageTabbar() { + const { path } = currRoute() + return isPageTabbar(path) +} + +/** + * 根据微信小程序当前环境,判断应该获取的 baseUrl + */ +export function getEnvBaseUrl() { + // 请求基准地址 + let baseUrl = import.meta.env.VITE_SERVER_BASEURL + + // # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。 + const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'http://localhost:28175' + const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'https://mp.sclktx.com/api' + const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'https://mp.sclktx.com/api' + + // 微信小程序端环境区分 + if (isMpWeixin) { + const { + miniProgram: { envVersion }, + } = uni.getAccountInfoSync() + + switch (envVersion) { + case 'develop': + baseUrl = VITE_SERVER_BASEURL__WEIXIN_DEVELOP || baseUrl + break + case 'trial': + baseUrl = VITE_SERVER_BASEURL__WEIXIN_TRIAL || baseUrl + break + case 'release': + baseUrl = VITE_SERVER_BASEURL__WEIXIN_RELEASE || baseUrl + break + } + } + + return baseUrl +} + +/** + * 是否是双token模式 + */ +export const isDoubleTokenMode = import.meta.env.VITE_AUTH_MODE === 'double' + +/** + * 首页路径,通过 page.json 里面的 type 为 home 的页面获取,如果没有,则默认是第一个页面 + * 通常为 /pages/index/index + */ +export const HOME_PAGE = `/${(pages as PageMetaDatum[]).find(page => page.type === 'home')?.path || (pages as PageMetaDatum[])[0].path}` diff --git a/mp-sc-frontend/src/utils/qrcode.ts b/mp-sc-frontend/src/utils/qrcode.ts new file mode 100644 index 0000000..9b25e81 --- /dev/null +++ b/mp-sc-frontend/src/utils/qrcode.ts @@ -0,0 +1,51 @@ +/** + * 访客预约二维码工具函数 + * 二维码内容:{ id, timestamp, sign } + * sign = md5(id + "lktx" + timestamp) + */ + +import md5 from '@/lib/md5' + +const SALT = 'lktx' + +/** + * 生成二维码数据 + */ +export function generateQRData(id: number): { id: number; timestamp: number; sign: string } { + const timestamp = Date.now() + const sign = md5(id + SALT + timestamp) + return { id, timestamp, sign } +} + +/** + * 验证二维码数据 + * @returns { valid: boolean, expired: boolean, timeDiff: number } + */ +export function verifyQRData(data: { id: number; timestamp: number; sign: string }): { + valid: boolean + expired: boolean + timeDiff: number +} { + const now = Date.now() + const timeDiff = now - data.timestamp + const expectedSign = md5(data.id + SALT + data.timestamp) + + if (data.sign !== expectedSign) { + return { valid: false, expired: false, timeDiff } + } + + return { + valid: true, + expired: timeDiff > 10000, // 10秒过期 + timeDiff, + } +} + +/** + * 格式化二维码生成时间 + */ +export function formatQRTime(timestamp: number): string { + const d = new Date(timestamp) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} diff --git a/mp-sc-frontend/src/utils/systemInfo.ts b/mp-sc-frontend/src/utils/systemInfo.ts new file mode 100644 index 0000000..a60f82e --- /dev/null +++ b/mp-sc-frontend/src/utils/systemInfo.ts @@ -0,0 +1,38 @@ +/* eslint-disable import/no-mutable-exports */ +// 获取屏幕边界到安全区域距离 +let systemInfo +let safeAreaInsets + +// #ifdef MP-WEIXIN +// 微信小程序使用新的API +systemInfo = uni.getWindowInfo() +safeAreaInsets = systemInfo.safeArea + ? { + top: systemInfo.safeArea.top, + right: systemInfo.windowWidth - systemInfo.safeArea.right, + bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom, + left: systemInfo.safeArea.left, + } + : null +// #endif + +// #ifndef MP-WEIXIN +// 其他平台继续使用uni API +systemInfo = uni.getSystemInfoSync() +safeAreaInsets = systemInfo.safeAreaInsets +// #endif + +console.log('systemInfo', systemInfo) +// 微信里面打印 +// pixelRatio: 3 +// safeArea: {top: 47, left: 0, right: 390, bottom: 810, width: 390, …} +// safeAreaInsets: {top: 47, left: 0, right: 0, bottom: 34} +// screenHeight: 844 +// screenTop: 91 +// screenWidth: 390 +// statusBarHeight: 47 +// windowBottom: 0 +// windowHeight: 753 +// windowTop: 0 +// windowWidth: 390 +export { safeAreaInsets, systemInfo } diff --git a/mp-sc-frontend/src/utils/toLoginPage.ts b/mp-sc-frontend/src/utils/toLoginPage.ts new file mode 100644 index 0000000..6f59f33 --- /dev/null +++ b/mp-sc-frontend/src/utils/toLoginPage.ts @@ -0,0 +1,27 @@ +import { getLastPage } from '@/utils' +import { debounce } from '@/utils/debounce' + +interface ToLoginPageOptions { + mode?: 'navigateTo' | 'reLaunch' + queryString?: string +} + +const LOGIN_PAGE = '/pages/auth/login' + +export const toLoginPage = debounce((options: ToLoginPageOptions = {}) => { + const { mode = 'navigateTo', queryString = '' } = options + + const url = `${LOGIN_PAGE}${queryString}` + + const currentPage = getLastPage() + const currentPath = `/${currentPage.route}` + if (currentPath === LOGIN_PAGE) { + return + } + + if (mode === 'navigateTo') { + uni.navigateTo({ url }) + } else { + uni.reLaunch({ url }) + } +}, 500) diff --git a/mp-sc-frontend/src/utils/updateManager.wx.ts b/mp-sc-frontend/src/utils/updateManager.wx.ts new file mode 100644 index 0000000..20b8b50 --- /dev/null +++ b/mp-sc-frontend/src/utils/updateManager.wx.ts @@ -0,0 +1,29 @@ +export default () => { + if (!wx.canIUse('getUpdateManager')) { + return + } + + const updateManager = wx.getUpdateManager() + + updateManager.onCheckForUpdate((res) => { + // 请求完新版本信息的回调 + console.log('版本信息', res) + }) + + updateManager.onUpdateReady(() => { + wx.showModal({ + title: '更新提示', + content: '新版本已经准备好,是否重启应用?', + success(res) { + if (res.confirm) { + // 新的版本已经下载好,调用 applyUpdate 应用新版本并重启 + updateManager.applyUpdate() + } + }, + }) + }) + + updateManager.onUpdateFailed(() => { + // 新版本下载失败 + }) +} diff --git a/mp-sc-frontend/src/utils/uploadFile.ts b/mp-sc-frontend/src/utils/uploadFile.ts new file mode 100644 index 0000000..ac5a99b --- /dev/null +++ b/mp-sc-frontend/src/utils/uploadFile.ts @@ -0,0 +1,325 @@ +/** + * 文件上传钩子函数使用示例 + * @example + * const { loading, error, data, progress, run } = useUpload( + * uploadUrl, + * {}, + * { + * maxSize: 5, // 最大5MB + * sourceType: ['album'], // 仅支持从相册选择 + * onProgress: (p) => console.log(`上传进度:${p}%`), + * onSuccess: (res) => console.log('上传成功', res), + * onError: (err) => console.error('上传失败', err), + * }, + * ) + */ + +/** + * 上传文件的URL配置 + */ +export const API_PREFIX = import.meta.env.VITE_SERVER_HAS_API_PREFIX === 'true' ? '/api/v1' : '/v1' = { + /** 用户头像上传地址 */ + USER_AVATAR: `${import.meta.env.VITE_SERVER_BASEURL}/user/avatar`, +} + +/** + * 通用文件上传函数(支持直接传入文件路径) + * @param url 上传地址 + * @param filePath 本地文件路径 + * @param formData 额外表单数据 + * @param options 上传选项 + */ +export function useFileUpload(url: string, filePath: string, formData: Record = {}, options: Omit = {}) { + return useUpload( + url, + formData, + { + ...options, + sourceType: ['album'], + sizeType: ['original'], + }, + filePath, + ) +} + +export interface UploadOptions { + /** 最大可选择的图片数量,默认为1 */ + count?: number + /** 所选的图片的尺寸,original-原图,compressed-压缩图 */ + sizeType?: Array<'original' | 'compressed'> + /** 选择图片的来源,album-相册,camera-相机 */ + sourceType?: Array<'album' | 'camera'> + /** 文件大小限制,单位:MB */ + maxSize?: number // + /** 上传进度回调函数 */ + onProgress?: (progress: number) => void + /** 上传成功回调函数 */ + onSuccess?: (res: Record) => void + /** 上传失败回调函数 */ + onError?: (err: Error | UniApp.GeneralCallbackResult) => void + /** 上传完成回调函数(无论成功失败) */ + onComplete?: () => void +} + +/** + * 文件上传钩子函数 + * @template T 上传成功后返回的数据类型 + * @param url 上传地址 + * @param formData 额外的表单数据 + * @param options 上传选项 + * @returns 上传状态和控制对象 + */ +export function useUpload(url: string, formData: Record = {}, options: UploadOptions = {}, + /** 直接传入文件路径,跳过选择器 */ + directFilePath?: string) { + /** 上传中状态 */ + const loading = ref(false) + /** 上传错误状态 */ + const error = ref(false) + /** 上传成功后的响应数据 */ + const data = ref() + /** 上传进度(0-100) */ + const progress = ref(0) + + /** 解构上传选项,设置默认值 */ + const { + /** 最大可选择的图片数量 */ + count = 1, + /** 所选的图片的尺寸 */ + sizeType = ['original', 'compressed'], + /** 选择图片的来源 */ + sourceType = ['album', 'camera'], + /** 文件大小限制(MB) */ + maxSize = 10, + /** 进度回调 */ + onProgress, + /** 成功回调 */ + onSuccess, + /** 失败回调 */ + onError, + /** 完成回调 */ + onComplete, + } = options + + /** + * 检查文件大小是否超过限制 + * @param size 文件大小(字节) + * @returns 是否通过检查 + */ + const checkFileSize = (size: number) => { + const sizeInMB = size / 1024 / 1024 + if (sizeInMB > maxSize) { + uni.showToast({ + title: `文件大小不能超过${maxSize}MB`, + icon: 'none', + }) + return false + } + return true + } + /** + * 触发文件选择和上传 + * 根据平台使用不同的选择器: + * - 微信小程序使用 chooseMedia + * - 其他平台使用 chooseImage + */ + const run = () => { + if (directFilePath) { + // 直接使用传入的文件路径 + loading.value = true + progress.value = 0 + uploadFile({ + url, + tempFilePath: directFilePath, + formData, + data, + error, + loading, + progress, + onProgress, + onSuccess, + onError, + onComplete, + }) + return + } + + // #ifdef MP-WEIXIN + // 微信小程序环境下使用 chooseMedia API + uni.chooseMedia({ + count, + mediaType: ['image'], // 仅支持图片类型 + sourceType, + success: (res) => { + const file = res.tempFiles[0] + // 检查文件大小是否符合限制 + if (!checkFileSize(file.size)) + return + + // 开始上传 + loading.value = true + progress.value = 0 + uploadFile({ + url, + tempFilePath: file.tempFilePath, + formData, + data, + error, + loading, + progress, + onProgress, + onSuccess, + onError, + onComplete, + }) + }, + fail: (err) => { + console.error('选择媒体文件失败:', err) + error.value = true + onError?.(err) + }, + }) + // #endif + + // #ifndef MP-WEIXIN + // 非微信小程序环境下使用 chooseImage API + uni.chooseImage({ + count, + sizeType, + sourceType, + success: (res) => { + console.log('选择图片成功:', res) + + // 开始上传 + loading.value = true + progress.value = 0 + uploadFile({ + url, + tempFilePath: res.tempFilePaths[0], + formData, + data, + error, + loading, + progress, + onProgress, + onSuccess, + onError, + onComplete, + }) + }, + fail: (err) => { + console.error('选择图片失败:', err) + error.value = true + onError?.(err) + }, + }) + // #endif + } + + return { loading, error, data, progress, run } +} + +/** + * 文件上传选项接口 + * @template T 上传成功后返回的数据类型 + */ +interface UploadFileOptions { + /** 上传地址 */ + url: string + /** 临时文件路径 */ + tempFilePath: string + /** 额外的表单数据 */ + formData: Record + /** 上传成功后的响应数据 */ + data: Ref + /** 上传错误状态 */ + error: Ref + /** 上传中状态 */ + loading: Ref + /** 上传进度(0-100) */ + progress: Ref + /** 上传进度回调 */ + onProgress?: (progress: number) => void + /** 上传成功回调 */ + onSuccess?: (res: Record) => void + /** 上传失败回调 */ + onError?: (err: Error | UniApp.GeneralCallbackResult) => void + /** 上传完成回调 */ + onComplete?: () => void +} + +/** + * 执行文件上传 + * @template T 上传成功后返回的数据类型 + * @param options 上传选项 + */ +function uploadFile({ + url, + tempFilePath, + formData, + data, + error, + loading, + progress, + onProgress, + onSuccess, + onError, + onComplete, +}: UploadFileOptions) { + try { + // 创建上传任务 + const uploadTask = uni.uploadFile({ + url, + filePath: tempFilePath, + name: 'file', // 文件对应的 key + formData, + header: { + // H5环境下不需要手动设置Content-Type,让浏览器自动处理multipart格式 + // #ifndef H5 + 'Content-Type': 'multipart/form-data', + // #endif + }, + // 确保文件名称合法 + success: (uploadFileRes) => { + console.log('上传文件成功:', uploadFileRes) + try { + // 解析响应数据 + const { data: _data } = JSON.parse(uploadFileRes.data) + // 上传成功 + data.value = _data as T + onSuccess?.(_data) + } + catch (err) { + // 响应解析错误 + console.error('解析上传响应失败:', err) + error.value = true + onError?.(new Error('上传响应解析失败')) + } + }, + fail: (err) => { + // 上传请求失败 + console.error('上传文件失败:', err) + error.value = true + onError?.(err) + }, + complete: () => { + // 无论成功失败都执行 + loading.value = false + onComplete?.() + }, + }) + + // 监听上传进度 + uploadTask.onProgressUpdate((res) => { + progress.value = res.progress + onProgress?.(res.progress) + }) + } + catch (err) { + // 创建上传任务失败 + console.error('创建上传任务失败:', err) + error.value = true + loading.value = false + onError?.(new Error('创建上传任务失败')) + } +} diff --git a/mp-sc-frontend/tsconfig.json b/mp-sc-frontend/tsconfig.json new file mode 100644 index 0000000..d8b6672 --- /dev/null +++ b/mp-sc-frontend/tsconfig.json @@ -0,0 +1,58 @@ +{ + "compilerOptions": { + "composite": true, + "lib": [ + "esnext", + "dom" + ], + "baseUrl": ".", + "module": "esnext", + "moduleResolution": "bundler", + "paths": { + "@/*": [ + "./src/*" + ], + "@img/*": [ + "./src/static/*" + ] + }, + "resolveJsonModule": true, + "types": [ + "@dcloudio/types", + "@uni-helper/uni-types", + "@uni-helper/vite-plugin-uni-pages", + "miniprogram-api-typings", + "z-paging/types", + "vitest/globals", + "./src/types/async-component.d.ts", + "./src/types/async-import.d.ts", + "./src/typings.d.ts", + "@wot-ui/ui/global" + ], + "allowJs": true, + "noImplicitThis": true, + "outDir": "dist", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true + }, + "vueCompilerOptions": { + "plugins": [ + "@uni-helper/uni-types/volar-plugin" + ] + }, + "include": [ + "package.json", + "src/**/*.ts", + "src/**/*.js", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.jsx", + "src/**/*.vue", + "src/**/*.json" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/mp-sc-frontend/uno.config.ts b/mp-sc-frontend/uno.config.ts new file mode 100644 index 0000000..c81c7cd --- /dev/null +++ b/mp-sc-frontend/uno.config.ts @@ -0,0 +1,137 @@ +import type { Preset } from 'unocss' +import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders' + +// https://www.npmjs.com/package/@uni-helper/unocss-preset-uni +import { presetUni } from '@uni-helper/unocss-preset-uni' +// @see https://unocss.dev/presets/legacy-compat +import { presetLegacyCompat } from '@unocss/preset-legacy-compat' +import { + defineConfig, + presetIcons, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUni({ + attributify: false, + }), + presetIcons({ + scale: 1.2, + warn: true, + extraProperties: { + 'display': 'inline-block', + 'vertical-align': 'middle', + }, + collections: { + // 注册本地 SVG 图标集合, 从本地文件系统加载图标 + // 在 './src/static/my-icons' 目录下的所有 svg 文件将被注册为图标, + // my-icons 是图标集合名称,使用 `i-my-icons-图标名` 调用 + 'my-icons': FileSystemIconLoader( + './src/static/my-icons', + // 可选的,你可以提供一个 transform 回调来更改每个图标 + (svg) => { + let svgStr = svg + + // 如果 SVG 文件未定义 `fill` 属性,则默认填充 `currentColor`, 这样图标颜色会继承文本颜色,方便在不同场景下适配 + svgStr = svgStr.includes('fill="') + ? svgStr + : svgStr.replace(/^ `rgb(255, 0, 0)` + // `rgba(255 0 0 / 0.5)` -> `rgba(255, 0, 0, 0.5)` + presetLegacyCompat({ + commaStyleColorFunction: true, + legacyColorSpace: true, // by QQ4群-量子蔷薇 + // @菲鸽 unocss 配置中,建议在 presetLegacyCompat 中添加 legacyColorSpace: true,以去除生成的颜色样式中的 in oklch 关键字,现在发现有些渐变色生成不符合预期 + }) as Preset, + ], + transformers: [ + // 启用指令功能:主要用于支持 @apply、@screen 和 theme() 等 CSS 指令 + transformerDirectives(), + // 启用 () 分组功能 + // 支持css class组合,eg: `
测试 unocss
` + transformerVariantGroup(), + ], + shortcuts: [ + { + center: 'flex justify-center items-center', + }, + ], + // 动态图标需要在这里配置,或者写在vue页面中注释掉 + safelist: [ + 'i-carbon-code', + 'i-carbon-home', + 'i-carbon-user', + 'i-carbon-ibm-watson-language-translator', + 'i-carbon-menu', + 'i-carbon-notification', + 'i-carbon-chevron-right', + 'i-carbon-add', + 'i-carbon-edit', + 'i-carbon-list', + 'i-carbon-time', + 'i-carbon-scan', + 'i-carbon-phone', + 'i-carbon-checkmark', + 'i-carbon-application', + ], + rules: [ + [ + 'p-safe', + { + padding: + 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)', + }, + ], + ['pt-safe', { 'padding-top': 'env(safe-area-inset-top)' }], + ['pb-safe', { 'padding-bottom': 'env(safe-area-inset-bottom)' }], + ], + theme: { + colors: { + /** 主题色,用法如: text-primary */ + primary: 'var(--wot-color-theme,#0957DE)', + }, + fontSize: { + /** 提供更小号的字体,用法如:text-2xs */ + '2xs': ['20rpx', '28rpx'], + '3xs': ['18rpx', '26rpx'], + }, + }, + // windows 系统会报错:[plugin:unocss:transformers:pre] Cannot overwrite a zero-length range - use append Left or prependRight instead. + // 去掉下面的就正常了 + // content: { + // /** + // * 解决小程序报错 `./app.wxss(78:2814): unexpected unexpected at pos 5198` + // * 为什么同时使用include和exclude?虽然看起来多余,但同时配置两者是一种常见的 `防御性编程` 做法。 + // 1. 结构变化保障 : 如果未来项目结构发生变化,某些排除目录可能被移动到包含路径下,exclude配置可以确保它们仍被排除 + // 2. 明确性 : 明确列出要排除的目录使配置意图更加清晰 + // 3. 性能优化 : 避免处理不必要的文件,提高构建性能 + // 4. 防止冲突 : 排除第三方库和构建输出目录,避免潜在的CSS冲突 + // */ + // pipeline: { + // exclude: [ + // 'node_modules/**/*', + // 'public/**/*', + // 'dist/**/*', + // ], + // include: [ + // './src/**/*', + // ], + // }, + // }, +}) diff --git a/mp-sc-frontend/vite-plugins/README.md b/mp-sc-frontend/vite-plugins/README.md new file mode 100644 index 0000000..956a73e --- /dev/null +++ b/mp-sc-frontend/vite-plugins/README.md @@ -0,0 +1,236 @@ +# unibest原生插件资源复制插件 + +## 概述 + +`copy-native-resources.ts` 是一个专为 基于unibest框架的UniApp 项目设计的 Vite 插件,用于解决使用原生插件时打包后出现"插件找不到"的问题。该插件会在构建过程中自动将本地原生插件资源复制到正确的目标目录。 + +## 功能特性 + +- ✅ 自动复制原生插件资源到构建目录 +- ✅ 支持环境变量控制插件启用/禁用 +- ✅ 支持详细日志输出用于调试 +- ✅ 智能检测源目录是否存在 + +## 目录结构 + +根据 [UniApp 官方文档](https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6),本地原生插件应存储在项目根目录的 `nativeplugins` 目录下: + +``` +项目根目录/ +├── nativeplugins/ # 原生插件存储目录(官方规范) +│ ├── HL-HHWUHFController/ # 示例:RFID 控制器插件 +│ │ ├── android/ # Android 平台资源 +│ │ │ ├── libs/ # Android 库文件 +│ │ │ └── res/ # Android 资源文件 +│ │ ├── ios/ # iOS 平台资源(如果有) +│ │ └── package.json # 插件配置文件 +│ └── 其他原生插件/ +├── src/ +├── vite-plugins/ +│ ├── copy-native-resources.ts # 本插件文件 +│ └── README.md # 本文档 +└── vite.config.ts +``` + +## 安装配置 + +### 1. 环境变量配置 + +在 `env/.env` 文件中添加以下配置: + +```bash +# 是否启用原生插件资源复制 +VITE_COPY_NATIVE_RES_ENABLE = true +``` + +### 2. Vite 配置 + +在 `vite.config.ts` 中引入并使用插件: + +```typescript +import { createCopyNativeResourcesPlugin } from './vite-plugins/copy-native-resources' + +export default defineConfig({ + plugins: [ + // 其他插件... + + // 原生插件资源复制插件 + createCopyNativeResourcesPlugin( + UNI_PLATFORM === 'app' && VITE_COPY_NATIVE_RES_ENABLE === 'true', + { + verbose: mode === 'development', // 开发模式显示详细日志 + }, + ), + + // 其他插件... + ], +}) +``` + +### 3. manifest.config.ts 配置 + +在 `manifest.config.ts` 中配置原生插件: + +```typescript +export default defineManifest({ + // 其他配置... + + 'app-plus': { + // 其他配置... + + // 原生插件配置 + nativePlugins: { + // RFID 控制器插件示例 + 'HL-HHWUHFController': { + __plugin_info__: { + name: 'HL-HHWUHFController', + description: 'RFID UHF 控制器插件', + platforms: 'Android', + url: '', + android_package_name: '', + ios_bundle_id: '', + isCloud: false, + bought: -1, + pid: '', + parameters: {} + } + } + } + } +}) +``` + +## 插件配置选项 + +```typescript +interface CopyNativeResourcesOptions { + /** 是否启用插件 */ + enable?: boolean + + /** + * 源目录路径,相对于项目根目录 + * 默认为 'nativeplugins',符合 UniApp 官方规范 + */ + sourceDir?: string + + /** + * 目标目录名称,构建后在 dist 目录中的文件夹名 + * 默认为 'nativeplugins',与源目录保持一致 + */ + targetDirName?: string + + /** 是否显示详细日志 */ + verbose?: boolean +} +``` + +## 使用示例 + +### 基础使用 + +```typescript +// 使用默认配置 +createCopyNativeResourcesPlugin(true) +``` + +### 自定义配置 + +```typescript +// 自定义配置 +createCopyNativeResourcesPlugin(true, { + sourceDir: 'nativeplugins', // 源目录 + targetDirName: 'nativeplugins', // 目标目录名 + verbose: true // 显示详细日志 +}) +``` + +### 条件启用 + +```typescript +// 仅在 app 平台且环境变量启用时生效 +createCopyNativeResourcesPlugin( + UNI_PLATFORM === 'app' && VITE_COPY_NATIVE_RES_ENABLE === 'true', + { verbose: mode === 'development' } +) +``` + +## 工作原理 + +1. **构建时机**:插件在 Vite 的 `writeBundle` 阶段执行 +2. **目录检测**:检查源目录 `nativeplugins` 是否存在 +3. **资源复制**:将整个 `nativeplugins` 目录复制到构建输出目录 +4. **路径处理**:自动处理不同平台的路径差异 +5. **日志输出**:根据配置显示复制过程的详细信息 + +## 构建输出结构 + +插件会将原生插件资源复制到以下位置: + +``` +dist/ +├── build/ +│ └── app/ +│ └── nativeplugins/ # 生产环境构建 +│ └── HL-HHWUHFController/ +└── dev/ + └── app/ + └── nativeplugins/ # 开发环境构建 + └── HL-HHWUHFController/ +``` + +## 常见问题 + +### Q: 为什么要使用这个插件? + +A: 目前使用unibest框架在打包时可能不会自动复制原生插件资源,导致运行时出现"插件找不到"的错误。此插件确保原生插件资源被正确复制到构建目录。 + +### Q: 插件不生效怎么办? + +A: 检查以下几点: +1. 确认 `nativeplugins` 目录存在且包含插件文件 +2. 确认环境变量 `VITE_COPY_NATIVE_RES_ENABLE` 设置为 `true` +3. 确认当前平台为 `app`(插件仅在 app 平台生效) +4. 开启 `verbose: true` 查看详细日志 + +### Q: 可以自定义源目录吗? + +A: 可以,但不推荐。UniApp 官方规范要求使用 `nativeplugins` 目录,自定义可能导致其他问题。 + +### Q: 支持哪些平台? + +A: 插件本身支持所有平台,但通常只在 `app` 平台(目前只测试了Android环境,iOS有条件的伙伴可以测试后反馈)使用原生插件。 + + +### Q: 产生权限冲突问题? + +A: 有伙伴反馈过接入的原生插件之前使用【Lastly1999】提交的版本初步解决了问题,但是又遇到两个新的问题: +- 导入的两个插件内的权限配置有版本冲突,在云打包的最后一步会报错,然后通过修改其中一个aar配置版本解决的。 +- 测试发现在android版本大于12的手机,获取相册权限后,打开相册看不到里面的照片,将两个插件删除就没问题 ,可以正常显示,不删除就会有问题,怀疑是插件的AndroidManifest.xml覆盖了项目内manifest.config.ts的安卓权限申请 +也欢迎其他有伙伴反馈,望能一起解决。 + +## 更新日志 + +### v1.0.0 +- 初始版本发布 +- 支持基础的原生插件资源复制功能 + +### v1.1.0 +- 更新为符合 UniApp 官方规范的 `nativeplugins` 目录结构 +- 修复 ESLint 警告 +- 增加详细的代码注释和文档 +- 优化错误处理和日志输出 + +## 技术支持 + +如果在使用过程中遇到问题,请检查: + +1. UniApp 官方文档:[本地插件配置](https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6) +2. 插件配置是否正确 +3. 目录结构是否符合规范 +4. 环境变量是否正确设置 + +## 特别声明及感谢 + +- 感谢【Lastly1999】,此插件时基于他pr的代码进行的还原和修改。[fix: app-plus、dev/prod、nativeResources插件未被正确移](https://gitee.com/feige996/unibest/commit/22e0bd5cfb47a4927373fe88be6809216f43d046) +- 感谢【菲鸽】造了这么好用的框架 + diff --git a/mp-sc-frontend/vite-plugins/copy-native-resources.ts b/mp-sc-frontend/vite-plugins/copy-native-resources.ts new file mode 100644 index 0000000..d0caa4b --- /dev/null +++ b/mp-sc-frontend/vite-plugins/copy-native-resources.ts @@ -0,0 +1,198 @@ +import type { Plugin } from 'vite' +import path from 'node:path' +import process from 'node:process' +import fs from 'fs-extra' + +/** + * 原生插件资源复制配置接口 + * + * 根据 UniApp 官方文档:https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6 + * 本地插件应该存储在项目根目录的 nativeplugins 目录下 + */ +export interface CopyNativeResourcesOptions { + /** 是否启用插件 */ + enable?: boolean + /** + * 源目录路径,相对于项目根目录 + * 默认为 'nativeplugins',符合 UniApp 官方规范 + * @see https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6 + */ + sourceDir?: string + /** + * 目标目录名称,构建后在 dist 目录中的文件夹名 + * 默认为 'nativeplugins',与源目录保持一致 + */ + targetDirName?: string + /** 是否显示详细日志,便于调试和监控复制过程 */ + verbose?: boolean + /** 自定义日志前缀,用于区分不同插件的日志输出 */ + logPrefix?: string +} + +/** + * 默认配置 + * + * 根据 UniApp 官方文档规范设置默认值: + * - sourceDir: 'nativeplugins' - 符合官方本地插件存储规范 + * - targetDirName: 'nativeplugins' - 构建后保持相同的目录结构 + */ +const DEFAULT_OPTIONS: Required = { + enable: true, + sourceDir: 'nativeplugins', + targetDirName: 'nativeplugins', + verbose: true, + logPrefix: '[copy-native-resources]', +} + +/** + * UniApp 原生插件资源复制插件 + * + * 功能说明: + * 1. 解决 UniApp 使用本地原生插件时,打包后原生插件资源找不到的问题 + * 2. 将项目根目录下的 nativeplugins 目录复制到构建输出目录中 + * 3. 支持 Android 和 iOS 平台的原生插件资源复制 + * 4. 仅在 app 平台构建时生效,其他平台(H5、小程序)不执行 + * + * 使用场景: + * - 使用了 UniApp 本地原生插件(非云端插件) + * - 原生插件包含额外的资源文件(如 .so 库文件、配置文件等) + * - 需要在打包后保持原生插件的完整目录结构 + * + * 官方文档参考: + * @see https://uniapp.dcloud.net.cn/plugin/native-plugin.html#%E6%9C%AC%E5%9C%B0%E6%8F%92%E4%BB%B6-%E9%9D%9E%E5%86%85%E7%BD%AE%E5%8E%9F%E7%94%9F%E6%8F%92%E4%BB%B6 + * @see https://uniapp.dcloud.net.cn/tutorial/nvue-api.html#dom + * + * @param options 插件配置选项 + * @returns Vite 插件对象 + */ +export function copyNativeResources(options: CopyNativeResourcesOptions = {}): Plugin { + const config = { ...DEFAULT_OPTIONS, ...options } + + // 如果插件被禁用,返回一个空插件 + if (!config.enable) { + return { + name: 'copy-native-resources-disabled', + apply: 'build', + writeBundle() { + // 插件已禁用,不执行任何操作 + }, + } + } + + return { + name: 'copy-native-resources', + apply: 'build', // 只在构建时应用 + enforce: 'post', // 在其他插件执行完毕后执行 + + async writeBundle() { + const { sourceDir, targetDirName, verbose, logPrefix } = config + + try { + // 获取项目根目录路径 + const projectRoot = process.cwd() + + // 构建源目录绝对路径(项目根目录下的 nativeplugins 目录) + const sourcePath = path.resolve(projectRoot, sourceDir) + + // 构建目标路径:dist/[build|dev]/[platform]/nativeplugins + // buildMode: 'build' (生产环境) 或 'dev' (开发环境) + // platform: 'app' (App平台) 或其他平台标识 + const buildMode = process.env.NODE_ENV === 'production' ? 'build' : 'dev' + const platform = process.env.UNI_PLATFORM || 'app' + const targetPath = path.resolve( + projectRoot, + 'dist', + buildMode, + platform, + targetDirName, + ) + + // 检查源目录是否存在 + // 如果不存在 nativeplugins 目录,说明项目没有使用本地原生插件 + const sourceExists = await fs.pathExists(sourcePath) + if (!sourceExists) { + if (verbose) { + console.warn(`${logPrefix} 源目录不存在,跳过复制操作`) + console.warn(`${logPrefix} 源目录路径: ${sourcePath}`) + console.warn(`${logPrefix} 如需使用本地原生插件,请在项目根目录创建 nativeplugins 目录`) + console.warn(`${logPrefix} 并按照官方文档放入原生插件文件`) + console.warn(`${logPrefix} 参考: https://uniapp.dcloud.net.cn/plugin/native-plugin.html`) + } + return + } + + // 检查源目录是否为空 + // 如果目录存在但为空,也跳过复制操作 + const sourceFiles = await fs.readdir(sourcePath) + if (sourceFiles.length === 0) { + if (verbose) { + console.warn(`${logPrefix} 源目录为空,跳过复制操作`) + console.warn(`${logPrefix} 源目录路径: ${sourcePath}`) + console.warn(`${logPrefix} 请在 nativeplugins 目录中放入原生插件文件`) + } + return + } + + // 确保目标目录及其父目录存在 + await fs.ensureDir(targetPath) + + if (verbose) { + console.log(`${logPrefix} 开始复制 UniApp 本地原生插件...`) + console.log(`${logPrefix} 源目录: ${sourcePath}`) + console.log(`${logPrefix} 目标目录: ${targetPath}`) + console.log(`${logPrefix} 构建模式: ${buildMode}`) + console.log(`${logPrefix} 目标平台: ${platform}`) + console.log(`${logPrefix} 发现 ${sourceFiles.length} 个原生插件文件/目录`) + } + + // 执行文件复制操作 + // 将整个 nativeplugins 目录复制到构建输出目录 + await fs.copy(sourcePath, targetPath, { + overwrite: true, // 覆盖已存在的文件,确保使用最新版本 + errorOnExist: false, // 如果目标文件存在不报错 + preserveTimestamps: true, // 保持文件的时间戳 + }) + + console.log(`${logPrefix} ✅ UniApp 本地原生插件复制完成: ${sourcePath} -> ${targetPath}`) + console.log(`${logPrefix} 已成功复制 ${sourceFiles.length} 个文件/目录到构建目录`) + } + catch (error) { + console.error(`${config.logPrefix} ❌ 复制 UniApp 本地原生插件失败:`, error) + console.error(`${config.logPrefix} 错误详情:`, error instanceof Error ? error.message : String(error)) + console.error(`${config.logPrefix} 请检查源目录权限和磁盘空间`) + // 不抛出错误,避免影响整个构建过程,但会记录详细的错误信息 + } + }, + } +} + +/** + * 创建 UniApp 本地原生插件资源复制插件的便捷函数 + * + * 这是一个便捷的工厂函数,用于快速创建插件实例 + * 特别适用于在 vite.config.ts 中进行条件性插件配置 + * + * 使用示例: + * ```typescript + * // 在 vite.config.ts 中 + * plugins: [ + * // 仅在 app 平台且启用时生效 + * UNI_PLATFORM === 'app' + * ? createCopyNativeResourcesPlugin( + * VITE_COPY_NATIVE_RES_ENABLE === 'true', + * { verbose: mode === 'development' } + * ) + * : null, + * ] + * ``` + * + * @param enable 是否启用插件,通常通过环境变量控制 + * @param options 其他配置选项,不包含 enable 属性 + * @returns Vite 插件对象 + */ +export function createCopyNativeResourcesPlugin( + enable: boolean = true, + options: Omit = {}, +): Plugin { + return copyNativeResources({ enable, ...options }) +} diff --git a/mp-sc-frontend/vite-plugins/sync-manifest-plugins.ts b/mp-sc-frontend/vite-plugins/sync-manifest-plugins.ts new file mode 100644 index 0000000..4f5b273 --- /dev/null +++ b/mp-sc-frontend/vite-plugins/sync-manifest-plugins.ts @@ -0,0 +1,68 @@ +import type { Plugin } from 'vite' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +interface ManifestType { + 'plus'?: { + distribute?: { + plugins?: Record + } + } + 'app-plus'?: { + distribute?: { + plugins?: Record + } + } +} + +export default function syncManifestPlugin(): Plugin { + return { + name: 'sync-manifest', + apply: 'build', + enforce: 'post', + writeBundle: { + order: 'post', + handler() { + const srcManifestPath = path.resolve(process.cwd(), './src/manifest.json') + const distAppPath = path.resolve(process.cwd(), './dist/dev/app/manifest.json') + + try { + // 读取源文件 + const srcManifest = JSON.parse(fs.readFileSync(srcManifestPath, 'utf8')) as ManifestType + + // 确保目标目录存在 + const distAppDir = path.dirname(distAppPath) + if (!fs.existsSync(distAppDir)) { + fs.mkdirSync(distAppDir, { recursive: true }) + } + + // 读取目标文件(如果存在) + let distManifest: ManifestType = {} + if (fs.existsSync(distAppPath)) { + distManifest = JSON.parse(fs.readFileSync(distAppPath, 'utf8')) + } + + // 如果源文件存在 plugins + if (srcManifest['app-plus']?.distribute?.plugins) { + // 确保目标文件中有必要的对象结构 + if (!distManifest.plus) + distManifest.plus = {} + if (!distManifest.plus.distribute) + distManifest.plus.distribute = {} + + // 复制 plugins 内容 + distManifest.plus.distribute.plugins = srcManifest['app-plus'].distribute.plugins + + // 写入更新后的内容 + fs.writeFileSync(distAppPath, JSON.stringify(distManifest, null, 2)) + console.log('✅ Manifest plugins 同步成功') + } + } + catch (error) { + console.error('❌ 同步 manifest plugins 失败:', error) + } + }, + }, + } +} diff --git a/mp-sc-frontend/vite.config.ts b/mp-sc-frontend/vite.config.ts new file mode 100644 index 0000000..3d33a92 --- /dev/null +++ b/mp-sc-frontend/vite.config.ts @@ -0,0 +1,218 @@ +import path from 'node:path' +import process from 'node:process' +import Uni from '@uni-helper/plugin-uni' +import { isMpWeixin } from '@uni-helper/uni-env' +import UniComponents from '@uni-helper/vite-plugin-uni-components' +// @see https://uni-helper.js.org/vite-plugin-uni-layouts +import UniLayouts from '@uni-helper/vite-plugin-uni-layouts' +// @see https://github.com/uni-helper/vite-plugin-uni-manifest +import UniManifest from '@uni-helper/vite-plugin-uni-manifest' +// @see https://uni-helper.js.org/vite-plugin-uni-pages +import UniPages from '@uni-helper/vite-plugin-uni-pages' +import { WotResolver } from './wot-ui-resolver' +// @see https://github.com/uni-helper/vite-plugin-uni-platform +// 需要与 @uni-helper/vite-plugin-uni-pages 插件一起使用 +import UniPlatform from '@uni-helper/vite-plugin-uni-platform' + +/** + * 分包优化、模块异步跨包调用、组件异步跨包引用 + * @see https://github.com/uni-ku/bundle-optimizer + */ +import UniOptimization from '@uni-ku/bundle-optimizer' +// https://github.com/uni-ku/root +import UniKuRoot from '@uni-ku/root' +import dayjs from 'dayjs' +import { visualizer } from 'rollup-plugin-visualizer' +import UnoCSS from 'unocss/vite' +import AutoImport from 'unplugin-auto-import/vite' +import { defineConfig, loadEnv } from 'vite' +import ViteRestart from 'vite-plugin-restart' +import openDevTools from './scripts/open-dev-tools' +import vitePluginEruda from './scripts/vite-plugin-eruda' +import { createCopyNativeResourcesPlugin } from './vite-plugins/copy-native-resources' +import syncManifestPlugin from './vite-plugins/sync-manifest-plugins' + +// https://vitejs.dev/config/ +export default defineConfig(({ command, mode }) => { + // @see https://unocss.dev/ + // const UnoCSS = (await import('unocss/vite')).default + // console.log(mode === process.env.NODE_ENV) // true + + // mode: 区分生产环境还是开发环境 + console.log('command, mode -> ', command, mode) + // pnpm dev:h5 时得到 => serve development + // pnpm build:h5 时得到 => build production + // pnpm dev:mp-weixin 时得到 => build development (注意区别,command为build) + // pnpm build:mp-weixin 时得到 => build production + // pnpm dev:app 时得到 => build development (注意区别,command为build) + // pnpm build:app 时得到 => build production + // dev 和 build 命令可以分别使用 .env.development 和 .env.production 的环境变量 + // 非 H5 端 dev 也是 build command,最终加载哪个 env 文件以实际 mode 为准。 + + const { UNI_PLATFORM, SKIP_OPEN_DEVTOOLS } = process.env + console.log('UNI_PLATFORM -> ', UNI_PLATFORM) // 得到 mp-weixin, h5, app 等 + + const envDir = path.resolve(process.cwd(), 'env') + const env = loadEnv(mode, envDir) + const localEnv = loadEnv(mode, envDir, '') + const { + VITE_APP_PORT, + VITE_SERVER_BASEURL, + VITE_APP_TITLE, + VITE_DELETE_CONSOLE, + VITE_APP_PUBLIC_BASE, + VITE_APP_PROXY_ENABLE, + VITE_APP_PROXY_PREFIX, + VITE_COPY_NATIVE_RES_ENABLE, + } = env + const { WECHAT_DEVTOOLS_CLI_PATH } = localEnv + console.log('环境变量 env -> ', env) + + return defineConfig({ + envDir: './env', // 自定义env目录 + base: VITE_APP_PUBLIC_BASE, + plugins: [ + // UniXXX 需要在 Uni 之前引入 + UniLayouts(), + UniPlatform(), + UniManifest(), + UniComponents({ + extensions: ['vue'], + deep: true, // 是否递归扫描子目录, + directoryAsNamespace: false, // 是否把目录名作为命名空间前缀,true 时组件名为 目录名+组件名, + dts: 'src/types/components.d.ts', // 自动生成的组件类型声明文件路径(用于 TypeScript 支持) + resolvers: [WotResolver()], + }), + UniPages({ + exclude: ['**/components/**/**.*', '**/sections/**/**.*'], + // pages 目录为 src/pages,分包目录不能配置在pages目录下!! + // 是个数组,可以配置多个,但是不能为pages里面的目录!! + // 分包目录统一放在 src/subpackages 下,每个分包是一个独立的业务模块 + subPackages: [ + 'src/subpackages/appointment', + 'src/subpackages/system', + ], + dts: 'src/types/uni-pages.d.ts', + }), + // UniOptimization 插件需要 page.json 文件,故应在 UniPages 插件之后执行 + UniOptimization({ + enable: isMpWeixin, + dts: { + base: 'src/types', + }, + logger: false, + }), + // 若存在改变 pages.json 的插件,请将 UniKuRoot 放置其后 + UniKuRoot({ + excludePages: ['**/components/**/**.*', '**/sections/**/**.*'], + }), + Uni(), + { + // 临时解决 dcloudio 官方的 @dcloudio/uni-mp-compiler 出现的编译 BUG + // 参考 github issue: https://github.com/dcloudio/uni-app/issues/4952 + // 自定义插件禁用 vite:vue 插件的 devToolsEnabled,强制编译 vue 模板时 inline 为 true + name: 'fix-vite-plugin-vue', + configResolved(config) { + const plugin = config.plugins.find(p => p.name === 'vite:vue') + if (plugin && plugin.api && plugin.api.options) { + plugin.api.options.devToolsEnabled = false + } + }, + }, + UnoCSS(), + AutoImport({ + imports: ['vue', 'uni-app'], + dts: 'src/types/auto-import.d.ts', + dirs: ['src/hooks'], // 自动导入 hooks + vueTemplate: true, // default false + }), + ViteRestart({ + // 通过这个插件,在修改vite.config.js文件则不需要重新运行也生效配置 + restart: ['vite.config.js'], + }), + // h5环境增加 BUILD_TIME 和 BUILD_BRANCH + UNI_PLATFORM === 'h5' && { + name: 'html-transform', + transformIndexHtml(html) { + return html + .replace('%BUILD_TIME%', dayjs().format('YYYY-MM-DD HH:mm:ss')) + .replace('%VITE_APP_TITLE%', VITE_APP_TITLE) + }, + }, + // 打包分析插件,h5 + 生产环境才弹出 + UNI_PLATFORM === 'h5' + && mode === 'production' + && visualizer({ + filename: './node_modules/.cache/visualizer/stats.html', + open: true, + gzipSize: true, + brotliSize: true, + }), + // 原生插件资源复制插件 - 仅在 app 平台且启用时生效 + createCopyNativeResourcesPlugin( + UNI_PLATFORM === 'app' && VITE_COPY_NATIVE_RES_ENABLE === 'true', + { + verbose: mode === 'development', // 开发模式显示详细日志 + }, + ), + syncManifestPlugin(), + vitePluginEruda({ + open: UNI_PLATFORM === 'h5' && mode === 'development', + }), + // 自动打开开发者工具插件 (必须修改 .env 文件中的 VITE_WX_APPID) + // 上传时通过 SKIP_OPEN_DEVTOOLS=true 跳过 + SKIP_OPEN_DEVTOOLS !== 'true' && openDevTools({ + mode, + wechatDevtoolsCliPath: WECHAT_DEVTOOLS_CLI_PATH, + }), + ], + define: { + __VITE_APP_PROXY__: JSON.stringify(VITE_APP_PROXY_ENABLE), + }, + css: { + postcss: { + plugins: [ + // autoprefixer({ + // // 指定目标浏览器 + // overrideBrowserslist: ['> 1%', 'last 2 versions'], + // }), + ], + }, + }, + + resolve: { + alias: { + '@': path.join(process.cwd(), './src'), + '@img': path.join(process.cwd(), './src/static/images'), + }, + }, + server: { + host: '0.0.0.0', + hmr: true, + port: Number.parseInt(VITE_APP_PORT, 10), + // 仅 H5 端生效,其他端不生效(其他端走build,不走devServer) + proxy: JSON.parse(VITE_APP_PROXY_ENABLE) + ? { + [VITE_APP_PROXY_PREFIX]: { + target: VITE_SERVER_BASEURL, + changeOrigin: true, + // 后端有/api前缀则不做处理,没有则需要去掉 + rewrite: path => + path.replace(new RegExp(`^${VITE_APP_PROXY_PREFIX}`), ''), + }, + } + : undefined, + }, + esbuild: { + drop: VITE_DELETE_CONSOLE === 'true' ? ['console', 'debugger'] : [], + }, + build: { + sourcemap: false, + // 方便非h5端调试 + // sourcemap: VITE_SHOW_SOURCEMAP === 'true', // 默认是false + target: 'es6', + // 开发环境不用压缩 + minify: mode === 'development' ? false : 'esbuild', + }, + }) +}) diff --git a/mp-sc-frontend/vitest.config.ts b/mp-sc-frontend/vitest.config.ts new file mode 100644 index 0000000..f6f7cae --- /dev/null +++ b/mp-sc-frontend/vitest.config.ts @@ -0,0 +1,21 @@ +import path from 'node:path' +import process from 'node:process' +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['src/test-setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'src/uni_modules/**'], + }, + resolve: { + alias: { + '@': path.resolve(process.cwd(), 'src'), + '@img': path.resolve(process.cwd(), 'src/static/images'), + }, + }, +}) diff --git a/mp-sc-frontend/wot-ui-resolver.ts b/mp-sc-frontend/wot-ui-resolver.ts new file mode 100644 index 0000000..6a79867 --- /dev/null +++ b/mp-sc-frontend/wot-ui-resolver.ts @@ -0,0 +1,27 @@ +import type { ComponentResolver } from '@uni-helper/vite-plugin-uni-components' +import { kebabCase } from '@uni-helper/vite-plugin-uni-components' + +/** + * npm 安装的 Wot UI 组件在 H5 端需通过 vite-plugin-uni-components 解析, + * 才能正确挂载组件样式。 + */ +export function WotResolver(): ComponentResolver { + return { + type: 'component', + resolve: (name: string) => { + if (name.match(/^Wd[A-Z]/)) { + const compName = kebabCase(name) + return { + name, + from: `@wot-ui/ui/components/${compName}/${compName}.vue`, + } + } + if (name.startsWith('wd-')) { + return { + name, + from: `@wot-ui/ui/components/${name}/${name}.vue`, + } + } + }, + } +} diff --git a/sc-lktx-backend/.gitignore b/sc-lktx-backend/.gitignore new file mode 100644 index 0000000..7e2cd35 --- /dev/null +++ b/sc-lktx-backend/.gitignore @@ -0,0 +1,103 @@ +# Go workspace +go.work +go.work.sum + +# Go build output +/bin/ +/pkg/ +/build/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Go vendor +/vendor/ + +# IDE - GoLand / IntelliJ IDEA +.idea/ +*.iml +*.iws +*.ipr +out/ +.idea_modules/ +atlassian-ide-plugin.xml + +# IDE - VS Code (备用) +.vscode/ +*.code-workspace + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Windows +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.stackdump +[Dd]esktop.ini +$RECYCLE.BIN/ + +# Linux +*~ +.fuse_hidden* +.directory +.Trash-* + +# Environment & Config +.env +.env.local +.env.*.local +!config/config.example.yaml +*.toml +!config/config.example.toml + +# Logs +/logs/ +*.log + +# Dependency directories +node_modules/ + +# MinIO data +/minio_data/ + +# PostgreSQL data +/pgdata/ + +# Redis dump +dump.rdb + +# Docker +docker-compose.override.yml + +# Upload & Temp +/uploads/ +/tmp/ +/temp/ + +# Air live reload +.air.toml +tmp/ + +# Coverage +*.coverprofile +coverage.html +coverage.out + +server \ No newline at end of file diff --git a/sc-lktx-backend/Dockerfile b/sc-lktx-backend/Dockerfile new file mode 100644 index 0000000..629417c --- /dev/null +++ b/sc-lktx-backend/Dockerfile @@ -0,0 +1,38 @@ +# ===== 构建阶段 ===== +FROM golang:1.22-alpine AS builder + +WORKDIR /app + +# 安装构建依赖 +RUN apk add --no-cache gcc musl-dev + +# 先复制 go.mod 和 go.sum 以利用 Docker 缓存 +COPY go.mod go.sum ./ +RUN go mod download + +# 复制源码并构建 +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server + +# ===== 运行阶段 ===== +FROM alpine:3.19 + +WORKDIR /app + +# 安装运行时依赖(ca-certificates 用于 HTTPS 请求,tzdata 用于时区) +RUN apk add --no-cache ca-certificates tzdata && \ + cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" > /etc/timezone + +# 从构建阶段复制二进制和配置文件 +COPY --from=builder /app/server . +COPY --from=builder /app/config/ ./config/ + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +EXPOSE 8080 + +# 默认使用生产配置,可通过命令行参数覆盖 +CMD ["./server", "config/config-prod.yaml"] diff --git a/sc-lktx-backend/Makefile b/sc-lktx-backend/Makefile new file mode 100644 index 0000000..3cdebc9 --- /dev/null +++ b/sc-lktx-backend/Makefile @@ -0,0 +1,42 @@ +.PHONY: run build clean deps + +# 安装依赖 +deps: + go mod tidy + go mod download + +# 构建 +build: + go build -o bin/server ./cmd/server + +# 运行 +run: + go run ./cmd/server + +# 运行(指定配置) +run-prod: + go run ./cmd/server config/config.yaml + +# 清理 +clean: + rm -rf bin/ + +# 测试 +test: + go test ./... + +# Docker 启动基础设施 +docker-up: + docker-compose up -d + +# Docker 停止 +docker-down: + docker-compose down + +# 代码格式化 +fmt: + go fmt ./... + +# 代码检查 +lint: + golangci-lint run ./... diff --git a/sc-lktx-backend/build.bat b/sc-lktx-backend/build.bat new file mode 100644 index 0000000..b875f30 --- /dev/null +++ b/sc-lktx-backend/build.bat @@ -0,0 +1,34 @@ +@echo off +chcp 65001 >nul + +setlocal enabledelayedexpansion + +set OUTPUT=server +set BIN_DIR=bin + +echo === Build Go backend (Linux amd64) === + +set GOOS=linux +set GOARCH=amd64 +set CGO_ENABLED=0 + +go build -ldflags="-s -w" -o "%BIN_DIR%/%OUTPUT%" ./cmd/server/ + +if errorlevel 1 ( + echo Build failed, error code: %errorlevel% + exit /b %errorlevel% +) + +echo === Build complete === +echo Output: %BIN_DIR%/%OUTPUT% + +if exist config\config-prod.yaml ( + copy config\config-prod.yaml "%BIN_DIR%\" >nul +) else ( + echo Warning: config-prod.yaml not found +) + +echo === Deploy files === +dir "%BIN_DIR%" + +endlocal diff --git a/sc-lktx-backend/build.sh b/sc-lktx-backend/build.sh new file mode 100644 index 0000000..300069f --- /dev/null +++ b/sc-lktx-backend/build.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# 编译 Go 后端到 Linux amd64 +# 使用方式:./build.sh + +set -e + +cd "$(dirname "$0")" + +OUTPUT="server" +BIN_DIR="bin" + +echo "=== 编译 Go 后端(Linux amd64)===" + +# 设置交叉编译环境 +export GOOS=linux +export GOARCH=amd64 +export CGO_ENABLED=0 + +go build -ldflags="-s -w" -o "${BIN_DIR}/${OUTPUT}" ./cmd/server/ + +echo "=== 编译完成 ===" +echo "输出文件:${BIN_DIR}/${OUTPUT}" +file "${BIN_DIR}/${OUTPUT}" + +# 复制配置文件 +cp config/config-prod.yaml "${BIN_DIR}/" 2>/dev/null || echo "注意:config-prod.yaml 不存在" +echo "=== 部署文件 ===" +ls -lh "${BIN_DIR}/" diff --git a/sc-lktx-backend/config/config-prod.yaml b/sc-lktx-backend/config/config-prod.yaml new file mode 100644 index 0000000..3601ee5 --- /dev/null +++ b/sc-lktx-backend/config/config-prod.yaml @@ -0,0 +1,55 @@ +server: + port: 28175 + mode: release # debug | release | test + +database: + host: postgresql + port: 5432 + user: postgres + password: password_cA25Gm + dbname: lktx + sslmode: disable + timezone: Asia/Shanghai + +redis: + host: redis + port: 6379 + password: "redis_WmWQnZ" + db: 0 + +minio: + endpoint: minio:9000 + access_key: tz9sjGPHppJosRVhGp5Y + secret_key: qXO7ptrIymMhvFfAsfi7rb4vDuyLToMe5ghcnVfM + bucket: lktx + use_ssl: false + base_url: https://storage.sclktx.com + +rabbitmq: + host: rabbitmq + port: 5672 + user: guest + password: guest + vhost: / + +wechat: + app_id: wx4c5f6fdb5bfa3b3a + app_secret: 61f9402825ea5808808edf86736c8748 + token: your_wechat_token + encoding_aes_key: your_encoding_aes_key + +dingtalk: + app_key: dinglmhcikqiqfh2lyys + app_secret: HksFl_FaIShMhnqpK4dr4-dJX52LOe3SWO3L8GlSVPRcSgSk-GpfTJBXi3QNwMLE + robot_code: dinglmhcikqiqfh2lyys + +jwt: + secret: lktx-mp-jwt-secret-key-2024 + expire_hours: 72 + +snowflake: + worker_id: 1 + datacenter_id: 1 + +system: + default_avatar: "https://wx.qlogo.cn/mmhead/B2EfAOZfS1hcmGxwIBm2jqd7g94iaWziaURo0f8bsAu3GKKIsTY8NMtweRMJ8H7YXBQXfibkrCAKp0/0" diff --git a/sc-lktx-backend/config/config.yaml b/sc-lktx-backend/config/config.yaml new file mode 100644 index 0000000..cf74f12 --- /dev/null +++ b/sc-lktx-backend/config/config.yaml @@ -0,0 +1,55 @@ +server: + port: 28175 + mode: debug # debug | release | test + +database: + host: 127.0.0.1 + port: 5432 + user: postgres + password: 123456 + dbname: lktx + sslmode: disable + timezone: Asia/Shanghai + +redis: + host: 127.0.0.1 + port: 6379 + password: "123456" + db: 0 + +minio: + endpoint: 127.0.0.1:9000 + access_key: tz9sjGPHppJosRVhGp5Y + secret_key: qXO7ptrIymMhvFfAsfi7rb4vDuyLToMe5ghcnVfM + bucket: lktx + use_ssl: false + base_url: http://127.0.0.1:9000 + +rabbitmq: + host: 127.0.0.1 + port: 5672 + user: guest + password: guest + vhost: / + +wechat: + app_id: wx4c5f6fdb5bfa3b3a + app_secret: 61f9402825ea5808808edf86736c8748 + token: your_wechat_token + encoding_aes_key: your_encoding_aes_key + +dingtalk: + app_key: dinglmhcikqiqfh2lyys + app_secret: HksFl_FaIShMhnqpK4dr4-dJX52LOe3SWO3L8GlSVPRcSgSk-GpfTJBXi3QNwMLE + robot_code: dinglmhcikqiqfh2lyys + +jwt: + secret: lktx-mp-jwt-secret-key-2024 + expire_hours: 72 + +snowflake: + worker_id: 1 + datacenter_id: 1 + +system: + default_avatar: "https://wx.qlogo.cn/mmhead/B2EfAOZfS1hcmGxwIBm2jqd7g94iaWziaURo0f8bsAu3GKKIsTY8NMtweRMJ8H7YXBQXfibkrCAKp0/0" diff --git a/sc-lktx-backend/docker-compose.yml b/sc-lktx-backend/docker-compose.yml new file mode 100644 index 0000000..01c2229 --- /dev/null +++ b/sc-lktx-backend/docker-compose.yml @@ -0,0 +1,58 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: lktx-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres123 + POSTGRES_DB: lktx + TZ: Asia/Shanghai + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: lktx-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + + minio: + image: minio/minio:latest + container_name: lktx-minio + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + command: server /data --console-address ":9001" + restart: unless-stopped + + rabbitmq: + image: rabbitmq:3-management-alpine + container_name: lktx-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + minio_data: + rabbitmq_data: diff --git a/sc-lktx-backend/docs/docs.go b/sc-lktx-backend/docs/docs.go new file mode 100644 index 0000000..d53a9ed --- /dev/null +++ b/sc-lktx-backend/docs/docs.go @@ -0,0 +1,2 @@ +// Package docs 用于 swagger 文档初始化 +package docs diff --git a/sc-lktx-backend/docs/easy-workflow.md b/sc-lktx-backend/docs/easy-workflow.md new file mode 100644 index 0000000..ddd5660 --- /dev/null +++ b/sc-lktx-backend/docs/easy-workflow.md @@ -0,0 +1,358 @@ +# Easy-Workflow 工作流框架使用指南 + +## 概述 + +本项目已集成 easy-workflow 工作流框架,支持可视化的流程编排和灵活的审批流管理。 + +## 核心概念 + +### 1. 流程定义 (ProcessDefinition) + +流程定义是工作流的模板,描述了一个完整的审批流程结构。 + +**数据结构:** +```json +{ + "process_name": "员工请假", + "process_code": "leave_request", + "source": "办公系统", + "nodes": [ + { + "node_id": "Start", + "node_name": "请假申请", + "node_type": 0, + "user_ids": ["$starter"], + "roles": [] + }, + { + "node_id": "Manager", + "node_name": "主管审批", + "node_type": 1, + "prev_node_ids": ["Start"], + "roles": ["主管"], + "is_cosigned": 0 + } + ] +} +``` + +### 2. 节点类型 (NodeType) + +- **0 - 开始节点**:流程的起点 +- **1 - 审批节点**:需要人工审批的节点 +- **2 - 网关节点**:条件分支或并行分支 +- **3 - 结束节点**:流程终点 + +### 3. 流程实例 (ProcessInstance) + +每次发起审批时创建的实例,关联具体的业务数据。 + +### 4. 任务 (Task) + +每个审批节点会生成一个或多个待办任务,分配给具体的处理人。 + +## API 接口 + +### 流程定义管理 + +```bash +# 创建流程定义 +POST /api/v1/workflow/definitions +Content-Type: application/json + +{ + "process_name": "访客预约审批", + "process_code": "visitor_approval", + "source": "访客系统", + "nodes_json": "[...]" +} + +# 获取流程定义列表 +GET /api/v1/workflow/definitions + +# 获取流程定义详情 +GET /api/v1/workflow/definitions/:id + +# 更新流程定义 +PUT /api/v1/workflow/definitions/:id + +# 删除流程定义 +DELETE /api/v1/workflow/definitions/:id +``` + +### 流程实例管理 + +```bash +# 启动流程实例 +POST /api/v1/workflow/instances +Content-Type: application/json + +{ + "process_code": "visitor_approval", + "business_type": "appointment", + "business_id": 123, + "variables": { + "days": 3, + "reason": "商务拜访" + } +} + +# 获取流程实例列表 +GET /api/v1/workflow/instances?business_type=appointment&status=0 + +# 获取流程实例详情 +GET /api/v1/workflow/instances/:id +``` + +### 任务管理 + +```bash +# 获取待办任务 +GET /api/v1/workflow/tasks/pending + +# 审批通过 +POST /api/v1/workflow/tasks/:id/approve +Content-Type: application/json + +{ + "comment": "同意" +} + +# 审批拒绝 +POST /api/v1/workflow/tasks/:id/reject +Content-Type: application/json + +{ + "comment": "拒绝,原因:..." +} +``` + +## 前端使用 + +### 1. 流程设计器 + +访问路径:`/subpackages/admin/workflow-designer` + +支持功能: +- 创建/编辑流程定义 +- 添加/删除节点 +- 配置节点类型和审批人 +- 设置网关条件 + +### 2. 流程管理列表 + +访问路径:`/subpackages/admin/workflow-list` + +支持功能: +- 查看所有流程定义 +- 编辑/删除流程 +- 查看流程实例 + +### 3. 我的待办 + +访问路径:`/subpackages/workflow/my-tasks` + +支持功能: +- 查看待审批任务 +- 快速审批通过/拒绝 + +### 4. 流程实例详情 + +访问路径:`/subpackages/admin/workflow-instance-detail?id={instanceId}` + +支持功能: +- 查看审批进度时间线 +- 查看每个节点的审批意见 +- 执行审批操作 + +## 代码示例 + +### 发起审批流程 + +```typescript +import workflowAPI from '@/api/workflow' + +// 发起访客预约审批 +const instance = await workflowAPI.startInstance({ + process_code: 'visitor_approval', + business_type: 'appointment', + business_id: appointmentId, + variables: { + visitDays: 3, + visitorCount: 5 + } +}) +``` + +### 获取待办任务 + +```typescript +// 获取我的待办 +const tasks = await workflowAPI.getPendingTasks() + +// 遍历处理 +tasks.forEach(task => { + console.log(`${task.node_name} - ${task.instance?.process_def?.process_name}`) +}) +``` + +### 审批操作 + +```typescript +// 审批通过 +await workflowAPI.approveTask(taskId, '同意申请') + +// 审批拒绝 +await workflowAPI.rejectTask(taskId, '拒绝,资料不全') +``` + +## 高级特性 + +### 1. 条件网关 + +```json +{ + "node_id": "GW-Day", + "node_name": "请假天数判断", + "node_type": 2, + "gw_config": { + "conditions": [ + { + "expression": "$days >= 3", + "node_id": "Manager" + }, + { + "expression": "$days < 3", + "node_id": "END" + } + ] + } +} +``` + +### 2. 并行网关 + +```json +{ + "node_id": "GW-Parallel", + "node_name": "并行审批", + "node_type": 2, + "gw_config": { + "inevitable_nodes": ["HR", "DeputyBoss"], + "wait_for_all_prev_node": 1 + } +} +``` + +### 3. 会签节点 + +```json +{ + "node_id": "Board", + "node_name": "董事会审批", + "node_type": 1, + "roles": ["董事 A", "董事 B", "董事 C"], + "is_cosigned": 1 +} +``` + +## 数据库表结构 + +### wf_process_definitions +- 存储流程定义模板 +- 节点配置以 JSON 格式存储 + +### wf_process_instances +- 存储流程实例 +- 关联业务数据 +- 记录当前节点 + +### wf_tasks +- 存储审批任务 +- 记录处理人和审批意见 + +## 迁移现有审批逻辑 + +### 步骤 1:创建流程定义 + +使用流程设计器创建对应的流程模板。 + +### 步骤 2:修改业务代码 + +将原有的审批逻辑改为调用工作流引擎: + +```go +// 旧代码 +approvalHandler.StartInstance(...) + +// 新代码 +workflowEngine.StartInstance("visitor_approval", "appointment", appointmentID, userID, variables) +``` + +### 步骤 3:数据迁移 + +将现有的审批记录迁移到新的工作流表(可选)。 + +## 注意事项 + +1. **流程变量**:使用 `$` 前缀引用,如 `$days` +2. **角色解析**:审批人可以是具体用户 ID 或角色编码 +3. **会签支持**:设置 `is_cosigned=1` 启用会签 +4. **网关等待**:`wait_for_all_prev_node=1` 表示等待所有前置节点完成 + +## 常见问题 + +### Q: 如何添加自定义事件? +A: 在节点的 `node_start_events` 或 `node_end_events` 数组中添加事件名称。 + +### Q: 如何获取审批进度? +A: 调用 `GET /api/v1/workflow/instances/:id` 获取实例详情,包含所有任务记录。 + +### Q: 支持撤回吗? +A: 当前版本暂不支持,可在流程定义中配置 `revoke_events` 实现。 + +## 访客预约审批流程示例 + +### 流程定义(创建时 nodes_json 内容) + +```json +[ + {"node_id":"Start","node_name":"发起预约","node_type":0,"user_ids":["$starter"]}, + {"node_id":"Employee","node_name":"公司接待员工","node_type":1,"prev_node_ids":["Start"],"user_ids":["$employee_id"]}, + {"node_id":"DeptApprover","node_name":"部门指定人审批","node_type":1,"prev_node_ids":["Employee"],"user_ids":["$dept_approver_ids"]}, + {"node_id":"VPApproval","node_name":"分管领导审批","node_type":1,"prev_node_ids":["DeptApprover"],"user_ids":["$vp_ids"]}, + {"node_id":"End","node_name":"审批完成","node_type":3,"prev_node_ids":["VPApproval"]} +] +``` + +### 流程变量说明 + +| 变量名 | 类型 | 说明 | +|--------|------|------| +| `$starter` | uint | 发起人的用户 ID(引擎自动填入) | +| `$employee_id` | uint | 被访员工用户 ID | +| `$dept_approver_ids` | JSON 数组 | 部门指定审批人 ID 列表,支持多人 | +| `$vp_ids` | JSON 数组 | 分管领导 ID 列表,支持多人,空数组跳过节点 | + +### 行为规则 + +- **`$dept_approver_ids` 为空**:跳过「部门指定人审批」节点 +- **`$vp_ids` 为空**:跳过「分管领导审批」节点 +- **`$default_approver_ids` 为空**:跳过「兜底审批」节点,流程直接结束 +- **多个审批人**:会签模式,需全部通过才流转下一节点 + +### 兜底审批流程定义(visitor_approval_simple) + +当未匹配到员工或部门未配置审批人时使用: + +```json +[ + {"node_id":"Start","node_name":"发起预约","node_type":0,"user_ids":["$starter"]}, + {"node_id":"DefaultApproval","node_name":"兜底审批","node_type":1,"prev_node_ids":["Start"],"user_ids":["$default_approver_ids"],"is_cosigned":1}, + {"node_id":"End","node_name":"审批完成","node_type":3,"prev_node_ids":["DefaultApproval"]} +] +``` + +## 技术支持 + +如有问题,请联系开发团队或查看示例代码。 diff --git a/sc-lktx-backend/go.mod b/sc-lktx-backend/go.mod new file mode 100644 index 0000000..b0b7682 --- /dev/null +++ b/sc-lktx-backend/go.mod @@ -0,0 +1,103 @@ +module com.sclktx/m/v2 + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/google/uuid v1.6.0 + github.com/minio/minio-go/v7 v7.2.0 + github.com/silenceper/wechat/v2 v2.1.13 + github.com/sirupsen/logrus v1.9.4 + github.com/spf13/viper v1.18.2 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + go.uber.org/zap v1.27.0 + gorm.io/driver/postgres v1.5.4 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/alibabacloud-go/dingtalk v1.7.38 // indirect + github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d // indirect + github.com/bytedance/sonic v1.9.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.6 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/excelize/v2 v2.10.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/sc-lktx-backend/go.sum b/sc-lktx-backend/go.sum new file mode 100644 index 0000000..fca407a --- /dev/null +++ b/sc-lktx-backend/go.sum @@ -0,0 +1,509 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= +github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.12/go.mod h1:cgtLEj8i4ddXMcQgq4PnpVQvlzS+y5B+QtdSfmcLM3A= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/dingtalk v1.7.38 h1:7tx5KFKmOxrFCoxue3/cUWfFTjCRrmyo5BHgJFPrQxQ= +github.com/alibabacloud-go/dingtalk v1.7.38/go.mod h1:uP5klG3rdfmG3sjk9c7WpegtGgvqzEnM9VKfsX+gO3Q= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/gateway-dingtalk v1.0.2/go.mod h1:JUvHpkJtlPFpgJcfXqc9Y4mk2JnoRn5XpKbRz38jJho= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw= +github.com/alibabacloud-go/openapi-util v0.1.2/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils/v2 v2.0.1/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4= +github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-utils/v2 v2.0.8/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I= +github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M= +github.com/alicebob/miniredis/v2 v2.30.0/go.mod h1:84TWKZlxYkfgMucPBf5SOQBYJceZeQRFIaQgNMiCX6Q= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0= +github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM= +github.com/aliyun/credentials-go v1.4.6/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U= +github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw= +github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs= +github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/silenceper/wechat/v2 v2.1.13 h1:4JCnQKFtw7t5DNTZPa/ITralevehQnl6BapjBp3W3Ns= +github.com/silenceper/wechat/v2 v2.1.13/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ= +github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/sc-lktx-backend/internal/admin/admin_auth.go b/sc-lktx-backend/internal/admin/admin_auth.go new file mode 100644 index 0000000..f07bcd7 --- /dev/null +++ b/sc-lktx-backend/internal/admin/admin_auth.go @@ -0,0 +1,95 @@ +package admin + +import ( + "crypto/sha256" + "fmt" + + "com.sclktx/m/v2/internal/common/model" + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/common/utils" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AdminAuthHandler struct { + db *gorm.DB + jwtSecret string + expireHours int +} + +func NewAdminAuthHandler(db *gorm.DB, jwtSecret string, expireHours int) *AdminAuthHandler { + return &AdminAuthHandler{db: db, jwtSecret: jwtSecret, expireHours: expireHours} +} + +type AdminLoginRequest struct { + Account string `json:"account" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// @Summary 管理员登录 +// @Description 管理员账号密码登录,返回JWT token +// @Tags 管理员 +// @Accept json +// @Produce json +// @Param body body AdminLoginRequest true "管理员登录信息" +// @Success 200 {object} response.Response{data=object{token=string,admin_id=uint,account=string,name=string,is_super=bool}} "成功" +// @Router /admin/login [post] + +func (h *AdminAuthHandler) Login(c *gin.Context) { + var req AdminLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + var admin model.AdminUser + if err := h.db.Where("account = ? AND status = 1", req.Account).First(&admin).Error; err != nil { + response.Unauthorized(c, "账号或密码错误") + return + } + + hashed := fmt.Sprintf("%x", sha256.Sum256([]byte(req.Password))) + if admin.Password != hashed { + response.Unauthorized(c, "账号或密码错误") + return + } + + token, err := utils.GenerateAdminToken(admin.ID, admin.Account, h.jwtSecret, h.expireHours) + if err != nil { + response.InternalError(c, "生成令牌失败") + return + } + + response.Success(c, gin.H{ + "token": token, + "admin_id": admin.ID, + "account": admin.Account, + "name": admin.Name, + "is_super": admin.IsSuper, + }) +} + +// @Summary 获取管理员信息 +// @Description 获取当前登录管理员的个人信息 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=object{id=uint,account=string,name=string,is_super=bool}} "成功" +// @Router /admin/profile [get] + +func (h *AdminAuthHandler) GetProfile(c *gin.Context) { + adminID, _ := c.Get("admin_id") + var admin model.AdminUser + if err := h.db.First(&admin, adminID).Error; err != nil { + response.NotFound(c, "管理员不存在") + return + } + response.Success(c, gin.H{ + "id": admin.ID, + "account": admin.Account, + "name": admin.Name, + "is_super": admin.IsSuper, + }) +} diff --git a/sc-lktx-backend/internal/admin/handler.go b/sc-lktx-backend/internal/admin/handler.go new file mode 100644 index 0000000..e62f3f1 --- /dev/null +++ b/sc-lktx-backend/internal/admin/handler.go @@ -0,0 +1,1898 @@ +package admin + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + + "com.sclktx/m/v2/internal/common/model" + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/common/utils" + "com.sclktx/m/v2/internal/pkg/wechat" + + "github.com/gin-gonic/gin" + "github.com/xuri/excelize/v2" + "gorm.io/gorm" +) + +// AdminHandler 管理员处理器 +type AdminHandler struct { + db *gorm.DB + wxClient *wechat.MiniProgramClient +} + +func NewAdminHandler(db *gorm.DB, wxClient *wechat.MiniProgramClient) *AdminHandler { + return &AdminHandler{db: db, wxClient: wxClient} +} + +// ============ 用户管理 ============ + +// UserVO 用户视图对象 +type UserVO struct { + ID uint `json:"id"` + Openid string `json:"openid"` + Nickname string `json:"nickname"` + RealName string `json:"real_name"` + Phone string `json:"phone"` + AvatarURL string `json:"avatar_url"` + Role string `json:"role"` + RoleName string `json:"role_name"` + Status int `json:"status"` + CreatedAt string `json:"created_at"` +} + +// roleName 角色编码转中文名 +func roleName(code string) string { + switch code { + case "employee": + return "员工" + case "guard": + return "保安" + default: + return "访客" + } +} + +// @Summary 用户列表 +// @Description 分页查询用户列表,支持关键词搜索 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(10) +// @Param keyword query string false "搜索关键词(姓名/昵称/手机号)" +// @Success 200 {object} response.Response{data=response.PageData{list=[]UserVO}} "成功" +// @Router /admin/users [get] + +// ListUsers 用户列表 +func (h *AdminHandler) ListUsers(c *gin.Context) { + page, pageSize := utils.ParsePagination(c) + keyword := c.DefaultQuery("keyword", "") + userType := c.DefaultQuery("type", "") // employee / visitor / guard + statusStr := c.DefaultQuery("status", "") + + var users []model.User + var total int64 + + query := h.db.Model(&model.User{}) + if keyword != "" { + query = query.Where("real_name LIKE ? OR nickname LIKE ? OR phone LIKE ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + if userType != "" { + query = query.Where("role = ?", userType) + } + if statusStr != "" { + query = query.Where("status = ?", statusStr) + } + + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)).Limit(pageSize).Order("created_at DESC, id DESC").Find(&users) + + var vos []UserVO + for _, u := range users { + vos = append(vos, userToVO(&u)) + } + + response.SuccessPage(c, vos, total, page, pageSize) +} + +// @Summary 更新用户信息 +// @Description 管理员更新用户信息(昵称、姓名、手机号、状态、角色等) +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "用户ID" +// @Param body body object{real_name=string,nickname=string,phone=string,status=int,role=string} true "更新信息" +// @Success 200 {object} response.Response "成功" +// @Router /admin/users/{id} [put] + +// UpdateUser 更新用户信息(管理员操作) +func (h *AdminHandler) UpdateUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var req struct { + RealName string `json:"real_name"` + Nickname string `json:"nickname"` + Phone string `json:"phone"` + Status *int `json:"status"` + Role string `json:"role"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + updates := map[string]interface{}{} + if req.RealName != "" { + updates["real_name"] = req.RealName + } + if req.Nickname != "" { + updates["nickname"] = req.Nickname + } + if req.Phone != "" { + updates["phone"] = req.Phone + } + if req.Status != nil { + updates["status"] = *req.Status + } + if req.Role != "" { + updates["role"] = req.Role + } + + if err := h.db.Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + + // 如果设为员工角色且尚无 Employee 记录,自动创建 + if req.Role == "employee" { + var count int64 + h.db.Model(&model.UserDepartment{}).Where("user_id = ?", id).Count(&count) + if count == 0 { + h.db.Model(&model.User{}).Where("id = ?", id).Update("role", "employee") + // 同时创建 UserDepartment 记录 + h.db.Create(&model.UserDepartment{ + UserID: uint(id), + EmployeeStatus: 1, + EmployeeType: "employee", + }) + } else { + h.db.Model(&model.User{}).Where("id = ?", id).Update("role", "employee") + h.db.Model(&model.UserDepartment{}).Where("user_id = ?", id).Update("employee_status", 1) + } + } + + response.SuccessWithMessage(c, "更新成功", nil) +} + +func userToVO(u *model.User) UserVO { + code := u.Role + if code == "" { + code = "visitor" + } + return UserVO{ + ID: u.ID, + Openid: u.Openid, + Nickname: u.Nickname, + RealName: u.RealName, + Phone: u.Phone, + AvatarURL: u.AvatarURL, + Status: u.Status, + Role: code, + RoleName: roleName(code), + CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), + } +} + +// RegisterRoutes 注册管理路由 +func (h *AdminHandler) RegisterRoutes(r *gin.RouterGroup, jwtMiddleware gin.HandlerFunc) { + admin := r.Group("/admin") + admin.Use(jwtMiddleware) + { + // 用户管理 + admin.GET("/users", h.ListUsers) + admin.PUT("/users/:id", h.UpdateUser) + + // Banner 管理 + admin.GET("/banners", h.ListBanners) + + // 员工列表(审批配置用) + admin.GET("/employees", h.ListEmployees) + admin.POST("/banners", h.CreateBanner) + admin.PUT("/banners/:id", h.UpdateBanner) + admin.DELETE("/banners/:id", h.DeleteBanner) + + // 通知管理 + admin.GET("/notices", h.ListNotices) + admin.POST("/notices", h.CreateNotice) + admin.PUT("/notices/:id", h.UpdateNotice) + admin.DELETE("/notices/:id", h.DeleteNotice) + + // 组织架构(部门树) + admin.GET("/departments", h.ListDepartments) + + // 员工认证审核 + admin.GET("/certifications", h.ListCertifications) + admin.PUT("/certifications/:id/audit", h.AuditCertification) + + // 部门管理(树形结构) + admin.GET("/department-tree", h.ListDepartmentTree) + admin.GET("/departments-all", h.ListAllDepartments) + admin.POST("/departments", h.CreateDepartment) + admin.PUT("/departments/:id", h.UpdateDepartment) + admin.DELETE("/departments/:id", h.DeleteDepartment) + + // 员工-部门关联 + admin.PUT("/employees/:userId/department", h.SetEmployeeDepartment) + admin.DELETE("/employees/:userId/department", h.ClearEmployeeDepartment) + admin.GET("/employee-departments", h.ListEmployeeDepartments) + + // 到访区域管理 + admin.GET("/visitor-areas", h.ListVisitorAreas) + admin.POST("/visitor-areas", h.CreateVisitorArea) + admin.PUT("/visitor-areas/:id", h.UpdateVisitorArea) + admin.DELETE("/visitor-areas/:id", h.DeleteVisitorArea) + + // 来访目的管理 + admin.GET("/visit-types", h.ListVisitTypes) + admin.POST("/visit-types", h.CreateVisitType) + admin.PUT("/visit-types/:id", h.UpdateVisitType) + admin.DELETE("/visit-types/:id", h.DeleteVisitType) + + // 预约管理(仅查看,审核走工作流引擎) + admin.GET("/appointments", h.ListAppointments) + + // 审批流配置:部门分管领导(存储在 department.vp_user_ids) + admin.PUT("/departments/:id/vp-users", h.SetDepartmentVPs) + + // 审批流配置:设置部门指定审批人 + admin.PUT("/departments/:id/approver", h.SetDepartmentApprover) + + // 待确认员工管理(通讯录导入) + admin.POST("/employees/import", h.ImportPendingEmployees) + admin.POST("/employees/import-excel", h.ImportPendingEmployeesExcel) + admin.GET("/employees/pending", h.ListPendingEmployees) + admin.GET("/employees/pending/stats", h.GetPendingEmployeeStats) + admin.POST("/employees/pending/:id/match", h.MatchPendingEmployee) + admin.POST("/employees/pending/sync-all", h.SyncAllPendingEmployees) + + // 全局兜底审批人 + admin.GET("/global-approvers", h.ListGlobalApprovers) + admin.POST("/global-approvers", h.CreateGlobalApprover) + admin.DELETE("/global-approvers/:id", h.DeleteGlobalApprover) + } +} + +// ============ Banner 管理 ============ + +// @Summary Banner列表 +// @Description 获取所有Banner列表,按排序字段升序 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]model.Banner} "成功" +// @Router /admin/banners [get] + +// ListBanners Banner 列表 +func (h *AdminHandler) ListBanners(c *gin.Context) { + var banners []model.Banner + h.db.Order("sort ASC").Find(&banners) + response.Success(c, banners) +} + +// @Summary 创建Banner +// @Description 创建一个新的Banner +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{image_url=string,jump_path=string,is_jump=bool,sort=int} true "Banner信息" +// @Success 200 {object} response.Response{data=model.Banner} "成功" +// @Router /admin/banners [post] + +// CreateBanner 创建 Banner +func (h *AdminHandler) CreateBanner(c *gin.Context) { + var req struct { + ImageURL string `json:"image_url" binding:"required"` + JumpPath string `json:"jump_path"` + IsJump bool `json:"is_jump"` + Sort int `json:"sort"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + banner := model.Banner{ + ImageURL: req.ImageURL, + JumpPath: req.JumpPath, + IsJump: req.IsJump, + Sort: req.Sort, + IsValid: true, + } + if err := h.db.Create(&banner).Error; err != nil { + response.InternalError(c, "创建失败") + return + } + response.SuccessWithMessage(c, "创建成功", banner) +} + +// @Summary 更新Banner +// @Description 更新指定Banner的信息 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Banner ID" +// @Param body body object{image_url=string,jump_path=string,is_jump=bool,sort=int,is_valid=bool} true "更新信息" +// @Success 200 {object} response.Response "成功" +// @Router /admin/banners/{id} [put] + +// UpdateBanner 更新 Banner +func (h *AdminHandler) UpdateBanner(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + var banner model.Banner + if err := h.db.First(&banner, id).Error; err != nil { + response.NotFound(c, "Banner 不存在") + return + } + var req struct { + ImageURL string `json:"image_url"` + JumpPath string `json:"jump_path"` + IsJump *bool `json:"is_jump"` + Sort *int `json:"sort"` + IsValid *bool `json:"is_valid"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + updates := map[string]interface{}{} + if req.ImageURL != "" { + updates["image_url"] = req.ImageURL + } + if req.JumpPath != "" { + updates["jump_path"] = req.JumpPath + } + if req.IsJump != nil { + updates["is_jump"] = *req.IsJump + } + if req.Sort != nil { + updates["sort"] = *req.Sort + } + if req.IsValid != nil { + updates["is_valid"] = *req.IsValid + } + if err := h.db.Model(&banner).Updates(updates).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// @Summary 删除Banner +// @Description 删除指定Banner +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Banner ID" +// @Success 200 {object} response.Response "成功" +// @Router /admin/banners/{id} [delete] + +// DeleteBanner 删除 Banner +func (h *AdminHandler) DeleteBanner(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.db.Delete(&model.Banner{}, id).Error; err != nil { + response.InternalError(c, "删除失败") + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ============ 通知管理 ============ + +// @Summary 通知列表 +// @Description 获取所有通知列表,按排序字段升序 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]model.Notice} "成功" +// @Router /admin/notices [get] + +// ListNotices 通知列表 +func (h *AdminHandler) ListNotices(c *gin.Context) { + var notices []model.Notice + h.db.Order("sort ASC").Find(¬ices) + response.Success(c, notices) +} + +// @Summary 创建通知 +// @Description 创建一个新的通知 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{title=string,content=string,sort=int} true "通知信息" +// @Success 200 {object} response.Response{data=model.Notice} "成功" +// @Router /admin/notices [post] + +// CreateNotice 创建通知 +func (h *AdminHandler) CreateNotice(c *gin.Context) { + var req struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Sort int `json:"sort"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + notice := model.Notice{ + Title: req.Title, + Content: req.Content, + Sort: req.Sort, + IsValid: true, + } + if err := h.db.Create(¬ice).Error; err != nil { + response.InternalError(c, "创建失败") + return + } + response.SuccessWithMessage(c, "创建成功", notice) +} + +// @Summary 更新通知 +// @Description 更新指定通知的信息 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "通知ID" +// @Param body body object{title=string,content=string,sort=int,is_valid=bool} true "更新信息" +// @Success 200 {object} response.Response "成功" +// @Router /admin/notices/{id} [put] + +// UpdateNotice 更新通知 +func (h *AdminHandler) UpdateNotice(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + var notice model.Notice + if err := h.db.First(¬ice, id).Error; err != nil { + response.NotFound(c, "通知不存在") + return + } + var req struct { + Title string `json:"title"` + Content string `json:"content"` + Sort *int `json:"sort"` + IsValid *bool `json:"is_valid"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + updates := map[string]interface{}{} + if req.Title != "" { + updates["title"] = req.Title + } + if req.Content != "" { + updates["content"] = req.Content + } + if req.Sort != nil { + updates["sort"] = *req.Sort + } + if req.IsValid != nil { + updates["is_valid"] = *req.IsValid + } + if err := h.db.Model(¬ice).Updates(updates).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// @Summary 删除通知 +// @Description 删除指定通知 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "通知ID" +// @Success 200 {object} response.Response "成功" +// @Router /admin/notices/{id} [delete] + +// DeleteNotice 删除通知 +func (h *AdminHandler) DeleteNotice(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.db.Delete(&model.Notice{}, id).Error; err != nil { + response.InternalError(c, "删除失败") + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ============ 员工列表 ============ + +// @Summary 员工列表 +// @Description 获取所有员工列表(供审批配置选择用),包含正式员工和role为employee的用户 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]object{id=uint,user_id=uint,name=string,department_name=string}} "成功" +// @Router /admin/employees [get] + +// ListEmployees 员工列表(供审批配置选择用) +func (h *AdminHandler) ListEmployees(c *gin.Context) { + type EmployeeItem struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Name string `json:"name"` + DepartmentName string `json:"department_name"` + Position string `json:"position"` + EmployeeType string `json:"employee_type"` + } + + var items []EmployeeItem + + // 查询所有状态正常的 role=employee 用户,左连接 user_departments 获取部门信息 + var rows []struct { + model.User + DeptName string `gorm:"column:dept_name"` + DeptPosition string `gorm:"column:dept_position"` + DeptType string `gorm:"column:dept_employee_type"` + } + h.db.Table("users"). + Select("users.*, COALESCE(ud.department_name, '') as dept_name, COALESCE(ud.position, '') as dept_position, COALESCE(ud.employee_type, 'employee') as dept_employee_type"). + Joins("LEFT JOIN user_departments ud ON ud.user_id = users.id"). + Where("users.role = ? AND users.status = ?", "employee", 1). + Order("users.real_name ASC"). + Scan(&rows) + + employeeUserIDs := make(map[uint]bool) + for _, r := range rows { + employeeUserIDs[r.User.ID] = true + name := r.User.RealName + if name == "" { + name = r.User.RealName + } + items = append(items, EmployeeItem{ + ID: r.User.ID, + UserID: r.User.ID, + Name: name, + DepartmentName: r.DeptName, + Position: r.DeptPosition, + EmployeeType: r.DeptType, + }) + } + + // 补充 role='employee' 但尚未有关联记录的用户(兼容旧数据) + var users []model.User + h.db.Where("role = ? AND status = ?", "employee", 1).Find(&users) + for _, u := range users { + if !employeeUserIDs[u.ID] { + items = append(items, EmployeeItem{ + ID: u.ID, + UserID: u.ID, + Name: u.RealName, + DepartmentName: "", + Position: "", + EmployeeType: "employee", + }) + } + } + + if items == nil { + items = []EmployeeItem{} + } + response.Success(c, items) +} + +// ============ 组织架构 ============ + +// DepartmentVO 部门视图 +type DepartmentVO struct { + Name string `json:"name"` + Employees []EmployeeBrief `json:"employees"` +} + +// EmployeeBrief 员工简要信息 +type EmployeeBrief struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Name string `json:"name"` + Phone string `json:"phone"` + Position string `json:"position"` + EmployeeType string `json:"employee_type"` +} + +// @Summary 组织架构 +// @Description 获取组织架构列表,按部门分组显示员工 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]DepartmentVO} "成功" +// @Router /admin/departments [get] + +// ListDepartments 获取组织架构(按部门分组) +func (h *AdminHandler) ListDepartments(c *gin.Context) { + type EmpRow struct { + model.User + DeptName string `gorm:"column:dept_name"` + DeptPosition string `gorm:"column:dept_position"` + DeptType string `gorm:"column:dept_employee_type"` + } + var rows []EmpRow + h.db.Table("users"). + Select("users.*, COALESCE(ud.department_name, '') as dept_name, COALESCE(ud.position, '') as dept_position, COALESCE(ud.employee_type, 'employee') as dept_employee_type"). + Joins("LEFT JOIN user_departments ud ON ud.user_id = users.id"). + Where("users.status = ?", 1). + Order("dept_name ASC, users.real_name ASC"). + Scan(&rows) + + depMap := make(map[string][]EmployeeBrief) + for _, r := range rows { + dept := r.DeptName + if dept == "" { + dept = "未分配部门" + } + depMap[dept] = append(depMap[dept], EmployeeBrief{ + ID: r.User.ID, + UserID: r.User.ID, + Name: r.User.RealName, + Phone: r.User.Phone, + Position: r.DeptPosition, + EmployeeType: r.DeptType, + }) + } + + var depts []DepartmentVO + for name, emps := range depMap { + depts = append(depts, DepartmentVO{Name: name, Employees: emps}) + } + + response.Success(c, depts) +} + +// ============ 员工认证审核 ============ + +// @Summary 员工认证申请列表 +// @Description 分页查询员工认证申请列表,支持按状态筛选和关键词搜索 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(10) +// @Param status query int false "审核状态(0待审核 1通过 2拒绝)" +// @Param keyword query string false "搜索关键词" +// @Success 200 {object} response.Response{data=response.PageData{list=[]model.EmployeeCertification}} "成功" +// @Router /admin/certifications [get] + +// ListCertifications 员工认证申请列表 +func (h *AdminHandler) ListCertifications(c *gin.Context) { + page, pageSize := utils.ParsePagination(c) + statusStr := c.DefaultQuery("status", "") + keyword := c.Query("keyword") + + var certs []model.EmployeeCertification + var total int64 + + query := h.db.Model(&model.EmployeeCertification{}). + Preload("User"). + Preload("Department"). + Preload("Approver") + if statusStr != "" { + status, _ := strconv.Atoi(statusStr) + query = query.Where("status = ?", status) + } + if keyword != "" { + query = query.Where("real_name LIKE ? OR phone LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)).Limit(pageSize).Order("created_at DESC").Find(&certs) + + response.SuccessPage(c, certs, total, page, pageSize) +} + +// @Summary 审核员工认证 +// @Description 审核员工认证申请(通过/拒绝),通过时需分配部门 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "认证申请ID" +// @Param body body object{status=int,department_id=uint,comment=string} true "审核信息(status:1通过 2拒绝)" +// @Success 200 {object} response.Response "成功" +// @Router /admin/certifications/{id}/audit [put] + +// AuditCertification 审核员工认证申请 +func (h *AdminHandler) AuditCertification(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var req struct { + Status int `json:"status" binding:"required"` // 1 通过 2 拒绝 + RealName string `json:"real_name"` + Phone string `json:"phone"` + DepartmentID *uint `json:"department_id"` + Position string `json:"position"` + Role string `json:"role"` + Comment string `json:"comment"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if req.Status != 1 && req.Status != 2 { + response.BadRequest(c, "状态值无效") + return + } + + var cert model.EmployeeCertification + if err := h.db.First(&cert, id).Error; err != nil { + response.NotFound(c, "申请不存在") + return + } + + if cert.Status != 0 { + response.BadRequest(c, "该申请已处理") + return + } + + approverID := utils.GetUserID(c) + + realName := req.RealName + if realName == "" { + realName = cert.RealName + } + phone := req.Phone + if phone == "" { + phone = cert.Phone + } + + updates := map[string]interface{}{ + "status": req.Status, + "approver_id": approverID, + "real_name": realName, + "phone": phone, + "comment": req.Comment, + } + + // 如果通过,设置分配的部门、职位、角色类型 + if req.Status == 1 { + if req.DepartmentID == nil { + response.BadRequest(c, "通过时必须分配部门") + return + } + var dept model.Department + if err := h.db.First(&dept, *req.DepartmentID).Error; err != nil { + response.BadRequest(c, "部门不存在") + return + } + updates["department_id"] = *req.DepartmentID + updates["position"] = req.Position + updates["role"] = req.Role + } + + if err := h.db.Model(&cert).Updates(updates).Error; err != nil { + response.InternalError(c, "审核失败") + return + } + + // 如果通过,同步用户表 + 员工-部门关联表 + if req.Status == 1 { + // 用户角色取认证申请时的角色类型(employee / guard) + userRole := cert.Role + if userRole == "" { + userRole = "employee" + } + h.db.Model(&model.User{}).Where("id = ?", cert.UserID).Updates(map[string]interface{}{ + "role": userRole, + "real_name": realName, + "phone": phone, + }) + + // 同步员工-部门关联表(含职位、员工类型、状态) + pos := req.Position + if pos == "" { + pos = cert.Position + } + empType := req.Role + if empType == "" { + empType = "employee" + } + // 查找通讯录中的钉钉UserId(如已导入) + var pendingEmp model.PendingEmployee + dingTalkUserID := "" + if err := h.db.Where("phone = ?", phone).First(&pendingEmp).Error; err == nil { + dingTalkUserID = pendingEmp.DingTalkUserID + } + + udData := map[string]interface{}{ + "position": pos, + "employee_type": empType, + "employee_status": 1, + "ding_talk_user_id": dingTalkUserID, + } + if req.DepartmentID != nil { + var dept model.Department + if err := h.db.First(&dept, *req.DepartmentID).Error; err == nil { + udData["department_id"] = *req.DepartmentID + udData["department_name"] = dept.Name + } + } + var ud model.UserDepartment + result := h.db.Where("user_id = ?", cert.UserID).First(&ud) + if result.Error != nil { + udData["user_id"] = cert.UserID + h.db.Model(&model.UserDepartment{}).Create(udData) + } else { + h.db.Model(&ud).Updates(udData) + } + } + + response.SuccessWithMessage(c, "审核完成", nil) +} + +// ============ 部门管理(树形结构)============ + +// DepartmentTreeNode 部门树节点 +type DepartmentTreeNode struct { + model.Department + Children []*DepartmentTreeNode `json:"children"` +} + +// buildDeptTree 构建部门树 +func buildDeptTree(departments []model.Department, parentID *uint) []*DepartmentTreeNode { + var nodes []*DepartmentTreeNode + for _, d := range departments { + if (parentID == nil && d.ParentID == nil) || + (parentID != nil && d.ParentID != nil && *d.ParentID == *parentID) { + node := &DepartmentTreeNode{ + Department: d, + Children: buildDeptTree(departments, &d.ID), + } + nodes = append(nodes, node) + } + } + return nodes +} + +// @Summary 部门树 +// @Description 获取部门树形结构 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]DepartmentTreeNode} "成功" +// @Router /admin/department-tree [get] + +// ListDepartmentTree 获取部门树 +func (h *AdminHandler) ListDepartmentTree(c *gin.Context) { + var departments []model.Department + h.db.Where("status = ?", 1).Order("sort ASC, id ASC").Find(&departments) + + tree := buildDeptTree(departments, nil) + response.Success(c, tree) +} + +// DepartmentItem 部门扁平列表项 +type DepartmentItem struct { + ID uint `json:"id"` + Name string `json:"name"` + ParentID *uint `json:"parent_id"` + LeaderName string `json:"leader_name"` + LeaderPhone string `json:"leader_phone"` + ApproverUserIDs string `json:"approver_user_ids"` + VpUserIDs string `json:"vp_user_ids"` + Sort int `json:"sort"` + Status int `json:"status"` + CreatedAt string `json:"created_at"` +} + +// @Summary 所有部门列表 +// @Description 获取所有部门扁平列表 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]DepartmentItem} "成功" +// @Router /admin/departments-all [get] + +// ListAllDepartments 获取所有部门(扁平列表) +func (h *AdminHandler) ListAllDepartments(c *gin.Context) { + var departments []model.Department + h.db.Where("status = 1").Preload("Leader").Order("sort ASC, id ASC").Find(&departments) + + var items []DepartmentItem + for _, d := range departments { + item := DepartmentItem{ + ID: d.ID, + Name: d.Name, + ParentID: d.ParentID, + Sort: d.Sort, + Status: d.Status, + CreatedAt: d.CreatedAt.Format("2006-01-02 15:04:05"), + } + if d.Leader != nil { + item.LeaderName = d.Leader.RealName + item.LeaderPhone = d.Leader.Phone + } + item.ApproverUserIDs = d.ApproverUserIDs + item.VpUserIDs = d.VpUserIDs + items = append(items, item) + } + + response.Success(c, items) +} + +// @Summary 创建部门 +// @Description 创建一个新的部门 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{name=string,parent_id=uint,leader_id=uint,deputy_id=uint,sort=int,description=string} true "部门信息" +// @Success 200 {object} response.Response{data=model.Department} "成功" +// @Router /admin/departments [post] + +// CreateDepartment 创建部门 +func (h *AdminHandler) CreateDepartment(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + ParentID *uint `json:"parent_id"` + LeaderID *uint `json:"leader_id"` + DeputyID *uint `json:"deputy_id"` + Sort int `json:"sort"` + Description string `json:"description"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 如果指定了上级部门,检查是否存在 + if req.ParentID != nil { + var parent model.Department + if err := h.db.First(&parent, *req.ParentID).Error; err != nil { + response.BadRequest(c, "上级部门不存在") + return + } + } + + dept := model.Department{ + Name: req.Name, + ParentID: req.ParentID, + LeaderID: req.LeaderID, + DeputyID: req.DeputyID, + Sort: req.Sort, + Status: 1, + Description: req.Description, + } + + if err := h.db.Create(&dept).Error; err != nil { + response.InternalError(c, "创建失败") + return + } + + response.SuccessWithMessage(c, "创建成功", dept) +} + +// @Summary 更新部门 +// @Description 更新指定部门的信息 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "部门ID" +// @Param body body object{name=string,parent_id=uint,leader_id=uint,deputy_id=uint,sort=int,status=int,description=string} true "更新信息" +// @Success 200 {object} response.Response "成功" +// @Router /admin/departments/{id} [put] + +// UpdateDepartment 更新部门 +func (h *AdminHandler) UpdateDepartment(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var dept model.Department + if err := h.db.First(&dept, id).Error; err != nil { + response.NotFound(c, "部门不存在") + return + } + + var req struct { + Name string `json:"name"` + ParentID *uint `json:"parent_id"` + LeaderID *uint `json:"leader_id"` + DeputyID *uint `json:"deputy_id"` + Sort *int `json:"sort"` + Status *int `json:"status"` + Description string `json:"description"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 不能将自己的 parent_id 设为自己 + if req.ParentID != nil && *req.ParentID == dept.ID { + response.BadRequest(c, "上级部门不能是自己") + return + } + + updates := map[string]interface{}{} + if req.Name != "" { + updates["name"] = req.Name + } + if req.ParentID != nil { + updates["parent_id"] = *req.ParentID + } + if req.LeaderID != nil { + updates["leader_id"] = *req.LeaderID + } + if req.DeputyID != nil { + updates["deputy_id"] = *req.DeputyID + } + if req.Sort != nil { + updates["sort"] = *req.Sort + } + if req.Status != nil { + updates["status"] = *req.Status + } + if req.Description != "" { + updates["description"] = req.Description + } + + if err := h.db.Model(&dept).Updates(updates).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + + response.SuccessWithMessage(c, "更新成功", nil) +} + +// @Summary 删除部门 +// @Description 删除指定部门及其关联检查 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "部门ID" +// @Success 200 {object} response.Response "成功" +// @Router /admin/departments/{id} [delete] + +// DeleteDepartment 删除部门 +func (h *AdminHandler) DeleteDepartment(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var dept model.Department + if err := h.db.First(&dept, id).Error; err != nil { + response.NotFound(c, "部门不存在") + return + } + + // 检查是否有子部门 + var childCount int64 + h.db.Model(&model.Department{}).Where("parent_id = ?", id).Count(&childCount) + if childCount > 0 { + response.BadRequest(c, "该部门下存在子部门,请先删除子部门") + return + } + + // 检查是否有员工关联 + var empCount int64 + h.db.Model(&model.EmployeeCertification{}).Where("department_id = ? AND status = 1", id).Count(&empCount) + if empCount > 0 { + response.BadRequest(c, "该部门下有认证员工,请先移除关联") + return + } + + if err := h.db.Delete(&dept).Error; err != nil { + response.InternalError(c, "删除失败") + return + } + + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ============ 员工-部门关联 ============ + +// @Summary 设置员工部门 +// @Description 为指定员工设置所属部门 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param userId path int true "用户ID" +// @Param body body object{department_id=uint} true "部门ID" +// @Success 200 {object} response.Response "成功" +// @Router /admin/employees/{userId}/department [put] + +// SetEmployeeDepartment 设置员工部门 +func (h *AdminHandler) SetEmployeeDepartment(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var req struct { + DepartmentID uint `json:"department_id" binding:"required"` + Position string `json:"position"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 检查用户是否存在 + var user model.User + if err := h.db.First(&user, userID).Error; err != nil { + response.NotFound(c, "用户不存在") + return + } + + // 检查部门是否存在 + var dept model.Department + if err := h.db.First(&dept, req.DepartmentID).Error; err != nil { + response.NotFound(c, "部门不存在") + return + } + + // 使用 UserDepartment 关联,有则更新,无则创建 + var ud model.UserDepartment + result := h.db.Where("user_id = ?", userID).First(&ud) + fields := map[string]interface{}{ + "department_id": req.DepartmentID, + "department_name": dept.Name, + "position": req.Position, + } + if result.Error != nil { + // 创建新关联 + ud = model.UserDepartment{ + UserID: uint(userID), + DepartmentID: req.DepartmentID, + DepartmentName: dept.Name, + Position: req.Position, + } + if err := h.db.Create(&ud).Error; err != nil { + response.InternalError(c, "设置部门失败") + return + } + } else { + // 更新已有关联 + if err := h.db.Model(&ud).Updates(fields).Error; err != nil { + response.InternalError(c, "设置部门失败") + return + } + } + + response.SuccessWithMessage(c, "设置成功", nil) +} + +// @Summary 清除员工部门 +// @Description 移除指定员工的部门归属 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param userId path int true "用户ID" +// @Success 200 {object} response.Response "成功" +// @Router /admin/employees/{userId}/department [delete] + +// ClearEmployeeDepartment 清除员工部门 +func (h *AdminHandler) ClearEmployeeDepartment(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.db.Where("user_id = ?", userID).Delete(&model.UserDepartment{}).Error; err != nil { + response.InternalError(c, "清除部门失败") + return + } + + response.SuccessWithMessage(c, "已移除", nil) +} + +// @Summary 员工-部门关联列表 +// @Description 获取所有员工-部门关联关系 +// @Tags 管理员 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]model.UserDepartment} "成功" +// @Router /admin/employee-departments [get] + +// ListEmployeeDepartments 员工-部门关联列表 +func (h *AdminHandler) ListEmployeeDepartments(c *gin.Context) { + var list []model.UserDepartment + h.db.Preload("Department").Find(&list) + if list == nil { + list = []model.UserDepartment{} + } + response.Success(c, list) +} + +// ============ 到访区域管理 ============ + +// ListVisitorAreas 到访区域列表 +func (h *AdminHandler) ListVisitorAreas(c *gin.Context) { + var areas []model.VisitorArea + h.db.Order("sort ASC, id ASC").Find(&areas) + if areas == nil { + areas = []model.VisitorArea{} + } + response.Success(c, areas) +} + +// CreateVisitorArea 创建到访区域 +func (h *AdminHandler) CreateVisitorArea(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Sort int `json:"sort"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + area := model.VisitorArea{Name: req.Name, Sort: req.Sort, Status: 1} + if err := h.db.Create(&area).Error; err != nil { + response.InternalError(c, "创建失败") + return + } + response.SuccessWithMessage(c, "创建成功", area) +} + +// UpdateVisitorArea 更新到访区域 +func (h *AdminHandler) UpdateVisitorArea(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + var area model.VisitorArea + if err := h.db.First(&area, id).Error; err != nil { + response.NotFound(c, "区域不存在") + return + } + var req struct { + Name *string `json:"name"` + Sort *int `json:"sort"` + Status *int `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + updates := map[string]interface{}{} + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Sort != nil { + updates["sort"] = *req.Sort + } + if req.Status != nil { + updates["status"] = *req.Status + } + if len(updates) > 0 { + h.db.Model(&area).Updates(updates) + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// DeleteVisitorArea 删除到访区域 +func (h *AdminHandler) DeleteVisitorArea(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.db.Delete(&model.VisitorArea{}, id).Error; err != nil { + response.InternalError(c, "删除失败") + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ============ 来访目的管理 ============ + +// ListVisitTypes 来访目的列表 +func (h *AdminHandler) ListVisitTypes(c *gin.Context) { + var types []model.VisitType + h.db.Order("sort ASC, id ASC").Find(&types) + if types == nil { + types = []model.VisitType{} + } + response.Success(c, types) +} + +// CreateVisitType 创建来访目的 +func (h *AdminHandler) CreateVisitType(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Sort int `json:"sort"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + vt := model.VisitType{Name: req.Name, Sort: req.Sort, Status: 1} + if err := h.db.Create(&vt).Error; err != nil { + response.InternalError(c, "创建失败") + return + } + response.SuccessWithMessage(c, "创建成功", vt) +} + +// UpdateVisitType 更新来访目的 +func (h *AdminHandler) UpdateVisitType(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + var vt model.VisitType + if err := h.db.First(&vt, id).Error; err != nil { + response.NotFound(c, "类型不存在") + return + } + var req struct { + Name *string `json:"name"` + Sort *int `json:"sort"` + Status *int `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + updates := map[string]interface{}{} + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Sort != nil { + updates["sort"] = *req.Sort + } + if req.Status != nil { + updates["status"] = *req.Status + } + if len(updates) > 0 { + h.db.Model(&vt).Updates(updates) + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// DeleteVisitType 删除来访目的 +func (h *AdminHandler) DeleteVisitType(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.db.Delete(&model.VisitType{}, id).Error; err != nil { + response.InternalError(c, "删除失败") + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ============ 预约管理(管理员) ============ + +// ListAppointments 管理员查看所有预约 +func (h *AdminHandler) ListAppointments(c *gin.Context) { + page, pageSize := utils.ParsePagination(c) + statusStr := c.DefaultQuery("status", "") + keyword := c.DefaultQuery("keyword", "") + + var appointments []model.Appointment + var total int64 + + query := h.db.Model(&model.Appointment{}) + if statusStr != "" { + status, _ := strconv.Atoi(statusStr) + query = query.Where("status = ?", status) + } + if keyword != "" { + query = query.Where("visitor_info LIKE ? OR employee_info LIKE ?", + "%"+keyword+"%", "%"+keyword+"%") + } + + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)).Limit(pageSize). + Order("created_at DESC").Find(&appointments) + + response.SuccessPage(c, appointments, total, page, pageSize) +} + +// ApproveAppointment 管理员审核预约 +// ============ 新审批流配置 ============ + +// SetDepartmentVPs 设置部门的分管领导用户ID列表 +func (h *AdminHandler) SetDepartmentVPs(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var req struct { + VpUserIDs []uint `json:"vp_user_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 序列化为 JSON 数组字符串 + b, _ := json.Marshal(req.VpUserIDs) + vpIDsStr := string(b) + if req.VpUserIDs == nil { + vpIDsStr = "" + } + + if err := h.db.Model(&model.Department{}).Where("id = ?", id).Update("vp_user_ids", vpIDsStr).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// SetDepartmentApprover 设置部门指定审批人列表 +func (h *AdminHandler) SetDepartmentApprover(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var req struct { + ApproverUserIDs []uint `json:"approver_user_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 序列化为 JSON 数组字符串 + b, _ := json.Marshal(req.ApproverUserIDs) + idsStr := string(b) + if req.ApproverUserIDs == nil { + idsStr = "" + } + + if err := h.db.Model(&model.Department{}).Where("id = ?", id).Update("approver_user_ids", idsStr).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// ============ 待确认员工管理 ============ + +// ImportPendingEmployees 批量导入待确认员工 +func (h *AdminHandler) ImportPendingEmployees(c *gin.Context) { + var req struct { + Employees []struct { + RealName string `json:"real_name" binding:"required"` + Phone string `json:"phone" binding:"required"` + DepartmentID uint `json:"department_id" binding:"required"` + DepartmentName string `json:"department_name"` + Position string `json:"position"` + EmployeeType string `json:"employee_type"` + DingTalkUserID string `json:"dingtalk_user_id"` + } `json:"employees" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + imported := 0 + updated := 0 + for _, emp := range req.Employees { + if emp.EmployeeType == "" { + emp.EmployeeType = "employee" + } + + var existing model.PendingEmployee + result := h.db.Where("phone = ?", emp.Phone).First(&existing) + if result.RowsAffected > 0 { + // 已存在 → 更新(岗位调动等情况) + if err := h.db.Model(&existing).Updates(map[string]interface{}{ + "real_name": emp.RealName, + "department_id": emp.DepartmentID, + "department_name": emp.DepartmentName, + "position": emp.Position, + "employee_type": emp.EmployeeType, + "ding_talk_user_id": emp.DingTalkUserID, + }).Error; err != nil { + fmt.Printf("更新待确认员工失败(JSON): phone=%s err=%v\n", emp.Phone, err) + continue + } + updated++ + continue + } + + pe := model.PendingEmployee{ + RealName: emp.RealName, + Phone: emp.Phone, + DepartmentID: emp.DepartmentID, + DepartmentName: emp.DepartmentName, + Position: emp.Position, + EmployeeType: emp.EmployeeType, + DingTalkUserID: emp.DingTalkUserID, + } + if err := h.db.Create(&pe).Error; err != nil { + fmt.Printf("创建待确认员工失败: phone=%s err=%v\n", emp.Phone, err) + continue + } + imported++ + } + response.Success(c, gin.H{ + "imported": imported, + "updated": updated, + "total": len(req.Employees), + }) +} + +// ListPendingEmployees 待确认员工列表 +func (h *AdminHandler) ListPendingEmployees(c *gin.Context) { + page, pageSize := utils.ParsePagination(c) + status := c.DefaultQuery("status", "") + keyword := c.DefaultQuery("keyword", "") + var list []model.PendingEmployee + var total int64 + query := h.db.Model(&model.PendingEmployee{}) + if status == "matched" { + query = query.Where("is_matched = ?", true) + } else if status == "pending" { + query = query.Where("is_matched = ?", false) + } + if keyword != "" { + query = query.Where("real_name LIKE ? OR phone LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + deptIDStr := c.DefaultQuery("department_id", "") + if deptIDStr != "" { + deptID, _ := strconv.ParseUint(deptIDStr, 10, 64) + if deptID > 0 { + query = query.Where("department_id = ?", deptID) + } + } + query.Count(&total) + query.Order("created_at DESC").Offset(utils.GetOffset(page, pageSize)).Limit(pageSize).Find(&list) + if list == nil { + list = []model.PendingEmployee{} + } + response.SuccessPage(c, list, total, page, pageSize) +} + +// GetPendingEmployeeStats 待确认员工统计 +func (h *AdminHandler) GetPendingEmployeeStats(c *gin.Context) { + var total, pending, matched int64 + h.db.Model(&model.PendingEmployee{}).Count(&total) + h.db.Model(&model.PendingEmployee{}).Where("is_matched = ?", false).Count(&pending) + h.db.Model(&model.PendingEmployee{}).Where("is_matched = ?", true).Count(&matched) + response.Success(c, gin.H{ + "total": total, + "pending": pending, + "matched": matched, + }) +} + +// MatchPendingEmployee 手动匹配待确认员工到用户(按手机号) +func (h *AdminHandler) MatchPendingEmployee(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var pending model.PendingEmployee + if err := h.db.First(&pending, id).Error; err != nil { + response.NotFound(c, "记录不存在") + return + } + if pending.IsMatched { + // 已匹配过的也允许重新同步,不阻塞 + } + + // 按手机号查找用户(不要求 phone_verified,手机号本身即可) + var user model.User + if err := h.db.Where("phone = ?", pending.Phone).First(&user).Error; err != nil { + response.BadRequest(c, "未找到已绑定该手机号的用户,请先让员工登录并绑定手机") + return + } + + now := time.Now() + // 创建 UserDepartment + ud := model.UserDepartment{ + UserID: user.ID, + DepartmentID: pending.DepartmentID, + DepartmentName: pending.DepartmentName, + Position: pending.Position, + EmployeeType: pending.EmployeeType, + EmployeeStatus: 1, + DingTalkUserID: pending.DingTalkUserID, + } + h.db.Where("user_id = ?", user.ID).Assign(ud).FirstOrCreate(&model.UserDepartment{UserID: user.ID}) + + // 升级角色 + 同步真实姓名 + updates := map[string]interface{}{ + "role": "employee", + } + if user.RealName == "" || user.RealName != pending.RealName { + updates["real_name"] = pending.RealName + } + h.db.Model(&user).Updates(updates) + + // 标记已匹配 + h.db.Model(&pending).Updates(map[string]interface{}{ + "is_matched": true, + "matched_user_id": &user.ID, + "matched_at": &now, + }) + + response.SuccessWithMessage(c, "匹配成功", gin.H{ + "user_id": user.ID, + "real_name": user.RealName, + "phone": user.Phone, + }) +} + +// SyncAllPendingEmployees 一键同步所有待确认员工信息到用户 +func (h *AdminHandler) SyncAllPendingEmployees(c *gin.Context) { + var pendings []model.PendingEmployee + h.db.Find(&pendings) + + synced := 0 + skipped := 0 + for _, p := range pendings { + var user model.User + if err := h.db.Where("phone = ?", p.Phone).First(&user).Error; err != nil { + skipped++ + continue + } + + now := time.Now() + // 创建或更新 UserDepartment + ud := model.UserDepartment{ + UserID: user.ID, + DepartmentID: p.DepartmentID, + DepartmentName: p.DepartmentName, + Position: p.Position, + EmployeeType: p.EmployeeType, + EmployeeStatus: 1, + DingTalkUserID: p.DingTalkUserID, + } + h.db.Where("user_id = ?", user.ID).Assign(ud).FirstOrCreate(&model.UserDepartment{UserID: user.ID}) + + // 升级角色 + 同步姓名 + userUpdates := map[string]interface{}{ + "role": "employee", + } + if user.RealName == "" || user.RealName != p.RealName { + userUpdates["real_name"] = p.RealName + } + h.db.Model(&user).Updates(userUpdates) + + // 标记已匹配 + h.db.Model(&p).Updates(map[string]interface{}{ + "is_matched": true, + "matched_user_id": &user.ID, + "matched_at": &now, + }) + synced++ + } + + response.Success(c, gin.H{ + "synced": synced, + "skipped": skipped, + "total": len(pendings), + }) +} + +// ============ 全局兜底审批人 ============ + +func (h *AdminHandler) ListGlobalApprovers(c *gin.Context) { + var list []model.GlobalApprover + h.db.Preload("User").Order("created_at ASC").Find(&list) + if list == nil { + list = []model.GlobalApprover{} + } + response.Success(c, list) +} + +func (h *AdminHandler) CreateGlobalApprover(c *gin.Context) { + var req struct { + UserID uint `json:"user_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + ga := model.GlobalApprover{UserID: req.UserID} + if err := h.db.Create(&ga).Error; err != nil { + response.InternalError(c, "创建失败") + return + } + h.db.Preload("User").First(&ga, ga.ID) + response.SuccessWithMessage(c, "创建成功", ga) +} + +func (h *AdminHandler) DeleteGlobalApprover(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.db.Delete(&model.GlobalApprover{}, id).Error; err != nil { + response.InternalError(c, "删除失败") + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ImportPendingEmployeesExcel 通过 Excel 文件导入待确认员工 +func (h *AdminHandler) ImportPendingEmployeesExcel(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + response.BadRequest(c, "请上传 Excel 文件") + return + } + + ext := strings.ToLower(filepath.Ext(file.Filename)) + if ext != ".xlsx" && ext != ".xls" { + response.BadRequest(c, "仅支持 .xlsx 或 .xls 文件") + return + } + + src, err := file.Open() + if err != nil { + response.InternalError(c, "无法读取文件") + return + } + defer src.Close() + + f, err := excelize.OpenReader(src) + if err != nil { + response.BadRequest(c, "无法解析 Excel 文件") + return + } + + rows, err := f.GetRows(f.GetSheetName(0)) + if err != nil || len(rows) < 4 { + response.BadRequest(c, "Excel 文件为空或格式不正确") + return + } + + var departments []model.Department + h.db.Where("status = 1").Find(&departments) + deptMap := make(map[string]uint) + for _, d := range departments { + deptMap[d.Name] = d.ID + } + + var imported, updated, noDept int + for i := 3; i < len(rows); i++ { + row := rows[i] + if len(row) < 4 { + continue + } + dingTalkUserID := strings.TrimSpace(row[0]) + name := strings.TrimSpace(row[1]) + phone := strings.TrimSpace(row[2]) + deptFull := strings.TrimSpace(row[3]) + + if name == "" || phone == "" || deptFull == "" { + continue + } + if !strings.Contains(deptFull, "四川") { + continue + } + + phone = strings.ReplaceAll(phone, "+86-", "") + phone = strings.ReplaceAll(phone, "+86", "") + phone = strings.TrimSpace(phone) + + subDept := deptFull + if idx := strings.Index(deptFull, "-"); idx >= 0 { + subDept = strings.TrimSpace(deptFull[idx+1:]) + } + + deptID, ok := deptMap[subDept] + if !ok { + deptID, ok = deptMap[deptFull] + } + if !ok { + noDept++ + continue + } + + updates := map[string]interface{}{ + "real_name": name, + "department_id": deptID, + "department_name": subDept, + "position": "", + "employee_type": "employee", + "ding_talk_user_id": dingTalkUserID, + } + + var existing model.PendingEmployee + result := h.db.Where("phone = ?", phone).First(&existing) + if result.RowsAffected > 0 { + // 已存在 → 更新 + if err := h.db.Model(&existing).Updates(updates).Error; err != nil { + continue + } + updated++ + updated++ + continue + } + + pe := model.PendingEmployee{ + RealName: name, + Phone: phone, + DepartmentID: deptID, + DepartmentName: subDept, + Position: "", + EmployeeType: "employee", + DingTalkUserID: dingTalkUserID, + } + if err := h.db.Create(&pe).Error; err != nil { + continue + } + imported++ + } + + response.Success(c, gin.H{ + "imported": imported, + "updated": updated, + "noDept": noDept, + "total": imported + updated + noDept, + }) +} diff --git a/sc-lktx-backend/internal/auth/handler.go b/sc-lktx-backend/internal/auth/handler.go new file mode 100644 index 0000000..b4b577c --- /dev/null +++ b/sc-lktx-backend/internal/auth/handler.go @@ -0,0 +1,380 @@ +// 认证模块:微信登录、用户信息获取和更新 +package auth + +import ( + "strconv" + "time" + + "com.sclktx/m/v2/internal/common/model" + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/common/utils" + "com.sclktx/m/v2/internal/pkg/config" + "com.sclktx/m/v2/internal/pkg/wechat" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AuthHandler struct { + db *gorm.DB + cfg *config.Config + wxClient *wechat.MiniProgramClient +} + +func NewAuthHandler(db *gorm.DB, cfg *config.Config, wxClient *wechat.MiniProgramClient) *AuthHandler { + return &AuthHandler{db: db, cfg: cfg, wxClient: wxClient} +} + +type LoginRequest struct { + Code string `json:"code" binding:"required"` +} + +type LoginResponse struct { + Token string `json:"token"` + UserInfo *UserInfoVO `json:"user_info"` +} + +type UserInfoVO struct { + ID uint `json:"id"` + Openid string `json:"openid"` + Nickname string `json:"nickname"` + RealName string `json:"real_name"` + Phone string `json:"phone"` + AvatarURL string `json:"avatar_url"` + RoleCode string `json:"role"` + RoleName string `json:"role_name"` + Status int `json:"status"` + IsFormalEmployee bool `json:"is_formal_employee"` + DepartmentName string `json:"department_name,omitempty"` + Position string `json:"position,omitempty"` +} + +// Login 微信登录:code 换 openid → 查找或注册用户 → 生成 JWT +// @Summary 微信登录 +// @Description 使用微信小程序 code 登录,返回 JWT token 和用户信息 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param body body LoginRequest true "微信登录code" +// @Success 200 {object} response.Response{data=LoginResponse} "登录成功" +// @Router /auth/login [post] +func (h *AuthHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + // 调用微信 code2session 换取 openid + session, err := h.wxClient.Code2Session(req.Code) + if err != nil { + response.BadRequest(c, "微信登录失败: "+err.Error()) + return + } + openid := session.OpenID + + var user model.User + result := h.db.Where("openid = ?", openid).First(&user) + + if result.Error != nil { + // 新用户注册,分配初始角色(默认访客) + user = model.User{Openid: openid, Role: "visitor", Status: 1, AvatarURL: h.cfg.System.DefaultAvatar} + if err := h.db.Create(&user).Error; err != nil { + response.InternalError(c, "创建用户失败") + return + } + } + + roleCode := user.Role + if roleCode == "" { + roleCode = "visitor" + } + + token, err := utils.GenerateToken(user.ID, roleCode, h.cfg.JWT.Secret, h.cfg.JWT.ExpireHours) + if err != nil { + response.InternalError(c, "生成令牌失败") + return + } + + // 查询用户部门信息(从 UserDepartment 关联表获取) + deptName, position := "", "" + var ud model.UserDepartment + if err := h.db.Where("user_id = ?", user.ID).First(&ud).Error; err == nil { + deptName = ud.DepartmentName + position = ud.Position + } + + response.Success(c, LoginResponse{ + Token: token, + UserInfo: &UserInfoVO{ + ID: user.ID, Openid: user.Openid, Nickname: user.Nickname, + RealName: user.RealName, Phone: user.Phone, AvatarURL: user.AvatarURL, + RoleCode: roleCode, RoleName: roleNameMap(roleCode), + Status: user.Status, + IsFormalEmployee: roleCode == "employee", + DepartmentName: deptName, + Position: position, + }, + }) +} + +// GetUserInfo 获取当前用户信息 +// @Summary 获取当前用户信息 +// @Description 获取当前登录用户的详细信息,包含角色、部门、职位等 +// @Tags 认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=UserInfoVO} "用户信息" +// @Router /auth/userinfo [get] +func (h *AuthHandler) GetUserInfo(c *gin.Context) { + userID := utils.GetUserID(c) + var user model.User + if err := h.db.First(&user, userID).Error; err != nil { + response.Unauthorized(c, "用户不存在,请重新登录") + return + } + roleCode := user.Role + if roleCode == "" { + roleCode = "visitor" + } + + // 查询用户部门信息(从 UserDepartment 关联表获取) + deptName, position := "", "" + var ud model.UserDepartment + if err := h.db.Where("user_id = ?", userID).First(&ud).Error; err == nil { + deptName = ud.DepartmentName + position = ud.Position + } + + response.Success(c, UserInfoVO{ + ID: user.ID, Openid: user.Openid, Nickname: user.Nickname, + RealName: user.RealName, Phone: user.Phone, AvatarURL: user.AvatarURL, + RoleCode: roleCode, RoleName: roleNameMap(roleCode), + Status: user.Status, + IsFormalEmployee: roleCode == "employee", + DepartmentName: deptName, + Position: position, + }) +} + +// UpdateUserInfo 更新用户信息(部分更新) +// @Summary 更新用户信息 +// @Description 部分更新当前用户的昵称、真实姓名、头像 +// @Tags 认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{nickname=string,real_name=string,avatar_url=string} true "要更新的字段" +// @Success 200 {object} response.Response "更新成功" +// @Router /auth/userinfo [put] +func (h *AuthHandler) UpdateUserInfo(c *gin.Context) { + userID := utils.GetUserID(c) + var req struct { + Nickname string `json:"nickname"` + RealName string `json:"real_name"` + AvatarURL string `json:"avatar_url"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + updates := map[string]interface{}{} + if req.Nickname != "" { + updates["nickname"] = req.Nickname + } + if req.RealName != "" { + updates["real_name"] = req.RealName + } + if req.AvatarURL != "" { + updates["avatar_url"] = req.AvatarURL + } + if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// UpdateAvatar 更新用户头像 +// @Summary 更新用户头像 +// @Description 接受微信图片 URL,直接更新用户头像 +// @Tags 用户 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param userId path int true "用户ID" +// @Param body body object{url=string} true "微信图片URL" +// @Success 200 {object} response.Response "成功" +// @Router /v1/user/{userId}/avatar [put] +func (h *AuthHandler) UpdateAvatar(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + var req struct { + URL string `json:"url" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.db.Model(&model.User{}).Where("id = ?", userID).Update("avatar_url", req.URL).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// BindPhone 绑定手机号(微信解密) +// @Summary 绑定手机号 +// @Description 通过微信加密数据解密并绑定手机号 +// @Tags 认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{code=string,encrypted_data=string,iv=string} true "微信加密数据" +// @Success 200 {object} response.Response "绑定成功" +// @Router /auth/phone [post] +func (h *AuthHandler) BindPhone(c *gin.Context) { + userID := utils.GetUserID(c) + var req struct { + Code string `json:"code" binding:"required"` + EncryptedData string `json:"encrypted_data" binding:"required"` + Iv string `json:"iv" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 用 code 换取 session_key + session, err := h.wxClient.Code2Session(req.Code) + if err != nil { + response.BadRequest(c, "微信授权失败") + return + } + + // 解密手机号 + phone, err := h.wxClient.DecryptPhone(session.SessionKey, req.EncryptedData, req.Iv) + if err != nil { + response.BadRequest(c, "手机号解密失败") + return + } + + if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "phone": phone, "phone_verified": true, + }).Error; err != nil { + response.InternalError(c, "绑定失败") + return + } + + // 自动匹配待确认员工:手机号匹配则升级为正式员工 + var pending model.PendingEmployee + if err := h.db.Where("phone = ? AND is_matched = ?", phone, false).First(&pending).Error; err == nil { + now := time.Now() + // 创建 UserDepartment + ud := model.UserDepartment{ + UserID: userID, + DepartmentID: pending.DepartmentID, + DepartmentName: pending.DepartmentName, + Position: pending.Position, + EmployeeType: pending.EmployeeType, + EmployeeStatus: 1, + } + h.db.Where("user_id = ?", userID).Assign(ud).FirstOrCreate(&model.UserDepartment{UserID: userID}) + + // 升级用户角色 + h.db.Model(&model.User{}).Where("id = ?", userID).Update("role", "employee") + + // 标记已匹配 + h.db.Model(&pending).Updates(map[string]interface{}{ + "is_matched": true, + "matched_user_id": &userID, + "matched_at": &now, + }) + } + + response.SuccessWithMessage(c, "绑定成功", nil) +} + +// MockLogin 模拟登录(开发调试用):直接登录 id=1 的用户 +// @Summary 模拟登录 +// @Description 直接登录 id=1 的账号,返回与微信登录一致的 token 和用户信息 +// @Tags 认证 +// @Accept json +// @Produce json +// @Success 200 {object} response.Response{data=LoginResponse} "登录成功" +// @Router /auth/mock-login [post] +func (h *AuthHandler) MockLogin(c *gin.Context) { + var user model.User + if err := h.db.First(&user, 1).Error; err != nil { + response.NotFound(c, "用户不存在") + return + } + + roleCode := user.Role + if roleCode == "" { + roleCode = "visitor" + } + + token, err := utils.GenerateToken(user.ID, roleCode, h.cfg.JWT.Secret, h.cfg.JWT.ExpireHours) + if err != nil { + response.InternalError(c, "生成令牌失败") + return + } + + // 查询用户部门信息(从 UserDepartment 关联表获取) + deptName, position := "", "" + var ud model.UserDepartment + if err := h.db.Where("user_id = ?", user.ID).First(&ud).Error; err == nil { + deptName = ud.DepartmentName + position = ud.Position + } + + response.Success(c, LoginResponse{ + Token: token, + UserInfo: &UserInfoVO{ + ID: user.ID, Openid: user.Openid, Nickname: user.Nickname, + RealName: user.RealName, Phone: user.Phone, AvatarURL: user.AvatarURL, + RoleCode: roleCode, RoleName: roleNameMap(roleCode), + Status: user.Status, + IsFormalEmployee: roleCode == "employee", + DepartmentName: deptName, + Position: position, + }, + }) +} + +func (h *AuthHandler) RegisterRoutes(r *gin.RouterGroup) { + auth := r.Group("/auth") + { + auth.POST("/login", h.Login) + auth.POST("/mock-login", h.MockLogin) + } +} + +// RegisterAuthRoutes 注册需要 JWT 认证的 auth 路由 +func (h *AuthHandler) RegisterAuthRoutes(r *gin.RouterGroup) { + auth := r.Group("/auth") + { + auth.GET("/userinfo", h.GetUserInfo) + auth.PUT("/userinfo", h.UpdateUserInfo) + auth.POST("/phone", h.BindPhone) + } + // 头像更新 + r.PUT("/user/:userId/avatar", h.UpdateAvatar) +} + +// roleNameMap 角色编码 → 中文名 +func roleNameMap(code string) string { + switch code { + case "employee": + return "员工" + case "guard": + return "保安" + default: + return "访客" + } +} diff --git a/sc-lktx-backend/internal/common/middleware/admin_jwt.go b/sc-lktx-backend/internal/common/middleware/admin_jwt.go new file mode 100644 index 0000000..c92e974 --- /dev/null +++ b/sc-lktx-backend/internal/common/middleware/admin_jwt.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/common/utils" + + "github.com/gin-gonic/gin" +) + +func AdminJWTAuth(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + response.Unauthorized(c, "未提供认证令牌") + c.Abort() + return + } + var tokenStr string + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + tokenStr = authHeader[7:] + } else { + tokenStr = authHeader + } + claims, err := utils.ParseAdminToken(tokenStr, secret) + if err != nil { + response.Unauthorized(c, "管理员令牌无效或已过期") + c.Abort() + return + } + c.Set("admin_id", claims.AdminID) + c.Set("admin_account", claims.Account) + c.Next() + } +} diff --git a/sc-lktx-backend/internal/common/middleware/cors.go b/sc-lktx-backend/internal/common/middleware/cors.go new file mode 100644 index 0000000..bad4c12 --- /dev/null +++ b/sc-lktx-backend/internal/common/middleware/cors.go @@ -0,0 +1,25 @@ +// 中间件:Gin 的中间件是 func(*gin.Context),通过 c.Next() 控制执行链 +// 执行顺序:请求进入 → 中间件1(c.Next前) → 中间件2(c.Next前) → Handler → 中间件2(c.Next后) → 中间件1(c.Next后) → 响应 +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// CORS 跨域中间件,允许 H5 调试时跨域访问 +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Type") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/sc-lktx-backend/internal/common/middleware/jwt.go b/sc-lktx-backend/internal/common/middleware/jwt.go new file mode 100644 index 0000000..508564c --- /dev/null +++ b/sc-lktx-backend/internal/common/middleware/jwt.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "strings" + + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/common/utils" + + "github.com/gin-gonic/gin" +) + +// JWTAuth JWT 认证中间件 +// 1. 从 Authorization header 提取 Bearer Token +// 2. 解析验证 JWT(签名 + 过期时间) +// 3. 将 user_id、role_code、role_codes 注入 gin.Context +// 4. 后续 Handler 通过 utils.GetUserID(c) / utils.GetRoleCodes(c) 获取 +func JWTAuth(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + response.Unauthorized(c, "未提供认证令牌") + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + response.Unauthorized(c, "认证格式错误") + c.Abort() + return + } + + claims, err := utils.ParseToken(parts[1], secret) + if err != nil { + response.Unauthorized(c, "认证令牌无效或已过期") + c.Abort() + return + } + + // 注入用户信息到 Context + c.Set("user_id", claims.UserID) + c.Set("role_code", claims.RoleCode) + c.Next() + } +} diff --git a/sc-lktx-backend/internal/common/middleware/logger.go b/sc-lktx-backend/internal/common/middleware/logger.go new file mode 100644 index 0000000..3bd7317 --- /dev/null +++ b/sc-lktx-backend/internal/common/middleware/logger.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Logger 请求日志中间件,记录每个请求的方法、路径、状态码、耗时 +// 在 c.Next() 前后分别记录开始和结束时间 +func Logger(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() // 执行后续中间件和 Handler + + cost := time.Since(start) + logger.Info("HTTP Request", + zap.Int("status", c.Writer.Status()), + zap.String("method", c.Request.Method), + zap.String("path", path), + zap.String("query", query), + zap.String("ip", c.ClientIP()), + zap.Duration("cost", cost), + zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()), + ) + } +} diff --git a/sc-lktx-backend/internal/common/model/base.go b/sc-lktx-backend/internal/common/model/base.go new file mode 100644 index 0000000..715280d --- /dev/null +++ b/sc-lktx-backend/internal/common/model/base.go @@ -0,0 +1,15 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// BaseModel 基础模型,所有表内嵌此结构,提供 ID、时间戳、软删除 +type BaseModel struct { + ID uint `gorm:"primarykey;comment:主键ID" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间(软删除)" json:"deleted_at,omitempty"` +} diff --git a/sc-lktx-backend/internal/common/model/models.go b/sc-lktx-backend/internal/common/model/models.go new file mode 100644 index 0000000..4c7437f --- /dev/null +++ b/sc-lktx-backend/internal/common/model/models.go @@ -0,0 +1,332 @@ +package model + +import ( + "time" +) + +// User 用户模型 +type User struct { + BaseModel + Openid string `gorm:"uniqueIndex;size:100;not null;comment:微信OpenID" json:"openid"` + Nickname string `gorm:"size:50;comment:微信昵称" json:"nickname"` + RealName string `gorm:"size:50;comment:真实姓名" json:"real_name"` + Phone string `gorm:"size:20;comment:手机号" json:"phone"` + PhoneVerified bool `gorm:"default:false;comment:手机号是否已验证" json:"phone_verified"` + AvatarURL string `gorm:"size:500;comment:头像URL" json:"avatar_url"` + FaceImageURL string `gorm:"size:500;comment:人脸图片URL" json:"face_image_url"` + Role string `gorm:"size:20;default:visitor;comment:角色(visitor/employee/guard)" json:"role"` + Status int `gorm:"default:1;comment:用户状态(1正常 0禁用)" json:"status"` +} + +func (User) TableName() string { return "users" } + +// UserDepartment 员工-部门关联表 +type UserDepartment struct { + ID uint `gorm:"primarykey;comment:主键ID" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + UserID uint `gorm:"uniqueIndex;not null;comment:用户ID" json:"user_id"` + User *User `gorm:"foreignKey:UserID;comment:关联用户信息" json:"user,omitempty"` + DepartmentID uint `gorm:"not null;index;comment:部门ID" json:"department_id"` + Department *Department `gorm:"foreignKey:DepartmentID;comment:关联部门信息" json:"department,omitempty"` + DepartmentName string `gorm:"size:100;comment:部门名称(冗余)" json:"department_name"` + Position string `gorm:"size:100;comment:职位" json:"position"` + EmployeeType string `gorm:"size:30;default:employee;comment:员工类型" json:"employee_type"` + EmployeeStatus int `gorm:"default:1;comment:员工状态(1在职 0离职)" json:"employee_status"` + DingTalkUserID string `gorm:"column:ding_talk_user_id;size:100;comment:钉钉用户unionId(从通讯录导入)" json:"dingtalk_user_id"` +} + +func (UserDepartment) TableName() string { return "user_departments" } + +// Visitor 访客信息 +type Visitor struct { + BaseModel + UserID *uint `gorm:"comment:关联用户ID(可为空)" json:"user_id"` + Company string `gorm:"size:200;comment:访客公司名称" json:"company"` + Name string `gorm:"size:50;not null;comment:访客姓名" json:"name"` + Phone string `gorm:"size:20;not null;comment:访客电话" json:"phone"` + Gender int `gorm:"comment:性别(0未知 1男 2女)" json:"gender"` + IDType string `gorm:"size:20;comment:证件类型" json:"id_type"` + IDNumber string `gorm:"size:50;comment:证件号码" json:"id_number"` + FaceVerified bool `gorm:"default:false;comment:是否已完成人脸验证" json:"face_verified"` + PhoneVerified bool `gorm:"default:false;comment:是否已完成手机验证" json:"phone_verified"` +} + +func (Visitor) TableName() string { return "visitors" } + +// Appointment 预约 +type Appointment struct { + BaseModel + VisitUserID uint `gorm:"comment:被访用户ID" json:"visit_user_id"` + CreatorUserID uint `gorm:"comment:创建人用户ID" json:"creator_user_id"` + Creator *User `gorm:"foreignKey:CreatorUserID;comment:创建人信息" json:"creator,omitempty"` + VisitType string `gorm:"size:50;comment:来访目的" json:"visit_type"` + VisitStartTime time.Time `gorm:"comment:预计来访开始时间" json:"visit_start_time"` + VisitEndTime time.Time `gorm:"comment:预计来访结束时间" json:"visit_end_time"` + Status int `gorm:"default:0;comment:预约状态(0审核中 1通过 2拒绝 3取消)" json:"status"` + Remark string `gorm:"type:text;comment:备注" json:"remark"` + Comment string `gorm:"type:text;comment:审核意见" json:"comment"` + VisitPurpose string `gorm:"type:text;comment:来访目的" json:"visit_purpose"` + VisitorCompany string `gorm:"size:200;comment:来访单位名" json:"visitor_company"` + QrCodeURL string `gorm:"size:500;comment:入场二维码URL" json:"qr_code_url"` + VisitorInfo string `gorm:"type:jsonb;comment:所有访客信息(JSON数组)" json:"visitor_info"` + CreatorInfo string `gorm:"type:jsonb;comment:创建人信息(JSON)" json:"creator_info"` + EmployeeInfo string `gorm:"type:jsonb;comment:被访人信息(JSON)" json:"employee_info"` + GoodsInfo string `gorm:"type:jsonb;comment:携带物品信息(JSON)" json:"goods_info"` + AreasInfo string `gorm:"type:jsonb;comment:到访区域信息(JSON)" json:"areas_info"` + VehicleInfo string `gorm:"type:jsonb;comment:车辆信息(JSON数组)" json:"vehicle_info"` +} + +func (Appointment) TableName() string { return "appointments" } + +// InviteCode 邀请码 +type InviteCode struct { + Code string `gorm:"primaryKey;size:100;comment:邀请码" json:"code"` + VisitUserID uint `gorm:"index;comment:被访人用户ID" json:"visit_user_id"` + VisitUser *User `gorm:"foreignKey:VisitUserID;comment:被访人信息" json:"visit_user,omitempty"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + ExpiresAt time.Time `gorm:"comment:过期时间" json:"expires_at"` +} + +func (InviteCode) TableName() string { return "invite_codes" } + +// VisitRecord 访问记录 +type VisitRecord struct { + BaseModel + AppointmentID uint `gorm:"comment:关联的预约ID" json:"appointment_id"` + Appointment *Appointment `gorm:"foreignKey:AppointmentID;comment:关联的预约信息" json:"appointment,omitempty"` + GuardID uint `gorm:"comment:登记保安的用户ID" json:"guard_id"` + Guard *User `gorm:"foreignKey:GuardID;comment:关联的保安用户信息" json:"guard,omitempty"` + CheckType int `gorm:"comment:登记类型(1进场 2出场)" json:"check_type"` + CheckTime time.Time `gorm:"comment:登记时间" json:"check_time"` + IsValid bool `gorm:"default:true;comment:是否有效" json:"is_valid"` + Remark string `gorm:"type:text;comment:备注" json:"remark"` +} + +func (VisitRecord) TableName() string { return "visit_records" } + +// Invitation 邀请 +type Invitation struct { + BaseModel + EmployeeID uint `gorm:"comment:发起邀请的员工用户ID" json:"employee_id"` + Employee *User `gorm:"foreignKey:EmployeeID;comment:关联的员工信息" json:"employee,omitempty"` + InviteCode string `gorm:"size:100;uniqueIndex;not null;comment:邀请码" json:"invite_code"` + VisitorName string `gorm:"size:50;comment:被邀请访客姓名" json:"visitor_name"` + VisitorPhone string `gorm:"size:20;comment:被邀请访客电话" json:"visitor_phone"` + VisitType string `gorm:"size:50;comment:来访目的" json:"visit_type"` + VisitArea string `gorm:"size:200;comment:允许访问的区域" json:"visit_area"` + ValidFrom time.Time `gorm:"comment:邀请生效时间" json:"valid_from"` + ValidUntil time.Time `gorm:"comment:邀请失效时间" json:"valid_until"` + MaxUseCount int `gorm:"default:1;comment:最大使用次数" json:"max_use_count"` + UsedCount int `gorm:"default:0;comment:已使用次数" json:"used_count"` + IsActive bool `gorm:"default:true;comment:是否有效" json:"is_active"` +} + +func (Invitation) TableName() string { return "invitations" } + +// Banner 首页轮播图 +type Banner struct { + BaseModel + ImageURL string `gorm:"size:500;not null;comment:图片地址" json:"image_url"` + JumpPath string `gorm:"size:500;comment:跳转链接" json:"jump_path"` + IsJump bool `gorm:"default:false;comment:是否可跳转" json:"is_jump"` + Sort int `gorm:"default:0;comment:排序号" json:"sort"` + IsValid bool `gorm:"default:true;comment:是否有效" json:"is_valid"` +} + +func (Banner) TableName() string { return "banners" } + +// Department 部门(树形结构) +type Department struct { + BaseModel + Name string `gorm:"size:100;not null;comment:部门名称" json:"name"` + ParentID *uint `gorm:"comment:上级部门ID" json:"parent_id"` + Parent *Department `gorm:"foreignKey:ParentID;comment:上级部门" json:"parent,omitempty"` + Children []Department `gorm:"foreignKey:ParentID;comment:子部门列表" json:"children,omitempty"` + LeaderID *uint `gorm:"comment:部门负责人(部长)用户ID" json:"leader_id"` + Leader *User `gorm:"foreignKey:LeaderID;comment:部门负责人信息" json:"leader,omitempty"` + DeputyID *uint `gorm:"comment:部门副负责人(副部长)用户ID" json:"deputy_id"` + Deputy *User `gorm:"foreignKey:DeputyID;comment:部门副负责人信息" json:"deputy,omitempty"` + ApproverUserIDs string `gorm:"type:text;comment:部门指定审批人用户ID列表(JSON数组)" json:"approver_user_ids"` + VpUserIDs string `gorm:"type:text;comment:分管领导用户ID列表(JSON数组)" json:"vp_user_ids"` + Sort int `gorm:"default:0;comment:排序号" json:"sort"` + Status int `gorm:"default:1;comment:状态(1启用 0禁用)" json:"status"` + Description string `gorm:"size:200;comment:部门描述" json:"description"` +} + +func (Department) TableName() string { return "departments" } + +// EmployeeCertification 角色认证申请 +type EmployeeCertification struct { + BaseModel + UserID uint `gorm:"not null;index;comment:申请人用户ID" json:"user_id"` + User *User `gorm:"foreignKey:UserID;comment:关联的申请人信息" json:"user,omitempty"` + RealName string `gorm:"size:50;not null;comment:真实姓名" json:"real_name"` + Phone string `gorm:"size:20;not null;comment:手机号" json:"phone"` + Role string `gorm:"size:20;default:员工;comment:角色类型(员工/保安)" json:"role"` + Snapshot []string `gorm:"type:text;serializer:json;comment:材料截图URL列表" json:"snapshot"` + Position string `gorm:"size:100;comment:职位" json:"position"` + DepartmentID *uint `gorm:"comment:分配的部门ID(审核通过后设置)" json:"department_id"` + Department *Department `gorm:"foreignKey:DepartmentID;comment:关联的部门信息" json:"department,omitempty"` + Status int `gorm:"default:0;comment:审核状态(0待审核 1通过 2拒绝)" json:"status"` + ApproverID *uint `gorm:"comment:审批人用户ID" json:"approver_id"` + Approver *User `gorm:"foreignKey:ApproverID;comment:关联的审批人信息" json:"approver,omitempty"` + Comment string `gorm:"type:text;comment:审核意见" json:"comment"` +} + +func (EmployeeCertification) TableName() string { return "employee_certifications" } + +// Notice 系统通知 +type Notice struct { + BaseModel + Title string `gorm:"size:200;not null;comment:通知标题" json:"title"` + Content string `gorm:"type:text;not null;comment:通知内容" json:"content"` + IsValid bool `gorm:"default:true;comment:是否有效" json:"is_valid"` + Sort int `gorm:"default:0;comment:排序号" json:"sort"` +} + +func (Notice) TableName() string { return "notices" } + +// Notification 用户通知 +type Notification struct { + BaseModel + UserID uint `gorm:"index;not null;comment:接收用户ID" json:"user_id"` + User *User `gorm:"foreignKey:UserID;comment:关联的用户信息" json:"user,omitempty"` + Title string `gorm:"size:200;not null;comment:通知标题" json:"title"` + Content string `gorm:"type:text;not null;comment:通知内容" json:"content"` + Type string `gorm:"size:50;default:appointment;comment:通知类型(appointment/certification)" json:"type"` + RefID *uint `gorm:"comment:关联业务ID" json:"ref_id"` + IsRead bool `gorm:"default:false;comment:是否已读" json:"is_read"` +} + +func (Notification) TableName() string { return "notifications" } + +// GoodsTemplate 物品模板 +type GoodsTemplate struct { + BaseModel + UserID uint `gorm:"index;not null;comment:用户ID" json:"user_id"` + Name string `gorm:"size:200;not null;comment:物品名称" json:"name"` + Quantity int `gorm:"default:1;comment:物品数量" json:"quantity"` + Model string `gorm:"size:100;comment:物品型号" json:"model"` + Remark string `gorm:"type:text;comment:物品备注" json:"remark"` +} + +func (GoodsTemplate) TableName() string { return "goods_templates" } + +// VehicleInfo 用户车辆信息 +type VehicleInfo struct { + BaseModel + UserID uint `gorm:"index;not null;comment:用户ID" json:"user_id"` + LicensePlate string `gorm:"size:20;comment:车牌号" json:"license_plate"` + Brand string `gorm:"size:50;comment:品牌型号" json:"brand"` + Color string `gorm:"size:20;comment:车辆颜色" json:"color"` +} + +func (VehicleInfo) TableName() string { return "vehicle_infos" } + +// ==================== 工作流引擎模型 ==================== + +// ProcessDefinition 流程定义 +type ProcessDefinition struct { + BaseModel + ProcessName string `gorm:"size:100;not null;comment:流程名称" json:"process_name"` + ProcessCode string `gorm:"uniqueIndex;size:50;not null;comment:流程编码" json:"process_code"` + Source string `gorm:"size:100;comment:流程来源" json:"source"` + Description string `gorm:"size:200;comment:流程描述" json:"description"` + IsActive bool `gorm:"default:true;comment:是否启用" json:"is_active"` + RevokeEvents string `gorm:"type:text;comment:撤销事件JSON" json:"revoke_events"` + NodesJSON string `gorm:"type:text;comment:节点配置JSON" json:"nodes_json"` +} + +func (ProcessDefinition) TableName() string { return "process_definitions" } + +// ProcessInstance 流程实例 +type ProcessInstance struct { + BaseModel + ProcessDefID uint `gorm:"index;not null;comment:流程定义ID" json:"process_def_id"` + ProcessDef *ProcessDefinition `gorm:"foreignKey:ProcessDefID" json:"process_def,omitempty"` + BusinessType string `gorm:"size:50;not null;comment:业务类型" json:"business_type"` + BusinessID uint `gorm:"index;not null;comment:业务ID" json:"business_id"` + Status int `gorm:"default:0;comment:实例状态(0运行中 1已完成 2已终止 3已撤销)" json:"status"` + StarterID uint `gorm:"comment:发起人用户ID" json:"starter_id"` + Starter *User `gorm:"foreignKey:StarterID" json:"starter,omitempty"` + CurrentNodeID string `gorm:"size:50;comment:当前节点ID" json:"current_node_id"` + Variables string `gorm:"type:text;comment:流程变量JSON" json:"variables"` + FinishedAt *time.Time `gorm:"comment:完成时间" json:"finished_at"` +} + +func (ProcessInstance) TableName() string { return "process_instances" } + +// Task 任务 +type Task struct { + BaseModel + InstanceID uint `gorm:"index;not null;comment:流程实例ID" json:"instance_id"` + Instance *ProcessInstance `gorm:"foreignKey:InstanceID" json:"instance,omitempty"` + NodeID string `gorm:"size:50;not null;comment:节点ID" json:"node_id"` + NodeName string `gorm:"size:100;comment:节点名称" json:"node_name"` + Status int `gorm:"default:0;comment:任务状态(0待处理 1已完成 2已拒绝 3已取消)" json:"status"` + AssigneeID *uint `gorm:"comment:处理人用户ID" json:"assignee_id"` + Assignee *User `gorm:"foreignKey:AssigneeID" json:"assignee,omitempty"` + Comment string `gorm:"type:text;comment:处理意见" json:"comment"` + HandledAt *time.Time `gorm:"comment:处理时间" json:"handled_at"` +} + +func (Task) TableName() string { return "tasks" } + +// AdminUser 后台管理员账号 +type AdminUser struct { + BaseModel + Account string `gorm:"uniqueIndex;size:50;not null;comment:登录账号" json:"account"` + Password string `gorm:"size:200;not null;comment:密码(SHA256)" json:"-"` + Name string `gorm:"size:50;comment:管理员姓名" json:"name"` + IsSuper bool `gorm:"default:false;comment:是否超级管理员" json:"is_super"` + Status int `gorm:"default:1;comment:状态(1正常 0禁用)" json:"status"` +} + +func (AdminUser) TableName() string { return "admin_users" } + +// VisitorArea 到访区域 +type VisitorArea struct { + BaseModel + Name string `gorm:"size:100;not null;comment:区域名称" json:"name"` + Sort int `gorm:"default:0;comment:排序号" json:"sort"` + Status int `gorm:"default:1;comment:状态(1启用 0禁用)" json:"status"` +} + +func (VisitorArea) TableName() string { return "visitor_areas" } + +// VisitType 来访目的 +type VisitType struct { + BaseModel + Name string `gorm:"size:100;not null;comment:类型名称" json:"name"` + Sort int `gorm:"default:0;comment:排序号" json:"sort"` + Status int `gorm:"default:1;comment:状态(1启用 0禁用)" json:"status"` +} + +func (VisitType) TableName() string { return "visit_types" } + +// PendingEmployee 待确认员工(通讯录导入) +type PendingEmployee struct { + BaseModel + RealName string `gorm:"size:50;not null;index;comment:真实姓名" json:"real_name"` + Phone string `gorm:"size:20;not null;index;comment:手机号" json:"phone"` + DepartmentID uint `gorm:"not null;comment:部门ID" json:"department_id"` + DepartmentName string `gorm:"size:100;comment:部门名称" json:"department_name"` + Position string `gorm:"size:100;comment:职位" json:"position"` + EmployeeType string `gorm:"size:30;default:employee;comment:员工类型" json:"employee_type"` + DingTalkUserID string `gorm:"column:ding_talk_user_id;size:100;comment:钉钉用户unionId" json:"dingtalk_user_id"` + IsMatched bool `gorm:"default:false;comment:是否已匹配用户" json:"is_matched"` + MatchedUserID *uint `gorm:"comment:匹配到的用户ID" json:"matched_user_id"` + MatchedAt *time.Time `gorm:"comment:匹配时间" json:"matched_at"` +} + +func (PendingEmployee) TableName() string { return "pending_employees" } + +// GlobalApprover 全局兜底审批人 +type GlobalApprover struct { + BaseModel + UserID uint `gorm:"uniqueIndex;not null;comment:审批人用户ID" json:"user_id"` + User *User `gorm:"foreignKey:UserID;comment:关联用户信息" json:"user,omitempty"` +} + +func (GlobalApprover) TableName() string { return "global_approvers" } diff --git a/sc-lktx-backend/internal/common/response/response.go b/sc-lktx-backend/internal/common/response/response.go new file mode 100644 index 0000000..a3794d0 --- /dev/null +++ b/sc-lktx-backend/internal/common/response/response.go @@ -0,0 +1,61 @@ +// 统一 API 响应格式,所有接口使用此包返回数据 +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response 统一响应结构:code=200 成功,其他为业务错误码 +type Response struct { + Code int `json:"code"` + Message string `json:"msg"` + Data interface{} `json:"data,omitempty"` +} + +// PageData 分页数据结构 +type PageData struct { + List interface{} `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ===== 成功响应 ===== + +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{Code: 200, Message: "success", Data: data}) +} + +func SuccessWithMessage(c *gin.Context, message string, data interface{}) { + c.JSON(http.StatusOK, Response{Code: 200, Message: message, Data: data}) +} + +func SuccessPage(c *gin.Context, list interface{}, total int64, page, pageSize int) { + c.JSON(http.StatusOK, Response{Code: 200, Message: "success", Data: PageData{ + List: list, Total: total, Page: page, PageSize: pageSize, + }}) +} + +// ===== 错误响应 ===== + +func Error(c *gin.Context, code int, message string) { + c.JSON(http.StatusOK, Response{Code: code, Message: message}) +} + +func ErrorWithStatus(c *gin.Context, httpStatus int, code int, message string) { + c.JSON(httpStatus, Response{Code: code, Message: message}) +} + +func BadRequest(c *gin.Context, message string) { Error(c, 400, message) } +func Unauthorized(c *gin.Context, message string) { + ErrorWithStatus(c, http.StatusUnauthorized, 401, message) +} +func Forbidden(c *gin.Context, message string) { + ErrorWithStatus(c, http.StatusForbidden, 403, message) +} +func NotFound(c *gin.Context, message string) { ErrorWithStatus(c, http.StatusNotFound, 404, message) } +func InternalError(c *gin.Context, message string) { + ErrorWithStatus(c, http.StatusInternalServerError, 500, message) +} diff --git a/sc-lktx-backend/internal/common/utils/context.go b/sc-lktx-backend/internal/common/utils/context.go new file mode 100644 index 0000000..41e0732 --- /dev/null +++ b/sc-lktx-backend/internal/common/utils/context.go @@ -0,0 +1,61 @@ +// 从 gin.Context 提取中间件注入的数据 + 分页工具 +package utils + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +// GetUserID 从 Context 获取用户 ID(支持 uint/float64/string 类型转换) +func GetUserID(c *gin.Context) uint { + userID, exists := c.Get("user_id") + if !exists { + return 0 + } + switch v := userID.(type) { + case uint: + return v + case float64: + return uint(v) + case string: + id, _ := strconv.ParseUint(v, 10, 64) + return uint(id) + default: + return 0 + } +} + +// GetRoleCode 获取主角色编码 +func GetRoleCode(c *gin.Context) string { + roleCode, exists := c.Get("role_code") + if !exists { + return "" + } + return roleCode.(string) +} + +// HasRole 检查当前用户是否为指定角色 +func HasRole(c *gin.Context, roleCode string) bool { + return GetRoleCode(c) == roleCode +} + +// ParsePagination 解析分页参数,默认 page=1, pageSize=10, 最大 1000 +func ParsePagination(c *gin.Context) (page, pageSize int) { + pageStr := c.DefaultQuery("page", "1") + pageSizeStr := c.DefaultQuery("page_size", "10") + page, _ = strconv.Atoi(pageStr) + pageSize, _ = strconv.Atoi(pageSizeStr) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 1000 { + pageSize = 10 + } + return +} + +// GetOffset 计算 SQL offset +func GetOffset(page, pageSize int) int { + return (page - 1) * pageSize +} diff --git a/sc-lktx-backend/internal/common/utils/jwt.go b/sc-lktx-backend/internal/common/utils/jwt.go new file mode 100644 index 0000000..0e307c4 --- /dev/null +++ b/sc-lktx-backend/internal/common/utils/jwt.go @@ -0,0 +1,101 @@ +// JWT Token 生成与解析 + 业务工具函数 +package utils + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// JWTClaims JWT 载荷,包含用户 ID 和角色列表 +type JWTClaims struct { + UserID uint `json:"user_id"` + RoleCode string `json:"role_code"` // 角色(visitor/employee/guard) + jwt.RegisteredClaims +} + +// AdminJWTClaims 管理员 JWT 载荷 +type AdminJWTClaims struct { + AdminID uint `json:"admin_id"` + Account string `json:"account"` + jwt.RegisteredClaims +} + +// GenerateAdminToken 生成管理员 JWT +func GenerateAdminToken(adminID uint, account string, secret string, expireHours int) (string, error) { + claims := AdminJWTClaims{ + AdminID: adminID, + Account: account, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "lktx-admin", + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +// ParseAdminToken 解析管理员 JWT +func ParseAdminToken(tokenString string, secret string) (*AdminJWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &AdminJWTClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*AdminJWTClaims); ok && token.Valid { + return claims, nil + } + return nil, fmt.Errorf("invalid token") +} + +// GenerateToken 生成 JWT(单角色,兼容旧版) +func GenerateToken(userID uint, roleCode string, secret string, expireHours int) (string, error) { + claims := JWTClaims{ + UserID: userID, + RoleCode: roleCode, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "lktx-mp", + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +// ParseToken 解析并验证 JWT +func ParseToken(tokenString string, secret string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + return nil, fmt.Errorf("invalid token") +} + +// GenerateInviteCode 生成 16 位随机邀请码 +func GenerateInviteCode() string { + b := make([]byte, 8) + rand.Read(b) + return hex.EncodeToString(b) +} + +// GenerateQRCodeContent 生成二维码内容 +func GenerateQRCodeContent(appointmentID uint) string { + return fmt.Sprintf("LKTX:APPT:%d:%d", appointmentID, time.Now().Unix()) +} diff --git a/sc-lktx-backend/internal/common/utils/validator.go b/sc-lktx-backend/internal/common/utils/validator.go new file mode 100644 index 0000000..da5b5f7 --- /dev/null +++ b/sc-lktx-backend/internal/common/utils/validator.go @@ -0,0 +1,22 @@ +// 数据校验工具 +package utils + +import "regexp" + +// ValidatePhone 验证中国大陆手机号(1 开头 + 3-9 + 9 位数字) +func ValidatePhone(phone string) bool { + matched, _ := regexp.MatchString(`^1[3-9]\d{9}$`, phone) + return matched +} + +// ValidateIDNumber 验证 18 位身份证号格式 +func ValidateIDNumber(idNumber string) bool { + matched, _ := regexp.MatchString(`^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[\dXx]$`, idNumber) + return matched +} + +// ValidateLicensePlate 验证中国大陆车牌号 +func ValidateLicensePlate(plate string) bool { + matched, _ := regexp.MatchString(`^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤川青藏琼宁][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,5}[A-HJ-NP-Z0-9挂学警港澳]$`, plate) + return matched +} diff --git a/sc-lktx-backend/internal/modules/appointment/handler.go b/sc-lktx-backend/internal/modules/appointment/handler.go new file mode 100644 index 0000000..f9361c7 --- /dev/null +++ b/sc-lktx-backend/internal/modules/appointment/handler.go @@ -0,0 +1,1697 @@ +package appointment + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + "com.sclktx/m/v2/internal/common/model" + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/common/utils" + "com.sclktx/m/v2/internal/modules/workflow" + "com.sclktx/m/v2/internal/pkg/dingtalk" + "com.sclktx/m/v2/internal/pkg/wechat" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// AppointmentHandler 预约处理器 +// 处理访客预约的创建、查询、审核、取消,以及员工搜索和邀请管理 +type AppointmentHandler struct { + db *gorm.DB + wxClient *wechat.MiniProgramClient + dingTalk *dingtalk.Client +} + +func NewAppointmentHandler(db *gorm.DB, wxClient *wechat.MiniProgramClient, dingTalk *dingtalk.Client) *AppointmentHandler { + return &AppointmentHandler{db: db, wxClient: wxClient, dingTalk: dingTalk} +} + +// ============ 员工搜索 ============ + +// SearchEmployees 搜索公司接待人员 +// @Summary 搜索公司接待人员 +// @Description 根据关键字搜索员工信息 +// @Tags 员工管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{keyword=string} true "搜索关键字" +// @Success 200 {object} response.Response "员工列表" +// @Router /appointment/employees/search [post] +func (h *AppointmentHandler) SearchEmployees(c *gin.Context) { + var req struct { + Keyword string `json:"keyword"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + var employees []model.User + query := h.db.Where("users.status = 1") + if req.Keyword != "" { + query = query.Joins("LEFT JOIN user_departments ON user_departments.user_id = users.id"). + Where("users.real_name LIKE ? OR users.phone LIKE ? OR user_departments.department_name LIKE ?", + "%"+req.Keyword+"%", "%"+req.Keyword+"%", "%"+req.Keyword+"%") + } + + if err := query.Select("users.*").Limit(20).Find(&employees).Error; err != nil { + response.InternalError(c, "查询失败") + return + } + + // 批量查询部门信息 + var userIDs []uint + for _, e := range employees { + userIDs = append(userIDs, e.ID) + } + deptMap := map[uint]model.UserDepartment{} + if len(userIDs) > 0 { + var depts []model.UserDepartment + h.db.Where("user_id IN ?", userIDs).Find(&depts) + for _, d := range depts { + deptMap[d.UserID] = d + } + } + + // EmployeeVO 员工视图对象 + type EmployeeVO struct { + ID uint `json:"id"` // 员工ID(Employee表主键) + UserID uint `json:"user_id"` // 关联的用户ID(User表主键) + Name string `json:"name"` // 员工姓名 + Department string `json:"department"` // 所属部门名称 + DepartmentID *uint `json:"department_id"` // 所属部门ID + Position string `json:"position"` // 职位标签 + Phone string `json:"phone"` // 手机号 + } + + var result []EmployeeVO + for _, e := range employees { + ud, hasDept := deptMap[e.ID] + var deptName string + var deptID *uint + if hasDept { + deptName = ud.DepartmentName + deptID = &ud.DepartmentID + } + result = append(result, EmployeeVO{ + ID: e.ID, + UserID: e.ID, + Name: e.RealName, + Department: deptName, + DepartmentID: deptID, + Position: ud.Position, + Phone: e.Phone, + }) + } + + response.Success(c, result) +} + +// ============ 预约管理 ============ + +// CreateAppointmentRequest 创建预约请求参数 +type CreateAppointmentRequest struct { + EmployeeName string `json:"employee_name"` // 公司接待人员姓名 + EmployeePhone string `json:"employee_phone"` // 公司接待人员手机号 + VisitStartTime string `json:"visit_start_time" binding:"required"` // 预计来访开始时间(必填) + VisitEndTime string `json:"visit_end_time" binding:"required"` // 预计来访结束时间(必填) + VisitAreas []string `json:"visit_areas"` // 可访问区域列表 + Goods []GoodsItem `json:"goods"` // 携带物品列表 + Visitors []VisitorItem `json:"visitors"` // 所有外部来访人员(第一人为主要访客) + Remark string `json:"remark"` // 备注 + VisitPurpose string `json:"visit_purpose"` // 来访目的 + HasVehicle bool `json:"has_vehicle"` // 是否有车辆 + LicensePlates []string `json:"license_plates"` // 车牌号列表 +} + +// GoodsItem 携带物品项 +type GoodsItem struct { + Name string `json:"name"` // 物品名称 + Quantity int `json:"quantity"` // 物品数量 + Model string `json:"model"` // 物品型号 + Remark string `json:"remark"` // 物品备注 +} + +// VisitorItem 同行访客 +type VisitorItem struct { + Name string `json:"name"` // 访客姓名 + Phone string `json:"phone"` // 访客电话 + Company string `json:"company"` // 公司名称 + Gender int `json:"gender"` // 性别(0未知 1男 2女) + IDType string `json:"id_type"` // 证件类型 + IDNumber string `json:"id_number"` // 证件号码 +} + +// CreateAppointment 创建预约 +// @Summary 创建预约 +// @Description 创建访客预约,包含访客信息、物品、同行人等 +// @Tags 预约管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body CreateAppointmentRequest true "预约信息" +// @Success 200 {object} response.Response{data=model.Appointment} "创建成功" +// @Router /appointment/appointments [post] +func (h *AppointmentHandler) CreateAppointment(c *gin.Context) { + var req CreateAppointmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + // 验证外部来访人员 + if len(req.Visitors) == 0 { + response.BadRequest(c, "请至少填写一位外部来访人员") + return + } + if !utils.ValidatePhone(req.Visitors[0].Phone) { + response.BadRequest(c, "手机号格式不正确") + return + } + + // 解析时间(优先带时区格式,兼容无时区) + startTime, err := time.Parse(time.RFC3339, req.VisitStartTime) + if err != nil { + startTime, err = time.Parse("2006-01-02 15:04:05-07:00", req.VisitStartTime) + if err != nil { + startTime, err = time.Parse("2006-01-02 15:04:05", req.VisitStartTime) + if err != nil { + response.BadRequest(c, "开始时间格式错误") + return + } + } + } + endTime, err := time.Parse(time.RFC3339, req.VisitEndTime) + if err != nil { + endTime, err = time.Parse("2006-01-02 15:04:05-07:00", req.VisitEndTime) + if err != nil { + endTime, err = time.Parse("2006-01-02 15:04:05", req.VisitEndTime) + if err != nil { + response.BadRequest(c, "结束时间格式错误") + return + } + } + } + if endTime.Before(startTime) { + response.BadRequest(c, "结束时间不能早于开始时间") + return + } + + // 获取当前创建者 + creatorUserID := utils.GetUserID(c) + var creatorUser model.User + if err := h.db.First(&creatorUser, creatorUserID).Error; err != nil { + response.BadRequest(c, "创建者不存在") + return + } + + // 创建或查找主访客(按手机号去重) + mainVisitor := req.Visitors[0] + var visitor model.Visitor + result := h.db.Where("phone = ?", mainVisitor.Phone).First(&visitor) + if result.Error != nil { + visitor = model.Visitor{ + UserID: &creatorUserID, + Company: mainVisitor.Company, + Name: mainVisitor.Name, + Phone: mainVisitor.Phone, + Gender: mainVisitor.Gender, + PhoneVerified: false, + } + if err := h.db.Create(&visitor).Error; err != nil { + response.InternalError(c, "创建访客信息失败") + return + } + } else { + updates := map[string]interface{}{ + "name": mainVisitor.Name, + "company": mainVisitor.Company, + "gender": mainVisitor.Gender, + } + if req.HasVehicle && len(req.LicensePlates) > 0 { + updates["license_plate"] = req.LicensePlates[0] + } + if visitor.UserID == nil { + updates["user_id"] = &creatorUserID + } + h.db.Model(&visitor).Updates(updates) + } + + // === 构建预约数据 === + appointment := model.Appointment{ + CreatorUserID: creatorUserID, + VisitStartTime: startTime, + VisitEndTime: endTime, + Status: 0, // 审核中 + Remark: req.Remark, + VisitPurpose: req.VisitPurpose, + QrCodeURL: "", // TODO: 生成二维码 + } + + // 1. 访客信息 (visitor_info):所有访客 + type visitorJSON struct { + Name string `json:"name"` + Phone string `json:"phone"` + Company string `json:"company"` + Gender int `json:"gender"` + Visitors []VisitorItem `json:"visitors,omitempty"` + } + visitorInfo := visitorJSON{ + Name: mainVisitor.Name, + Phone: mainVisitor.Phone, + Company: mainVisitor.Company, + Gender: mainVisitor.Gender, + Visitors: req.Visitors, + } + visitorInfoBytes, _ := json.Marshal(visitorInfo) + appointment.VisitorInfo = string(visitorInfoBytes) + + // 2. 创建者信息 (creator_info) + creatorInfo := map[string]interface{}{ + "id": creatorUser.ID, + "name": creatorUser.RealName, + "phone": creatorUser.Phone, + } + if creatorUser.RealName == "" { + creatorInfo["name"] = creatorUser.Nickname + } + creatorInfoBytes, _ := json.Marshal(creatorInfo) + appointment.CreatorInfo = string(creatorInfoBytes) + + // 3. 被访人信息 (employee_info) + employeeInfo := map[string]interface{}{ + "name": req.EmployeeName, + "phone": req.EmployeePhone, + } + employeeInfoBytes, _ := json.Marshal(employeeInfo) + appointment.EmployeeInfo = string(employeeInfoBytes) + + // 4. 携带物品 (goods_info) + if len(req.Goods) > 0 { + goodsInfoBytes, _ := json.Marshal(req.Goods) + appointment.GoodsInfo = string(goodsInfoBytes) + } else { + appointment.GoodsInfo = "[]" + } + + // 5. 到访区域 (areas_info) + if len(req.VisitAreas) > 0 { + areasInfoBytes, _ := json.Marshal(req.VisitAreas) + appointment.AreasInfo = string(areasInfoBytes) + } else { + appointment.AreasInfo = "[]" + } + + // 6. 车辆信息 (vehicle_info) + type vehicleItemJSON struct { + Plate string `json:"plate"` + } + var vehicleList []vehicleItemJSON + if req.HasVehicle && len(req.LicensePlates) > 0 { + for _, plate := range req.LicensePlates { + if plate != "" { + vehicleList = append(vehicleList, vehicleItemJSON{Plate: plate}) + } + } + } + if vehicleList == nil { + vehicleList = []vehicleItemJSON{} + } + vehicleInfoBytes, _ := json.Marshal(vehicleList) + appointment.VehicleInfo = string(vehicleInfoBytes) + + // 开启事务 + tx := h.db.Begin() + + // 创建预约 + if err := tx.Create(&appointment).Error; err != nil { + tx.Rollback() + response.InternalError(c, "创建预约失败") + return + } + + // === 根据员工匹配情况选择审批流程 === + wfEngine := workflow.NewEngine(h.db) + resolver := workflow.NewApproverResolver(h.db) + + // 按手机号匹配员工 + var matchedEmployee model.User + var empUD model.UserDepartment + var defaultApproverIDs []uint + + if err := h.db.Where("phone = ? AND role = ?", req.EmployeePhone, "employee").First(&matchedEmployee).Error; err == nil { + // 匹配到员工 → 设置 VisitUserID + tx.Model(&appointment).Update("visit_user_id", matchedEmployee.ID) + + // 查部门审批配置 + if err := tx.Where("user_id = ?", matchedEmployee.ID).First(&empUD).Error; err == nil { + deptApproverIDs, _ := resolver.ResolveDeptApproverIDs(empUD.DepartmentID) + vpIDs, _ := resolver.ResolveVPIDsByDepartment(empUD.DepartmentID) + + if len(deptApproverIDs) > 0 || len(vpIDs) > 0 { + // 有部门配置 → 走三级审批 + deptApproverIDsJSON, _ := json.Marshal(deptApproverIDs) + vpIDsJSON, _ := json.Marshal(vpIDs) + _, err = wfEngine.StartInstanceByCode("visitor_approval", "appointment", appointment.ID, creatorUserID, map[string]interface{}{ + "employee_id": matchedEmployee.ID, + "dept_approver_ids": string(deptApproverIDsJSON), + "vp_ids": string(vpIDsJSON), + "department_id": empUD.DepartmentID, + "department_name": empUD.DepartmentName, + }) + if err != nil { + tx.Rollback() + response.InternalError(c, "启动审批流程失败: "+err.Error()) + return + } + goto committed + } + } + // 匹配到员工但未配置部门审批人/VP → 走兜底 + defaultApproverIDs, _ = resolver.GetGlobalApproverIDs() + } else { + // 未匹配到员工 → 走兜底 + tx.Model(&appointment).Update("visit_user_id", 0) + defaultApproverIDs, _ = resolver.GetGlobalApproverIDs() + } + + // 启动兜底审批流程 + if len(defaultApproverIDs) > 0 { + defaultIDsJSON, _ := json.Marshal(defaultApproverIDs) + _, err := wfEngine.StartInstanceByCode("visitor_approval_simple", "appointment", appointment.ID, creatorUserID, map[string]interface{}{ + "default_approver_ids": string(defaultIDsJSON), + }) + if err != nil { + tx.Rollback() + response.InternalError(c, "启动审批流程失败: "+err.Error()) + return + } + } + // 兜底审批人也未配置 → 不启动审批,预约直接通过(或保持待审核) + +committed: + tx.Commit() + + // 异步发送钉钉消息给被访员工(如果匹配到员工且有钉钉ID) + if h.dingTalk != nil && matchedEmployee.ID > 0 { + var empDept model.UserDepartment + if err := h.db.Where("user_id = ?", matchedEmployee.ID).First(&empDept).Error; err != nil { + logrus.WithFields(logrus.Fields{ + "employee_id": matchedEmployee.ID, + "employee_name": matchedEmployee.RealName, + "appointment_id": appointment.ID, + }).Warn("匹配到员工但未找到 UserDepartment 记录,跳过钉钉通知") + } else if empDept.DingTalkUserID == "" { + logrus.WithFields(logrus.Fields{ + "employee_id": matchedEmployee.ID, + "employee_name": matchedEmployee.RealName, + "appointment_id": appointment.ID, + }).Warn("员工 UserDepartment 记录中 DingTalkUserID 为空,跳过钉钉通知") + } else { + dingTalkUserID := empDept.DingTalkUserID + logrus.WithFields(logrus.Fields{ + "employee_id": matchedEmployee.ID, + "employee_name": matchedEmployee.RealName, + "dingtalk_user_id": dingTalkUserID, + "appointment_id": appointment.ID, + }).Info("准备发送钉钉通知给被访员工") + + go func() { + // 从预约对象中解析访客信息 + var vi struct { + Name string `json:"name"` + Phone string `json:"phone"` + Company string `json:"company"` + Gender int `json:"gender"` + Visitors []struct { + Name string `json:"name"` + Phone string `json:"phone"` + Company string `json:"company"` + Gender int `json:"gender"` + IDType string `json:"id_type"` + IDNumber string `json:"id_number"` + } `json:"visitors"` + } + json.Unmarshal([]byte(appointment.VisitorInfo), &vi) + + // 拼接所有访客表格行(访客1、访客2、访客3……) + type visitorRow struct { + Name string + Company string + Phone string + IDCard string + } + var allVisitors []visitorRow + allVisitors = append(allVisitors, visitorRow{ + Name: vi.Name, + Company: vi.Company, + Phone: vi.Phone, + }) + for _, v := range vi.Visitors { + if v.Name != "" && (v.Name != vi.Name || v.Phone != vi.Phone) { + idCard := "" + if v.IDType != "" && v.IDNumber != "" { + idCard = v.IDType + " " + v.IDNumber + } + allVisitors = append(allVisitors, visitorRow{ + Name: v.Name, + Company: v.Company, + Phone: v.Phone, + IDCard: idCard, + }) + } + } + visitorList := "" + for i, v := range allVisitors { + idx := i + 1 + line := fmt.Sprintf("**访客%d:** %s", idx, v.Name) + if v.Phone != "" { + line += "(" + v.Phone + ")" + } + if v.Company != "" { + line += "
" + v.Company + } + if v.IDCard != "" { + line += "
" + v.IDCard + } + visitorList += line + "
" + } + + // 从预约对象中解析车辆信息 + type vehicleItem struct { + Plate string `json:"plate"` + } + var vehicles []vehicleItem + json.Unmarshal([]byte(appointment.VehicleInfo), &vehicles) + vehicleText := "" + for _, v := range vehicles { + if v.Plate != "" { + vehicleText += "- " + v.Plate + "
" + } + } + + msg := &dingtalk.AppointmentMessage{ + VisitorList: visitorList, + EmployeeName: req.EmployeeName, + VisitTime: dingtalk.FormatCSTTime(appointment.VisitStartTime, appointment.VisitEndTime), + VisitPurpose: req.VisitPurpose, + Remark: req.Remark, + VehicleInfo: vehicleText, + AppointmentID: appointment.ID, + } + title, markdown := dingtalk.BuildNewAppointmentMsg(msg) + logrus.WithFields(logrus.Fields{ + "dingtalk_user_id": dingTalkUserID, + "title": title, + "appointment_id": appointment.ID, + }).Info("钉钉机器人群发消息开始") + if err := h.dingTalk.BatchSendRobotMessage([]string{dingTalkUserID}, title, markdown); err != nil { + logrus.WithError(err).WithFields(logrus.Fields{ + "dingtalk_user_id": dingTalkUserID, + "appointment_id": appointment.ID, + }).Error("发送钉钉消息给被访员工失败") + } else { + logrus.WithFields(logrus.Fields{ + "dingtalk_user_id": dingTalkUserID, + "appointment_id": appointment.ID, + }).Info("发送钉钉消息给被访员工成功") + } + }() + } + } + + response.SuccessWithMessage(c, "预约创建成功", appointment) +} + +// GetMyAppointments 我的预约列表(包括我创建的 + 别人预约我的) +// @Summary 我的预约列表 +// @Description 获取当前用户的预约列表,包括我创建的和别人预约我的 +// @Tags 预约管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param status query integer false "预约状态筛选" +// @Success 200 {object} response.Response{data=[]model.Appointment} "预约列表" +// @Router /appointment/appointments/my [get] +func (h *AppointmentHandler) GetMyAppointments(c *gin.Context) { + userID := utils.GetUserID(c) + page, pageSize := utils.ParsePagination(c) + statusStr := c.DefaultQuery("status", "") + typeFilter := c.DefaultQuery("type", "") // created / visited / approvable / mine + + var appointments []model.Appointment + var total int64 + + query := h.db.Model(&model.Appointment{}) + if typeFilter == "created" { + query = query.Where("creator_user_id = ?", userID) + } else if typeFilter == "visited" { + query = query.Where("visit_user_id = ?", userID) + } else if typeFilter == "approvable" { + // 查询当前用户在工作流中有待审批任务的预约 + subQuery := h.db.Table("process_instances"). + Select("process_instances.business_id"). + Joins("JOIN tasks ON tasks.instance_id = process_instances.id"). + Where("process_instances.business_type = ? AND tasks.assignee_id = ? AND tasks.status = ?", "appointment", userID, 0) + query = query.Where("id IN (?)", subQuery) + } else if typeFilter == "mine" { + // 预约我的:我是被访人 OR 我在工作流中有审批任务 + subQuery := h.db.Table("process_instances"). + Select("process_instances.business_id"). + Joins("JOIN tasks ON tasks.instance_id = process_instances.id"). + Where("process_instances.business_type = ? AND tasks.assignee_id = ?", "appointment", userID) + query = query.Where("visit_user_id = ? OR id IN (?)", userID, subQuery) + } else { + query = query.Where("creator_user_id = ? OR visit_user_id = ?", userID, userID) + } + + if statusStr != "" { + status, _ := strconv.Atoi(statusStr) + query = query.Where("status = ?", status) + } + + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)).Limit(pageSize). + Order("created_at DESC").Find(&appointments) + + response.SuccessPage(c, appointments, total, page, pageSize) +} + +// GetAllAppointments 全部预约列表(员工专属) +// @Summary 全部预约列表 +// @Description 获取所有预约申请,按申请时间降序排列(仅员工可用) +// @Tags 预约管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param page query integer false "页码" +// @Param page_size query integer false "每页数量" +// @Param status query integer false "预约状态筛选" +// @Success 200 {object} response.Response{data=[]model.Appointment} "预约列表" +// @Router /appointment/appointments/all [get] +func (h *AppointmentHandler) GetAllAppointments(c *gin.Context) { + page, pageSize := utils.ParsePagination(c) + statusStr := c.DefaultQuery("status", "") + + var appointments []model.Appointment + var total int64 + + query := h.db.Model(&model.Appointment{}) + + if statusStr != "" { + status, _ := strconv.Atoi(statusStr) + query = query.Where("status = ?", status) + } + + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)).Limit(pageSize). + Order("created_at DESC").Find(&appointments) + + response.SuccessPage(c, appointments, total, page, pageSize) +} + +// GetAppointmentDetail 预约详情 +// @Summary 获取预约详情 +// @Description 根据ID获取预约详细信息 +// @Tags 预约管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path integer true "预约ID" +// @Success 200 {object} response.Response{data=model.Appointment} "预约详情" +// @Router /appointment/appointments/{id} [get] +func (h *AppointmentHandler) GetAppointmentDetail(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var appointment model.Appointment + if err := h.db.First(&appointment, id).Error; err != nil { + response.NotFound(c, "预约不存在") + return + } + + // 解析 JSON 字符串字段 + var visitors []map[string]interface{} + if err := json.Unmarshal([]byte(appointment.VisitorInfo), &visitors); err != nil { + // 兼容旧格式:包含顶层 name/phone + 嵌套 visitors 数组 + var old struct { + Name string `json:"name"` + Phone string `json:"phone"` + Company string `json:"company"` + Gender int `json:"gender"` + Visitors []map[string]interface{} `json:"visitors"` + } + if err2 := json.Unmarshal([]byte(appointment.VisitorInfo), &old); err2 == nil && len(old.Visitors) > 0 { + visitors = old.Visitors + } else { + visitors = []map[string]interface{}{} + } + } + + var creatorInfo map[string]interface{} + json.Unmarshal([]byte(appointment.CreatorInfo), &creatorInfo) + if creatorInfo == nil { + creatorInfo = map[string]interface{}{} + } + + var employeeInfo map[string]interface{} + json.Unmarshal([]byte(appointment.EmployeeInfo), &employeeInfo) + if employeeInfo == nil { + employeeInfo = map[string]interface{}{} + } + + var goods []map[string]interface{} + json.Unmarshal([]byte(appointment.GoodsInfo), &goods) + if goods == nil { + goods = []map[string]interface{}{} + } + + var areas []string + json.Unmarshal([]byte(appointment.AreasInfo), &areas) + if areas == nil { + areas = []string{} + } + + var vehicles []map[string]interface{} + if err := json.Unmarshal([]byte(appointment.VehicleInfo), &vehicles); err != nil { + // 兼容旧格式:{"license_plates": [...]} + var old map[string]interface{} + if err2 := json.Unmarshal([]byte(appointment.VehicleInfo), &old); err2 == nil { + if plates, ok := old["license_plates"]; ok { + if pArr, ok := plates.([]interface{}); ok { + for _, p := range pArr { + vehicles = append(vehicles, map[string]interface{}{ + "plate": p, + }) + } + } + } + } + } + if vehicles == nil { + vehicles = []map[string]interface{}{} + } + + // 保安角色查看时对手机号脱敏 + if utils.GetRoleCode(c) == "guard" { + maskPhone := func(phone string) string { + if len(phone) == 11 { + return phone[:3] + "****" + phone[7:] + } + if len(phone) > 3 { + return phone[:3] + "****" + phone[len(phone)-4:] + } + return phone + } + for i, v := range visitors { + if phone, ok := v["phone"].(string); ok { + visitors[i]["phone"] = maskPhone(phone) + } + } + if phone, ok := creatorInfo["phone"].(string); ok { + creatorInfo["phone"] = maskPhone(phone) + } + if phone, ok := employeeInfo["phone"].(string); ok { + employeeInfo["phone"] = maskPhone(phone) + } + } + + resp := map[string]interface{}{ + "id": appointment.ID, + "created_at": appointment.CreatedAt, + "updated_at": appointment.UpdatedAt, + "visit_user_id": appointment.VisitUserID, + "creator_user_id": appointment.CreatorUserID, + "visit_type": appointment.VisitType, + "visit_start_time": appointment.VisitStartTime, + "visit_end_time": appointment.VisitEndTime, + "status": appointment.Status, + "remark": appointment.Remark, + "comment": appointment.Comment, + "visit_purpose": appointment.VisitPurpose, + "visitor_company": appointment.VisitorCompany, + "qr_code_url": appointment.QrCodeURL, + "visitors": visitors, + "creator_info": creatorInfo, + "employee_info": employeeInfo, + "goods": goods, + "areas": areas, + "vehicles": vehicles, + } + response.Success(c, resp) +} + +// GetApprovalSteps 获取预约的审批步骤列表 +// ============ 邀请管理 ============ + +// CreateInvitation 生成邀请链接 +// @Summary 生成邀请链接 +// @Description 员工生成访客邀请链接 +// @Tags 邀请管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{visitor_name=string,visitor_phone=string,visit_type=string,visit_area=string,valid_days=int,max_use_count=int} true "邀请信息" +// @Success 200 {object} response.Response{data=model.Invitation} "创建成功" +// @Router /appointment/invitations [post] +func (h *AppointmentHandler) CreateInvitation(c *gin.Context) { + userID := utils.GetUserID(c) + + // 查找员工信息 + var employee model.User + if err := h.db.Where("user_id = ?", userID).First(&employee).Error; err != nil { + response.BadRequest(c, "您不是员工,无法生成邀请") + return + } + + var req struct { + VisitorName string `json:"visitor_name"` + VisitorPhone string `json:"visitor_phone"` + VisitType string `json:"visit_type"` + VisitArea string `json:"visit_area"` + ValidDays int `json:"valid_days"` + MaxUseCount int `json:"max_use_count"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if req.ValidDays <= 0 { + req.ValidDays = 7 + } + if req.MaxUseCount <= 0 { + req.MaxUseCount = 1 + } + + now := time.Now() + invitation := model.Invitation{ + EmployeeID: employee.ID, + InviteCode: utils.GenerateInviteCode(), + VisitorName: req.VisitorName, + VisitorPhone: req.VisitorPhone, + VisitType: req.VisitType, + VisitArea: req.VisitArea, + ValidFrom: now, + ValidUntil: now.AddDate(0, 0, req.ValidDays), + MaxUseCount: req.MaxUseCount, + UsedCount: 0, + IsActive: true, + } + + if err := h.db.Create(&invitation).Error; err != nil { + response.InternalError(c, "创建邀请失败") + return + } + + response.SuccessWithMessage(c, "邀请创建成功", invitation) +} + +// GetMyInvitations 我的邀请列表 +// @Summary 我的邀请列表 +// @Description 获取当前员工创建的邀请列表 +// @Tags 邀请管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]model.Invitation} "邀请列表" +// @Router /appointment/invitations/my [get] +func (h *AppointmentHandler) GetMyInvitations(c *gin.Context) { + userID := utils.GetUserID(c) + page, pageSize := utils.ParsePagination(c) + + var employee model.User + if err := h.db.Where("user_id = ?", userID).First(&employee).Error; err != nil { + response.BadRequest(c, "您不是员工") + return + } + + var invitations []model.Invitation + var total int64 + + query := h.db.Model(&model.Invitation{}).Where("employee_id = ?", employee.ID) + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)).Limit(pageSize). + Order("created_at DESC").Find(&invitations) + + response.SuccessPage(c, invitations, total, page, pageSize) +} + +// ============ 公开接口 ============ + +// GetPublicInvitation 公开邀请详情 +// @Summary 公开邀请详情 +// @Description 根据邀请码获取公开的邀请详情 +// @Tags 公开接口 +// @Accept json +// @Produce json +// @Param code path string true "邀请码" +// @Success 200 {object} response.Response "邀请详情" +// @Router /public/invitations/{code} [get] +func (h *AppointmentHandler) GetPublicInvitation(c *gin.Context) { + code := c.Param("code") + + var invitation model.Invitation + if err := h.db.Preload("Employee").Where("invite_code = ?", code).First(&invitation).Error; err != nil { + response.NotFound(c, "邀请不存在或已失效") + return + } + + if !invitation.IsActive { + response.BadRequest(c, "邀请已失效") + return + } + + if time.Now().After(invitation.ValidUntil) { + response.BadRequest(c, "邀请已过期") + return + } + + if invitation.UsedCount >= invitation.MaxUseCount { + response.BadRequest(c, "邀请使用次数已达上限") + return + } + + // InvitationVO 邀请详情视图对象 + type InvitationVO struct { + InviteCode string `json:"invite_code"` // 邀请码 + EmployeeID uint `json:"employee_id"` // 发起邀请的员工ID + EmployeeName string `json:"employee_name"` // 发起邀请的员工姓名 + EmployeePhone string `json:"employee_phone"` // 发起邀请的员工电话 + Department string `json:"department"` // 员工所属部门 + VisitorName string `json:"visitor_name"` // 被邀请访客姓名 + VisitorPhone string `json:"visitor_phone"` // 被邀请访客电话 + VisitType string `json:"visit_type"` // 来访目的 + VisitArea string `json:"visit_area"` // 允许访问的区域 + ValidUntil string `json:"valid_until"` // 邀请失效时间 + } + + // 查询邀请人部门信息 + deptName := "" + var empUD model.UserDepartment + if err := h.db.Where("user_id = ?", invitation.EmployeeID).First(&empUD).Error; err == nil { + deptName = empUD.DepartmentName + } + + vo := InvitationVO{ + InviteCode: invitation.InviteCode, + EmployeeID: invitation.EmployeeID, + EmployeeName: invitation.Employee.RealName, + EmployeePhone: invitation.Employee.Phone, + Department: deptName, + VisitorName: invitation.VisitorName, + VisitorPhone: invitation.VisitorPhone, + VisitType: invitation.VisitType, + VisitArea: invitation.VisitArea, + ValidUntil: invitation.ValidUntil.Format("2006-01-02 15:04:05"), + } + + response.Success(c, vo) +} + +// ============ 保安核验 ============ + +// GetTodayAppointments 今日预约列表 +// @Summary 今日预约列表 +// @Description 保安查看今日已审核通过的预约列表 +// @Tags 保安核验 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=object{stats=object{total=int,checked_in=int,checked_out=int},appointments=[]model.Appointment}} "预约列表" +// @Router /guard/today-appointments [get] +func (h *AppointmentHandler) GetTodayAppointments(c *gin.Context) { + today := time.Now().Format("2006-01-02") + startOfDay, _ := time.Parse("2006-01-02", today) + endOfDay := startOfDay.Add(24 * time.Hour) + + var total int64 + + query := h.db.Model(&model.Appointment{}). + Where("status = 1"). // 已通过 + Where("visit_start_time >= ? AND visit_start_time < ?", startOfDay, endOfDay) + + query.Count(&total) + + // 统计已进场/已出场数量 + var checkedIn, checkedOut int64 + h.db.Model(&model.VisitRecord{}). + Where("created_at >= ? AND created_at < ?", startOfDay, endOfDay). + Where("check_type = ?", 1). + Count(&checkedIn) + h.db.Model(&model.VisitRecord{}). + Where("created_at >= ? AND created_at < ?", startOfDay, endOfDay). + Where("check_type = ?", 2). + Count(&checkedOut) + + stats := map[string]interface{}{ + "total": total, + "checked_in": checkedIn, + "checked_out": checkedOut, + } + + response.Success(c, map[string]interface{}{ + "stats": stats, + }) +} + +// CheckIn 登记进场 +// @Summary 登记进场 +// @Description 保安登记访客进场 +// @Tags 保安核验 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{appointment_id=uint,remark=string} true "登记信息" +// @Success 200 {object} response.Response "登记进场成功" +// @Router /guard/check-in [post] +func (h *AppointmentHandler) CheckIn(c *gin.Context) { + h.checkRecord(c, 1) +} + +// CheckOut 登记出场 +// @Summary 登记出场 +// @Description 保安登记访客出场 +// @Tags 保安核验 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{appointment_id=uint,remark=string} true "登记信息" +// @Success 200 {object} response.Response "登记出场成功" +// @Router /guard/check-out [post] +func (h *AppointmentHandler) CheckOut(c *gin.Context) { + h.checkRecord(c, 2) +} + +// GetVisitRecords 获取预约的进出场记录 +// @Summary 获取进出场记录 +// @Description 按预约ID查询进出场记录列表 +// @Tags 保安核验 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param appointment_id query uint true "预约ID" +// @Success 200 {object} response.Response{data=[]model.VisitRecord} +// @Router /guard/records [get] +func (h *AppointmentHandler) GetVisitRecords(c *gin.Context) { + appointmentIDStr := c.Query("appointment_id") + appointmentID, err := strconv.ParseUint(appointmentIDStr, 10, 64) + if err != nil { + response.BadRequest(c, "appointment_id 参数错误") + return + } + + var records []model.VisitRecord + h.db.Where("appointment_id = ?", appointmentID). + Order("check_time ASC"). + Find(&records) + if records == nil { + records = []model.VisitRecord{} + } + response.Success(c, records) +} + +func (h *AppointmentHandler) checkRecord(c *gin.Context, checkType int) { + var req struct { + AppointmentID uint `json:"appointment_id" binding:"required"` + Remark string `json:"remark"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + var appointment model.Appointment + if err := h.db.First(&appointment, req.AppointmentID).Error; err != nil { + response.NotFound(c, "预约不存在") + return + } + + if appointment.Status != 1 { + response.BadRequest(c, "预约未通过审核") + return + } + + // 出场时校验是否已进场 + if checkType == 2 { + var checkInCount int64 + h.db.Model(&model.VisitRecord{}). + Where("appointment_id = ? AND check_type = 1 AND is_valid = true", req.AppointmentID). + Count(&checkInCount) + if checkInCount == 0 { + response.BadRequest(c, "该预约尚未进场,无法登记出场") + return + } + } + + guardID := utils.GetUserID(c) + record := model.VisitRecord{ + AppointmentID: req.AppointmentID, + GuardID: guardID, + CheckType: checkType, + CheckTime: time.Now(), + IsValid: true, + Remark: req.Remark, + } + + if err := h.db.Create(&record).Error; err != nil { + response.InternalError(c, "登记失败") + return + } + + action := "进场" + if checkType == 2 { + action = "出场" + } + + response.SuccessWithMessage(c, "登记"+action+"成功", record) +} + +// ============ 领导追溯 ============ + +// GetLeaderAppointments 领导查看预约(仅部门领导可查看) +// @Summary 领导查看预约 +// @Description 部门领导查看本部门员工的预约列表 +// @Tags 领导审批 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]model.Appointment} "预约列表" +// @Router /leader/appointments [get] +func (h *AppointmentHandler) GetLeaderAppointments(c *gin.Context) { + userID := utils.GetUserID(c) + page, pageSize := utils.ParsePagination(c) + + // 查找当前用户的员工信息,校验是否为领导 + var employee model.User + if err := h.db.Where("user_id = ?", userID).First(&employee).Error; err != nil { + response.BadRequest(c, "您不是员工") + return + } + var empUD model.UserDepartment + if err := h.db.Where("user_id = ?", employee.ID).First(&empUD).Error; err != nil { + response.BadRequest(c, "您未分配部门") + return + } + + // 只有领导可以查看部门预约 + isLeader := empUD.EmployeeType == "department_leader" || empUD.EmployeeType == "deputy_leader" || empUD.EmployeeType == "vice_president" || empUD.EmployeeType == "general_manager" + if !isLeader { + response.Forbidden(c, "仅部门领导可查看") + return + } + + // 查找同部门员工的预约 + 被指定为审批人的预约 + deptID := empUD.DepartmentID + + var employeeIDs []uint + h.db.Model(&model.UserDepartment{}). + Where("department_id = ?", deptID). + Pluck("user_id", &employeeIDs) + + var appointments []model.Appointment + var total int64 + + query := h.db.Model(&model.Appointment{}). + Where("visit_user_id IN ? OR id IN (SELECT pi.business_id FROM process_instances pi JOIN tasks t ON t.instance_id = pi.id WHERE pi.business_type = ? AND t.assignee_id = ?)", employeeIDs, "appointment", employee.ID) + + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)).Limit(pageSize). + Order("created_at DESC").Find(&appointments) + + response.SuccessPage(c, appointments, total, page, pageSize) +} + +// ============ 员工验证 ============ + +// VerifyEmployee 验证员工姓名+手机号 +// @Summary 验证员工姓名+手机号 +// @Description 根据姓名和手机号验证是否为正式员工 +// @Tags 员工管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{name=string,phone=string} true "姓名和手机号" +// @Success 200 {object} response.Response "员工信息" +// @Router /appointment/employees/verify [post] +func (h *AppointmentHandler) VerifyEmployee(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Phone string `json:"phone" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + var employee model.User + if err := h.db.Where("real_name = ? AND phone = ? AND role = 'employee' AND status = 1", req.Name, req.Phone).First(&employee).Error; err != nil { + response.NotFound(c, "未找到匹配的员工,请确认姓名和手机号是否正确") + return + } + + type EmployeeVO struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Name string `json:"name"` + Department string `json:"department"` + DepartmentID *uint `json:"department_id"` + Phone string `json:"phone"` + Position string `json:"position"` + } + + // 查询部门信息 + deptName := "" + var deptID *uint + var empUD model.UserDepartment + if err := h.db.Where("user_id = ?", employee.ID).First(&empUD).Error; err == nil { + deptName = empUD.DepartmentName + deptID = &empUD.DepartmentID + } + + response.Success(c, EmployeeVO{ + ID: employee.ID, + UserID: employee.ID, + Name: employee.RealName, + Department: deptName, + DepartmentID: deptID, + Phone: employee.Phone, + Position: empUD.Position, + }) +} + +// ============ 访客历史 ============ + +// GetMyVisitors 获取当前用户的历史访客列表(按手机号去重,取最新记录) +// @Summary 获取历史访客列表 +// @Description 获取当前用户的常用访客列表,按手机号去重 +// @Tags 访客管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response "访客列表" +// @Router /appointment/visitors/my [get] +func (h *AppointmentHandler) GetMyVisitors(c *gin.Context) { + userID := utils.GetUserID(c) + + // 查找当前用户所有预约的访客(作为访客创建时的 user_id 查找) + var visitors []model.Visitor + if err := h.db.Where("user_id = ?", userID). + Order("created_at DESC"). + Find(&visitors).Error; err != nil { + response.InternalError(c, "查询失败") + return + } + + // 按手机号去重 + seen := make(map[string]bool) + type VisitorVO struct { + ID uint `json:"id"` + Company string `json:"company"` + Name string `json:"name"` + Phone string `json:"phone"` + Gender int `json:"gender"` + IDType string `json:"id_type"` + IDNumber string `json:"id_number"` + } + var result []VisitorVO + for _, v := range visitors { + if seen[v.Phone] { + continue + } + seen[v.Phone] = true + result = append(result, VisitorVO{ + ID: v.ID, + Company: v.Company, + Name: v.Name, + Phone: v.Phone, + Gender: v.Gender, + IDType: v.IDType, + IDNumber: v.IDNumber, + }) + } + + response.Success(c, result) +} + +// SaveVisitorTemplate 保存常用访客 +// @Summary 保存常用访客 +// @Description 保存或更新常用访客信息 +// @Tags 访客管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{name=string,phone=string,company=string,gender=int,id_type=string,id_number=string} true "访客信息" +// @Success 200 {object} response.Response "保存成功" +// @Router /appointment/visitor-templates [post] +func (h *AppointmentHandler) SaveVisitorTemplate(c *gin.Context) { + userID := utils.GetUserID(c) + + var req struct { + Name string `json:"name" binding:"required"` + Phone string `json:"phone" binding:"required"` + Company string `json:"company"` + Gender int `json:"gender"` + IDType string `json:"id_type"` + IDNumber string `json:"id_number"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 先按手机号查找是否已存在 + var existing model.Visitor + if err := h.db.Where("phone = ? AND user_id = ?", req.Phone, userID).First(&existing).Error; err == nil { + // 更新已有记录 + h.db.Model(&existing).Updates(map[string]interface{}{ + "name": req.Name, + "company": req.Company, + "gender": req.Gender, + "id_type": req.IDType, + "id_number": req.IDNumber, + }) + response.SuccessWithMessage(c, "已更新", nil) + return + } + + visitor := model.Visitor{ + UserID: &userID, + Name: req.Name, + Phone: req.Phone, + Company: req.Company, + Gender: req.Gender, + IDType: req.IDType, + IDNumber: req.IDNumber, + } + if err := h.db.Create(&visitor).Error; err != nil { + response.InternalError(c, "保存失败") + return + } + response.SuccessWithMessage(c, "保存成功", nil) +} + +// DeleteVisitorTemplate 删除常用访客 +// @Summary 删除常用访客 +// @Description 根据ID删除常用访客记录 +// @Tags 访客管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path integer true "访客ID" +// @Success 200 {object} response.Response "删除成功" +// @Router /appointment/visitor-templates/{id} [delete] +func (h *AppointmentHandler) DeleteVisitorTemplate(c *gin.Context) { + userID := utils.GetUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + result := h.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.Visitor{}) + if result.RowsAffected == 0 { + response.NotFound(c, "记录不存在") + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ============ 物品模板管理 ============ + +// ListGoodsTemplates 获取当前用户的物品模板列表 +// @Summary 获取物品模板列表 +// @Description 获取当前用户的物品模板列表 +// @Tags 物品模板 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]model.GoodsTemplate} "物品模板列表" +// @Router /appointment/goods-templates [get] +func (h *AppointmentHandler) ListGoodsTemplates(c *gin.Context) { + userID := utils.GetUserID(c) + + var templates []model.GoodsTemplate + if err := h.db.Where("user_id = ?", userID). + Order("created_at DESC"). + Find(&templates).Error; err != nil { + response.InternalError(c, "查询失败") + return + } + + response.Success(c, templates) +} + +// CreateGoodsTemplate 创建物品模板 +// @Summary 创建物品模板 +// @Description 创建新的物品模板 +// @Tags 物品模板 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{name=string,quantity=int,model=string,remark=string} true "物品模板信息" +// @Success 200 {object} response.Response{data=model.GoodsTemplate} "创建成功" +// @Router /appointment/goods-templates [post] +func (h *AppointmentHandler) CreateGoodsTemplate(c *gin.Context) { + userID := utils.GetUserID(c) + + var req struct { + Name string `json:"name" binding:"required"` + Quantity int `json:"quantity"` + Model string `json:"model"` + Remark string `json:"remark"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if req.Quantity <= 0 { + req.Quantity = 1 + } + + template := model.GoodsTemplate{ + UserID: userID, + Name: req.Name, + Quantity: req.Quantity, + Model: req.Model, + Remark: req.Remark, + } + + if err := h.db.Create(&template).Error; err != nil { + response.InternalError(c, "创建失败") + return + } + + response.SuccessWithMessage(c, "创建成功", template) +} + +// UpdateGoodsTemplate 更新物品模板 +// @Summary 更新物品模板 +// @Description 根据ID更新物品模板信息 +// @Tags 物品模板 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path integer true "物品模板ID" +// @Param body body object{name=string,quantity=int,model=string,remark=string} true "物品模板信息" +// @Success 200 {object} response.Response "更新成功" +// @Router /appointment/goods-templates/{id} [put] +func (h *AppointmentHandler) UpdateGoodsTemplate(c *gin.Context) { + userID := utils.GetUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var template model.GoodsTemplate + if err := h.db.Where("id = ? AND user_id = ?", id, userID).First(&template).Error; err != nil { + response.NotFound(c, "物品模板不存在") + return + } + + var req struct { + Name string `json:"name"` + Quantity int `json:"quantity"` + Model string `json:"model"` + Remark string `json:"remark"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + updates := map[string]interface{}{} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Quantity > 0 { + updates["quantity"] = req.Quantity + } + updates["model"] = req.Model + updates["remark"] = req.Remark + + if err := h.db.Model(&template).Updates(updates).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + + response.SuccessWithMessage(c, "更新成功", nil) +} + +// DeleteGoodsTemplate 删除物品模板 +// @Summary 删除物品模板 +// @Description 根据ID删除物品模板 +// @Tags 物品模板 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path integer true "物品模板ID" +// @Success 200 {object} response.Response "删除成功" +// @Router /appointment/goods-templates/{id} [delete] +func (h *AppointmentHandler) DeleteGoodsTemplate(c *gin.Context) { + userID := utils.GetUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + result := h.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.GoodsTemplate{}) + if result.RowsAffected == 0 { + response.NotFound(c, "物品模板不存在") + return + } + + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ============ 车辆信息管理 ============ + +// ListVehicleInfos 获取车辆信息列表 +// @Summary 获取车辆信息列表 +// @Description 获取当前用户保存的车辆信息列表 +// @Tags 车辆信息 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response{data=[]model.VehicleInfo} "车辆列表" +// @Router /appointment/vehicles [get] +func (h *AppointmentHandler) ListVehicleInfos(c *gin.Context) { + userID := utils.GetUserID(c) + var vehicles []model.VehicleInfo + h.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&vehicles) + response.Success(c, vehicles) +} + +// CreateVehicleInfo 创建车辆信息 +// @Summary 创建车辆信息 +// @Description 保存新的车辆信息到当前用户 +// @Tags 车辆信息 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body object{license_plate=string,brand=string,color=string} true "车辆信息" +// @Success 200 {object} response.Response{data=model.VehicleInfo} "创建成功" +// @Router /appointment/vehicles [post] +func (h *AppointmentHandler) CreateVehicleInfo(c *gin.Context) { + userID := utils.GetUserID(c) + var req struct { + LicensePlate string `json:"license_plate" binding:"required"` + Brand string `json:"brand"` + Color string `json:"color"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + vehicle := model.VehicleInfo{ + UserID: userID, + LicensePlate: req.LicensePlate, + Brand: req.Brand, + Color: req.Color, + } + if err := h.db.Create(&vehicle).Error; err != nil { + response.InternalError(c, "保存失败") + return + } + response.SuccessWithMessage(c, "保存成功", vehicle) +} + +// DeleteVehicleInfo 删除车辆信息 +// @Summary 删除车辆信息 +// @Description 根据ID删除保存的车辆信息 +// @Tags 车辆信息 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path integer true "车辆信息ID" +// @Success 200 {object} response.Response "删除成功" +// @Router /appointment/vehicles/{id} [delete] +func (h *AppointmentHandler) DeleteVehicleInfo(c *gin.Context) { + userID := utils.GetUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + result := h.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.VehicleInfo{}) + if result.RowsAffected == 0 { + response.NotFound(c, "车辆信息不存在") + return + } + response.SuccessWithMessage(c, "删除成功", nil) +} + +// UpdateVehicleInfo 更新车辆信息 +// @Summary 更新车辆信息 +// @Description 更新已保存的车辆信息 +// @Tags 车辆信息 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path integer true "车辆信息ID" +// @Param body body object{license_plate=string,brand=string,color=string} true "车辆信息" +// @Success 200 {object} response.Response "更新成功" +// @Router /appointment/vehicles/{id} [put] +func (h *AppointmentHandler) UpdateVehicleInfo(c *gin.Context) { + userID := utils.GetUserID(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + var req struct { + LicensePlate string `json:"license_plate" binding:"required"` + Brand string `json:"brand"` + Color string `json:"color"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + result := h.db.Model(&model.VehicleInfo{}).Where("id = ? AND user_id = ?", id, userID).Updates(map[string]interface{}{ + "license_plate": req.LicensePlate, + "brand": req.Brand, + "color": req.Color, + }) + if result.RowsAffected == 0 { + response.NotFound(c, "车辆信息不存在") + return + } + response.SuccessWithMessage(c, "更新成功", nil) +} + +// ============ 通用数据查询 ============ + +// ListVisitTypes 获取全部启用的来访目的(公开接口) +func (h *AppointmentHandler) ListVisitTypes(c *gin.Context) { + var types []model.VisitType + h.db.Where("status = 1").Order("sort ASC").Find(&types) + if types == nil { + types = []model.VisitType{} + } + response.Success(c, types) +} + +// ListVisitorAreas 获取全部启用的到访区域(公开接口) +func (h *AppointmentHandler) ListVisitorAreas(c *gin.Context) { + var areas []model.VisitorArea + h.db.Where("status = 1").Order("sort ASC").Find(&areas) + if areas == nil { + areas = []model.VisitorArea{} + } + response.Success(c, areas) +} + +// formatVisitTimeRange 格式化访问时间段:06月20日08:00 ~ 23:59 +func formatVisitTimeRange(a *model.Appointment) string { + if a.VisitStartTime.IsZero() { + return "" + } + return a.VisitStartTime.Format("01-02 15:04") + "-" + a.VisitEndTime.Format("15:04") +} + +// formatVisitTimeShort 格式化简短时间段:01-02 15:04 ~ 15:04 +func formatVisitTimeShort(a *model.Appointment) string { + if a.VisitStartTime.IsZero() { + return "" + } + start := a.VisitStartTime.Format("01-02 15:04") + endTime := a.VisitEndTime.Format("15:04") + return fmt.Sprintf("%s ~ %s", start, endTime) +} + +// RegisterRoutes 注册预约路由 +func (h *AppointmentHandler) RegisterRoutes(r *gin.RouterGroup) { + appt := r.Group("/appointment") + { + // 员工搜索 & 验证 + appt.POST("/employees/search", h.SearchEmployees) + appt.POST("/employees/verify", h.VerifyEmployee) + + // 访客历史 + appt.GET("/visitors/my", h.GetMyVisitors) + appt.POST("/visitor-templates", h.SaveVisitorTemplate) + appt.DELETE("/visitor-templates/:id", h.DeleteVisitorTemplate) + + // 物品模板管理 + appt.GET("/goods-templates", h.ListGoodsTemplates) + appt.POST("/goods-templates", h.CreateGoodsTemplate) + appt.PUT("/goods-templates/:id", h.UpdateGoodsTemplate) + appt.DELETE("/goods-templates/:id", h.DeleteGoodsTemplate) + + // 车辆信息管理 + appt.GET("/vehicles", h.ListVehicleInfos) + appt.POST("/vehicles", h.CreateVehicleInfo) + appt.DELETE("/vehicles/:id", h.DeleteVehicleInfo) + appt.PUT("/vehicles/:id", h.UpdateVehicleInfo) + + // 预约管理 + appt.POST("/appointments", h.CreateAppointment) + appt.GET("/appointments/my", h.GetMyAppointments) + appt.GET("/appointments/all", h.GetAllAppointments) + appt.GET("/appointments/:id", h.GetAppointmentDetail) + + // 邀请管理 + appt.POST("/invitations", h.CreateInvitation) + appt.GET("/invitations/my", h.GetMyInvitations) + } + + // 公开接口(无需认证) + public := r.Group("/public") + { + public.GET("/invitations/:code", h.GetPublicInvitation) + public.GET("/visit-types", h.ListVisitTypes) + public.GET("/visitor-areas", h.ListVisitorAreas) + } + + // 保安接口 + guard := r.Group("/guard") + { + guard.GET("/today-appointments", h.GetTodayAppointments) + guard.POST("/check-in", h.CheckIn) + guard.POST("/check-out", h.CheckOut) + guard.GET("/records", h.GetVisitRecords) + } + + // 领导接口 + leader := r.Group("/leader") + { + leader.GET("/appointments", h.GetLeaderAppointments) + } +} diff --git a/sc-lktx-backend/internal/modules/banner/handler.go b/sc-lktx-backend/internal/modules/banner/handler.go new file mode 100644 index 0000000..2fcb7da --- /dev/null +++ b/sc-lktx-backend/internal/modules/banner/handler.go @@ -0,0 +1,107 @@ +// Banner 模块:首页轮播图接口 +package banner + +import ( + "com.sclktx/m/v2/internal/common/model" + "com.sclktx/m/v2/internal/common/response" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// BannerHandler Banner 处理器 +type BannerHandler struct { + db *gorm.DB +} + +// NewBannerHandler 创建 Banner 处理器 +func NewBannerHandler(db *gorm.DB) *BannerHandler { + return &BannerHandler{db: db} +} + +// BannerVO Banner 视图对象(只返回前端需要的字段) +type BannerVO struct { + ImageURL string `json:"image_url"` // 图片地址 + JumpPath string `json:"jump_path"` // 跳转链接(空字符串表示不可跳转) + IsJump bool `json:"is_jump"` // 是否可跳转 +} + +// @Summary 获取首页轮播图列表 +// @Description 获取首页轮播图列表(公开接口) +// @Tags 公共 +// @Accept json +// @Produce json +// @Success 200 {object} response.Response{data=[]banner.BannerVO} "success" +// @Router /api/v1/banners [get] +// GetBanners 获取首页轮播图列表(公开接口) +func (h *BannerHandler) GetBanners(c *gin.Context) { + var banners []model.Banner + if err := h.db.Where("is_valid = ?", true). + Order("sort ASC, id DESC"). + Find(&banners).Error; err != nil { + response.InternalError(c, "查询失败") + return + } + + result := make([]BannerVO, 0, len(banners)) + for _, b := range banners { + result = append(result, BannerVO{ + ImageURL: b.ImageURL, + JumpPath: b.JumpPath, + IsJump: b.IsJump, + }) + } + + // 如果没有数据,返回空数组而非 null + if result == nil { + result = []BannerVO{ + { + ImageURL: "//mmbiz.qpic.cn/mmbiz_jpg/XekA4rfc3lcLOadSAicKvm3ZXFgn9TtZUOh5tSgaJeQrqs6JhWKfszMHBQhXaicPpgVM09U3dnHBfoEriaM9fKPXYCDvsB2fiaib4TAjXMGV2Ql0/0?from=appmsg", + JumpPath: "", + IsJump: false, + }, + } + } + + response.Success(c, result) +} + +// NoticeVO 通知视图对象 +type NoticeVO struct { + ID uint `json:"id"` + Title string `json:"title"` + Content string `json:"content"` +} + +// @Summary 获取有效通知列表 +// @Description 获取有效通知列表(公开接口) +// @Tags 公共 +// @Accept json +// @Produce json +// @Success 200 {object} response.Response{data=[]banner.NoticeVO} "success" +// @Router /api/v1/notices [get] +// GetNotices 获取有效通知列表(公开接口) +func (h *BannerHandler) GetNotices(c *gin.Context) { + var notices []model.Notice + h.db.Where("is_valid = ?", true).Order("sort ASC, id DESC").Find(¬ices) + + result := make([]NoticeVO, 0, len(notices)) + for _, n := range notices { + result = append(result, NoticeVO{ + ID: n.ID, + Title: n.Title, + Content: n.Content, + }) + } + if result == nil { + result = []NoticeVO{} + } + + response.Success(c, result) +} + +// RegisterRoutes 注册 Banner 路由(公开接口,无需认证) +func (h *BannerHandler) RegisterRoutes(r *gin.RouterGroup) { + r.GET("/banners", h.GetBanners) + r.GET("/notices", h.GetNotices) +} diff --git a/sc-lktx-backend/internal/modules/certification/handler.go b/sc-lktx-backend/internal/modules/certification/handler.go new file mode 100644 index 0000000..59aa6df --- /dev/null +++ b/sc-lktx-backend/internal/modules/certification/handler.go @@ -0,0 +1,236 @@ +// 员工认证模块:提交认证申请、查询申请状态、撤回申请 +package certification + +import ( + "time" + + "com.sclktx/m/v2/internal/common/model" + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/common/utils" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// Handler 角色认证处理器 +type Handler struct { + db *gorm.DB +} + +// NewHandler 创建角色认证处理器 +func NewHandler(db *gorm.DB) *Handler { + return &Handler{db: db} +} + +// SubmitCertificationReq 提交认证申请请求 +type SubmitCertificationReq struct { + RealName string `json:"real_name" binding:"required"` + Phone string `json:"phone" binding:"required"` + Role string `json:"role" binding:"required"` + Snapshot []string `json:"snapshot" binding:"required"` + DepartmentID *uint `json:"department_id"` + Position string `json:"position"` +} + +// SubmitCertification 提交角色认证申请 +// @Summary 提交角色认证申请 +// @Description 提交员工角色认证申请,需上传钉钉截图 +// @Tags 员工认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body SubmitCertificationReq true "认证申请请求" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/certification/submit [post] +func (h *Handler) SubmitCertification(c *gin.Context) { + var req SubmitCertificationReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误: "+err.Error()) + return + } + + userID := utils.GetUserID(c) + + // 检查是否已有待审核或已通过的申请 + var existing model.EmployeeCertification + if err := h.db.Where("user_id = ? AND status IN (0, 1)", userID).First(&existing).Error; err == nil { + if existing.Status == 1 { + response.BadRequest(c, "您已通过认证,无需重复申请") + return + } + response.BadRequest(c, "您已提交申请,请等待审核") + return + } + + cert := model.EmployeeCertification{ + UserID: userID, + RealName: req.RealName, + Phone: req.Phone, + Role: req.Role, + Snapshot: req.Snapshot, + Position: req.Position, + DepartmentID: req.DepartmentID, + Status: 0, + } + + if err := h.db.Create(&cert).Error; err != nil { + response.InternalError(c, "提交失败: "+err.Error()) + return + } + + response.SuccessWithMessage(c, "提交成功,请等待管理员审核", cert) +} + +// GetMyCertification 获取我的认证申请 +// @Summary 获取我的认证申请 +// @Description 获取当前用户的角色认证申请状态 +// @Tags 员工认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response "success" +// @Router /api/v1/certification/my [get] +func (h *Handler) GetMyCertification(c *gin.Context) { + userID := utils.GetUserID(c) + + var cert model.EmployeeCertification + if err := h.db.Preload("Department").Preload("Approver").Where("user_id = ?", userID).Order("created_at DESC").First(&cert).Error; err != nil { + response.Success(c, nil) + return + } + + response.Success(c, cert) +} + +// WithdrawCertification 撤回认证申请 +// @Summary 撤回认证申请 +// @Description 撤回已提交但尚未处理的认证申请 +// @Tags 员工认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path uint true "认证申请ID" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/certification/{id}/withdraw [put] +func (h *Handler) WithdrawCertification(c *gin.Context) { + userID := utils.GetUserID(c) + certID := c.Param("id") + + var cert model.EmployeeCertification + if err := h.db.First(&cert, certID).Error; err != nil { + response.NotFound(c, "申请不存在") + return + } + + if cert.UserID != userID { + response.Forbidden(c, "无权操作") + return + } + + if cert.Status != 0 { + response.BadRequest(c, "该申请已处理,无法撤回") + return + } + + if err := h.db.Delete(&cert).Error; err != nil { + response.InternalError(c, "撤回失败") + return + } + + response.SuccessWithMessage(c, "已撤回", nil) +} + +// AutoVerifyReq 自动认证请求 +type AutoVerifyReq struct { + RealName string `json:"real_name" binding:"required"` + Phone string `json:"phone" binding:"required"` +} + +// AutoVerify 自动认证:姓名+手机号 → 匹配 pending_employees → 直接认证为员工 +// @Summary 自动认证为员工 +// @Description 输入姓名和手机号,系统自动匹配通讯录导入数据,匹配成功直接认证为员工 +// @Tags 员工认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body AutoVerifyReq true "认证请求" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/certification/auto-verify [post] +func (h *Handler) AutoVerify(c *gin.Context) { + var req AutoVerifyReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请输入姓名和手机号") + return + } + + userID := utils.GetUserID(c) + + // 查当前用户角色 + var user model.User + if err := h.db.First(&user, userID).Error; err != nil { + response.InternalError(c, "用户不存在") + return + } + if user.Role == "employee" { + response.BadRequest(c, "您已经是认证员工,无需重复申请") + return + } + + // 查找 pending_employees + var pending model.PendingEmployee + if err := h.db.Where("phone = ?", req.Phone).First(&pending).Error; err != nil { + response.BadRequest(c, "未找到您的认证信息,请联系管理员导入通讯录") + return + } + + // 检查是否已被其他用户匹配 + if pending.IsMatched { + if pending.MatchedUserID != nil && *pending.MatchedUserID == userID { + // 已匹配当前用户但角色尚未升级 → 修复 + if user.Role != "employee" { + goto doMatch + } + response.BadRequest(c, "您已认证成为员工,如有问题请联系管理员") + } else { + response.BadRequest(c, "该手机号已被其他用户认证,如有问题请联系管理员") + } + return + } + +doMatch: + // 执行认证 + now := time.Now() + ud := model.UserDepartment{ + UserID: userID, + DepartmentID: pending.DepartmentID, + DepartmentName: pending.DepartmentName, + Position: pending.Position, + EmployeeType: pending.EmployeeType, + EmployeeStatus: 1, + } + h.db.Where("user_id = ?", userID).Assign(ud).FirstOrCreate(&model.UserDepartment{UserID: userID}) + h.db.Model(&user).Update("role", "employee") + h.db.Model(&pending).Updates(map[string]interface{}{ + "is_matched": true, + "matched_user_id": &userID, + "matched_at": &now, + }) + + // 如果用户还没有真实姓名,同步填入 + if user.RealName == "" { + h.db.Model(&user).Update("real_name", req.RealName) + } + + response.SuccessWithMessage(c, "认证成功", gin.H{ + "department_name": pending.DepartmentName, + "position": pending.Position, + }) +} + +// RegisterRoutes 注册认证路由(需要 JWT 认证) +func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { + r.POST("/certification/submit", h.SubmitCertification) + r.GET("/certification/my", h.GetMyCertification) + r.PUT("/certification/:id/withdraw", h.WithdrawCertification) + r.POST("/certification/auto-verify", h.AutoVerify) +} diff --git a/sc-lktx-backend/internal/modules/upload/handler.go b/sc-lktx-backend/internal/modules/upload/handler.go new file mode 100644 index 0000000..d25bb37 --- /dev/null +++ b/sc-lktx-backend/internal/modules/upload/handler.go @@ -0,0 +1,266 @@ +// Upload 模块:图片上传(支持上传到微信服务器和 MinIO,公开接口,无需权限) +package upload + +import ( + "context" + "io" + "net/http" + "path/filepath" + "strings" + "time" + + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/pkg/minio" + "com.sclktx/m/v2/internal/pkg/wechat" + + "github.com/gin-gonic/gin" +) + +// UploadHandler 上传处理器 +type UploadHandler struct { + wxClient *wechat.MiniProgramClient + minioClient *minio.Client +} + +// NewUploadHandler 创建上传处理器 +func NewUploadHandler(wxClient *wechat.MiniProgramClient, minioClient *minio.Client) *UploadHandler { + return &UploadHandler{ + wxClient: wxClient, + minioClient: minioClient, + } +} + +// UploadImgResponse 上传图片返回 +type UploadImgResponse struct { + URL string `json:"url"` // 微信返回的图片 URL +} + +// @Summary 上传图片到微信服务器 +// @Description 上传图片文件到微信服务器(公开接口,无需权限) +// @Tags 文件上传 +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "图片文件" +// @Success 200 {object} response.Response{data=upload.UploadImgResponse} "success" +// @Router /api/v1/upload/image [post] +// UploadImage 上传图片文件到微信服务器(公开接口,无需权限) +// POST /api/v1/upload/image +func (h *UploadHandler) UploadImage(c *gin.Context) { + // 获取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + response.BadRequest(c, "请选择要上传的图片文件") + return + } + defer file.Close() + + // 读取文件内容 + imageData, err := io.ReadAll(file) + if err != nil { + response.InternalError(c, "读取文件失败") + return + } + + // 获取文件名,如果没有则生成一个 + filename := header.Filename + if filename == "" { + filename = "image_" + time.Now().Format("20060102150405") + ".jpg" + } + + // 确保文件名有扩展名 + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + // 根据 Content-Type 猜测扩展名 + contentType := header.Header.Get("Content-Type") + switch contentType { + case "image/png": + filename += ".png" + case "image/gif": + filename += ".gif" + case "image/webp": + filename += ".webp" + default: + filename += ".jpg" + } + } + + // 上传到微信服务器 + result, err := h.wxClient.UploadImg(imageData, filename) + if err != nil { + response.InternalError(c, "上传到微信失败: "+err.Error()) + return + } + + response.Success(c, &UploadImgResponse{URL: result.URL}) +} + +// UploadImageByURLRequest 通过链接上传请求 +type UploadImageByURLRequest struct { + URL string `json:"url" binding:"required"` // 图片链接 +} + +// @Summary 通过链接上传图片到微信服务器 +// @Description 通过图片链接下载后上传到微信服务器(公开接口,无需权限) +// @Tags 文件上传 +// @Accept json +// @Produce json +// @Param body body upload.UploadImageByURLRequest true "图片链接信息" +// @Success 200 {object} response.Response{data=upload.UploadImgResponse} "success" +// @Router /api/v1/upload/image-by-url [post] +// UploadImageByURL 通过图片链接下载后上传到微信服务器(公开接口,无需权限) +// POST /api/v1/upload/image-by-url +func (h *UploadHandler) UploadImageByURL(c *gin.Context) { + var req UploadImageByURLRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请提供图片链接(url)") + return + } + + // 下载图片(使用自定义请求,添加 User-Agent 避免被反爬拦截) + httpReq, err := http.NewRequest("GET", req.URL, nil) + if err != nil { + response.InternalError(c, "创建请求失败: "+err.Error()) + return + } + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + response.InternalError(c, "下载图片失败: "+err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + response.InternalError(c, "下载图片失败,HTTP状态码: "+http.StatusText(resp.StatusCode)) + return + } + + imageData, err := io.ReadAll(resp.Body) + if err != nil { + response.InternalError(c, "读取图片数据失败") + return + } + + // 从 URL 中提取文件名 + filename := extractFilenameFromURL(req.URL) + contentType := resp.Header.Get("Content-Type") + ext := getExtByContentType(contentType) + if !strings.HasSuffix(strings.ToLower(filename), ext) && ext != "" { + filename += ext + } + + // 上传到微信服务器 + result, err := h.wxClient.UploadImg(imageData, filename) + if err != nil { + response.InternalError(c, "上传到微信失败: "+err.Error()) + return + } + + response.Success(c, &UploadImgResponse{URL: result.URL}) +} + +// extractFilenameFromURL 从 URL 中提取文件名 +func extractFilenameFromURL(rawURL string) string { + // 去掉查询参数 + if idx := strings.Index(rawURL, "?"); idx != -1 { + rawURL = rawURL[:idx] + } + // 取最后一段作为文件名 + base := filepath.Base(rawURL) + if base == "." || base == "/" || base == "" { + return "image_" + time.Now().Format("20060102150405") + ".jpg" + } + return base +} + +// getExtByContentType 根据 Content-Type 获取文件扩展名 +func getExtByContentType(contentType string) string { + switch { + case strings.Contains(contentType, "image/png"): + return ".png" + case strings.Contains(contentType, "image/gif"): + return ".gif" + case strings.Contains(contentType, "image/webp"): + return ".webp" + case strings.Contains(contentType, "image/svg"): + return ".svg" + case strings.Contains(contentType, "image/jpeg"): + return ".jpg" + default: + return ".jpg" + } +} + +// UploadToMinioResponse 上传到 MinIO 返回 +type UploadToMinioResponse struct { + URL string `json:"url"` // MinIO 中的图片 URL +} + +// @Summary 上传图片到MinIO对象存储 +// @Description 上传图片到 MinIO 对象存储(公开接口,无需权限) +// @Tags 文件上传 +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "图片文件" +// @Success 200 {object} response.Response{data=upload.UploadToMinioResponse} "success" +// @Router /api/v1/upload/minio [post] +// UploadImageToMinio 上传图片到 MinIO 对象存储(公开接口,无需权限) +// POST /api/v1/upload/minio +func (h *UploadHandler) UploadImageToMinio(c *gin.Context) { + // 获取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + response.BadRequest(c, "请选择要上传的图片文件") + return + } + defer file.Close() + + // 读取文件内容 + imageData, err := io.ReadAll(file) + if err != nil { + response.InternalError(c, "读取文件失败") + return + } + + // 获取文件名,如果没有则生成一个 + filename := header.Filename + if filename == "" { + filename = "image_" + time.Now().Format("20060102150405") + ".jpg" + } + + // 确保文件名有扩展名 + ext := strings.ToLower(filepath.Ext(filename)) + if ext == "" { + // 根据 Content-Type 猜测扩展名 + contentType := header.Header.Get("Content-Type") + switch contentType { + case "image/png": + filename += ".png" + case "image/gif": + filename += ".gif" + case "image/webp": + filename += ".webp" + default: + filename += ".jpg" + } + } + + // 上传到 MinIO + ctx := context.Background() + url, err := h.minioClient.UploadImage(ctx, imageData, filename) + if err != nil { + response.InternalError(c, "上传到 MinIO 失败:"+err.Error()) + return + } + + response.Success(c, &UploadToMinioResponse{URL: url}) +} + +// RegisterRoutes 注册上传路由(公开接口,无需认证和权限) +func (h *UploadHandler) RegisterRoutes(r *gin.RouterGroup) { + r.POST("/upload/image", h.UploadImage) + r.POST("/upload/image-by-url", h.UploadImageByURL) + r.POST("/upload/minio", h.UploadImageToMinio) +} diff --git a/sc-lktx-backend/internal/modules/workflow/approver_resolver.go b/sc-lktx-backend/internal/modules/workflow/approver_resolver.go new file mode 100644 index 0000000..eaa4984 --- /dev/null +++ b/sc-lktx-backend/internal/modules/workflow/approver_resolver.go @@ -0,0 +1,59 @@ +package workflow + +import ( + "encoding/json" + "fmt" + + "com.sclktx/m/v2/internal/common/model" + "gorm.io/gorm" +) + +// ApproverResolver 审批人动态解析器 +type ApproverResolver struct { + db *gorm.DB +} + +func NewApproverResolver(db *gorm.DB) *ApproverResolver { + return &ApproverResolver{db: db} +} + +// parseJSONIDs 解析 JSON 数组字符串为 uint 切片 +func parseJSONIDs(jsonStr string) []uint { + if jsonStr == "" { + return nil + } + var ids []uint + if err := json.Unmarshal([]byte(jsonStr), &ids); err != nil { + return nil + } + return ids +} + +// ResolveDeptApproverIDs 取部门指定审批人(approver_user_ids),空则返回空切片 +func (r *ApproverResolver) ResolveDeptApproverIDs(deptID uint) ([]uint, error) { + var dept model.Department + if err := r.db.First(&dept, deptID).Error; err != nil { + return nil, fmt.Errorf("部门不存在: %w", err) + } + return parseJSONIDs(dept.ApproverUserIDs), nil +} + +// ResolveVPIDsByDepartment 取部门分管领导(vp_user_ids),空则返回空切片 +func (r *ApproverResolver) ResolveVPIDsByDepartment(deptID uint) ([]uint, error) { + var dept model.Department + if err := r.db.First(&dept, deptID).Error; err != nil { + return nil, fmt.Errorf("部门不存在: %w", err) + } + return parseJSONIDs(dept.VpUserIDs), nil +} + +// GetGlobalApproverIDs 获取所有全局兜底审批人 +func (r *ApproverResolver) GetGlobalApproverIDs() ([]uint, error) { + var approvers []model.GlobalApprover + r.db.Find(&approvers) + var ids []uint + for _, a := range approvers { + ids = append(ids, a.UserID) + } + return ids, nil +} diff --git a/sc-lktx-backend/internal/modules/workflow/engine.go b/sc-lktx-backend/internal/modules/workflow/engine.go new file mode 100644 index 0000000..ff9404e --- /dev/null +++ b/sc-lktx-backend/internal/modules/workflow/engine.go @@ -0,0 +1,556 @@ +// 工作流引擎核心逻辑 +package workflow + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "com.sclktx/m/v2/internal/common/model" + + "gorm.io/gorm" +) + +// Engine 工作流引擎 +type Engine struct { + db *gorm.DB +} + +// NewEngine 创建工作流引擎 +func NewEngine(db *gorm.DB) *Engine { + return &Engine{db: db} +} + +// Node 节点配置(从 nodes_json 解析) +type Node struct { + NodeID string `json:"node_id"` + NodeName string `json:"node_name"` + NodeType int `json:"node_type"` // 0-开始 1-审批 2-网关 3-结束 + PrevNodeIDs []string `json:"prev_node_ids"` + UserIDs []string `json:"user_ids"` + Roles []string `json:"roles"` + GwConfig *GatewayConfig `json:"gw_config"` + IsCosigned int `json:"is_cosigned"` // 0-否 1-会签 + NodeStartEvents []string `json:"node_start_events"` + NodeEndEvents []string `json:"node_end_events"` + TaskFinishEvents []string `json:"task_finish_events"` +} + +// GatewayConfig 网关配置 +type GatewayConfig struct { + Conditions []Condition `json:"conditions"` + InevitableNodes []string `json:"inevitable_nodes"` + WaitForAllPrevNode int `json:"wait_for_all_prev_node"` // 0-否 1-是 +} + +// Condition 条件分支 +type Condition struct { + Expression string `json:"expression"` + NodeID string `json:"node_id"` +} + +// parseNodes 解析流程定义的节点配置 +func (e *Engine) parseNodes(def *model.ProcessDefinition) ([]Node, error) { + if def.NodesJSON == "" { + return nil, errors.New("流程节点配置为空") + } + var nodes []Node + if err := json.Unmarshal([]byte(def.NodesJSON), &nodes); err != nil { + return nil, fmt.Errorf("解析节点配置失败: %w", err) + } + return nodes, nil +} + +// findStartNode 查找开始节点 +func findStartNode(nodes []Node) *Node { + for i := range nodes { + if nodes[i].NodeType == 0 { + return &nodes[i] + } + } + return nil +} + +// findNextNodes 查找下一个节点 +func findNextNodes(nodes []Node, currentNodeID string) []Node { + var next []Node + for _, n := range nodes { + for _, prevID := range n.PrevNodeIDs { + if prevID == currentNodeID { + next = append(next, n) + break + } + } + } + return next +} + +// findNodeByID 根据节点 ID 查找节点 +func findNodeByID(nodes []Node, nodeID string) *Node { + for i := range nodes { + if nodes[i].NodeID == nodeID { + return &nodes[i] + } + } + return nil +} + +// parseVariables 解析流程实例中的变量 JSON +func (e *Engine) parseVariables(variablesJSON string) map[string]interface{} { + var vars map[string]interface{} + if variablesJSON != "" { + json.Unmarshal([]byte(variablesJSON), &vars) + } + if vars == nil { + vars = make(map[string]interface{}) + } + return vars +} + + +// StartInstanceByCode 启动流程实例(按流程编码) +func (e *Engine) StartInstanceByCode(processCode string, businessType string, businessID uint, starterID uint, variables map[string]interface{}) (*model.ProcessInstance, error) { + var def model.ProcessDefinition + if err := e.db.Where("process_code = ?", processCode).First(&def).Error; err != nil { + return nil, fmt.Errorf("流程定义不存在: %s", processCode) + } + return e.StartInstance(def.ID, businessType, businessID, starterID, variables) +} +func (e *Engine) StartInstance(defID uint, businessType string, businessID uint, starterID uint, variables map[string]interface{}) (*model.ProcessInstance, error) { + // 查询流程定义 + var def model.ProcessDefinition + if err := e.db.First(&def, defID).Error; err != nil { + return nil, errors.New("流程定义不存在") + } + if !def.IsActive { + return nil, errors.New("流程定义已禁用") + } + + // 解析节点配置 + nodes, err := e.parseNodes(&def) + if err != nil { + return nil, err + } + + // 查找开始节点 + startNode := findStartNode(nodes) + if startNode == nil { + return nil, errors.New("流程定义缺少开始节点") + } + + // 序列化变量 + variablesJSON := "{}" + if variables != nil { + b, _ := json.Marshal(variables) + variablesJSON = string(b) + } + + // 创建流程实例 + instance := &model.ProcessInstance{ + ProcessDefID: def.ID, + BusinessType: businessType, + BusinessID: businessID, + Status: 0, // 运行中 + StarterID: starterID, + CurrentNodeID: startNode.NodeID, + Variables: variablesJSON, + } + + if err := e.db.Create(instance).Error; err != nil { + return nil, fmt.Errorf("创建流程实例失败: %w", err) + } + + // 流转到下一个节点 + if err := e.moveToNextNodes(instance, nodes, startNode.NodeID); err != nil { + return nil, err + } + + return instance, nil +} + +// moveToNextNodes 流转到下一个节点,创建任务(使用默认 DB) +func (e *Engine) moveToNextNodes(instance *model.ProcessInstance, nodes []Node, currentNodeID string) error { + return e.moveToNextNodesTx(e.db, instance, nodes, currentNodeID) +} + +// moveToNextNodesTx 流转到下一个节点(支持外部事务) +func (e *Engine) moveToNextNodesTx(tx *gorm.DB, instance *model.ProcessInstance, nodes []Node, currentNodeID string) error { + nextNodes := findNextNodes(nodes, currentNodeID) + + // 如果没有下一个节点(到达结束节点),标记流程完成 + if len(nextNodes) == 0 { + now := time.Now() + if err := tx.Model(instance).Updates(map[string]interface{}{ + "status": 1, // 已完成 + "finished_at": now, + }).Error; err != nil { + return err + } + return e.updateAppointmentStatus(tx, instance, 1) + } + + // 为每个下一个节点创建任务 + for _, node := range nextNodes { + switch node.NodeType { + case 1: // 审批节点 + if err := e.createApprovalTasksTx(tx, instance, nodes, &node); err != nil { + return err + } + case 3: // 结束节点 + now := time.Now() + if err := tx.Model(instance).Updates(map[string]interface{}{ + "status": 1, + "finished_at": now, + }).Error; err != nil { + return err + } + return e.updateAppointmentStatus(tx, instance, 1) + case 2: // 网关节点 - 自动判断条件 + if err := e.handleGatewayNodeTx(tx, instance, nodes, &node); err != nil { + return err + } + } + } + + // 更新当前节点 + if len(nextNodes) > 0 { + tx.Model(instance).Update("current_node_id", nextNodes[0].NodeID) + } + + return nil +} + +// handleGatewayNodeTx 处理网关节点(支持外部事务) +func (e *Engine) handleGatewayNodeTx(tx *gorm.DB, instance *model.ProcessInstance, nodes []Node, gwNode *Node) error { + if gwNode.GwConfig == nil { + return e.moveToNextNodesTx(tx, instance, nodes, gwNode.NodeID) + } + + // 解析流程变量 + var variables map[string]interface{} + if instance.Variables != "" { + json.Unmarshal([]byte(instance.Variables), &variables) + } + if variables == nil { + variables = make(map[string]interface{}) + } + + // 评估条件 + for _, cond := range gwNode.GwConfig.Conditions { + if evaluateCondition(cond.Expression, variables) { + return e.moveToNextNodesTx(tx, instance, nodes, cond.NodeID) + } + } + + // 没有条件满足,走必经节点 + if len(gwNode.GwConfig.InevitableNodes) > 0 { + return e.moveToNextNodesTx(tx, instance, nodes, gwNode.GwConfig.InevitableNodes[0]) + } + + return e.moveToNextNodesTx(tx, instance, nodes, gwNode.NodeID) +} + +// createApprovalTasks 为审批节点创建任务(使用默认 DB) +func (e *Engine) createApprovalTasks(instance *model.ProcessInstance, nodes []Node, node *Node) error { + return e.createApprovalTasksTx(e.db, instance, nodes, node) +} + +// createApprovalTasksTx 为审批节点创建任务(支持外部事务) +func (e *Engine) createApprovalTasksTx(tx *gorm.DB, instance *model.ProcessInstance, nodes []Node, node *Node) error { + // 解析审批人:优先使用 user_ids,其次使用 roles + var assigneeIDs []uint + variables := e.parseVariables(instance.Variables) + + if len(node.UserIDs) > 0 { + for _, uidStr := range node.UserIDs { + if strings.HasPrefix(uidStr, "$") { + // $变量名 → 从流程变量中取值 + varName := uidStr[1:] + if val, ok := variables[varName]; ok { + switch v := val.(type) { + case float64: + assigneeIDs = append(assigneeIDs, uint(v)) + case uint: + assigneeIDs = append(assigneeIDs, v) + case int: + assigneeIDs = append(assigneeIDs, uint(v)) + case string: + // 先尝试解析为单个 ID + if id, err := strconv.ParseUint(v, 10, 64); err == nil { + assigneeIDs = append(assigneeIDs, uint(id)) + } else { + // 再尝试解析为 JSON 数组(如 "[1,2,3]") + var ids []uint + if err := json.Unmarshal([]byte(v), &ids); err == nil { + assigneeIDs = append(assigneeIDs, ids...) + } + } + } + } + } else { + // 静态数字 ID + var uid uint + if _, err := fmt.Sscanf(uidStr, "%d", &uid); err == nil { + assigneeIDs = append(assigneeIDs, uid) + } + } + } + } + + if len(assigneeIDs) == 0 && len(node.Roles) > 0 { + // 根据角色编码查找用户 + var users []model.User + tx.Where("role IN ?", node.Roles).Find(&users) + for _, u := range users { + assigneeIDs = append(assigneeIDs, u.ID) + } + } + + if len(assigneeIDs) == 0 { + // 没有找到审批人 — 跳过此节点,自动流转到下一节点(适用于未配置分管领导等情况) + return e.moveToNextNodesTx(tx, instance, nodes, node.NodeID) + } + + // 为每个审批人创建任务 + for _, assigneeID := range assigneeIDs { + task := &model.Task{ + InstanceID: instance.ID, + NodeID: node.NodeID, + NodeName: node.NodeName, + Status: 0, // 待处理 + AssigneeID: &assigneeID, + } + if err := tx.Create(task).Error; err != nil { + return fmt.Errorf("创建任务失败: %w", err) + } + } + + return nil +} + +// handleGatewayNode 处理网关节点 +func (e *Engine) handleGatewayNode(instance *model.ProcessInstance, nodes []Node, gwNode *Node) error { + if gwNode.GwConfig == nil { + return e.moveToNextNodes(instance, nodes, gwNode.NodeID) + } + + // 解析流程变量 + var variables map[string]interface{} + if instance.Variables != "" { + json.Unmarshal([]byte(instance.Variables), &variables) + } + if variables == nil { + variables = make(map[string]interface{}) + } + + // 评估条件 + for _, cond := range gwNode.GwConfig.Conditions { + if evaluateCondition(cond.Expression, variables) { + return e.moveToNextNodes(instance, nodes, cond.NodeID) + } + } + + // 没有条件满足,走必经节点 + if len(gwNode.GwConfig.InevitableNodes) > 0 { + return e.moveToNextNodes(instance, nodes, gwNode.GwConfig.InevitableNodes[0]) + } + + return e.moveToNextNodes(instance, nodes, gwNode.NodeID) +} + +// evaluateCondition 简单条件评估(支持 $days>=3 这样的表达式) +func evaluateCondition(expression string, variables map[string]interface{}) bool { + // 简单实现:解析 "$key op value" 格式 + var key string + var op string + var expected float64 + n, _ := fmt.Sscanf(expression, "$%s %s %f", &key, &op, &expected) + if n != 3 { + return false + } + + val, ok := variables[key] + if !ok { + return false + } + + var numVal float64 + switch v := val.(type) { + case float64: + numVal = v + case int: + numVal = float64(v) + case json.Number: + numVal, _ = v.Float64() + default: + return false + } + + switch op { + case ">=": + return numVal >= expected + case ">": + return numVal > expected + case "<=": + return numVal <= expected + case "<": + return numVal < expected + case "==": + return numVal == expected + case "!=": + return numVal != expected + } + return false +} + +// ApproveTask 审批通过任务 +func (e *Engine) ApproveTask(taskID uint, userID uint, comment string) error { + var task model.Task + if err := e.db.First(&task, taskID).Error; err != nil { + return errors.New("任务不存在") + } + if task.Status != 0 { + return errors.New("任务已处理") + } + // 验证审批人身份 + if task.AssigneeID == nil || *task.AssigneeID != userID { + return errors.New("您不是该任务的审批人") + } + + now := time.Now() + + // 开启事务,保证一致性 + tx := e.db.Begin() + + if err := tx.Model(&task).Updates(map[string]interface{}{ + "status": 1, // 已完成 + "comment": comment, + "handled_at": now, + }).Error; err != nil { + tx.Rollback() + return fmt.Errorf("更新任务失败: %w", err) + } + + // 获取实例和流程定义 + var instance model.ProcessInstance + if err := tx.First(&instance, task.InstanceID).Error; err != nil { + tx.Rollback() + return err + } + + var def model.ProcessDefinition + if err := tx.First(&def, instance.ProcessDefID).Error; err != nil { + tx.Rollback() + return err + } + + nodes, err := e.parseNodes(&def) + if err != nil { + tx.Rollback() + return err + } + + // 查找当前节点的定义 + currentNode := findNodeByID(nodes, task.NodeID) + needAllApprovers := currentNode != nil && currentNode.IsCosigned == 1 + + if needAllApprovers { + // 会签模式(is_cosigned=1):需要所有任务都完成 + var pendingCount int64 + tx.Model(&model.Task{}). + Where("instance_id = ? AND node_id = ? AND status = 0", task.InstanceID, task.NodeID). + Count(&pendingCount) + if pendingCount > 0 { + tx.Commit() + return nil // 等待其他审批人 + } + } + // 非会签模式(is_cosigned=0 或不设置):一人通过即流转下一节点 + // 取消本节点其他待处理任务,防止并发重复创建下一节点 + if !needAllApprovers { + tx.Model(&model.Task{}). + Where("instance_id = ? AND node_id = ? AND status = 0 AND id != ?", task.InstanceID, task.NodeID, task.ID). + Update("status", 3) // 已取消 + } + + // 流转到下一个节点(在同一事务中) + if err := e.moveToNextNodesTx(tx, &instance, nodes, task.NodeID); err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +// RejectTask 审批拒绝任务 +func (e *Engine) RejectTask(taskID uint, userID uint, comment string) error { + var task model.Task + if err := e.db.First(&task, taskID).Error; err != nil { + return errors.New("任务不存在") + } + if task.Status != 0 { + return errors.New("任务已处理") + } + // 验证审批人身份 + if task.AssigneeID == nil || *task.AssigneeID != userID { + return errors.New("您不是该任务的审批人") + } + + now := time.Now() + tx := e.db.Begin() + + if err := tx.Model(&task).Updates(map[string]interface{}{ + "status": 2, // 已拒绝 + "comment": comment, + "handled_at": now, + }).Error; err != nil { + tx.Rollback() + return fmt.Errorf("更新任务失败: %w", err) + } + + // 终止流程实例 + var instance model.ProcessInstance + if err := tx.First(&instance, task.InstanceID).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Model(&instance).Updates(map[string]interface{}{ + "status": 2, // 已终止 + "finished_at": now, + }).Error; err != nil { + tx.Rollback() + return err + } + + // 更新业务状态 + if err := e.updateAppointmentStatus(tx, &instance, 2); err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +// checkNodeCompletion 检查节点所有任务是否完成 +func (e *Engine) checkNodeCompletion(instanceID uint, nodeID string) (*Node, bool, error) { + // 检查是否还有待处理的任务 + var pendingCount int64 + e.db.Model(&model.Task{}). + Where("instance_id = ? AND node_id = ? AND status = 0", instanceID, nodeID). + Count(&pendingCount) + + return nil, pendingCount == 0, nil +} + +// updateAppointmentStatus 工作流完成后更新业务表状态 +func (e *Engine) updateAppointmentStatus(tx *gorm.DB, instance *model.ProcessInstance, apptStatus int) error { + if instance.BusinessType != "appointment" { + return nil // 只处理预约业务 + } + return tx.Model(&model.Appointment{}).Where("id = ?", instance.BusinessID).Updates(map[string]interface{}{ + "status": apptStatus, + }).Error +} diff --git a/sc-lktx-backend/internal/modules/workflow/handler.go b/sc-lktx-backend/internal/modules/workflow/handler.go new file mode 100644 index 0000000..55f8d5c --- /dev/null +++ b/sc-lktx-backend/internal/modules/workflow/handler.go @@ -0,0 +1,787 @@ +// 工作流模块 HTTP Handler +package workflow + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "com.sclktx/m/v2/internal/common/model" + "com.sclktx/m/v2/internal/common/response" + "com.sclktx/m/v2/internal/common/utils" + "com.sclktx/m/v2/internal/pkg/dingtalk" + "com.sclktx/m/v2/internal/pkg/wechat/templates" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// Handler 工作流处理器 +type Handler struct { + db *gorm.DB + engine *Engine + wxSend func(openid, templateID string, data map[string]string, page string) error + dingTalk *dingtalk.Client +} + +// NewHandler 创建工作流处理器 +func NewHandler(db *gorm.DB, wxSend func(openid, templateID string, data map[string]string, page string) error, dingTalk *dingtalk.Client) *Handler { + return &Handler{ + db: db, + engine: NewEngine(db), + wxSend: wxSend, + dingTalk: dingTalk, + } +} + +// ==================== 请求/响应结构 ==================== + +// CreateDefinitionReq 创建流程定义请求 +type CreateDefinitionReq struct { + ProcessName string `json:"process_name" binding:"required"` + ProcessCode string `json:"process_code" binding:"required"` + Source string `json:"source"` + Description string `json:"description"` + RevokeEvents string `json:"revoke_events"` + NodesJSON string `json:"nodes_json"` +} + +// UpdateDefinitionReq 更新流程定义请求 +type UpdateDefinitionReq struct { + ProcessName string `json:"process_name"` + Source string `json:"source"` + Description string `json:"description"` + IsActive *bool `json:"is_active"` + RevokeEvents string `json:"revoke_events"` + NodesJSON string `json:"nodes_json"` +} + +// StartInstanceReq 启动流程实例请求 +type StartInstanceReq struct { + ProcessCode string `json:"process_code" binding:"required"` + BusinessType string `json:"business_type" binding:"required"` + BusinessID uint `json:"business_id" binding:"required"` + Variables map[string]interface{} `json:"variables"` +} + +// TaskActionReq 任务操作请求 +type TaskActionReq struct { + Comment string `json:"comment"` +} + +// ==================== 路由注册 ==================== + +// RegisterRoutes 注册工作流路由(需要 JWT 认证) +func (h *Handler) RegisterRoutes(router *gin.RouterGroup) { + // 流程定义(管理员操作) + router.GET("/workflow/definitions", h.ListDefinitions) + router.GET("/workflow/definitions/:id", h.GetDefinition) + router.POST("/workflow/definitions", h.CreateDefinition) + router.PUT("/workflow/definitions/:id", h.UpdateDefinition) + router.DELETE("/workflow/definitions/:id", h.DeleteDefinition) + + // 流程实例 + router.POST("/workflow/instances", h.StartInstance) + router.GET("/workflow/instances", h.ListInstances) + router.GET("/workflow/instances/:id", h.GetInstanceDetail) + + // 任务 + router.GET("/workflow/tasks/pending", h.GetPendingTasks) + router.POST("/workflow/tasks/:id/approve", h.ApproveTask) + router.POST("/workflow/tasks/:id/reject", h.RejectTask) +} + +// ==================== 流程定义管理 ==================== + +// ListDefinitions 获取流程定义列表 +// @Summary 获取流程定义列表 +// @Description 获取所有流程定义列表 +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页条数" default(10) +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/definitions [get] +func (h *Handler) ListDefinitions(c *gin.Context) { + page, pageSize := utils.ParsePagination(c) + + var defs []model.ProcessDefinition + var total int64 + + query := h.db.Model(&model.ProcessDefinition{}) + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)). + Limit(pageSize). + Order("created_at DESC"). + Find(&defs) + + response.SuccessPage(c, defs, total, page, pageSize) +} + +// GetDefinition 获取流程定义详情 +// @Summary 获取流程定义详情 +// @Description 根据ID获取流程定义详情 +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path uint true "流程定义ID" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/definitions/{id} [get] +func (h *Handler) GetDefinition(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var def model.ProcessDefinition + if err := h.db.First(&def, id).Error; err != nil { + response.NotFound(c, "流程定义不存在") + return + } + + response.Success(c, def) +} + +// CreateDefinition 创建流程定义 +// @Summary 创建流程定义 +// @Description 创建一个新的流程定义(管理员操作) +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body CreateDefinitionReq true "创建流程定义请求" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/definitions [post] +func (h *Handler) CreateDefinition(c *gin.Context) { + var req CreateDefinitionReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 检查编码唯一性 + var existCount int64 + h.db.Model(&model.ProcessDefinition{}).Where("process_code = ?", req.ProcessCode).Count(&existCount) + if existCount > 0 { + response.BadRequest(c, "流程编码已存在") + return + } + + def := model.ProcessDefinition{ + ProcessName: req.ProcessName, + ProcessCode: req.ProcessCode, + Source: req.Source, + Description: req.Description, + IsActive: true, + RevokeEvents: req.RevokeEvents, + NodesJSON: req.NodesJSON, + } + + if err := h.db.Create(&def).Error; err != nil { + response.InternalError(c, "创建失败") + return + } + + response.SuccessWithMessage(c, "创建成功", def) +} + +// UpdateDefinition 更新流程定义 +// @Summary 更新流程定义 +// @Description 更新指定流程定义的信息(管理员操作) +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path uint true "流程定义ID" +// @Param body body UpdateDefinitionReq true "更新流程定义请求" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/definitions/{id} [put] +func (h *Handler) UpdateDefinition(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var def model.ProcessDefinition + if err := h.db.First(&def, id).Error; err != nil { + response.NotFound(c, "流程定义不存在") + return + } + + var req UpdateDefinitionReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + updates := map[string]interface{}{} + if req.ProcessName != "" { + updates["process_name"] = req.ProcessName + } + if req.Source != "" { + updates["source"] = req.Source + } + if req.Description != "" { + updates["description"] = req.Description + } + if req.IsActive != nil { + updates["is_active"] = *req.IsActive + } + if req.RevokeEvents != "" { + updates["revoke_events"] = req.RevokeEvents + } + if req.NodesJSON != "" { + updates["nodes_json"] = req.NodesJSON + } + + if len(updates) > 0 { + if err := h.db.Model(&def).Updates(updates).Error; err != nil { + response.InternalError(c, "更新失败") + return + } + } + + response.SuccessWithMessage(c, "更新成功", nil) +} + +// DeleteDefinition 删除流程定义 +// @Summary 删除流程定义 +// @Description 删除指定的流程定义(管理员操作,存在运行中的实例时无法删除) +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path uint true "流程定义ID" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/definitions/{id} [delete] +func (h *Handler) DeleteDefinition(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 检查是否有运行中的实例 + var activeCount int64 + h.db.Model(&model.ProcessInstance{}).Where("process_def_id = ? AND status = 0", id).Count(&activeCount) + if activeCount > 0 { + response.BadRequest(c, "存在运行中的流程实例,无法删除") + return + } + + if err := h.db.Delete(&model.ProcessDefinition{}, id).Error; err != nil { + response.InternalError(c, "删除失败") + return + } + + response.SuccessWithMessage(c, "删除成功", nil) +} + +// ==================== 流程实例管理 ==================== + +// StartInstance 启动流程实例 +// @Summary 启动流程实例 +// @Description 根据流程定义启动一个新的流程实例 +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body StartInstanceReq true "启动流程实例请求" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/instances [post] +func (h *Handler) StartInstance(c *gin.Context) { + var req StartInstanceReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 查找流程定义 + var def model.ProcessDefinition + if err := h.db.Where("process_code = ?", req.ProcessCode).First(&def).Error; err != nil { + response.NotFound(c, "流程定义不存在") + return + } + + userID := utils.GetUserID(c) + + instance, err := h.engine.StartInstance(def.ID, req.BusinessType, req.BusinessID, userID, req.Variables) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + response.SuccessWithMessage(c, "启动成功", instance) +} + +// ListInstances 获取流程实例列表 +// @Summary 获取流程实例列表 +// @Description 获取所有流程实例列表,可按业务类型和状态筛选 +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param business_type query string false "业务类型" +// @Param status query int false "实例状态" +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页条数" default(10) +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/instances [get] +func (h *Handler) ListInstances(c *gin.Context) { + page, pageSize := utils.ParsePagination(c) + businessType := c.Query("business_type") + businessIDStr := c.Query("business_id") + statusStr := c.Query("status") + + var instances []model.ProcessInstance + var total int64 + + query := h.db.Model(&model.ProcessInstance{}). + Preload("ProcessDef"). + Preload("Starter") + if businessType != "" { + query = query.Where("business_type = ?", businessType) + } + if businessIDStr != "" { + businessID, _ := strconv.ParseUint(businessIDStr, 10, 64) + if businessID > 0 { + query = query.Where("business_id = ?", businessID) + } + } + if statusStr != "" { + status, _ := strconv.Atoi(statusStr) + query = query.Where("status = ?", status) + } + + query.Count(&total) + query.Offset(utils.GetOffset(page, pageSize)). + Limit(pageSize). + Order("created_at DESC"). + Find(&instances) + + response.SuccessPage(c, instances, total, page, pageSize) +} + +// GetInstanceDetail 获取流程实例详情(含任务列表) +// @Summary 获取流程实例详情 +// @Description 获取流程实例详情,包含关联的任务列表 +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path uint true "流程实例ID" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/instances/{id} [get] +func (h *Handler) GetInstanceDetail(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var instance model.ProcessInstance + if err := h.db.Preload("ProcessDef").Preload("Starter").First(&instance, id).Error; err != nil { + response.NotFound(c, "流程实例不存在") + return + } + + var tasks []model.Task + h.db.Where("instance_id = ?", id). + Preload("Assignee"). + Order("created_at ASC"). + Find(&tasks) + + response.Success(c, gin.H{ + "instance": instance, + "tasks": tasks, + }) +} + +// ==================== 任务管理 ==================== + +// GetPendingTasks 获取当前用户的待办任务 +// @Summary 获取待办任务 +// @Description 获取当前用户的待处理流程任务列表 +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/tasks/pending [get] +func (h *Handler) GetPendingTasks(c *gin.Context) { + userID := utils.GetUserID(c) + + var tasks []model.Task + h.db.Where("assignee_id = ? AND status = 0", userID). + Preload("Instance"). + Preload("Instance.ProcessDef"). + Preload("Assignee"). + Order("created_at DESC"). + Find(&tasks) + + response.Success(c, tasks) +} + +// ApproveTask 审批通过任务 +// @Summary 审批通过任务 +// @Description 审批通过指定的流程任务 +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path uint true "任务ID" +// @Param body body TaskActionReq true "审批请求" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/tasks/{id}/approve [post] +func (h *Handler) ApproveTask(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var req TaskActionReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := utils.GetUserID(c) + + if err := h.engine.ApproveTask(uint(id), userID, req.Comment); err != nil { + response.InternalError(c, err.Error()) + return + } + + // 审批完成后发送通知 + h.sendAppointmentNotification(uint(id)) + h.sendDingTalkAfterApproval(uint(id), req.Comment) + + response.SuccessWithMessage(c, "审批通过", nil) +} + +// RejectTask 审批拒绝任务 +// @Summary 审批拒绝任务 +// @Description 拒绝指定的流程任务 +// @Tags 工作流 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path uint true "任务ID" +// @Param body body TaskActionReq true "拒绝请求" +// @Success 200 {object} response.Response "success" +// @Router /api/v1/workflow/tasks/{id}/reject [post] +func (h *Handler) RejectTask(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "参数错误") + return + } + + var req TaskActionReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := utils.GetUserID(c) + + if err := h.engine.RejectTask(uint(id), userID, req.Comment); err != nil { + response.InternalError(c, err.Error()) + return + } + + // 拒绝后发送通知 + h.sendAppointmentNotification(uint(id)) + h.sendDingTalkAfterRejection(uint(id), req.Comment) + + response.SuccessWithMessage(c, "已拒绝", nil) +} + +// sendAppointmentNotification 审批结束后发送微信通知给申请人 +func (h *Handler) sendAppointmentNotification(taskID uint) { + var task model.Task + if err := h.db.Preload("Instance").First(&task, taskID).Error; err != nil { + return + } + if task.Instance == nil || task.Instance.BusinessType != "appointment" { + return + } + + go func() { + var appointment model.Appointment + if err := h.db.First(&appointment, task.Instance.BusinessID).Error; err != nil { + return + } + // 只发送已通过或已拒绝的通知 + if appointment.Status != 1 && appointment.Status != 2 { + return + } + + var creatorUser model.User + if err := h.db.First(&creatorUser, appointment.CreatorUserID).Error; err != nil || creatorUser.Openid == "" { + return + } + + resultText := "已通过" + if appointment.Status == 2 { + resultText = "已拒绝" + } + + // 解析 JSON 字段 + var employeeInfo map[string]interface{} + json.Unmarshal([]byte(appointment.EmployeeInfo), &employeeInfo) + var visitorInfo map[string]interface{} + json.Unmarshal([]byte(appointment.VisitorInfo), &visitorInfo) + company, _ := visitorInfo["company"].(string) + employeeName, _ := employeeInfo["name"].(string) + + var areasInfo []string + json.Unmarshal([]byte(appointment.AreasInfo), &areasInfo) + areaText := strings.Join(areasInfo, "、") + + // 格式化时间(微信 thing 类型字段限 20 字) + timeStr := "" + if !appointment.VisitStartTime.IsZero() { + loc, _ := time.LoadLocation("Asia/Shanghai") + timeStr = appointment.VisitStartTime.In(loc).Format("01-02 15:04") + "-" + appointment.VisitEndTime.In(loc).Format("15:04") + } + + // TMPL_RESULT: 访客申请结果通知(thing 字段限 20 字) + trunc := func(s string) string { + runes := []rune(s) + if len(runes) > 20 { + return string(runes[:20]) + } + return s + } + tmplID := templates.TMPL_RESULT + if h.wxSend != nil { + h.wxSend(creatorUser.Openid, tmplID, map[string]string{ + "thing1": trunc(resultText), + "thing2": trunc(company), + "thing3": trunc(timeStr), + "thing8": trunc(employeeName), + "thing9": trunc(areaText), + }, fmt.Sprintf("/subpackages/appointment/appointment-detail?id=%d", appointment.ID)) + } + }() +} + +// sendDingTalkAfterApproval 审批通过后发送钉钉消息 +func (h *Handler) sendDingTalkAfterApproval(taskID uint, comment string) { + if h.dingTalk == nil { + return + } + + var task model.Task + if err := h.db.Preload("Instance").First(&task, taskID).Error; err != nil { + return + } + if task.Instance == nil || task.Instance.BusinessType != "appointment" || task.Instance.Status != 0 { + return + } + + logrus.WithFields(logrus.Fields{ + "task_id": taskID, + "instance_id": task.InstanceID, + }).Info("钉钉:审批通过,开始发送通知") + + go func() { + // 获取审批人信息 + var approverUser model.User + if task.AssigneeID == nil { + return + } + if err := h.db.First(&approverUser, *task.AssigneeID).Error; err != nil { + logrus.WithError(err).WithField("task_id", taskID).Warn("钉钉:查询审批人信息失败") + return + } + + // 获取预约信息 + var appointment model.Appointment + if err := h.db.First(&appointment, task.Instance.BusinessID).Error; err != nil { + return + } + + var visitorInfo struct { + Name string `json:"name"` + Company string `json:"company"` + } + json.Unmarshal([]byte(appointment.VisitorInfo), &visitorInfo) + visitTime := "" + if !appointment.VisitStartTime.IsZero() { + visitTime = dingtalk.FormatCSTTime(appointment.VisitStartTime, appointment.VisitEndTime) + } + + // 1. 通知被取消的同节点审批人(同一节点非会签模式,其他人审批后当前节点其他人被取消) + var cancelledTasks []model.Task + if err := h.db.Where("instance_id = ? AND node_id = ? AND status = 3 AND id != ?", + task.InstanceID, task.NodeID, task.ID).Find(&cancelledTasks).Error; err == nil && len(cancelledTasks) > 0 { + var userIDs []string + for _, ct := range cancelledTasks { + if ct.AssigneeID != nil { + var ud model.UserDepartment + if err := h.db.Where("user_id = ?", *ct.AssigneeID).First(&ud).Error; err == nil && ud.DingTalkUserID != "" { + userIDs = append(userIDs, ud.DingTalkUserID) + } else if err != nil { + logrus.WithError(err).WithField("user_id", *ct.AssigneeID).Warn("钉钉:查询同节点取消人 UserDepartment 失败") + } else { + logrus.WithField("user_id", *ct.AssigneeID).Warn("钉钉:同节点取消人 DingTalkUserID 为空") + } + } + } + if len(userIDs) > 0 { + title, markdown := dingtalk.BuildSameNodeCancelledMsg(approverUser.RealName, comment, visitTime) + logrus.WithFields(logrus.Fields{ + "user_ids": userIDs, + "title": title, + }).Info("钉钉:发送同节点取消通知") + if err := h.dingTalk.BatchSendRobotMessage(userIDs, title, markdown); err != nil { + logrus.WithError(err).Error("钉钉发送同节点取消通知失败") + } + } else { + logrus.WithField("cancelled_count", len(cancelledTasks)).Info("钉钉:同节点已取消任务无有效钉钉ID") + } + } else { + logrus.WithField("has_cancelled", len(cancelledTasks) > 0).Info("钉钉:无同节点取消任务") + } + + // 2. 通知下一个节点审批人 + var pendingTasks []model.Task + if err := h.db.Where("instance_id = ? AND status = 0", task.InstanceID).Find(&pendingTasks).Error; err == nil && len(pendingTasks) > 0 { + var userIDs []string + for _, pt := range pendingTasks { + if pt.AssigneeID != nil { + var ud model.UserDepartment + if err := h.db.Where("user_id = ?", *pt.AssigneeID).First(&ud).Error; err == nil && ud.DingTalkUserID != "" { + userIDs = append(userIDs, ud.DingTalkUserID) + } else if err != nil { + logrus.WithError(err).WithField("user_id", *pt.AssigneeID).Warn("钉钉:查询下一节点审批人 UserDepartment 失败") + } else { + logrus.WithField("user_id", *pt.AssigneeID).Warn("钉钉:下一节点审批人 DingTalkUserID 为空") + } + } + } + if len(userIDs) > 0 { + msg := &dingtalk.AppointmentResultMessage{ + ApproverName: approverUser.RealName, + Action: "通过", + Comment: comment, + AppointmentID: appointment.ID, + VisitTime: visitTime, + VisitorName: visitorInfo.Name, + } + title, markdown := dingtalk.BuildApproveNotificationMsg(msg) + logrus.WithFields(logrus.Fields{ + "user_ids": userIDs, + "title": title, + }).Info("钉钉:发送下一节点审批人通知") + if err := h.dingTalk.BatchSendRobotMessage(userIDs, title, markdown); err != nil { + logrus.WithError(err).Error("钉钉发送下一节点通知失败") + } + } else { + logrus.WithField("pending_count", len(pendingTasks)).Info("钉钉:下一节点无有效钉钉ID的审批人") + } + } else { + logrus.WithField("has_pending", len(pendingTasks) > 0).Info("钉钉:无下一节点待审批任务") + } + }() +} + +// sendDingTalkAfterRejection 审批拒绝后发送钉钉消息 +func (h *Handler) sendDingTalkAfterRejection(taskID uint, comment string) { + if h.dingTalk == nil { + return + } + + var task model.Task + if err := h.db.Preload("Instance").First(&task, taskID).Error; err != nil { + return + } + if task.Instance == nil || task.Instance.BusinessType != "appointment" { + return + } + + logrus.WithFields(logrus.Fields{ + "task_id": taskID, + "instance_id": task.InstanceID, + }).Info("钉钉:审批拒绝,开始发送通知") + + go func() { + // 获取拒绝人信息 + var rejector model.User + if task.AssigneeID == nil { + return + } + if err := h.db.First(&rejector, *task.AssigneeID).Error; err != nil { + logrus.WithError(err).WithField("task_id", taskID).Warn("钉钉:查询拒绝人信息失败") + return + } + + // 获取预约信息 + var appointment model.Appointment + if err := h.db.First(&appointment, task.Instance.BusinessID).Error; err != nil { + return + } + + var visitorInfo struct { + Name string `json:"name"` + Company string `json:"company"` + } + json.Unmarshal([]byte(appointment.VisitorInfo), &visitorInfo) + visitTime := "" + if !appointment.VisitStartTime.IsZero() { + visitTime = dingtalk.FormatCSTTime(appointment.VisitStartTime, appointment.VisitEndTime) + } + + // 查找本实例中其他待审批任务,通知其审批人流程已终止 + var otherPendingTasks []model.Task + if err := h.db.Where("instance_id = ? AND status = 0 AND id != ?", task.InstanceID, task.ID).Find(&otherPendingTasks).Error; err != nil { + return + } + + var userIDs []string + for _, pt := range otherPendingTasks { + if pt.AssigneeID != nil { + var ud model.UserDepartment + if err := h.db.Where("user_id = ?", *pt.AssigneeID).First(&ud).Error; err == nil && ud.DingTalkUserID != "" { + userIDs = append(userIDs, ud.DingTalkUserID) + } else if err != nil { + logrus.WithError(err).WithField("user_id", *pt.AssigneeID).Warn("钉钉:查询其他待审批人 UserDepartment 失败") + } else { + logrus.WithField("user_id", *pt.AssigneeID).Warn("钉钉:其他待审批人 DingTalkUserID 为空") + } + } + } + + if len(userIDs) > 0 { + msg := &dingtalk.AppointmentResultMessage{ + ApproverName: rejector.RealName, + Action: "拒绝", + Comment: comment, + AppointmentID: appointment.ID, + VisitTime: visitTime, + VisitorName: visitorInfo.Name, + } + title, markdown := dingtalk.BuildRejectNotificationMsg(msg) + logrus.WithFields(logrus.Fields{ + "user_ids": userIDs, + "title": title, + }).Info("钉钉:发送拒绝通知给其他待审批人") + if err := h.dingTalk.BatchSendRobotMessage(userIDs, title, markdown); err != nil { + logrus.WithError(err).Error("钉钉发送拒绝通知失败") + } + } else { + logrus.WithField("other_pending_count", len(otherPendingTasks)).Info("钉钉:拒绝后无其他待审批人需通知") + } + }() +} diff --git a/sc-lktx-backend/internal/pkg/config/config.go b/sc-lktx-backend/internal/pkg/config/config.go new file mode 100644 index 0000000..16648c7 --- /dev/null +++ b/sc-lktx-backend/internal/pkg/config/config.go @@ -0,0 +1,121 @@ +// 使用 Viper 从 YAML 文件加载配置 +package config + +import ( + "fmt" + + "github.com/spf13/viper" +) + +// Config 全局配置,聚合所有子模块配置 +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + MinIO MinIOConfig `mapstructure:"minio"` + RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"` + Wechat WechatConfig `mapstructure:"wechat"` + DingTalk DingTalkConfig `mapstructure:"dingtalk"` + JWT JWTConfig `mapstructure:"jwt"` + Snowflake SnowflakeConfig `mapstructure:"snowflake"` + System SystemConfig `mapstructure:"system"` +} + +type ServerConfig struct { + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` // debug / release / test +} + +type DatabaseConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` + SSLMode string `mapstructure:"sslmode"` + Timezone string `mapstructure:"timezone"` +} + +type RedisConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` +} + +type MinIOConfig struct { + Endpoint string `mapstructure:"endpoint"` + AccessKey string `mapstructure:"access_key"` + SecretKey string `mapstructure:"secret_key"` + Bucket string `mapstructure:"bucket"` + UseSSL bool `mapstructure:"use_ssl"` + BaseURL string `mapstructure:"base_url"` +} + +type RabbitMQConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + VHost string `mapstructure:"vhost"` +} + +type WechatConfig struct { + AppID string `mapstructure:"app_id"` + AppSecret string `mapstructure:"app_secret"` + Token string `mapstructure:"token"` + EncodingAESKey string `mapstructure:"encoding_aes_key"` +} + +type JWTConfig struct { + Secret string `mapstructure:"secret"` + ExpireHours int `mapstructure:"expire_hours"` +} + +type SnowflakeConfig struct { + WorkerID int64 `mapstructure:"worker_id"` + DatacenterID int64 `mapstructure:"datacenter_id"` +} + +// DingTalkConfig 钉钉应用配置 +type DingTalkConfig struct { + AppKey string `mapstructure:"app_key"` + AppSecret string `mapstructure:"app_secret"` + RobotCode string `mapstructure:"robot_code"` +} + +type SystemConfig struct { + DefaultAvatar string `mapstructure:"default_avatar"` +} + +// DSN 生成 PostgreSQL 连接字符串 +func (d *DatabaseConfig) DSN() string { + return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=%s", + d.Host, d.User, d.Password, d.DBName, d.Port, d.SSLMode, d.Timezone) +} + +func (r *RedisConfig) Addr() string { + return fmt.Sprintf("%s:%d", r.Host, r.Port) +} + +func (r *RabbitMQConfig) AMQPURL() string { + return fmt.Sprintf("amqp://%s:%s@%s:%d/%s", r.User, r.Password, r.Host, r.Port, r.VHost) +} + +// LoadConfig 从 YAML 文件加载配置 +func LoadConfig(configPath string) (*Config, error) { + v := viper.New() + v.SetConfigFile(configPath) + v.SetConfigType("yaml") + + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &cfg, nil +} diff --git a/sc-lktx-backend/internal/pkg/database/database.go b/sc-lktx-backend/internal/pkg/database/database.go new file mode 100644 index 0000000..98a5c8d --- /dev/null +++ b/sc-lktx-backend/internal/pkg/database/database.go @@ -0,0 +1,72 @@ +// 数据库连接管理 + GORM AutoMigrate +package database + +import ( + "log" + + "com.sclktx/m/v2/internal/common/model" + "com.sclktx/m/v2/internal/pkg/config" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +// InitDB 初始化 PostgreSQL 连接并执行自动迁移 +func InitDB(cfg *config.DatabaseConfig) *gorm.DB { + var err error + logLevel := logger.Info + + if cfg.SSLMode == "" { + cfg.SSLMode = "disable" + } + + DB, err = gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{ + Logger: logger.Default.LogMode(logLevel), + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + if err := AutoMigrate(DB); err != nil { + log.Fatalf("Failed to auto migrate: %v", err) + } + + return DB +} + +// AutoMigrate 自动迁移所有模型对应的数据库表 +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &model.User{}, + &model.Visitor{}, + &model.Appointment{}, + &model.VisitRecord{}, + &model.Invitation{}, + &model.Banner{}, + &model.InviteCode{}, + &model.Notice{}, + &model.Notification{}, + &model.AdminUser{}, + &model.Department{}, + &model.EmployeeCertification{}, + // 工作流引擎 + &model.ProcessDefinition{}, + &model.ProcessInstance{}, + &model.Task{}, + &model.GoodsTemplate{}, + &model.VehicleInfo{}, + &model.UserDepartment{}, + &model.VisitorArea{}, + &model.VisitType{}, + &model.PendingEmployee{}, + &model.GlobalApprover{}, + ) +} + +func GetDB() *gorm.DB { + return DB +} diff --git a/sc-lktx-backend/internal/pkg/database/init_data.go b/sc-lktx-backend/internal/pkg/database/init_data.go new file mode 100644 index 0000000..8f99f7d --- /dev/null +++ b/sc-lktx-backend/internal/pkg/database/init_data.go @@ -0,0 +1,58 @@ +package database + +import ( + "log" + + "com.sclktx/m/v2/internal/common/model" + "gorm.io/gorm" +) + +// InitData 初始化基础数据 +func InitData(db *gorm.DB) { + initProcessDefinitions(db) +} + +func initProcessDefinitions(db *gorm.DB) { + defs := []model.ProcessDefinition{ + { + ProcessName: "访客预约审批", + ProcessCode: "visitor_approval", + Source: "访客系统", + Description: "访客预约三级审批:公司接待员工 → 部门指定人 → 分管领导", + IsActive: true, + NodesJSON: `[ + {"node_id":"Start","node_name":"发起预约","node_type":0,"user_ids":["$starter"]}, + {"node_id":"Employee","node_name":"公司接待员工审批","node_type":1,"prev_node_ids":["Start"],"user_ids":["$employee_id"]}, + {"node_id":"DeptApprover","node_name":"部门指定人审批","node_type":1,"prev_node_ids":["Employee"],"user_ids":["$dept_approver_ids"]}, + {"node_id":"VPApproval","node_name":"分管领导审批","node_type":1,"prev_node_ids":["DeptApprover"],"user_ids":["$vp_ids"]}, + {"node_id":"End","node_name":"审批完成","node_type":3,"prev_node_ids":["VPApproval"]} + ]`, + }, + { + ProcessName: "访客预约审批(兜底)", + ProcessCode: "visitor_approval_simple", + Source: "访客系统", + Description: "访客预约兜底审批:直接推送给全局兜底审批人", + IsActive: true, + NodesJSON: `[ + {"node_id":"Start","node_name":"发起预约","node_type":0,"user_ids":["$starter"]}, + {"node_id":"DefaultApproval","node_name":"兜底审批","node_type":1,"prev_node_ids":["Start"],"user_ids":["$default_approver_ids"]}, + {"node_id":"End","node_name":"审批完成","node_type":3,"prev_node_ids":["DefaultApproval"]} + ]`, + }, + } + + for _, def := range defs { + var count int64 + db.Model(&model.ProcessDefinition{}).Where("process_code = ?", def.ProcessCode).Count(&count) + if count > 0 { + log.Printf("[init] 流程定义已存在,跳过: %s", def.ProcessCode) + continue + } + if err := db.Create(&def).Error; err != nil { + log.Printf("[init] 创建流程定义失败 %s: %v", def.ProcessCode, err) + } else { + log.Printf("[init] 创建流程定义成功: %s", def.ProcessCode) + } + } +} diff --git a/sc-lktx-backend/internal/pkg/dingtalk/dingtalk.go b/sc-lktx-backend/internal/pkg/dingtalk/dingtalk.go new file mode 100644 index 0000000..f98a710 --- /dev/null +++ b/sc-lktx-backend/internal/pkg/dingtalk/dingtalk.go @@ -0,0 +1,189 @@ +// 钉钉开放平台 API 封装 +package dingtalk + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + myconfig "com.sclktx/m/v2/internal/pkg/config" + + "github.com/sirupsen/logrus" +) + +// Client 钉钉客户端 +type Client struct { + cfg *myconfig.DingTalkConfig + httpCl *http.Client + token string + tokenExp time.Time + tokenLock sync.RWMutex +} + +// NewClient 创建钉钉客户端 +func NewClient(cfg *myconfig.DingTalkConfig) *Client { + return &Client{ + cfg: cfg, + httpCl: &http.Client{Timeout: 10 * time.Second}, + } +} + +// tokenResponse 获取 access_token 的响应 +type tokenResponse struct { + AccessToken string `json:"accessToken"` + ExpireIn int64 `json:"expireIn"` // 秒 +} + +// getAccessToken 获取钉钉 access_token(带缓存) +func (c *Client) getAccessToken() (string, error) { + c.tokenLock.RLock() + if c.token != "" && time.Now().Before(c.tokenExp) { + c.tokenLock.RUnlock() + return c.token, nil + } + c.tokenLock.RUnlock() + + c.tokenLock.Lock() + defer c.tokenLock.Unlock() + + // 双重检查 + if c.token != "" && time.Now().Before(c.tokenExp) { + return c.token, nil + } + + body := map[string]string{ + "appKey": c.cfg.AppKey, + "appSecret": c.cfg.AppSecret, + } + data, _ := json.Marshal(body) + + logrus.Debug("钉钉获取 access_token 开始") + resp, err := c.httpCl.Post("https://api.dingtalk.com/v1.0/oauth2/accessToken", "application/json", bytes.NewReader(data)) + if err != nil { + logrus.WithError(err).Error("钉钉获取 access_token HTTP 请求失败") + return "", fmt.Errorf("dingtalk getAccessToken request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + logrus.WithFields(logrus.Fields{ + "status_code": resp.StatusCode, + "response": string(respBody), + }).Debug("钉钉获取 access_token 响应") + + var tr tokenResponse + if err := json.Unmarshal(respBody, &tr); err != nil { + logrus.WithError(err).WithField("response", string(respBody)).Error("钉钉获取 access_token 解析失败") + return "", fmt.Errorf("dingtalk getAccessToken parse failed: %s", string(respBody)) + } + if tr.AccessToken == "" { + logrus.WithField("response", string(respBody)).Error("钉钉获取 access_token 返回空") + return "", fmt.Errorf("dingtalk getAccessToken empty token: %s", string(respBody)) + } + + c.token = tr.AccessToken + c.tokenExp = time.Now().Add(time.Duration(tr.ExpireIn-60) * time.Second) // 提前 60 秒刷新 + logrus.WithFields(logrus.Fields{ + "expire_in": tr.ExpireIn, + }).Info("钉钉 access_token 获取成功") + return c.token, nil +} + +// RobotBatchSendReq 批量发送机器人消息请求 +type RobotBatchSendReq struct { + RobotCode string `json:"robotCode"` + UserIDs []string `json:"userIds"` + MsgKey string `json:"msgKey"` + MsgParam string `json:"msgParam"` +} + +// BatchSendRobotMessage 批量发送人与机器人会话中机器人消息 +// userIDs: 钉钉用户 unionId 列表 +// title: 消息标题 +// markdown: 消息内容(Markdown 格式) +func (c *Client) BatchSendRobotMessage(userIDs []string, title, markdown string) error { + if len(userIDs) == 0 { + logrus.Warn("钉钉 BatchSendRobotMessage 被调用但 userIDs 为空") + return nil + } + if c.cfg.RobotCode == "" { + logrus.Warn("钉钉 BatchSendRobotMessage 被调用但 RobotCode 为空") + return nil + } + + logrus.WithFields(logrus.Fields{ + "user_ids": userIDs, + "title": title, + "robot_code": c.cfg.RobotCode, + }).Info("钉钉 BatchSendRobotMessage 开始") + + token, err := c.getAccessToken() + if err != nil { + logrus.WithError(err).Error("钉钉 BatchSendRobotMessage 获取 token 失败") + return err + } + + msgParam := map[string]string{ + "title": title, + "text": markdown, + } + msgParamJSON, _ := json.Marshal(msgParam) + + req := RobotBatchSendReq{ + RobotCode: c.cfg.RobotCode, + UserIDs: userIDs, + MsgKey: "sampleMarkdown", + MsgParam: string(msgParamJSON), + } + reqBody, _ := json.Marshal(req) + + logrus.WithFields(logrus.Fields{ + "request_body": string(reqBody), + }).Debug("钉钉 BatchSendRobotMessage 请求体") + + httpReq, err := http.NewRequest("POST", + "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend", + bytes.NewReader(reqBody)) + if err != nil { + logrus.WithError(err).Error("钉钉 BatchSendRobotMessage 创建请求失败") + return fmt.Errorf("dingtalk BatchSendRobotMessage create request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-acs-dingtalk-access-token", token) + + resp, err := c.httpCl.Do(httpReq) + if err != nil { + logrus.WithError(err).Error("钉钉 BatchSendRobotMessage HTTP 请求失败") + return fmt.Errorf("dingtalk BatchSendRobotMessage request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + logrus.WithFields(logrus.Fields{ + "status_code": resp.StatusCode, + "response": string(respBody), + }).Debug("钉钉 BatchSendRobotMessage 响应") + + // 成功时钉钉返回 200 空 body 或 processQueryKey + if resp.StatusCode >= 400 { + errMsg := fmt.Errorf("dingtalk BatchSendRobotMessage failed: status=%d body=%s", resp.StatusCode, string(respBody)) + logrus.WithFields(logrus.Fields{ + "status_code": resp.StatusCode, + "response": string(respBody), + }).Error("钉钉 BatchSendRobotMessage 请求返回错误状态码") + return errMsg + } + + logrus.WithFields(logrus.Fields{ + "user_ids": userIDs, + "title": title, + "response": string(respBody), + }).Info("钉钉机器人消息发送成功") + + return nil +} diff --git a/sc-lktx-backend/internal/pkg/dingtalk/templates.go b/sc-lktx-backend/internal/pkg/dingtalk/templates.go new file mode 100644 index 0000000..e41fc03 --- /dev/null +++ b/sc-lktx-backend/internal/pkg/dingtalk/templates.go @@ -0,0 +1,119 @@ +// 访客预约相关钉钉消息模板 +package dingtalk + +import ( + "fmt" + "time" +) + +// FormatCSTTime 格式化时间为 Asia/Shanghai 时区的 "01-02 15:04 ~ 15:04" 格式 +func FormatCSTTime(start, end time.Time) string { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.UTC + } + start = start.In(loc) + end = end.In(loc) + if start.IsZero() { + return "" + } + return start.Format("01-02 15:04") + " ~ " + end.Format("15:04") +} + +// AppointmentMessage 访客预约消息参数 +type AppointmentMessage struct { + VisitorList string // 所有访客信息(已格式化,每行一条用
连接) + EmployeeName string + VisitTime string + VisitPurpose string + Remark string + VehicleInfo string // 车辆信息(已格式化,每行一条用
连接) + AppointmentID uint +} + +// AppointmentResultMessage 预约审批结果消息参数 +type AppointmentResultMessage struct { + ApproverName string + Action string // "通过" / "拒绝" + Comment string + AppointmentID uint + VisitTime string + VisitorName string +} + +// BuildNewAppointmentMsg 构建新预约提醒消息(发送给被访员工) +func BuildNewAppointmentMsg(m *AppointmentMessage) (title, markdown string) { + title = "📋 新的访客预约" + text := "有新的访客预约

" + + // 访客 + text += "【访客】
" + if m.VisitorList != "" { + text += m.VisitorList + "
" + } + + // 接待信息 + text += "**接待员工:** " + m.EmployeeName + "
" + text += "**访问时间:** " + m.VisitTime + "
" + if m.VisitPurpose != "" { + text += "**来访目的:** " + m.VisitPurpose + "
" + } + text += "
" + + // 车辆 + if m.VehicleInfo != "" { + text += "**车辆信息:**
" + m.VehicleInfo + } + + // 备注 + if m.Remark != "" { + text += "**备注:** " + m.Remark + } + + text += "

请及时前往微信小程序“四川凌空天行”处理预约申请。" + return title, text +} + +// BuildApproveNotificationMsg 构建审批通知消息(发送给下一节点审批人) +func BuildApproveNotificationMsg(m *AppointmentResultMessage) (title, markdown string) { + title = fmt.Sprintf("✅ 审批通过 - %s", m.ApproverName) + text := "审批通过

" + text += "**审批人:** " + m.ApproverName + "
" + text += "**结果:** 通过
" + if m.Comment != "" { + text += "**审批意见:** " + m.Comment + "
" + } + text += "**访问时间:** " + m.VisitTime + "
" + text += "**访客:** " + m.VisitorName + "
" + text += "
请前往小程序处理您的审批任务。" + return title, text +} + +// BuildRejectNotificationMsg 构建审批拒绝通知消息(发送给下一节点审批人) +func BuildRejectNotificationMsg(m *AppointmentResultMessage) (title, markdown string) { + title = fmt.Sprintf("❌ 审批已终止 - %s", m.ApproverName) + text := "审批已终止

" + text += "**拒绝人:** " + m.ApproverName + "
" + text += "**结果:** 拒绝
" + if m.Comment != "" { + text += "**拒绝原因:** " + m.Comment + "
" + } + text += "**访问时间:** " + m.VisitTime + "
" + text += "**访客:** " + m.VisitorName + "
" + text += "
该预约申请已被拒绝,无需继续处理。" + return title, text +} + +// BuildSameNodeCancelledMsg 构建同节点已取消通知消息 +// 当同一节点有多人审批(非会签),其中一人已审时,通知其他待审人 +func BuildSameNodeCancelledMsg(approvedUserName, comment, visitTime string) (title, markdown string) { + title = fmt.Sprintf("ℹ️ %s 已审批", approvedUserName) + text := "审批状态更新

" + text += "**已审批人:** " + approvedUserName + "
" + if comment != "" { + text += "**备注:** " + comment + "
" + } + text += "**访问时间:** " + visitTime + "
" + text += "
该节点已有其他审批人处理,此任务已自动取消。" + return title, text +} diff --git a/sc-lktx-backend/internal/pkg/minio/client.go b/sc-lktx-backend/internal/pkg/minio/client.go new file mode 100644 index 0000000..442829d --- /dev/null +++ b/sc-lktx-backend/internal/pkg/minio/client.go @@ -0,0 +1,120 @@ +// MinIO 对象存储客户端封装 +package minio + +import ( + "bytes" + "context" + "fmt" + "io" + "path/filepath" + "time" + + myconfig "com.sclktx/m/v2/internal/pkg/config" + + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// Client MinIO 客户端 +type Client struct { + client *minio.Client + config *myconfig.MinIOConfig +} + +// NewClient 创建 MinIO 客户端 +func NewClient(cfg *myconfig.MinIOConfig) (*Client, error) { + minioClient, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("创建 MinIO 客户端失败:%w", err) + } + + // 确保 bucket 存在 + ctx := context.Background() + exists, err := minioClient.BucketExists(ctx, cfg.Bucket) + if err != nil { + return nil, fmt.Errorf("检查 bucket 失败:%w", err) + } + + if !exists { + // 创建 bucket + err = minioClient.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}) + if err != nil { + return nil, fmt.Errorf("创建 bucket 失败:%w", err) + } + } + + return &Client{ + client: minioClient, + config: cfg, + }, nil +} + +// UploadFile 上传文件到 MinIO +// 返回文件的公开访问 URL +func (c *Client) UploadFile(ctx context.Context, reader io.Reader, objectName string, contentType string) (string, error) { + // 上传文件 + info, err := c.client.PutObject(ctx, c.config.Bucket, objectName, reader, -1, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return "", fmt.Errorf("上传文件失败:%w", err) + } + + // 生成公开访问 URL(不带签名,永久有效) + // 如果配置了 base_url(可带协议如 https://),直接使用 + // 否则用 endpoint(需自行保证协议正确) + urlBase := c.config.BaseURL + if urlBase == "" { + urlBase = c.config.Endpoint + } + url := fmt.Sprintf("%s/%s/%s", urlBase, c.config.Bucket, info.Key) + return url, nil +} + +// UploadImage 上传图片文件 +// 自动生成唯一的文件名(UUID + 时间戳 + 扩展名) +func (c *Client) UploadImage(ctx context.Context, data []byte, filename string) (string, error) { + // 生成唯一的文件名 + ext := filepath.Ext(filename) + if ext == "" { + ext = ".jpg" // 默认扩展名 + } + + // 使用 UUID + 时间戳生成唯一文件名 + objectName := fmt.Sprintf("images/%s_%s%s", + time.Now().Format("20060102150405"), + uuid.New().String()[:8], + ext, + ) + + // 判断内容类型 + contentType := "image/jpeg" + switch ext { + case ".png": + contentType = "image/png" + case ".gif": + contentType = "image/gif" + case ".webp": + contentType = "image/webp" + case ".jpg", ".jpeg": + contentType = "image/jpeg" + } + + // 上传 + reader := bytes.NewReader(data) + return c.UploadFile(ctx, reader, objectName, contentType) +} + +// GetClient 获取原始 MinIO 客户端 +func (c *Client) GetClient() *minio.Client { + return c.client +} + +// GetConfig 获取配置 +func (c *Client) GetConfig() *myconfig.MinIOConfig { + return c.config +} diff --git a/sc-lktx-backend/internal/pkg/wechat/templates/templates.go b/sc-lktx-backend/internal/pkg/wechat/templates/templates.go new file mode 100644 index 0000000..faccc93 --- /dev/null +++ b/sc-lktx-backend/internal/pkg/wechat/templates/templates.go @@ -0,0 +1,8 @@ +package templates + +// 微信订阅消息模板 ID +const ( + TMPL_VISIT = "ZSbJDf3HzOjAhbN3IRvyljS-ffmR9yzxGdt_yEwYxHI" // 访客预约通知 + TMPL_RESULT = "7tFHeTmubiHMhAaJGZB5lMLqlhU1VrUUgSjYp1Z05cY" // 访客申请结果通知 + TMPL_CANCEL = "I_fTh-qE3cY8kTSb4VU3v1cJFEYG9V7eEE8cNBb9C00" // 来访预约取消通知 +) diff --git a/sc-lktx-backend/internal/pkg/wechat/wechat.go b/sc-lktx-backend/internal/pkg/wechat/wechat.go new file mode 100644 index 0000000..4c9e9cf --- /dev/null +++ b/sc-lktx-backend/internal/pkg/wechat/wechat.go @@ -0,0 +1,179 @@ +// 微信小程序 SDK 封装,使用 silenceper/wechat/v2 +package wechat + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strings" + + myconfig "com.sclktx/m/v2/internal/pkg/config" + + "github.com/silenceper/wechat/v2" + "github.com/silenceper/wechat/v2/cache" + miniProgram "github.com/silenceper/wechat/v2/miniprogram" + "github.com/silenceper/wechat/v2/miniprogram/auth" + "github.com/silenceper/wechat/v2/miniprogram/subscribe" + wechatConfig "github.com/silenceper/wechat/v2/miniprogram/config" + "github.com/sirupsen/logrus" +) + +// MiniProgramClient 微信小程序客户端 +type MiniProgramClient struct { + mp *miniProgram.MiniProgram +} + +// NewMiniProgramClient 创建小程序客户端 +func NewMiniProgramClient(cfg *myconfig.WechatConfig) *MiniProgramClient { + // 开启 wechat SDK 日志(开发阶段使用 DebugLevel,生产环境可改为 WarnLevel) + logrus.SetOutput(os.Stdout) + logrus.SetLevel(logrus.DebugLevel) + logrus.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: "2006-01-02 15:04:05", + }) + + wc := wechat.NewWechat() + // 使用内存缓存 access_token(生产环境可换 Redis) + memoryCache := cache.NewMemory() + + mp := wc.GetMiniProgram(&wechatConfig.Config{ + AppID: cfg.AppID, + AppSecret: cfg.AppSecret, + Cache: memoryCache, + }) + + return &MiniProgramClient{mp: mp} +} + +// Code2Session 用 wx.login 返回的 code 换取 openid 和 session_key +func (c *MiniProgramClient) Code2Session(code string) (*auth.ResCode2Session, error) { + result, err := c.mp.GetAuth().Code2Session(code) + if err != nil { + return nil, fmt.Errorf("code2session failed: %w", err) + } + if result.ErrCode != 0 { + return nil, fmt.Errorf("code2session error: %d %s", result.ErrCode, result.ErrMsg) + } + return &result, nil +} + +// DecryptPhone 解密手机号(需 session_key + encrypted_data + iv) +func (c *MiniProgramClient) DecryptPhone(sessionKey, encryptedData, iv string) (string, error) { + phoneInfo, err := c.mp.GetEncryptor().Decrypt(sessionKey, encryptedData, iv) + if err != nil { + return "", fmt.Errorf("decrypt phone failed: %w", err) + } + return phoneInfo.PhoneNumber, nil +} + +// GetAccessToken 获取小程序 access_token +func (c *MiniProgramClient) GetAccessToken() (string, error) { + token, err := c.mp.GetContext().GetAccessToken() + if err != nil { + return "", fmt.Errorf("get access_token failed: %w", err) + } + return token, nil +} + +// UploadImgResponse 微信上传图片接口返回结构 +type UploadImgResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + URL string `json:"url"` // 微信返回的图片 URL +} + +// UploadImg 上传图片到微信服务器(作为永久素材) +// imageData: 图片二进制数据 +// filename: 文件名 +func (c *MiniProgramClient) UploadImg(imageData []byte, filename string) (*UploadImgResponse, error) { + // 获取 access_token + accessToken, err := c.GetAccessToken() + if err != nil { + return nil, err + } + + // 构造 multipart/form-data 请求 + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // 添加 media 字段(微信要求的字段名) + part, err := writer.CreateFormFile("media", filename) + if err != nil { + return nil, fmt.Errorf("create form file failed: %w", err) + } + if _, err := part.Write(imageData); err != nil { + return nil, fmt.Errorf("write image data failed: %w", err) + } + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("close writer failed: %w", err) + } + + // 发送请求 + url := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=%s", accessToken) + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("upload img request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response failed: %w", err) + } + + var result UploadImgResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse response failed: %w, body: %s", err, string(respBody)) + } + + if result.ErrCode != 0 { + return nil, fmt.Errorf("wechat upload img error: %d %s", result.ErrCode, result.ErrMsg) + } + + // 微信返回的 URL 是 http:// 协议,统一替换为 https:// + result.URL = strings.Replace(result.URL, "http://", "https://", 1) + + return &result, nil +} + +// SendSubscribeMessage 发送微信订阅消息 +// openid: 接收用户的 openid +// templateID: 模板 ID +// data: 模板数据,key 为模板字段名(如 thing1, date2),value 为字段值 +func (c *MiniProgramClient) SendSubscribeMessage(openid, templateID string, data map[string]string, page string) error { + msg := &subscribe.Message{ + ToUser: openid, + TemplateID: templateID, + Page: page, + Data: make(map[string]*subscribe.DataItem), + } + for k, v := range data { + msg.Data[k] = &subscribe.DataItem{Value: v} + } + err := c.mp.GetSubscribe().Send(msg) + if err != nil { + logrus.WithFields(logrus.Fields{ + "openid": openid, + "template_id": templateID, + }).Errorf("微信订阅消息推送失败: %v", err) + } else { + logrus.WithFields(logrus.Fields{ + "openid": openid, + "template_id": templateID, + }).Info("微信订阅消息推送成功") + } + return err +} diff --git a/sc-lktx-backend/scripts/__pycache__/import_employees.cpython-313.pyc b/sc-lktx-backend/scripts/__pycache__/import_employees.cpython-313.pyc new file mode 100644 index 0000000..56a7410 Binary files /dev/null and b/sc-lktx-backend/scripts/__pycache__/import_employees.cpython-313.pyc differ diff --git a/sc-lktx-backend/scripts/_check_depts.py b/sc-lktx-backend/scripts/_check_depts.py new file mode 100644 index 0000000..85a5cb9 --- /dev/null +++ b/sc-lktx-backend/scripts/_check_depts.py @@ -0,0 +1,10 @@ +import requests, os + +token = os.environ.get("ADMIN_TOKEN", "") +if not token: + token = input("ADMIN_TOKEN: ").strip() + +resp = requests.get("http://localhost:28175/v1/admin/departments-all", + headers={"Authorization": f"Bearer {token}"}) +for d in resp.json().get("data", []): + print(f'{d["id"]:>3d} | {d["name"]}') diff --git a/sc-lktx-backend/scripts/fix_appointments.py b/sc-lktx-backend/scripts/fix_appointments.py new file mode 100644 index 0000000..69018dd --- /dev/null +++ b/sc-lktx-backend/scripts/fix_appointments.py @@ -0,0 +1,20 @@ +"""Remove audit refs and functions from Appointments.vue""" +with open('mp-sc-admin/src/pages/Appointments.vue', 'rb') as f: + content = f.read() + +# Lines to remove - the refs +old1 = b"const auditVisible = ref(false)\r\nconst auditTarget = ref(null)\r\nconst auditStatus = ref(1)\r\nconst auditComment = ref('')\r\nconst auditing = ref(false)\r\n" +new1 = b"" +content = content.replace(old1, new1) + +# The functions handleAudit + submitAudit +old2 = b"function handleAudit(row, status) {\r\n auditTarget.value = row; auditStatus.value = status; auditComment.value = ''; auditVisible.value = true\r\n}\r\nasync function submitAudit() {\r\n auditing.value = true\r\n try {\r\n const status = auditStatus.value\r\n" +content = content.replace(old2, b"") + +# Find the closing part of submitAudit and remove it +old3 = b" await request.Put(`/v1/admin/appointments/${auditTarget.value.id}/audit`, { status, comment: auditComment.value })\r\n ElMessage.success(`${text}\xe6\x88\x90\xe5\x8a\x9f`)\r\n auditVisible.value = false\r\n await fetchData()\r\n } catch (err) { ElMessage.error(err.message || '\xe6\x93\x8d\xe4\xbd\x9c\xe5\xa4\xb1\xe8\xb4\xa5') }\r\n finally { auditing.value = false }\r\n}\r\n" +content = content.replace(old3, b"") + +with open('mp-sc-admin/src/pages/Appointments.vue', 'wb') as f: + f.write(content) +print('done') diff --git a/sc-lktx-backend/scripts/fix_menu.py b/sc-lktx-backend/scripts/fix_menu.py new file mode 100644 index 0000000..071deb9 --- /dev/null +++ b/sc-lktx-backend/scripts/fix_menu.py @@ -0,0 +1,44 @@ +"""Move workflow-designer menu item from 业务管理 to 访客模块""" +with open('mp-sc-admin/src/pages/Layout.vue', 'rb') as f: + content = f.read() + +# File uses \r\n line endings, so search with \r\n +old = ( + b" { path: '/pending-employees', label: '\xe9\x80\x9a\xe8\xae\xaf\xe5\xbd\x95\xe5\xaf\xbc\xe5\x85\xa5', icon: 'i-carbon-import-export' },\n" + b" { path: '/workflow-designer', label: '\xe6\xb5\x81\xe7\xa8\x8b\xe8\xae\xbe\xe8\xae\xa1\xe5\x99\xa8', icon: 'i-carbon-diagram' },\r\n" + b" ],\r\n" + b" },\r\n" + b" {\r\n" + b" label: '\xe8\xae\xbf\xe5\xae\xa2\xe6\xa8\xa1\xe5\x9d\x97',\r\n" + b" items: [\r\n" + b" { path: '/appointments', label: '\xe9\xa2\x84\xe7\xba\xa6\xe7\xae\xa1\xe7\x90\x86', icon: 'i-carbon-calendar' },\r\n" + b" { path: '/visit-types', label: '\xe6\x9d\xa5\xe8\xae\xbf\xe7\x9b\xae\xe7\x9a\x84', icon: 'i-carbon-list-dropdown' },\r\n" + b" { path: '/visitor-areas', label: '\xe5\x88\xb0\xe8\xae\xbf\xe5\x8c\xba\xe5\x9f\x9f', icon: 'i-carbon-location' },\r\n" + b" { path: '/config', label: '\xe5\xae\xa1\xe6\x89\xb9\xe9\x85\x8d\xe7\xbd\xae', icon: 'i-carbon-settings-adjust' }," +) + +new = ( + b" { path: '/pending-employees', label: '\xe9\x80\x9a\xe8\xae\xaf\xe5\xbd\x95\xe5\xaf\xbc\xe5\x85\xa5', icon: 'i-carbon-import-export' },\n" + b" ],\r\n" + b" },\r\n" + b" {\r\n" + b" label: '\xe8\xae\xbf\xe5\xae\xa2\xe6\xa8\xa1\xe5\x9d\x97',\r\n" + b" items: [\r\n" + b" { path: '/appointments', label: '\xe9\xa2\x84\xe7\xba\xa6\xe7\xae\xa1\xe7\x90\x86', icon: 'i-carbon-calendar' },\r\n" + b" { path: '/visit-types', label: '\xe6\x9d\xa5\xe8\xae\xbf\xe7\x9b\xae\xe7\x9a\x84', icon: 'i-carbon-list-dropdown' },\r\n" + b" { path: '/visitor-areas', label: '\xe5\x88\xb0\xe8\xae\xbf\xe5\x8c\xba\xe5\x9f\x9f', icon: 'i-carbon-location' },\r\n" + b" { path: '/workflow-designer', label: '\xe6\xb5\x81\xe7\xa8\x8b\xe8\xae\xbe\xe8\xae\xa1\xe5\x99\xa8', icon: 'i-carbon-diagram' },\r\n" + b" { path: '/config', label: '\xe5\xae\xa1\xe6\x89\xb9\xe9\x85\x8d\xe7\xbd\xae', icon: 'i-carbon-settings-adjust' }," +) + +if old in content: + content = content.replace(old, new) + with open('mp-sc-admin/src/pages/Layout.vue', 'wb') as f: + f.write(content) + print('done - replaced successfully') +else: + print('pattern not found') + idx = content.find(b'pending-employees') + if idx >= 0: + # Show bytes around it + print(content[idx:idx+400]) diff --git a/sc-lktx-backend/scripts/gen-swagger.ps1 b/sc-lktx-backend/scripts/gen-swagger.ps1 new file mode 100644 index 0000000..a3f355e --- /dev/null +++ b/sc-lktx-backend/scripts/gen-swagger.ps1 @@ -0,0 +1,100 @@ +# 生成 Swagger 文档 +# 用法: .\scripts\gen-swagger.ps1 + +$swag = "$env:USERPROFILE\go\bin\swag.exe" +$project = "E:\workspaces\sc-lktx-mp\sc-lktx-backend" +$output = "$project\docs" + +Write-Host "=== 1. 生成 Swagger 文档 ===" +& $swag init -g cmd/server/main.go --output $output --parseDependency --parseDepth 1 + +if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 1) { + Write-Host "swag init failed with code $LASTEXITCODE" -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "=== 2. 转换鉴权格式 (apiKey -> Bearer, 全局继承) ===" +$json = Get-Content "$output\swagger.json" -Raw | ConvertFrom-Json + +$bearerScheme = @{ + type = "http" + scheme = "bearer" + bearerFormat = "JWT" +} + +$json.PSObject.Properties.Remove('securityDefinitions') + +$components = $json.components +if (-not $components) { + $components = New-Object PSObject + $json | Add-Member -Name 'components' -Value $components -MemberType NoteProperty -Force +} +$components | Add-Member -Name 'securitySchemes' -Value @{ BearerAuth = $bearerScheme } -MemberType NoteProperty -Force + +# 移除所有接口上的 security(避免覆盖全局继承) +function Remove-SecurityFromPaths($obj) { + if ($obj.PSObject.Properties.Name -contains 'security') { + $obj.PSObject.Properties.Remove('security') + } + foreach ($prop in $obj.PSObject.Properties) { + if ($prop.Value -is [PSCustomObject]) { + Remove-SecurityFromPaths $prop.Value + } elseif ($prop.Value -is [System.Collections.ICollection]) { + foreach ($item in $prop.Value) { + if ($item -is [PSCustomObject]) { + Remove-SecurityFromPaths $item + } + } + } + } +} +Remove-SecurityFromPaths $json.paths + +# 添加全局 security(所有接口继承此鉴权) +$json | Add-Member -Name 'security' -Value @(@{ BearerAuth = @() }) -MemberType NoteProperty -Force + +$json | ConvertTo-Json -Depth 32 | Set-Content "$output\swagger.json" + +# 同步修改 docs.go +$docsPath = "$output\docs.go" +$content = Get-Content $docsPath -Raw + +# 替换 securityDefinitions 为 components/securitySchemes (bearer 格式) +$oldSecurity = '"securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + },' +$newSecurity = '"components": { + "securitySchemes": { + "BearerAuth": { + "bearerFormat": "JWT", + "type": "http", + "scheme": "bearer" + } + } + }, + "security": [ + { + "BearerAuth": [] + } + ],' +$content = $content.Replace($oldSecurity, $newSecurity) + +# 移除所有接口上的 security(避免覆盖全局继承) +$content = $content -replace '\s{12,}"security": \[\s*\{\s*"BearerAuth":\s*\[\]\s*\}\s*\],?\s*', "`r`n" +Write-Host " 已移除接口级 security" + +Set-Content $docsPath $content +Write-Host " docs.go 同步更新(全局继承)" + +# 清理临时文件,只保留 docs.go +Remove-Item "$output\swagger.json", "$output\swagger.yaml" -Force -ErrorAction SilentlyContinue +Write-Host " 已清理 swagger.json / swagger.yaml" + +Write-Host "=== 3. 验证编译 ===" +Set-Location $project +go build ./... +Write-Host "=== 完成 ===" -ForegroundColor Green diff --git a/sc-lktx-backend/scripts/import_employees.py b/sc-lktx-backend/scripts/import_employees.py new file mode 100644 index 0000000..87c62c4 --- /dev/null +++ b/sc-lktx-backend/scripts/import_employees.py @@ -0,0 +1,181 @@ +""" +从通讯录 Excel 提取四川凌空员工数据 调用 admin API 导入 +用法: python import_employees.py [--dry-run] + +需要管理员 JWT,三种方式: + 方式1:环境变量 ADMIN_TOKEN="xxx" + 方式2:环境变量 ADMIN_ACCOUNT="admin" ADMIN_PASSWORD="xxx" + 方式3:都不设则提示手动输入账号密码 +""" + +import os +import sys +import json +import requests +import openpyxl + +API_BASE = os.getenv("API_BASE", "http://localhost:28175/v1/admin") +TOKEN = os.getenv("ADMIN_TOKEN", "") + +def login_and_get_token(): + account = os.getenv("ADMIN_ACCOUNT", "") + password = os.getenv("ADMIN_PASSWORD", "") + if not account or not password: + account = input("管理员账号: ").strip() + password = input("管理员密码: ").strip() + resp = requests.post(API_BASE.replace("/admin", "") + "/admin/login", json={ + "account": account, + "password": password, + }) + data = resp.json() + if data.get("code") != 200: + print(f"登录失败: {data.get('msg')}") + sys.exit(1) + token = data["data"]["token"] + print(f" 登录成功") + return token + +def get_headers(): + global TOKEN + if not TOKEN: + TOKEN = login_and_get_token() + return {"Authorization": f"Bearer {TOKEN}"} + +def get_dept_map(): + """从后台获取部门列表,建立 部门名称 ID 的映射""" + resp = requests.get(f"{API_BASE}/departments-all", headers=get_headers()) + data = resp.json().get("data", []) + print(f" 找到 {len(data)} 个部门") + dept_map = {} + for d in data: + dept_map[d["name"]] = d["id"] + return dept_map + +def parse_excel(path): + """解析 Excel,返回所有员工列表(不过滤公司)""" + wb = openpyxl.load_workbook(path, data_only=True) + ws = wb.active + + employees = [] + for row in ws.iter_rows(min_row=4, values_only=True): + name = str(row[1] or "").strip() + phone = str(row[2] or "").strip() + dept_full = str(row[3] or "").strip() + position = str(row[4] or "").strip() + + if not name or not phone: + continue + + # 标准化手机号 + phone_clean = phone.replace("+86-", "").replace("+86", "").strip() + + # 取第一个部门路径 + dept_first = dept_full.split(",")[0].strip() if dept_full else "" + + employees.append({ + "real_name": name, + "phone": phone_clean, + "department_full": dept_first, + "position": position, + }) + + return employees + +def match_department(dept_full, dept_map): + """尝试多种方式匹配部门""" + # 1. 精确匹配完整名称 + if dept_full in dept_map: + return dept_full, dept_map[dept_full] + + # 2. 带分隔符的:取最后一级名 + for sep in ["-", "-", "", "/", ""]: + if sep in dept_full: + sub = dept_full.rsplit(sep, 1)[-1].strip() + if sub in dept_map: + return sub, dept_map[sub] + + # 3. 模糊匹配:dept_map 中有包含关系的 + for name, did in dept_map.items(): + if name in dept_full or dept_full in name: + return name, did + + return None, None + +def import_employees(employees, dept_map, dry_run=False): + """批量导入""" + items = [] + skipped_no_dept = 0 + for emp in employees: + matched_name, dept_id = match_department(emp["department_full"], dept_map) + + if not dept_id: + print(f" 跳过 {emp['real_name']}: 未匹配部门 '{emp['department_full']}'") + skipped_no_dept += 1 + continue + + items.append({ + "real_name": emp["real_name"], + "phone": emp["phone"], + "department_id": dept_id, + "department_name": matched_name, + "position": emp["position"], + }) + + if dry_run: + print(f"\n[dry-run] 将导入 {len(items)} 人,跳过 {skipped_no_dept} 人(无匹配部门)") + for item in items[:5]: + print(f" {item['real_name']} | {item['phone']} | {item['department_name']} | {item['position']}") + if len(items) > 5: + print(f" ... 共 {len(items)} 人") + return + + batch_size = 50 + total_imported = 0 + for i in range(0, len(items), batch_size): + batch = items[i:i + batch_size] + resp = requests.post(f"{API_BASE}/employees/import", json={"employees": batch}, headers=get_headers()) + result = resp.json() + if result.get("code") == 200: + data = result.get("data", {}) + total_imported += data.get("imported", 0) + print(f" 批次 {i//batch_size + 1}: 导入 {data.get('imported')} / 跳过 {data.get('skipped')}") + else: + print(f" 批次 {i//batch_size + 1}: 失败 - {result.get('msg')}") + + print(f"\n 共导入 {total_imported} 人") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("用法: python import_employees.py [--dry-run] [--region 关键词]") + print(" --region 关键词 只导入部门包含此关键词的员工(默认: 四川)") + sys.exit(1) + + path = sys.argv[1] + dry_run = "--dry-run" in sys.argv + + # 解析 region 参数 + region_filter = None + for i, arg in enumerate(sys.argv): + if arg == "--region" and i + 1 < len(sys.argv): + region_filter = sys.argv[i + 1] + if region_filter is None: + region_filter = "四川" # 默认只导入四川 + + print(f" 获取部门列表...") + dept_map = get_dept_map() + + print("\n 解析 Excel...") + employees = parse_excel(path) + # 按区域过滤 + if region_filter: + before = len(employees) + employees = [e for e in employees if region_filter in e["department_full"]] + print(f" 共 {before} 名员工,按区域 '{region_filter}' 过滤后: {len(employees)} 人") + else: + print(f" 提取到 {len(employees)} 名员工") + + print("\n 导入中...") + import_employees(employees, dept_map, dry_run) + + if dry_run: + print("\n 去掉 --dry-run 执行实际导入") diff --git a/sc-lktx-backend/start.bat b/sc-lktx-backend/start.bat new file mode 100644 index 0000000..90f2887 --- /dev/null +++ b/sc-lktx-backend/start.bat @@ -0,0 +1 @@ +go run com.sclktx/m/v2/cmd/server \ No newline at end of file diff --git a/sc-lktx-backend/start.sh b/sc-lktx-backend/start.sh new file mode 100644 index 0000000..90f2887 --- /dev/null +++ b/sc-lktx-backend/start.sh @@ -0,0 +1 @@ +go run com.sclktx/m/v2/cmd/server \ No newline at end of file diff --git a/北京凌空天行科技有限责任公司-通讯录.xlsx b/北京凌空天行科技有限责任公司-通讯录.xlsx new file mode 100644 index 0000000..fdbd0fe Binary files /dev/null and b/北京凌空天行科技有限责任公司-通讯录.xlsx differ diff --git a/教程/保安/1、首页.jpg b/教程/保安/1、首页.jpg new file mode 100644 index 0000000..728dad3 Binary files /dev/null and b/教程/保安/1、首页.jpg differ diff --git a/教程/保安/2、访客预约.jpg b/教程/保安/2、访客预约.jpg new file mode 100644 index 0000000..55996a6 Binary files /dev/null and b/教程/保安/2、访客预约.jpg differ diff --git a/教程/保安/3、保安工作台.jpg b/教程/保安/3、保安工作台.jpg new file mode 100644 index 0000000..13fbb00 Binary files /dev/null and b/教程/保安/3、保安工作台.jpg differ diff --git a/教程/保安/4、登记进场、出场.jpg b/教程/保安/4、登记进场、出场.jpg new file mode 100644 index 0000000..356d51e Binary files /dev/null and b/教程/保安/4、登记进场、出场.jpg differ diff --git a/教程/凌空天行管理系统使用教程.docx b/教程/凌空天行管理系统使用教程.docx new file mode 100644 index 0000000..da1c0a8 Binary files /dev/null and b/教程/凌空天行管理系统使用教程.docx differ diff --git a/教程/管理员/1、首页.jpg b/教程/管理员/1、首页.jpg new file mode 100644 index 0000000..728dad3 Binary files /dev/null and b/教程/管理员/1、首页.jpg differ diff --git a/教程/管理员/3、全部预约.jpg b/教程/管理员/3、全部预约.jpg new file mode 100644 index 0000000..afac938 Binary files /dev/null and b/教程/管理员/3、全部预约.jpg differ diff --git a/教程/管理员/4、预约详情(审核).jpg b/教程/管理员/4、预约详情(审核).jpg new file mode 100644 index 0000000..be32661 Binary files /dev/null and b/教程/管理员/4、预约详情(审核).jpg differ diff --git a/教程/管理员/访客预约.jpg b/教程/管理员/访客预约.jpg new file mode 100644 index 0000000..47c62fe Binary files /dev/null and b/教程/管理员/访客预约.jpg differ diff --git a/教程/访客/1、首页.jpg b/教程/访客/1、首页.jpg new file mode 100644 index 0000000..728dad3 Binary files /dev/null and b/教程/访客/1、首页.jpg differ diff --git a/教程/访客/2、访客预约.jpg b/教程/访客/2、访客预约.jpg new file mode 100644 index 0000000..06f63f7 Binary files /dev/null and b/教程/访客/2、访客预约.jpg differ diff --git a/教程/访客/3、访问告知书.jpg b/教程/访客/3、访问告知书.jpg new file mode 100644 index 0000000..ac44772 Binary files /dev/null and b/教程/访客/3、访问告知书.jpg differ diff --git a/教程/访客/4、填写预约.jpg b/教程/访客/4、填写预约.jpg new file mode 100644 index 0000000..a4c9ff7 Binary files /dev/null and b/教程/访客/4、填写预约.jpg differ diff --git a/教程/访客/5、提交预约.jpg b/教程/访客/5、提交预约.jpg new file mode 100644 index 0000000..9d38bcf Binary files /dev/null and b/教程/访客/5、提交预约.jpg differ diff --git a/教程/访客/6、预约详情.jpg b/教程/访客/6、预约详情.jpg new file mode 100644 index 0000000..9e43dee Binary files /dev/null and b/教程/访客/6、预约详情.jpg differ diff --git a/教程/访客/7、预约详情(审核通过).jpg b/教程/访客/7、预约详情(审核通过).jpg new file mode 100644 index 0000000..3dd6878 Binary files /dev/null and b/教程/访客/7、预约详情(审核通过).jpg differ diff --git a/教程/访客/8、微信服务通知.jpg b/教程/访客/8、微信服务通知.jpg new file mode 100644 index 0000000..091cbf9 Binary files /dev/null and b/教程/访客/8、微信服务通知.jpg differ