Compare commits

...

12 Commits

Author SHA1 Message Date
ChoChoX
63b6cc93c6 Merge branch 'main' of https://chochox.asia/ChoChoX/backmanagerweb 2026-06-27 17:32:45 +08:00
99e87a6e8c fixbug 2026-06-26 21:44:07 +08:00
a002bd56ad 新增readme 2026-06-26 20:24:44 +08:00
37e2e8df74 新增readme 2026-06-26 10:25:52 +08:00
ccc9cb717a 修改了大部分的显示逻辑和输入逻辑 2026-06-26 09:44:11 +08:00
f6ebafccb9 fix bug 2026-06-26 01:25:15 +08:00
ChoChoX
468b762e8e 更新使用说明文档 2026-06-25 21:54:08 +08:00
ChoChoX
984d5b8118 测试暂未发现问题 2026-06-25 21:49:55 +08:00
ChoChoX
5cd569797c 员工和用户整合前暂存 2026-06-24 16:42:13 +08:00
ChoChoX
f856bcbc38 暂存更改 2026-06-23 10:52:08 +08:00
ChoChoX
cc36e9fede 111 2026-06-22 23:06:04 +08:00
ChoChoX
5f67bf9122 暂存更改 2026-06-22 22:18:08 +08:00
34 changed files with 6082 additions and 275 deletions

315
README.md
View File

