This commit is contained in:
huangjin
2026-06-29 17:34:59 +08:00
commit fe3ad20fe2
271 changed files with 51767 additions and 0 deletions

16
.codegraph/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,6 @@
{
"pid": 23452,
"version": "0.9.7",
"socketPath": "\\\\.\\pipe\\codegraph-8d231922d5519a75",
"startedAt": 1782091277662
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,3 @@
{
"topic_20260623-052546_02ce107c7f58bd23": "auto"
}

View File

@@ -0,0 +1,3 @@
{
"topic_20260623-052546_02ce107c7f58bd23": "因为项目中想要使用到审批流相关的内容…"
}

16
.vscode/launch.json vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
VITE_API_BASEURL=http://localhost:28175

1
mp-sc-admin/.env.prod Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASEURL=https://mp.sclktx.com/api

24
mp-sc-admin/.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

32
mp-sc-admin/package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View 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
View 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')

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

View File

@@ -0,0 +1,9 @@
@import "tailwindcss";
@import "element-plus/dist/index.css";
/* 默认边框颜色:避免 Tailwind base reset 导致未显式设置颜色的边框显示为黑色 */
*,
::before,
::after {
border-color: #e5e7eb;
}

View 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
View 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
View 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"]
}

View 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 },
// },
},
})

View 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).

View 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": []
}

View File

@@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
}

View 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
---

View 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: 开发工作流程和最佳实践指南
---

View 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` - 构建生产版本

View 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
---

View 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
---

View 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
---

View 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
View 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
View 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

View 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
View 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
View 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"
]
}

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

26
mp-sc-frontend/index.html Normal file
View 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>

View 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',
})

View 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
View 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"
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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-----

View 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)
})

View 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))
}

View 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 })
}
},
}
}

View 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')
})

View 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)
})

View 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 }
},
}
}

View 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>

View 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>

View 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')
}

View 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 }, // 用于切换请求地址
})
}

View 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' })
}

View 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)
}

View 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[]
}

View 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
}

View 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;
}

View 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

View File

41
mp-sc-frontend/src/env.d.ts vendored Normal file
View 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'

View 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
}

View 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 后变 falsedata 更新为返回值', 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')
})
})

View 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 }
}

View 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` 状态,在列表底部显示不同的提示信息,提升用户体验。
通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。

View 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,
}
}

View 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,
})
}

View 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号

View 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

View 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

View 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)
},
}

View File

@@ -0,0 +1,68 @@
export enum ResultEnum {
// 0和200当做成功都很普遍这里直接兼容两者PS0和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},请检查网络或联系管理员!`
}

View 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('&')
}

View 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