'init'
This commit is contained in:
16
.codegraph/.gitignore
vendored
Normal file
16
.codegraph/.gitignore
vendored
Normal file
@@ -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
|
||||
6
.codegraph/daemon.pid
Normal file
6
.codegraph/daemon.pid
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pid": 23452,
|
||||
"version": "0.9.7",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-8d231922d5519a75",
|
||||
"startedAt": 1782091277662
|
||||
}
|
||||
1
.reasonix/desktop-topic-created-at.json
Normal file
1
.reasonix/desktop-topic-created-at.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
3
.reasonix/desktop-topic-title-sources.json
Normal file
3
.reasonix/desktop-topic-title-sources.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"topic_20260623-052546_02ce107c7f58bd23": "auto"
|
||||
}
|
||||
3
.reasonix/desktop-topic-titles.json
Normal file
3
.reasonix/desktop-topic-titles.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"topic_20260623-052546_02ce107c7f58bd23": "因为项目中想要使用到审批流相关的内容…"
|
||||
}
|
||||
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
133
README.md
Normal file
133
README.md
Normal file
@@ -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/ # 静态资源
|
||||
```
|
||||
122
_通讯录样本.json
Normal file
122
_通讯录样本.json
Normal file
@@ -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": "'总经理'"
|
||||
}
|
||||
]
|
||||
1
mp-sc-admin/.env
Normal file
1
mp-sc-admin/.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASEURL=http://localhost:28175
|
||||
1
mp-sc-admin/.env.prod
Normal file
1
mp-sc-admin/.env.prod
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASEURL=https://mp.sclktx.com/api
|
||||
24
mp-sc-admin/.gitignore
vendored
Normal file
24
mp-sc-admin/.gitignore
vendored
Normal file
@@ -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?
|
||||
13
mp-sc-admin/index.html
Normal file
13
mp-sc-admin/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>凌空天行后台管理</title>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2835
mp-sc-admin/package-lock.json
generated
Normal file
2835
mp-sc-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
mp-sc-admin/package.json
Normal file
32
mp-sc-admin/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
3
mp-sc-admin/src/App.vue
Normal file
3
mp-sc-admin/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
192
mp-sc-admin/src/api/index.ts
Normal file
192
mp-sc-admin/src/api/index.ts
Normal file
@@ -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<string, any>) {
|
||||
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<string, any>) {
|
||||
return request.Post('/v1/admin/banners', data)
|
||||
}
|
||||
|
||||
export function updateBanner(id: number, data: Record<string, any>) {
|
||||
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<string, any>) {
|
||||
return request.Post('/v1/admin/notices', data)
|
||||
}
|
||||
|
||||
export function updateNotice(id: number, data: Record<string, any>) {
|
||||
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<string, any>) {
|
||||
return request.Get('/v1/admin/certifications', { params })
|
||||
}
|
||||
|
||||
export function auditCertification(id: number, data: Record<string, any>) {
|
||||
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<string, any> = { 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<string, any>) {
|
||||
return request.Post('/v1/admin/departments', data)
|
||||
}
|
||||
|
||||
export function updateDepartment(id: number, data: Record<string, any>) {
|
||||
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}`)
|
||||
}
|
||||
13
mp-sc-admin/src/main.ts
Normal file
13
mp-sc-admin/src/main.ts
Normal file
@@ -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')
|
||||
345
mp-sc-admin/src/pages/Appointments.vue
Normal file
345
mp-sc-admin/src/pages/Appointments.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold text-gray-800">预约管理</h1>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="mb-3">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col :span="4">
|
||||
<el-select v-model="statusFilter" placeholder="审批状态" clearable class="w-full">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已拒绝" :value="2" />
|
||||
<el-option label="已取消" :value="3" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-button type="primary" @click="page = 1; fetchData()">查询</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="appointments" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column label="外部来访人员" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-col text-sm leading-relaxed">
|
||||
<template v-if="parseVisitors(row.visitor_info)?.length">
|
||||
<span class="text-gray-800">
|
||||
{{ parseVisitors(row.visitor_info)[0].name }}
|
||||
<span v-if="parseVisitors(row.visitor_info).length > 1" class="text-gray-400 font-normal">
|
||||
(等{{ parseVisitors(row.visitor_info).length }}人)
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="parseVisitors(row.visitor_info)[0]?.company" class="text-gray-400 text-xs">{{ parseVisitors(row.visitor_info)[0]?.company }}</span>
|
||||
</template>
|
||||
<span v-else class="text-gray-800">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="公司接待人员" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ parseEmployee(row.employee_info)?.name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来访事由" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.visit_purpose || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来访时间" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm">{{ formatTime(row.visit_start_time) }} ~ {{ formatTime(row.visit_end_time) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="['warning','success','danger','info'][row.status]">
|
||||
{{ ['待审核','已通过','已拒绝','已取消'][row.status] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="total > 0" class="p-4 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total,prev,pager,next"
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="预约详情" width="600px" :close-on-click-modal="false">
|
||||
<template v-if="detailRow">
|
||||
<div class="space-y-5">
|
||||
<!-- 基本信息 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">基本信息</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-gray-50 rounded-lg p-4">
|
||||
<div><span class="text-gray-400">预约ID:</span><span class="text-gray-700">{{ detailRow.id }}</span></div>
|
||||
<div><span class="text-gray-400">来访事由:</span><span class="text-gray-700">{{ detailRow.visit_purpose || '-' }}</span></div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-gray-400">来访时间:</span>
|
||||
<span class="text-gray-700">{{ formatTime(detailRow.visit_start_time) }} ~ {{ formatTime(detailRow.visit_end_time) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">状态:</span>
|
||||
<el-tag :type="['warning','success','danger','info'][detailRow.status]" size="small">
|
||||
{{ ['待审核','已通过','已拒绝','已取消'][detailRow.status] }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div><span class="text-gray-400">创建时间:</span><span class="text-gray-700">{{ formatTime(detailRow.created_at) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 访客信息 -->
|
||||
<div v-if="parsedVisitors?.length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">外部来访人员</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
|
||||
<div v-for="(v, i) in parsedVisitors" :key="i" class="flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-800 font-medium">{{ v.name }}</span>
|
||||
<span v-if="v.phone" class="text-gray-500">{{ v.phone }}</span>
|
||||
<span v-if="v.company" class="text-gray-400">{{ v.company }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 被访人 -->
|
||||
<div v-if="parsedEmployee">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">公司接待人员</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm">
|
||||
<span class="text-gray-800 font-medium">{{ parsedEmployee.name }}</span>
|
||||
<span v-if="parsedEmployee.phone" class="text-gray-500 ml-2">{{ parsedEmployee.phone }}</span>
|
||||
<span v-if="parsedEmployee.department" class="text-gray-400 ml-2">{{ parsedEmployee.department }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建人 -->
|
||||
<div v-if="parsedCreator">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">创建人</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm">
|
||||
<span class="text-gray-800 font-medium">{{ parsedCreator.name }}</span>
|
||||
<span v-if="parsedCreator.phone" class="text-gray-500 ml-2">{{ parsedCreator.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 携带物品 -->
|
||||
<div v-if="parsedGoods?.length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">携带物品</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-1.5">
|
||||
<div v-for="(g, i) in parsedGoods" :key="i" class="text-sm">
|
||||
<span class="text-gray-800">{{ g.name }}</span>
|
||||
<span v-if="g.quantity" class="text-gray-500 ml-1">x{{ g.quantity }}</span>
|
||||
<span v-if="g.remark" class="text-gray-400 ml-2">{{ g.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 车辆信息 -->
|
||||
<div v-if="parsedVehicles?.length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">车辆信息</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-1.5">
|
||||
<div v-for="(v, i) in parsedVehicles" :key="i" class="text-sm">
|
||||
<span class="text-gray-400">车辆{{ i + 1 }}:</span>
|
||||
<span class="text-gray-800">{{ [v.brand, v.plate, v.color].filter(Boolean).join(' ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 到访区域 -->
|
||||
<div v-if="parsedAreas?.length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">到访区域</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-700">{{ parsedAreas.join('、') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div v-if="detailRow.remark">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">备注</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">{{ detailRow.remark }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核意见 -->
|
||||
<div v-if="detailRow.comment">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">审核意见</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">{{ detailRow.comment }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批进度 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">审批进度</span>
|
||||
</div>
|
||||
<div v-if="workflowTasks.length === 0 && !workflowLoading" class="text-sm text-gray-400 bg-gray-50 rounded-lg p-4">暂无审批记录</div>
|
||||
<div v-else-if="workflowLoading" class="text-sm text-gray-400 bg-gray-50 rounded-lg p-4">加载中...</div>
|
||||
<div v-else class="space-y-0">
|
||||
<div
|
||||
v-for="(t, idx) in workflowTasks"
|
||||
:key="t.id"
|
||||
class="flex gap-3 pb-4"
|
||||
:class="{ 'pb-0': idx === workflowTasks.length - 1 }"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full border-2 shrink-0 mt-1"
|
||||
:class="{
|
||||
'bg-green-500 border-green-500': t.status === 1,
|
||||
'bg-red-500 border-red-500': t.status === 2,
|
||||
'bg-orange-400 border-orange-400': t.status === 0,
|
||||
'bg-gray-200 border-gray-300': t.status === 3 || !t.status,
|
||||
}"
|
||||
/>
|
||||
<div v-if="idx < workflowTasks.length - 1" class="w-0.5 flex-1 mt-1"
|
||||
:class="t.status === 1 ? 'bg-green-300' : 'bg-gray-200'" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">{{ t.node_name }}</span>
|
||||
<el-tag v-if="t.status === 0" type="warning" size="small">待审批</el-tag>
|
||||
<el-tag v-else-if="t.status === 1" type="success" size="small">已通过</el-tag>
|
||||
<el-tag v-else-if="t.status === 2" type="danger" size="small">已拒绝</el-tag>
|
||||
<el-tag v-else size="small" type="info">已取消</el-tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">
|
||||
<template v-if="t.assignee">{{ t.assignee.real_name || t.assignee.nickname }}</template>
|
||||
<template v-if="t.handled_at"> · {{ formatTime(t.handled_at) }}</template>
|
||||
</div>
|
||||
<div v-if="t.comment" class="text-xs text-gray-500 mt-0.5 bg-white rounded px-2 py-1 border">
|
||||
{{ t.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const appointments = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const statusFilter = ref('')
|
||||
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<any>(null)
|
||||
const workflowTasks = ref<any[]>([])
|
||||
const workflowLoading = ref(false)
|
||||
const parsedVisitors = computed(() => detailRow.value ? parseVisitors(detailRow.value.visitor_info) : null)
|
||||
const parsedEmployee = computed(() => detailRow.value ? parseEmployee(detailRow.value.employee_info) : null)
|
||||
const parsedCreator = computed(() => detailRow.value ? parseCreator(detailRow.value.creator_info) : null)
|
||||
const parsedAreas = computed(() => detailRow.value ? parseAreas(detailRow.value.areas_info) : null)
|
||||
const parsedGoods = computed(() => detailRow.value ? parseGoods(detailRow.value.goods_info) : null)
|
||||
const parsedVehicles = computed(() => detailRow.value ? parseVehicles(detailRow.value.vehicle_info) : null)
|
||||
|
||||
function parseVisitors(info) {
|
||||
if (!info) return null
|
||||
try {
|
||||
const parsed = JSON.parse(info)
|
||||
// 对象格式:{ name, phone, company, visitors: [...] }
|
||||
if (!Array.isArray(parsed) && parsed?.visitors) {
|
||||
return parsed.visitors
|
||||
}
|
||||
// 已经是数组
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
// 单个对象包装为数组
|
||||
return [parsed]
|
||||
} catch { return null }
|
||||
}
|
||||
function parseEmployee(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
|
||||
function parseCreator(info) { if (!info) return null; try { const p = JSON.parse(info); return p.name ? p : null } catch { return null } }
|
||||
function parseAreas(info) { if (!info) return null; try { return JSON.parse(info).map(a => a.area || a) } catch { return null } }
|
||||
function parseGoods(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
|
||||
function parseVehicles(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
|
||||
function formatTime(t) {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
const pad = n => n.toString().padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, page_size: pageSize.value }
|
||||
if (statusFilter.value !== '') params.status = statusFilter.value
|
||||
const res = await request.Get('/v1/admin/appointments', { params })
|
||||
appointments.value = res?.data?.list || []
|
||||
total.value = res?.data?.total || 0
|
||||
} catch { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 加载工作流审批记录
|
||||
async function openDetail(row) {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
workflowTasks.value = []
|
||||
workflowLoading.value = true
|
||||
try {
|
||||
// 查询工作流实例
|
||||
const instances = await request.Get('/v1/workflow/instances', {
|
||||
params: { business_type: 'appointment', business_id: row.id },
|
||||
}) as any
|
||||
const list = instances?.data?.list || []
|
||||
if (list.length > 0) {
|
||||
const detail = await request.Get(`/v1/workflow/instances/${list[0].id}`) as any
|
||||
workflowTasks.value = detail?.data?.tasks || []
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { workflowLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
213
mp-sc-admin/src/pages/ApprovalConfig.vue
Normal file
213
mp-sc-admin/src/pages/ApprovalConfig.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">审批配置</h1>
|
||||
|
||||
<!-- 部门审批配置 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-700">部门审批配置</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">为每个部门配置「指定审批人」和「分管领导」</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="departments" stripe class="w-full">
|
||||
<el-table-column prop="name" label="部门名称" min-width="140" />
|
||||
<el-table-column label="部门负责人" min-width="120">
|
||||
<template #default="{ row }">{{ row.leader_name || '未设置' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="指定审批人(节点2)" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="parsedArr(row.approver_user_ids).length">
|
||||
<el-tag v-for="uid in parsedArr(row.approver_user_ids)" :key="uid" type="success" size="small" class="mr-1">{{ getUserName(uid) }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-gray-400 text-sm">未配置(将跳过该节点)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分管领导(节点3)" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="parsedArr(row.vp_user_ids).length">
|
||||
<el-tag v-for="uid in parsedArr(row.vp_user_ids)" :key="uid" type="primary" size="small" class="mr-1">{{ getUserName(uid) }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-gray-400 text-sm">未配置(将跳过该节点)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 全局兜底审批人 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden mt-6">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-700">全局兜底审批人</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">未匹配到员工或部门未配置审批人时,推送给这些人审批</p>
|
||||
</div>
|
||||
<el-button type="primary" size="small" @click="globalDialogVisible = true">+ 添加</el-button>
|
||||
</div>
|
||||
<el-table :data="globalApprovers" stripe class="w-full">
|
||||
<el-table-column label="姓名" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ row.user?.real_name || row.user?.nickname || `#${row.user_id}` }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" :loading="deletingId === row.id" @click="handleGlobalDelete(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑部门弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="'审批配置 — ' + (currentDept?.name || '')" width="580px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="指定审批人(节点2)">
|
||||
<el-select v-model="approverUserIds" multiple filterable placeholder="可选择多人(会签审批)" class="w-full" collapse-tags collapse-tags-tooltip :max-collapse-tags="5">
|
||||
<el-option v-for="e in employees" :key="e.id" :label="`${displayName(e)}(${[e.department_name, e.position].filter(Boolean).join(' · ') || '未分配'})`" :value="e.id" />
|
||||
</el-select>
|
||||
<div class="text-xs text-gray-400 mt-1">不选择时将跳过该节点</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="分管领导(节点3)">
|
||||
<el-select v-model="vpUserIds" multiple filterable placeholder="可选择多人(会签审批)" class="w-full" collapse-tags collapse-tags-tooltip :max-collapse-tags="5">
|
||||
<el-option v-for="e in employees" :key="e.id" :label="`${displayName(e)}(${[e.department_name, e.position].filter(Boolean).join(' · ') || '未分配'})`" :value="e.id" />
|
||||
</el-select>
|
||||
<div class="text-xs text-gray-400 mt-1">不选择时将跳过该节点</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加兜底审批人弹窗 -->
|
||||
<el-dialog v-model="globalDialogVisible" title="添加兜底审批人" width="480px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="选择员工">
|
||||
<el-select v-model="globalUserId" filterable placeholder="请选择员工" class="w-full">
|
||||
<el-option v-for="e in employees" :key="e.id" :label="`${displayName(e)}(${[e.department_name, e.position].filter(Boolean).join(' · ') || '未分配'})`" :value="e.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="globalDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="globalSaving" @click="handleGlobalAdd">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
setDepartmentApprover,
|
||||
setDepartmentVPs,
|
||||
getEmployees,
|
||||
listAllDepartments,
|
||||
getGlobalApprovers,
|
||||
createGlobalApprover,
|
||||
deleteGlobalApprover,
|
||||
} from '@/api'
|
||||
|
||||
const departments = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
// ===== 部门编辑 =====
|
||||
const dialogVisible = ref(false)
|
||||
const currentDept = ref<any>(null)
|
||||
const approverUserIds = ref<number[]>([])
|
||||
const vpUserIds = ref<number[]>([])
|
||||
|
||||
function openEdit(dept: any) {
|
||||
currentDept.value = dept
|
||||
approverUserIds.value = parsedArr(dept.approver_user_ids)
|
||||
vpUserIds.value = parsedArr(dept.vp_user_ids)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!currentDept.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
setDepartmentApprover(currentDept.value.id, approverUserIds.value),
|
||||
setDepartmentVPs(currentDept.value.id, vpUserIds.value),
|
||||
])
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
await loadDepartments()
|
||||
} catch { /* request.ts handled */ }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ===== 全局兜底 =====
|
||||
const globalApprovers = ref<any[]>([])
|
||||
const globalDialogVisible = ref(false)
|
||||
const globalUserId = ref<number | null>(null)
|
||||
const globalSaving = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
|
||||
async function loadGlobalApprovers() {
|
||||
const res = await getGlobalApprovers() as any
|
||||
globalApprovers.value = res?.data || []
|
||||
}
|
||||
|
||||
async function handleGlobalAdd() {
|
||||
if (!globalUserId.value) { ElMessage.warning('请选择员工'); return }
|
||||
globalSaving.value = true
|
||||
try {
|
||||
await createGlobalApprover(globalUserId.value)
|
||||
ElMessage.success('添加成功')
|
||||
globalDialogVisible.value = false
|
||||
globalUserId.value = null
|
||||
await loadGlobalApprovers()
|
||||
} catch { /* request.ts handled */ }
|
||||
finally { globalSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleGlobalDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定移除 ${row.user?.real_name || row.user?.nickname || ''}?`)
|
||||
deletingId.value = row.id
|
||||
try {
|
||||
await deleteGlobalApprover(row.id)
|
||||
ElMessage.success('已移除')
|
||||
await loadGlobalApprovers()
|
||||
} catch { /* request.ts handled */ }
|
||||
finally { deletingId.value = null }
|
||||
}
|
||||
|
||||
// ===== 工具 =====
|
||||
function displayName(e: any): string {
|
||||
return e.real_name || e.nickname || e.name || `#${e.id}`
|
||||
}
|
||||
function getUserName(id: number): string {
|
||||
const emp = employees.value.find((e) => e.id === id)
|
||||
return emp ? displayName(emp) : `#${id}`
|
||||
}
|
||||
function parsedArr(str: string | null | undefined): number[] {
|
||||
if (!str) return []
|
||||
try { return JSON.parse(str) } catch { return [] }
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
const res = await listAllDepartments() as any
|
||||
// 过滤掉根节点(四川凌空,无实际部门审批意义)
|
||||
departments.value = (res?.data || []).filter((d: any) => d.id !== 1)
|
||||
}
|
||||
async function loadEmployees() {
|
||||
const res = await getEmployees() as any
|
||||
employees.value = res?.data || []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await Promise.all([loadDepartments(), loadEmployees(), loadGlobalApprovers()])
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
252
mp-sc-admin/src/pages/Approvals.vue
Normal file
252
mp-sc-admin/src/pages/Approvals.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">认证审核</h1>
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="certs" stripe class="w-full">
|
||||
<el-table-column prop="real_name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column label="角色类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ { employee: '员工', guard: '保安' }[row.role] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请部门" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ row.department?.name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请职位" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ row.position || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="['warning','success','danger'][row.status]">{{ ['待审核','已通过','已拒绝'][row.status] }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status===0" size="small" type="primary" @click="openReview(row)">审核</el-button>
|
||||
<el-button v-else size="small" type="primary" plain @click="openReview(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 审核弹窗(固定高度 + 内容滚动 + 底部按钮固定) -->
|
||||
<el-dialog v-model="reviewVisible" title="审核认证申请" width="560px" :close-on-click-modal="false" top="5vh">
|
||||
<div class="review-dialog-body" v-if="reviewRow">
|
||||
<!-- ===== 申请人信息 ===== -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">申请人信息</h3>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-gray-400">姓名:</span>{{ reviewRow.real_name }}</div>
|
||||
<div><span class="text-gray-400">手机号:</span>{{ reviewRow.phone }}</div>
|
||||
<div><span class="text-gray-400">角色类型:</span>{{ { employee: '员工', guard: '保安' }[reviewRow.role] }}</div>
|
||||
<div><span class="text-gray-400">申请职位:</span>{{ reviewRow.position || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 证明材料 ===== -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">证明材料</h3>
|
||||
<div v-if="reviewRow.snapshot?.length" class="grid grid-cols-3 gap-2">
|
||||
<el-image
|
||||
v-for="(url, i) in reviewRow.snapshot"
|
||||
:key="i"
|
||||
:src="url"
|
||||
:preview-src-list="reviewRow.snapshot"
|
||||
:initial-index="i"
|
||||
fit="cover"
|
||||
class="rounded-lg border border-gray-200 cursor-pointer"
|
||||
style="width: 100%; height: 120px;"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 text-sm">无证明材料</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 审核操作(仅待审核) ===== -->
|
||||
<template v-if="reviewRow.status === 0">
|
||||
<el-divider />
|
||||
|
||||
<!-- 步骤1:选择操作类型 -->
|
||||
<div v-if="!reviewAction" class="text-center py-4">
|
||||
<p class="text-gray-500 text-sm mb-4">请选择审核结果</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<el-button size="large" class="w-32" @click="reviewAction = 'reject'">
|
||||
<span class="text-lg mr-1">✕</span> 拒绝
|
||||
</el-button>
|
||||
<el-button size="large" type="primary" class="w-32" @click="reviewAction = 'approve'">
|
||||
<span class="text-lg mr-1">✓</span> 通过
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2:填写详情 -->
|
||||
<div v-if="reviewAction === 'approve'" class="space-y-3">
|
||||
<div class="text-xs text-gray-400 mb-2 border-b pb-2">申请信息确认</div>
|
||||
<el-form-item label="姓名" label-width="80px">
|
||||
<el-input v-model="reviewName" placeholder="姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" label-width="80px">
|
||||
<el-input v-model="reviewPhone" placeholder="手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色类型" label-width="80px">
|
||||
<el-select v-model="reviewEmployeeType" class="w-full">
|
||||
<el-option label="员工" value="employee" />
|
||||
<el-option label="保安" value="guard" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请部门" label-width="80px">
|
||||
<el-select v-model="reviewDeptId" filterable placeholder="请选择部门" class="w-full">
|
||||
<el-option v-for="d in departments" :key="d.id" :label="d.name" :value="d.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="职位" label-width="80px">
|
||||
<el-input v-model="reviewPosition" placeholder="如:应用系统开发工程师" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核备注" label-width="80px">
|
||||
<el-input v-model="reviewComment" placeholder="可选" type="textarea" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div v-if="reviewAction === 'reject'" class="space-y-3">
|
||||
<el-form-item label="拒绝原因">
|
||||
<el-input v-model="reviewComment" placeholder="请输入拒绝原因" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 已审核状态 -->
|
||||
<template v-else>
|
||||
<el-divider />
|
||||
<div class="text-sm">
|
||||
<div>
|
||||
<span class="text-gray-400">审核结果:</span>
|
||||
<el-tag :type="reviewRow.status===1?'success':'danger'" size="small">
|
||||
{{ reviewRow.status===1?'已通过':'已拒绝' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="reviewRow.comment" class="mt-1">
|
||||
<span class="text-gray-400">备注:</span>{{ reviewRow.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 底部固定按钮 -->
|
||||
<template #footer>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<el-button @click="reviewVisible = false">关闭</el-button>
|
||||
|
||||
<template v-if="reviewRow?.status === 0 && reviewAction === 'approve'">
|
||||
<el-button @click="reviewAction = ''">返回</el-button>
|
||||
<el-button type="success" :loading="reviewing" @click="handleApprove">确认通过</el-button>
|
||||
</template>
|
||||
|
||||
<template v-if="reviewRow?.status === 0 && reviewAction === 'reject'">
|
||||
<el-button @click="reviewAction = ''">返回</el-button>
|
||||
<el-button type="danger" :loading="reviewing" @click="handleReject">确认拒绝</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCertifications, auditCertification, listAllDepartments } from '@/api'
|
||||
|
||||
const certs = ref<any[]>([])
|
||||
const departments = ref<any[]>([])
|
||||
|
||||
// 审核弹窗
|
||||
const reviewVisible = ref(false)
|
||||
const reviewRow = ref<any>(null)
|
||||
const reviewAction = ref<'approve' | 'reject' | ''>('')
|
||||
const reviewName = ref('')
|
||||
const reviewPhone = ref('')
|
||||
const reviewPosition = ref('')
|
||||
const reviewEmployeeType = ref('employee')
|
||||
const reviewDeptId = ref<number | null>(null)
|
||||
const reviewComment = ref('')
|
||||
const reviewing = ref(false)
|
||||
|
||||
async function load() {
|
||||
const [certRes, deptRes] = await Promise.all([
|
||||
getCertifications(),
|
||||
listAllDepartments(),
|
||||
])
|
||||
certs.value = (certRes as any)?.data?.list || []
|
||||
departments.value = (deptRes as any)?.data || []
|
||||
}
|
||||
|
||||
function openReview(row: any) {
|
||||
reviewRow.value = row
|
||||
reviewAction.value = ''
|
||||
reviewName.value = row.real_name || ''
|
||||
reviewPhone.value = row.phone || ''
|
||||
reviewPosition.value = row.position || ''
|
||||
reviewEmployeeType.value = row.role || 'employee'
|
||||
reviewDeptId.value = row.department_id || row.department?.id || null
|
||||
reviewComment.value = ''
|
||||
reviewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!reviewDeptId.value) {
|
||||
ElMessage.warning('请选择部门')
|
||||
return
|
||||
}
|
||||
reviewing.value = true
|
||||
try {
|
||||
await auditCertification(reviewRow.value.id, {
|
||||
status: 1,
|
||||
real_name: reviewName.value || '',
|
||||
phone: reviewPhone.value || '',
|
||||
department_id: reviewDeptId.value,
|
||||
position: reviewPosition.value || '',
|
||||
role: reviewEmployeeType.value,
|
||||
comment: reviewComment.value || '',
|
||||
})
|
||||
ElMessage.success('审核通过')
|
||||
reviewVisible.value = false
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '审核失败')
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
reviewing.value = true
|
||||
try {
|
||||
await auditCertification(reviewRow.value.id, {
|
||||
status: 2,
|
||||
comment: reviewComment.value || '管理员拒绝',
|
||||
})
|
||||
ElMessage.success('已拒绝')
|
||||
reviewVisible.value = false
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-dialog-body {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
211
mp-sc-admin/src/pages/Banners.vue
Normal file
211
mp-sc-admin/src/pages/Banners.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-lg font-bold text-gray-800">Banner 管理</h1>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>新增 Banner
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Banner List -->
|
||||
<div class="space-y-3">
|
||||
<div v-for="item in banners" :key="item.id"
|
||||
class="bg-white rounded-xl border border-gray-200 p-4 flex items-start gap-4 hover:shadow-sm transition-shadow">
|
||||
<!-- Image Preview -->
|
||||
<div class="w-56 h-28 shrink-0 rounded-lg overflow-hidden bg-gray-100 border">
|
||||
<img :src="item.image_url" class="w-full h-full object-cover" @error="onImgError($event)" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<el-tag size="small" :type="item.is_valid ? 'success' : 'danger'">
|
||||
{{ item.is_valid ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.is_jump" size="small" type="warning">可跳转</el-tag>
|
||||
<el-tag size="small" type="info">排序: {{ item.sort }}</el-tag>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 truncate mb-1">
|
||||
<span class="text-gray-400">图片:</span> {{ item.image_url }}
|
||||
</div>
|
||||
<div v-if="item.is_jump && item.jump_path" class="text-sm text-gray-500 truncate">
|
||||
<span class="text-gray-400">跳转:</span> {{ item.jump_path }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1">创建时间: {{ item.created_at }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<el-button size="small" @click="openEdit(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-if="banners.length === 0" class="bg-white rounded-xl border border-gray-200 p-12 text-center text-gray-400">
|
||||
<div class="i-carbon-image text-5xl mx-auto mb-3 opacity-50" />
|
||||
<p>暂无 Banner,点击右上角「新增 Banner」添加</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑 Banner' : '新增 Banner'" width="520px" destroy-on-close>
|
||||
<el-form :model="form" label-width="100px" size="small">
|
||||
<!-- Image URL -->
|
||||
<el-form-item label="图片地址" required>
|
||||
<el-input v-model="form.image_url" placeholder="输入图片URL或上传图片" class="mb-2" />
|
||||
<div class="flex gap-2">
|
||||
<el-upload :show-file-list="false" :auto-upload="false" accept="image/*" @change="handleUpload">
|
||||
<el-button size="small">选择图片</el-button>
|
||||
</el-upload>
|
||||
<span v-if="form.image_url" class="text-xs text-gray-400 self-center ml-1">已选择: {{ previewName }}</span>
|
||||
</div>
|
||||
<img v-if="form.image_url" :src="form.image_url" class="mt-2 w-full h-24 object-cover rounded border" @error="onImgError($event)" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- Jump Settings -->
|
||||
<el-form-item label="可跳转">
|
||||
<el-switch v-model="form.is_jump" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.is_jump" label="跳转链接">
|
||||
<el-input v-model="form.jump_path" placeholder="输入跳转URL" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- Sort -->
|
||||
<el-form-item label="排序号">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- Valid -->
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.is_valid" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getBanners, createBanner, updateBanner, deleteBanner, uploadImage } from '@/api'
|
||||
|
||||
const banners = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const saving = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewName = ref('')
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
image_url: '',
|
||||
jump_path: '',
|
||||
is_jump: false,
|
||||
sort: 0,
|
||||
is_valid: true,
|
||||
})
|
||||
|
||||
async function loadBanners() {
|
||||
const res: any = await getBanners()
|
||||
banners.value = res?.data || []
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
isEditing.value = false
|
||||
form.id = 0
|
||||
form.image_url = ''
|
||||
form.jump_path = ''
|
||||
form.is_jump = false
|
||||
form.sort = 0
|
||||
form.is_valid = true
|
||||
previewUrl.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
isEditing.value = true
|
||||
form.id = item.id
|
||||
form.image_url = item.image_url
|
||||
form.jump_path = item.jump_path || ''
|
||||
form.is_jump = item.is_jump
|
||||
form.sort = item.sort
|
||||
form.is_valid = item.is_valid
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleUpload(uploadFile: any) {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
previewName.value = file.name
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await uploadImage(file)
|
||||
const url = res?.data?.url || res?.url || ''
|
||||
if (url) {
|
||||
form.image_url = url
|
||||
} else {
|
||||
ElMessage.error('上传失败:响应中未找到图片 URL')
|
||||
}
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.image_url) {
|
||||
ElMessage.warning('请填写图片地址')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await updateBanner(form.id, {
|
||||
image_url: form.image_url,
|
||||
jump_path: form.jump_path,
|
||||
is_jump: form.is_jump,
|
||||
sort: form.sort,
|
||||
is_valid: form.is_valid,
|
||||
})
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createBanner({
|
||||
image_url: form.image_url,
|
||||
jump_path: form.jump_path,
|
||||
is_jump: form.is_jump,
|
||||
sort: form.sort,
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadBanners()
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该 Banner?', '删除确认', { type: 'warning' })
|
||||
await deleteBanner(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadBanners()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect fill="%23f3f4f6" width="200" height="100"/><text x="100" y="55" text-anchor="middle" fill="%239ca3af" font-size="12">加载失败</text></svg>'
|
||||
}
|
||||
|
||||
onMounted(loadBanners)
|
||||
</script>
|
||||
33
mp-sc-admin/src/pages/Dashboard.vue
Normal file
33
mp-sc-admin/src/pages/Dashboard.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">概览</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div v-for="s in stats" :key="s.label" class="bg-white rounded-xl p-5 shadow-sm border">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ s.value }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getUsers, getCertifications } from '@/api'
|
||||
|
||||
const stats = ref([
|
||||
{ label: '用户总数', value: 0 },
|
||||
{ label: '待审核认证', value: 0 },
|
||||
{ label: '今日预约', value: '-' },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [users, certs] = await Promise.all([
|
||||
getUsers({ page: 1, page_size: 1 }),
|
||||
getCertifications({ status: 0 }),
|
||||
])
|
||||
stats.value[0].value = users?.data?.total || 0
|
||||
stats.value[1].value = certs?.data?.total || 0
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
453
mp-sc-admin/src/pages/Departments.vue
Normal file
453
mp-sc-admin/src/pages/Departments.vue
Normal file
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold text-gray-800">组织架构</h1>
|
||||
<el-button type="primary" size="small" @click="openAddDept(null)">+ 新增根部门</el-button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<el-input v-model="search" placeholder="搜索部门名称" clearable class="max-w-xs" size="small" @clear="onSearch" @input="onSearch" />
|
||||
<el-button size="small" @click="loadData">
|
||||
<template #icon><span class="i-carbon-refresh" /></template>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 部门表格(可展开行) -->
|
||||
<div v-loading="loading">
|
||||
<el-table ref="tableRef" :data="flatDepts" row-key="id" border stripe class="w-full" @expand-change="onExpandChange" @row-click="onRowClick">
|
||||
<el-table-column type="expand" width="50">
|
||||
<template #default="{ row }">
|
||||
<!-- 展开后显示该部门的员工列表(迷你表格) -->
|
||||
<div class="px-6 py-4" v-if="row._empList && row._empList.length">
|
||||
<el-table :data="row._empList" size="small" stripe class="w-full">
|
||||
<el-table-column label="姓名" min-width="100">
|
||||
<template #default="{ row: emp }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-carbon-user text-blue-400 text-sm shrink-0" />
|
||||
<span class="text-gray-700 font-medium">{{ emp.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="职位" min-width="100">
|
||||
<template #default="{ row: emp }">
|
||||
<span class="text-gray-500">{{ emp.position || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="员工类型" min-width="110">
|
||||
<template #default="{ row: emp }">
|
||||
<el-tag v-if="emp.employeeType === 'department_leader'" size="small" type="warning">部门负责人</el-tag>
|
||||
<el-tag v-else-if="emp.employeeType === 'deputy_leader'" size="small">副负责人</el-tag>
|
||||
<el-tag v-else size="small" type="info">{{ emp.employeeTypeLabel || '员工' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row: emp }">
|
||||
<el-button size="small" type="danger" link @click="handleEmpRemove(emp)">移出</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else class="px-6 py-3 text-sm text-gray-400">暂无员工</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="部门名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-2" :style="{ paddingLeft: row._depth * 24 + 'px' }">
|
||||
<span class="i-carbon-folder text-yellow-500 text-sm shrink-0" />
|
||||
<span class="font-medium text-gray-700">{{ row.name }}</span>
|
||||
<span class="text-gray-400 text-xs">({{ row._empCount }}人)</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" min-width="340">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<el-button size="small" type="primary" link @click="openAddDept(row)">新增子部门</el-button>
|
||||
<el-button size="small" type="primary" link @click="openEmpManage(row)">管理员工</el-button>
|
||||
<el-button size="small" type="primary" link @click="openEditDept(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDeleteDept(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!flatDepts.length && !loading" class="text-center text-gray-400 py-12">暂无部门数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑部门对话框 -->
|
||||
<el-dialog v-model="deptDialogVisible" :title="deptEditId ? '编辑部门' : '新增部门'" width="450px" :close-on-click-modal="false">
|
||||
<el-form :model="deptForm" label-width="100px">
|
||||
<el-form-item label="部门名称">
|
||||
<el-input v-model="deptForm.name" placeholder="请输入部门名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="deptForm.parent_id" label="上级部门">
|
||||
<span class="text-gray-500">{{ getDeptName(deptForm.parent_id) }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="deptDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="deptSaving" @click="handleSaveDept">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============ 员工管理对话框 ============ -->
|
||||
<el-dialog v-model="empManageVisible" :title="'管理员工 — ' + (empManageDept?.name || '')" width="500px" :close-on-click-modal="false">
|
||||
<div v-loading="empManageLoading">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">当前部门员工</div>
|
||||
<div v-if="empManageList.length === 0" class="text-gray-400 text-sm py-3">暂无员工</div>
|
||||
<div v-for="emp in empManageList" :key="emp.id"
|
||||
class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50">
|
||||
<div>
|
||||
<span class="text-sm text-gray-700">{{ emp.real_name || emp.nickname || emp.name }}</span>
|
||||
<span v-if="emp.position" class="text-xs text-gray-400 ml-2">{{ emp.position }}</span>
|
||||
</div>
|
||||
<el-button size="small" type="danger" plain @click="handleEmpRemove(emp)">移出</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">添加员工</div>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1 space-y-2">
|
||||
<el-select v-model="empManageNewId" filterable placeholder="选择员工添加" class="w-full" size="default">
|
||||
<el-option
|
||||
v-for="e in availableEmployees"
|
||||
:key="e.id"
|
||||
:label="(e.real_name || e.nickname || e.name) + (e.position ? '(' + e.position + ')' : '')"
|
||||
:value="e.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input v-model="empManageNewPosition" placeholder="职位(如:前端工程师)" size="default" />
|
||||
</div>
|
||||
<el-button type="primary" :disabled="!empManageNewId" :loading="empManageAdding" @click="handleEmpAdd" class="mt-0">添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="empManageVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getDepartmentTree,
|
||||
createDepartment,
|
||||
updateDepartment,
|
||||
deleteDepartment,
|
||||
getEmployees,
|
||||
getEmployeeDepartments,
|
||||
setEmployeeDepartment,
|
||||
clearEmployeeDepartment,
|
||||
} from '@/api'
|
||||
|
||||
// ============ 状态 ============
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const tableRef = ref<any>(null)
|
||||
|
||||
/** 平铺后的部门列表(含层级 depth) */
|
||||
const flatDepts = ref<any[]>([])
|
||||
|
||||
// 部门表单
|
||||
const deptDialogVisible = ref(false)
|
||||
const deptEditId = ref<number | null>(null)
|
||||
const deptSaving = ref(false)
|
||||
const deptForm = ref<{ name: string; parent_id: number | null }>({
|
||||
name: '',
|
||||
parent_id: null,
|
||||
})
|
||||
|
||||
// 原始数据缓存
|
||||
let rawTree: any[] = []
|
||||
let flatDeptMap = new Map<number, any>()
|
||||
let allEmployees: any[] = []
|
||||
|
||||
// ============ 数据加载 ============
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
|
||||
const [treeRes, empRes, mappingRes] = await Promise.all([
|
||||
getDepartmentTree().catch(() => null),
|
||||
getEmployees().catch(() => null),
|
||||
getEmployeeDepartments().catch(() => null),
|
||||
])
|
||||
|
||||
rawTree = (treeRes as any)?.data || []
|
||||
|
||||
const empList: any[] = (empRes as any)?.data?.list || (empRes as any)?.data || []
|
||||
const empInfoMap: Record<number, any> = {}
|
||||
for (const e of empList) {
|
||||
empInfoMap[e.id || e.user_id] = e
|
||||
}
|
||||
|
||||
const mappingList: any[] = (mappingRes as any)?.data?.list || (mappingRes as any)?.data || []
|
||||
|
||||
// 关联表映射:user_id → { department_id, department_name }
|
||||
const mappingByUser: Record<number, any> = {}
|
||||
for (const m of mappingList) {
|
||||
mappingByUser[m.user_id] = m
|
||||
}
|
||||
|
||||
// 所有员工(含未分配部门的),department_id/position 优先从关联表取
|
||||
allEmployees = empList.map((e: any) => {
|
||||
const uid = e.id || e.user_id
|
||||
const map = mappingByUser[uid]
|
||||
return {
|
||||
id: uid,
|
||||
real_name: e.name || e.real_name || '',
|
||||
name: e.name || e.real_name || '',
|
||||
position: map?.position || e.position || '',
|
||||
employee_type: e.employee_type || '',
|
||||
department_id: map?.department_id ?? null,
|
||||
department_name: map?.department_name || '',
|
||||
}
|
||||
})
|
||||
|
||||
rebuildFlatList()
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
/** 将部门树平铺为一维数组,附带 depth 和员工列表 */
|
||||
function rebuildFlatList() {
|
||||
flatDeptMap.clear()
|
||||
const result: any[] = []
|
||||
|
||||
function flatten(nodes: any[], depth: number) {
|
||||
for (const dept of nodes) {
|
||||
flatDeptMap.set(dept.id, dept)
|
||||
|
||||
// 该部门的直属员工
|
||||
const empList = allEmployees
|
||||
.filter((e) => e.department_id === dept.id)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.real_name || e.nickname || e.name || `#${e.id}`,
|
||||
position: e.position || '',
|
||||
employeeType: e.employee_type || '',
|
||||
employeeTypeLabel: e.employee_type === 'department_leader' ? '部门负责人'
|
||||
: e.employee_type === 'deputy_leader' ? '副负责人'
|
||||
: e.employee_type === 'vice_president' ? '副总裁'
|
||||
: e.employee_type === 'general_manager' ? '总经理'
|
||||
: '员工',
|
||||
}))
|
||||
|
||||
// 子孙部门员工总数
|
||||
function countSub(n: any[]): number {
|
||||
let c = 0
|
||||
for (const d of n) {
|
||||
c += allEmployees.filter((e) => e.department_id === d.id).length
|
||||
if (d.children?.length) c += countSub(d.children)
|
||||
}
|
||||
return c
|
||||
}
|
||||
const subCount = dept.children?.length ? countSub(dept.children) : 0
|
||||
const empCount = empList.length + subCount
|
||||
|
||||
result.push({
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
parent_id: dept.parent_id,
|
||||
_depth: depth,
|
||||
_empList: empList,
|
||||
_empCount: empCount,
|
||||
_children: dept.children || [],
|
||||
})
|
||||
|
||||
if (dept.children?.length) {
|
||||
flatten(dept.children, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatten(rawTree, 0)
|
||||
flatDepts.value = result
|
||||
}
|
||||
|
||||
// ============ 搜索 ============
|
||||
|
||||
function onSearch() {
|
||||
// 简单的前端过滤:搜索时只显示名称匹配的部门
|
||||
if (!search.value) {
|
||||
rebuildFlatList()
|
||||
return
|
||||
}
|
||||
const kw = search.value.toLowerCase()
|
||||
flatDepts.value = flatDepts.value.filter((d) => d.name.toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
/** 展开/收起某行时 */
|
||||
function onExpandChange(row: any, expanded: boolean) {
|
||||
// 暂不需要额外逻辑
|
||||
}
|
||||
|
||||
/** 点击行切换展开/折叠(点击操作按钮时不触发) */
|
||||
function onRowClick(row: any, _column: any, event: Event) {
|
||||
// 如果点击目标是按钮或按钮内的元素,不切换
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.el-button, .el-button *')) return
|
||||
tableRef.value?.toggleRowExpansion(row)
|
||||
}
|
||||
|
||||
// ============ 部门名称查询 ============
|
||||
|
||||
function getDeptName(id: number): string {
|
||||
const dept = flatDeptMap.get(id)
|
||||
return dept?.name || '(已删除)'
|
||||
}
|
||||
|
||||
/** 获取原始树节点(用于删除判断子部门) */
|
||||
function getRawNode(id: number): any {
|
||||
function find(nodes: any[]): any {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n
|
||||
if (n.children?.length) {
|
||||
const found = find(n.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return find(rawTree)
|
||||
}
|
||||
|
||||
// ============ 新增/编辑部门 ============
|
||||
|
||||
function openAddDept(parent: any) {
|
||||
deptEditId.value = null
|
||||
deptForm.value = {
|
||||
name: '',
|
||||
parent_id: parent?.id ?? null,
|
||||
}
|
||||
deptDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDept(data: any) {
|
||||
deptEditId.value = data.id
|
||||
deptForm.value = {
|
||||
name: data.name || '',
|
||||
parent_id: data.parent_id ?? null,
|
||||
}
|
||||
deptDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSaveDept() {
|
||||
if (!deptForm.value.name.trim()) {
|
||||
ElMessage.warning('请输入部门名称')
|
||||
return
|
||||
}
|
||||
|
||||
deptSaving.value = true
|
||||
try {
|
||||
if (deptEditId.value) {
|
||||
await updateDepartment(deptEditId.value, {
|
||||
name: deptForm.value.name.trim(),
|
||||
parent_id: deptForm.value.parent_id,
|
||||
})
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await createDepartment({
|
||||
name: deptForm.value.name.trim(),
|
||||
parent_id: deptForm.value.parent_id,
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
deptDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
deptSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 删除部门 ============
|
||||
|
||||
async function handleDeleteDept(data: any) {
|
||||
const raw = getRawNode(data.id)
|
||||
const hasChildren = raw?.children?.length > 0
|
||||
const hasEmployees = data._empCount > 0
|
||||
|
||||
let msg = `确定删除部门「${data.name}」?`
|
||||
if (hasChildren) msg += ' 其子部门也将一并删除。'
|
||||
if (hasEmployees) msg += ' 该部门下的员工将被移除关联。'
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(msg, '确认删除', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteDepartment(data.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
} catch {
|
||||
// 取消则不处理
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 员工管理 ============
|
||||
|
||||
const empManageVisible = ref(false)
|
||||
const empManageLoading = ref(false)
|
||||
const empManageAdding = ref(false)
|
||||
const empManageDept = ref<any>(null)
|
||||
const empManageList = ref<any[]>([])
|
||||
const empManageNewId = ref<number | null>(null)
|
||||
const empManageNewPosition = ref('')
|
||||
|
||||
const availableEmployees = computed(() =>
|
||||
allEmployees.filter((e) => e.department_id !== empManageDept.value?.id),
|
||||
)
|
||||
|
||||
function openEmpManage(dept: any) {
|
||||
empManageDept.value = dept
|
||||
empManageList.value = allEmployees.filter((e) => e.department_id === dept.id)
|
||||
empManageNewId.value = null
|
||||
empManageNewPosition.value = ''
|
||||
empManageVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEmpAdd() {
|
||||
if (!empManageNewId.value || !empManageDept.value) return
|
||||
empManageAdding.value = true
|
||||
try {
|
||||
await setEmployeeDepartment(empManageNewId.value, empManageDept.value.id, empManageNewPosition.value || undefined)
|
||||
ElMessage.success('添加成功')
|
||||
empManageNewId.value = null
|
||||
empManageNewPosition.value = ''
|
||||
await loadData()
|
||||
empManageList.value = allEmployees.filter((e) => e.department_id === empManageDept.value.id)
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '添加失败')
|
||||
} finally {
|
||||
empManageAdding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmpRemove(emp: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将 ${emp.real_name || emp.nickname || emp.name} 移出该部门?`, '确认')
|
||||
await clearEmployeeDepartment(emp.id)
|
||||
ElMessage.success('已移出')
|
||||
await loadData()
|
||||
empManageList.value = allEmployees.filter((e) => e.department_id === empManageDept.value?.id)
|
||||
} catch {
|
||||
// 取消则不处理
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 初始化 ============
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
114
mp-sc-admin/src/pages/Layout.vue
Normal file
114
mp-sc-admin/src/pages/Layout.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="h-screen flex bg-gray-50">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-56 bg-white border-r border-gray-200 flex flex-col shrink-0 shadow-sm">
|
||||
<!-- Logo -->
|
||||
<div class="h-16 flex items-center gap-2.5 px-5 border-b border-gray-100">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
|
||||
<span class="text-white font-bold text-sm">凌</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-800">凌空天行</div>
|
||||
<div class="text-[10px] text-gray-400">后台管理系统</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
|
||||
<div v-for="group in menuGroups" :key="group.label">
|
||||
<div class="text-[11px] text-gray-400 font-medium px-3 py-2 uppercase tracking-wider">{{ group.label }}</div>
|
||||
<router-link v-for="item in group.items" :key="item.path" :to="item.path"
|
||||
class="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-200"
|
||||
:class="$route.path === item.path
|
||||
? 'bg-blue-50 text-blue-700 font-medium shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'">
|
||||
<div :class="item.icon" class="text-lg shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="p-4 border-t border-gray-100">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-500">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<span class="text-blue-600 font-medium text-xs">{{ adminName.charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-gray-700 text-sm truncate">{{ adminName }}</div>
|
||||
<div class="text-gray-400 text-xs">管理员</div>
|
||||
</div>
|
||||
<button class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition" @click="handleLogout" title="退出登录">
|
||||
<div class="i-carbon-logout text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shrink-0">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span class="text-gray-400">/</span>
|
||||
<span class="text-gray-700 font-medium">{{ $route.meta.title }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-1 overflow-auto p-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const adminName = ref(localStorage.getItem('admin_name') || '管理员')
|
||||
|
||||
const menuGroups = [
|
||||
{
|
||||
label: '系统管理',
|
||||
items: [
|
||||
{ path: '/dashboard', label: '工作台', icon: 'i-carbon-dashboard' },
|
||||
{ path: '/users', label: '用户管理', icon: 'i-carbon-user-multiple' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '业务管理',
|
||||
items: [
|
||||
{ path: '/approvals', label: '认证审核', icon: 'i-carbon-certificate-check' },
|
||||
{ path: '/departments', label: '组织架构', icon: 'i-carbon-tree-view-alt' },
|
||||
{ path: '/pending-employees', label: '通讯录导入', icon: 'i-carbon-import-export' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '访客模块',
|
||||
items: [
|
||||
{ path: '/appointments', label: '预约管理', icon: 'i-carbon-calendar' },
|
||||
{ path: '/visit-types', label: '来访目的', icon: 'i-carbon-list-dropdown' },
|
||||
{ path: '/visitor-areas', label: '到访区域', icon: 'i-carbon-location' },
|
||||
{ path: '/workflow-designer', label: '流程设计器', icon: 'i-carbon-diagram' },
|
||||
{ path: '/config', label: '审批配置', icon: 'i-carbon-settings-adjust' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '内容管理',
|
||||
items: [
|
||||
{ path: '/banners', label: 'Banner管理', icon: 'i-carbon-image' },
|
||||
{ path: '/materials', label: '素材上传', icon: 'i-carbon-upload' },
|
||||
{ path: '/notices', label: '通知管理', icon: 'i-carbon-notification' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.clear()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
54
mp-sc-admin/src/pages/Login.vue
Normal file
54
mp-sc-admin/src/pages/Login.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<el-card class="w-[420px] shadow-xl rounded-2xl">
|
||||
<template #header>
|
||||
<div class="text-center">
|
||||
<div class="i-carbon-cloud-foundation text-5xl text-blue-500 mb-3" />
|
||||
<h2 class="text-xl font-bold text-gray-800">凌空天行后台管理</h2>
|
||||
<p class="text-sm text-gray-400 mt-1">请登录管理员账号</p>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="form" @submit.prevent="handleLogin" class="mt-2">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.account" placeholder="账号" size="large" :prefix-icon="iconUser" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password :prefix-icon="iconLock" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" size="large" class="w-full !bg-blue-500">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="text-center text-xs text-gray-400 mt-2">默认账号: admin / admin123</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const iconUser = User
|
||||
const iconLock = Lock
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const form = ref({ account: 'admin', password: 'admin123' })
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.Post('/v1/admin/login', form.value)
|
||||
localStorage.setItem('admin_token', res.data.token)
|
||||
localStorage.setItem('admin_name', res.data.name)
|
||||
router.push('/dashboard')
|
||||
} catch {
|
||||
// handled by alova interceptor
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
192
mp-sc-admin/src/pages/Materials.vue
Normal file
192
mp-sc-admin/src/pages/Materials.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-lg font-bold text-gray-800">素材上传(微信)</h1>
|
||||
</div>
|
||||
|
||||
<!-- 上传方式切换 -->
|
||||
<el-card class="mb-4">
|
||||
<div class="flex gap-4 mb-4">
|
||||
<el-radio-group v-model="uploadMode">
|
||||
<el-radio value="file">选择文件上传</el-radio>
|
||||
<el-radio value="url">通过链接上传</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传模式 -->
|
||||
<template v-if="uploadMode === 'file'">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
accept="image/*"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="flex flex-col items-center py-4">
|
||||
<div class="i-carbon-cloud-upload text-4xl text-gray-400 mb-2" />
|
||||
<div class="text-sm text-gray-500">将图片拖到此处,或<em class="text-blue-600">点击选择</em></div>
|
||||
<div class="text-xs text-gray-400 mt-1">支持 JPG / PNG / GIF 格式</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="selectedFile" class="mt-3 flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">已选择: {{ selectedFile.name }}</span>
|
||||
<el-button type="primary" size="small" :loading="uploading" @click="handleFileUpload">
|
||||
开始上传
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 链接上传模式 -->
|
||||
<template v-if="uploadMode === 'url'">
|
||||
<div class="flex gap-2">
|
||||
<el-input
|
||||
v-model="imageLink"
|
||||
placeholder="请输入图片链接地址,例如 https://example.com/image.jpg"
|
||||
clearable
|
||||
class="flex-1"
|
||||
/>
|
||||
<el-button type="primary" :loading="uploading" @click="handleUrlUpload">
|
||||
上传
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<!-- 上传结果 -->
|
||||
<el-card v-if="uploadedUrl" class="mb-4">
|
||||
<template #header>
|
||||
<span class="text-sm font-medium">上传结果</span>
|
||||
</template>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg overflow-hidden bg-gray-100 border">
|
||||
<img :src="uploadedUrl" class="w-full h-full object-cover" @error="onImgError" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-gray-400 mb-1">微信图片 URL:</div>
|
||||
<div class="text-sm text-blue-600 break-all mb-2">{{ uploadedUrl }}</div>
|
||||
<el-button size="small" type="warning" @click="copyUrl">复制链接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 上传记录 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span class="text-sm font-medium">上传记录</span>
|
||||
</template>
|
||||
<div v-if="history.length === 0" class="text-center text-gray-400 py-6 text-sm">
|
||||
暂无上传记录
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="(item, index) in history" :key="index"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div class="w-10 h-10 shrink-0 rounded overflow-hidden bg-gray-100 border">
|
||||
<img :src="item.url" class="w-full h-full object-cover" @error="onImgError" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-gray-500 truncate">{{ item.url }}</div>
|
||||
<div class="text-xs text-gray-400">{{ item.time }}</div>
|
||||
</div>
|
||||
<el-button size="small" text type="primary" @click="copyText(item.url)">复制</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadImage, uploadImageByURL } from '@/api'
|
||||
|
||||
const uploadMode = ref<'file' | 'url'>('file')
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const imageLink = ref('')
|
||||
const uploading = ref(false)
|
||||
const uploadedUrl = ref('')
|
||||
|
||||
interface HistoryItem {
|
||||
url: string
|
||||
time: string
|
||||
}
|
||||
const history = ref<HistoryItem[]>([])
|
||||
|
||||
function handleFileChange(uploadFile: any) {
|
||||
const file = uploadFile.raw as File
|
||||
if (!file) return
|
||||
selectedFile.value = file
|
||||
}
|
||||
|
||||
async function handleFileUpload() {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请先选择图片文件')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
uploadedUrl.value = ''
|
||||
try {
|
||||
const res: any = await uploadImage(selectedFile.value)
|
||||
const url = res?.data?.url || res?.url || ''
|
||||
if (url) {
|
||||
uploadedUrl.value = url
|
||||
addHistory(url)
|
||||
ElMessage.success('上传成功')
|
||||
} else {
|
||||
ElMessage.error('上传失败:响应中未找到图片 URL')
|
||||
}
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUrlUpload() {
|
||||
if (!imageLink.value.trim()) {
|
||||
ElMessage.warning('请输入图片链接')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
uploadedUrl.value = ''
|
||||
try {
|
||||
const res: any = await uploadImageByURL(imageLink.value.trim())
|
||||
const url = res?.data?.url || res?.url || ''
|
||||
if (url) {
|
||||
uploadedUrl.value = url
|
||||
addHistory(url)
|
||||
ElMessage.success('上传成功')
|
||||
imageLink.value = ''
|
||||
} else {
|
||||
ElMessage.error('上传失败:响应中未找到图片 URL')
|
||||
}
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addHistory(url: string) {
|
||||
history.value.unshift({
|
||||
url,
|
||||
time: new Date().toLocaleString(),
|
||||
})
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
copyText(uploadedUrl.value)
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect fill="%23f3f4f6" width="200" height="100"/><text x="100" y="55" text-anchor="middle" fill="%239ca3af" font-size="12">加载失败</text></svg>'
|
||||
}
|
||||
</script>
|
||||
6
mp-sc-admin/src/pages/Notices.vue
Normal file
6
mp-sc-admin/src/pages/Notices.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">通知管理</h1>
|
||||
<p class="text-gray-500">系统通知管理(开发中)</p>
|
||||
</div>
|
||||
</template>
|
||||
254
mp-sc-admin/src/pages/PendingEmployees.vue
Normal file
254
mp-sc-admin/src/pages/PendingEmployees.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">通讯录导入</h1>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ stats.total }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">总计导入</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-3xl font-bold text-amber-500">{{ stats.pending }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">待匹配</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-3xl font-bold text-green-500">{{ stats.matched }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">已匹配</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 导入工具条 -->
|
||||
<el-card shadow="never" class="mb-4">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style="display:none"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
<el-button type="success" :loading="uploading" @click="fileInputRef?.click()">
|
||||
{{ uploading ? '导入中...' : '上传 Excel 导入' }}
|
||||
</el-button>
|
||||
<span class="text-xs text-gray-400 ml-2">支持 .xlsx / .xls 格式,自动提取四川凌空员工</span>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 搜索/筛选栏 -->
|
||||
<el-card shadow="never" class="mb-4">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col :span="7">
|
||||
<el-input v-model="keyword" placeholder="搜索姓名/手机号" clearable />
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-select v-model="deptFilter" filterable placeholder="选择部门" clearable class="w-full" @change="doSearch">
|
||||
<el-option v-for="d in departments" :key="d.id" :label="d.name" :value="d.id" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable class="w-full" @change="doSearch">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待匹配" value="pending" />
|
||||
<el-option label="已匹配" value="matched" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-button type="primary" @click="doSearch">搜索</el-button>
|
||||
</el-col>
|
||||
<el-col :span="4" :push="2">
|
||||
<el-button type="warning" plain :loading="syncingAll" @click="handleSyncAll">一键同步</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 导入记录 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-4 py-3 border-b">
|
||||
<h2 class="text-base font-semibold text-gray-700">导入记录</h2>
|
||||
</div>
|
||||
<el-table :data="list" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="real_name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="140" />
|
||||
<el-table-column prop="department_name" label="部门" min-width="140" />
|
||||
<el-table-column prop="position" label="职位" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.position || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dingtalk_user_id" label="钉钉UserId" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600">{{ row.dingtalk_user_id || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_matched" type="success" size="small">已匹配</el-tag>
|
||||
<el-tag v-else type="warning" size="small">待匹配</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="匹配用户" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.matched_user_id" class="text-sm">#{{ row.matched_user_id }}</span>
|
||||
<span v-else class="text-gray-400 text-sm">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="matched_at" label="匹配时间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-500">{{ row.matched_at ? dayjs(row.matched_at).format('YYYY-MM-DD HH:mm') : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="matchingId === row.id"
|
||||
@click="handleMatch(row)"
|
||||
>{{ row.is_matched ? '重新同步' : '手动匹配' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex justify-center p-4" v-if="total > pageSize">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[15, 30, 50, 100]"
|
||||
:total="total"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadData"
|
||||
@size-change="loadData(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getPendingEmployees, matchPendingEmployee, getPendingEmployeeStats, syncAllPendingEmployees, listAllDepartments } from '@/api'
|
||||
import request from '@/utils/request'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const statusFilter = ref('')
|
||||
const keyword = ref('')
|
||||
const deptFilter = ref<number | undefined>(undefined)
|
||||
const departments = ref<any[]>([])
|
||||
const stats = ref({ total: 0, pending: 0, matched: 0 })
|
||||
const matchingId = ref<number | null>(null)
|
||||
const syncingAll = ref(false)
|
||||
const uploading = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadData(reset = false) {
|
||||
// reset 可能是布尔值或页码数字(来自 pagination 事件)
|
||||
if (reset === true) {
|
||||
page.value = 1
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPendingEmployees({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: statusFilter.value || undefined,
|
||||
keyword: keyword.value || undefined,
|
||||
department_id: deptFilter.value || undefined,
|
||||
})
|
||||
const data = res as any
|
||||
list.value = data?.data?.list || []
|
||||
total.value = data?.data?.total || 0
|
||||
} catch {
|
||||
// request.ts 已自动弹出错误
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await getPendingEmployeeStats() as any
|
||||
stats.value = res?.data || { total: 0, pending: 0, matched: 0 }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
loadData(true)
|
||||
}
|
||||
|
||||
async function handleSyncAll() {
|
||||
await ElMessageBox.confirm('将一键同步所有待确认员工的信息到已绑定手机号的用户,确定执行?', '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定同步',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
syncingAll.value = true
|
||||
try {
|
||||
const res = await syncAllPendingEmployees() as any
|
||||
const d = res?.data
|
||||
ElMessage.success(`同步完成:成功 ${d?.synced || 0} 人,跳过 ${d?.skipped || 0} 人(共 ${d?.total || 0} 人)`)
|
||||
await Promise.all([loadData(), loadStats()])
|
||||
} catch {
|
||||
// request.ts 已自动弹出错误
|
||||
} finally {
|
||||
syncingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await request.Post('/v1/admin/employees/import-excel', formData) as any
|
||||
const d = res?.data || {}
|
||||
ElMessage.success(`导入完成:成功 ${d.imported || 0} 人,更新 ${d.updated || 0} 人,未匹配部门 ${d.noDept || 0} 人`)
|
||||
await Promise.all([loadData(true), loadStats()])
|
||||
} catch {
|
||||
// request.ts 已自动提示
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = '' // 清空 input 以便重新选择同一个文件
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMatch(row: any) {
|
||||
matchingId.value = row.id
|
||||
try {
|
||||
const res = await matchPendingEmployee(row.id) as any
|
||||
ElMessage.success(`匹配成功:${res?.data?.real_name || ''}(${res?.data?.phone || ''})`)
|
||||
await Promise.all([loadData(), loadStats()])
|
||||
} catch {
|
||||
// request.ts 已自动弹出错误提示
|
||||
} finally {
|
||||
matchingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const deptRes = await listAllDepartments()
|
||||
departments.value = (deptRes as any)?.data || []
|
||||
loadData()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
288
mp-sc-admin/src/pages/Users.vue
Normal file
288
mp-sc-admin/src/pages/Users.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">用户管理</h1>
|
||||
<el-card shadow="never" class="mb-3">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col :span="7">
|
||||
<el-input v-model="keyword" placeholder="搜索姓名/昵称/手机号" clearable @clear="fetchData" @keyup.enter="fetchData" />
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="roleFilter" placeholder="角色" clearable class="w-full" @change="fetchData">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="访客" value="visitor" />
|
||||
<el-option label="员工" value="employee" />
|
||||
<el-option label="保安" value="guard" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable class="w-full" @change="fetchData">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-button type="primary" @click="fetchData">搜索</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="users" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column label="头像" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-avatar v-if="row.avatar_url" :src="row.avatar_url" :size="32" />
|
||||
<el-avatar v-else :size="32">{{ (row.real_name || row.nickname || '?').charAt(0) }}</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="姓名">
|
||||
<template #default="{ row }">{{ row.real_name || row.nickname || '未命名' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.role === 'employee'" type="success" size="small">员工</el-tag>
|
||||
<el-tag v-else-if="row.role === 'guard'" type="warning" size="small">保安</el-tag>
|
||||
<el-tag v-else size="small" type="info">{{ row.role_name || '访客' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status===1?'success':'danger'" size="small">{{ row.status===1?'正常':'禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属部门" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ getUserDept(row.id) || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="p-4 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total,sizes,prev,pager,next"
|
||||
@current-change="fetchData"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" title="编辑用户" width="500px" :close-on-click-modal="false" @closed="onDialogClosed">
|
||||
<el-form v-if="editForm" ref="formRef" :model="editForm" label-width="100px">
|
||||
<el-form-item label="用户ID">
|
||||
<span class="text-gray-500">{{ editForm.id }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="头像">
|
||||
<el-avatar v-if="editForm.avatar_url" :src="editForm.avatar_url" :size="48" />
|
||||
<el-avatar v-else :size="48">{{ (editForm.real_name || editForm.nickname || '?').charAt(0) }}</el-avatar>
|
||||
</el-form-item>
|
||||
<el-form-item label="真实姓名" prop="real_name">
|
||||
<el-input v-model="editForm.real_name" placeholder="输入真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="editForm.nickname" placeholder="输入微信昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="editForm.phone" placeholder="输入手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="editForm.status" :active-value="1" :inactive-value="0" active-text="正常" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-radio-group v-model="editForm.role">
|
||||
<el-radio value="visitor">访客</el-radio>
|
||||
<el-radio value="employee">员工</el-radio>
|
||||
<el-radio value="guard">保安</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="职位">
|
||||
<el-input v-model="editForm.position" placeholder="如:前端工程师" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属部门">
|
||||
<el-select v-model="editForm.department_id" filterable clearable placeholder="请选择部门" class="w-full">
|
||||
<el-option
|
||||
v-for="d in departments"
|
||||
:key="d.id"
|
||||
:label="d.name"
|
||||
:value="d.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getUsers, updateUser, listAllDepartments, setEmployeeDepartment, clearEmployeeDepartment, getEmployeeDepartments } from '@/api'
|
||||
|
||||
const users = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const roleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
// 编辑对话框
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editForm = ref<any>(null)
|
||||
|
||||
// 部门列表(用于下拉选择)
|
||||
const departments = ref<any[]>([])
|
||||
|
||||
// 员工-部门关联映射:userId -> { department_id, department_name }
|
||||
const deptMap = ref<Record<number, { department_id: number; department_name: string }>>({})
|
||||
|
||||
function getUserDept(userId: number): string {
|
||||
return deptMap.value[userId]?.department_name || ''
|
||||
}
|
||||
|
||||
function getUserDeptId(userId: number): number | null {
|
||||
return deptMap.value[userId]?.department_id ?? null
|
||||
}
|
||||
|
||||
function getUserPosition(userId: number): string {
|
||||
return deptMap.value[userId]?.position || ''
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
try {
|
||||
const res: any = await listAllDepartments()
|
||||
departments.value = res?.data || []
|
||||
} catch {
|
||||
// 部门列表非关键,静默失败
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeptMap() {
|
||||
try {
|
||||
const res: any = await getEmployeeDepartments()
|
||||
const list: any[] = res?.data?.list || res?.data || []
|
||||
const map: Record<number, { department_id: number; department_name: string; position: string }> = {}
|
||||
for (const item of list) {
|
||||
map[item.user_id] = {
|
||||
department_id: item.department_id,
|
||||
department_name: item.department_name,
|
||||
position: item.position || '',
|
||||
}
|
||||
}
|
||||
deptMap.value = map
|
||||
} catch {
|
||||
deptMap.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: page.value, page_size: pageSize.value }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (roleFilter.value) params.type = roleFilter.value
|
||||
if (statusFilter.value !== '') params.status = statusFilter.value
|
||||
|
||||
const [userRes] = await Promise.all([
|
||||
getUsers(params),
|
||||
loadDeptMap(),
|
||||
])
|
||||
users.value = (userRes as any)?.data?.list || []
|
||||
total.value = (userRes as any)?.data?.total || 0
|
||||
} catch {
|
||||
ElMessage.error('加载用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange(val: number) {
|
||||
pageSize.value = val
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editForm.value = {
|
||||
id: row.id,
|
||||
real_name: row.real_name || '',
|
||||
nickname: row.nickname || '',
|
||||
phone: row.phone || '',
|
||||
avatar_url: row.avatar_url || '',
|
||||
status: row.status,
|
||||
role: row.role || 'visitor',
|
||||
position: getUserPosition(row.id),
|
||||
_origDeptId: getUserDeptId(row.id),
|
||||
_origPosition: getUserPosition(row.id),
|
||||
department_id: getUserDeptId(row.id),
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onDialogClosed() {
|
||||
// 对话框关闭动画结束后清理
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editForm.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
// 1. 更新用户基本信息(不含部门)
|
||||
await updateUser(editForm.value.id, {
|
||||
real_name: editForm.value.real_name,
|
||||
nickname: editForm.value.nickname,
|
||||
phone: editForm.value.phone,
|
||||
status: editForm.value.status,
|
||||
role: editForm.value.role,
|
||||
})
|
||||
|
||||
// 2. 部门/职位变更走关联表接口
|
||||
const newDeptId = editForm.value.department_id
|
||||
const origDeptId = editForm.value._origDeptId
|
||||
const newPos = editForm.value.position
|
||||
const origPos = editForm.value._origPosition
|
||||
const deptChanged = newDeptId !== origDeptId
|
||||
const posChanged = newPos !== origPos
|
||||
|
||||
if (deptChanged || posChanged) {
|
||||
if (newDeptId) {
|
||||
await setEmployeeDepartment(editForm.value.id, newDeptId, newPos)
|
||||
} else {
|
||||
// 无部门:清除关联(职位也不存了)
|
||||
await clearEmployeeDepartment(editForm.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
page.value = 1
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
loadDepartments()
|
||||
})
|
||||
</script>
|
||||
122
mp-sc-admin/src/pages/VisitTypes.vue
Normal file
122
mp-sc-admin/src/pages/VisitTypes.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold text-gray-800">来访目的管理</h1>
|
||||
<el-button type="primary" size="small" @click="openAdd">+ 新增类型</el-button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="types" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="类型名称" min-width="160" />
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!types.length && !loading" class="text-center text-gray-400 py-12">暂无来访目的</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editId ? '编辑类型' : '新增类型'" width="420px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="类型名称">
|
||||
<el-input v-model="form.name" placeholder="如:政府" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editId" label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const types = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const form = ref({ name: '', sort: 0, status: 1 })
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.Get('/v1/admin/visit-types')
|
||||
types.value = res?.data || []
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editId.value = null
|
||||
form.value = { name: '', sort: 0, status: 1 }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editId.value = row.id
|
||||
form.value = { name: row.name, sort: row.sort, status: row.status }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入类型名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editId.value) {
|
||||
await request.Put(`/v1/admin/visit-types/${editId.value}`, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await request.Post('/v1/admin/visit-types', form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除类型「${row.name}」?`, '确认')
|
||||
await request.Delete(`/v1/admin/visit-types/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
122
mp-sc-admin/src/pages/VisitorAreas.vue
Normal file
122
mp-sc-admin/src/pages/VisitorAreas.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold text-gray-800">到访区域管理</h1>
|
||||
<el-button type="primary" size="small" @click="openAdd">+ 新增区域</el-button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="areas" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="区域名称" min-width="160" />
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!areas.length && !loading" class="text-center text-gray-400 py-12">暂无到访区域</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editId ? '编辑区域' : '新增区域'" width="420px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="区域名称">
|
||||
<el-input v-model="form.name" placeholder="如:A区域" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editId" label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const areas = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const form = ref({ name: '', sort: 0, status: 1 })
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.Get('/v1/admin/visitor-areas')
|
||||
areas.value = res?.data || []
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editId.value = null
|
||||
form.value = { name: '', sort: 0, status: 1 }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editId.value = row.id
|
||||
form.value = { name: row.name, sort: row.sort, status: row.status }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入区域名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editId.value) {
|
||||
await request.Put(`/v1/admin/visitor-areas/${editId.value}`, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await request.Post('/v1/admin/visitor-areas', form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除区域「${row.name}」?`, '确认')
|
||||
await request.Delete(`/v1/admin/visitor-areas/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
389
mp-sc-admin/src/pages/WorkflowDesigner.vue
Normal file
389
mp-sc-admin/src/pages/WorkflowDesigner.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">流程设计器</h1>
|
||||
|
||||
<!-- 流程定义列表 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-gray-700">流程定义</h2>
|
||||
<el-button type="primary" size="small" @click="openCreateDef">+ 新建流程</el-button>
|
||||
</div>
|
||||
<el-table :data="defList" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="process_name" label="流程名称" min-width="160" />
|
||||
<el-table-column prop="process_code" label="编码" min-width="140" />
|
||||
<el-table-column prop="source" label="来源" min-width="100" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openEditDef(row)">编辑节点</el-button>
|
||||
<el-button size="small" type="warning" link @click="toggleActive(row)">{{ row.is_active ? '禁用' : '启用' }}</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDeleteDef(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="defDialogVisible" :title="editingDef ? '编辑流程定义' : '新建流程定义'" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="流程名称">
|
||||
<el-input v-model="defForm.process_name" placeholder="如:访客预约审批" />
|
||||
</el-form-item>
|
||||
<el-form-item label="流程编码">
|
||||
<el-input v-model="defForm.process_code" placeholder="如:visitor_approval" :disabled="!!editingDef" />
|
||||
</el-form-item>
|
||||
<el-form-item label="来源">
|
||||
<el-input v-model="defForm.source" placeholder="如:访客系统" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="defForm.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="defDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="defSaving" @click="handleDefSave">保存基本信息</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 节点编辑器 -->
|
||||
<el-drawer v-model="nodeEditorVisible" :title="'节点编辑 — ' + (currentDef?.process_name || '')" size="800px">
|
||||
<div class="flex gap-4 h-full">
|
||||
<!-- 左侧:设计器 -->
|
||||
<div class="flex-1 overflow-y-auto px-1">
|
||||
<div class="text-xs text-gray-400 mb-3">第一个节点为开始节点(不可删除/移动)</div>
|
||||
|
||||
<div
|
||||
v-for="(node, idx) in nodeList"
|
||||
:key="node._key"
|
||||
class="border rounded-lg mb-2 p-3"
|
||||
:class="{
|
||||
'border-blue-400 bg-blue-50': node._editing,
|
||||
'border-green-200 bg-green-50': idx === 0,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-tag size="small" :type="nodeTypeTag(node.node_type)">{{ nodeTypeLabel(node.node_type) }}</el-tag>
|
||||
<span class="text-sm font-medium">{{ node.node_name || '未命名' }}</span>
|
||||
<span class="text-xs text-gray-400">#{{ node.node_id }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<el-button size="small" circle @click="moveNode(idx, -1)" :disabled="idx === 0 || idx === 1">▲</el-button>
|
||||
<el-button size="small" circle @click="moveNode(idx, 1)" :disabled="idx === nodeList.length - 1 || idx === 0">▼</el-button>
|
||||
<el-button size="small" type="primary" link @click="toggleEditNode(node)">{{ node._editing ? '完成' : '编辑' }}</el-button>
|
||||
<el-button size="small" type="danger" link :disabled="idx === 0" @click="nodeList.splice(idx, 1)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开编辑 -->
|
||||
<div v-if="node._editing" class="border-t pt-2 mt-1 space-y-2">
|
||||
<el-form label-position="top" size="small">
|
||||
<el-row :gutter="8">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="节点ID">
|
||||
<el-input v-model="node.node_id" placeholder="如:Employee" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="节点名称">
|
||||
<el-input v-model="node.node_name" placeholder="如:员工审批" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="审批人变量">
|
||||
<el-select v-model="node.user_var" filterable allow-create default-first-option class="w-full" placeholder="选择或输入变量名" :disabled="idx === 0">
|
||||
<el-option label="$employee_id(公司接待员工)" value="$employee_id" />
|
||||
<el-option label="$dept_approver_ids(部门审批人)" value="$dept_approver_ids" />
|
||||
<el-option label="$vp_ids(分管领导)" value="$vp_ids" />
|
||||
<el-option label="$default_approver_ids(兜底审批人)" value="$default_approver_ids" />
|
||||
<el-option label="$starter(发起人)" value="$starter" />
|
||||
</el-select>
|
||||
<div v-if="idx === 0" class="text-xs text-gray-400 mt-0.5">开始节点固定为发起人</div>
|
||||
</el-form-item>
|
||||
<el-row :gutter="8">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="会签(全部通过)">
|
||||
<el-switch v-model="node.is_cosigned" :active-value="1" :inactive-value="0" />
|
||||
<div class="text-xs text-gray-400 mt-0.5">开启后需所有审批人通过才流转</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" plain size="small" class="w-full mt-2" @click="addNode">+ 添加审批节点</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:JSON 预览 -->
|
||||
<div class="w-80 border-l pl-4 flex flex-col h-full">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2 shrink-0">JSON 预览</div>
|
||||
<div class="flex-1 overflow-y-auto bg-gray-50 p-2 rounded text-xs">
|
||||
<JsonViewer :value="parsedJSON" :expand-depth="3" copyable boxed sort />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="nodeEditorVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="nodeSaving" @click="handleNodeSave">保存节点配置</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JsonViewer } from 'vue3-json-viewer'
|
||||
import 'vue3-json-viewer/dist/vue3-json-viewer.css'
|
||||
|
||||
const loading = ref(false)
|
||||
const defList = ref<any[]>([])
|
||||
|
||||
// ===== 流程定义 CRUD =====
|
||||
const defDialogVisible = ref(false)
|
||||
const editingDef = ref<any>(null)
|
||||
const defSaving = ref(false)
|
||||
const defForm = ref({ process_name: '', process_code: '', source: '', description: '' })
|
||||
|
||||
async function loadDefs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.Get('/v1/admin/workflow/definitions') as any
|
||||
const raw = res?.data
|
||||
if (Array.isArray(raw)) {
|
||||
defList.value = raw
|
||||
} else if (raw && typeof raw === 'object' && Array.isArray(raw.list)) {
|
||||
// 可能是分页格式 { list: [...], total }
|
||||
defList.value = raw.list
|
||||
} else if (raw && typeof raw === 'object' && Array.isArray(raw.data)) {
|
||||
// 双层嵌套 { code, msg, data: { data: [...] } }
|
||||
defList.value = raw.data
|
||||
} else {
|
||||
defList.value = []
|
||||
console.warn('[WF] unexpected format:', raw)
|
||||
}
|
||||
} catch { defList.value = [] }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreateDef() {
|
||||
editingDef.value = null
|
||||
defForm.value = { process_name: '', process_code: '', source: '', description: '' }
|
||||
defDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDef(row: any) {
|
||||
currentDef.value = row
|
||||
// 解析节点
|
||||
if (row.nodes_json) {
|
||||
try {
|
||||
nodeList.value = JSON.parse(row.nodes_json).map((n: any, i: number) => ({
|
||||
...n,
|
||||
_key: Date.now() + i,
|
||||
_editing: false,
|
||||
_isStart: n.node_type === 0,
|
||||
user_var: (n.user_ids || []).join(', '),
|
||||
}))
|
||||
} catch { nodeList.value = [] }
|
||||
} else {
|
||||
nodeList.value = []
|
||||
}
|
||||
nodeEditorVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDefSave() {
|
||||
if (!defForm.value.process_name || !defForm.value.process_code) {
|
||||
ElMessage.warning('请填写流程名称和编码'); return
|
||||
}
|
||||
defSaving.value = true
|
||||
try {
|
||||
if (editingDef.value) {
|
||||
await request.Put(`/v1/workflow/definitions/${editingDef.value.id}`, defForm.value)
|
||||
} else {
|
||||
await request.Post('/v1/admin/workflow/definitions', defForm.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
defDialogVisible.value = false
|
||||
await loadDefs()
|
||||
} catch { /* */ }
|
||||
finally { defSaving.value = false }
|
||||
}
|
||||
|
||||
async function toggleActive(row: any) {
|
||||
await request.Put(`/v1/workflow/definitions/${row.id}`, { is_active: !row.is_active })
|
||||
ElMessage.success(row.is_active ? '已禁用' : '已启用')
|
||||
await loadDefs()
|
||||
}
|
||||
|
||||
async function handleDeleteDef(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.process_name}」?`)
|
||||
await request.Delete(`/v1/workflow/definitions/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
await loadDefs()
|
||||
}
|
||||
|
||||
// ===== 节点编辑器 =====
|
||||
const nodeEditorVisible = ref(false)
|
||||
const currentDef = ref<any>(null)
|
||||
const nodeSaving = ref(false)
|
||||
let keyCounter = 0
|
||||
|
||||
const nodeList = ref<any[]>([])
|
||||
|
||||
function genKey() { return Date.now() + (keyCounter++) }
|
||||
|
||||
function addNode() {
|
||||
// 插入到第一个节点之后
|
||||
const insertIdx = 1
|
||||
const newNode = {
|
||||
node_id: 'Node' + (nodeList.value.length + 1),
|
||||
node_name: '审批节点',
|
||||
node_type: 1,
|
||||
prev_node_ids: nodeList.value.length > 0 ? [nodeList.value[nodeList.value.length - 1].node_id] : [],
|
||||
user_ids: ['$starter'],
|
||||
_key: genKey(),
|
||||
_editing: true,
|
||||
_isStart: false,
|
||||
user_var: '$starter',
|
||||
}
|
||||
nodeList.value.splice(insertIdx, 0, newNode)
|
||||
rebuildPrevNodeIDs()
|
||||
}
|
||||
|
||||
function toggleEditNode(node: any) {
|
||||
node._editing = !node._editing
|
||||
}
|
||||
|
||||
function moveNode(idx: number, dir: number) {
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= nodeList.value.length) return
|
||||
const tmp = nodeList.value[idx]
|
||||
nodeList.value[idx] = nodeList.value[target]
|
||||
nodeList.value[target] = tmp
|
||||
rebuildPrevNodeIDs()
|
||||
}
|
||||
|
||||
let dragIdx = -1
|
||||
function onDragStart(e: DragEvent, idx: number) { dragIdx = idx }
|
||||
function onDrop(e: DragEvent, idx: number) {
|
||||
if (dragIdx >= 0 && dragIdx !== idx) {
|
||||
const tmp = nodeList.value[dragIdx]
|
||||
nodeList.value.splice(dragIdx, 1)
|
||||
nodeList.value.splice(idx, 0, tmp)
|
||||
rebuildPrevNodeIDs()
|
||||
}
|
||||
dragIdx = -1
|
||||
}
|
||||
|
||||
function rebuildPrevNodeIDs() {
|
||||
nodeList.value.forEach((n, i) => {
|
||||
if (i === 0) {
|
||||
n.node_type = 0
|
||||
n._isStart = true
|
||||
n.prev_node_ids = []
|
||||
} else {
|
||||
if (n.node_type === 0) n.node_type = 1
|
||||
n._isStart = false
|
||||
n.prev_node_ids = [nodeList.value[i - 1].node_id]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const previewJSON = computed(() => {
|
||||
// 从 nodeList 构建预览,过滤内部属性
|
||||
const nodes = nodeList.value.map((n: any) => {
|
||||
const { _key, _editing, _isStart, user_var, ...rest } = n
|
||||
// 同步 user_var → user_ids
|
||||
if (user_var) {
|
||||
rest.user_ids = user_var.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
}
|
||||
return rest
|
||||
})
|
||||
return JSON.stringify(nodes, null, 2)
|
||||
})
|
||||
|
||||
// 供 JsonViewer 使用的对象格式
|
||||
const parsedJSON = computed(() => {
|
||||
try { return JSON.parse(previewJSON.value) } catch { return {} }
|
||||
})
|
||||
|
||||
async function handleNodeSave() {
|
||||
if (!currentDef.value) return
|
||||
|
||||
// 1. 同步所有节点的 user_var → user_ids
|
||||
nodeList.value.forEach((n: any) => {
|
||||
if (n.user_var) {
|
||||
n.user_ids = n.user_var.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 固定第一个节点为开始节点
|
||||
if (nodeList.value.length > 0) {
|
||||
const first = nodeList.value[0]
|
||||
first.node_id = 'Start'
|
||||
first.node_type = 0
|
||||
first.user_ids = ['$starter']
|
||||
first.user_var = '$starter'
|
||||
first._isStart = true
|
||||
first.is_cosigned = 0
|
||||
first.prev_node_ids = []
|
||||
}
|
||||
|
||||
// 3. 重建后续节点的 node_id 和 prev_node_ids(从第2个开始)
|
||||
for (let i = 1; i < nodeList.value.length; i++) {
|
||||
const n = nodeList.value[i]
|
||||
n.node_id = 'Node' + (i + 1)
|
||||
n.node_type = 1
|
||||
n._isStart = false
|
||||
n.prev_node_ids = [nodeList.value[i - 1].node_id]
|
||||
}
|
||||
|
||||
// 4. 构建最终 JSON(去掉内部属性)
|
||||
const finalNodes = nodeList.value.map((n: any) => {
|
||||
const { _key, _editing, _isStart, user_var, ...rest } = n
|
||||
return rest
|
||||
})
|
||||
// 5. 添加结束节点
|
||||
finalNodes.push({
|
||||
node_id: 'End',
|
||||
node_name: '审批完成',
|
||||
node_type: 3,
|
||||
prev_node_ids: [nodeList.value[nodeList.value.length - 1]?.node_id || 'Node2'],
|
||||
})
|
||||
|
||||
nodeSaving.value = true
|
||||
try {
|
||||
await request.Put(`/v1/admin/workflow/definitions/${currentDef.value.id}`, {
|
||||
nodes_json: JSON.stringify(finalNodes),
|
||||
})
|
||||
ElMessage.success('节点配置已保存')
|
||||
nodeEditorVisible.value = false
|
||||
await loadDefs()
|
||||
} catch { /* */ }
|
||||
finally { nodeSaving.value = false }
|
||||
}
|
||||
|
||||
function nodeTypeLabel(t: number) {
|
||||
return ['开始', '审批', '网关', '结束'][t] || '未知'
|
||||
}
|
||||
function nodeTypeTag(t: number) {
|
||||
return ['success', 'primary', 'warning', 'info'][t] || 'info'
|
||||
}
|
||||
|
||||
onMounted(loadDefs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre { white-space: pre-wrap; word-break: break-all; }
|
||||
[draggable] { cursor: grab; }
|
||||
[draggable]:active { cursor: grabbing; opacity: 0.8; }
|
||||
.el-drawer__body { padding: 16px; overflow: hidden; }
|
||||
</style>
|
||||
35
mp-sc-admin/src/router/index.ts
Normal file
35
mp-sc-admin/src/router/index.ts
Normal file
@@ -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
|
||||
9
mp-sc-admin/src/style.css
Normal file
9
mp-sc-admin/src/style.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
@import "element-plus/dist/index.css";
|
||||
|
||||
/* 默认边框颜色:避免 Tailwind base reset 导致未显式设置颜色的边框显示为黑色 */
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
44
mp-sc-admin/src/utils/request.ts
Normal file
44
mp-sc-admin/src/utils/request.ts
Normal file
@@ -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
|
||||
6
mp-sc-admin/src/vite-env.d.ts
vendored
Normal file
6
mp-sc-admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
19
mp-sc-admin/tsconfig.json
Normal file
19
mp-sc-admin/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
19
mp-sc-admin/vite.config.ts
Normal file
19
mp-sc-admin/vite.config.ts
Normal file
@@ -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 },
|
||||
// },
|
||||
},
|
||||
})
|
||||
8
mp-sc-frontend/.changeset/README.md
Normal file
8
mp-sc-frontend/.changeset/README.md
Normal file
@@ -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).
|
||||
11
mp-sc-frontend/.changeset/config.json
Normal file
11
mp-sc-frontend/.changeset/config.json
Normal file
@@ -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": []
|
||||
}
|
||||
3
mp-sc-frontend/.commitlintrc.cjs
Normal file
3
mp-sc-frontend/.commitlintrc.cjs
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
}
|
||||
51
mp-sc-frontend/.cursor/rules/api-http-patterns.mdc
Normal file
51
mp-sc-frontend/.cursor/rules/api-http-patterns.mdc
Normal file
@@ -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<LoginResponse>('/api/login', params)
|
||||
|
||||
// vue-query 方式
|
||||
export const useLogin = () => {
|
||||
return useMutation({
|
||||
mutationFn: (params: LoginParams) =>
|
||||
http.post<LoginResponse>('/api/login', params)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
- 统一错误处理在拦截器中配置
|
||||
- 支持网络错误、业务错误、认证错误等
|
||||
- 自动处理 token 过期和刷新
|
||||
---
|
||||
globs: src/api/*.ts,src/http/*.ts
|
||||
---
|
||||
43
mp-sc-frontend/.cursor/rules/development-workflow.mdc
Normal file
43
mp-sc-frontend/.cursor/rules/development-workflow.mdc
Normal file
@@ -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: 开发工作流程和最佳实践指南
|
||||
---
|
||||
36
mp-sc-frontend/.cursor/rules/project-overview.mdc
Normal file
36
mp-sc-frontend/.cursor/rules/project-overview.mdc
Normal file
@@ -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` - 构建生产版本
|
||||
54
mp-sc-frontend/.cursor/rules/styling-css-patterns.mdc
Normal file
54
mp-sc-frontend/.cursor/rules/styling-css-patterns.mdc
Normal file
@@ -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
|
||||
<template>
|
||||
<view class="container flex flex-col items-center p-4">
|
||||
<text class="title text-lg font-bold mb-2">标题</text>
|
||||
<view class="content bg-gray-100 rounded-lg p-3">
|
||||
<!-- 内容 -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
|
||||
.title {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 600rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
## 响应式设计
|
||||
- 使用 rpx 单位适配不同屏幕
|
||||
- 支持横屏和竖屏布局
|
||||
- 使用 flexbox 和 grid 布局
|
||||
- 考虑不同平台的样式差异
|
||||
---
|
||||
globs: *.vue,*.scss,*.css
|
||||
---
|
||||
63
mp-sc-frontend/.cursor/rules/uni-app-patterns.mdc
Normal file
63
mp-sc-frontend/.cursor/rules/uni-app-patterns.mdc
Normal file
@@ -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
|
||||
<script setup lang="ts">
|
||||
// #ifdef H5
|
||||
import { h5Api } from '@/utils/h5'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
import { mpApi } from '@/utils/mp'
|
||||
// #endif
|
||||
|
||||
const handleClick = () => {
|
||||
// #ifdef H5
|
||||
h5Api.showToast('H5 平台')
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
mpApi.showToast('微信小程序')
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- uni-app 组件 -->
|
||||
<button @click="handleClick">点击</button>
|
||||
|
||||
<!-- 条件渲染 -->
|
||||
<!-- #ifdef H5 -->
|
||||
<view>H5 特有内容</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 生命周期
|
||||
- 使用 uni-app 页面生命周期
|
||||
- onLoad、onShow、onReady、onHide、onUnload
|
||||
- 组件生命周期遵循 Vue3 规范
|
||||
- 注意页面栈和导航管理
|
||||
---
|
||||
globs: src/pages/*.vue,src/components/*.vue
|
||||
---
|
||||
53
mp-sc-frontend/.cursor/rules/vue-typescript-patterns.mdc
Normal file
53
mp-sc-frontend/.cursor/rules/vue-typescript-patterns.mdc
Normal file
@@ -0,0 +1,53 @@
|
||||
# Vue3 + TypeScript 开发规范
|
||||
|
||||
## Vue 组件规范
|
||||
- 使用 Composition API 和 `<script setup>` 语法
|
||||
- 组件文件使用 PascalCase 命名
|
||||
- 页面文件放在 `src/pages/` 目录下
|
||||
- 全局组件文件放在 `src/components/` 目录下
|
||||
- 局部组件文件放在页面的 `/components/` 目录下
|
||||
|
||||
## Vue SFC 组件规范
|
||||
- `<script setup lang="ts">` 标签必须是第一个子元素
|
||||
- `<template>` 标签必须是第二个子元素
|
||||
- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
|
||||
|
||||
## TypeScript 规范
|
||||
- 严格使用 TypeScript,避免使用 `any` 类型
|
||||
- 为 API 响应数据定义接口类型
|
||||
- 使用 `interface` 定义对象类型,`type` 定义联合类型
|
||||
- 导入类型时使用 `import type` 语法
|
||||
|
||||
## 状态管理
|
||||
- 使用 Pinia 进行状态管理
|
||||
- Store 文件放在 `src/store/` 目录下
|
||||
- 使用 `defineStore` 定义 store
|
||||
- 支持持久化存储
|
||||
|
||||
## 示例代码结构
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { UserInfo } from '@/types/user'
|
||||
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化逻辑
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<!-- 模板内容 -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
// 样式
|
||||
}
|
||||
</style>
|
||||
---
|
||||
globs: *.vue,*.ts,*.tsx
|
||||
---
|
||||
13
mp-sc-frontend/.editorconfig
Normal file
13
mp-sc-frontend/.editorconfig
Normal file
@@ -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 # 关闭末尾空格修剪
|
||||
50
mp-sc-frontend/.gitignore
vendored
Normal file
50
mp-sc-frontend/.gitignore
vendored
Normal file
@@ -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
|
||||
9
mp-sc-frontend/.npmrc
Normal file
9
mp-sc-frontend/.npmrc
Normal file
@@ -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
|
||||
123
mp-sc-frontend/.trae/rules/project_rules.md
Normal file
123
mp-sc-frontend/.trae/rules/project_rules.md
Normal file
@@ -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 和 `<script setup>` 语法
|
||||
- 组件文件使用 PascalCase 命名
|
||||
- 页面文件放在 `src/pages/` 目录下
|
||||
- 全局组件文件放在 `src/components/` 目录下
|
||||
- 局部组件文件放在页面的 `/components/` 目录下
|
||||
|
||||
## TypeScript 规范
|
||||
- 严格使用 TypeScript,避免使用 `any` 类型
|
||||
- 为 API 响应数据定义接口类型
|
||||
- 使用 `interface` 定义对象类型,`type` 定义联合类型
|
||||
- 导入类型时使用 `import type` 语法
|
||||
|
||||
## 状态管理
|
||||
- 使用 Pinia 进行状态管理
|
||||
- Store 文件放在 `src/store/` 目录下
|
||||
- 使用 `defineStore` 定义 store
|
||||
- 支持持久化存储
|
||||
|
||||
## UnoCSS 原子化 CSS
|
||||
- 项目使用 UnoCSS 作为原子化 CSS 框架
|
||||
- 配置在 [uno.config.ts]
|
||||
- 支持预设和自定义规则
|
||||
- 优先使用原子化类名,减少自定义 CSS
|
||||
|
||||
## Vue SFC 组件规范
|
||||
- `<script setup lang="ts">` 标签必须是第一个子元素
|
||||
- `<template>` 标签必须是第二个子元素
|
||||
- `<style scoped>` 标签必须是最后一个子元素(因为推荐使用原子化类名,所以很可能没有)
|
||||
|
||||
## 页面开发
|
||||
- 页面文件放在 [src/pages/]目录下
|
||||
- 使用约定式路由,文件名即路由路径
|
||||
- 页面配置在仅需要在 宏`definePage` 中配置标题等内容即可,会自动生成到 `pages.json` 中
|
||||
- definePage的顺序在最上面
|
||||
|
||||
## 组件开发
|
||||
- 全局组件文件放在 `src/components/` 目录下
|
||||
- 局部组件文件放在页面的 `/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
|
||||
<script setup lang="ts">
|
||||
// #ifdef H5
|
||||
import { h5Api } from '@/utils/h5'
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
import { mpApi } from '@/utils/mp'
|
||||
// #endif
|
||||
|
||||
const handleClick = () => {
|
||||
// #ifdef H5
|
||||
h5Api.showToast('H5 平台')
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
mpApi.showToast('微信小程序')
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- uni-app 组件 -->
|
||||
<button @click="handleClick">点击</button>
|
||||
|
||||
<!-- 条件渲染 -->
|
||||
<!-- #ifdef H5 -->
|
||||
<view>H5 特有内容</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 生命周期
|
||||
- 使用 uni-app 页面生命周期
|
||||
- onLoad、onShow、onReady、onHide、onUnload
|
||||
- 组件生命周期遵循 Vue3 规范
|
||||
- 注意页面栈和导航管理
|
||||
15
mp-sc-frontend/.vscode/extensions.json
vendored
Normal file
15
mp-sc-frontend/.vscode/extensions.json
vendored
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
102
mp-sc-frontend/.vscode/settings.json
vendored
Normal file
102
mp-sc-frontend/.vscode/settings.json
vendored
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
80
mp-sc-frontend/.vscode/vue3.code-snippets
vendored
Normal file
80
mp-sc-frontend/.vscode/vue3.code-snippets
vendored
Normal file
@@ -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": [
|
||||
"<script lang=\"ts\" setup>",
|
||||
"definePage({",
|
||||
" style: {",
|
||||
" navigationBarTitleText: '$1',",
|
||||
" },",
|
||||
"})",
|
||||
"defineOptions({",
|
||||
" name: '$2',",
|
||||
" options: { ",
|
||||
" virtualHost: true,",
|
||||
" },",
|
||||
"})",
|
||||
"</script>\n",
|
||||
"<template>",
|
||||
" <view class=\"\">$3</view>",
|
||||
"</template>\n",
|
||||
"<style lang=\"scss\" scoped>",
|
||||
"//$4",
|
||||
"</style>\n",
|
||||
],
|
||||
},
|
||||
"Print unibest style": {
|
||||
"scope": "vue",
|
||||
"prefix": "st",
|
||||
"body": [
|
||||
"<style lang=\"scss\" scoped>",
|
||||
"//",
|
||||
"</style>\n"
|
||||
],
|
||||
},
|
||||
"Print unibest script with definePage": {
|
||||
"scope": "vue",
|
||||
"prefix": "sc",
|
||||
"body": [
|
||||
"<script lang=\"ts\" setup>",
|
||||
"definePage({",
|
||||
" style: {",
|
||||
" navigationBarTitleText: '$1',",
|
||||
" },",
|
||||
"})",
|
||||
"defineOptions({",
|
||||
" name: '$2',",
|
||||
" options: { ",
|
||||
" virtualHost: true,",
|
||||
" },",
|
||||
"})",
|
||||
"</script>\n"
|
||||
],
|
||||
},
|
||||
"Print unibest template": {
|
||||
"scope": "vue",
|
||||
"prefix": "te",
|
||||
"body": [
|
||||
"<template>",
|
||||
" <view class=\"\">$1</view>",
|
||||
"</template>\n"
|
||||
],
|
||||
},
|
||||
}
|
||||
21
mp-sc-frontend/LICENSE
Normal file
21
mp-sc-frontend/LICENSE
Normal file
@@ -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.
|
||||
98
mp-sc-frontend/README.md
Normal file
98
mp-sc-frontend/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/unibest-tech/unibest">
|
||||
<img width="160" src="./src/static/logo.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
<a href="https://github.com/unibest-tech/unibest" target="_blank">unibest - 最好的 uniapp 开发框架</a>
|
||||
</h1>
|
||||
|
||||
<div align="center">
|
||||
旧仓库 codercup 进不去了,star 也拿不回来,这里也展示一下那个地址的 star.
|
||||
|
||||
[](https://github.com/codercup/unibest)
|
||||
[](https://github.com/codercup/unibest)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/feige996/unibest)
|
||||
[](https://github.com/feige996/unibest)
|
||||
[](https://gitee.com/feige996/unibest/stargazers)
|
||||
[](https://gitee.com/feige996/unibest/members)
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
`unibest` —— 最好的 `uniapp` 开发模板,由 `uniapp` + `Vue3` + `Ts` + `Vite5` + `UnoCss` + `wot-ui` + `z-paging` 构成,使用了最新的前端技术栈,无需依靠 `HBuilderX`,通过命令行方式运行 `web`、`小程序` 和 `App`(编辑器推荐 `VSCode`,可选 `webstorm`)。
|
||||
|
||||
`unibest` 内置了 `约定式路由`、`layout布局`、`请求封装`、`请求拦截`、`登录拦截`、`UnoCSS`、`i18n多语言` 等基础功能,提供了 `代码提示`、`自动格式化`、`统一配置`、`代码片段` 等辅助功能,让你编写 `uniapp` 拥有 `best` 体验 ( `unibest 的由来`)。
|
||||
|
||||

|
||||
|
||||
<p align="center">
|
||||
<a href="https://unibest.tech/" target="_blank">📖 文档地址(new)</a>
|
||||
<span style="margin:0 10px;">|</span>
|
||||
<a href="https://unibest-tech.github.io/hello-unibest" target="_blank">📱 DEMO 地址</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
注意旧的地址 [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 菲鸽
|
||||
|
||||
## 捐赠
|
||||
|
||||
<p align='center'>
|
||||
<img alt="special sponsor appwrite" src="https://oss.laf.run/ukw0y1-site/pay/wepay.png" height="330" style="display:inline-block; height:330px;">
|
||||
<img alt="special sponsor appwrite" src="https://oss.laf.run/ukw0y1-site/pay/alipay.jpg" height="330" style="display:inline-block; height:330px; margin-left:10px;">
|
||||
</p>
|
||||
42
mp-sc-frontend/env/.env
vendored
Normal file
42
mp-sc-frontend/env/.env
vendored
Normal file
@@ -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
|
||||
9
mp-sc-frontend/env/.env.development
vendored
Normal file
9
mp-sc-frontend/env/.env.development
vendored
Normal file
@@ -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'
|
||||
9
mp-sc-frontend/env/.env.production
vendored
Normal file
9
mp-sc-frontend/env/.env.production
vendored
Normal file
@@ -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'
|
||||
9
mp-sc-frontend/env/.env.test
vendored
Normal file
9
mp-sc-frontend/env/.env.test
vendored
Normal file
@@ -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'
|
||||
60
mp-sc-frontend/eslint.config.mjs
Normal file
60
mp-sc-frontend/eslint.config.mjs
Normal file
@@ -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 `<style>` blocks in Vue
|
||||
* By default uses Prettier
|
||||
*/
|
||||
css: true,
|
||||
/**
|
||||
* Format HTML files
|
||||
* By default uses Prettier
|
||||
*/
|
||||
html: true,
|
||||
},
|
||||
})
|
||||
BIN
mp-sc-frontend/favicon.ico
Normal file
BIN
mp-sc-frontend/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
26
mp-sc-frontend/index.html
Normal file
26
mp-sc-frontend/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html build-time="%BUILD_TIME%">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
<script>
|
||||
var coverSupport =
|
||||
'CSS' in window &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
(CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') +
|
||||
'" />',
|
||||
)
|
||||
</script>
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
162
mp-sc-frontend/manifest.config.ts
Normal file
162
mp-sc-frontend/manifest.config.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
// manifest.config.ts
|
||||
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
// 手动解析命令行参数获取 mode
|
||||
function getMode() {
|
||||
const args = process.argv.slice(2)
|
||||
const modeFlagIndex = args.findIndex(arg => arg === '--mode')
|
||||
return modeFlagIndex !== -1 ? args[modeFlagIndex + 1] : args[0] === 'build' ? 'production' : 'development' // 默认 development
|
||||
}
|
||||
// 获取环境变量的范例
|
||||
const env = loadEnv(getMode(), path.resolve(process.cwd(), 'env'))
|
||||
const {
|
||||
VITE_APP_TITLE,
|
||||
VITE_UNI_APPID,
|
||||
VITE_WX_APPID,
|
||||
VITE_APP_PUBLIC_BASE,
|
||||
} = env
|
||||
// console.log('manifest.config.ts env:', env)
|
||||
|
||||
export default defineManifestConfig({
|
||||
'name': VITE_APP_TITLE,
|
||||
'appid': VITE_UNI_APPID,
|
||||
'description': '',
|
||||
'versionName': '1.0.0',
|
||||
'versionCode': '100',
|
||||
'transformPx': false,
|
||||
'h5': {
|
||||
router: {
|
||||
base: VITE_APP_PUBLIC_BASE,
|
||||
},
|
||||
},
|
||||
/* 5+App特有相关 */
|
||||
'app-plus': {
|
||||
usingComponents: true,
|
||||
nvueStyleCompiler: 'uni-app',
|
||||
compilerVersion: 3,
|
||||
compatible: {
|
||||
ignoreVersion: true,
|
||||
},
|
||||
splashscreen: {
|
||||
alwaysShowBeforeRender: true,
|
||||
waiting: true,
|
||||
autoclose: true,
|
||||
delay: 0,
|
||||
},
|
||||
/* 模块配置 */
|
||||
modules: {},
|
||||
/* 应用发布信息 */
|
||||
distribute: {
|
||||
/* android打包配置 */
|
||||
android: {
|
||||
minSdkVersion: 21,
|
||||
targetSdkVersion: 30,
|
||||
abiFilters: ['armeabi-v7a', 'arm64-v8a'],
|
||||
permissions: [
|
||||
'<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
|
||||
'<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',
|
||||
'<uses-permission android:name="android.permission.VIBRATE"/>',
|
||||
'<uses-permission android:name="android.permission.READ_LOGS"/>',
|
||||
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>',
|
||||
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
|
||||
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
|
||||
'<uses-permission android:name="android.permission.CAMERA"/>',
|
||||
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
|
||||
'<uses-permission android:name="android.permission.READ_PHONE_STATE"/>',
|
||||
'<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>',
|
||||
'<uses-permission android:name="android.permission.WAKE_LOCK"/>',
|
||||
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
|
||||
'<uses-feature android:name="android.hardware.camera"/>',
|
||||
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
|
||||
],
|
||||
},
|
||||
/* ios打包配置 */
|
||||
ios: {},
|
||||
/* SDK配置 */
|
||||
sdkConfigs: {},
|
||||
/* 图标配置 */
|
||||
icons: {
|
||||
android: {
|
||||
hdpi: 'static/app/icons/72x72.png',
|
||||
xhdpi: 'static/app/icons/96x96.png',
|
||||
xxhdpi: 'static/app/icons/144x144.png',
|
||||
xxxhdpi: 'static/app/icons/192x192.png',
|
||||
},
|
||||
ios: {
|
||||
appstore: 'static/app/icons/1024x1024.png',
|
||||
ipad: {
|
||||
'app': 'static/app/icons/76x76.png',
|
||||
'app@2x': 'static/app/icons/152x152.png',
|
||||
'notification': 'static/app/icons/20x20.png',
|
||||
'notification@2x': 'static/app/icons/40x40.png',
|
||||
'proapp@2x': 'static/app/icons/167x167.png',
|
||||
'settings': 'static/app/icons/29x29.png',
|
||||
'settings@2x': 'static/app/icons/58x58.png',
|
||||
'spotlight': 'static/app/icons/40x40.png',
|
||||
'spotlight@2x': 'static/app/icons/80x80.png',
|
||||
},
|
||||
iphone: {
|
||||
'app@2x': 'static/app/icons/120x120.png',
|
||||
'app@3x': 'static/app/icons/180x180.png',
|
||||
'notification@2x': 'static/app/icons/40x40.png',
|
||||
'notification@3x': 'static/app/icons/60x60.png',
|
||||
'settings@2x': 'static/app/icons/58x58.png',
|
||||
'settings@3x': 'static/app/icons/87x87.png',
|
||||
'spotlight@2x': 'static/app/icons/80x80.png',
|
||||
'spotlight@3x': 'static/app/icons/120x120.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
/* 快应用特有相关 */
|
||||
'quickapp': {},
|
||||
/* 小程序特有相关 */
|
||||
'mp-weixin': {
|
||||
appid: VITE_WX_APPID,
|
||||
setting: {
|
||||
urlCheck: false,
|
||||
// 是否启用 ES6 转 ES5
|
||||
es6: true,
|
||||
minified: true,
|
||||
},
|
||||
optimization: {
|
||||
subPackages: true,
|
||||
},
|
||||
// 是否合并组件虚拟节点外层属性,uni-app 3.5.1+ 开始支持。目前仅支持 style、class 属性。
|
||||
// 默认不开启(undefined),这里设置为开启。
|
||||
mergeVirtualHostAttributes: true,
|
||||
// styleIsolation: 'shared',
|
||||
usingComponents: true,
|
||||
// __usePrivacyCheck__: true,
|
||||
},
|
||||
'mp-alipay': {
|
||||
usingComponents: true,
|
||||
styleIsolation: 'shared',
|
||||
optimization: {
|
||||
subPackages: true,
|
||||
},
|
||||
// 解决支付宝小程序开发工具报错 【globalThis is not defined】
|
||||
compileOptions: {
|
||||
globalObjectMode: 'enable',
|
||||
transpile: {
|
||||
script: {
|
||||
ignore: ['node_modules/**'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'mp-baidu': {
|
||||
usingComponents: true,
|
||||
},
|
||||
'mp-toutiao': {
|
||||
usingComponents: true,
|
||||
},
|
||||
'uniStatistics': {
|
||||
enable: false,
|
||||
},
|
||||
'vueVersion': '3',
|
||||
})
|
||||
14
mp-sc-frontend/openapi-ts-request.config.ts
Normal file
14
mp-sc-frontend/openapi-ts-request.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'openapi-ts-request'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
describe: 'unibest-openapi-test',
|
||||
schemaPath: 'https://ukw0y1.laf.run/unibest-opapi-test.json',
|
||||
serversPath: './src/service',
|
||||
requestLibPath: `import request from '@/http/vue-query';\n import { CustomRequestOptions_ } from '@/http/types';`,
|
||||
requestOptionsType: 'CustomRequestOptions_',
|
||||
isGenReactQuery: false,
|
||||
reactQueryMode: 'vue',
|
||||
isGenJavaScript: false,
|
||||
},
|
||||
])
|
||||
215
mp-sc-frontend/package.json
Normal file
215
mp-sc-frontend/package.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"name": "sc-lktx-frontend",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"unibest": {
|
||||
"platforms": [
|
||||
"h5",
|
||||
"mp-weixin",
|
||||
"app"
|
||||
],
|
||||
"uiLibrary": "wot-ui-v2",
|
||||
"loginStrategy": true,
|
||||
"i18n": false,
|
||||
"chartLibraries": [],
|
||||
"cliVersion": "4.0.16",
|
||||
"unibestVersion": "4.4.1",
|
||||
"unibestUpdateTime": "2026-05-22",
|
||||
"createdAt": "2026-06-06 23:22:43"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0",
|
||||
"description": "unibest - 最好的 uniapp 开发模板",
|
||||
"generate-time": "用户创建项目时生成",
|
||||
"author": {
|
||||
"name": "feige996",
|
||||
"zhName": "菲鸽",
|
||||
"email": "1020103647@qq.com",
|
||||
"github": "https://github.com/feige996",
|
||||
"gitee": "https://gitee.com/feige996"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://unibest.tech",
|
||||
"repository": "https://github.com/feige996/unibest",
|
||||
"bugs": {
|
||||
"url": "https://github.com/feige996/unibest/issues",
|
||||
"url-old": "https://github.com/codercup/unibest/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"scripts": {
|
||||
"openapi": "openapi-ts",
|
||||
"init-baseFiles": "node ./scripts/create-base-files.js",
|
||||
"prepare": "pnpm init-baseFiles",
|
||||
"predev": "pnpm init-baseFiles",
|
||||
"predev:app": "pnpm init-baseFiles",
|
||||
"predev:mp": "pnpm init-baseFiles",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"uvm": "npx @dcloudio/uvm@latest",
|
||||
"uvm-rm": "node ./scripts/postupgrade.js",
|
||||
"bump-version": "node ./scripts/bump-version.js",
|
||||
"postuvm": "echo upgrade uni-app success!",
|
||||
"dev:app": "uni -p app",
|
||||
"dev:app:test": "uni -p app --mode test",
|
||||
"dev:app:prod": "uni -p app --mode production",
|
||||
"dev:app-android": "uni -p app-android",
|
||||
"dev:app-ios": "uni -p app-ios",
|
||||
"dev:custom": "uni -p",
|
||||
"dev": "uni",
|
||||
"dev:test": "uni --mode test",
|
||||
"dev:prod": "uni --mode production",
|
||||
"dev:h5": "uni",
|
||||
"dev:h5:test": "uni --mode test",
|
||||
"dev:h5:prod": "uni --mode production",
|
||||
"dev:h5:ssr": "uni --ssr",
|
||||
"dev:mp": "uni -p mp-weixin",
|
||||
"dev:mp:test": "uni -p mp-weixin --mode test",
|
||||
"dev:mp:prod": "uni -p mp-weixin --mode production",
|
||||
"dev:mp-alipay": "uni -p mp-alipay",
|
||||
"dev:mp-baidu": "uni -p mp-baidu",
|
||||
"dev:mp-jd": "uni -p mp-jd",
|
||||
"dev:mp-kuaishou": "uni -p mp-kuaishou",
|
||||
"dev:mp-lark": "uni -p mp-lark",
|
||||
"dev:mp-qq": "uni -p mp-qq",
|
||||
"dev:mp-toutiao": "uni -p mp-toutiao",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"dev:mp-xhs": "uni -p mp-xhs",
|
||||
"dev:quickapp-webview": "uni -p quickapp-webview",
|
||||
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
|
||||
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
|
||||
"build:app": "uni build -p app",
|
||||
"build:app:test": "uni build -p app --mode test",
|
||||
"build:app:prod": "uni build -p app --mode production",
|
||||
"build:app-android": "uni build -p app-android",
|
||||
"build:app-ios": "uni build -p app-ios",
|
||||
"build:custom": "uni build -p",
|
||||
"build:h5": "uni build",
|
||||
"build:h5:test": "uni build --mode test",
|
||||
"build:h5:prod": "uni build --mode production",
|
||||
"build": "uni build",
|
||||
"build:test": "uni build --mode test",
|
||||
"build:prod": "uni build --mode production",
|
||||
"build:h5:ssr": "uni build --ssr",
|
||||
"build:mp-alipay": "uni build -p mp-alipay",
|
||||
"build:mp": "uni build -p mp-weixin",
|
||||
"build:mp:test": "uni build -p mp-weixin --mode test",
|
||||
"build:mp:prod": "uni build -p mp-weixin --mode production",
|
||||
"build:mp-baidu": "uni build -p mp-baidu",
|
||||
"build:mp-jd": "uni build -p mp-jd",
|
||||
"build:mp-kuaishou": "uni build -p mp-kuaishou",
|
||||
"build:mp-lark": "uni build -p mp-lark",
|
||||
"build:mp-qq": "uni build -p mp-qq",
|
||||
"build:mp-toutiao": "uni build -p mp-toutiao",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"build:mp-xhs": "uni build -p mp-xhs",
|
||||
"build:quickapp-webview": "uni build -p quickapp-webview",
|
||||
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
|
||||
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
|
||||
"upload:changeset": "pnpm changeset && pnpm changeset version",
|
||||
"upload:mp": "node ./scripts/upload-weixin.js",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alova/adapter-uniapp": "^2.0.14",
|
||||
"@alova/shared": "^1.3.1",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"@dcloudio/uni-app": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-app-harmony": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-components": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-alipay": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-baidu": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-harmony": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-jd": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-kuaishou": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-lark": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-qq": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-toutiao": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
|
||||
"@wot-ui/ui": "latest",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"alova": "^3.3.3",
|
||||
"dayjs": "1.11.10",
|
||||
"js-md5": "^0.8.3",
|
||||
"pinia": "2.0.36",
|
||||
"pinia-plugin-persistedstate": "3.2.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "4.5.1",
|
||||
"z-paging": "2.8.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4070620250821001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4070620250821001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4070620250821001",
|
||||
"@iconify-json/carbon": "^1.2.4",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@types/node": "^20.17.9",
|
||||
"@uni-helper/eslint-config": "0.5.0",
|
||||
"@uni-helper/plugin-uni": "0.1.0",
|
||||
"@uni-helper/uni-env": "0.1.8",
|
||||
"@uni-helper/uni-types": "1.0.0-alpha.6",
|
||||
"@uni-helper/unocss-preset-uni": "0.2.11",
|
||||
"@uni-helper/vite-plugin-uni-components": "0.2.3",
|
||||
"@uni-helper/vite-plugin-uni-layouts": "0.1.11",
|
||||
"@uni-helper/vite-plugin-uni-manifest": "0.2.12",
|
||||
"@uni-helper/vite-plugin-uni-pages": "0.3.23",
|
||||
"@uni-helper/vite-plugin-uni-platform": "0.0.5",
|
||||
"@uni-ku/bundle-optimizer": "v1.3.15-beta.2",
|
||||
"@uni-ku/root": "1.4.1",
|
||||
"@unocss/eslint-plugin": "^66.2.3",
|
||||
"@unocss/preset-legacy-compat": "66.0.0",
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^10.0.0",
|
||||
"enquirer": "^2.4.1",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-format": "^1.0.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"miniprogram-api-typings": "^4.1.0",
|
||||
"miniprogram-ci": "^2.1.26",
|
||||
"openapi-ts-request": "^1.10.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-html": "^1.8.0",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"rollup-plugin-visualizer": "^6.0.3",
|
||||
"sass": "1.77.8",
|
||||
"std-env": "^3.9.0",
|
||||
"typescript": "~5.8.0",
|
||||
"unocss": "66.0.0",
|
||||
"unplugin-auto-import": "^20.0.0",
|
||||
"vite": "5.2.8",
|
||||
"vite-plugin-restart": "^1.0.0",
|
||||
"vite-plugin-vue-devtools": "^8.0.5",
|
||||
"vitest": "3.2.4",
|
||||
"vue-tsc": "^3.0.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"unconfig": "7.3.2"
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"unconfig": "7.3.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"bin-wrapper": "npm:bin-wrapper-china",
|
||||
"unconfig": "7.3.2"
|
||||
}
|
||||
}
|
||||
23
mp-sc-frontend/pages.config.ts
Normal file
23
mp-sc-frontend/pages.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
|
||||
import { tabBar } from './src/tabbar/config'
|
||||
|
||||
export default defineUniPages({
|
||||
globalStyle: {
|
||||
navigationStyle: 'default',
|
||||
navigationBarTitleText: '四川凌空天行',
|
||||
navigationBarBackgroundColor: '#f8f8f8',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
easycom: {
|
||||
autoscan: true,
|
||||
custom: {
|
||||
'^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
|
||||
'^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
|
||||
'z-paging/components/z-paging$1/z-paging$1.vue',
|
||||
'^wd-(.*)': '@wot-ui/ui/components/wd-$1/wd-$1.vue',
|
||||
},
|
||||
},
|
||||
// tabbar 的配置统一在 “./src/tabbar/config.ts” 文件中
|
||||
tabBar: tabBar as any,
|
||||
})
|
||||
22052
mp-sc-frontend/pnpm-lock.yaml
generated
Normal file
22052
mp-sc-frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
mp-sc-frontend/private.wx4c5f6fdb5bfa3b3a.key
Normal file
27
mp-sc-frontend/private.wx4c5f6fdb5bfa3b3a.key
Normal file
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpQIBAAKCAQEA3KecWDuhVTo6aFpTGnJK8MEEjjkxfz3aCMpvRuX7Pg43QYSF
|
||||
99hLkyW9QRPKl16ANEL6WW7O6YozBPOp/g5NEym24q2B1s0NGGNj0WJLc7Qt4LEV
|
||||
2IYZX16rfpoY9aApLFlO1KFCt3UbS8sJMo2IpUuocCauS4R128Ycdu7qPgclwod9
|
||||
A/QmlLHAue/t6tAfb/Tf8fB7/+bJEmLgKT0w85I3v4hOt35mvvIRcciz/wB9U+B0
|
||||
cE+u6XUtqVik9fPjjSuqQYxg+oV8Aj/LO4lvMusSNFOQJJDEo42DJfyjRBCaQrjh
|
||||
9pBiOmQxeqFVZVPcosJ12ukdeZdFsmd43046RQIDAQABAoIBAQDY6SgHkK77Yl9S
|
||||
gCCbqelDnOtGiLDAvePdqmsTjjeafE0TahxsVUON5paSJ8uLXAm51nHWgtiCuimH
|
||||
X6Unq5VXFjXDxf8SUsbhx6qzheZYWrKS5GJuVP0SRLVfokqRA54WC8Ezw0cbo9Ju
|
||||
gqyK9plyrNprTYsfj5pwruMCg8DfsUEphlKQZj7bWmXYRYH/Ax7Z5PUn3aEk0van
|
||||
K/DL4ky250E3Iliab6TwBRN5hAhGgCLvQkiT+iRKfGtRHpoo0iFyLTaobQshkGcu
|
||||
CRBsI7rckIPMzQPN2LMjkhw9s3SqZKqAL0rAKkZ1wEE+gy/D1FgMGgMXaeYFIU6Z
|
||||
ZIxLsc8BAoGBAPoLxb2yQz8R6AdJcUuoBOzpjeopqERi2sihJjd+YNJuufNQACvA
|
||||
j/64zuu0Rb5o7XLI7FrRO3wWhkLIt43iTwbTTW6HfknbxI82CiE0XflVYqNrA+zf
|
||||
NH4gs1vzFxIpp8WN0kWPv/a+irRcCEnjU3xVQSJ73nwRMD5RXGfvvQXBAoGBAOHo
|
||||
rObDJXklOGaPI0R6RNWL3+aYjUpe/500bSgsZGtbWz69bJ3LNWjKzqaK5tAlDSBw
|
||||
lnVlGd0kdgZrn2FgrOA8zMw5g4pG6Kh9UxIxBO2kC5GHx2yE4nTZr8Be04RnctY8
|
||||
sSFeafAaXrpcnL9MPvoaiAxaaIh/Q+xLzsKHC32FAoGBAPUONpTsMTWNsg36L1wL
|
||||
ZhBd8SSuAOhMzcjVDqRSakeyFvHb1N8MUNM+giTEv5mWMihNvD5hUuARHzIyjpoy
|
||||
UmsJCZkql12BUglc196k+PiUcyBfkDBErKh0GfQisNivFGrrzEk6UdNb+Io8rC7l
|
||||
6Pswfq5yIaEMI3DfwiVm8qTBAoGBAM3fCJJTjLbWIIv2LaGd+1TQX375zujTogZV
|
||||
XJSbv/fGDWUjovQ517Zj++bx9l4BJfFGKRdaxzMsoxI+ycQoIeNIBSqnzyQYcrX5
|
||||
X9bYLTGTqac6IZbXkrgCGZQp1oB29cQfExzhuZFBtsoG1CHRDiNGQm1fhpu9vtx8
|
||||
STQldWcxAoGAUkjxh98VXGjwhzqW9CCm5EVnd5HT3/cB1bu9hzfscaRkS3Jy9MyP
|
||||
XtlhixpA4ZR8o0BDai85aVHlXDcbEPO39oybrS9b+kgm8KTuA4UfADo1v0fDc9yV
|
||||
OTC0CB3GXOayas9hf9fjMpOUUhtKDsgpXcDb6LwG69mCQF2iVuv2cXk=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
154
mp-sc-frontend/scripts/bump-version.js
Normal file
154
mp-sc-frontend/scripts/bump-version.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import enquirer from 'enquirer'
|
||||
import pc from 'picocolors'
|
||||
|
||||
const manifestPath = path.resolve(process.cwd(), 'manifest.config.ts')
|
||||
// 获取是否只是测试运行
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
|
||||
/**
|
||||
* 将语义化版本号根据传递的类型进行递增
|
||||
* @param {string} version 当前版本号,如 '1.1.0'
|
||||
* @param {string} type 升级类型: 'patch' | 'minor' | 'major' | 'none'
|
||||
* @returns {string} 新的版本号
|
||||
*/
|
||||
function bumpVersionName(version, type) {
|
||||
if (type === 'none') return version
|
||||
|
||||
const parts = version.split('.')
|
||||
let major = Number.parseInt(parts[0] || '0', 10)
|
||||
let minor = Number.parseInt(parts[1] || '0', 10)
|
||||
let patch = Number.parseInt(parts[2] || '0', 10)
|
||||
|
||||
switch (type) {
|
||||
case 'major':
|
||||
major += 1; minor = 0; patch = 0;
|
||||
break;
|
||||
case 'minor':
|
||||
minor += 1; patch = 0;
|
||||
break;
|
||||
case 'patch':
|
||||
patch += 1;
|
||||
break;
|
||||
}
|
||||
return `${major}.${minor}.${patch}`
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const source = fs.readFileSync(manifestPath, 'utf8')
|
||||
|
||||
// 匹配 versionCode 和 versionName 的正则表达式,支持键名带有引号的情况,例如 'versionCode': '100'
|
||||
const versionCodeRegex = /((?:['"])?versionCode(?:['"])?\s*:\s*)(['"])(\d+)\2/
|
||||
const versionNameRegex = /((?:['"])?versionName(?:['"])?\s*:\s*)(['"])([\d\.]+)\2/
|
||||
|
||||
const codeMatch = source.match(versionCodeRegex)
|
||||
const nameMatch = source.match(versionNameRegex)
|
||||
|
||||
if (!codeMatch || !nameMatch) {
|
||||
console.error(pc.red('✖ [bump-version] 未在 manifest.config.ts 中找到合法的 versionCode 或 versionName'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const currentVersionCode = Number.parseInt(codeMatch[3], 10)
|
||||
const currentVersionName = nameMatch[3]
|
||||
const nextVersionCode = String(currentVersionCode + 1)
|
||||
|
||||
// 1. 检查命令行参数是否有 --type
|
||||
const typeArgMatch = process.argv.find(arg => arg.startsWith('--type='))
|
||||
let bumpType = typeArgMatch ? typeArgMatch.split('=')[1] : null
|
||||
|
||||
// 2. 环境判定:如果不在交互终端或者是CI环境,但是没有指定类型,则默认只升级 versionCode
|
||||
const isInteractive = process.stdout.isTTY && !process.env.CI
|
||||
|
||||
if (!bumpType) {
|
||||
if (!isInteractive) {
|
||||
console.log(pc.yellow('⚠ [bump-version] 非交互环境且未指定参数,默认不修改 versionName'))
|
||||
bumpType = 'none'
|
||||
} else {
|
||||
// 3. 在终端交互式询问用户怎么处理 versionName
|
||||
console.log('')
|
||||
console.log(pc.cyan('📦 准备发布新版本'))
|
||||
console.log(`${pc.gray('当前版本:')} ${pc.bold(currentVersionName)} ${pc.gray(`(v${currentVersionCode})`)}`)
|
||||
console.log('')
|
||||
|
||||
const response = await enquirer.prompt({
|
||||
type: 'select',
|
||||
name: 'selectedType',
|
||||
message: '请选择如何升级版本名称 (versionName)?',
|
||||
pointer: pc.cyan('❯'),
|
||||
choices: [
|
||||
{
|
||||
message: `${pc.bold('修复')} (Patch) ${pc.gray(currentVersionName)} → ${pc.green(bumpVersionName(currentVersionName, 'patch'))}`,
|
||||
name: 'patch',
|
||||
hint: pc.gray('修复Bug、极小的代码安全变动')
|
||||
},
|
||||
{
|
||||
message: `${pc.bold('特性')} (Minor) ${pc.gray(currentVersionName)} → ${pc.cyan(bumpVersionName(currentVersionName, 'minor'))}`,
|
||||
name: 'minor',
|
||||
hint: pc.gray('新增功能、向下兼容的API更新')
|
||||
},
|
||||
{
|
||||
message: `${pc.bold('重大')} (Major) ${pc.gray(currentVersionName)} → ${pc.magenta(bumpVersionName(currentVersionName, 'major'))}`,
|
||||
name: 'major',
|
||||
hint: pc.gray('重大重构、不兼容的API修改')
|
||||
},
|
||||
{
|
||||
message: `${pc.bold('仅Code')} (None) ${pc.gray('保持 ' + currentVersionName)}`,
|
||||
name: 'none',
|
||||
hint: pc.gray('保持名称不变,仅升级构建号(versionCode)')
|
||||
},
|
||||
{
|
||||
message: `${pc.bold('取消')} (Cancel) ${pc.gray('完全不升级版本')}`,
|
||||
name: 'cancel',
|
||||
hint: pc.gray('跳过版本修改,直接进入后续打包流程')
|
||||
},
|
||||
],
|
||||
// 如果用户按 ctrl+c 退出
|
||||
onCancel: () => {
|
||||
console.log(pc.red('✖ 取消操作并退出编译'))
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
bumpType = response.selectedType
|
||||
|
||||
// 用户选择了完全不升级
|
||||
if (bumpType === 'cancel') {
|
||||
console.log('')
|
||||
console.log(pc.green('✔ 已跳过版本升级操作'))
|
||||
console.log('')
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 根据类型计算下一代版本并进行替换计算
|
||||
const nextVersionName = bumpVersionName(currentVersionName, bumpType)
|
||||
|
||||
let updated = source.replace(versionCodeRegex, `${codeMatch[1]}${codeMatch[2]}${nextVersionCode}${codeMatch[2]}`)
|
||||
updated = updated.replace(versionNameRegex, `${nameMatch[1]}${nameMatch[2]}${nextVersionName}${nameMatch[2]}`)
|
||||
|
||||
// 5. 回填文件内容
|
||||
if (!dryRun) {
|
||||
fs.writeFileSync(manifestPath, updated, 'utf8')
|
||||
}
|
||||
|
||||
// 美化成功提示输出
|
||||
console.log('')
|
||||
console.log(pc.green(`✔ ${dryRun ? '(模拟运行) ' : ''}版本更新成功!`))
|
||||
|
||||
if (bumpType !== 'none') {
|
||||
console.log(` ${pc.gray('versionName:')} ${pc.strikethrough(pc.gray(currentVersionName))} → ${pc.bold(pc.green(nextVersionName))}`)
|
||||
} else {
|
||||
console.log(` ${pc.gray('versionName:')} ${pc.dim(currentVersionName)} (未更改)`)
|
||||
}
|
||||
|
||||
console.log(` ${pc.gray('versionCode:')} ${pc.strikethrough(pc.gray(currentVersionCode))} → ${pc.bold(pc.green(nextVersionCode))}`)
|
||||
console.log('')
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error(pc.red('✖ [bump-version] 发生错误:'), err)
|
||||
process.exit(1)
|
||||
})
|
||||
55
mp-sc-frontend/scripts/create-base-files.js
Normal file
55
mp-sc-frontend/scripts/create-base-files.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// 基础配置文件生成脚本
|
||||
// 此脚本用于生成 src/manifest.json 和 src/pages.json 基础文件
|
||||
// 由于这两个配置文件会被添加到 .gitignore 中,因此需要通过此脚本确保项目能正常运行
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// 获取当前文件的目录路径(替代 CommonJS 中的 __dirname)
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
// 最简可运行配置
|
||||
const manifest = { }
|
||||
const pages = {
|
||||
pages: [
|
||||
{
|
||||
path: 'pages/index/index',
|
||||
type: 'home',
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '首页',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'pages/me/me',
|
||||
type: 'page',
|
||||
style: {
|
||||
navigationBarTitleText: '我的',
|
||||
},
|
||||
},
|
||||
],
|
||||
subPackages: [],
|
||||
}
|
||||
|
||||
// 使用修复后的 __dirname 来解析文件路径
|
||||
const manifestPath = path.resolve(__dirname, '../src/manifest.json')
|
||||
const pagesPath = path.resolve(__dirname, '../src/pages.json')
|
||||
|
||||
// 确保 src 目录存在
|
||||
const srcDir = path.resolve(__dirname, '../src')
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
fs.mkdirSync(srcDir, { recursive: true })
|
||||
}
|
||||
|
||||
const MIN_SIZE = `{ }`.length // 如果只有一个空对象,必定是不对的,需要重新生成
|
||||
|
||||
// 如果 src/manifest.json 不存在,就创建它;或者如果文件大小小于等于 MIN_SIZE,也重新创建
|
||||
if (!fs.existsSync(manifestPath) || fs.statSync(manifestPath).size <= MIN_SIZE) {
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
}
|
||||
|
||||
// 如果 src/pages.json 不存在,就创建它;或者如果文件大小小于等于 MIN_SIZE,也重新创建
|
||||
if (!fs.existsSync(pagesPath) || fs.statSync(pagesPath).size <= MIN_SIZE) {
|
||||
fs.writeFileSync(pagesPath, JSON.stringify(pages, null, 2))
|
||||
}
|
||||
107
mp-sc-frontend/scripts/open-dev-tools.js
Normal file
107
mp-sc-frontend/scripts/open-dev-tools.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { exec } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
/**
|
||||
* 打开开发者工具
|
||||
* @param {string} env - 环境,'dev' 或 'build'
|
||||
* @param {object} options - 配置选项
|
||||
* @param {string} options.wechatDevtoolsCliPath - 微信开发者工具 CLI 路径
|
||||
*/
|
||||
function _openDevTools(env = 'dev', options = {}) {
|
||||
const { wechatDevtoolsCliPath } = options
|
||||
const platform = process.platform // darwin, win32, linux
|
||||
const { UNI_PLATFORM } = process.env // mp-weixin, mp-alipay, mp-lark
|
||||
|
||||
const uniPlatformText = UNI_PLATFORM === 'mp-weixin' ? '微信小程序' : UNI_PLATFORM === 'mp-alipay' ? '支付宝小程序' : UNI_PLATFORM === 'mp-lark' ? '抖音小程序' : '小程序'
|
||||
|
||||
// 项目路径(构建输出目录),根据环境选择不同目录
|
||||
const outputDir = env === 'build' ? `dist/build/${UNI_PLATFORM}` : `dist/dev/${UNI_PLATFORM}`
|
||||
const projectPath = path.resolve(process.cwd(), outputDir)
|
||||
|
||||
// 检查构建输出目录是否存在
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
console.log(`❌ ${uniPlatformText}构建目录不存在:`, projectPath)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`🚀 正在打开${uniPlatformText}开发者工具...`)
|
||||
|
||||
// 根据不同操作系统执行不同命令
|
||||
let command = ''
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS
|
||||
if (UNI_PLATFORM === 'mp-weixin') {
|
||||
const cliPath = wechatDevtoolsCliPath || '/Applications/wechatwebdevtools.app/Contents/MacOS/cli'
|
||||
command = `"${cliPath}" -o "${projectPath}"`
|
||||
}
|
||||
else if (UNI_PLATFORM === 'mp-alipay') {
|
||||
command = `/Applications/小程序开发者工具.app/Contents/MacOS/小程序开发者工具 --p "${projectPath}"`
|
||||
}
|
||||
else if (UNI_PLATFORM === 'mp-lark') {
|
||||
command = `/Applications/抖音开发者工具.app/Contents/MacOS/抖音开发者工具 --p "${projectPath}"`
|
||||
}
|
||||
}
|
||||
else if (platform === 'win32' || platform === 'win64') {
|
||||
// Windows
|
||||
if (UNI_PLATFORM === 'mp-weixin') {
|
||||
const cliPath = wechatDevtoolsCliPath || 'C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat'
|
||||
command = `"${cliPath}" -o "${projectPath}"`
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Linux 或其他系统
|
||||
console.log('❌ 当前系统不支持自动打开微信开发者工具')
|
||||
return
|
||||
}
|
||||
|
||||
exec(command, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.log(`❌ 打开${uniPlatformText}开发者工具失败:`, error.message)
|
||||
if (UNI_PLATFORM === 'mp-weixin') {
|
||||
console.log('💡 当前使用的微信开发者工具 CLI 命令:', command)
|
||||
console.log('💡 如果安装位置不同,可以在 env/.env 配置 WECHAT_DEVTOOLS_CLI_PATH 为本机实际 CLI 路径')
|
||||
}
|
||||
console.log(`💡 请确保${uniPlatformText}开发者工具服务端口已启用`)
|
||||
console.log(`💡 可以手动打开${uniPlatformText}开发者工具并导入项目:`, projectPath)
|
||||
return
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.log('⚠️ 警告:', stderr)
|
||||
}
|
||||
|
||||
console.log(`✅ ${uniPlatformText}开发者工具已打开`)
|
||||
|
||||
if (stdout) {
|
||||
console.log(stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Vite 插件,用于自动打开开发者工具
|
||||
* @param {object} options - 配置选项
|
||||
* @param {string} options.mode - 构建模式,'development' 或 'production'
|
||||
* @param {string} options.wechatDevtoolsCliPath - 微信开发者工具 CLI 路径
|
||||
*/
|
||||
export default function openDevTools(options = {}) {
|
||||
const { mode = 'development', wechatDevtoolsCliPath } = options
|
||||
// 根据 mode 确定环境:development -> dev, production -> build
|
||||
const env = mode === 'production' ? 'build' : 'dev'
|
||||
|
||||
// 首次构建标记
|
||||
let isFirstBuild = true
|
||||
|
||||
return {
|
||||
name: 'uni-devtools',
|
||||
writeBundle() {
|
||||
if (isFirstBuild && process.env.UNI_PLATFORM?.includes('mp')) {
|
||||
isFirstBuild = false
|
||||
_openDevTools(env, { wechatDevtoolsCliPath })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
95
mp-sc-frontend/scripts/postupgrade.js
Normal file
95
mp-sc-frontend/scripts/postupgrade.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// # 执行 `pnpm upgrade` 后会升级 `uniapp` 相关依赖
|
||||
// # 在升级完后,会自动添加很多无用依赖,这需要删除以减小依赖包体积
|
||||
// # 只需要执行下面的命令即可
|
||||
|
||||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
// 日志控制开关,设置为 true 可以启用所有日志输出
|
||||
const FG_LOG_ENABLE = true
|
||||
|
||||
// 将 exec 转换为返回 Promise 的函数
|
||||
const execPromise = promisify(exec)
|
||||
|
||||
// 定义要执行的命令
|
||||
const dependencies = [
|
||||
// TODO: 如果不需要某个平台的小程序,请手动删除或注释掉
|
||||
'@dcloudio/uni-mp-baidu',
|
||||
'@dcloudio/uni-mp-jd',
|
||||
'@dcloudio/uni-mp-kuaishou',
|
||||
'@dcloudio/uni-mp-qq',
|
||||
'@dcloudio/uni-mp-xhs',
|
||||
'@dcloudio/uni-quickapp-webview',
|
||||
]
|
||||
|
||||
/**
|
||||
* 带开关的日志输出函数
|
||||
* @param {string} message 日志消息
|
||||
* @param {string} type 日志类型 (log, error)
|
||||
*/
|
||||
function log(message, type = 'log') {
|
||||
if (FG_LOG_ENABLE) {
|
||||
if (type === 'error') {
|
||||
console.error(message)
|
||||
}
|
||||
else {
|
||||
console.log(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载单个依赖包
|
||||
* @param {string} dep 依赖包名
|
||||
* @returns {Promise<boolean>} 是否成功卸载
|
||||
*/
|
||||
async function uninstallDependency(dep) {
|
||||
try {
|
||||
log(`开始卸载依赖: ${dep}`)
|
||||
const { stdout, stderr } = await execPromise(`pnpm un ${dep}`)
|
||||
if (stdout) {
|
||||
log(`stdout [${dep}]: ${stdout}`)
|
||||
}
|
||||
if (stderr) {
|
||||
log(`stderr [${dep}]: ${stderr}`, 'error')
|
||||
}
|
||||
log(`成功卸载依赖: ${dep}`)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
// 单个依赖卸载失败不影响其他依赖
|
||||
log(`卸载依赖 ${dep} 失败: ${error.message}`, 'error')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 串行卸载所有依赖包
|
||||
*/
|
||||
async function uninstallAllDependencies() {
|
||||
log(`开始串行卸载 ${dependencies.length} 个依赖包...`)
|
||||
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
// 串行执行所有卸载命令
|
||||
for (const dep of dependencies) {
|
||||
const success = await uninstallDependency(dep)
|
||||
if (success) {
|
||||
successCount++
|
||||
}
|
||||
else {
|
||||
failedCount++
|
||||
}
|
||||
|
||||
// 为了避免命令执行过快导致的问题,添加短暂延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
log(`卸载操作完成: 成功 ${successCount} 个, 失败 ${failedCount} 个`)
|
||||
}
|
||||
|
||||
// 执行串行卸载
|
||||
uninstallAllDependencies().catch((err) => {
|
||||
log(`串行卸载过程中出现未捕获的错误: ${err}`, 'error')
|
||||
})
|
||||
270
mp-sc-frontend/scripts/upload-weixin.js
Normal file
270
mp-sc-frontend/scripts/upload-weixin.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 微信小程序 CLI 上传脚本
|
||||
*
|
||||
* 使用方法:
|
||||
* pnpm upload:mp # 版本号读取 package.json,描述使用最新 Git commit
|
||||
* pnpm upload:mp --version=1.0.1 # 指定版本号(覆盖 package.json)
|
||||
* pnpm upload:mp --desc="修复bug" # 指定版本描述(覆盖 Git commit)
|
||||
* pnpm upload:mp --robot=2 # 指定机器人编号(1-30)
|
||||
* pnpm upload:mp --version=2.0.0 --desc="重大更新" # 组合使用多个参数
|
||||
*
|
||||
* 版本号策略: 命令行参数 > package.json version
|
||||
* 描述策略: 命令行参数 > Git 最新 commit > 默认时间戳
|
||||
*
|
||||
* 注意事项:
|
||||
* 1. 确保已在微信公众平台开通 "小程序代码上传" 权限
|
||||
* 2. 确保私钥文件存在(private.${appid}.key),并且配置了上传IP白名单
|
||||
* 3. 上传前会自动执行 build:mp:prod 构建 并跳过打开开发者工具
|
||||
* 4. 秘钥文件的appid(VITE_WX_APPID)需要与微信公众平台的小程序appid一致
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ci from 'miniprogram-ci'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT_DIR = path.resolve(__dirname, '..')
|
||||
|
||||
// 从 package.json 读取版本号
|
||||
function getPackageVersion() {
|
||||
try {
|
||||
const pkgPath = path.resolve(ROOT_DIR, 'package.json')
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
||||
return pkg.version || '1.0.0'
|
||||
}
|
||||
catch {
|
||||
return '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最新的 Git commit 信息
|
||||
function getGitCommitMessage() {
|
||||
try {
|
||||
// 获取最新 commit 的作者和标题
|
||||
const message = execSync('git log -1 --pretty="%an: %s"', {
|
||||
cwd: ROOT_DIR,
|
||||
encoding: 'utf-8',
|
||||
}).trim()
|
||||
return message || null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 生成默认描述
|
||||
function getDefaultDesc() {
|
||||
// 优先使用 Git commit 信息
|
||||
const gitMessage = getGitCommitMessage()
|
||||
if (gitMessage) {
|
||||
return gitMessage
|
||||
}
|
||||
// 回退到时间戳
|
||||
return `上传于 ${new Date().toLocaleString('zh-CN')}`
|
||||
}
|
||||
|
||||
// 解析命令行参数
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2)
|
||||
const params = {
|
||||
version: null, // 稍后设置,优先级:命令行 > package.json
|
||||
desc: null, // 稍后设置,优先级:命令行 > Git commit > 默认
|
||||
robot: 1, // 机器人编号 1-30
|
||||
}
|
||||
|
||||
args.forEach((arg) => {
|
||||
if (arg.startsWith('--version=')) {
|
||||
params.version = arg.split('=')[1]
|
||||
}
|
||||
else if (arg.startsWith('--desc=')) {
|
||||
params.desc = arg.split('=')[1]
|
||||
}
|
||||
else if (arg.startsWith('--robot=')) {
|
||||
params.robot = Number.parseInt(arg.split('=')[1], 10)
|
||||
}
|
||||
})
|
||||
|
||||
// 如果命令行没有指定版本号,则读取 package.json
|
||||
if (!params.version) {
|
||||
params.version = getPackageVersion()
|
||||
}
|
||||
|
||||
// 如果命令行没有指定描述,则读取 Git commit 或使用默认
|
||||
if (!params.desc) {
|
||||
params.desc = getDefaultDesc()
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// 读取环境变量
|
||||
function loadEnvFile(mode = 'production') {
|
||||
const envPath = path.resolve(ROOT_DIR, 'env', `.env.${mode}`)
|
||||
const defaultEnvPath = path.resolve(ROOT_DIR, 'env', '.env')
|
||||
|
||||
const envContent = {}
|
||||
|
||||
// 先读取默认 .env
|
||||
if (fs.existsSync(defaultEnvPath)) {
|
||||
const content = fs.readFileSync(defaultEnvPath, 'utf-8')
|
||||
content.split('\n').forEach((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmed.split('=')
|
||||
if (key) {
|
||||
envContent[key.trim()] = valueParts.join('=').trim().replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 再读取对应模式的 .env 文件(会覆盖默认值)
|
||||
if (fs.existsSync(envPath)) {
|
||||
const content = fs.readFileSync(envPath, 'utf-8')
|
||||
content.split('\n').forEach((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmed.split('=')
|
||||
if (key) {
|
||||
envContent[key.trim()] = valueParts.join('=').trim().replace(/^['"]|['"]$/g, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return envContent
|
||||
}
|
||||
|
||||
// 获取私钥路径
|
||||
function getPrivateKeyPath(appid) {
|
||||
// 查找私钥文件
|
||||
const keyPatterns = [
|
||||
`private.${appid}.key`,
|
||||
'private.key',
|
||||
]
|
||||
|
||||
for (const pattern of keyPatterns) {
|
||||
const keyPath = path.resolve(ROOT_DIR, pattern)
|
||||
if (fs.existsSync(keyPath)) {
|
||||
return keyPath
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`未找到私钥文件,请确保项目根目录存在 private.${appid}.key 文件`)
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
console.log('\n🚀 开始微信小程序上传流程...\n')
|
||||
|
||||
const params = parseArgs()
|
||||
const env = loadEnvFile('production')
|
||||
const appid = env.VITE_WX_APPID
|
||||
|
||||
if (!appid) {
|
||||
throw new Error('未找到 VITE_WX_APPID 环境变量,请检查 env/.env 文件')
|
||||
}
|
||||
|
||||
console.log(`📱 AppID: ${appid}`)
|
||||
console.log(`📌 版本号: ${params.version}`)
|
||||
console.log(`📝 版本描述: ${params.desc}`)
|
||||
console.log(`🤖 机器人编号: ${params.robot}`)
|
||||
|
||||
// 获取私钥路径
|
||||
const privateKeyPath = getPrivateKeyPath(appid)
|
||||
console.log(`🔑 私钥路径: ${privateKeyPath}`)
|
||||
|
||||
// 构建小程序(跳过自动打开开发者工具)
|
||||
console.log('\n📦 正在构建小程序...(跳过自动打开开发者工具)\n')
|
||||
try {
|
||||
execSync('pnpm build:mp:prod', {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
SKIP_OPEN_DEVTOOLS: 'true', // 上传时跳过打开开发者工具
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('❌ 构建失败:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 小程序代码目录
|
||||
const projectPath = path.resolve(ROOT_DIR, 'dist', 'build', 'mp-weixin')
|
||||
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
throw new Error(`构建产物不存在: ${projectPath}`)
|
||||
}
|
||||
|
||||
console.log(`📂 项目路径: ${projectPath}`)
|
||||
console.log('\n⬆️ 正在上传到微信服务器...\n')
|
||||
|
||||
// 创建项目实例
|
||||
const project = new ci.Project({
|
||||
appid,
|
||||
type: 'miniProgram',
|
||||
projectPath,
|
||||
privateKeyPath,
|
||||
ignores: ['node_modules/**/*'],
|
||||
})
|
||||
|
||||
try {
|
||||
// 上传代码
|
||||
const uploadResult = await ci.upload({
|
||||
project,
|
||||
version: params.version,
|
||||
desc: params.desc,
|
||||
robot: params.robot,
|
||||
setting: {
|
||||
es6: true,
|
||||
es7: true,
|
||||
minify: true,
|
||||
autoPrefixWXSS: true,
|
||||
minifyWXML: true,
|
||||
minifyWXSS: true,
|
||||
minifyJS: true,
|
||||
},
|
||||
onProgressUpdate: (task) => {
|
||||
if (task._status === 'done') {
|
||||
console.log(` ✅ ${task._msg}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
console.log('\n✅ 上传成功!')
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
||||
console.log(` 📌 版本号: ${params.version}`)
|
||||
console.log(` 📝 描述: ${params.desc}`)
|
||||
console.log(` 🤖 机器人: ${params.robot}`)
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
|
||||
console.log('\n📋 下一步操作:')
|
||||
console.log(' 1. 登录微信公众平台: https://mp.weixin.qq.com')
|
||||
console.log(' 2. 进入 "管理 -> 版本管理"')
|
||||
console.log(' 3. 在 "开发版本" 中找到刚上传的版本')
|
||||
console.log(' 4. 点击 "选为体验版" 按钮\n')
|
||||
|
||||
return uploadResult
|
||||
}
|
||||
catch (error) {
|
||||
console.error('\n❌ 上传失败:', error.message)
|
||||
if (error.message.includes('privateKeyPath')) {
|
||||
console.log('\n💡 提示: 请确保已在微信公众平台配置代码上传密钥')
|
||||
console.log(' 1. 登录微信公众平台')
|
||||
console.log(' 2. 进入 "开发 -> 开发设置"')
|
||||
console.log(' 3. 在 "小程序代码上传" 区域生成并下载密钥')
|
||||
console.log(' 4. 在 "小程序代码上传" 区域配置上传IP白名单')
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ 执行出错:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
37
mp-sc-frontend/scripts/vite-plugin-eruda.js
Normal file
37
mp-sc-frontend/scripts/vite-plugin-eruda.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @description 通过 vite 自定义条件动态导入 eruda
|
||||
* @description Eruda 配置参考 https://eruda.liriliri.io/zh/docs/
|
||||
* @param {object} options
|
||||
* @param {boolean} [options.open] - 是否开启 eruda
|
||||
* @param {object} [options.erudaOptions] - eruda 配置
|
||||
* @param {string} [options.erudaUrl] - eruda 地址
|
||||
*/
|
||||
export default function vitePluginEruda(options = {}) {
|
||||
const { open = true, erudaOptions = {}, erudaUrl = 'https://cdn.jsdelivr.net/npm/eruda' } = options
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-eruda',
|
||||
|
||||
transformIndexHtml(html) {
|
||||
const tags = [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: {
|
||||
src: erudaUrl,
|
||||
},
|
||||
injectTo: 'head',
|
||||
},
|
||||
{
|
||||
tag: 'script',
|
||||
children: `eruda.init(${JSON.stringify(erudaOptions)});`,
|
||||
injectTo: 'head',
|
||||
},
|
||||
]
|
||||
|
||||
if (!open) {
|
||||
return html
|
||||
}
|
||||
return { html, tags }
|
||||
},
|
||||
}
|
||||
}
|
||||
40
mp-sc-frontend/src/App.ku.vue
Normal file
40
mp-sc-frontend/src/App.ku.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import FgTabbar from '@/tabbar/index.vue'
|
||||
import { isPageTabbar } from './tabbar/store'
|
||||
import { currRoute } from './utils'
|
||||
|
||||
const isCurrentPageTabbar = ref(true)
|
||||
onShow(() => {
|
||||
const { path } = currRoute()
|
||||
// “蜡笔小开心”提到本地是 '/pages/index/index',线上是 '/' 导致线上 tabbar 不见了
|
||||
// 所以这里需要判断一下,如果是 '/' 就当做首页,也要显示 tabbar
|
||||
if (path === '/') {
|
||||
isCurrentPageTabbar.value = true
|
||||
}
|
||||
else {
|
||||
isCurrentPageTabbar.value = isPageTabbar(path)
|
||||
}
|
||||
})
|
||||
|
||||
const helloKuRoot = ref('Hello AppKuVue')
|
||||
|
||||
const exposeRef = ref('this is form app.Ku.vue')
|
||||
|
||||
defineExpose({
|
||||
exposeRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view>
|
||||
<!-- 这个先隐藏了,知道这样用就行 -->
|
||||
<view class="hidden text-center">
|
||||
{{ helloKuRoot }},这里可以配置全局的东西
|
||||
</view>
|
||||
|
||||
<KuRootView />
|
||||
|
||||
<FgTabbar v-if="isCurrentPageTabbar" />
|
||||
</view>
|
||||
</template>
|
||||
51
mp-sc-frontend/src/App.vue
Normal file
51
mp-sc-frontend/src/App.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
|
||||
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
||||
import { navigateToInterceptor } from '@/router/interceptor'
|
||||
import { tabbarStore } from '@/tabbar/store'
|
||||
import { permission } from '@/router/permission'
|
||||
|
||||
const { proxy } = (getCurrentInstance() || {}) as any
|
||||
const router = proxy?.$router
|
||||
|
||||
router && permission.install(router)
|
||||
|
||||
onLaunch((options) => {
|
||||
console.log('App.vue onLaunch', options)
|
||||
})
|
||||
onShow((options) => {
|
||||
console.log('App.vue onShow', options)
|
||||
// 处理直接进入页面路由的情况
|
||||
if (options?.path) {
|
||||
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
|
||||
}
|
||||
else {
|
||||
navigateToInterceptor.invoke({ url: '/' })
|
||||
}
|
||||
})
|
||||
onHide(() => {
|
||||
console.log('App Hide')
|
||||
})
|
||||
|
||||
// #ifdef H5
|
||||
function syncTabbarWhenPageVisible() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
tabbarStore.syncCurIdxByCurrentPageAsync()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', syncTabbarWhenPageVisible)
|
||||
window.addEventListener('pageshow', syncTabbarWhenPageVisible)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', syncTabbarWhenPageVisible)
|
||||
window.removeEventListener('pageshow', syncTabbarWhenPageVisible)
|
||||
})
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
</style>
|
||||
588
mp-sc-frontend/src/api/appointment.ts
Normal file
588
mp-sc-frontend/src/api/appointment.ts
Normal file
@@ -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<IBanner[]>('/v1/banners')
|
||||
}
|
||||
|
||||
/** 获取系统通知 */
|
||||
export function getNotices() {
|
||||
return http.get<INotice[]>('/v1/notices')
|
||||
}
|
||||
|
||||
// ==================== 认证 ====================
|
||||
|
||||
/** 微信登录 */
|
||||
export function loginByCode(code: string) {
|
||||
return http.post<ILoginRes>('/v1/auth/login', { code })
|
||||
}
|
||||
|
||||
/** 获取用户信息 */
|
||||
export function getUserInfo() {
|
||||
return http.get<IUserInfo>('/v1/auth/userinfo')
|
||||
}
|
||||
|
||||
/** 更新用户信息 */
|
||||
export function updateUserInfo(data: Record<string, any>) {
|
||||
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<IEmployee[]>('/v1/appointment/employees/search', { keyword })
|
||||
}
|
||||
|
||||
/** 验证员工姓名+手机号 */
|
||||
export function verifyEmployee(name: string, phone: string) {
|
||||
return http.post<IEmployee>('/v1/appointment/employees/verify', { name, phone })
|
||||
}
|
||||
|
||||
/** 获取当前用户历史访客列表 */
|
||||
export function getMyVisitors() {
|
||||
return http.get<IVisitorVO[]>('/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<IGoodsTemplate[]>('/v1/appointment/goods-templates')
|
||||
}
|
||||
|
||||
/** 创建物品模板 */
|
||||
export function createGoodsTemplate(data: { name: string; quantity?: number; model?: string; remark?: string }) {
|
||||
return http.post<IGoodsTemplate>('/v1/appointment/goods-templates', data)
|
||||
}
|
||||
|
||||
/** 更新物品模板 */
|
||||
export function updateGoodsTemplate(id: number, data: Partial<IGoodsTemplate>) {
|
||||
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<any[]>('/v1/appointment/vehicles')
|
||||
}
|
||||
|
||||
/** 创建车辆模板 */
|
||||
export function createVehicleTemplate(data: { license_plate: string; brand?: string; color?: string }) {
|
||||
return http.post<any>('/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<string, any>) {
|
||||
return http.post<IAppointment>('/v1/appointment/appointments', data)
|
||||
}
|
||||
|
||||
/** 我的预约列表 */
|
||||
export function getMyAppointments(params: IPageParams) {
|
||||
return http.get<IPageResult<IAppointment>>('/v1/appointment/appointments/my', params)
|
||||
}
|
||||
|
||||
/** 全部预约列表(员工专属,按申请时间降序) */
|
||||
export function getAllAppointments(params: IPageParams & { status?: number }) {
|
||||
return http.get<IPageResult<IAppointment>>('/v1/appointment/appointments/all', params)
|
||||
}
|
||||
|
||||
/** 预约详情 */
|
||||
export function getAppointmentDetail(id: number) {
|
||||
return http.get<IAppointmentDetail>('/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<any[]>(`/v1/appointment/appointments/${id}/steps`)
|
||||
}
|
||||
|
||||
/** 取消预约 */
|
||||
export function cancelAppointment(id: number) {
|
||||
return http.put(`/v1/appointment/appointments/${id}/cancel`)
|
||||
}
|
||||
|
||||
// ==================== 邀请 ====================
|
||||
|
||||
/** 生成邀请 */
|
||||
export function createInvitation(data: Record<string, any>) {
|
||||
return http.post<IInvitation>('/v1/appointment/invitations', data)
|
||||
}
|
||||
|
||||
/** 公开邀请详情 */
|
||||
export function getInvitationDetail(code: string) {
|
||||
return http.get<IInvitation>(`/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<IVisitRecord>('/v1/guard/check-in', data)
|
||||
}
|
||||
|
||||
/** 登记出场 */
|
||||
export function checkOut(data: { appointment_id: number, remark?: string }) {
|
||||
return http.post<IVisitRecord>('/v1/guard/check-out', data)
|
||||
}
|
||||
|
||||
/** 获取预约的进出场记录 */
|
||||
export function getVisitRecords(appointmentId: number) {
|
||||
return http.get<IVisitRecord[]>('/v1/guard/records', { appointment_id: appointmentId })
|
||||
}
|
||||
|
||||
// ==================== 领导 ====================
|
||||
|
||||
/** 部门预约 */
|
||||
export function getLeaderAppointments(params: IPageParams) {
|
||||
return http.get<IPageResult<IAppointment>>('/v1/leader/appointments', params)
|
||||
}
|
||||
|
||||
// ==================== 管理 ====================
|
||||
|
||||
/** 角色列表 */
|
||||
export function getRoles() {
|
||||
return http.get<ISystemRole[]>('/v1/admin/roles')
|
||||
}
|
||||
|
||||
/** 创建角色 */
|
||||
export function createRole(data: Partial<ISystemRole>) {
|
||||
return http.post<ISystemRole>('/v1/admin/roles', data)
|
||||
}
|
||||
|
||||
/** 更新角色 */
|
||||
export function updateRole(id: number, data: Partial<ISystemRole>) {
|
||||
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<ISystemModule[]>('/v1/admin/modules')
|
||||
}
|
||||
|
||||
/** 创建模块 */
|
||||
export function createModule(data: Partial<ISystemModule>) {
|
||||
return http.post<ISystemModule>('/v1/admin/modules', data)
|
||||
}
|
||||
|
||||
/** 更新模块 */
|
||||
export function updateModule(id: number, data: Partial<ISystemModule>) {
|
||||
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<IPermission[]>('/v1/admin/permissions', params)
|
||||
}
|
||||
|
||||
/** 添加权限 */
|
||||
export function createPermission(data: { roleCode: string, moduleCode: string, resource: string, action: string }) {
|
||||
return http.post<IPermission>('/v1/admin/permissions', data)
|
||||
}
|
||||
|
||||
/** 删除权限 */
|
||||
export function deletePermission(id: number) {
|
||||
return http.delete(`/v1/admin/permissions/${id}`)
|
||||
}
|
||||
|
||||
/** 申请列表 */
|
||||
export function getApplications() {
|
||||
return http.get<IRoleApplication[]>('/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<IRoleApplication[]>('/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<string, any>
|
||||
}) {
|
||||
return http.post<IApprovalInstance>('/v1/approval/start', data)
|
||||
}
|
||||
|
||||
/** 审批通过 */
|
||||
export function approveRecord(data: {
|
||||
record_id: number
|
||||
comment?: string
|
||||
variables?: Record<string, any>
|
||||
}) {
|
||||
return http.post<IApprovalRecord>('/v1/approval/approve', data)
|
||||
}
|
||||
|
||||
/** 审批拒绝 */
|
||||
export function rejectRecord(data: {
|
||||
record_id: number
|
||||
comment?: string
|
||||
}) {
|
||||
return http.post<IApprovalRecord>('/v1/approval/reject', data)
|
||||
}
|
||||
|
||||
/** 获取待审批列表 */
|
||||
export function getPendingApprovals(params: {
|
||||
business_type?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return http.get<IPageResult<IApprovalRecord>>('/v1/approval/pending', params)
|
||||
}
|
||||
|
||||
/** 获取我的审批列表(按状态筛选) */
|
||||
export function getMyApprovals(params: {
|
||||
status?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return http.get<IPageResult<IApprovalRecord>>('/v1/approval/my', params)
|
||||
}
|
||||
|
||||
/** 获取审批实例详情 */
|
||||
export function getApprovalDetail(instanceId: number) {
|
||||
return http.get<IApprovalDetail>(`/v1/approval/instance/${instanceId}`)
|
||||
}
|
||||
|
||||
/** 获取审批模板列表 */
|
||||
export function getApprovalTemplates() {
|
||||
return http.get<IApprovalTemplate[]>('/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<IEmployeeCertification>('/v1/certification/submit', data)
|
||||
}
|
||||
|
||||
/** 获取我的认证申请 */
|
||||
export function getMyCertification() {
|
||||
return http.get<IEmployeeCertification | null>('/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<IPageResult<IEmployeeCertification>>('/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<IDepartmentTreeNode[]>('/v1/admin/department-tree')
|
||||
}
|
||||
|
||||
/** 获取所有部门(扁平列表) */
|
||||
export function adminGetAllDepartments() {
|
||||
return http.get<IDepartmentItem[]>('/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<string, any>) {
|
||||
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<IApprovalConfig[]>('/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<IApprovalNotifyInfo[]>('/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<any[]>('/v1/public/visit-types')
|
||||
}
|
||||
|
||||
/** 获取所有启用的到访区域 */
|
||||
export function getVisitorAreas() {
|
||||
return http.get<any[]>('/v1/public/visitor-areas')
|
||||
}
|
||||
17
mp-sc-frontend/src/api/foo-alova.ts
Normal file
17
mp-sc-frontend/src/api/foo-alova.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { API_DOMAINS, http } from '@/http/alova'
|
||||
|
||||
export interface IFoo {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export function foo() {
|
||||
return http.Get<IFoo>('/foo', {
|
||||
params: {
|
||||
name: '菲鸽',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
},
|
||||
meta: { domain: API_DOMAINS.SECONDARY }, // 用于切换请求地址
|
||||
})
|
||||
}
|
||||
43
mp-sc-frontend/src/api/foo.ts
Normal file
43
mp-sc-frontend/src/api/foo.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { http } from '@/http/http'
|
||||
|
||||
export interface IFoo {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export function foo() {
|
||||
return http.Get<IFoo>('/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<IFooItem>('/foo', { name })
|
||||
}
|
||||
/** GET 请求;支持 传递 header 的范例 */
|
||||
export function getFooAPI2(name: string) {
|
||||
return http.get<IFooItem>('/foo', { name }, { 'Content-Type-100': '100' })
|
||||
}
|
||||
|
||||
/** POST 请求 */
|
||||
export function postFooAPI(name: string) {
|
||||
return http.post<IFooItem>('/foo', { name })
|
||||
}
|
||||
/** POST 请求;需要传递 query 参数的范例;微信小程序经常有同时需要query参数和body参数的场景 */
|
||||
export function postFooAPI2(name: string) {
|
||||
return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 })
|
||||
}
|
||||
/** POST 请求;支持 传递 header 的范例 */
|
||||
export function postFooAPI3(name: string) {
|
||||
return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 }, { 'Content-Type-100': '100' })
|
||||
}
|
||||
85
mp-sc-frontend/src/api/login.ts
Normal file
85
mp-sc-frontend/src/api/login.ts
Normal file
@@ -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<ICaptcha>('/user/getCode')
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param loginForm 登录表单
|
||||
*/
|
||||
export function login(loginForm: ILoginForm) {
|
||||
return http.post<IAuthLoginRes>('/auth/login', loginForm)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token
|
||||
* @param refreshToken 刷新token
|
||||
*/
|
||||
export function refreshToken(refreshToken: string) {
|
||||
return http.post<IDoubleTokenRes>('/auth/refreshToken', { refreshToken })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
export function getUserInfo() {
|
||||
return http.get<IUserInfoRes>('/user/info')
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*/
|
||||
export function logout() {
|
||||
return http.get<void>('/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<UniApp.LoginRes>((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<IAuthLoginRes>('/auth/wxLogin', data)
|
||||
}
|
||||
428
mp-sc-frontend/src/api/types/appointment.ts
Normal file
428
mp-sc-frontend/src/api/types/appointment.ts
Normal file
@@ -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<T> {
|
||||
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[]
|
||||
}
|
||||
102
mp-sc-frontend/src/api/types/login.ts
Normal file
102
mp-sc-frontend/src/api/types/login.ts
Normal file
@@ -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
|
||||
}
|
||||
79
mp-sc-frontend/src/api/types/workflow.ts
Normal file
79
mp-sc-frontend/src/api/types/workflow.ts
Normal file
@@ -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;
|
||||
}
|
||||
76
mp-sc-frontend/src/api/workflow.ts
Normal file
76
mp-sc-frontend/src/api/workflow.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { http } from '@/http/http'
|
||||
import type { IProcessDefinition, IProcessInstance, ITask } from './types/workflow'
|
||||
|
||||
/**
|
||||
* 流程定义管理
|
||||
*/
|
||||
export const workflowAPI = {
|
||||
// 获取流程定义列表
|
||||
getProcessDefinitions() {
|
||||
return http.get<IProcessDefinition[]>('/v1/workflow/definitions')
|
||||
},
|
||||
|
||||
// 获取流程定义详情
|
||||
getProcessDefinition(id: number) {
|
||||
return http.get<IProcessDefinition>(`/v1/workflow/definitions/${id}`)
|
||||
},
|
||||
|
||||
// 创建流程定义
|
||||
createProcessDefinition(data: Partial<IProcessDefinition>) {
|
||||
return http.post<IProcessDefinition>('/v1/workflow/definitions', data)
|
||||
},
|
||||
|
||||
// 更新流程定义
|
||||
updateProcessDefinition(id: number, data: Partial<IProcessDefinition>) {
|
||||
return http.put<IProcessDefinition>(`/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<string, any>
|
||||
}) {
|
||||
return http.post<IProcessInstance>('/v1/workflow/instances', data)
|
||||
},
|
||||
|
||||
// 获取流程实例列表
|
||||
getProcessInstances(params?: {
|
||||
business_type?: string
|
||||
business_id?: number
|
||||
status?: string
|
||||
}) {
|
||||
return http.get<IProcessInstance[]>('/v1/workflow/instances', params)
|
||||
},
|
||||
|
||||
// 获取流程实例详情
|
||||
getInstanceDetail(id: number) {
|
||||
return http.get<{
|
||||
instance: IProcessInstance
|
||||
tasks: ITask[]
|
||||
}>(`/v1/workflow/instances/${id}`)
|
||||
},
|
||||
|
||||
// 获取待办任务
|
||||
getPendingTasks() {
|
||||
return http.get<ITask[]>('/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
|
||||
0
mp-sc-frontend/src/components/.gitkeep
Normal file
0
mp-sc-frontend/src/components/.gitkeep
Normal file
41
mp-sc-frontend/src/env.d.ts
vendored
Normal file
41
mp-sc-frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-svg-loader" />
|
||||
|
||||
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'
|
||||
242
mp-sc-frontend/src/hooks/useNavBar.ts
Normal file
242
mp-sc-frontend/src/hooks/useNavBar.ts
Normal file
@@ -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<number>
|
||||
// 标题栏内容高度(px)- 与胶囊对齐的区域
|
||||
titleBarHeight: Ref<number>
|
||||
// 整个导航栏总高度(px)- 状态栏 + 标题栏
|
||||
totalNavHeight: ComputedRef<number>
|
||||
// 胶囊按钮信息
|
||||
menuButtonInfo: Ref<MenuButtonInfo | null>
|
||||
// 是否是小程序环境
|
||||
isMiniProgram: Ref<boolean>
|
||||
// 重新获取系统信息
|
||||
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<number>(0)
|
||||
|
||||
// 标题栏内容高度(与胶囊对齐的区域)
|
||||
const titleBarHeight = ref<number>(44)
|
||||
|
||||
// 胶囊按钮信息
|
||||
const menuButtonInfo = ref<MenuButtonInfo | null>(null)
|
||||
|
||||
// 是否是小程序环境
|
||||
const isMiniProgram = ref<boolean>(false)
|
||||
|
||||
// 总高度(计算属性)
|
||||
const totalNavHeight = computed<number>(() => {
|
||||
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<number> {
|
||||
const statusBarHeight = ref<number>(0)
|
||||
|
||||
onMounted(() => {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
statusBarHeight.value = systemInfo.statusBarHeight || 20
|
||||
})
|
||||
|
||||
return statusBarHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取胶囊信息的快捷方法(仅小程序有效)
|
||||
*/
|
||||
export function useMenuButtonInfo(): Ref<MenuButtonInfo | null> {
|
||||
const menuButtonInfo = ref<MenuButtonInfo | null>(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
|
||||
}
|
||||
74
mp-sc-frontend/src/hooks/useRequest.test.ts
Normal file
74
mp-sc-frontend/src/hooks/useRequest.test.ts
Normal file
@@ -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<T>(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')
|
||||
})
|
||||
})
|
||||
54
mp-sc-frontend/src/hooks/useRequest.ts
Normal file
54
mp-sc-frontend/src/hooks/useRequest.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface IUseRequestOptions<T> {
|
||||
/** 是否立即执行 */
|
||||
immediate?: boolean
|
||||
/** 初始化数据 */
|
||||
initialData?: T
|
||||
}
|
||||
|
||||
interface IUseRequestReturn<T, P = undefined> {
|
||||
loading: Ref<boolean>
|
||||
error: Ref<boolean | Error>
|
||||
data: Ref<T | undefined>
|
||||
run: (args?: P) => Promise<T | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T, P = undefined>(
|
||||
func: (args?: P) => Promise<T>,
|
||||
options: IUseRequestOptions<T> = { immediate: false },
|
||||
): IUseRequestReturn<T, P> {
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
|
||||
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<T | undefined>)({} as P)
|
||||
}
|
||||
return { loading, error, data, run }
|
||||
}
|
||||
116
mp-sc-frontend/src/hooks/useScroll.md
Normal file
116
mp-sc-frontend/src/hooks/useScroll.md
Normal file
@@ -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
|
||||
<template>
|
||||
<view class="scroll-page">
|
||||
<scroll-view
|
||||
class="scroll-view"
|
||||
scroll-y
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="loading"
|
||||
@scrolltolower="onScrollToLower"
|
||||
@refresherrefresh="onRefresherRefresh"
|
||||
>
|
||||
<view v-for="item in list" :key="item.id" class="scroll-item">
|
||||
{{ item.name }}
|
||||
</view>
|
||||
|
||||
<!-- 加载状态提示 -->
|
||||
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||
<view v-if="finished" class="finished-tip">没有更多了</view>
|
||||
<view v-if="error" class="error-tip">加载失败,请重试</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@/hooks/useScroll'
|
||||
import { getList } from '@/service/list'
|
||||
|
||||
const { list, loading, finished, error, onScrollToLower, onRefresherRefresh } = useScroll(getList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 样式省略 */
|
||||
.scroll-page, .scroll-view {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## 实现步骤总结
|
||||
|
||||
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` 状态,在列表底部显示不同的提示信息,提升用户体验。
|
||||
|
||||
通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。
|
||||
74
mp-sc-frontend/src/hooks/useScroll.ts
Normal file
74
mp-sc-frontend/src/hooks/useScroll.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
interface UseScrollOptions<T> {
|
||||
fetchData: (page: number, pageSize: number) => Promise<T[]>
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
interface UseScrollReturn<T> {
|
||||
list: Ref<T[]>
|
||||
loading: Ref<boolean>
|
||||
finished: Ref<boolean>
|
||||
error: Ref<any>
|
||||
refresh: () => Promise<void>
|
||||
loadMore: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useScroll<T>({
|
||||
fetchData,
|
||||
pageSize = 10,
|
||||
}: UseScrollOptions<T>): UseScrollReturn<T> {
|
||||
const list = ref<T[]>([]) as Ref<T[]>
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const error = ref<any>(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,
|
||||
}
|
||||
}
|
||||
171
mp-sc-frontend/src/hooks/useUpload.ts
Normal file
171
mp-sc-frontend/src/hooks/useUpload.ts
Normal file
@@ -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<T extends TfileType> {
|
||||
formData?: Record<string, any>
|
||||
maxSize?: number
|
||||
accept?: T extends 'image' ? TImage[] : TFile[]
|
||||
fileType?: T
|
||||
success?: (params: any) => void
|
||||
error?: (err: any) => void
|
||||
}
|
||||
|
||||
export default function useUpload<T extends TfileType>(options: TOptions<T> = {} as TOptions<T>) {
|
||||
const {
|
||||
formData = {},
|
||||
maxSize = 5 * 1024 * 1024,
|
||||
accept = ['*'],
|
||||
fileType = 'image',
|
||||
success,
|
||||
error: onError,
|
||||
} = options
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const data = ref<any>(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<string, any>
|
||||
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,
|
||||
})
|
||||
}
|
||||
13
mp-sc-frontend/src/http/README.md
Normal file
13
mp-sc-frontend/src/http/README.md
Normal file
@@ -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号)。
|
||||
119
mp-sc-frontend/src/http/alova.ts
Normal file
119
mp-sc-frontend/src/http/alova.ts
Normal file
@@ -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
|
||||
200
mp-sc-frontend/src/http/http.ts
Normal file
200
mp-sc-frontend/src/http/http.ts
Normal file
@@ -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<T>(options: CustomRequestOptions) {
|
||||
// 1. 返回 Promise 对象
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
dataType: 'json',
|
||||
// #ifndef MP-WEIXIN
|
||||
responseType: 'json',
|
||||
// #endif
|
||||
// 响应成功
|
||||
success: async (res) => {
|
||||
const responseData = res.data as IResponse<T>
|
||||
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<T>(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<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||
return http<T>({
|
||||
url,
|
||||
query,
|
||||
method: 'GET',
|
||||
header,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
* @param url 后台地址
|
||||
* @param data 请求body参数
|
||||
* @param query 请求query参数,post请求也支持query,很多微信接口都需要
|
||||
* @param header 请求头,默认为json格式
|
||||
* @returns
|
||||
*/
|
||||
export function httpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||
return http<T>({
|
||||
url,
|
||||
query,
|
||||
data,
|
||||
method: 'POST',
|
||||
header,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
/**
|
||||
* PUT 请求
|
||||
*/
|
||||
export function httpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||
return http<T>({
|
||||
url,
|
||||
data,
|
||||
query,
|
||||
method: 'PUT',
|
||||
header,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求(无请求体,仅 query)
|
||||
*/
|
||||
export function httpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
|
||||
return http<T>({
|
||||
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
|
||||
69
mp-sc-frontend/src/http/interceptor.ts
Normal file
69
mp-sc-frontend/src/http/interceptor.ts
Normal file
@@ -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)
|
||||
},
|
||||
}
|
||||
68
mp-sc-frontend/src/http/tools/enum.ts
Normal file
68
mp-sc-frontend/src/http/tools/enum.ts
Normal file
@@ -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},请检查网络或联系管理员!`
|
||||
}
|
||||
29
mp-sc-frontend/src/http/tools/queryString.ts
Normal file
29
mp-sc-frontend/src/http/tools/queryString.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 将对象序列化为URL查询字符串,用于替代第三方的 qs 库,节省宝贵的体积
|
||||
* 支持基本类型值和数组,不支持嵌套对象
|
||||
* @param obj 要序列化的对象
|
||||
* @returns 序列化后的查询字符串
|
||||
*/
|
||||
export function stringifyQuery(obj: Record<string, any>): 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('&')
|
||||
}
|
||||
39
mp-sc-frontend/src/http/types.ts
Normal file
39
mp-sc-frontend/src/http/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数
|
||||
*/
|
||||
export type CustomRequestOptions = UniApp.RequestOptions & {
|
||||
query?: Record<string, any>
|
||||
/** 出错时是否隐藏错误提示 */
|
||||
hideErrorToast?: boolean
|
||||
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
|
||||
|
||||
/** 主要提供给 openapi-ts-request 生成的代码使用 */
|
||||
export type CustomRequestOptions_ = Omit<CustomRequestOptions, 'url'>
|
||||
|
||||
export interface HttpRequestResult<T> {
|
||||
promise: Promise<T>
|
||||
requestTask: UniApp.RequestTask
|
||||
}
|
||||
|
||||
// 通用响应格式
|
||||
export type IResponse<T = any> = {
|
||||
code: number
|
||||
data: T
|
||||
msg: string
|
||||
[key: string]: any // 允许额外属性
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PageParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 分页响应数据
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user