@@ -1,5 +1,314 @@
# Vue 3 + Vite # 🍦 蜜雪冰城管理系统 (Mixue Management System)
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. **蜜雪冰城管理系统**是一个面向企业内部的 Web 管理后台,基于 Vue 3 + Element Plus 构建。用于管理客户、合同、售后、产品、供应商、员工及系统用户等核心业务模块,为蜜雪冰城品牌运营提供一体化管理支持。
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support). > 前端项目地址:`backmanagerweb`
> 后端项目地址:`backmanager-server`Express + MySQL
---
## 目录
- [功能概览](#功能概览)
- [技术栈](#技术栈)
- [快速开始](#快速开始)
- [项目结构](#项目结构)
- [页面与模块说明](#页面与模块说明)
- [权限体系](#权限体系)
- [与后端 API 的关系](#与后端-api-的关系)
- [开发命令](#开发命令)
- [构建部署](#构建部署)
---
## 功能概览
| 模块 | 说明 | 权限标识 |
|------|------|---------|
| 🏠 **首页** | 欢迎页,展示蜜雪冰城品牌视频与雪王简介 | 登录即可访问 |
| 👥 **客户管理** | 客户信息 CRUD、客户列表搜索、详情查看 | `customer:read` / `customer:create` / `customer:update` / `customer:delete` |
| 📄 **合同管理** | 合同 CRUD关联客户支持金额与签约日期管理 | `contract:read` / `contract:*` |
| 🔧 **售后管理** | 售后服务记录管理 | `after_sale:read` / `after_sale:*` |
| 🧋 **产品管理** | 产品(饮品)信息管理 | `product:read` / `product:*` |
| 📦 **供应商管理** | 供应商信息管理 | `supplier:read` / `supplier:*` |
| 👤 **员工管理** | 企业员工信息(花名册)管理,含薪资可见范围控制 | `employee:read` / `employee:create` / `employee:update` / `employee:delete` |
| 🔐 **用户管理** | 系统登录用户管理(管理员专有模块) | `user:manage` |
---
## 技术栈
| 技术 | 版本 | 用途 |
|------|------|------|
| **Vue 3** | ^3.5 | 前端框架Composition API + `<script setup>` |
| **Vite** | ^8.0 | 构建工具 |
| **Element Plus** | ^2.14 | UI 组件库 |
| **@element-plus/icons-vue** | ^2.3 | 图标库 |
| **Vue Router 4** | ^4.x | 前端路由 |
| **Axios** | ^1.18 | HTTP 请求库 |
| **element-china-area-data** | ^5.0 | 中国省市区数据(合同模块使用) |
---
## 快速开始
### 前置条件
- Node.js >= 18
- 后端项目 `backmanager-server` 已启动运行(默认端口 3000
### 安装与运行
```bash
# 1. 克隆项目
git clone <repo-url>
cd backmanagerweb
# 2. 安装依赖
npm install
# 3. 启动开发服务器(默认端口 5173
npm run dev
```
浏览器打开 `http://localhost:5173` 即可访问。
> 开发环境下,`/api` 请求会自动代理到 `http://localhost:3000`(参见 `vite.config.js`)。
### 登录
项目默认对接后端认证系统,使用后端预置的管理员账号登录:
| 用户名 | 密码 | 角色 |
|--------|------|------|
| `admin` | 请联系管理员 | 信息技术部 · 超管 |
具体账号信息由后端 `db.js` 中的种子数据定义。
---
## 项目结构
```
backmanagerweb/
├── public/ # 静态资源
│ ├── logo.png # 系统 Logo
│ ├── mixue.png # 雪王头像(首页)
│ ├── 首页顶图.mp4 # 首页视频横幅
│ └── 登陆界面背景.mp4 # 登录页背景视频
├── src/
│ ├── api/ # API 请求层
│ │ ├── request.js # Axios 实例与响应拦截器
│ │ ├── employee.js # 员工相关 API
│ │ ├── user.js # 用户相关 API
│ │ ├── customer.js # 客户相关 API
│ │ ├── contract.js # 合同相关 API
│ │ ├── service.js # 售后相关 API
│ │ ├── products.js # 产品相关 API
│ │ ├── suppliers.js # 供应商相关 API
│ │ └── mock/ # Mock 数据(开发参考)
│ ├── components/ # 页面组件
│ │ ├── Login.vue # 登录页
│ │ ├── panel.vue # 主布局(顶栏 + 侧栏 + 内容区)
│ │ ├── home.vue # 首页
│ │ ├── employee.vue # 员工管理
│ │ ├── user.vue # 用户管理
│ │ ├── customer.vue # 客户管理
│ │ ├── contract.vue # 合同管理
│ │ ├── service.vue # 售后管理
│ │ ├── products.vue # 产品管理
│ │ └── supplier.vue # 供应商管理
│ ├── constants/ # 常量定义
│ │ └── departments.js # 部门列表常量
│ ├── store/
│ │ └── user.js # 用户状态管理(登录/登出/权限检查)
│ ├── utils/
│ │ └── date.js # 日期格式化工具
│ ├── router.js # 路由配置
│ ├── main.js # 应用入口
│ ├── App.vue # 根组件
│ └── style.css # 全局样式
├── index.html # HTML 入口
├── vite.config.js # Vite 配置
├── package.json
└── README.md
```
---
## 页面与模块说明
### 登录页 (`Login.vue`)
- 背景播放品牌视频,半透明遮罩,右侧展示登录卡片
- 支持用户名/密码表单验证
- "记住我" 复选框(当前为前端状态,可扩展持久化)
- 登录成功后跳转到 `/panel/home`
### 主布局 (`panel.vue`)
- **顶栏**Logo + 系统名称 + 当前用户信息(姓名 / 部门)+ 退出登录按钮
- **侧栏**:根据当前用户的权限动态展示可访问的菜单项(通过 `hasPermission` 控制)
- **内容区**:嵌套 `<router-view>` 渲染子页面
- 侧栏可收缩
### 首页 (`home.vue`)
- 品牌视频横幅 + 雪王简介卡片
- 展示雪王的基本信息(生日、性格、职位等)
### 员工管理 (`employee.vue`)
- **列表**:支持按 ID、姓名、部门搜索按在职/离职状态筛选
- **分页**:服务端分页(向后端发送 `page`/`pageSize` 参数)
- **CRUD**:新增、编辑、删除员工信息
- **薪资可见范围**:仅"总经理办公室"和"财务部"用户可见薪资字段
- **创建用户联动**:新增员工后可选择同步创建系统用户
- **部门**:部门列表与后端数据库 6 个部门保持一致
### 用户管理 (`user.vue`)
- **权限**:仅 `user:manage` 权限的用户(信息技术部管理员)可访问
- **列表**支持按用户名、真实姓名、ID 搜索,按部门/状态筛选
- **分页**:服务端分页
- **CRUD**:系统用户的创建、编辑、启用/禁用、删除
- **关联员工**:创建用户时可关联已有员工(员工下拉列表使用 `/api/employees/simple` 接口,仅展示在职员工基本信息)
- **安全保护**:后端禁止删除自身账号或最后一个超级管理员
### 其它模块
| 模块 | 关键特性 |
|------|---------|
| **客户管理** | 客户信息维护,支持按名称/联系方式搜索 |
| **合同管理** | 合同信息管理,关联客户,支持省市区选择 |
| **售后管理** | 售后服务记录管理 |
| **产品管理** | 产品(饮品)信息管理 |
| **供应商管理** | 供应商基本信息管理 |
---
## 权限体系
### 权限模型
系统采用**基于资源+操作的权限模型**,每个权限表示为 `resource:action` 格式的字符串。用户的权限列表在登录时从后端获取,存储在 Vue Store 中。
### 资源与操作
| 资源 | 操作 | 说明 |
|------|------|------|
| `customer` | `read` / `create` / `update` / `delete` | 客户管理 |
| `contract` | `read` / `create` / `update` / `delete` | 合同管理 |
| `after_sale` | `read` / `create` / `update` / `delete` | 售后管理 |
| `product` | `read` / `create` / `update` / `delete` | 产品管理 |
| `supplier` | `read` / `create` / `update` / `delete` | 供应商管理 |
| `employee` | `read` / `create` / `update` / `delete` | 员工管理 |
| `user` | `manage` | 用户管理(仅超管角色) |
### 权限检查
```javascript
import { hasPermission } from '../store/user'
// 检查当前用户是否拥有某个权限
hasPermission('employee', 'create') // → true/false
```
### 数据范围
员工列表受**数据范围data scope** 控制:
- **信息技术部、总经理办公室**:查看全部员工
- **招商部、运营部、采购部**:仅查看本部门员工
- **财务部**:查看全部员工(同时可查看薪资)
### 菜单可见性
侧栏菜单项根据当前用户的权限动态渲染(在 `panel.vue` 中通过 `v-if="checkPermission(...)"` 控制)。
---
## 与后端 API 的关系
前端通过 Axios 请求后端 RESTful API所有请求以 `/api` 为前缀。
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/auth/login` | POST | 登录认证 |
| `/api/auth/userinfo` | GET | 获取当前用户信息与权限 |
| `/api/employees` | GET/POST/PUT/DELETE | 员工 CRUD |
| `/api/employees/simple` | GET | 简易员工列表(下拉专用,仅在职) |
| `/api/users` | GET/POST/PUT/DELETE | 用户 CRUD |
| `/api/user/list` | GET | 简易用户列表(下拉专用) |
| `/api/customers` | GET/POST/PUT/DELETE | 客户 CRUD |
| `/api/contracts` | GET/POST/PUT/DELETE | 合同 CRUD |
| `/api/after-sales` | GET/POST/PUT/DELETE | 售后 CRUD |
| `/api/products` | GET/POST/PUT/DELETE | 产品 CRUD |
| `/api/suppliers` | GET/POST/PUT/DELETE | 供应商 CRUD |
> 详细 API 文档请参见后端项目的 `API.md`。
### 请求配置
基础请求配置在 `src/api/request.js` 中定义,包括:
- 自动附加 `Authorization: Bearer <token>` 请求头
- 响应拦截器统一处理错误401 跳转登录、403 无权限提示等)
- 自动解析 `res.data` 数据
---
## 开发命令
```bash
# 启动开发服务器(热更新)
npm run dev
# 构建生产版本
npm run build
# 预览生产构建
npm run preview
```
---
## 构建部署
```bash
# 构建
npm run build
# 构建产物在 dist/ 目录
# 将 dist/ 内容部署到任意静态文件服务器或 Nginx
```
### Nginx 反向代理示例
```nginx
server {
listen 80;
server_name your-domain.com;
root /path/to/backmanagerweb/dist;
index index.html;
# SPA 路由重写
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理到后端
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
---
## 品牌说明
本项目是为 **蜜雪冰城Mixue Ice Cream & Tea** 量身定制的管理系统品牌视觉元素Logo、雪王 IP、品牌色 `#E60012` 等)均归属蜜雪冰城所有。
> "你爱我,我爱你,蜜雪冰城甜蜜蜜" 🎵

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link href="/favicon.svg" rel="icon" type="image/svg+xml" /> <link href="./public/mixue.png" rel="icon" type="image/png" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" /> <meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>backmanagerweb</title> <title>蜜雪冰城管理系统</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

41
package-lock.json generated
View File

@@ -9,8 +9,10 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"element-china-area-data": "^5.0.2",
"element-plus": "^2.14.1", "element-plus": "^2.14.1",
"vue": "^3.5.34" "vue": "^3.5.34",
"vue-router": "4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
@@ -549,6 +551,12 @@
"@vue/shared": "3.5.35" "@vue/shared": "3.5.35"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.35", "version": "3.5.35",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
@@ -643,6 +651,12 @@
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/china-area-data": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/china-area-data/-/china-area-data-5.0.1.tgz",
"integrity": "sha512-BQDPpiv5Nn+018ekcJK2oSD9PAD+E1bvXB0wgabc//dFVS/KvRqCgg0QOEUt3vBkx9XzB5a9BmkJCEZDBxVjVw==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -665,6 +679,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/element-china-area-data": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/element-china-area-data/-/element-china-area-data-5.0.2.tgz",
"integrity": "sha512-vLQuvOKJy/uiX7MRHEk3x/j09hipuIl6DJ/C4XFUG7D7Pj3O47sy+Y6aAArM6k9v8cD9UX6e+yz2S4J+IPnZ8g==",
"license": "MIT",
"dependencies": {
"china-area-data": "^5.0.1",
"lodash-es": "^4.17.15"
}
},
"node_modules/element-plus": { "node_modules/element-plus": {
"version": "2.14.1", "version": "2.14.1",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz", "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz",
@@ -1284,6 +1308,21 @@
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.3.tgz", "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.3.tgz",
"integrity": "sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==", "integrity": "sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==",
"license": "MIT" "license": "MIT"
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
} }
} }
} }

View File

@@ -10,7 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"element-china-area-data": "^6.1.0", "axios": "^1.18.1",
"element-china-area-data": "^5.0.2",
"element-plus": "^2.14.1", "element-plus": "^2.14.1",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-router": "4" "vue-router": "4"

26
src/api/contract.js Normal file
View File

@@ -0,0 +1,26 @@
import request from './request'
// 获取合同列表
export const getContractList = (params) => {
return request.get('/contracts', { params })
}
// 获取合同详情
export const getContractDetail = (id) => {
return request.get(`/contracts/${id}`)
}
// 新增合同
export const createContract = (data) => {
return request.post('/contracts', data)
}
// 更新合同
export const updateContract = (id, data) => {
return request.put(`/contracts/${id}`, data)
}
// 删除合同
export const deleteContractAPI = (id) => {
return request.delete(`/contracts/${id}`)
}

31
src/api/customer.js Normal file
View File

@@ -0,0 +1,31 @@
import request from './request'
// 获取简易客户列表(用于下拉选择)
export const getSimpleCustomerList = (params, config) => {
return request.get('/customers/simple', { params, ...config })
}
// 获取客户列表
export const getCustomerList = (params, config) => {
return request.get('/customers', { params, ...config })
}
// 获取客户详情
export const getCustomerDetail = (id) => {
return request.get(`/customers/${id}`)
}
// 新增客户
export const createCustomer = (data) => {
return request.post('/customers', data)
}
// 更新客户
export const updateCustomer = (id, data) => {
return request.put(`/customers/${id}`, data)
}
// 删除客户
export const deleteCustomerAPI = (id) => {
return request.delete(`/customers/${id}`)
}

44
src/api/employee.js Normal file
View File

@@ -0,0 +1,44 @@
import request from './request'
import { DEPARTMENT_LIST } from '../constants/departments'
// 获取员工列表
export const getEmployeeList = (params, config) => {
return request.get('/employees', { params, ...config })
}
// 获取简易员工列表(无数据范围限制,支持按部门过滤)
export const getSimpleEmployeeList = (params, config) => {
return request.get('/employees/simple', { params, ...config })
}
// 获取员工详情
export const getEmployeeDetail = (id) => {
return request.get(`/employees/${id}`)
}
// 新增员工
export const createEmployee = (data) => {
return request.post('/employees', data)
}
// 更新员工
export const updateEmployee = (id, data) => {
return request.put(`/employees/${id}`, data)
}
// 删除员工
export const deleteEmployeeAPI = (id) => {
return request.delete(`/employees/${id}`)
}
/**
* 获取部门列表
*
* 当前返回本地常量(与后端数据库一致)。
* 当后端部署了 GET /api/departments 接口后,将下方注释替换为:
*
* export const getDepartmentList = () => request.get('/departments')
*/
export const getDepartmentList = () => {
return Promise.resolve({ code: 0, message: 'ok', data: DEPARTMENT_LIST })
}

147
src/api/mock/contract.js Normal file
View File

@@ -0,0 +1,147 @@
// 模拟合同数据
const mockContracts = [
{
id: 1,
contract_name: '蜜雪冰城加盟合同',
type: '加盟合同',
party_a: '蜜雪冰城股份有限公司',
party_b: '张三',
sign_date: '2025-01-15',
start_date: '2025-02-01',
end_date: '2028-01-31',
amount: 300000,
status: '生效中',
remark: '三年期加盟合同',
create_time: '2025-01-15 10:30:00'
},
{
id: 2,
contract_name: '原材料供货合同',
type: '供货合同',
party_a: '蜜雪冰城股份有限公司',
party_b: '李四',
sign_date: '2025-03-10',
start_date: '2025-04-01',
end_date: '2026-03-31',
amount: 50000,
status: '生效中',
remark: '年度供货协议',
create_time: '2025-03-10 09:15:00'
},
{
id: 3,
contract_name: '门店租赁合同',
type: '租赁合同',
party_a: '蜜雪冰城股份有限公司',
party_b: '王五',
sign_date: '2024-06-20',
start_date: '2024-07-01',
end_date: '2025-06-30',
amount: 120000,
status: '已到期',
remark: '已续签',
create_time: '2024-06-20 14:20:00'
},
{
id: 4,
contract_name: '设备维护服务合同',
type: '服务合同',
party_a: '蜜雪冰城股份有限公司',
party_b: '赵六',
sign_date: '2025-05-01',
start_date: '2025-05-15',
end_date: '2026-05-14',
amount: 15000,
status: '待审批',
remark: '',
create_time: '2025-05-01 11:00:00'
}
]
// 模拟网络延迟
const delay = (ms = 300) => new Promise(r => setTimeout(r, ms))
// Mock API
export const mockContractAPI = {
// 获取列表
async getList(params = {}) {
await delay()
let result = [...mockContracts]
// 关键词搜索
if (params.keyword) {
const keyword = params.keyword.toLowerCase()
result = result.filter(item =>
String(item.id).includes(keyword) ||
item.contract_name.toLowerCase().includes(keyword) ||
item.party_b.toLowerCase().includes(keyword)
)
}
// 合同类型筛选
if (params.type) {
result = result.filter(item => item.type === params.type)
}
// 日期范围筛选(按签订日期)
if (params.startDate && params.endDate) {
const start = new Date(params.startDate)
const end = new Date(params.endDate)
result = result.filter(item => {
const signDate = new Date(item.sign_date)
return signDate >= start && signDate <= end
})
}
// 状态筛选
if (params.status) {
result = result.filter(item => item.status === params.status)
}
return { code: 200, data: result, total: result.length }
},
// 获取详情
async getDetail(id) {
await delay()
const contract = mockContracts.find(item => item.id === id)
if (contract) {
return { code: 200, data: contract }
}
return { code: 404, message: '合同不存在' }
},
// 新增
async create(data) {
await delay()
const newContract = {
id: Date.now(),
...data,
create_time: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
mockContracts.push(newContract)
return { code: 200, data: newContract, message: '新增成功' }
},
// 更新
async update(id, data) {
await delay()
const index = mockContracts.findIndex(item => item.id === id)
if (index !== -1) {
mockContracts[index] = { ...mockContracts[index], ...data }
return { code: 200, data: mockContracts[index], message: '更新成功' }
}
return { code: 404, message: '合同不存在' }
},
// 删除
async delete(id) {
await delay()
const index = mockContracts.findIndex(item => item.id === id)
if (index !== -1) {
mockContracts.splice(index, 1)
return { code: 200, message: '删除成功' }
}
return { code: 404, message: '合同不存在' }
}
}

146
src/api/mock/customer.js Normal file
View File

@@ -0,0 +1,146 @@
// 模拟客户数据
const mockCustomers = [
{
id: 1,
name: '张三',
phone: '13800138001',
province: '广东省',
city: '深圳市',
district: '南山区',
address: '科技园路1号',
email: 'zhangsan@example.com',
create_time: '2025-01-15 10:30:00'
},
{
id: 2,
name: '李四',
phone: '13800138002',
province: '浙江省',
city: '杭州市',
district: '西湖区',
address: '文三路100号',
email: 'lisi@example.com',
create_time: '2025-02-20 14:20:00'
},
{
id: 3,
name: '王五',
phone: '13800138003',
province: '四川省',
city: '成都市',
district: '武侯区',
address: '天府大道200号',
email: 'wangwu@example.com',
create_time: '2025-03-10 09:15:00'
},
{
id: 4,
name: '赵六',
phone: '13800138004',
province: '湖南省',
city: '长沙市',
district: '岳麓区',
address: '麓山南路100号',
email: 'zhaoliu@example.com',
create_time: '2025-04-05 16:45:00'
}
]
// 模拟网络延迟
const delay = (ms = 300) => new Promise(r => setTimeout(r, ms))
// Mock API
export const mockCustomerAPI = {
// 获取列表
async getList(params = {}) {
await delay()
let result = [...mockCustomers]
// 关键词搜索
if (params.keyword) {
const keyword = params.keyword.toLowerCase()
const searchField = params.searchField || '1'
if (searchField === '2') {
// 编号:精确匹配
result = result.filter(item => String(item.id) === keyword)
} else if (searchField === '1') {
// 姓名:模糊匹配
result = result.filter(item => item.name.includes(keyword))
} else if (searchField === '3') {
// 电话:模糊匹配
result = result.filter(item => item.phone.includes(keyword))
} else if (searchField === '4') {
// 邮箱:模糊匹配
result = result.filter(item => item.email.includes(keyword))
} else {
// 默认:模糊匹配所有字段
result = result.filter(item =>
String(item.id).includes(keyword) ||
item.name.includes(keyword) ||
item.phone.includes(keyword) ||
item.email.includes(keyword)
)
}
}
// 地区筛选(省/市/区)
if (params.province) {
result = result.filter(item => item.province === params.province)
}
if (params.city) {
result = result.filter(item => item.city === params.city)
}
if (params.district) {
result = result.filter(item => item.district === params.district)
}
return { code: 200, data: result, total: result.length }
},
// 获取详情
async getDetail(id) {
await delay()
const customer = mockCustomers.find(item => item.id === id)
if (customer) {
return { code: 200, data: customer }
}
return { code: 404, message: '客户不存在' }
},
// 新增
async create(data) {
await delay()
// 生成自增 ID
const maxId = mockCustomers.length > 0 ? Math.max(...mockCustomers.map(item => item.id)) : 0
const newCustomer = {
id: maxId + 1,
...data,
create_time: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
mockCustomers.push(newCustomer)
return { code: 200, data: newCustomer, message: '新增成功' }
},
// 更新
async update(id, data) {
await delay()
const index = mockCustomers.findIndex(item => item.id === id)
if (index !== -1) {
mockCustomers[index] = { ...mockCustomers[index], ...data }
return { code: 200, data: mockCustomers[index], message: '更新成功' }
}
return { code: 404, message: '客户不存在' }
},
// 删除
async delete(id) {
await delay()
const index = mockCustomers.findIndex(item => item.id === id)
if (index !== -1) {
mockCustomers.splice(index, 1)
return { code: 200, message: '删除成功' }
}
return { code: 404, message: '客户不存在' }
}
}

239
src/api/mock/employee.js Normal file
View File

@@ -0,0 +1,239 @@
// 模拟员工数据(单一数据源,同时包含用户字段)
export const mockEmployees = [
{
id: 1,
name: '张伟',
gender: '男',
age: 28,
education: '本科',
department: '运营部',
entry_date: '2023-03-15',
position: '运营主管',
salary: 8500.00,
phone: '13800138001',
email: 'zhangwei@mxbc.com',
status: 1,
remark: '负责门店日常运营',
// 用户字段
username: 'zhangwei',
password: '123456',
user_type: 'admin',
user_status: '1',
created_at: '2023-03-15 09:00:00',
updated_at: '2023-03-15 09:00:00'
},
{
id: 2,
name: '李娜',
gender: '女',
age: 25,
education: '专科',
department: '销售部',
entry_date: '2023-06-01',
position: '销售专员',
salary: 6000.00,
phone: '13800138002',
email: 'lina@mxbc.com',
status: 1,
remark: '',
// 用户字段
username: 'lina',
password: '123456',
user_type: 'user',
user_status: '1',
created_at: '2023-06-01 09:00:00',
updated_at: '2023-06-01 09:00:00'
},
{
id: 3,
name: '王强',
gender: '男',
age: 32,
education: '硕士',
department: '技术部',
entry_date: '2022-11-10',
position: '技术总监',
salary: 15000.00,
phone: '13800138003',
email: 'wangqiang@mxbc.com',
status: 1,
remark: '负责系统架构设计',
// 用户字段(无用户账号)
username: null,
password: null,
user_type: null,
user_status: null,
created_at: '2022-11-10 09:00:00',
updated_at: '2022-11-10 09:00:00'
},
{
id: 4,
name: '刘洋',
gender: '女',
age: 23,
education: '本科',
department: '市场部',
entry_date: '2024-01-20',
position: '市场专员',
salary: 5500.00,
phone: '13800138004',
email: 'liuyang@mxbc.com',
status: 0,
remark: '已离职',
// 用户字段(无用户账号)
username: null,
password: null,
user_type: null,
user_status: null,
created_at: '2024-01-20 09:00:00',
updated_at: '2025-03-01 17:00:00'
},
{
id: 5,
name: '赵敏',
gender: '女',
age: 27,
education: '本科',
department: '财务部',
entry_date: '2023-09-05',
position: '财务会计',
salary: 7000.00,
phone: '13800138005',
email: 'zhaomin@mxbc.com',
status: 1,
remark: '',
// 用户字段
username: 'zhaomin',
password: '123456',
user_type: 'user',
user_status: '0',
created_at: '2023-09-05 09:00:00',
updated_at: '2023-09-05 09:00:00'
},
{
id: 6,
name: '陈刚',
gender: '男',
age: 35,
education: '高中',
department: '仓储部',
entry_date: '2022-04-12',
position: '仓库管理员',
salary: 5000.00,
phone: '13800138006',
email: 'chengang@mxbc.com',
status: 1,
remark: '负责原料入库出库',
// 用户字段(无用户账号)
username: null,
password: null,
user_type: null,
user_status: null,
created_at: '2022-04-12 09:00:00',
updated_at: '2022-04-12 09:00:00'
}
]
// 模拟网络延迟
const delay = (ms = 300) => new Promise(r => setTimeout(r, ms))
// Mock API
export const mockEmployeeAPI = {
// 获取列表
async getList(params = {}) {
await delay()
let result = [...mockEmployees]
// 关键词搜索
if (params.keyword) {
const keyword = params.keyword.toLowerCase()
const searchField = params.searchField || '1'
if (searchField === '1') {
// ID精确匹配
result = result.filter(item => String(item.id) === keyword)
} else if (searchField === '2') {
// 姓名:模糊匹配
result = result.filter(item => item.name.toLowerCase().includes(keyword))
} else if (searchField === '3') {
// 部门:模糊匹配
result = result.filter(item => item.department.toLowerCase().includes(keyword))
} else {
result = result.filter(item =>
String(item.id).includes(keyword) ||
item.name.toLowerCase().includes(keyword) ||
item.department.toLowerCase().includes(keyword)
)
}
}
// 在职状态筛选
if (params.status !== undefined && params.status !== '') {
result = result.filter(item => item.status === Number(params.status))
}
// 部门筛选
if (params.department) {
result = result.filter(item => item.department === params.department)
}
return { code: 200, data: result, total: result.length }
},
// 获取详情
async getDetail(id) {
await delay()
const employee = mockEmployees.find(item => item.id === id)
if (employee) {
return { code: 200, data: employee }
}
return { code: 404, message: '员工不存在' }
},
// 新增
async create(data) {
await delay()
const maxId = mockEmployees.length > 0 ? Math.max(...mockEmployees.map(item => item.id)) : 0
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const newEmployee = {
id: maxId + 1,
status: 1,
...data,
// 用户字段初始为空
username: null,
password: null,
user_type: null,
user_status: null,
created_at: now,
updated_at: now
}
mockEmployees.push(newEmployee)
return { code: 200, data: newEmployee, message: '新增成功' }
},
// 更新
async update(id, data) {
await delay()
const index = mockEmployees.findIndex(item => item.id === id)
if (index !== -1) {
mockEmployees[index] = {
...mockEmployees[index],
...data,
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
return { code: 200, data: mockEmployees[index], message: '更新成功' }
}
return { code: 404, message: '员工不存在' }
},
// 删除
async delete(id) {
await delay()
const index = mockEmployees.findIndex(item => item.id === id)
if (index !== -1) {
mockEmployees.splice(index, 1)
return { code: 200, message: '删除成功' }
}
return { code: 404, message: '员工不存在' }
}
}

174
src/api/mock/products.js Normal file
View File

@@ -0,0 +1,174 @@
// 模拟产品数据
const mockProducts = [
{
id: 1,
name: '红茶茶叶',
type: 'raw_material',
quantity: 500,
price: 45.00,
unit: '斤',
specification: '特级红茶 500g/袋',
supplier: '福建茶业有限公司',
remark: '常温保存,防潮',
created_at: '2025-01-10 08:00:00',
updated_at: '2025-01-10 08:00:00'
},
{
id: 2,
name: '奶茶杯',
type: 'packaging',
quantity: 10000,
price: 0.15,
unit: '个',
specification: '500ml PP材质 透明',
supplier: '广州包装制品厂',
remark: '',
created_at: '2025-02-15 10:30:00',
updated_at: '2025-02-15 10:30:00'
},
{
id: 3,
name: '制冰机',
type: 'equipment',
quantity: 5,
price: 3500.00,
unit: '台',
specification: 'SM-50 商用日产50kg',
supplier: '深圳冰雪设备有限公司',
remark: '含一年保修',
created_at: '2025-03-20 14:00:00',
updated_at: '2025-03-20 14:00:00'
},
{
id: 4,
name: '食品级清洁剂',
type: 'cleaning',
quantity: 30,
price: 28.50,
unit: '瓶',
specification: '500ml/瓶',
supplier: '洁康日化有限公司',
remark: '适用于设备表面清洁',
created_at: '2025-04-05 09:15:00',
updated_at: '2025-04-05 09:15:00'
},
{
id: 5,
name: '吸管',
type: 'packaging',
quantity: 20000,
price: 0.03,
unit: '根',
specification: '可降解 250mm',
supplier: '广州包装制品厂',
remark: '环保可降解材质',
created_at: '2025-05-10 16:45:00',
updated_at: '2025-05-10 16:45:00'
},
{
id: 6,
name: '果糖糖浆',
type: 'raw_material',
quantity: 200,
price: 32.00,
unit: '瓶',
specification: '2.5L/瓶',
supplier: '上海糖业贸易有限公司',
remark: '开封后冷藏保存',
created_at: '2025-06-01 11:00:00',
updated_at: '2025-06-01 11:00:00'
}
]
// 模拟网络延迟
const delay = (ms = 300) => new Promise(r => setTimeout(r, ms))
// Mock API
export const mockProductsAPI = {
// 获取列表
async getList(params = {}) {
await delay()
let result = [...mockProducts]
// 关键词搜索
if (params.keyword) {
const keyword = params.keyword.toLowerCase()
const searchField = params.searchField || '2'
if (searchField === '1') {
// ID精确匹配
result = result.filter(item => String(item.id) === keyword)
} else if (searchField === '2') {
// 名称:模糊匹配
result = result.filter(item => item.name.toLowerCase().includes(keyword))
} else if (searchField === '3') {
// 供应商:模糊匹配
result = result.filter(item => item.supplier.toLowerCase().includes(keyword))
} else {
result = result.filter(item =>
String(item.id).includes(keyword) ||
item.name.toLowerCase().includes(keyword) ||
item.supplier.toLowerCase().includes(keyword)
)
}
}
// 产品类型筛选
if (params.type) {
result = result.filter(item => item.type === params.type)
}
return { code: 200, data: result, total: result.length }
},
// 获取详情
async getDetail(id) {
await delay()
const product = mockProducts.find(item => item.id === id)
if (product) {
return { code: 200, data: product }
}
return { code: 404, message: '产品不存在' }
},
// 新增
async create(data) {
await delay()
const maxId = mockProducts.length > 0 ? Math.max(...mockProducts.map(item => item.id)) : 0
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const newProduct = {
id: maxId + 1,
...data,
created_at: now,
updated_at: now
}
mockProducts.push(newProduct)
return { code: 200, data: newProduct, message: '新增成功' }
},
// 更新
async update(id, data) {
await delay()
const index = mockProducts.findIndex(item => item.id === id)
if (index !== -1) {
mockProducts[index] = {
...mockProducts[index],
...data,
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
return { code: 200, data: mockProducts[index], message: '更新成功' }
}
return { code: 404, message: '产品不存在' }
},
// 删除
async delete(id) {
await delay()
const index = mockProducts.findIndex(item => item.id === id)
if (index !== -1) {
mockProducts.splice(index, 1)
return { code: 200, message: '删除成功' }
}
return { code: 404, message: '产品不存在' }
}
}

145
src/api/mock/service.js Normal file
View File

@@ -0,0 +1,145 @@
// 模拟售后数据
const mockServices = [
{
id: 1,
customer_id: 1,
feedback: '收到的奶茶杯有破损,需要更换',
employee_id: 2,
handle_method: '同意补发,已安排快递寄出新品',
handle_status: '已完成',
service_date: '2025-03-15',
remark: '客户态度良好',
created_at: '2025-03-15 10:30:00',
updated_at: '2025-03-16 14:00:00'
},
{
id: 2,
customer_id: 3,
feedback: '制冰机出现异响,疑似零件松动',
employee_id: null,
handle_method: null,
handle_status: '待处理',
service_date: '2025-05-20',
remark: '紧急,客户反映影响营业',
created_at: '2025-05-20 09:00:00',
updated_at: '2025-05-20 09:00:00'
},
{
id: 3,
customer_id: 2,
feedback: '订购的红茶茶叶与样品不符,颜色偏深',
employee_id: 3,
handle_method: '已联系供应商核实,确认为新批次色差,品质无问题',
handle_status: '处理中',
service_date: '2025-06-01',
remark: '等待供应商书面说明',
created_at: '2025-06-01 14:20:00',
updated_at: '2025-06-02 10:00:00'
},
{
id: 4,
customer_id: 4,
feedback: '果糖糖浆保质期标注模糊,要求退换',
employee_id: 1,
handle_method: '已同意退货,安排换货处理',
handle_status: '已完成',
service_date: '2025-06-10',
remark: '',
created_at: '2025-06-10 11:00:00',
updated_at: '2025-06-12 16:30:00'
}
]
// 模拟网络延迟
const delay = (ms = 300) => new Promise(r => setTimeout(r, ms))
// Mock API
export const mockServiceAPI = {
// 获取列表
async getList(params = {}) {
await delay()
let result = [...mockServices]
// 关键词搜索
if (params.keyword) {
const keyword = params.keyword.toLowerCase()
const searchField = params.searchField || '1'
if (searchField === '1') {
// ID精确匹配
result = result.filter(item => String(item.id) === keyword)
} else if (searchField === '2') {
// 客户ID精确匹配
result = result.filter(item => String(item.customer_id) === keyword)
} else if (searchField === '3') {
// 反馈内容:模糊匹配
result = result.filter(item => item.feedback.toLowerCase().includes(keyword))
} else {
result = result.filter(item =>
String(item.id).includes(keyword) ||
String(item.customer_id).includes(keyword) ||
item.feedback.toLowerCase().includes(keyword)
)
}
}
// 处理状态筛选
if (params.handle_status) {
result = result.filter(item => item.handle_status === params.handle_status)
}
return { code: 200, data: result, total: result.length }
},
// 获取详情
async getDetail(id) {
await delay()
const service = mockServices.find(item => item.id === id)
if (service) {
return { code: 200, data: service }
}
return { code: 404, message: '售后记录不存在' }
},
// 新增
async create(data) {
await delay()
const maxId = mockServices.length > 0 ? Math.max(...mockServices.map(item => item.id)) : 0
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const newService = {
id: maxId + 1,
handle_status: '待处理',
...data,
created_at: now,
updated_at: now
}
mockServices.push(newService)
return { code: 200, data: newService, message: '新增成功' }
},
// 更新
async update(id, data) {
await delay()
const index = mockServices.findIndex(item => item.id === id)
if (index !== -1) {
mockServices[index] = {
...mockServices[index],
...data,
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
return { code: 200, data: mockServices[index], message: '更新成功' }
}
return { code: 404, message: '售后记录不存在' }
},
// 删除
async delete(id) {
await delay()
const index = mockServices.findIndex(item => item.id === id)
if (index !== -1) {
mockServices.splice(index, 1)
return { code: 200, message: '删除成功' }
}
return { code: 404, message: '售后记录不存在' }
}
}

138
src/api/mock/user.js Normal file
View File

@@ -0,0 +1,138 @@
import { mockEmployees } from './employee'
// 模拟网络延迟
const delay = (ms = 300) => new Promise(r => setTimeout(r, ms))
// 将员工记录转换为用户格式(字段名映射)
const toUserFormat = (employee) => ({
id: employee.id,
username: employee.username,
name: employee.name,
user_type: employee.user_type,
status: employee.user_status,
employee_id: employee.id,
createdate: employee.created_at
})
// Mock API操作同一数据源 mockEmployees
export const mockUserAPI = {
// 获取用户列表(返回有用户账号的员工记录)
async getList(params = {}) {
await delay()
// 筛选有用户名的记录(即已创建用户的员工)
let result = mockEmployees.filter(item => item.username).map(toUserFormat)
// 关键词搜索
if (params.keyword) {
const keyword = params.keyword.toLowerCase()
const searchField = params.searchField || '1'
if (searchField === '2') {
// 编号:精确匹配
result = result.filter(item => String(item.id) === keyword)
} else if (searchField === '1') {
// 用户名:模糊匹配
result = result.filter(item => item.username && item.username.includes(keyword))
} else if (searchField === '3') {
// 真实姓名:模糊匹配
result = result.filter(item => item.name.toLowerCase().includes(keyword))
} else {
result = result.filter(item =>
String(item.id).includes(keyword) ||
(item.username && item.username.includes(keyword)) ||
item.name.toLowerCase().includes(keyword)
)
}
}
// 用户类型筛选
if (params.user_type) {
result = result.filter(item => item.user_type === params.user_type)
}
// 用户状态筛选
if (params.status) {
result = result.filter(item => item.status === params.status)
}
return { code: 200, data: result, total: result.length }
},
// 获取用户详情
async getDetail(id) {
await delay()
const employee = mockEmployees.find(item => item.id === id && item.username)
if (employee) {
return { code: 200, data: toUserFormat(employee) }
}
return { code: 404, message: '用户不存在' }
},
// 创建用户(实际上是更新已有员工记录,添加用户字段)
async create(data) {
await delay()
// 查找对应的员工记录
const employeeIndex = mockEmployees.findIndex(item => item.id === data.id || item.id === data.employee_id)
if (employeeIndex === -1) {
return { code: 404, message: '关联员工不存在' }
}
// 检查是否已有用户账号
if (mockEmployees[employeeIndex].username) {
return { code: 400, message: '该员工已有用户账号' }
}
// 更新员工记录,添加用户字段
mockEmployees[employeeIndex] = {
...mockEmployees[employeeIndex],
username: data.username,
password: data.password,
user_type: data.user_type || 'user',
user_status: data.status || '1',
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
return { code: 200, data: toUserFormat(mockEmployees[employeeIndex]), message: '用户创建成功' }
},
// 更新用户(更新用户字段)
async update(id, data) {
await delay()
const index = mockEmployees.findIndex(item => item.id === id && item.username)
if (index !== -1) {
// 只更新用户相关字段
const updateFields = {}
if (data.username !== undefined) updateFields.username = data.username
if (data.password !== undefined) updateFields.password = data.password
if (data.user_type !== undefined) updateFields.user_type = data.user_type
if (data.status !== undefined) updateFields.user_status = data.status
mockEmployees[index] = {
...mockEmployees[index],
...updateFields,
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
return { code: 200, data: toUserFormat(mockEmployees[index]), message: '更新成功' }
}
return { code: 404, message: '用户不存在' }
},
// 删除用户(清除用户字段,保留员工记录)
async delete(id) {
await delay()
const index = mockEmployees.findIndex(item => item.id === id && item.username)
if (index !== -1) {
// 清除用户字段,而不是删除记录
mockEmployees[index] = {
...mockEmployees[index],
username: null,
password: null,
user_type: null,
user_status: null,
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
return { code: 200, message: '用户已删除' }
}
return { code: 404, message: '用户不存在' }
}
}

26
src/api/products.js Normal file
View File

@@ -0,0 +1,26 @@
import request from './request'
// 获取产品列表
export const getProductsList = (params) => {
return request.get('/products', { params })
}
// 获取产品详情
export const getProductsDetail = (id) => {
return request.get(`/products/${id}`)
}
// 新增产品
export const createProducts = (data) => {
return request.post('/products', data)
}
// 更新产品
export const updateProducts = (id, data) => {
return request.put(`/products/${id}`, data)
}
// 删除产品
export const deleteProductsAPI = (id) => {
return request.delete(`/products/${id}`)
}

53
src/api/request.js Normal file
View File

@@ -0,0 +1,53 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
// 创建 axios 实例
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:自动添加 token
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器统一处理错误config.quiet = true 时不弹窗)
request.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
const status = error.response?.status
const message = error.response?.data?.message || '请求失败'
const quiet = error.config?.quiet
const isLogin = error.config?.url?.includes('/user/login')
if (!quiet) {
if (status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (status === 403 && !isLogin) {
ElMessage.error('权限不足')
} else {
ElMessage.error(message)
}
}
return Promise.reject(error)
}
)
export default request

26
src/api/service.js Normal file
View File

@@ -0,0 +1,26 @@
import request from './request'
// 获取售后列表
export const getServiceList = (params) => {
return request.get('/after-sales', { params })
}
// 获取售后详情
export const getServiceDetail = (id) => {
return request.get(`/after-sales/${id}`)
}
// 新增售后
export const createService = (data) => {
return request.post('/after-sales', data)
}
// 更新售后
export const updateService = (id, data) => {
return request.put(`/after-sales/${id}`, data)
}
// 删除售后
export const deleteServiceAPI = (id) => {
return request.delete(`/after-sales/${id}`)
}

31
src/api/suppliers.js Normal file
View File

@@ -0,0 +1,31 @@
import request from './request'
// 获取简易供应商列表(用于下拉选择)
export const getSimpleSupplierList = (params, config) => {
return request.get('/suppliers/simple', { params, ...config })
}
// 获取供应商列表
export const getSupplierList = (params, config) => {
return request.get('/suppliers', { params, ...config })
}
// 获取供应商详情
export const getSupplierDetail = (id) => {
return request.get(`/suppliers/${id}`)
}
// 新增供应商
export const createSupplier = (data) => {
return request.post('/suppliers', data)
}
// 更新供应商
export const updateSupplier = (id, data) => {
return request.put(`/suppliers/${id}`, data)
}
// 删除供应商
export const deleteSupplierAPI = (id) => {
return request.delete(`/suppliers/${id}`)
}

31
src/api/user.js Normal file
View File

@@ -0,0 +1,31 @@
import request from './request'
// 获取用户列表(管理员)
export const getUserList = (params, config) => {
return request.get('/users', { params, ...config })
}
// 获取简易用户列表(用于下拉选择,仅需登录)
export const getSimpleUserList = (config) => {
return request.get('/user/list', config)
}
// 获取用户详情
export const getUserDetail = (id) => {
return request.get(`/users/${id}`)
}
// 创建用户
export const createUser = (data) => {
return request.post('/users', data)
}
// 更新用户
export const updateUser = (id, data) => {
return request.put(`/users/${id}`, data)
}
// 删除用户
export const deleteUserAPI = (id) => {
return request.delete(`/users/${id}`)
}

View File

@@ -67,6 +67,7 @@ import {reactive, ref} from 'vue'
import {ElMessage} from 'element-plus' import {ElMessage} from 'element-plus'
import {Lock, User} from '@element-plus/icons-vue' import {Lock, User} from '@element-plus/icons-vue'
import {useRouter} from 'vue-router' import {useRouter} from 'vue-router'
import { login } from '../store/user'
const router = useRouter() const router = useRouter()
@@ -93,9 +94,13 @@ const handleLogin = async () => {
try { try {
await loginFormRef.value.validate() await loginFormRef.value.validate()
loading.value = true loading.value = true
await new Promise((r) => setTimeout(r, 800)) const res = await login(loginForm.username, loginForm.password)
ElMessage.success('登录成功(模拟)') if (res.code === 0 || res.code === 200) {
router.push('/panel') ElMessage.success('登录成功')
router.push('/panel/home')
} else {
ElMessage.error(res.message || '登录失败')
}
} catch { } catch {
// 校验失败element-plus 会自动显示红色提示 // 校验失败element-plus 会自动显示红色提示
} finally { } finally {

View File

@@ -1,33 +1,280 @@
<script setup> <script setup>
import { ref,computed } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import {
getContractList,
createContract,
updateContract,
deleteContractAPI
} from '../api/contract'
import { getSimpleCustomerList } from '../api/customer'
import { getEmployeeList } from '../api/employee'
import { getSimpleSupplierList } from '../api/suppliers'
import { formatDateTime, formatDate } from "../utils/date"
import { hasPermission, getUserInfo } from '../store/user'
const searchKeyword = ref('') const search = ref('')
const selectField = ref('1') const select = ref('1')
const dateRange = ref([]) const dateRange = ref([])
const loading=ref(false) const loading = ref(false)
const searchResults = ref([])
const showSearchResults = ref(false)
const showAddForm = ref(false)
const isEditMode = ref(false)
const currentContractId = ref(null)
const customerList = ref([])
const employeeList = ref([])
const supplierList = ref([])
//过滤搜索结果 // 分页相关
const filteredContracts=computed(()=>{ const currentPage = ref(1)
const pageSize = ref(10)
})
//处理搜索
const handleSearch=()=>{
// 采购经理只能操作采购合同,招商经理只能操作加盟合同(提前定义,供 form 初始化使用)
const isProcurementManager = computed(() => getUserInfo()?.departmentName === 'procurement_manager')
const isFranchiseManager = computed(() => getUserInfo()?.departmentName === 'franchise_manager')
const getDefaultType = () => {
if (isProcurementManager.value) return 'supply'
if (isFranchiseManager.value) return 'franchise'
return 'franchise'
} }
//日期变更处理 const form = reactive({
const handleDateChange=()=>{ contract_name: '',
handleSearch(); type: getDefaultType(),
customer_id: '',
supplier_id: '',
contract_content: '',
employee_id: '',
effective_date: '',
expiry_date: '',
amount: 0,
status: '生效中',
remark: ''
})
onMounted(() => {
fetchData()
fetchOptions()
})
// 监听搜索结果变化,重置页码
watch(searchResults, () => {
currentPage.value = 1
})
// 分页后的数据
const filteredResults = computed(() => {
if (!isFranchiseManager.value && !isProcurementManager.value) return searchResults.value
return searchResults.value.filter(row => {
if (row.type === 'supply' && isProcurementManager.value) return true
if (row.type === 'franchise' && isFranchiseManager.value) return true
return false
})
})
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredResults.value.slice(start, end)
})
// 页码变化
const handleCurrentChange = (val) => {
currentPage.value = val
}
// 获取客户、员工、供应商和用户列表供下拉选择
const fetchOptions = async () => {
const results = await Promise.allSettled([
getSimpleCustomerList(null, { quiet: true }),
getEmployeeList(null, { quiet: true }),
getSimpleSupplierList(null, { quiet: true })
])
if (results[0].status === 'fulfilled') {
const cRes = results[0].value
customerList.value = cRes?.data || []
}
if (results[1].status === 'fulfilled') {
const eRes = results[1].value
employeeList.value = eRes.data?.list || eRes.data || []
}
if (results[2].status === 'fulfilled') {
const sRes = results[2].value
supplierList.value = sRes?.data || []
}
}
// 获取合同列表
const fetchData = async () => {
loading.value = true
const res = await getContractList()
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 处理搜索
const handleSearch = async () => {
// 如果在表单页面,先关闭表单
if (showAddForm.value) {
cancelAdd()
}
loading.value = true
const params = {}
if (select.value === '1') {
params.id = Number(search.value)
} else if (select.value === '2') {
params.contract_name = search.value
}
if (dateRange.value && dateRange.value.length === 2) {
params.effective_date_start = formatDate(dateRange.value[0])
params.effective_date_end = formatDate(dateRange.value[1])
}
const res = await getContractList(params)
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 日期变更处理
const handleDateChange = () => {
handleSearch()
} }
// 重置搜索 // 重置搜索
const resetSearch = () => { const resetSearch = () => {
searchKeyword.value = '' search.value = ''
searchField.value = '1' select.value = '1'
dateRange.value = [] dateRange.value = []
handleSearch() fetchData()
}
// 新增合同
const addContract = () => {
resetForm()
form.employee_id = getUserInfo()?.id || ''
showAddForm.value = true
showSearchResults.value = false
}
// 编辑合同
const editContract = (row) => {
isEditMode.value = true
currentContractId.value = row.id
Object.assign(form, {
contract_name: row.contract_name,
type: row.type || 'franchise',
customer_id: row.customer_id,
supplier_id: row.supplier_id,
contract_content: row.contract_content,
employee_id: row.employee_id,
effective_date: row.effective_date,
expiry_date: row.expiry_date,
amount: row.amount,
status: row.status,
remark: row.remark
})
showAddForm.value = true
showSearchResults.value = false
}
// 保存合同
const saveContract = async () => {
// 表单校验
if (!form.contract_name) {
return ElMessage.warning('请输入合同名称')
}
if (form.type !== 'supply' && !form.customer_id) {
return ElMessage.warning('加盟合同必须选择客户')
}
if (form.type === 'supply' && !form.supplier_id) {
return ElMessage.warning('采购合同必须选择供应商')
}
const payload = {
...form,
customer_id: form.customer_id || null,
supplier_id: form.supplier_id || null,
employee_id: form.employee_id || null
}
try {
if (isEditMode.value) {
await updateContract(currentContractId.value, payload)
ElMessage.success('更新成功')
} else {
await createContract(payload)
ElMessage.success('新增成功')
}
cancelAdd()
fetchData()
} catch (e) {
// 错误已由拦截器处理
}
}
// 删除合同
const deleteContract = (id) => {
ElMessageBox.confirm('确定删除该合同吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteContractAPI(id)
ElMessage.success('删除成功')
fetchData()
} catch (e) {
// 错误已由拦截器处理
}
})
}
// 取消
const cancelAdd = () => {
resetForm()
showAddForm.value = false
showSearchResults.value = true
}
// 重置表单
const resetForm = () => {
Object.assign(form, {
contract_name: '',
type: getDefaultType(),
customer_id: '',
supplier_id: '',
contract_content: '',
employee_id: '',
effective_date: '',
expiry_date: '',
amount: 0,
status: '生效中',
remark: ''
})
isEditMode.value = false
currentContractId.value = null
}
// 状态样式
const getStatusType = (status) => {
const map = {
'草稿': 'info',
'生效中': 'success',
'生效': 'success',
'完成': 'success',
'已完成': 'success',
'作废': 'danger'
}
return map[status] || 'info'
} }
// 日期快捷选项 // 日期快捷选项
@@ -61,10 +308,24 @@ const dateShortcuts = [
} }
] ]
// 格式化日期 // 检查权限
const formatDate = (date) => { const canCreate = computed(() => hasPermission('contract', 'create'))
if (!date) return '' const canUpdate = computed(() => hasPermission('contract', 'update'))
return date const canDelete = computed(() => hasPermission('contract', 'delete'))
const canOperate = computed(() => canUpdate.value || canDelete.value)
// 招商经理只能编辑加盟合同,采购经理只能编辑采购合同
const canEditRow = (row) => {
if (!canUpdate.value) return false
if (isProcurementManager.value && row.type !== 'supply') return false
if (isFranchiseManager.value && row.type !== 'franchise') return false
return true
}
const canDeleteRow = (row) => {
if (!canDelete.value) return false
if (isProcurementManager.value && row.type !== 'supply') return false
if (isFranchiseManager.value && row.type !== 'franchise') return false
return true
} }
</script> </script>
@@ -72,125 +333,274 @@ const formatDate = (date) => {
<div class="contract-container"> <div class="contract-container">
<!-- 搜索栏区域 --> <!-- 搜索栏区域 -->
<div class="contract-search"> <div class="contract-search">
<!-- 第一行搜索框 + 按钮 -->
<div class="search-row"> <div class="search-row">
<el-input v-model="searchKeyword" style="max-width: 600px" placeholder="Please input" <el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词" class="contract-search-with-select"
class="contract-search-with-select"> @keyup.enter="handleSearch">
<template #prepend> <template #prepend>
<el-select v-model="searchField" placeholder="Select" style="width: 115px"> <el-select v-model="select" placeholder="请选择" style="width: 115px">
<el-option label="编号" value="1" /> <el-option label="编号" value="1" />
<el-option label="名称" value="2" /> <el-option label="名称" value="2" />
<el-option label="类型" value="3" />
</el-select> </el-select>
</template> </template>
<template #append> <template #append>
<el-button :icon="Search" /> <el-button :icon="Search" @click="handleSearch" />
</template> </template>
</el-input> </el-input>
<div class="search-buttons">
<el-button type="success" @click="addContract" v-if="canCreate">新增合同</el-button>
<el-button type="default" @click="resetSearch">重置</el-button>
</div> </div>
<div class="search-row date-range">
<span class="date-label">日期范围</span>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
:shortcuts="dateShortcuts"
@change="handleDateChange"
/>
</div> </div>
<el-button type="primary" @click="addCustomer" style="margin-left: 20px"> <!-- 第二行筛选条件 -->
新增合同 <div class="filter-row">
</el-button> <span class="filter-label">筛选条件</span>
<el-button type="default" @click="resetSearch" style="margin-left:20px">
重置 <el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="生效日期"
</el-button> end-placeholder="到期日期" style="margin-left: 10px;" @change="handleDateChange" />
</div>
</div> </div>
<!-- 合同列表 --> <!-- 合同列表 -->
<div class="contract-list"> <div v-if="showSearchResults" class="contract-list">
<div v-if="loading" class="loading-container"> <div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated /> <el-skeleton :rows="5" animated />
</div> </div>
<div v-else-if="filteredContracts.length===0" class="empty-container"> <div v-else-if="searchResults.length === 0" class="empty-container">
<el-empty description="暂无合同数据" /> <el-empty description="暂无合同数据" />
</div> </div>
<div v-else> <div v-else>
<div class="list-header"> <div class="list-header">
<span class="total-count">共找到{{ filteredContracts.length }}条合同</span> <span class="total-count">共找到 {{ searchResults.length }} 条合同</span>
</div> </div>
<div class="contract-cards"> <el-table :data="paginatedData" border style="width: 100%">
<el-card <el-table-column prop="id" label="编号" width="70" />
v-for="contract in filteredContracts" <el-table-column prop="contract_name" label="合同名称" min-width="180" />
:key="contract.id" <el-table-column prop="type" label="类型" width="90">
class="contract-card" <template #default="{ row }">
shadow="hover" <el-tag :type="row.type === 'franchise' ? 'success' : 'warning'">{{ row.type === 'franchise' ? '加盟' : '采购' }}</el-tag>
@click="viewDetail" </template>
> </el-table-column>
<div class="contract-info"> <el-table-column prop="customer_name" label="客户" width="100">
<div class="contract-no"> <template #default="{ row }">
<el-tag size="small" type="primary">编号</el-tag> {{ row.customer_name || '-' }}
<span class="no-value">{{ contract.contractNo }}</span> </template>
</div> </el-table-column>
<div class="contract-name"> <el-table-column prop="supplier_name" label="供应商" width="100">
<el-icon><Document /></el-icon> <template #default="{ row }">
<span class="name-value">{{ contract.contractName }}</span> {{ row.supplier_name || '-' }}
</div> </template>
<div class="contract-date"> </el-table-column>
<el-icon><Calendar /></el-icon> <el-table-column prop="employee_name" label="业务员" width="90">
<span class="date-value">{{ formatDate(contract.signDate) }}</span> <template #default="{ row }">
</div> {{ row.employee_name || '-' }}
</div> </template>
</el-card> </el-table-column>
<el-table-column prop="amount" label="金额" width="110">
<template #default="{ row }">
¥{{ row.amount?.toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="effective_date" label="生效日期" width="110">
<template #default="{ row }">
{{ formatDate(row.effective_date) }}
</template>
</el-table-column>
<el-table-column prop="expiry_date" label="到期日期" width="110">
<template #default="{ row }">
{{ formatDate(row.expiry_date) }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" v-if="canOperate">
<template #default="{ row }">
<el-button link type="primary" @click="editContract(row)" v-if="canEditRow(row)">编辑</el-button>
<el-button link type="danger" @click="deleteContract(row.id)" v-if="canDeleteRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredResults.length"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div> </div>
</div> </div>
</div> </div>
<!-- 新增客户表单 --> <!-- 新增/编辑表单 -->
<div v-if="showAddForm" class="customer-info-label"> <div v-if="showAddForm" class="contract-form" style="margin-top: 20px;">
<el-form :model="form" label-width="auto" style="max-width: 600px" label-position="top"> <el-divider content-position="left">{{ isEditMode ? '编辑合同' : '新增合同' }}</el-divider>
<el-form-item label="姓名"> <el-form :model="form" label-width="auto" style="max-width: 700px;" label-position="top">
<el-input v-model="form.name" placeholder="请输入姓名" /> <el-row :gutter="20">
<el-col :span="12">
<el-form-item label="合同名称" required>
<el-input v-model="form.contract_name" placeholder="请输入合同名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同类型" required>
<el-select v-model="form.type" placeholder="请选择合同类型" style="width: 100%;" :disabled="isProcurementManager || isFranchiseManager">
<el-option v-if="!isProcurementManager" label="加盟合同" value="franchise" />
<el-option v-if="!isFranchiseManager" label="采购合同" value="supply" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<!-- 加盟合同选客户 -->
<el-form-item v-if="form.type !== 'supply'" label="客户" required>
<el-select v-model="form.customer_id" placeholder="请选择客户" style="width: 100%;" filterable>
<el-option v-for="c in customerList" :key="c.id" :label="`${c.id} - ${c.name}`" :value="c.id" />
</el-select>
</el-form-item>
<!-- 采购合同选供应商 -->
<el-form-item v-else label="供应商" required>
<el-select v-model="form.supplier_id" placeholder="请选择供应商" style="width: 100%;" filterable>
<el-option v-for="s in supplierList" :key="s.id" :label="`${s.id} - ${s.name}`" :value="s.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="业务员">
<el-select v-model="form.employee_id" placeholder="请选择业务员" style="width: 100%;" filterable>
<el-option v-for="e in employeeList" :key="e.id" :label="`${e.id} - ${e.name}`" :value="e.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="生效日期" required>
<el-date-picker v-model="form.effective_date" type="date" value-format="YYYY-MM-DD" style="width: 100%;" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="到期日期" required>
<el-date-picker v-model="form.expiry_date" type="date" value-format="YYYY-MM-DD" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同金额" required>
<el-input-number v-model="form.amount" :min="0" :step="1000" style="width: 100%;" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="状态" required>
<el-select v-model="form.status" style="width: 100%;">
<el-option label="草稿" value="草稿" />
<el-option label="生效中" value="生效中" />
<el-option label="生效" value="生效" />
<el-option label="完成" value="完成" />
<el-option label="作废" value="作废" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="合同内容/条款">
<el-input v-model="form.contract_content" type="textarea" :rows="4" placeholder="请输入合同内容" />
</el-form-item> </el-form-item>
<el-form-item label="电话"> <el-form-item label="备注">
<el-input v-model="form.phone" :controls="false" :min="0" :max="99999999999" :precision="0" <el-input v-model="form.remark" type="textarea" :rows="2" />
placeholder="请输入11位手机号" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="地区">
<el-cascader v-model="form.region" :options="regionData" :props="{ expandTrigger: 'hover' }"
placeholder="请选择省/市/区" clearable style="width: 100%" />
</el-form-item>
<el-form-item label="详细地址">
<el-input v-model="form.address" placeholder=" 请输入详细地址" />
</el-form-item>
<el-form-item label="电子邮箱">
<el-input v-model="form.email" placeholder=" 请输入邮箱地址" />
</el-form-item>
<el-form-item label="代理商类型">
<el-radio-group v-model="form.customer_type">
<el-radio value="VIP">地区总代理</el-radio>
<el-radio value="Normal">普通代理</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="saveCustomer">保存</el-button> <el-button type="primary" @click="saveContract">保存</el-button>
<el-button type="danger" @click="cancelAdd">取消</el-button> <el-button @click="cancelAdd">取消</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.contract-container {
padding: 20px;
}
.contract-search {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.search-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.search-buttons {
display: flex;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.filter-label {
color: #606266;
font-size: 14px;
margin-right: 10px;
}
.list-header {
margin-bottom: 15px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.loading-container,
.empty-container {
padding: 40px 0;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
</style> </style>

View File

@@ -1,37 +1,118 @@
<script setup> <script setup>
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { ref,reactive } from 'vue' import { ref, computed, onMounted, reactive, watch } from 'vue'
import { regionData } from 'element-china-area-data' import { regionData, CodeToText, TextToCode } from 'element-china-area-data'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import {
getCustomerList,
createCustomer,
updateCustomer,
deleteCustomerAPI
} from '../api/customer'
import { hasPermission } from '../store/user'
const form = reactive({ const form = reactive({
name: '', // 姓名,默认为空字符串 id: '',
phone: '', // 电话,默认为空字符串 name: '',
region: [], // 地区,默认为空数组(省市区三级代码) phone: '',
address: '', // 详细地址,默认为空字符串 region: [],
email: '', // 电子邮箱,默认为空字符串 address: '',
customer_type: 'Normal' // 客户类型,默认选中"普通客户" email: '',
remark: ''
}) })
const search = ref('') const search = ref('')
const select = ref('1') const select = ref('1')
const searchResults = ref([])
const showSearchResults = ref(false)
const showAddForm = ref(false)
const isEditMode = ref(false)
const currentCustomerId = ref(null)
const loading = ref(false)
let searchSeq = 0
const showDetailDialog = ref(false)
const currentDetail = ref(null)
const filterRegion = ref([])
const searchResults=ref([]) // 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const showSearchResults=ref(false) //是否显示搜索结果 // 只保留省市两级的地区数据
const showAddForm=ref(false) //是否显示新增表单 const regionDataTwoLevel = computed(() => {
const isEditMode = ref(false) //是否为编辑模式 return regionData.map(province => ({
const currentCustomerId = ref(null) //当前编辑的客户的ID ...province,
children: province.children?.map(city => ({
value: city.value,
label: city.label
}))
}))
})
// 分页后的数据
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return searchResults.value.slice(start, end)
})
onMounted(() => {
fetchData()
})
// 监听搜索结果变化,重置页码
watch(searchResults, () => {
currentPage.value = 1
})
// 获取客户列表
const fetchData = async () => {
loading.value = true
const res = await getCustomerList()
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 搜索
const handleSearch = async () => {
// 如果在表单页面,先关闭表单
if (showAddForm.value) {
cancelAdd()
}
const seq = ++searchSeq
loading.value = true
try {
const params = {
name: select.value === '1' ? search.value : undefined,
id: select.value === '2' ? Number(search.value) : undefined,
province: filterRegion.value?.[0] ? CodeToText[filterRegion.value[0]] : undefined,
city: filterRegion.value?.[1] ? CodeToText[filterRegion.value[1]] : undefined
}
const res = await getCustomerList(params)
if (seq !== searchSeq) return
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
} finally {
if (seq === searchSeq) loading.value = false
}
}
// 清空表单 // 清空表单
const resetForm = () => { const resetForm = () => {
form.id = ''
form.name = '' form.name = ''
form.phone = '' form.phone = ''
form.region = [] form.region = []
form.address = '' form.address = ''
form.email = '' form.email = ''
form.customer_type = 'Normal' form.remark = ''
isEditMode.value = false isEditMode.value = false
currentCustomerId.value = null currentCustomerId.value = null
} }
@@ -43,96 +124,231 @@ const addCustomer = () => {
showSearchResults.value = false showSearchResults.value = false
} }
// 编辑
const editCustomer = (row) => {
isEditMode.value = true
currentCustomerId.value = row.id
form.name = row.name
form.phone = row.phone
form.region = [
TextToCode[row.province]?.code || '',
TextToCode[row.province]?.[row.city]?.code || '',
TextToCode[row.province]?.[row.city]?.[row.district]?.code || ''
]
form.address = row.address
form.email = row.email
form.remark = row.remark
showAddForm.value = true
showSearchResults.value = false
}
const saveCustomer = async () => {
const formData = {
name: form.name,
phone: form.phone,
province: CodeToText[form.region[0]] || '',
city: CodeToText[form.region[1]] || '',
district: CodeToText[form.region[2]] || '',
address: form.address,
email: form.email,
remark: form.remark
}
try {
if (isEditMode.value) {
await updateCustomer(currentCustomerId.value, formData)
ElMessage.success('更新成功')
} else {
await createCustomer(formData)
ElMessage.success('新增成功')
}
cancelAdd()
fetchData()
} catch (error) {
ElMessage.error(error.response?.data?.message || error.message || '操作失败')
}
}
// 删除
const deleteCustomer = async (id) => {
ElMessageBox.confirm('确定删除该客户吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteCustomerAPI(id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
ElMessage.error(error.response?.data?.message || error.message || '删除失败')
}
})
}
// 取消
const cancelAdd = () => {
resetForm()
showAddForm.value = false
showSearchResults.value = true
}
// 清空搜索 // 清空搜索
const clearSearch = () => { const clearSearch = () => {
search.value = '' search.value = ''
filterRegion.value = []
showSearchResults.value = false showSearchResults.value = false
showAddForm.value = false showAddForm.value = false
currentPage.value = 1
fetchData()
} }
// 双击行查看详情
const handleRowDblClick = (row) => {
currentDetail.value = { ...row }
showDetailDialog.value = true
}
// 关闭详情弹窗
const closeDetail = () => {
showDetailDialog.value = false
currentDetail.value = null
}
// 页码变化
const handleCurrentChange = (val) => {
currentPage.value = val
}
// 检查权限
const canCreate = computed(() => hasPermission('customer', 'create'))
const canUpdate = computed(() => hasPermission('customer', 'update'))
const canDelete = computed(() => hasPermission('customer', 'delete'))
const canOperate = computed(() => canUpdate.value || canDelete.value)
</script> </script>
<template> <template>
<div class="customer-container"> <div class="customer-container">
<!-- 搜索栏区域 --> <!-- 搜索栏区域 -->
<div class="customer-search"> <div class="customer-search">
<el-input v-model="search" style="max-width: 600px" placeholder="Please input" <div class="search-row">
class="customer-search-with-select"> <el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词"
class="customer-search-with-select" @keyup.enter="handleSearch">
<template #prepend> <template #prepend>
<el-select v-model="select" placeholder="Select" style="width: 115px"> <el-select v-model="select" placeholder="请选择" style="width: 115px">
<el-option label="姓名" value="1" /> <el-option label="姓名" value="1" />
<el-option label="编号" value="2" /> <el-option label="编号" value="2" />
<el-option label="电话" value="3" />
<el-option label="邮箱" value="4" />
</el-select> </el-select>
</template> </template>
<template #append> <template #append>
<el-button :icon="Search" /> <el-button :icon="Search" @click="handleSearch" />
</template> </template>
</el-input> </el-input>
<el-button color="#E60012" :dark="isDark" @click="addCustomer" style="margin-left: 20px"> <div class="search-buttons">
新增用户 <el-button color="#E60012" @click="addCustomer" v-if="canCreate">新增客户</el-button>
</el-button> <el-button type="default" @click="clearSearch">重置</el-button>
</div>
</div> </div>
<div v-if="showSearchResults" class="customer-table"> <div class="filter-row">
<el-table :data="searchResults" border style="width: 100%"> <span class="filter-label">筛选条件</span>
<el-cascader
v-model="filterRegion"
:options="regionDataTwoLevel"
:props="{ expandTrigger: 'hover' }"
placeholder="请选择省/市"
clearable
style="width: 280px;"
@change="handleSearch"
/>
</div>
</div>
<div v-if="showSearchResults" class="customer-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="searchResults.length === 0" class="empty-container">
<el-empty description="暂无数据" />
</div>
<div v-else>
<div class="list-header">
<span class="total-count">共找到 {{ searchResults.length }} 条记录</span>
</div>
<el-table :data="paginatedData" v-loading="loading" border style="width: 100%" @row-dblclick="handleRowDblClick" row-class-name="clickable-row">
<el-table-column prop="id" label="编号" width="80" />
<el-table-column prop="name" label="姓名" width="120" /> <el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="电话" width="150" /> <el-table-column prop="phone" label="电话" width="150" />
<el-table-column prop="regionText" label="地区" width="200" /> <el-table-column label="地区" min-width="150">
<el-table-column prop="address" label="详细地址" width="200" />
<el-table-column prop="email" label="电子邮箱" width="200" />
<el-table-column prop="customer_type" label="代理商类型" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.customer_type === 'VIP' ? 'danger' : 'info'"> {{ [row.province, row.city].filter(Boolean).join(' ') || '-' }}
{{ row.customer_type === 'VIP' ? '地区总代理' : '普通代理' }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="150" fixed="right"> <el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right" v-if="canOperate">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="editCustomer(row)">编辑</el-button> <el-button link type="primary" @click="editCustomer(row)" v-if="canUpdate">编辑</el-button>
<el-button link type="danger" @click="deleteCustomer(row.id)">删除</el-button> <el-button link type="danger" @click="deleteCustomer(row.id)" v-if="canDelete">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div style="margin-top: 10px; color: #909399; font-size: 14px;">
找到 {{ searchResults.length }} 条结果 <!-- 分页 -->
<el-button link type="primary" @click="clearSearch">清空搜索</el-button> <div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="searchResults.length"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div> </div>
</div> </div>
</div>
<!-- 新增客户表单 --> <!-- 新增客户表单 -->
<div v-if="showAddForm" class="customer-info-label"> <div v-if="showAddForm" class="customer-info-label">
<el-divider content-position="left">{{ isEditMode ? '编辑客户信息' : '新增客户' }}</el-divider>
<el-form :model="form" label-width="auto" style="max-width: 600px" label-position="top"> <el-form :model="form" label-width="auto" style="max-width: 600px" label-position="top">
<el-form-item label="姓名"> <el-form-item label="姓名" required>
<el-input v-model="form.name" placeholder="请输入姓名" /> <el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item> </el-form-item>
<el-form-item label="电话"> <el-form-item label="电话" required>
<el-input v-model="form.phone" :controls="false" :min="0" :max="99999999999" :precision="0" <el-input v-model="form.phone" maxlength="11"
placeholder="请输入11位手机号" style="width: 100%" /> placeholder="请输入11位手机号" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="地区"> <el-form-item label="地区">
<el-cascader v-model="form.region" :options="regionData" :props="{ expandTrigger: 'hover' }" <el-cascader v-model="form.region"
placeholder="请选择省/市/区" clearable style="width: 100%" /> :options="regionData"
:props="{ expandTrigger: 'hover' }"
placeholder="请选择省/市/区"
clearable style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="详细地址"> <el-form-item label="详细地址">
<el-input v-model="form.address" placeholder=" 请输入详细地址" /> <el-input v-model="form.address" placeholder="请输入详细地址" />
</el-form-item> </el-form-item>
<el-form-item label="电子邮箱"> <el-form-item label="电子邮箱">
<el-input v-model="form.email" placeholder=" 请输入邮箱地址" /> <el-input v-model="form.email" placeholder="请输入邮箱地址" />
</el-form-item> </el-form-item>
<el-form-item label="代理商类型">
<el-radio-group v-model="form.customer_type"> <el-form-item label="备注">
<el-radio value="VIP">地区总代理</el-radio> <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
<el-radio value="Normal">普通代理</el-radio>
</el-radio-group>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="saveCustomer">保存</el-button> <el-button type="primary" @click="saveCustomer">保存</el-button>
@@ -141,9 +357,99 @@ const clearSearch = () => {
</el-form> </el-form>
</div> </div>
<!-- 客户详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="客户详情" width="650px" @close="closeDetail">
<div v-if="currentDetail" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="客户编号">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ currentDetail.phone }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱">{{ currentDetail.email }}</el-descriptions-item>
<el-descriptions-item label="地区">
{{ [currentDetail.province, currentDetail.city, currentDetail.district].filter(Boolean).join(' ') }}
</el-descriptions-item>
<el-descriptions-item label="详细地址" :span="2">{{ currentDetail.address || '无' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ currentDetail.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="closeDetail">关闭</el-button>
<el-button type="primary" @click="(() => { const d = currentDetail; closeDetail(); editCustomer(d) })()" v-if="canOperate">编辑</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.customer-container {
padding: 20px;
}
.customer-search {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.search-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.search-buttons {
display: flex;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.filter-label {
color: #606266;
font-size: 14px;
margin-right: 10px;
}
.list-header {
margin-bottom: 15px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.loading-container,
.empty-container {
padding: 40px 0;
}
.clickable-row {
cursor: pointer;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.detail-content {
padding: 10px 0;
}
</style> </style>

736
src/components/employee.vue Normal file
View File

@@ -0,0 +1,736 @@
<script setup>
import { Search } from '@element-plus/icons-vue'
import { ref, onMounted, reactive, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getEmployeeList,
createEmployee,
updateEmployee,
deleteEmployeeAPI,
getDepartmentList,
getSimpleEmployeeList
} from '../api/employee'
import { hasPermission, getUserInfo } from '../store/user'
import { formatDateTime, formatDate } from '../utils/date'
import { createUser } from '../api/user'
import { DEPARTMENT_NAME_LIST } from '../constants/departments'
const form = reactive({
name: '',
gender: '男',
age: null,
education: '',
department: '',
entry_date: '',
position: '',
salary: null,
phone: '',
email: '',
status: 1,
remark: ''
})
const search = ref('')
const select = ref('1')
const filterStatus = ref('')
const searchResults = ref([])
const showSearchResults = ref(false)
const showAddForm = ref(false)
const isEditMode = ref(false)
const currentEmployeeId = ref(null)
const loading = ref(false)
const showDetailDialog = ref(false)
const currentDetail = ref(null)
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const totalPages = ref(0)
// 新增用户相关状态
const showCreateUserForm = ref(false)
const justCreatedEmployee = ref(null)
const userForm = reactive({
username: '',
password: '',
name: '',
department_id: 2,
is_active: 1,
employee_id: null
})
// 部门列表
const departmentOptions = ref([...DEPARTMENT_NAME_LIST])
// 学历列表
const educationOptions = ['高中', '专科', '本科', '硕士', '博士']
// 分页后的数据服务端已分页searchResults 即为当前页数据)
const paginatedData = computed(() => searchResults.value)
// onMounted 已在下方合并(同时加载部门列表)
// 监听筛选变化
watch([filterStatus], () => {
currentPage.value = 1
handleSearch()
})
// 监听筛选变化时重置页码(服务端分页:已由 filterStatus watch 处理)
// watch(searchResults 不再重置页码,避免翻页后被重置回第 1 页
// 获取员工列表
const fetchData = async (page) => {
loading.value = true
const params = {
page: page || currentPage.value,
pageSize: pageSize.value
}
const res = await getEmployeeList(params)
if (res.code === 0 || res.code === 200) {
if (res.data && Array.isArray(res.data.list)) {
// 服务端分页响应
searchResults.value = res.data.list
total.value = res.data.total
totalPages.value = res.data.totalPages
} else if (Array.isArray(res.data)) {
// 兼容直接返回数组的场景(如 mock
searchResults.value = res.data
total.value = res.data.length
}
}
showSearchResults.value = true
loading.value = false
}
// 搜索
const handleSearch = async () => {
// 如果在表单页面,先关闭表单
if (showAddForm.value || showCreateUserForm.value) {
cancelAdd()
cancelCreateUser()
}
currentPage.value = 1
loading.value = true
const params = {
page: 1,
pageSize: pageSize.value,
status: filterStatus.value !== '' ? filterStatus.value : undefined
}
if (search.value) {
if (select.value === '1') {
params.id = Number(search.value)
} else if (select.value === '2') {
params.name = search.value
} else if (select.value === '3') {
params.department = search.value
}
}
const res = await getEmployeeList(params)
if (res.code === 0 || res.code === 200) {
if (res.data && Array.isArray(res.data.list)) {
searchResults.value = res.data.list
total.value = res.data.total
totalPages.value = res.data.totalPages
} else if (Array.isArray(res.data)) {
searchResults.value = res.data
total.value = res.data.length
}
}
showSearchResults.value = true
loading.value = false
}
// 重置搜索
const resetSearch = () => {
search.value = ''
filterStatus.value = ''
showSearchResults.value = false
showAddForm.value = false
currentPage.value = 1
fetchData(1)
}
// 清空搜索
const clearSearch = () => {
resetSearch()
}
// 清空表单
const resetForm = () => {
form.name = ''
form.gender = '男'
form.age = null
form.education = ''
form.department = ''
form.entry_date = ''
form.position = ''
form.salary = null
form.phone = ''
form.email = ''
form.status = 1
form.remark = ''
isEditMode.value = false
currentEmployeeId.value = null
}
// 显示新增表单
const addEmployee = () => {
resetForm()
showAddForm.value = true
showSearchResults.value = false
}
// 编辑
const editEmployee = (row) => {
isEditMode.value = true
currentEmployeeId.value = row.id
form.name = row.name
form.gender = row.gender
form.age = row.age
form.education = row.education
form.department = row.department
form.entry_date = row.entry_date
form.position = row.position
form.salary = row.salary
form.phone = row.phone
form.email = row.email
form.status = row.status
form.remark = row.remark
showAddForm.value = true
showSearchResults.value = false
}
// 保存员工
const saveEmployee = async () => {
const formData = {
name: form.name,
gender: form.gender,
age: form.age,
education: form.education,
department: form.department,
entry_date: form.entry_date,
position: form.position,
salary: form.salary,
phone: form.phone,
email: form.email,
status: form.status,
remark: form.remark
}
try {
if (isEditMode.value) {
await updateEmployee(currentEmployeeId.value, formData)
ElMessage.success('更新成功')
cancelAdd()
fetchData()
} else {
const res = await createEmployee(formData)
ElMessage.success('新增成功')
// 保存刚创建的员工信息
justCreatedEmployee.value = res.data
// 弹出确认框:是否将员工新增为系统用户
ElMessageBox.confirm(
'员工已成功创建!是否将该员工新增为管理系统用户?',
'创建系统用户',
{
confirmButtonText: '确定新增用户',
cancelButtonText: '暂不创建',
type: 'info'
}
).then(() => {
// 确认:进入用户创建表单
openUserForm(res.data)
}).catch(() => {
// 取消:返回列表
cancelAdd()
fetchData()
})
}
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '操作失败,请稍后重试')
}
}
// 删除员工
const deleteEmployee = async (id) => {
ElMessageBox.confirm('确定删除该员工吗?', '提示', {
confirmButtonText:'确认',
cancelButtonText:'取消',
type: 'warning'
}).then(async () => {
try {
await deleteEmployeeAPI(id)
ElMessage.success('删除成功')
fetchData()
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '删除失败,请稍后重试')
}
})
}
// 取消
const cancelAdd = () => {
resetForm()
showAddForm.value = false
showSearchResults.value = true
}
// 双击行查看详情
const handleRowDblClick = (row) => {
currentDetail.value = { ...row }
showDetailDialog.value = true
}
// 关闭详情弹窗
const closeDetail = () => {
showDetailDialog.value = false
currentDetail.value = null
}
// 页码变化(服务端分页:重新请求后端)
const handleCurrentChange = (val) => {
currentPage.value = val
fetchData(val)
}
// 打开用户创建表单(从员工新增后调用)
const openUserForm = (employee) => {
showAddForm.value = false
showSearchResults.value = false
showCreateUserForm.value = true
// 自动填充员工信息
userForm.username = ''
userForm.password = ''
userForm.name = employee.name
userForm.department_id = 2
userForm.is_active = 1
userForm.employee_id = employee.id
}
// 加载部门列表 + 员工列表
onMounted(async () => {
const res = await getDepartmentList()
if (res.code === 0 || res.code === 200) {
departmentOptions.value = res.data.map(d => d.description) || DEPARTMENT_NAME_LIST
}
fetchData(1)
})
// 保存用户(从员工页面创建)
const saveUserFromEmployee = async () => {
if (!userForm.username) {
ElMessage.warning('请输入用户名')
return
}
if (!userForm.password) {
ElMessage.warning('请输入密码')
return
}
await createUser({
username: userForm.username,
password: userForm.password,
department_id: userForm.department_id,
employee_id: userForm.employee_id,
is_active: userForm.is_active
})
ElMessage.success('用户创建成功')
cancelCreateUser()
fetchData()
}
// 取消创建用户
const cancelCreateUser = () => {
showCreateUserForm.value = false
justCreatedEmployee.value = null
userForm.username = ''
userForm.password = ''
userForm.name = ''
userForm.department_id = 2
userForm.is_active = 1
userForm.employee_id = null
showSearchResults.value = true
}
// 检查权限
const canCreate = computed(() => hasPermission('employee', 'create'))
const canUpdate = computed(() => hasPermission('employee', 'update'))
const canDelete = computed(() => hasPermission('employee', 'delete'))
const canOperate = computed(() => canUpdate.value || canDelete.value)
const showSalary = computed(() => ['总经理办公室', '财务部'].includes(getUserInfo()?.departmentDesc))
</script>
<template>
<div class="employee-container">
<!-- 搜索栏区域 -->
<div class="employee-search">
<div class="search-row">
<el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词"
class="employee-search-with-select" @keyup.enter="handleSearch">
<template #prepend>
<el-select v-model="select" placeholder="请选择" style="width: 115px">
<el-option label="ID" value="1" />
<el-option label="姓名" value="2" />
<el-option label="部门" value="3" />
</el-select>
</template>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<div class="search-buttons">
<el-button type="success" @click="addEmployee" v-if="canCreate">新增员工</el-button>
<el-button type="default" @click="resetSearch">重置</el-button>
</div>
</div>
<div class="filter-row">
<span class="filter-label">筛选条件</span>
<el-select v-model="filterStatus" placeholder="在职状态" clearable style="width: 140px;">
<el-option label="在职" :value="1" />
<el-option label="离职" :value="0" />
</el-select>
</div>
</div>
<div v-if="showSearchResults" class="employee-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="searchResults.length === 0" class="empty-container">
<el-empty description="暂无数据" />
</div>
<div v-else>
<div class="list-header">
<span class="total-count">共找到 {{ total }} 条记录</span>
</div>
<el-table :data="paginatedData" v-loading="loading" border style="width: 100%" @row-dblclick="handleRowDblClick" row-class-name="clickable-row">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="姓名" width="80" />
<el-table-column prop="gender" label="性别" width="60">
<template #default="{ row }">
<span :style="{ color: row.gender === '女' ? '#e6a23c' : '#409eff' }">{{ row.gender }}</span>
</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="60" />
<el-table-column prop="education" label="学历" width="70" />
<el-table-column prop="department" label="部门" min-width="90" />
<el-table-column prop="position" label="职务" min-width="110" />
<el-table-column prop="salary" label="工资" width="100" v-if="showSalary">
<template #default="{ row }">
¥{{ row.salary?.toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="phone" label="联系电话" min-width="130" />
<el-table-column prop="entry_date" label="入职时间" width="110">
<template #default="{ row }">
{{ formatDate(row.entry_date) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '在职' : '离职' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" v-if="canOperate">
<template #default="{ row }">
<el-button link type="primary" @click="editEmployee(row)" v-if="canUpdate">编辑</el-button>
<el-button link type="danger" @click="deleteEmployee(row.id)" v-if="canDelete">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 新增/编辑员工表单 -->
<div v-if="showAddForm" class="employee-info-label">
<el-divider content-position="left">{{ isEditMode ? '编辑员工信息' : '新增员工' }}</el-divider>
<el-form :model="form" label-width="auto" style="max-width: 700px" label-position="top">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="员工姓名" required>
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="性别" required>
<el-radio-group v-model="form.gender">
<el-radio value="男"></el-radio>
<el-radio value="女"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="年龄">
<el-input-number v-model="form.age" :min="18" :max="65" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学历">
<el-select v-model="form.education" placeholder="请选择学历" style="width: 100%;">
<el-option v-for="edu in educationOptions" :key="edu" :label="edu" :value="edu" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所属部门" required>
<el-select v-model="form.department" placeholder="请选择部门" style="width: 100%;">
<el-option v-for="dept in departmentOptions" :key="dept" :label="dept" :value="dept" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="职务/岗位" required>
<el-input v-model="form.position" placeholder="请输入职务" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="入职时间" required>
<el-date-picker v-model="form.entry_date" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="工资">
<el-input-number v-model="form.salary" :min="0" :step="500" :precision="2" style="width: 100%;" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="联系电话">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="电子邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="在职状态" required>
<el-radio-group v-model="form.status">
<el-radio :value="1">在职</el-radio>
<el-radio :value="0">离职</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注信息" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveEmployee">保存</el-button>
<el-button type="danger" @click="cancelAdd">取消</el-button>
</el-form-item>
</el-form>
</div>
<!-- 新增用户表单从员工创建 -->
<div v-if="showCreateUserForm" class="employee-info-label">
<el-divider content-position="left">为员工创建系统用户</el-divider>
<el-form :model="userForm" label-width="auto" style="max-width: 700px" label-position="top">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="关联员工ID">
<el-input :model-value="userForm.employee_id" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="员工姓名">
<el-input :model-value="userForm.name" disabled />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="用户名" required>
<el-input v-model="userForm.username" placeholder="请输入用户名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="密码" required>
<el-input v-model="userForm.password" type="password" show-password placeholder="请输入密码" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="所属部门">
<el-select v-model="userForm.department_id" placeholder="请选择部门" style="width: 100%;">
<el-option label="信息技术部" :value="1" />
<el-option label="总经理办公室" :value="2" />
<el-option label="招商部" :value="3" />
<el-option label="运营部" :value="4" />
<el-option label="采购部" :value="5" />
<el-option label="财务部" :value="6" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用户状态">
<el-radio-group v-model="userForm.is_active">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button type="primary" @click="saveUserFromEmployee">保存用户</el-button>
<el-button type="danger" @click="cancelCreateUser">暂不创建</el-button>
</el-form-item>
</el-form>
</div>
<!-- 员工详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="员工详情" width="650px" @close="closeDetail">
<div v-if="currentDetail" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="员工ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="性别">
<span :style="{ color: currentDetail.gender === '女' ? '#e6a23c' : '#409eff' }">{{ currentDetail.gender }}</span>
</el-descriptions-item>
<el-descriptions-item label="年龄">{{ currentDetail.age }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ currentDetail.education }}</el-descriptions-item>
<el-descriptions-item label="部门">{{ currentDetail.department }}</el-descriptions-item>
<el-descriptions-item label="职务">{{ currentDetail.position }}</el-descriptions-item>
<el-descriptions-item label="工资">
<span class="amount-text">¥{{ currentDetail.salary?.toLocaleString() }}</span>
</el-descriptions-item>
<el-descriptions-item label="入职时间">{{ formatDate(currentDetail.entry_date) }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ currentDetail.phone }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱" :span="2">{{ currentDetail.email }}</el-descriptions-item>
<el-descriptions-item label="在职状态">
<el-tag :type="currentDetail.status === 1 ? 'success' : 'danger'">
{{ currentDetail.status === 1 ? '在职' : '离职' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ currentDetail.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="closeDetail">关闭</el-button>
<el-button type="primary" @click="(() => { const d = currentDetail; closeDetail(); editEmployee(d) })()" v-if="canUpdate">编辑</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.employee-container {
padding: 20px;
}
.employee-search {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.search-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.search-buttons {
display: flex;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.filter-label {
color: #606266;
font-size: 14px;
margin-right: 10px;
}
.list-header {
margin-bottom: 15px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.loading-container,
.empty-container {
padding: 40px 0;
}
.clickable-row {
cursor: pointer;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.detail-content {
padding: 10px 0;
}
.amount-text {
font-weight: 600;
color: #e6a23c;
font-size: 16px;
}
</style>

View File

@@ -1,13 +1,36 @@
<script setup> <script setup>
import {ref} from 'vue' import {ref, computed} from 'vue'
import {useRoute} from 'vue-router' import {useRoute, useRouter} from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { getUserInfo, logout, hasPermission } from '../store/user'
const route = useRoute() const route = useRoute()
const router = useRouter()
const isCollapse = ref(false) const isCollapse = ref(false)
const switchFold = () => { const switchFold = () => {
isCollapse.value = !isCollapse.value isCollapse.value = !isCollapse.value
} }
// 获取用户信息
const userInfo = computed(() => getUserInfo())
// 退出登录
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await logout()
router.push('/login')
})
}
// 检查权限
const checkPermission = (resource, action) => {
return hasPermission(resource, action)
}
</script> </script>
<template> <template>
@@ -26,11 +49,17 @@ const switchFold = () => {
<h1 class="logo-title">蜜雪冰城管理系统</h1> <h1 class="logo-title">蜜雪冰城管理系统</h1>
</el-menu-item> </el-menu-item>
<!-- 右侧退出 --> <!-- 右侧用户信息和退出 -->
<el-menu-item index="/login" class="logout-item"> <div class="header-right">
<span class="user-info" v-if="userInfo">
<el-icon><User /></el-icon>
{{ userInfo.name }}{{ userInfo.departmentDesc }}
</span>
<el-menu-item index="" class="logout-item" @click="handleLogout">
<el-icon><SwitchButton/></el-icon> <el-icon><SwitchButton/></el-icon>
<template #title>退出登录</template> <template #title>退出登录</template>
</el-menu-item> </el-menu-item>
</div>
</el-menu> </el-menu>
</el-header> </el-header>
@@ -48,31 +77,41 @@ const switchFold = () => {
<template #title>首页</template> <template #title>首页</template>
</el-menu-item> </el-menu-item>
<el-menu-item index="/panel/customer"> <el-menu-item index="/panel/customer" v-if="checkPermission('customer', 'read')">
<el-icon><Avatar /></el-icon> <el-icon><Avatar /></el-icon>
<template #title>客户管理</template> <template #title>客户管理</template>
</el-menu-item> </el-menu-item>
<el-menu-item index="/panel/contract"> <el-menu-item index="/panel/contract" v-if="checkPermission('contract', 'read')">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<template #title>合同管理</template> <template #title>合同管理</template>
</el-menu-item> </el-menu-item>
<el-menu-item index="/panel/service"> <el-menu-item index="/panel/service" v-if="checkPermission('after_sale', 'read')">
<el-icon><Service /></el-icon> <el-icon><Service /></el-icon>
<template #title>售后管理</template> <template #title>售后管理</template>
</el-menu-item> </el-menu-item>
<el-menu-item index="/panel/page3"> <el-menu-item index="/panel/products" v-if="checkPermission('product', 'read')">
<el-icon><IceTea /></el-icon> <el-icon><IceTea /></el-icon>
<template #title>产品管理</template> <template #title>产品管理</template>
</el-menu-item> </el-menu-item>
<el-menu-item index="/panel/page3"> <el-menu-item index="/panel/supplier" v-if="checkPermission('supplier', 'read')">
<el-icon><Box /></el-icon>
<template #title>供应商管理</template>
</el-menu-item>
<el-menu-item index="/panel/employee" v-if="checkPermission('employee', 'read')">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<template #title>员工管理</template> <template #title>员工管理</template>
</el-menu-item> </el-menu-item>
<el-menu-item index="/panel/user" v-if="checkPermission('user', 'manage')">
<el-icon><UserFilled /></el-icon>
<template #title>用户管理</template>
</el-menu-item>
<!-- 底部收缩按钮 --> <!-- 底部收缩按钮 -->
<el-menu-item index="" class="collapse-btn" @click="switchFold"> <el-menu-item index="" class="collapse-btn" @click="switchFold">
<el-icon :class="{'rotate-180-animation':!isCollapse,'rotate-180-animation-reverse':isCollapse}"> <el-icon :class="{'rotate-180-animation':!isCollapse,'rotate-180-animation-reverse':isCollapse}">
@@ -112,6 +151,15 @@ const switchFold = () => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
border-bottom: none !important;
}
.logo-item.is-active {
border-bottom: none !important;
}
.logo-item.is-active::after {
display: none !important;
} }
.logo-img { .logo-img {
@@ -128,8 +176,22 @@ const switchFold = () => {
white-space: nowrap; white-space: nowrap;
} }
.header-right {
display: flex;
align-items: center;
margin-left: auto;
}
.user-info {
display: flex;
align-items: center;
gap: 6px;
margin-right: 20px;
color: #606266;
font-size: 14px;
}
.logout-item { .logout-item {
margin-left: auto !important;
color: #909399; color: #909399;
} }
@@ -142,6 +204,7 @@ const switchFold = () => {
transition: width 0.3s ease; transition: width 0.3s ease;
background: #fff; background: #fff;
border-right: 1px solid #eee; border-right: 1px solid #eee;
overflow: hidden;
} }
.aside-menu { .aside-menu {
@@ -149,6 +212,7 @@ const switchFold = () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: none; border-right: none;
overflow: hidden;
} }
.aside-menu:not(.el-menu--collapse) { .aside-menu:not(.el-menu--collapse) {

513
src/components/products.vue Normal file
View File

@@ -0,0 +1,513 @@
<script setup>
import { Search } from '@element-plus/icons-vue'
import { ref, onMounted, reactive, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getProductsList,
createProducts,
updateProducts,
deleteProductsAPI
} from '../api/products'
import { getSupplierList } from '../api/suppliers'
import { hasPermission } from '../store/user'
const form = reactive({
name: '',
type: '',
quantity: 0,
price: 0,
unit: '件',
specification: '',
supplier: '',
remark: ''
})
const search = ref('')
const select = ref('2')
const filterType = ref('')
const searchResults = ref([])
const showSearchResults = ref(false)
const showAddForm = ref(false)
const isEditMode = ref(false)
const currentProductId = ref(null)
const loading = ref(false)
const showDetailDialog = ref(false)
const currentDetail = ref(null)
const supplierList = ref([])
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
// 产品类型映射
const productTypeMap = {
raw_material: '原材料',
packaging: '包装材料',
equipment: '设备器具',
cleaning: '清洁用品',
promote: '宣传物料'
}
onMounted(() => {
fetchData()
fetchSuppliers()
})
const fetchSuppliers = async () => {
try {
const res = await getSupplierList(null, { quiet: true })
supplierList.value = res.data?.list || res.data || []
} catch {}
}
// 监听筛选变化
watch(filterType, () => {
currentPage.value = 1
handleSearch()
})
// 监听搜索结果变化,重置页码
watch(searchResults, () => {
currentPage.value = 1
})
// 分页后的数据
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return searchResults.value.slice(start, end)
})
// 获取产品列表
const fetchData = async () => {
loading.value = true
const res = await getProductsList()
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 搜索
const handleSearch = async () => {
// 如果在表单页面,先关闭表单
if (showAddForm.value) {
cancelAdd()
}
loading.value = true
const params = {
type: filterType.value || undefined
}
if (select.value === '1') {
params.id = Number(search.value)
} else if (select.value === '2') {
params.name = search.value
} else if (select.value === '3') {
params.supplier = search.value
}
const res = await getProductsList(params)
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 重置搜索
const resetSearch = () => {
search.value = ''
filterType.value = ''
showSearchResults.value = false
showAddForm.value = false
currentPage.value = 1
fetchData()
}
// 清空搜索
const clearSearch = () => {
resetSearch()
}
// 清空表单
const resetForm = () => {
form.name = ''
form.type = ''
form.quantity = 0
form.price = 0
form.unit = '件'
form.specification = ''
form.supplier = ''
form.remark = ''
isEditMode.value = false
currentProductId.value = null
}
// 显示新增表单
const addProduct = () => {
resetForm()
showAddForm.value = true
showSearchResults.value = false
}
// 编辑
const editProduct = (row) => {
isEditMode.value = true
currentProductId.value = row.id
form.name = row.name
form.type = row.type
form.quantity = row.quantity
form.price = row.price
form.unit = row.unit
form.specification = row.specification
form.supplier = row.supplier
form.remark = row.remark
showAddForm.value = true
showSearchResults.value = false
}
// 保存产品
const saveProduct = async () => {
const formData = {
name: form.name,
type: form.type,
quantity: form.quantity,
price: form.price,
unit: form.unit,
specification: form.specification,
supplier: form.supplier,
remark: form.remark
}
try {
if (isEditMode.value) {
await updateProducts(currentProductId.value, formData)
ElMessage.success('更新成功')
} else {
await createProducts(formData)
ElMessage.success('新增成功')
}
cancelAdd()
fetchData()
} catch (error) {
ElMessage.error(error.response?.data?.message || error.message || '操作失败')
}
}
// 删除产品
const deleteProduct = async (id) => {
ElMessageBox.confirm('确定删除该产品吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteProductsAPI(id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
ElMessage.error(error.response?.data?.message || error.message || '删除失败')
}
})
}
// 取消
const cancelAdd = () => {
resetForm()
showAddForm.value = false
showSearchResults.value = true
}
// 双击行查看详情
const handleRowDblClick = (row) => {
currentDetail.value = { ...row }
showDetailDialog.value = true
}
// 关闭详情弹窗
const closeDetail = () => {
showDetailDialog.value = false
currentDetail.value = null
}
// 产品类型标签样式
const getTypeTag = (type) => {
const map = {
raw_material: '',
packaging: 'success',
equipment: 'warning',
cleaning: 'info',
promote: 'danger'
}
return map[type] || 'info'
}
// 页码变化
const handleCurrentChange = (val) => {
currentPage.value = val
}
// 检查权限
const canCreate = computed(() => hasPermission('product', 'create'))
const canUpdate = computed(() => hasPermission('product', 'update'))
const canDelete = computed(() => hasPermission('product', 'delete'))
const canOperate = computed(() => canUpdate.value || canDelete.value)
</script>
<template>
<div class="products-container">
<!-- 搜索栏区域 -->
<div class="products-search">
<div class="search-row">
<el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词"
class="products-search-with-select" @keyup.enter="handleSearch">
<template #prepend>
<el-select v-model="select" placeholder="请选择" style="width: 115px">
<el-option label="ID" value="1" />
<el-option label="名称" value="2" />
<el-option label="供应商" value="3" />
</el-select>
</template>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<div class="search-buttons">
<el-button type="success" @click="addProduct" v-if="canCreate">新增产品</el-button>
<el-button type="default" @click="resetSearch">重置</el-button>
</div>
</div>
<div class="filter-row">
<span class="filter-label">筛选条件</span>
<el-select v-model="filterType" placeholder="产品类型" clearable style="width: 140px; margin-right: 12px;">
<el-option label="原材料" value="raw_material" />
<el-option label="包装材料" value="packaging" />
<el-option label="设备器具" value="equipment" />
<el-option label="清洁用品" value="cleaning" />
<el-option label="宣传物料" value="promote" />
</el-select>
</div>
</div>
<div v-if="showSearchResults" class="products-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="searchResults.length === 0" class="empty-container">
<el-empty description="暂无数据" />
</div>
<div v-else>
<div class="list-header">
<span class="total-count">共找到 {{ searchResults.length }} 条记录</span>
</div>
<el-table :data="paginatedData" v-loading="loading" border style="width: 100%"
@row-dblclick="handleRowDblClick" row-class-name="clickable-row">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="产品名称" min-width="120" />
<el-table-column prop="type" label="产品类型" width="110">
<template #default="{ row }">
<el-tag :type="getTypeTag(row.type)">
{{ productTypeMap[row.type] || row.type }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="quantity" label="库存" width="80" />
<el-table-column prop="price" label="单价" width="100">
<template #default="{ row }">
¥{{ row.price?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="70" />
<el-table-column prop="specification" label="规格" min-width="140" show-overflow-tooltip />
<el-table-column prop="supplier" label="供应商" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right" v-if="canOperate">
<template #default="{ row }">
<el-button link type="primary" @click="editProduct(row)" v-if="canUpdate">编辑</el-button>
<el-button link type="danger" @click="deleteProduct(row.id)" v-if="canDelete">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="searchResults.length"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 新增/编辑产品表单 -->
<div v-if="showAddForm" class="products-info-label">
<el-divider content-position="left">{{ isEditMode ? '编辑产品信息' : '新增产品' }}</el-divider>
<el-form :model="form" label-width="auto" style="max-width: 600px" label-position="top">
<el-form-item label="产品名称" required>
<el-input v-model="form.name" placeholder="请输入产品名称" />
</el-form-item>
<el-form-item label="产品类型" required>
<el-select v-model="form.type" placeholder="请选择产品类型" style="width: 100%;">
<el-option label="原材料" value="raw_material" />
<el-option label="包装材料" value="packaging" />
<el-option label="设备器具" value="equipment" />
<el-option label="清洁用品" value="cleaning" />
<el-option label="宣传物料" value="promote" />
</el-select>
</el-form-item>
<el-form-item label="产品库存" required>
<el-input-number v-model="form.quantity" :min="0" :step="10" style="width: 100%;" />
</el-form-item>
<el-form-item label="产品单价" required>
<el-input-number v-model="form.price" :min="0" :step="0.01" :precision="2" style="width: 100%;" />
</el-form-item>
<el-form-item label="计量单位">
<el-input v-model="form.unit" placeholder="请输入计量单位" />
</el-form-item>
<el-form-item label="产品规格">
<el-input v-model="form.specification" placeholder="请输入产品规格/型号" />
</el-form-item>
<el-form-item label="供应商">
<el-select v-model="form.supplier" placeholder="请选择供应商" style="width: 100%;" filterable clearable>
<el-option v-for="s in supplierList" :key="s.id" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注信息" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveProduct">保存</el-button>
<el-button type="danger" @click="cancelAdd">取消</el-button>
</el-form-item>
</el-form>
</div>
<!-- 产品详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="产品详情" width="650px" @close="closeDetail">
<div v-if="currentDetail" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="产品编号">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="产品名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="产品类型">
<el-tag :type="getTypeTag(currentDetail.type)">
{{ productTypeMap[currentDetail.type] || currentDetail.type }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="库存数量">{{ currentDetail.quantity }}</el-descriptions-item>
<el-descriptions-item label="产品单价">
<span class="amount-text">¥{{ currentDetail.price?.toFixed(2) }}</span>
</el-descriptions-item>
<el-descriptions-item label="计量单位">{{ currentDetail.unit }}</el-descriptions-item>
<el-descriptions-item label="产品规格" :span="2">{{ currentDetail.specification || '无' }}</el-descriptions-item>
<el-descriptions-item label="供应商" :span="2">{{ currentDetail.supplier || '无' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ currentDetail.remark || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="closeDetail">关闭</el-button>
<el-button type="primary" @click="(() => { const d = currentDetail; closeDetail(); editProduct(d) })()">编辑</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.products-container {
padding: 20px;
}
.products-search {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.search-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.search-buttons {
display: flex;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.filter-label {
color: #606266;
font-size: 14px;
margin-right: 10px;
}
.list-header {
margin-bottom: 15px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.loading-container,
.empty-container {
padding: 40px 0;
}
.clickable-row {
cursor: pointer;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.detail-content {
padding: 10px 0;
}
.amount-text {
font-weight: 600;
color: #e6a23c;
font-size: 16px;
}
</style>

View File

@@ -1,11 +1,493 @@
<script setup> <script setup>
import { Search } from '@element-plus/icons-vue'
import { ref, onMounted, reactive, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getServiceList,
createService,
updateService,
deleteServiceAPI
} from '../api/service'
import { getSimpleCustomerList } from '../api/customer'
import { getSimpleEmployeeList } from '../api/employee'
import { hasPermission, getUserInfo } from '../store/user'
import { formatDate } from '../utils/date'
const form = reactive({
customer_id: '',
feedback: '',
employee_id: '',
handle_method: '',
handle_status: '待处理',
service_date: '',
remark: ''
})
const search = ref('')
const select = ref('1')
const filterStatus = ref('')
const searchResults = ref([])
const showSearchResults = ref(false)
const showAddForm = ref(false)
const isEditMode = ref(false)
const currentServiceId = ref(null)
const loading = ref(false)
const customerList = ref([])
const employeeList = ref([])
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
// 状态映射
const statusMap = {
'待处理': 'warning',
'处理中': 'primary',
'已完成': 'success'
}
onMounted(() => {
fetchData()
fetchOptions()
})
// 获取客户和员工列表供下拉选择(仅运营部员工可跟进售后)
const fetchOptions = async () => {
const results = await Promise.allSettled([
getSimpleCustomerList(null, { quiet: true }),
getSimpleEmployeeList({ department: '运营部' }, { quiet: true })
])
if (results[0].status === 'fulfilled') {
customerList.value = results[0].value.data || []
}
if (results[1].status === 'fulfilled') {
employeeList.value = results[1].value.data || []
}
}
// 监听状态变化:待处理时清空业务员
watch(() => form.handle_status, (val) => {
if (val === '待处理') {
form.employee_id = ''
}
})
// 监听筛选变化
watch(filterStatus, () => {
currentPage.value = 1
handleSearch()
})
// 监听搜索结果变化,重置页码
watch(searchResults, () => {
currentPage.value = 1
})
const filteredResults = computed(() => {
if (!isOperationsManager.value) return searchResults.value
return searchResults.value.filter(row => {
return row.responsible_user_id != null && Number(row.responsible_user_id) === Number(currentUserId.value)
})
})
// 分页后的数据
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredResults.value.slice(start, end)
})
// 获取售后列表
const fetchData = async () => {
loading.value = true
const res = await getServiceList()
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 搜索
const handleSearch = async () => {
// 如果在表单页面,先关闭表单
if (showAddForm.value) {
cancelAdd()
}
loading.value = true
const params = {
handle_status: filterStatus.value || undefined
}
if (select.value === '1') {
params.id = Number(search.value)
} else if (select.value === '2') {
params.customer_id = search.value
} else if (select.value === '3') {
params.feedback = search.value
} else if (select.value === '4') {
params.customer_name = search.value
}
const res = await getServiceList(params)
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 重置搜索
const resetSearch = () => {
search.value = ''
filterStatus.value = ''
showSearchResults.value = false
showAddForm.value = false
currentPage.value = 1
fetchData()
}
// 清空搜索
const clearSearch = () => {
resetSearch()
}
// 清空表单
const resetForm = () => {
form.customer_id = ''
form.feedback = ''
form.employee_id = ''
form.handle_method = ''
form.handle_status = '待处理'
form.service_date = ''
form.remark = ''
isEditMode.value = false
currentServiceId.value = null
}
// 显示新增表单
const addService = () => {
resetForm()
// 默认选中当前用户为业务员(如果其员工信息在列表中)
const currentUser = getUserInfo()
if (currentUser?.name && employeeList.value.length > 0) {
const matched = employeeList.value.find(e => e.name === currentUser.name)
if (matched) {
form.employee_id = matched.id
}
}
showAddForm.value = true
showSearchResults.value = false
}
// 编辑
const editService = (row) => {
isEditMode.value = true
currentServiceId.value = row.id
form.customer_id = row.customer_id
form.feedback = row.feedback
form.employee_id = row.employee_id
form.handle_method = row.handle_method
form.handle_status = row.handle_status
form.service_date = row.service_date
form.remark = row.remark
showAddForm.value = true
showSearchResults.value = false
}
// 保存售后
const saveService = async () => {
// 逻辑校验:处理中/已完成必须有业务员
if (form.handle_status !== '待处理' && !form.employee_id) {
ElMessage.warning('处理中和已完成的售后必须指定业务员')
return
}
const formData = {
customer_id: form.customer_id,
feedback: form.feedback,
employee_id: form.employee_id || null,
handle_method: form.handle_method,
handle_status: form.handle_status,
service_date: form.service_date,
remark: form.remark
}
try {
if (isEditMode.value) {
await updateService(currentServiceId.value, formData)
ElMessage.success('更新成功')
} else {
await createService(formData)
ElMessage.success('新增成功')
}
cancelAdd()
fetchData()
} catch (error) {
ElMessage.error(error.message || '操作失败,请重试')
}
}
// 删除售后
const deleteService = async (id) => {
ElMessageBox.confirm('确定删除该售后记录吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteServiceAPI(id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
ElMessage.error(error.message || '删除失败,请重试')
}
})
}
// 取消
const cancelAdd = () => {
resetForm()
showAddForm.value = false
showSearchResults.value = true
}
// 页码变化
const handleCurrentChange = (val) => {
currentPage.value = val
}
// 检查权限
const canCreate = computed(() => hasPermission('after_sale', 'create'))
const canUpdate = computed(() => hasPermission('after_sale', 'update'))
const canDelete = computed(() => hasPermission('after_sale', 'delete'))
const canOperate = computed(() => canUpdate.value || canDelete.value)
const isOperationsManager = computed(() => getUserInfo()?.departmentName === 'operations_manager')
const currentUserId = computed(() => getUserInfo()?.id)
const canEditRow = (row) => {
if (!canUpdate.value) return false
return true
}
const canDeleteRow = (row) => {
if (!canDelete.value) return false
return true
}
</script> </script>
<template> <template>
<h1>hello ,im 3</h1> <div class="service-container">
<!-- 搜索栏区域 -->
<div class="service-search">
<div class="search-row">
<el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词"
class="service-search-with-select" @keyup.enter="handleSearch">
<template #prepend>
<el-select v-model="select" placeholder="请选择" style="width: 115px">
<el-option label="编号" value="1" />
<el-option label="客户ID" value="2" />
<el-option label="客户姓名" value="4" />
<el-option label="反馈内容" value="3" />
</el-select>
</template>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<div class="search-buttons">
<el-button type="success" @click="addService" v-if="canCreate">新增售后</el-button>
<el-button type="default" @click="resetSearch">重置</el-button>
</div>
</div>
<div class="filter-row">
<span class="filter-label">筛选条件</span>
<el-select v-model="filterStatus" placeholder="处理状态" clearable style="width: 140px;">
<el-option label="待处理" value="待处理" />
<el-option label="处理中" value="处理中" />
<el-option label="已完成" value="已完成" />
</el-select>
</div>
</div>
<div v-if="showSearchResults" class="service-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="searchResults.length === 0" class="empty-container">
<el-empty description="暂无数据" />
</div>
<div v-else>
<div class="list-header">
<span class="total-count">共找到 {{ searchResults.length }} 条记录</span>
</div>
<el-table :data="paginatedData" v-loading="loading" border style="width: 100%">
<el-table-column prop="id" label="编号" width="70" />
<el-table-column prop="customer_name" label="客户" width="100">
<template #default="{ row }">
{{ row.customer_name || row.customer_id }}
</template>
</el-table-column>
<el-table-column prop="feedback" label="反馈内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="employee_name" label="业务员" width="90">
<template #default="{ row }">
{{ row.employee_name || row.employee_id || '—' }}
</template>
</el-table-column>
<el-table-column prop="handle_status" label="处理状态" width="100">
<template #default="{ row }">
<el-tag :type="statusMap[row.handle_status]">
{{ row.handle_status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="service_date" label="售后日期" width="120">
<template #default="{ row }">
{{ formatDate(row.service_date) }}
</template>
</el-table-column>
<el-table-column prop="handle_method" label="处理方式" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right" v-if="canOperate">
<template #default="{ row }">
<el-button link type="primary" @click="editService(row)" v-if="canEditRow(row)">编辑</el-button>
<el-button link type="danger" @click="deleteService(row.id)" v-if="canDeleteRow(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredResults.length"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 新增/编辑售后表单 -->
<div v-if="showAddForm" class="service-info-label">
<el-divider content-position="left">{{ isEditMode ? '编辑售后记录' : '新增售后' }}</el-divider>
<el-form :model="form" label-width="auto" style="max-width: 600px" label-position="top">
<el-form-item label="客户" required>
<el-select v-model="form.customer_id" placeholder="请选择客户" style="width: 100%;" filterable>
<el-option v-for="c in customerList" :key="c.id" :label="`${c.id} - ${c.name}`" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="反馈内容" required>
<el-input v-model="form.feedback" type="textarea" :rows="3" placeholder="请输入客户反馈意见/售后内容" />
</el-form-item>
<el-form-item label="处理状态" required>
<el-select v-model="form.handle_status" style="width: 100%;">
<el-option label="待处理" value="待处理" />
<el-option label="处理中" value="处理中" />
<el-option label="已完成" value="已完成" />
</el-select>
</el-form-item>
<el-form-item label="售后日期" required>
<el-date-picker v-model="form.service_date" type="date" value-format="YYYY-MM-DD"
placeholder="请选择日期" style="width: 100%;" />
</el-form-item>
<el-form-item label="处理业务员">
<el-select v-model="form.employee_id" placeholder="请选择业务员" style="width: 100%;" filterable clearable>
<el-option v-for="e in employeeList" :key="e.id" :label="`${e.id} - ${e.name}`" :value="e.id" />
</el-select>
</el-form-item>
<el-form-item label="处理方式">
<el-input v-model="form.handle_method" type="textarea" :rows="3" placeholder="请输入处理方式/解决方案" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注信息" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveService">保存</el-button>
<el-button type="danger" @click="cancelAdd">取消</el-button>
</el-form-item>
</el-form>
</div>
<!-- 售后详情弹窗 -->
</div>
</template> </template>
<style scoped> <style scoped>
.service-container {
padding: 20px;
}
.service-search {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.search-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.search-buttons {
display: flex;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.filter-label {
color: #606266;
font-size: 14px;
margin-right: 10px;
}
.list-header {
margin-bottom: 15px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.loading-container,
.empty-container {
padding: 40px 0;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
</style> </style>

467
src/components/supplier.vue Normal file
View File

@@ -0,0 +1,467 @@
<script setup>
import { Search } from '@element-plus/icons-vue'
import { ref, onMounted, reactive, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getSupplierList,
createSupplier,
updateSupplier,
deleteSupplierAPI
} from '../api/suppliers'
import { hasPermission } from '../store/user'
const form = reactive({
name: '',
contact: '',
phone: '',
address: '',
type: '',
content: '',
status: 1
})
const search = ref('')
const select = ref('1')
const filterType = ref('')
const filterStatus = ref('')
const searchResults = ref([])
const showSearchResults = ref(false)
const showAddForm = ref(false)
const isEditMode = ref(false)
const currentSupplierId = ref(null)
const loading = ref(false)
const showDetailDialog = ref(false)
const currentDetail = ref(null)
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
// 供应商类型
const supplierTypes = ['原材料', '包装', '设备']
onMounted(() => {
fetchData()
})
// 监听筛选变化
watch([filterType, filterStatus], () => {
currentPage.value = 1
handleSearch()
})
// 监听搜索结果变化
watch(searchResults, () => {
currentPage.value = 1
})
// 分页后的数据
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return searchResults.value.slice(start, end)
})
// 获取供应商列表
const fetchData = async () => {
loading.value = true
const res = await getSupplierList()
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 搜索
const handleSearch = async () => {
// 如果在表单页面,先关闭表单
if (showAddForm.value) {
cancelAdd()
}
loading.value = true
const params = {
type: filterType.value || undefined,
status: filterStatus.value !== '' ? filterStatus.value : undefined
}
// 根据搜索类型添加参数
if (search.value) {
if (select.value === '1') {
params.id = Number(search.value)
} else if (select.value === '2') {
params.name = search.value
}
}
const res = await getSupplierList(params)
if (res.code === 0 || res.code === 200) {
searchResults.value = res.data.list || res.data
}
showSearchResults.value = true
loading.value = false
}
// 清空表单
const resetForm = () => {
form.name = ''
form.contact = ''
form.phone = ''
form.address = ''
form.type = ''
form.content = ''
form.status = 1
isEditMode.value = false
currentSupplierId.value = null
}
// 显示新增表单
const addSupplier = () => {
resetForm()
showAddForm.value = true
showSearchResults.value = false
}
// 编辑
const editSupplier = (row) => {
isEditMode.value = true
currentSupplierId.value = row.id
form.name = row.name
form.contact = row.contact
form.phone = row.phone
form.address = row.address
form.type = row.type
form.content = row.content
form.status = row.status
showAddForm.value = true
showSearchResults.value = false
}
// 保存供应商
const saveSupplier = async () => {
if (!form.name) {
ElMessage.warning('请输入供应商名称')
return
}
const formData = {
name: form.name,
contact: form.contact,
phone: form.phone,
address: form.address,
type: form.type,
content: form.content,
status: form.status
}
try {
if (isEditMode.value) {
await updateSupplier(currentSupplierId.value, formData)
ElMessage.success('更新成功')
} else {
await createSupplier(formData)
ElMessage.success('新增成功')
}
cancelAdd()
fetchData()
} catch (error) {
ElMessage.error(error.response?.data?.message || error.message || '操作失败')
}
}
// 删除供应商
const deleteSupplier = async (id) => {
ElMessageBox.confirm('确定删除该供应商吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteSupplierAPI(id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
ElMessage.error(error.response?.data?.message || error.message || '删除失败')
}
})
}
// 取消
const cancelAdd = () => {
resetForm()
showAddForm.value = false
showSearchResults.value = true
}
// 清空搜索
const clearSearch = () => {
search.value = ''
filterType.value = ''
filterStatus.value = ''
showSearchResults.value = false
showAddForm.value = false
currentPage.value = 1
fetchData()
}
// 双击行查看详情
const handleRowDblClick = (row) => {
currentDetail.value = { ...row }
showDetailDialog.value = true
}
// 关闭详情弹窗
const closeDetail = () => {
showDetailDialog.value = false
currentDetail.value = null
}
// 页码变化
const handleCurrentChange = (val) => {
currentPage.value = val
}
// 检查权限
const canCreate = computed(() => hasPermission('supplier', 'create'))
const canUpdate = computed(() => hasPermission('supplier', 'update'))
const canDelete = computed(() => hasPermission('supplier', 'delete'))
const canOperate = computed(() => canUpdate.value || canDelete.value)
</script>
<template>
<div class="supplier-container">
<!-- 搜索栏区域 -->
<div class="supplier-search">
<div class="search-row">
<el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词"
class="supplier-search-with-select" @keyup.enter="handleSearch">
<template #prepend>
<el-select v-model="select" placeholder="请选择" style="width: 115px">
<el-option label="ID" value="1" />
<el-option label="名称" value="2" />
</el-select>
</template>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<div class="search-buttons">
<el-button type="success" @click="addSupplier" v-if="canCreate">新增供应商</el-button>
<el-button type="default" @click="clearSearch">重置</el-button>
</div>
</div>
<div class="filter-row">
<span class="filter-label">筛选条件</span>
<el-select v-model="filterType" placeholder="供应商类型" clearable style="width: 140px; margin-right: 12px;">
<el-option v-for="type in supplierTypes" :key="type" :label="type" :value="type" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 140px;">
<el-option label="正常" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</div>
</div>
<div v-if="showSearchResults" class="supplier-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="searchResults.length === 0" class="empty-container">
<el-empty description="暂无数据" />
</div>
<div v-else>
<div class="list-header">
<span class="total-count">共找到 {{ searchResults.length }} 条记录</span>
</div>
<el-table :data="paginatedData" v-loading="loading" border style="width: 100%"
@row-dblclick="handleRowDblClick" row-class-name="clickable-row">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="供应商名称" min-width="150" />
<el-table-column prop="contact" label="联系人" width="100" />
<el-table-column prop="phone" label="联系电话" width="130" />
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag>{{ row.type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" v-if="canOperate">
<template #default="{ row }">
<el-button link type="primary" @click="editSupplier(row)" v-if="canUpdate">编辑</el-button>
<el-button link type="danger" @click="deleteSupplier(row.id)" v-if="canDelete">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="searchResults.length"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 新增/编辑供应商表单 -->
<div v-if="showAddForm" class="supplier-info-label">
<el-divider content-position="left">{{ isEditMode ? '编辑供应商信息' : '新增供应商' }}</el-divider>
<el-form :model="form" label-width="auto" style="max-width: 600px" label-position="top">
<el-form-item label="供应商名称" required>
<el-input v-model="form.name" placeholder="请输入供应商名称" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="form.contact" placeholder="请输入联系人" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="form.phone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="form.address" placeholder="请输入地址" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="form.type" placeholder="请选择类型" style="width: 100%;">
<el-option v-for="type in supplierTypes" :key="type" :label="type" :value="type" />
</el-select>
</el-form-item>
<el-form-item label="备注说明">
<el-input v-model="form.content" type="textarea" :rows="3" placeholder="请输入备注说明" />
</el-form-item>
<el-form-item label="状态" required>
<el-radio-group v-model="form.status">
<el-radio :value="1">正常</el-radio>
<el-radio :value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveSupplier">保存</el-button>
<el-button type="danger" @click="cancelAdd">取消</el-button>
</el-form-item>
</el-form>
</div>
<!-- 供应商详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="供应商详情" width="650px" @close="closeDetail">
<div v-if="currentDetail" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="供应商ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="供应商名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ currentDetail.contact || '无' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ currentDetail.phone || '无' }}</el-descriptions-item>
<el-descriptions-item label="类型">
<el-tag>{{ currentDetail.type || '无' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentDetail.status === 1 ? 'success' : 'danger'">
{{ currentDetail.status === 1 ? '正常' : '停用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ currentDetail.address || '无' }}</el-descriptions-item>
<el-descriptions-item label="备注说明" :span="2">{{ currentDetail.content || '无' }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="closeDetail">关闭</el-button>
<el-button type="primary" @click="(() => { const d = currentDetail; closeDetail(); editSupplier(d) })()" v-if="canUpdate">编辑</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.supplier-container {
padding: 20px;
}
.supplier-search {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.search-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.search-buttons {
display: flex;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.filter-label {
color: #606266;
font-size: 14px;
margin-right: 10px;
}
.list-header {
margin-bottom: 15px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.loading-container,
.empty-container {
padding: 40px 0;
}
.clickable-row {
cursor: pointer;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.detail-content {
padding: 10px 0;
}
</style>

508
src/components/user.vue Normal file
View File

@@ -0,0 +1,508 @@
<script setup>
import { Search, Lock } from '@element-plus/icons-vue'
import { ref, onMounted, reactive, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getUserList,
createUser,
updateUser,
deleteUserAPI
} from '../api/user'
import { getSimpleEmployeeList, getDepartmentList } from '../api/employee'
import { hasPermission } from '../store/user'
import { formatDateTime } from '../utils/date'
import { DEPARTMENT_LIST } from '../constants/departments'
const form = reactive({
username: '',
password: '',
department_id: 2,
employee_id: null,
is_active: 1
})
const search = ref('')
const select = ref('1')
const filterType = ref('')
const filterStatus = ref('')
const searchResults = ref([])
const showSearchResults = ref(false)
const showAddForm = ref(false)
const isEditMode = ref(false)
const currentUserId = ref(null)
const loading = ref(false)
const showDetailDialog = ref(false)
const currentDetail = ref(null)
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const totalPages = ref(0)
// 部门列表(动态加载,回退到常量)
const deptList = ref([...DEPARTMENT_LIST])
// 员工列表
const employeeList = ref([])
onMounted(() => {
fetchData()
fetchEmployees()
loadDepartments()
})
// 加载部门列表
const loadDepartments = async () => {
const res = await getDepartmentList()
if (res.code === 0 || res.code === 200) {
deptList.value = res.data || DEPARTMENT_LIST
}
}
// 监听筛选变化
watch([filterType, filterStatus], () => {
currentPage.value = 1
handleSearch()
})
// 监听搜索结果变化(服务端分页:不重置页码,避免翻页后被重置回第 1 页)
// 分页后的数据(服务端已分页)
const paginatedData = computed(() => searchResults.value)
// 获取员工列表(用于下拉选择,改用 simple 接口:无数据范围限制,仅返还在职员工基本信息)
const fetchEmployees = async () => {
const res = await getSimpleEmployeeList()
if (res.code === 0 || res.code === 200) {
employeeList.value = res.data
}
}
// 获取用户列表
const fetchData = async (page) => {
loading.value = true
const params = {
page: page || currentPage.value,
pageSize: pageSize.value
}
const res = await getUserList(params)
if (res.code === 0 || res.code === 200) {
if (res.data && Array.isArray(res.data.list)) {
searchResults.value = res.data.list
total.value = res.data.total
totalPages.value = res.data.totalPages
} else if (Array.isArray(res.data)) {
searchResults.value = res.data
total.value = res.data.length
}
}
showSearchResults.value = true
loading.value = false
}
// 搜索
const handleSearch = async () => {
// 如果在表单页面,先关闭表单
if (showAddForm.value) {
cancelAdd()
}
currentPage.value = 1
loading.value = true
const params = {
page: 1,
pageSize: pageSize.value,
department_id: filterType.value || undefined,
is_active: filterStatus.value !== '' ? filterStatus.value : undefined
}
// 根据搜索类型添加参数
if (search.value) {
if (select.value === '1') {
params.username = search.value
} else if (select.value === '2') {
params.real_name = search.value
} else if (select.value === '3') {
params.id = Number(search.value)
}
}
const res = await getUserList(params)
if (res.code === 0 || res.code === 200) {
if (res.data && Array.isArray(res.data.list)) {
searchResults.value = res.data.list
total.value = res.data.total
totalPages.value = res.data.totalPages
} else if (Array.isArray(res.data)) {
searchResults.value = res.data
total.value = res.data.length
}
}
showSearchResults.value = true
loading.value = false
}
// 清空表单
const resetForm = () => {
form.username = ''
form.password = ''
form.department_id = 2
form.employee_id = null
form.is_active = 1
isEditMode.value = false
currentUserId.value = null
}
// 显示新增表单
const addUser = () => {
resetForm()
showAddForm.value = true
showSearchResults.value = false
}
// 编辑
const editUser = (row) => {
isEditMode.value = true
currentUserId.value = row.id
form.username = row.username
form.password = ''
form.department_id = row.department_id
form.employee_id = row.employee_id
form.is_active = row.is_active
showAddForm.value = true
showSearchResults.value = false
}
// 保存用户
const saveUser = async () => {
if (!form.username) {
ElMessage.warning('请输入用户名')
return
}
if (!isEditMode.value && !form.password) {
ElMessage.warning('请输入密码')
return
}
const formData = {
username: form.username,
department_id: form.department_id,
employee_id: form.employee_id,
is_active: form.is_active
}
if (!isEditMode.value) {
formData.password = form.password
} else if (form.password) {
formData.password = form.password
}
try {
if (isEditMode.value) {
await updateUser(currentUserId.value, formData)
ElMessage.success('更新成功')
} else {
await createUser(formData)
ElMessage.success('新增成功')
}
cancelAdd()
fetchData(1)
} catch (error) {
ElMessage.error(error.response?.data?.message || error.message || '操作失败')
}
}
// 删除用户
const deleteUser = async (id) => {
ElMessageBox.confirm('确定删除该用户吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
await deleteUserAPI(id)
ElMessage.success('删除成功')
fetchData(1)
} catch (error) {
ElMessage.error(error.response?.data?.message || error.message || '删除失败')
}
})
}
// 取消
const cancelAdd = () => {
resetForm()
showAddForm.value = false
showSearchResults.value = true
}
// 清空搜索
const clearSearch = () => {
search.value = ''
filterType.value = ''
filterStatus.value = ''
showSearchResults.value = false
showAddForm.value = false
currentPage.value = 1
fetchData(1)
}
// 双击行查看详情
const handleRowDblClick = (row) => {
currentDetail.value = { ...row }
showDetailDialog.value = true
}
// 关闭详情弹窗
const closeDetail = () => {
showDetailDialog.value = false
currentDetail.value = null
}
// 页码变化(服务端分页:重新请求后端)
const handleCurrentChange = (val) => {
currentPage.value = val
fetchData(val)
}
// 检查权限
const canManage = computed(() => hasPermission('user', 'manage'))
</script>
<template>
<div class="user-container">
<!-- 搜索栏区域 -->
<div class="user-search">
<div class="search-row">
<el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词"
class="user-search-with-select" @keyup.enter="handleSearch">
<template #prepend>
<el-select v-model="select" placeholder="请选择" style="width: 115px">
<el-option label="用户名" value="1" />
<el-option label="真实姓名" value="2" />
<el-option label="ID" value="3" />
</el-select>
</template>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<div class="search-buttons">
<el-button color="#E60012" @click="addUser" v-if="canManage">新增用户</el-button>
<el-button type="default" @click="clearSearch">重置</el-button>
</div>
</div>
<div class="filter-row">
<span class="filter-label">筛选条件</span>
<el-select v-model="filterType" placeholder="所属部门" clearable style="width: 140px; margin-right: 12px;">
<el-option v-for="dept in deptList" :key="dept.id" :label="dept.description" :value="dept.id" />
</el-select>
<el-select v-model="filterStatus" placeholder="用户状态" clearable style="width: 140px;">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</div>
</div>
<div v-if="showSearchResults" class="user-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="searchResults.length === 0" class="empty-container">
<el-empty description="暂无数据" />
</div>
<div v-else>
<div class="list-header">
<span class="total-count">共找到 {{ total }} 条记录</span>
</div>
<el-table :data="paginatedData" v-loading="loading" border style="width: 100%"
@row-dblclick="handleRowDblClick" row-class-name="clickable-row">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="real_name" label="真实姓名" min-width="120">
<template #default="{ row }">
{{ row.real_name || '-' }}
</template>
</el-table-column>
<el-table-column prop="dept_desc" label="部门" min-width="120">
<template #default="{ row }">
{{ row.dept_desc || '-' }}
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_active === 1 ? 'success' : 'danger'">
{{ row.is_active === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" v-if="canManage">
<template #default="{ row }">
<el-button link type="primary" @click="editUser(row)">编辑</el-button>
<el-button link type="danger" @click="deleteUser(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 新增用户表单 -->
<div v-if="showAddForm" class="user-info-label">
<el-divider content-position="left">{{ isEditMode ? '编辑用户信息' : '新增用户' }}</el-divider>
<el-form :model="form" label-width="auto" style="max-width: 600px" label-position="top">
<el-form-item label="用户名" required>
<el-input v-model="form.username" placeholder="请输入用户名" :disabled="isEditMode" />
</el-form-item>
<el-form-item label="密码" :required="!isEditMode">
<el-input v-model="form.password" :prefix-icon="Lock" clearable :placeholder="isEditMode ? '留空则不修改' : '请输入密码'" show-password
type="password" />
</el-form-item>
<el-form-item label="关联员工">
<el-select v-model="form.employee_id" placeholder="请选择关联员工" style="width: 100%;" filterable clearable>
<el-option v-for="emp in employeeList" :key="emp.id" :label="`${emp.id} - ${emp.name}`" :value="emp.id" />
</el-select>
</el-form-item>
<el-form-item label="所属部门" required>
<el-select v-model="form.department_id" placeholder="请选择部门" style="width: 100%;">
<el-option v-for="dept in deptList" :key="dept.id" :label="dept.description" :value="dept.id" />
</el-select>
</el-form-item>
<el-form-item label="用户状态" required>
<el-radio-group v-model="form.is_active">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveUser">保存</el-button>
<el-button type="danger" @click="cancelAdd">取消</el-button>
</el-form-item>
</el-form>
</div>
<!-- 用户详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="用户详情" width="550px" @close="closeDetail">
<div v-if="currentDetail" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ currentDetail.username }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ currentDetail.real_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="部门">{{ currentDetail.dept_desc || '-' }}</el-descriptions-item>
<el-descriptions-item label="关联员工">{{ currentDetail.employee_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="currentDetail.is_active === 1 ? 'success' : 'danger'">
{{ currentDetail.is_active === 1 ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(currentDetail.created_at) }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="closeDetail">关闭</el-button>
<el-button type="primary" @click="(() => { const d = currentDetail; closeDetail(); editUser(d) })()" v-if="canManage">编辑</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.user-container {
padding: 20px;
}
.user-search {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.search-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.search-buttons {
display: flex;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.filter-label {
color: #606266;
font-size: 14px;
margin-right: 10px;
}
.list-header {
margin-bottom: 15px;
}
.total-count {
color: #909399;
font-size: 14px;
}
.loading-container,
.empty-container {
padding: 40px 0;
}
.clickable-row {
cursor: pointer;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.detail-content {
padding: 10px 0;
}
</style>

View File

@@ -0,0 +1,22 @@
/**
* 部门常量
*
* 与后端 backmanager-server 数据库 departments 表定义的 6 个部门一一对应。
* 当前使用本地常量,避免每次加载都请求后端。
*
* 如需改为动态获取,请:
* 1. 确保后端已部署 GET /api/departments 接口
* 2. 在 src/api/employee.js 中取消 getDepartmentList 的注释(替换现有实现)
* 3. 在组件中调用 getDepartmentList() 替代导入此文件
*/
export const DEPARTMENT_LIST = [
{ id: 1, name: 'admin', description: '信息技术部' },
{ id: 2, name: 'general_manager', description: '总经理办公室' },
{ id: 3, name: 'franchise_manager', description: '招商部' },
{ id: 4, name: 'operations_manager', description: '运营部' },
{ id: 5, name: 'procurement_manager', description: '采购部' },
{ id: 6, name: 'finance', description: '财务部' },
]
/** 描述文本列表(用于 employee 表单的部门下拉选择) */
export const DEPARTMENT_NAME_LIST = DEPARTMENT_LIST.map(d => d.description)

View File

@@ -5,6 +5,10 @@ import Panel from "./components/panel.vue";
import Customer from "./components/customer.vue"; import Customer from "./components/customer.vue";
import Contract from "./components/contract.vue"; import Contract from "./components/contract.vue";
import Service from "./components/service.vue"; import Service from "./components/service.vue";
import Products from "./components/products.vue";
import Employee from "./components/employee.vue";
import User from "./components/user.vue";
import Supplier from "./components/supplier.vue";
const routes = [ const routes = [
{ path: "/", redirect: "/login" }, { path: "/", redirect: "/login" },
@@ -14,10 +18,14 @@ const routes = [
component: Panel, component: Panel,
redirect: "/panel/home", redirect: "/panel/home",
children: [ children: [
{ path: "home", component:Home}, { path: "home", component: Home },
{ path: "customer", component: Customer }, { path: "customer", component: Customer, meta: { permission: 'customer:read' } },
{ path: "contract", component: Contract }, { path: "contract", component: Contract, meta: { permission: 'contract:read' } },
{ path: "service", component: Service }, { path: "service", component: Service, meta: { permission: 'after_sale:read' } },
{ path: "products", component: Products, meta: { permission: 'product:read' } },
{ path: "employee", component: Employee, meta: { permission: 'employee:read' } },
{ path: "user", component: User, meta: { permission: 'user:manage' } },
{ path: "supplier", component: Supplier, meta: { permission: 'supplier:read' } }
], ],
}, },
] ]
@@ -26,4 +34,39 @@ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes,
}) })
// 全局前置守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
// 登录页
if (to.path === '/login') {
if (token) {
// 已登录,跳转到首页
next('/panel/home')
} else {
next()
}
return
}
// 其他页面需要登录
if (!token) {
next('/login')
return
}
// 检查路由权限
if (to.meta.permission) {
const [resource, action] = to.meta.permission.split(':')
const userInfo = JSON.parse(localStorage.getItem('userInfo') || 'null')
if (!userInfo?.permissions?.includes(to.meta.permission)) {
next('/panel/home')
return
}
}
next()
})
export default router; export default router;

72
src/store/user.js Normal file
View File

@@ -0,0 +1,72 @@
import { reactive, ref } from 'vue'
import request from '../api/request'
// 用户状态
const state = reactive({
token: localStorage.getItem('token') || '',
userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null')
})
// 是否已登录
const isLoggedIn = ref(!!state.token)
// 登录
export const login = async (username, password) => {
const res = await request.post('/user/login', { username, password })
if (res.code === 0 || res.code === 200) {
state.token = res.data.token
state.userInfo = res.data.userInfo
isLoggedIn.value = true
localStorage.setItem('token', res.data.token)
localStorage.setItem('userInfo', JSON.stringify(res.data.userInfo))
}
return res
}
// 登出
export const logout = async () => {
try {
await request.post('/user/logout')
} catch (e) {
// 即使请求失败也清除本地状态
}
state.token = ''
state.userInfo = null
isLoggedIn.value = false
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
// 获取当前用户信息
export const fetchUserInfo = async () => {
const res = await request.get('/user/info')
if (res.code === 0 || res.code === 200) {
state.userInfo = res.data
localStorage.setItem('userInfo', JSON.stringify(res.data))
}
return res
}
// 检查是否有某个权限
export const hasPermission = (resource, action) => {
if (!state.userInfo?.permissions) return false
const permission = `${resource}:${action}`
return state.userInfo.permissions.includes(permission)
}
// 获取当前用户信息
export const getUserInfo = () => state.userInfo
// 获取 token
export const getToken = () => state.token
export default {
state,
isLoggedIn,
login,
logout,
fetchUserInfo,
hasPermission,
getUserInfo,
getToken
}

32
src/utils/date.js Normal file
View File

@@ -0,0 +1,32 @@
// 格式化日期时间(带时间)
export const formatDateTime = (dateStr) => {
if (!dateStr) return '-'
try {
const date = new Date(dateStr)
if (isNaN(date.getTime())) return dateStr
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} catch {
return dateStr
}
}
// 格式化日期(只显示日期部分)
export const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
const date = new Date(dateStr)
if (isNaN(date.getTime())) return dateStr
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
} catch {
return dateStr
}
}

View File

@@ -4,4 +4,13 @@ import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
}) })

240
yarn.lock
View File

@@ -34,7 +34,7 @@
"@element-plus/icons-vue@^2.3.2": "@element-plus/icons-vue@^2.3.2":
version "2.3.2" version "2.3.2"
resolved "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz#7e9cb231fb738b2056f33e22c3a29e214b538dcf" resolved "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz"
integrity sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A== integrity sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==
"@emnapi/core@1.10.0": "@emnapi/core@1.10.0":
@@ -85,11 +85,11 @@
integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
"@napi-rs/wasm-runtime@^1.1.4": "@napi-rs/wasm-runtime@^1.1.4":
version "1.1.4" version "1.1.6"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1" resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz#ed33806d0f9be98dc76d0c3d4fd872fda701b5d5"
integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow== integrity sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==
dependencies: dependencies:
"@tybys/wasm-util" "^0.10.1" "@tybys/wasm-util" "^0.10.3"
"@oxc-project/types@=0.133.0": "@oxc-project/types@=0.133.0":
version "0.133.0" version "0.133.0"
@@ -148,12 +148,12 @@
"@rolldown/binding-linux-x64-gnu@1.0.3": "@rolldown/binding-linux-x64-gnu@1.0.3":
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz#64c2d26f75dffd9b5a1f97557a00ae77250c8cb7" resolved "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz"
integrity sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg== integrity sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==
"@rolldown/binding-linux-x64-musl@1.0.3": "@rolldown/binding-linux-x64-musl@1.0.3":
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz#5a45132e8a47659eeaaf3b540c2954a97c860ff3" resolved "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz"
integrity sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow== integrity sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==
"@rolldown/binding-openharmony-arm64@1.0.3": "@rolldown/binding-openharmony-arm64@1.0.3":
@@ -185,10 +185,10 @@
resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz" resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz"
integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==
"@tybys/wasm-util@^0.10.1": "@tybys/wasm-util@^0.10.3":
version "0.10.2" version "0.10.3"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d"
integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
@@ -260,7 +260,7 @@
"@vue/devtools-api@^6.6.4": "@vue/devtools-api@^6.6.4":
version "6.6.4" version "6.6.4"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343" resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/reactivity@3.5.35": "@vue/reactivity@3.5.35":
@@ -320,15 +320,52 @@
resolved "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz" resolved "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz"
integrity sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg== integrity sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==
agent-base@6:
version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
dependencies:
debug "4"
async-validator@^4.2.5: async-validator@^4.2.5:
version "4.2.5" version "4.2.5"
resolved "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz" resolved "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz"
integrity sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg== integrity sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==
china-division@^2.7.0: asynckit@^0.4.0:
version "2.7.0" version "0.4.0"
resolved "https://registry.npmmirror.com/china-division/-/china-division-2.7.0.tgz#4060a4d243be66c7833dea64a48a4038f3e53e74" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-4uUPAT+1WfqDh5jytq7omdCmHNk3j+k76zEG/2IqaGcYB90c2SwcixttcypdsZ3T/9tN1TTpBDoeZn+Yw/qBEA== integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
axios@^1.18.1:
version "1.18.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.18.1.tgz#d63f9863bcd8938815c86f9e2abd380189d96dfe"
integrity sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==
dependencies:
follow-redirects "^1.16.0"
form-data "^4.0.5"
https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0"
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
dependencies:
es-errors "^1.3.0"
function-bind "^1.1.2"
china-area-data@^5.0.1:
version "5.0.1"
resolved "https://registry.npmmirror.com/china-area-data/-/china-area-data-5.0.1.tgz"
integrity sha512-BQDPpiv5Nn+018ekcJK2oSD9PAD+E1bvXB0wgabc//dFVS/KvRqCgg0QOEUt3vBkx9XzB5a9BmkJCEZDBxVjVw==
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
dependencies:
delayed-stream "~1.0.0"
csstype@^3.2.3: csstype@^3.2.3:
version "3.2.3" version "3.2.3"
@@ -340,17 +377,39 @@ dayjs@^1.11.20:
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz"
integrity sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA== integrity sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==
debug@4:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
dependencies:
ms "^2.1.3"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
detect-libc@^2.0.3: detect-libc@^2.0.3:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
element-china-area-data@^6.1.0: dunder-proto@^1.0.1:
version "6.1.0" version "1.0.1"
resolved "https://registry.npmmirror.com/element-china-area-data/-/element-china-area-data-6.1.0.tgz#f14b90c0762b21432e097ed5be8423514a0b57e3" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
integrity sha512-IkpcjwQv2A/2AxFiSoaISZ+oMw1rZCPUSOg5sOCwT5jKc96TaawmKZeY81xfxXsO0QbKxU5LLc6AirhG52hUmg== integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
dependencies: dependencies:
china-division "^2.7.0" call-bind-apply-helpers "^1.0.1"
es-errors "^1.3.0"
gopd "^1.2.0"
element-china-area-data@^5.0.2:
version "5.0.2"
resolved "https://registry.npmmirror.com/element-china-area-data/-/element-china-area-data-5.0.2.tgz"
integrity sha512-vLQuvOKJy/uiX7MRHEk3x/j09hipuIl6DJ/C4XFUG7D7Pj3O47sy+Y6aAArM6k9v8cD9UX6e+yz2S4J+IPnZ8g==
dependencies:
china-area-data "^5.0.1"
lodash-es "^4.17.15"
element-plus@^2.14.1: element-plus@^2.14.1:
version "2.14.1" version "2.14.1"
@@ -378,6 +437,33 @@ entities@^7.0.1:
resolved "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz" resolved "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz"
integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
es-define-property@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
es-errors@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b"
integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
dependencies:
es-errors "^1.3.0"
es-set-tostringtag@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d"
integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==
dependencies:
es-errors "^1.3.0"
get-intrinsic "^1.2.6"
has-tostringtag "^1.0.2"
hasown "^2.0.2"
estree-walker@^2.0.2: estree-walker@^2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz"
@@ -388,11 +474,88 @@ fdir@^6.5.0:
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
follow-redirects@^1.16.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
form-data@^4.0.5:
version "4.0.6"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827"
integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
es-set-tostringtag "^2.1.0"
hasown "^2.0.4"
mime-types "^2.1.35"
fsevents@~2.3.3: fsevents@~2.3.3:
version "2.3.3" version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
get-intrinsic@^1.2.6:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
dependencies:
call-bind-apply-helpers "^1.0.2"
es-define-property "^1.0.1"
es-errors "^1.3.0"
es-object-atoms "^1.1.1"
function-bind "^1.1.2"
get-proto "^1.0.1"
gopd "^1.2.0"
has-symbols "^1.1.0"
hasown "^2.0.2"
math-intrinsics "^1.1.0"
get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
dependencies:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
has-symbols@^1.0.3, has-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
has-tostringtag@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
dependencies:
has-symbols "^1.0.3"
hasown@^2.0.2, hasown@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
dependencies:
function-bind "^1.1.2"
https-proxy-agent@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
dependencies:
agent-base "6"
debug "4"
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
version "1.32.0" version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
@@ -430,12 +593,12 @@ lightningcss-linux-arm64-musl@1.32.0:
lightningcss-linux-x64-gnu@1.32.0: lightningcss-linux-x64-gnu@1.32.0:
version "1.32.0" version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6" resolved "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz"
integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
lightningcss-linux-x64-musl@1.32.0: lightningcss-linux-x64-musl@1.32.0:
version "1.32.0" version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b" resolved "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz"
integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
lightningcss-win32-arm64-msvc@1.32.0: lightningcss-win32-arm64-msvc@1.32.0:
@@ -467,7 +630,7 @@ lightningcss@^1.32.0:
lightningcss-win32-arm64-msvc "1.32.0" lightningcss-win32-arm64-msvc "1.32.0"
lightningcss-win32-x64-msvc "1.32.0" lightningcss-win32-x64-msvc "1.32.0"
lodash-es@^4.18.1: lodash-es@^4.17.15, lodash-es@^4.18.1:
version "4.18.1" version "4.18.1"
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz" resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz"
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
@@ -489,11 +652,33 @@ magic-string@^0.30.21:
dependencies: dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5" "@jridgewell/sourcemap-codec" "^1.5.5"
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
memoize-one@^6.0.0: memoize-one@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz"
integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.35:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
dependencies:
mime-db "1.52.0"
ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
nanoid@^3.3.12: nanoid@^3.3.12:
version "3.3.12" version "3.3.12"
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz"
@@ -523,6 +708,11 @@ postcss@^8.5.15:
picocolors "^1.1.1" picocolors "^1.1.1"
source-map-js "^1.2.1" source-map-js "^1.2.1"
proxy-from-env@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
rolldown@1.0.3: rolldown@1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz" resolved "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz"
@@ -585,7 +775,7 @@ vue-component-type-helpers@^3.3.1:
vue-router@4: vue-router@4:
version "4.6.4" version "4.6.4"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.6.4.tgz#a0a9cb9ef811a106d249e4bb9313d286718020d8" resolved "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz"
integrity sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg== integrity sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==
dependencies: dependencies:
"@vue/devtools-api" "^6.6.4" "@vue/devtools-api" "^6.6.4"

342
系统使用说明.md Normal file
View File

@@ -0,0 +1,342 @@
# 蜜雪冰城企业管理系统 — 使用说明
> 本文档面向系统管理员及各部门工作人员,指导如何从零开始搭建和使用本系统。
---
## 一、系统总览
本系统用于蜜雪冰城总部管理加盟商、合同、员工、产品、供应商及售后事务。采用 **RBAC 权限模型**,不同角色拥有不同的菜单可见性和数据操作范围。
### 角色一览
| 角色 | 负责人 | 核心职责 |
|------|--------|---------|
| 系统管理员 | 信息技术部 | 系统搭建、账号管理、数据全权管理 |
| 总经理 | 总经理办公室 | 全局查看所有业务数据 |
| 招商经理 | 招商部 | 管理加盟商、创建加盟合同 |
| 运营经理 | 运营部 | 维护加盟商信息、处理售后 |
| 采购经理 | 采购部 | 管理产品、供应商、创建采购合同 |
| 财务人员 | 财务部 | 只读查看业务数据 |
---
## 二、从第一个人开始 — 系统管理员首次登录
系统首次启动后,数据库中已预置了一个管理员账号:
| 用户名 | 密码 | 角色 |
|--------|------|------|
| `admin` | `123456` | 系统管理员 |
### 第一步:登录系统
1. 打开浏览器,访问系统地址(如 `http://localhost:5173`
2. 输入用户名 `admin`,密码 `123456`
3. 点击「登录」进入系统
### 第二步:认识系统界面
登录后进入主面板,左侧为导航菜单:
- **首页** — 系统概览
- **加盟商管理** — 管理所有加盟商信息
- **合同管理** — 管理所有合同
- **售后管理** — 管理售后记录
- **产品管理** — 管理产品原料信息
- **供应商管理** — 管理供应商信息
- **员工管理** — 管理所有员工
- **用户管理** — 管理系统登录账号
> 管理员拥有所有菜单和全部数据的访问权限。
---
## 三、搭建组织架构 — 逐步添加员工和用户
系统搭建的正确顺序是:**先建员工 → 再建用户账号**。因为用户账号需要关联一个已存在的员工。
### 第一步:添加员工
1. 点击左侧菜单「员工管理」
2. 点击「新增员工」按钮
3. 填写员工信息:
- **姓名**(必填):如「张超」
- **部门**:如「总经理办公室」
- **职务**:如「总经理」
- 其他信息:性别、年龄、学历、入职日期、电话等
4. 点击「保存」
按此方式,将所有员工逐一录入系统:
| 姓名 | 部门 | 职务 | 后续分配角色 |
|------|------|------|-------------|
| 张超 | 总经理办公室 | 总经理 | 总经理 |
| 李明 | 招商部 | 招商经理 | 招商经理 |
| 王丽 | 运营部 | 运营经理 | 运营经理 |
| 赵强 | 采购部 | 采购经理 | 采购经理 |
| 陈芳 | 财务部 | 财务主管 | 财务人员 |
### 第二步:为员工创建用户账号
1. 点击左侧菜单「用户管理」
2. 点击「新增用户」按钮
3. 填写用户信息:
- **用户名**(必填):如 `zhangchao`
- **密码**(必填):如 `123456`(至少 6 位)
- **角色**:选择对应角色,如「总经理」
- **关联员工**:选择对应的员工,如「张超」
4. 点击「保存」
系统会自动将员工的部门信息同步到用户账号中。
> **也可以在员工管理中直接创建用户**:在员工列表中点击某行的「创建用户」按钮,系统会自动关联该员工并创建用户账号。
### 预置账号速查
系统首次启动时已自动创建以下账号,密码均为 `123456`
| 用户名 | 姓名 | 角色 | 部门 |
|--------|------|------|------|
| `admin` | 系统管理员 | 系统管理员 | 信息技术部 |
| `zhangchao` | 张超 | 总经理 | 总经理办公室 |
| `liming` | 李明 | 招商经理 | 招商部 |
| `wangli` | 王丽 | 运营经理 | 运营部 |
| `zhaoqiang` | 赵强 | 采购经理 | 采购部 |
| `chenfang` | 陈芳 | 财务人员 | 财务部 |
---
## 四、各角色的工作流程
### 4.1 系统管理员admin
**日常职责:** 系统维护、账号管理、数据全权管理
| 操作 | 说明 |
|------|------|
| 管理员工 | 新增、编辑、删除员工信息 |
| 管理用户 | 创建、禁用、删除用户账号,分配角色 |
| 管理加盟商 | 新增、编辑、删除加盟商,指派负责人 |
| 管理合同 | 新增、编辑、删除所有合同 |
| 管理售后 | 新增、编辑、删除所有售后记录 |
| 管理产品 | 新增、编辑、删除产品信息 |
| 管理供应商 | 新增、编辑、删除供应商信息 |
**管理员独有操作:**
- 可以修改任何记录的「负责人」字段
- 不能删除自己、不能禁用自己、不能修改自己的角色
- 不能删除最后一个管理员账号
---
### 4.2 总经理general_manager
**日常职责:** 全局查看所有业务数据
| 操作 | 权限 |
|------|------|
| 查看加盟商 | ✅ 全部数据 |
| 查看合同 | ✅ 全部数据 |
| 查看售后 | ✅ 全部数据 |
| 查看员工 | ✅ 全部数据 |
| 新增/编辑/删除 | ❌ 无任何写入权限 |
> 总经理拥有全局只读权限,可以查看所有部门的所有数据,但不能进行任何增删改操作。
---
### 4.3 招商经理franchise_manager
**日常职责:** 开发加盟商、签订加盟合同
| 操作 | 权限 |
|------|------|
| 管理加盟商 | ✅ 新增、编辑自己负责的加盟商 |
| 管理加盟合同 | ✅ 新增、编辑自己负责的加盟合同 |
| 管理员工 | ✅ 查看本部门员工 |
| 创建采购合同 | ❌ 只能创建加盟合同 |
**工作流程:**
1. **新增加盟商**:在「加盟商管理」中录入新加盟商信息,系统自动将负责人设为当前用户
2. **签订加盟合同**:在「合同管理」中点击「新增合同」
- 合同类型自动设为「加盟合同」
- 必须选择关联客户
- 只能看到和编辑自己负责的加盟合同
3. **查看数据范围**:只能看到自己负责的加盟商和加盟合同
---
### 4.4 运营经理operations_manager
**日常职责:** 维护加盟商信息、处理售后
| 操作 | 权限 |
|------|------|
| 管理加盟商 | ✅ 编辑自己负责的加盟商 |
| 管理售后 | ✅ 新增、编辑自己负责的售后记录 |
| 管理合同 | ✅ 查看和编辑自己负责的加盟合同 |
| 管理员工 | ✅ 查本部门员工 |
**工作流程:**
1. **维护加盟商信息**:编辑加盟商的联系方式、地址等信息
2. **处理售后**
- 在「售后管理」中点击「新增售后」
- 选择客户、填写反馈内容
- 将状态从「待处理」改为「处理中」后,选择处理业务员(仅限运营部员工)
- 填写处理方式,完成后将状态改为「已完成」
3. **查看数据范围**:只能看到自己负责的加盟商、售后记录和加盟合同
---
### 4.5 采购经理procurement_manager
**日常职责:** 管理产品原料、供应商、签订采购合同
| 操作 | 权限 |
|------|------|
| 管理产品 | ✅ 新增、编辑、删除所有产品 |
| 管理供应商 | ✅ 新增、编辑、删除所有供应商 |
| 管理采购合同 | ✅ 新增、编辑自己负责的采购合同 |
| 创建加盟合同 | ❌ 只能创建采购合同 |
**工作流程:**
1. **管理供应商**:在「供应商管理」中录入供应商信息
2. **管理产品**:在「产品管理」中录入产品信息,选择关联供应商
3. **签订采购合同**
- 在「合同管理」中点击「新增合同」
- 合同类型自动设为「采购合同」
- 必须选择关联供应商
- 只能看到和编辑自己负责的采购合同
---
### 4.6 财务人员finance
**日常职责:** 只读查看业务数据
| 操作 | 权限 |
|------|------|
| 查看加盟商 | ✅ 全部数据 |
| 查看合同 | ✅ 全部数据 |
| 查看售后 | ✅ 全部数据 |
| 查看员工 | ✅ 全部数据 |
| 新增/编辑/删除 | ❌ 无任何写入权限 |
> 财务人员与总经理类似,拥有全局只读权限,用于财务审核和数据核对。
---
## 五、核心业务操作详解
### 5.1 加盟商管理
**新增加盟商:**
1. 点击「加盟商管理」→「新增加盟商」
2. 填写姓名(必填)、电话、地区、地址等信息
3. 管理员可指定负责人,其他角色自动设为当前用户
4. 保存后加盟商出现在列表中
**搜索加盟商:**
- 支持按姓名、电话、省份、城市搜索
- 支持按 ID 精确搜索
### 5.2 合同管理
**新增合同:**
1. 点击「合同管理」→「新增合同」
2. 选择合同类型(系统根据角色自动限制)
3. 加盟合同必须选择客户,采购合同必须选择供应商
4. 填写合同名称、金额、日期等信息
5. 保存合同
**合同状态流转:**
```
待审批 → 生效中 → 已完成
→ 已终止
→ 已到期(自动)
```
**搜索合同:**
- 支持按编号、名称、客户名、业务员名搜索
- 支持按生效日期范围筛选
### 5.3 售后管理
**新增售后:**
1. 点击「售后管理」→「新增售后」
2. 选择客户、填写反馈内容
3. 保存后状态默认为「待处理」
**处理售后:**
1. 将状态从「待处理」改为「处理中」
2. 选择处理业务员(仅限运营部员工)
3. 填写处理方式/解决方案
4. 完成后将状态改为「已完成」
### 5.4 产品与供应商管理
**管理供应商:** 录入供应商名称、联系人、电话、地址、类型等信息
**管理产品:** 录入产品名称、类型、数量、单价、规格,选择关联供应商
---
## 六、常见问题
### Q为什么我看不到某些菜单
A您的角色没有对应权限。请联系管理员确认角色分配。
### Q为什么我看不到某些数据
A系统有数据范围隔离。例如招商经理只能看到自己负责的加盟商和合同看不到其他人的数据。
### Q为什么合同的「客户」和「供应商」只能选一个
A合同分为「加盟合同」和「采购合同」两种类型。加盟合同关联客户采购合同关联供应商由合同类型决定。
### Q为什么售后的「处理业务员」下拉是空的
A请先将售后状态从「待处理」改为「处理中」业务员下拉才会启用。且只能选择运营部的员工。
### Q如何修改其他人的负责人
A只有系统管理员可以在新增/编辑时修改「负责人」字段。其他角色的负责人在创建时自动设为当前用户,不可更改。
### Q如何禁用一个用户
A管理员在「用户管理」中编辑用户将状态改为「禁用」。该用户将无法登录系统但已发出的 Token 在过期前仍有效。
### Q如何删除员工
A在「员工管理」中点击删除。系统会同步删除该员工关联的用户账号。注意不能删除自己关联的员工。
---
## 七、数据范围速查表
| 资源 | 管理员 | 总经理 | 招商经理 | 运营经理 | 采购经理 | 财务 |
|------|:------:|:------:|:--------:|:--------:|:--------:|:----:|
| 加盟商 | 全部 | 全部 | 自己负责 | 自己负责 | — | 全部 |
| 合同 | 全部 | 全部 | 自己的加盟合同 | 自己的加盟合同 | 自己的采购合同 | 全部 |
| 售后 | 全部 | 全部 | — | 自己负责 | — | 全部 |
| 员工 | 全部 | 全部 | 本部门 | 本部门 | 本部门 | 全部 |
| 产品 | 全部 | 全部 | — | — | 全部 | — |
| 供应商 | 全部 | 全部 | — | — | 全部 | — |
| 用户 | 全部 | — | — | — | — | — |
> **"—"** 表示该角色无权访问此资源。
---
## 八、快速上手清单
按以下步骤10 分钟内完成系统搭建:
- [ ] 1. 管理员登录系统(`admin` / `123456`
- [ ] 2. 确认员工已预置(或手动添加缺失的员工)
- [ ] 3. 确认用户账号已预置(或为每个员工创建用户账号)
- [ ] 4. 采购经理登录,添加供应商信息
- [ ] 5. 采购经理登录,添加产品信息
- [ ] 6. 招商经理登录,添加加盟商信息
- [ ] 7. 招商经理登录,创建加盟合同
- [ ] 8. 采购经理登录,创建采购合同
- [ ] 9. 运营经理登录,添加售后记录并处理
- [ ] 10. 总经理/财务登录,查看全局数据报表