Compare commits

3 Commits

Author SHA1 Message Date
ChoChoX
e1e45cee1a feat: 更新项目代码 2026-06-15 21:05:05 +08:00
Kyaru
cbbade3b9c Merge branch 'main' of https://chochox.asia/ChoChoX/backmanagerweb 2026-06-15 20:53:55 +08:00
7b927ff6e9 接入后端部分验证接口 2026-06-08 17:40:08 +08:00
49 changed files with 3072 additions and 9322 deletions

48
.gitignore vendored
View File

@@ -1,24 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
{
"recommendations": ["Vue.volar"]
}

319
README.md
View File

@@ -1,314 +1,5 @@
# 🍦 蜜雪冰城管理系统 (Mixue Management System)
**蜜雪冰城管理系统**是一个面向企业内部的 Web 管理后台,基于 Vue 3 + Element Plus 构建。用于管理客户、合同、售后、产品、供应商、员工及系统用户等核心业务模块,为蜜雪冰城品牌运营提供一体化管理支持。
> 前端项目地址:`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` 等)均归属蜜雪冰城所有。
> "你爱我,我爱你,蜜雪冰城甜蜜蜜" 🎵
# Vue 3 + Vite
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.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

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

2617
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,23 @@
{
"name": "backmanagerweb",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"element-china-area-data": "^5.0.2",
"element-plus": "^2.14.1",
"vue": "^3.5.34",
"vue-router": "4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.12"
}
}
{
"name": "backmanagerweb",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"element-china-area-data": "^6.1.0",
"element-plus": "^2.14.1",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-router": "4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.12"
}
}

View File

@@ -1,24 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

View File

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

View File

@@ -1,26 +0,0 @@
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}`)
}

View File

@@ -1,31 +0,0 @@
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}`)
}

View File

@@ -1,44 +0,0 @@
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 })
}

View File

@@ -1,147 +0,0 @@
// 模拟合同数据
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: '合同不存在' }
}
}

View File

@@ -1,146 +0,0 @@
// 模拟客户数据
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: '客户不存在' }
}
}

View File

@@ -1,239 +0,0 @@
// 模拟员工数据(单一数据源,同时包含用户字段)
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: '员工不存在' }
}
}

View File

@@ -1,174 +0,0 @@
// 模拟产品数据
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: '产品不存在' }
}
}

View File

@@ -1,145 +0,0 @@
// 模拟售后数据
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: '售后记录不存在' }
}
}

View File

@@ -1,138 +0,0 @@
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: '用户不存在' }
}
}

View File

@@ -1,26 +0,0 @@
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}`)
}

View File

@@ -1,53 +1,46 @@
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
import axios from 'axios'
import {ElMessage} from 'element-plus'
import {useUserStore} from '@/stores/user'
import router from '@/router'
const request = axios.create({
baseURL: '/api',
timeout: 10000,
})
// 请求拦截:自动 token
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token){
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(err) => Promise.reject(err)
)
// 响应拦截:统一处理 { code, data, message } + 401 跳登录
request.interceptors.response.use(
(res) => {
const body = res.data
if (typeof body?.code === 'undefined') return res.data
if (body.code === 0) return body.data
ElMessage.error(body.message || '请求失败')
return Promise.reject(new Error(body.message || '请求失败'))
},
(err) => {
const status = err.response?.status
if (status === 401) {
const userStore = useUserStore()
userStore.logout()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
}else {
ElMessage.error(err.response?.data?.message || err.message || '网络异常')
}
return Promise.reject(err)
}
)
export default request

View File

@@ -1,26 +0,0 @@
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}`)
}

View File

@@ -1,31 +0,0 @@
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}`)
}

View File

@@ -1,31 +1,5 @@
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}`)
}
import request from './request'
export const login = (data) => request.post('/user/login', data)
export const getUserInfo = () => request.get('/user/info')
export const logout = () => request.post('/user/logout')

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,2 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07"
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07"
height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 458 B

After

Width:  |  Height:  |  Size: 459 B

View File

@@ -1,214 +1,143 @@
<template>
<div class="login-container">
<!-- 背景视频 -->
<video src="/登陆界面背景.mp4" autoplay muted loop class="bg-video"></video>
<div class="video-overlay"></div>
<!-- 登录卡片 -->
<el-card class="login-card" shadow="always">
<template #header>
<div class="card-header">
<img src="/logo.png" alt="logo" class="login-logo">
<h2>蜜雪冰城管理系统</h2>
<p class="subtitle">请输入您的账号信息</p>
</div>
</template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="rules"
label-position="top"
@keyup.enter="handleLogin"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="loginForm.username"
:prefix-icon="User"
clearable
placeholder="请输入用户名"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
:prefix-icon="Lock"
clearable
placeholder="请输入密码"
show-password
type="password"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
</el-form-item>
<el-form-item class="login-form-button">
<el-button
:loading="loading"
class="login-btn"
type="primary"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import {reactive, ref} from 'vue'
import {ElMessage} from 'element-plus'
import {Lock, User} from '@element-plus/icons-vue'
import {useRouter} from 'vue-router'
import { login } from '../store/user'
const router = useRouter()
const loginFormRef = ref(null)
const loading = ref(false)
const loginForm = reactive({
username: '',
password: '',
remember: false,
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
],
}
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
const res = await login(loginForm.username, loginForm.password)
if (res.code === 0 || res.code === 200) {
ElMessage.success('登录成功')
router.push('/panel/home')
} else {
ElMessage.error(res.message || '登录失败')
}
} catch {
// 校验失败element-plus 会自动显示红色提示
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
position: relative;
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 100vh;
padding: 20px 60px 20px 20px;
overflow: hidden;
}
/* 背景视频 */
.bg-video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
z-index: 1;
}
/* 登录卡片 */
.login-card {
position: relative;
z-index: 2;
width: 420px;
border-radius: 16px;
border: none;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
.login-card :deep(.el-card__header) {
border-bottom: 2px solid #E60012;
padding: 20px;
}
.login-logo {
display: block;
width: 60px;
height: 60px;
margin: 0 auto 12px;
object-fit: contain;
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0 0 8px;
color: #E60012;
font-size: 22px;
font-weight: 600;
}
.subtitle {
margin: 0;
font-size: 14px;
color: #909399;
}
/* 输入框聚焦时红色边框 */
.login-card :deep(.el-input__wrapper:focus-within) {
box-shadow: 0 0 0 1px #E60012 inset;
}
/* 登录按钮 */
.login-btn {
display: block;
margin: 0 auto;
text-align: center;
width: 75%;
background: #E60012;
border-color: #E60012;
}
.login-btn:hover {
background: #d50010;
border-color: #d50010;
}
/* 记住我复选框 */
.login-card :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: #E60012;
border-color: #E60012;
}
.login-card :deep(.el-checkbox__input.is-checked + .el-checkbox__label) {
color: #E60012;
}
</style>
<template>
<div class="login-container">
<el-card class="login-card" shadow="always">
<template #header>
<div class="card-header">
<h2>欢迎登录</h2>
<p class="subtitle">请输入您的账号信息</p>
</div>
</template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="rules"
label-position="top"
@keyup.enter="handleLogin"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="loginForm.username"
:prefix-icon="User"
clearable
placeholder="请输入用户名"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
:prefix-icon="Lock"
clearable
placeholder="请输入密码"
show-password
type="password"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
</el-form-item>
<el-form-item class="login-form-button">
<el-button
:loading="loading"
class="login-btn"
type="primary"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import {reactive, ref} from 'vue'
import {ElMessage} from 'element-plus'
import {Lock, User} from '@element-plus/icons-vue'
import {useRouter} from 'vue-router'
import {useUserStore} from "@/stores/user.js";
const router = useRouter()
const userStore = useUserStore()
const loginFormRef = ref(null)
const loading = ref(false)
const loginForm = reactive({
username: '',
password: '',
remember: false,
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
],
}
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
await userStore.login({
username: loginForm.username,
password: loginForm.password,
})
ElMessage.success('登录成功')
router.push('/panel')
} catch(e) {
// 校验失败element-plus 会自动显示红色提示
console.warn('login failed',e?.message)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-card {
width: 420px;
border-radius: 12px;
border: none;
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0 0 8px;
color: #303133;
}
.subtitle {
margin: 0;
font-size: 14px;
color: #909399;
}
.login-btn {
display: block;
margin: 0 auto;
text-align: center;
width: 75%;
}
</style>

View File

@@ -1,696 +0,0 @@
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
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, getRoleLevel } from '../store/user'
const search = ref('')
const select = ref('1')
const filterType = ref('')
const filterStatus = ref('')
const loading = ref(false)
const searchResults = ref([])
const showSearchResults = ref(false)
const showAddForm = ref(false)
const isEditMode = ref(false)
const currentContractId = ref(null)
const showDetailDialog = ref(false)
const currentDetail = ref(null)
const customerList = ref([])
const employeeList = ref([])
const supplierList = ref([])
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
// 采购经理只能操作采购合同,招商经理只能操作加盟合同(提前定义,供 form 初始化使用)
const isProcurementManager = computed(() => getUserInfo()?.departmentName === 'procurement_manager')
const isFranchiseManager = computed(() => getUserInfo()?.departmentName === 'franchise_manager')
//是否是管理员
const isAdmin = computed(() => getUserInfo()?.departmentName === 'admin')
// 是否是一般专员role_level === 4
const isGeneralStaff = computed(() => getRoleLevel() === 4)
// 招商部专员:锁定业务员为自己
const isFranchiseStaff = computed(() => isFranchiseManager.value && isGeneralStaff.value)
// 采购部专员:锁定业务员为自己
const isProcurementStaff = computed(() => isProcurementManager.value && isGeneralStaff.value)
// 业务员字段是否锁定
const isEmployeeLocked = computed(() => isFranchiseStaff.value || isProcurementStaff.value)
const getDefaultType = () => {
if (isProcurementManager.value) return 'supply'
if (isFranchiseManager.value) return 'franchise'
return 'franchise'
}
const form = reactive({
contract_name: '',
type: getDefaultType(),
customer_id: '',
supplier_id: '',
contract_content: '',
employee_id: '',
effective_date: '',
expiry_date: '',
amount: 0,
status: '生效',
remark: ''
})
onMounted(() => {
fetchData()
fetchOptions()
})
// 监听筛选变化(与 user.vue 一致)
watch([filterType, filterStatus], () => {
currentPage.value = 1
handleSearch()
})
// 分页后的数据服务端已分页searchResults 即为当前页数据)
const paginatedData = computed(() => searchResults.value)
// 页码变化(服务端分页:重新请求后端)
const handleCurrentChange = (val) => {
currentPage.value = val
fetchData(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 (page) => {
loading.value = true
const params = {
page: page || currentPage.value,
pageSize: pageSize.value
}
const res = await getContractList(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
} 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
}
// 只在有搜索内容时才添加搜索参数(与 user.vue 一致)
if (search.value) {
if (select.value === '1') {
params.id = Number(search.value)
} else if (select.value === '2') {
params.contract_name = search.value
}
}
if (filterType.value) {
params.type = filterType.value
}
if (filterStatus.value) {
params.status = filterStatus.value
}
try {
const res = await getContractList(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
} else if (Array.isArray(res.data)) {
searchResults.value = res.data
total.value = res.data.length
}
} else {
console.warn('[contract] handleSearch 响应异常:', res)
searchResults.value = []
total.value = 0
}
} catch (e) {
console.error('[contract] handleSearch 请求失败:', e)
searchResults.value = []
total.value = 0
}
showSearchResults.value = true
loading.value = false
}
// 重置搜索
const resetSearch = () => {
search.value = ''
select.value = '1'
filterType.value = ''
filterStatus.value = ''
currentPage.value = 1
fetchData()
}
// 双击行查看详情
const handleRowDblClick = (row) => {
currentDetail.value = { ...row }
showDetailDialog.value = true
}
// 关闭详情弹窗
const closeDetail = () => {
showDetailDialog.value = false
currentDetail.value = null
}
// 新增合同
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',
'作废': 'danger'
}
return map[status] || 'info'
}
// 日期快捷选项
const dateShortcuts = [
{
text: '最近一周',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 7 * 24 * 3600 * 1000)
return [start, end]
}
},
{
text: '最近一个月',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 30 * 24 * 3600 * 1000)
return [start, end]
}
},
{
text: '最近三个月',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 90 * 24 * 3600 * 1000)
return [start, end]
}
}
]
// 检查权限
const canCreate = computed(() => hasPermission('contract', 'create'))
const canUpdate = computed(() => hasPermission('contract', 'update'))
const canDelete = computed(() => hasPermission('contract', 'delete'))
const canOperate = computed(() => canUpdate.value || canDelete.value)
//获取用户数据
const userInfo = computed(() => getUserInfo())
// 招商经理只能编辑加盟合同,采购经理只能编辑采购合同,且每个员工只能操作自己的合同(管理员除外)
const canEditRow = (row) => {
if (!canUpdate.value) return false
if (isProcurementManager.value && row.type !== 'supply') return false
if (isFranchiseManager.value && row.type !== 'franchise') return false
if (userInfo.value.employee_id !== row.employee_id) 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
if (userInfo.value.employee_id !== row.employee_id) return false
return true
}
</script>
<template>
<div class="contract-container">
<!-- 搜索栏区域 -->
<div class="contract-search">
<!-- 第一行搜索框 + 按钮 -->
<div class="search-row">
<el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词" class="contract-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-select>
</template>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</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="filter-row">
<span class="filter-label">筛选条件</span>
<el-select v-model="filterType" placeholder="合同类型" clearable style="width: 150px; margin-left: 10px;">
<el-option label="加盟合同" value="franchise" />
<el-option label="采购合同" value="supply" />
</el-select>
<el-select v-model="filterStatus" placeholder="合同状态" clearable style="width: 150px; margin-left: 10px;">
<el-option label="草稿" value="草稿" />
<el-option label="生效" value="生效" />
<el-option label="已完成" value="已完成" />
<el-option label="作废" value="作废" />
</el-select>
</div>
</div>
<!-- 合同列表 -->
<div v-if="showSearchResults" class="contract-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" border style="width: 100%" @row-dblclick="handleRowDblClick" row-class-name="clickable-row">
<el-table-column prop="id" label="合同编号" width="90" />
<el-table-column prop="contract_name" label="合同名称" min-width="200" />
<el-table-column prop="type" label="类型" width="90">
<template #default="{ row }">
<el-tag :type="row.type === 'franchise' ? 'success' : 'warning'">{{ row.type === 'franchise' ? '加盟' : '采购' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="employee_name" label="业务员" width="90">
<template #default="{ row }">
{{ row.employee_name || '-' }}
</template>
</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 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="total"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 合同详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="合同详情" width="700px" @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.contract_name }}</el-descriptions-item>
<el-descriptions-item label="合同类型">
<el-tag :type="currentDetail.type === 'franchise' ? 'success' : 'warning'">{{ currentDetail.type === 'franchise' ? '加盟合同' : '采购合同' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(currentDetail.status)">{{ currentDetail.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="客户">{{ currentDetail.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="供应商">{{ currentDetail.supplier_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="业务员">{{ currentDetail.employee_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ currentDetail.responsible_user_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="金额">¥{{ currentDetail.amount?.toLocaleString() }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDate(currentDetail.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="到期日期">{{ formatDate(currentDetail.expiry_date) }}</el-descriptions-item>
<el-descriptions-item label="合同内容" :span="2">{{ currentDetail.contract_content || '无' }}</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(); editContract(d) })()" v-if="canUpdate && userInfo.employee_id === currentDetail?.employee_id">编辑</el-button>
</template>
</el-dialog>
<!-- 新增/编辑表单 -->
<div v-if="showAddForm" class="contract-form" style="margin-top: 20px;">
<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.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 :disabled="isEmployeeLocked">
<el-option v-for="e in employeeList" :key="e.id" :label="`${e.id} - ${e.name}`" :value="e.id" />
</el-select>
<div class="field-hint" v-if="isEmployeeLocked">专员无法修改业务员已锁定为自己</div>
</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-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 label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveContract">保存</el-button>
<el-button @click="cancelAdd">取消</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<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;
}
.clickable-row {
cursor: pointer;
}
.detail-content {
padding: 10px 0;
}
.field-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
</style>

View File

@@ -1,466 +0,0 @@
<script setup>
import { Search } from '@element-plus/icons-vue'
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { regionData, CodeToText, TextToCode } from 'element-china-area-data'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getCustomerList,
createCustomer,
updateCustomer,
deleteCustomerAPI
} from '../api/customer'
import { hasPermission } from '../store/user'
const form = reactive({
id: '',
name: '',
phone: '',
region: [],
address: '',
email: '',
remark: ''
})
const search = ref('')
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 currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
// 只保留省市两级的地区数据
const regionDataTwoLevel = computed(() => {
return regionData.map(province => ({
...province,
children: province.children?.map(city => ({
value: city.value,
label: city.label
}))
}))
})
// 分页后的数据服务端已分页searchResults 即为当前页数据)
const paginatedData = computed(() => searchResults.value)
onMounted(() => {
fetchData()
})
// 获取客户列表(服务端分页)
const fetchData = async (page) => {
loading.value = true
const params = {
page: page || currentPage.value,
pageSize: pageSize.value
}
const res = await getCustomerList(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
} 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()
}
const seq = ++searchSeq
loading.value = true
try {
const params = {
page: currentPage.value,
pageSize: pageSize.value,
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) {
if (res.data && Array.isArray(res.data.list)) {
searchResults.value = res.data.list
total.value = res.data.total
} else if (Array.isArray(res.data)) {
searchResults.value = res.data
total.value = res.data.length
}
}
showSearchResults.value = true
} finally {
if (seq === searchSeq) loading.value = false
}
}
// 清空表单
const resetForm = () => {
form.id = ''
form.name = ''
form.phone = ''
form.region = []
form.address = ''
form.email = ''
form.remark = ''
isEditMode.value = false
currentCustomerId.value = null
}
// 显示新增表单
const addCustomer = () => {
resetForm()
showAddForm.value = true
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 = () => {
search.value = ''
filterRegion.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
fetchData(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>
<template>
<div class="customer-container">
<!-- 搜索栏区域 -->
<div class="customer-search">
<div class="search-row">
<el-input v-model="search" style="max-width: 500px" placeholder="请输入搜索关键词"
class="customer-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-select>
</template>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<div class="search-buttons">
<el-button color="#E60012" @click="addCustomer" 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-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">共找到 {{ 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="客户编号" width="90" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="电话" width="150" />
<el-table-column label="地区" min-width="150">
<template #default="{ row }">
{{ [row.province, row.city].filter(Boolean).join(' ') || '-' }}
</template>
</el-table-column>
<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 }">
<el-button link type="primary" @click="editCustomer(row)" v-if="canUpdate">编辑</el-button>
<el-button link type="danger" @click="deleteCustomer(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="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-item label="姓名" required>
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="电话" required>
<el-input v-model="form.phone" maxlength="11" oninput="this.value=this.value.replace(/\D/g,'')"
placeholder="请输入11位手机号" style="width: 100%" />
</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-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveCustomer">保存</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="电话">{{ 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>
</template>
<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>

View File

@@ -1,873 +0,0 @@
<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, getRoleLevel } from '../store/user'
import { formatDateTime, formatDate } from '../utils/date'
import { createUser } from '../api/user'
import { DEPARTMENT_NAME_LIST, DEPARTMENT_LIST } from '../constants/departments'
const form = reactive({
name: '',
gender: '男',
age: null,
education: '',
department: '',
entry_date: '',
position: '',
salary: null,
phone: '',
email: '',
status: 1,
remark: '',
role_level: 4 // 默认一般专员
})
const search = ref('')
const select = ref('1')
const filterStatus = ref('')
const filterDepartment = 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 linkedEmployeeId = ref(null) // 独立 ref 追踪关联员工 ID
const userForm = reactive({
username: '',
password: '',
name: '',
department_id: 2,
is_active: 1,
employee_id: null,
role_level: 4
})
// 角色级别选项
const roleLevelOptions = computed(() => {
if (form.department === '总经理办公室') {
// 总经理办公室只能选择副总经理
return [{ value: 2, label: '副总经理' }]
}
// 一般部门没有总经理,只有部门经理以下三级
return [
{ value: 2, label: '部门经理' },
{ value: 3, label: '部门副经理' },
{ value: 4, label: '一般专员' }
]
})
// 部门列表
const departmentOptions = ref([...DEPARTMENT_NAME_LIST])
// 学历列表
const educationOptions = ['高中', '专科', '本科', '硕士', '博士']
// 职务选项(根据部门动态变化)
const positionOptions = computed(() => {
if (form.department === '总经理办公室') {
// 总经理办公室只能选择副总经理,总经理是系统预设的
return ['副总经理']
}
return [] // 其他部门自由输入
})
// 是否锁定职务选择
const isPositionLocked = computed(() => form.department === '总经理办公室')
// 分页后的数据服务端已分页searchResults 即为当前页数据)
const paginatedData = computed(() => searchResults.value)
// onMounted 已在下方合并(同时加载部门列表)
// 监听筛选变化
watch([filterStatus, filterDepartment], () => {
currentPage.value = 1
handleSearch()
})
// 监听部门变化:总经理办公室自动锁定职位为副总经理,重置角色级别
watch(() => form.department, (val) => {
if (val === '总经理办公室') {
form.position = '副总经理'
form.role_level = 2 // 副总经理
} else {
form.role_level = 4 // 默认一般专员
}
})
// 监听筛选变化时重置页码(服务端分页:已由 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,
department: filterDepartment.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 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 = ''
filterDepartment.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 = ''
form.role_level = 4
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,
role_level: form.role_level
}
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((action) => {
if (action === 'confirm') {
// 确认:进入用户创建表单
openUserForm(res.data)
} else {
// 取消:返回列表
cancelAdd()
fetchData()
}
}).catch(() => {
// 用户点击了关闭按钮X返回列表
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 = (emp) => {
// 兼容 res.data 嵌套res.data 可能是 { data: {...} } 或直接 {...}
const employee = emp?.data || emp
if (!employee || !employee.id) {
console.error('[openUserForm] 员工数据异常:', emp)
ElMessage.error('员工信息获取失败,请重试')
return
}
showAddForm.value = false
showSearchResults.value = false
showCreateUserForm.value = true
// 用独立 ref 保存员工 ID避免 reactive 对象异步问题
linkedEmployeeId.value = employee.id
// 自动填充员工信息,部门与员工一致
const deptMatch = DEPARTMENT_LIST.find(d => d.description === employee.department)
userForm.username = ''
userForm.password = ''
userForm.name = employee.name || ''
userForm.department_id = deptMatch ? deptMatch.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
}
try {
const empId = linkedEmployeeId.value || userForm.employee_id
const payload = {
username: userForm.username,
password: userForm.password,
department_id: userForm.department_id,
employee_id: empId,
is_active: userForm.is_active,
role_level: userForm.role_level
}
await createUser(payload)
ElMessage.success('用户创建成功')
cancelCreateUser()
fetchData()
} catch (e) {
ElMessage.error(e?.response?.data?.message || e?.message || '用户创建失败')
}
}
// 取消创建用户
const cancelCreateUser = () => {
showCreateUserForm.value = false
justCreatedEmployee.value = null
linkedEmployeeId.value = null
userForm.username = ''
userForm.password = ''
userForm.name = ''
userForm.department_id = 2
userForm.is_active = 1
userForm.employee_id = null
userForm.role_level = 4
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))
// 获取当前用户角色级别
const currentRoleLevel = computed(() => getRoleLevel())
// 过滤后的部门列表(一般专员不能选择总经理办公室)
const filteredDeptListForUser = computed(() => {
if (currentRoleLevel.value === 4) {
// 一般专员不能选择总经理办公室
return DEPARTMENT_LIST.filter(d => d.name !== 'general_manager')
}
return DEPARTMENT_LIST
})
// 是否是总经理办公室部门(用于用户创建表单)
const isGeneralManagerDeptForUser = computed(() => {
const dept = DEPARTMENT_LIST.find(d => d.id === userForm.department_id)
return dept?.name === 'general_manager'
})
// 角色级别选项(根据部门动态变化,用于用户创建表单)
const roleLevelOptionsForUser = computed(() => {
if (isGeneralManagerDeptForUser.value) {
// 总经理办公室只能选择副总经理
return [{ value: 2, label: '副总经理' }]
}
// 一般部门没有总经理,只有部门经理以下三级
return [
{ value: 2, label: '部门经理' },
{ value: 3, label: '部门副经理' },
{ value: 4, label: '一般专员' }
]
})
</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="员工编号" 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="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="filterDepartment" placeholder="所属部门" clearable style="width: 140px; margin-right: 12px;">
<el-option v-for="dept in DEPARTMENT_LIST" :key="dept.id" :label="dept.description" :value="dept.description" />
</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="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="员工编号" width="90" />
<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>
<div class="field-hint" v-if="isEditMode">修改部门将同步更新关联用户的部门</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="职务/岗位" required>
<el-select v-if="isPositionLocked" v-model="form.position" placeholder="请选择职务" style="width: 100%;">
<el-option v-for="pos in positionOptions" :key="pos" :label="pos" :value="pos" />
</el-select>
<el-input v-else v-model="form.position" placeholder="请输入职务" />
<div class="field-hint" v-if="isPositionLocked">总经理办公室只能选择副总经理</div>
</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.role_level" placeholder="请选择角色级别" style="width: 100%;">
<el-option v-for="item in roleLevelOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<div class="field-hint" v-if="form.department === '总经理办公室'">总经理办公室只能选择副总经理</div>
<div class="field-hint" v-else>一般部门没有总经理级别</div>
</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="工资" v-if="showSalary">
<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="请输入手机号" type="tel" maxlength="11" oninput="this.value=this.value.replace(/\D/g,'')" />
</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="linkedEmployeeId" 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%;" disabled>
<el-option v-for="dept in filteredDeptListForUser" :key="dept.id" :label="dept.description" :value="dept.id" />
</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-row :gutter="20">
<el-col :span="12">
<el-form-item label="角色级别" required>
<el-select v-model="userForm.role_level" placeholder="请选择角色级别" style="width: 100%;">
<el-option
v-for="item in roleLevelOptionsForUser"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="field-hint" v-if="isGeneralManagerDeptForUser">总经理办公室只能选择副总经理</div>
<div class="field-hint" v-else>一般部门没有总经理级别</div>
</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="员工编号">{{ 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="工资" v-if="showSalary">
<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>
.field-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
.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,315 +1,194 @@
<template>
<div class="workspace-container">
<!-- 顶部视频横幅 -->
<div class="video-banner">
<video src="/首页顶图.mp4" autoplay muted loop class="bg-video"></video>
<div class="video-overlay">
<div class="welcome-text">
<h2 class="banner-title">欢迎回来{{ userInfo?.name || '用户' }}</h2>
<p class="welcome-sub">{{ userInfo?.departmentDesc }} · {{ roleName }}</p>
<p class="welcome-date">{{ currentDate }}</p>
</div>
</div>
</div>
<!-- 功能卡片网格 -->
<div class="card-grid">
<div
v-for="card in visibleCards"
:key="card.route"
class="function-card"
:style="{ borderTopColor: themeColor, '--hover-bg': themeBg }"
@click="navigateTo(card.route)"
>
<div class="card-icon" :style="{ background: themeBg, color: themeColor }">
<el-icon :size="28"><component :is="card.icon" /></el-icon>
</div>
<div class="card-content">
<h3 class="card-title">{{ card.title }}</h3>
<p class="card-desc">{{ card.description }}</p>
</div>
<div class="card-arrow">
<el-icon :style="{ color: themeColor }"><ArrowRight /></el-icon>
</div>
</div>
</div>
<!-- 快捷信息 -->
<div class="info-section">
<el-row :gutter="20">
<el-col :span="12">
<el-card shadow="hover" class="info-card">
<template #header>
<div class="info-card-header">
<span>我的信息</span>
</div>
</template>
<div class="info-item">
<span class="info-label">用户名</span>
<span>{{ userInfo?.username }}</span>
</div>
<div class="info-item">
<span class="info-label">部门</span>
<span>{{ userInfo?.departmentDesc }}</span>
</div>
<div class="info-item">
<span class="info-label">角色</span>
<span>{{ roleName }}</span>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="info-card">
<template #header>
<div class="info-card-header">
<span>快速操作</span>
</div>
</template>
<div class="quick-actions">
<el-button
v-for="card in visibleCards.slice(0, 3)"
:key="card.route"
:style="{ borderColor: themeColor, color: themeColor }"
@click="navigateTo(card.route)"
plain
>
{{ card.title }}
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { getUserInfo, hasPermission, getRoleLevel, isManager } from '../store/user'
const router = useRouter()
const userInfo = computed(() => getUserInfo())
const roleLevel = computed(() => getRoleLevel())
// 主题色
const themeColor = computed(() => roleLevel.value <= 3 ? '#E60012' : '#FF8C69')
const themeBg = computed(() => roleLevel.value <= 3 ? '#FEF0F0' : '#FFF5F0')
const bannerBg = computed(() => roleLevel.value <= 3
? 'linear-gradient(135deg, #FEF0F0 0%, #FFF5F5 100%)'
: 'linear-gradient(135deg, #FFF5F0 0%, #FFFAF5 100%)')
// 角色名称
const roleName = computed(() => {
const level = roleLevel.value
if (level === 1) return '总经理'
if (level === 2) return '部门经理'
if (level === 3) return '部门副经理'
return '一般专员'
})
// 当前日期
const currentDate = computed(() => {
const d = new Date()
const days = ['日', '一', '二', '三', '四', '五', '六']
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 星期${days[d.getDay()]}`
})
// 功能卡片定义
const allCards = [
{ title: '客户管理', description: '管理加盟商信息、联系方式、地址等', icon: 'Avatar', route: '/panel/customer', permission: ['customer', 'read'] },
{ title: '合同管理', description: '管理加盟合同和采购合同', icon: 'Document', route: '/panel/contract', permission: ['contract', 'read'] },
{ title: '售后管理', description: '处理客户反馈和售后服务', icon: 'Service', route: '/panel/service', permission: ['after_sale', 'read'] },
{ title: '产品管理', description: '管理茶饮原料、包装物料、设备', icon: 'Goods', route: '/panel/products', permission: ['product', 'read'] },
{ title: '供应商管理', description: '管理供应商信息和合作关系', icon: 'Box', route: '/panel/supplier', permission: ['supplier', 'read'] },
{ title: '员工管理', description: '管理员工信息、部门、职务', icon: 'User', route: '/panel/employee', permission: ['employee', 'read'] },
{ title: '用户管理', description: '管理系统用户账号和权限', icon: 'UserFilled', route: '/panel/user', permission: ['user', 'manage'] },
]
// 根据权限过滤可见卡片
const visibleCards = computed(() => {
return allCards.filter(card => hasPermission(card.permission[0], card.permission[1]))
})
// 导航
const navigateTo = (route) => {
router.push(route)
}
</script>
<style scoped>
.workspace-container {
max-width: 1200px;
margin: 0 auto;
}
/* ========== 视频横幅 ========== */
.video-banner {
position: relative;
width: calc(100% + 40px);
margin: -20px -20px 20px -20px;
height: 260px;
overflow: hidden;
border-radius: 0 0 12px 12px;
}
.bg-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
to bottom,
rgba(230, 0, 18, 0.3) 0%,
rgba(0, 0, 0, 0.5) 100%
);
display: flex;
align-items: center;
justify-content: center;
}
.welcome-text {
text-align: center;
}
.banner-title {
color: #fff;
font-size: 28px;
font-weight: 600;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
margin: 0 0 8px 0;
}
.welcome-sub {
margin: 0 0 4px 0;
color: rgba(255,255,255,0.9);
font-size: 16px;
}
.welcome-date {
margin: 0;
color: rgba(255,255,255,0.7);
font-size: 14px;
}
/* ========== 功能卡片网格 ========== */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.function-card {
background: #fff;
border-radius: 12px;
padding: 24px;
border-top: 3px solid #E60012;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 16px;
cursor: pointer;
transition: all 0.3s ease;
}
.function-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
background: var(--hover-bg, #FEF0F0);
}
.card-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-content {
flex: 1;
min-width: 0;
}
.card-title {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.card-desc {
margin: 0;
font-size: 13px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-arrow {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.3s;
}
.function-card:hover .card-arrow {
opacity: 1;
}
/* ========== 信息区域 ========== */
.info-section {
margin-bottom: 20px;
}
.info-card {
height: 100%;
}
.info-card-header {
font-weight: 600;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px dashed #eee;
font-size: 14px;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
color: #909399;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
/* ========== 响应式 ========== */
@media (max-width: 768px) {
.card-grid {
grid-template-columns: 1fr;
}
.welcome-banner {
padding: 20px;
}
}
</style>
<template>
<div class="home-container">
<div class="banner">
<video src="../../public/蜜雪冰城-雪王百科.mp4" autoplay muted loop></video>
</div>
</div>
<div class="home-body">
<div class="first-head">雪王简介</div>
<div class="intro-content">
<div class="pic">
<img src="../../public/mixue.png" alt="雪王">
</div>
<div class="txt">
<div class="second-head">
"我是手拿冰淇凌权杖的雪王"
<br>
"一生只爱冰淇凌与茶"
</div>
<ul class="xuewang-list">
<li v-for="(item, index) in snowKingInfo" :key="index">
<span class="label">{{ item.label }}</span>
<span class="divider"></span>
<span class="value">{{ item.value }}</span>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const snowKingInfo = ref([
{ label: '生日', value: '11月22日' },
{ label: '性格', value: '正直、友善、热情、进取' },
{ label: '职位', value: '蜜雪冰城首席品控官兼全球品牌代言人' },
{ label: '口头禅', value: '你爱我,我爱你,蜜雪冰城甜蜜蜜' },
{ label: '爱好', value: '唱歌跳舞,研究冰淇淋与茶的新奇吃法' }
])
</script>
<style scoped>
.home-container {
padding: 0;
text-align: center;
}
.banner {
width: 100%;
height: 90vh;
min-height: 500px;
}
.banner video {
width: 100%;
height: 100%;
object-fit: cover;
}
.home-body {
padding: 100px 80px;
background: linear-gradient(180deg, #FEF7F7 0%, rgba(254, 247, 247, 0.00) 64.96%);
}
.first-head {
text-align: center;
font-size: 58px;
font-weight: 700;
color: #2E2F30;
margin-bottom: 68px;
line-height: 1.17;
}
.intro-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1400px;
margin: 0 auto;
}
.pic {
width: 750px;
height: 660px;
border-radius: 30px;
overflow: hidden;
}
.pic img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s;
}
.pic:hover img {
transform: scale(1.05);
}
.txt {
width: 676px;
}
.second-head {
font-size: 46px;
font-weight: 700;
color: #E60012;
line-height: 1.33;
margin-bottom: 67px;
}
.xuewang-list {
list-style: none;
padding: 0;
}
.xuewang-list li {
display: flex;
align-items: center;
margin-bottom: 18px;
color: #666;
font-size: 24px;
font-weight: 400;
line-height: 1.5;
}
.xuewang-list li:last-child {
margin-bottom: 0;
}
.xuewang-list .label {
color: #2E2F30;
font-weight: 700;
min-width: 80px;
}
.xuewang-list .divider {
width: 1px;
height: 18px;
background-color: #999;
margin: 0 15px;
}
.xuewang-list .value {
display: flex;
align-items: center;
}
/* 响应式适配 */
@media (max-width: 1200px) {
.intro-content {
flex-direction: column;
}
.pic {
width: 100%;
height: auto;
aspect-ratio: 750/660;
}
.txt {
width: 100%;
margin-top: 48px;
}
}
@media (max-width: 768px) {
.home-body {
padding: 50px 20px;
}
.first-head {
font-size: 36px;
margin-bottom: 40px;
}
.second-head {
font-size: 28px;
margin-bottom: 40px;
}
.xuewang-list li {
font-size: 18px;
flex-direction: column;
align-items: flex-start;
}
.xuewang-list .divider {
display: none;
}
}
</style>

148
src/components/page1.vue Normal file
View File

@@ -0,0 +1,148 @@
<script setup>
import { Search } from '@element-plus/icons-vue'
import { ref,reactive } from 'vue'
import { regionData } from 'element-china-area-data'
import { ElMessage } from 'element-plus'
const form = reactive({
name: '', // 姓名,默认为空字符串
phone: '', // 电话,默认为空字符串
region: [], // 地区,默认为空数组(省市区三级代码)
address: '', // 详细地址,默认为空字符串
email: '', // 电子邮箱,默认为空字符串
customer_type: 'Normal' // 客户类型,默认选中"普通客户"
})
const search = ref('')
const select = ref('1')
const searchResults=ref([])
const showSearchResults=ref(false) //是否显示搜索结果
const showAddForm=ref(false) //是否显示新增表单
const isEditMode = ref(false) //是否为编辑模式
const currentCustomerId = ref(null) //当前编辑的客户的ID
// 清空表单
const resetForm = () => {
form.name = ''
form.phone = ''
form.region = []
form.address = ''
form.email = ''
form.customer_type = 'Normal'
isEditMode.value = false
currentCustomerId.value = null
}
// 显示新增表单
const addCustomer = () => {
resetForm()
showAddForm.value = true
showSearchResults.value = false
}
// 清空搜索
const clearSearch = () => {
search.value = ''
showSearchResults.value = false
showAddForm.value = false
}
</script>
<template>
<div class="customer-container">
<!-- 搜索栏区域 -->
<div class="customer-search">
<el-input v-model="search" style="max-width: 600px" placeholder="Please input"
class="customer-search-with-select">
<template #prepend>
<el-select v-model="select" placeholder="Select" style="width: 115px">
<el-option label="姓名" value="1" />
<el-option label="电话" value="2" />
<el-option label="邮箱" value="3" />
</el-select>
</template>
<template #append>
<el-button :icon="Search" />
</template>
</el-input>
<el-button type="primary" @click="addCustomer" style="margin-left: 20px">
新增用户
</el-button>
</div>
<div v-if="showSearchResults" class="customer-table">
<el-table :data="searchResults" border style="width: 100%">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="电话" width="150" />
<el-table-column prop="regionText" label="地区" width="200" />
<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 }">
<el-tag :type="row.customer_type === 'VIP' ? 'danger' : 'info'">
{{ row.customer_type === 'VIP' ? '地区总代理' : '普通代理' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="editCustomer(row)">编辑</el-button>
<el-button link type="danger" @click="deleteCustomer(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px; color: #909399; font-size: 14px;">
找到 {{ searchResults.length }} 条结果
<el-button link type="primary" @click="clearSearch">清空搜索</el-button>
</div>
</div>
<!-- 新增客户表单 -->
<div v-if="showAddForm" class="customer-info-label">
<el-form :model="form" label-width="auto" style="max-width: 600px" label-position="top">
<el-form-item label="姓名">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="电话">
<el-input v-model="form.phone" :controls="false" :min="0" :max="99999999999" :precision="0"
placeholder="请输入11位手机号" style="width: 100%" />
</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-button type="primary" @click="saveCustomer">保存</el-button>
<el-button type="danger" @click="cancelAdd">取消</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<style scoped>
</style>

11
src/components/page2.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<h1>hello ,im 2</h1>
</template>
<style scoped>
</style>

11
src/components/page3.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup>
</script>
<template>
<h1>hello ,im 3</h1>
</template>
<style scoped>
</style>

View File

@@ -1,179 +1,129 @@
<script setup>
import {ref, computed} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { getUserInfo, logout, hasPermission, getRoleLevel, isManager } from '../store/user'
const route = useRoute()
const router = useRouter()
// 获取用户信息
const userInfo = computed(() => getUserInfo())
const roleLevel = computed(() => getRoleLevel())
const isManagerLevel = computed(() => isManager())
// 主题色:副经理以上(level<=3)用红色,一般专员(level=4)用浅色
const themeColor = computed(() => roleLevel.value <= 3 ? '#E60012' : '#FF8C69')
const themeBg = computed(() => roleLevel.value <= 3 ? '#FEF0F0' : '#FFF5F0')
const themeBorder = computed(() => roleLevel.value <= 3 ? '#FECDD0' : '#FFDAB9')
// 退出登录
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
await logout()
router.push('/login')
})
}
// 检查权限
const checkPermission = (resource, action) => {
return hasPermission(resource, action)
}
// 角色级别名称
const roleName = computed(() => {
const level = roleLevel.value
if (level === 1) return '总经理'
if (level === 2) return '部门经理'
if (level === 3) return '部门副经理'
return '一般专员'
})
</script>
<template>
<el-container style="height: 100vh; flex-direction: column">
<!-- 顶部导航栏 -->
<el-header class="app-header" :style="{ borderBottomColor: themeColor }">
<div class="header-content">
<!-- 左侧 Logo -->
<div class="logo-section">
<img src="/logo.png" alt="logo" class="logo-img">
<h1 class="logo-title" :style="{ color: themeColor }">蜜雪冰城管理系统</h1>
<el-button
v-if="route.path !== '/panel/home'"
class="back-btn"
:style="{ color: themeColor }"
@click="router.push('/panel/home')"
>
<el-icon><ArrowLeft /></el-icon>
返回工作台
</el-button>
</div>
<!-- 右侧用户信息和退出 -->
<div class="header-right">
<span class="user-info" v-if="userInfo">
<el-icon><User /></el-icon>
{{ userInfo.name }}{{ userInfo.departmentDesc }} · {{ roleName }}
</span>
<el-button class="logout-btn" @click="handleLogout" :style="{ color: themeColor }">
<el-icon><SwitchButton/></el-icon>
退出登录
</el-button>
</div>
</div>
</el-header>
<!-- 主内容区 -->
<el-main class="app-main" :style="{ background: roleLevel <= 3 ? '#f5f7fa' : '#faf8f5' }">
<router-view />
</el-main>
</el-container>
</template>
<style scoped>
/* ========== 顶部导航栏 ========== */
.app-header {
display: flex;
align-items: center;
background: #fff;
border-bottom: 2px solid #E60012;
padding: 0 20px;
height: 60px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.logo-section {
display: flex;
align-items: center;
gap: 10px;
}
.logo-img {
width: 36px;
height: 36px;
object-fit: contain;
}
.logo-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #E60012;
white-space: nowrap;
}
.back-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
margin-left: 20px;
padding: 6px 12px;
border-radius: 6px;
transition: background 0.2s;
}
.back-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.user-info {
display: flex;
align-items: center;
gap: 6px;
color: #606266;
font-size: 14px;
}
.logout-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
}
.logout-btn:hover {
opacity: 0.8;
}
/* ========== 主内容区 ========== */
.app-main {
padding: 20px;
overflow-y: auto;
flex: 1;
}
</style>
<script setup>
import {ref} from 'vue'
import {useRoute} from 'vue-router'
const route = useRoute()
const isCollapse = ref(false)
const switchFold = () => {
isCollapse.value = !isCollapse.value
}
</script>
<template>
<el-container style="height: 100vh">
<el-header style="display: flex; align-items: center;">
<el-menu
:ellipsis="false"
mode="horizontal"
router
style="flex: 1;"
>
<!-- 左侧 -->
<el-menu-item index="">
<h1 style="margin: 0;">信息管理系统</h1>
</el-menu-item>
<!-- 右侧margin-left: auto 把它推到底部 -->
<el-menu-item index="/login" style="margin-left: auto">
<el-icon><SwitchButton/></el-icon>
<template #title>LogOut</template>
</el-menu-item>
</el-menu>
</el-header>
<el-container>
<el-aside style="display: flex; flex-direction: column" width="200px">
<el-menu
:collapse="isCollapse"
:default-active="route.path"
router
style="flex: 1; display: flex; flex-direction: column"
>
<!-- <el-sub-menu index="1">
<template #title>
<el-icon><IconMenu/></el-icon>
<span>一号栏</span>
</template>
</el-sub-menu> -->
<el-menu-item index="/panel/home">
<el-icon><House /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-menu-item index="/panel/page1">
<el-icon><Avatar /></el-icon>
<template #title>客户管理</template>
</el-menu-item>
<el-menu-item index="/panel/page2">
<el-icon><Document /></el-icon>
<template #title>合同管理</template>
</el-menu-item>
<el-menu-item index="/panel/page3">
<el-icon><Service /></el-icon>
<template #title>售后管理</template>
</el-menu-item>
<el-menu-item index="/panel/page3">
<el-icon><IceTea /></el-icon>
<template #title>产品管理</template>
</el-menu-item>
<el-menu-item index="/panel/page3">
<el-icon><User /></el-icon>
<template #title>员工管理</template>
</el-menu-item>
<!-- 关键margin-top: auto 把它推到最底部 -->
<el-menu-item style="margin-top: auto" @click="switchFold">
<el-icon :class="{'rotate-180-animation':!isCollapse,'rotate-180-animation-reverse':isCollapse}" ><ArrowLeft /></el-icon>
<template #title>收缩</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<!-- 这个 router-view 渲染嵌套的子路由page1/page2/page3 -->
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.rotate-180-animation {
/* 修改为你想要的动画旋转180度执行一次持续0.5秒 */
animation: rotate-180 0.3s ease-in-out;
animation-fill-mode:forwards;
}
.rotate-180-animation-reverse {
/* 修改为你想要的动画旋转180度执行一次持续0.5秒 */
animation: rotate-180-reverse 0.3s ease-in-out;
animation-fill-mode:forwards;
}
@keyframes rotate-180 {
from {
transform: rotate(0deg);
}
to {
transform: rotate(180deg);
}
}
@keyframes rotate-180-reverse {
from {
transform: rotate(180deg);
}
to {
transform: rotate(0deg);
}
}
</style>

View File

@@ -1,516 +0,0 @@
<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 total = ref(0)
// 产品类型映射
const productTypeMap = {
'茶饮原料': '茶饮原料',
'包装物料': '包装物料',
'设备器具': '设备器具'
}
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()
})
// 分页后的数据服务端已分页searchResults 即为当前页数据)
const paginatedData = computed(() => searchResults.value)
// 获取产品列表(服务端分页)
const fetchData = async (page) => {
loading.value = true
const params = {
page: page || currentPage.value,
pageSize: pageSize.value
}
const res = await getProductsList(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
} 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()
}
loading.value = true
const params = {
page: currentPage.value,
pageSize: pageSize.value,
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) {
if (res.data && Array.isArray(res.data.list)) {
searchResults.value = res.data.list
total.value = res.data.total
} 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 = ''
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 = {
'茶饮原料': '',
'包装物料': 'success',
'设备器具': 'warning'
}
return map[type] || 'info'
}
// 页码变化(服务端分页:重新请求后端)
const handleCurrentChange = (val) => {
currentPage.value = val
fetchData(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="产品编号" 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="茶饮原料" />
<el-option label="包装物料" value="包装物料" />
<el-option label="设备器具" value="设备器具" />
</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">共找到 {{ 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="产品编号" width="90" />
<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="total"
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="茶饮原料" />
<el-option label="包装物料" value="包装物料" />
<el-option label="设备器具" value="设备器具" />
</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) })()" v-if="canUpdate">编辑</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,577 +0,0 @@
<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, getRoleLevel } 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 showDetailDialog = ref(false)
const currentDetail = ref(null)
const loading = ref(false)
const customerList = ref([])
const employeeList = ref([])
// 分页相关
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
// 状态映射
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 = ''
}
})
// 监听筛选变化(与 user.vue 一致)
watch(filterStatus, () => {
currentPage.value = 1
handleSearch()
})
// 分页后的数据服务端已分页searchResults 即为当前页数据)
const paginatedData = computed(() => searchResults.value)
// 获取售后列表(服务端分页)
const fetchData = async (page) => {
loading.value = true
const params = {
page: page || currentPage.value,
pageSize: pageSize.value
}
const res = await getServiceList(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
} 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,
handle_status: filterStatus.value || undefined
}
// 只在有搜索内容时才添加搜索参数(与 user.vue 一致)
if (search.value) {
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
}
}
try {
const res = await getServiceList(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
} else if (Array.isArray(res.data)) {
searchResults.value = res.data
total.value = res.data.length
}
} else {
console.warn('[service] handleSearch 响应异常:', res)
searchResults.value = []
total.value = 0
}
} catch (e) {
console.error('[service] handleSearch 请求失败:', e)
searchResults.value = []
total.value = 0
}
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 handleRowDblClick = (row) => {
currentDetail.value = { ...row }
showDetailDialog.value = true
}
// 关闭详情弹窗
const closeDetail = () => {
showDetailDialog.value = false
currentDetail.value = null
}
// 清空表单
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
fetchData(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 isAdmin = computed(() => getUserInfo()?.departmentName === 'admin')
// 是否是一般专员role_level === 4
const isGeneralStaff = computed(() => getRoleLevel() === 4)
// 运营部专员:锁定处理人员为自己
const isOperationsStaff = computed(() => isOperationsManager.value && isGeneralStaff.value)
const currentUserId = computed(() => getUserInfo()?.id)
const canEditRow = (row) => {
if (!canUpdate.value) return false
if (getUserInfo()?.employee_id !== row.employee_id) return false
return true
}
const canDeleteRow = (row) => {
if (!canDelete.value) return false
if (getUserInfo()?.employee_id !== row.employee_id) return false
return true
}
</script>
<template>
<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="客户编号" 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">共找到 {{ 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="售后编号" width="90" />
<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="total"
layout="prev, pager, next"
prev-text="上一页"
next-text="下一页"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 售后详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="售后详情" width="700px" @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.customer_name || currentDetail.customer_id }}</el-descriptions-item>
<el-descriptions-item label="业务员">{{ currentDetail.employee_name || currentDetail.employee_id || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理状态">
<el-tag :type="statusMap[currentDetail.handle_status]">{{ currentDetail.handle_status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="售后日期">{{ formatDate(currentDetail.service_date) }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ currentDetail.employee_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="反馈内容" :span="2">{{ currentDetail.feedback || '无' }}</el-descriptions-item>
<el-descriptions-item label="处理方式" :span="2">{{ currentDetail.handle_method || '无' }}</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(); editService(d) })()" v-if="canUpdate && getUserInfo()?.employee_id === currentDetail?.employee_id">编辑</el-button>
</template>
</el-dialog>
<!-- 新增/编辑售后表单 -->
<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 :disabled="isOperationsStaff">
<el-option v-for="e in employeeList" :key="e.id" :label="`${e.id} - ${e.name}`" :value="e.id" />
</el-select>
<div class="field-hint" v-if="isOperationsStaff">专员无法修改处理人员已锁定为自己</div>
</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>
<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;
}
.clickable-row {
cursor: pointer;
}
.detail-content {
padding: 10px 0;
}
.field-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
</style>

View File

@@ -1,478 +0,0 @@
<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 total = ref(0)
// 供应商类型
const supplierTypes = ['原料', '包装', '设备']
onMounted(() => {
fetchData()
})
// 监听筛选变化
watch([filterType, filterStatus], () => {
currentPage.value = 1
handleSearch()
})
// 分页后的数据服务端已分页searchResults 即为当前页数据)
const paginatedData = computed(() => searchResults.value)
// 获取供应商列表(服务端分页)
const fetchData = async (page) => {
loading.value = true
const params = {
page: page || currentPage.value,
pageSize: pageSize.value
}
const res = await getSupplierList(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
} 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()
}
loading.value = true
const params = {
page: currentPage.value,
pageSize: pageSize.value,
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) {
if (res.data && Array.isArray(res.data.list)) {
searchResults.value = res.data.list
total.value = res.data.total
} else if (Array.isArray(res.data)) {
searchResults.value = res.data
total.value = res.data.length
}
}
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
fetchData(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="供应商编号" 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">共找到 {{ 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="供应商编号" width="100" />
<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="total"
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="请输入联系电话" type="tel" maxlength="11" oninput="this.value=this.value.replace(/\D/g,'')" />
</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="供应商编号">{{ 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>

View File

@@ -1,624 +0,0 @@
<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, getUserInfo, getRoleLevel } 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,
role_level: 4
})
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 roleLevelOptions = computed(() => {
const dept = deptList.value.find(d => d.id === form.department_id)
if (dept?.name === 'general_manager') {
// 总经理办公室只能选择副总经理
return [{ value: 2, label: '副总经理' }]
}
// 一般部门没有总经理,只有部门经理以下三级
return [
{ value: 2, label: '部门经理' },
{ value: 3, label: '部门副经理' },
{ value: 4, label: '一般专员' }
]
})
// 是否是总经理办公室部门
const isGeneralManagerDept = computed(() => {
const dept = deptList.value.find(d => d.id === form.department_id)
return dept?.name === 'general_manager'
})
// 员工列表
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()
})
// 监听部门变化:总经理办公室自动锁定角色级别为副总经理(2),一般部门重置为一般专员(4)
watch(() => form.department_id, (val) => {
const dept = deptList.value.find(d => d.id === val)
if (dept?.name === 'general_manager') {
form.role_level = 2 // 副总经理
} else {
form.role_level = 4 // 默认一般专员
}
})
// 监听搜索结果变化(服务端分页:不重置页码,避免翻页后被重置回第 1 页)
// 分页后的数据(服务端已分页)
const paginatedData = computed(() => searchResults.value)
// 获取员工列表(用于下拉选择,改用 simple 接口:无数据范围限制,仅返还在职员工基本信息)
const fetchEmployees = async () => {
const params = {}
// 一般专员只能选择同级别或更低级别的员工
if (currentRoleLevel.value === 4) {
params.max_role_level = 4
}
const res = await getSimpleEmployeeList(params)
if (res.code === 0 || res.code === 200) {
employeeList.value = res.data
}
}
// 过滤后的部门列表(一般专员不能选择总经理办公室)
const filteredDeptList = computed(() => {
const level = currentRoleLevel.value
if (level === 4) {
// 一般专员不能选择总经理办公室
return deptList.value.filter(d => d.name !== 'general_manager')
}
return deptList.value
})
// 过滤后的员工列表(不能选择比自己职位高的员工)
const filteredEmployeeList = computed(() => {
const level = currentRoleLevel.value
// 后端已经通过max_role_level参数过滤了直接返回
return employeeList.value
})
// 获取用户列表
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
form.role_level = 4
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
form.role_level = row.role_level || 4
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,
role_level: form.role_level
}
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'))
const currentRoleLevel = computed(() => getRoleLevel())
const isGeneralManager = computed(() => currentRoleLevel.value === 1)
// 角色级别显示
const getRoleLevelName = (level) => {
if (level === 1) return '总经理'
if (level === 2) return '部门经理'
if (level === 3) return '部门副经理'
return '一般专员'
}
const getRoleLevelTagType = (level) => {
const l = Number(level) || 4
if (l === 1) return 'danger'
if (l === 2) return 'warning'
if (l === 3) return 'success'
return 'info'
}
</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="用户编号" 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="用户编号" width="90" />
<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="role_level" label="角色级别" width="110">
<template #default="{ row }">
<el-tag :type="getRoleLevelTagType(row.role_level)">
{{ getRoleLevelName(row.role_level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="employee_id" label="关联员工" min-width="140">
<template #default="{ row }">
{{ row.employee_id ? `${row.employee_id} - ${row.real_name || '-'}` : '-' }}
</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 filteredEmployeeList" :key="emp.id" :label="`${emp.id} - ${emp.name}`" :value="emp.id" />
</el-select>
<div class="field-hint" v-if="currentRoleLevel === 4">一般专员只能关联同级别或更低级别的员工</div>
</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 filteredDeptList" :key="dept.id" :label="dept.description" :value="dept.id" />
</el-select>
<div class="field-hint" v-if="currentRoleLevel === 4">一般专员不能选择总经理办公室</div>
<div class="field-hint" v-else-if="isEditMode && form.employee_id">修改部门将同步更新关联员工的部门</div>
</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 label="角色级别" required>
<el-select v-model="form.role_level" placeholder="请选择角色级别" style="width: 100%;">
<el-option
v-for="item in roleLevelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
:disabled="item.value < currentRoleLevel"
/>
</el-select>
<div class="field-hint" v-if="isGeneralManagerDept">总经理办公室只能选择副总经理总经理是系统预设的</div>
<div class="field-hint" v-else>一般部门没有总经理级别不能创建比自己级别高的用户</div>
</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="用户编号">{{ 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="角色级别">
<el-tag :type="getRoleLevelTagType(currentDetail.role_level)">
{{ getRoleLevelName(currentDetail.role_level) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="关联员工">{{ currentDetail.employee_id ? `${currentDetail.employee_id} - ${currentDetail.real_name || '-'}` : '-' }}</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>
.field-hint {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
.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

@@ -1,23 +0,0 @@
/**
* 部门常量
*
* 与后端 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: '财务部' },
{ id: 7, name: 'hr', description: '人力资源部' },
]
/** 描述文本列表(用于 employee 表单的部门下拉选择) */
export const DEPARTMENT_NAME_LIST = DEPARTMENT_LIST.map(d => d.description)

View File

@@ -1,17 +1,18 @@
import {createApp} from 'vue'
import './style.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router)
app.mount('#app')
import { createPinia } from 'pinia'
import {createApp} from 'vue'
import './style.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router)
router.isReady().then(() => app.mount('#app'))

View File

@@ -1,81 +1,43 @@
import {createRouter, createWebHistory} from "vue-router";
import Login from "./components/Login.vue";
import Home from "./components/home.vue"
import Panel from "./components/panel.vue";
import Customer from "./components/customer.vue";
import Contract from "./components/contract.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 = [
{ path: "/", redirect: "/login" },
{ path: "/login", component: Login },
{
path: "/panel",
component: Panel,
redirect: "/panel/home",
children: [
{ path: "home", component: Home },
{ path: "customer", component: Customer, meta: { permission: 'customer:read' } },
{ path: "contract", component: Contract, meta: { permission: 'contract:read' } },
{ 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', requireManager: true } },
{ path: "supplier", component: Supplier, meta: { permission: 'supplier:read' } }
],
},
]
const router = createRouter({
history: createWebHistory(),
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 userInfo = JSON.parse(localStorage.getItem('userInfo') || 'null')
if (!userInfo?.permissions?.includes(to.meta.permission)) {
next('/panel/home')
return
}
// 用户管理页面信息技术部所有人都可以访问其他部门需要manager级别
if (to.meta.requireManager) {
const roleLevel = userInfo?.role_level || 4
const deptName = userInfo?.departmentName
if (deptName !== 'admin' && roleLevel > 2) {
next('/panel/home')
return
}
}
}
next()
})
export default router;
import {createRouter, createWebHistory} from "vue-router";
import Login from "./components/Login.vue";
import Home from "./components/home.vue"
import Panel from "./components/panel.vue";
import Page1 from "./components/page1.vue";
import Page2 from "./components/page2.vue";
import Page3 from "./components/page3.vue";
const routes = [
{ path: "/", redirect: "/login" },
{ path: "/login", component: Login },
{
path: "/panel",
component: Panel,
redirect: "/panel/home",
children: [
{ path: "home", component:Home},
{ path: "page1", component: Page1 },
{ path: "page2", component: Page2 },
{ path: "page3", component: Page3 },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem("bm_token");
if (to.meta['requiresAuth'] && !token) {
return next('/login');
}
if (to.path === "/login" && token) {
return next('/panel');
}
next()
})
export default router;

View File

@@ -1,90 +0,0 @@
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 getRoleLevel = () => {
return state.userInfo?.role_level || 4
}
// 是否为管理层总经理或部门经理level <= 2
export const isManager = () => {
return getRoleLevel() <= 2
}
// 是否为总经理
export const isGeneralManager = () => {
return getRoleLevel() === 1
}
// 获取当前用户信息
export const getUserInfo = () => state.userInfo
// 获取 token
export const getToken = () => state.token
export default {
state,
isLoggedIn,
login,
logout,
fetchUserInfo,
hasPermission,
getUserInfo,
getToken,
getRoleLevel,
isManager,
isGeneralManager
}

38
src/stores/user.js Normal file
View File

@@ -0,0 +1,38 @@
import {defineStore} from "pinia";
import {ref} from "vue";
import {login as loginApi, getUserInfo, logout as logoutApi} from "@/api/user.js";
const TOKEN_KEY = 'bm_token'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const userInfo = ref({})
async function login(form) {
const data = await loginApi(form)
token.value = data.token
userInfo.value = data.user
localStorage.setItem(TOKEN_KEY, data.token)
return data
}
async function fetchUserInfo() {
const data = await getUserInfo()
userInfo.value = data
return data
}
async function logout() {
try {
await logoutApi()
} catch (_) {
}
token.value = ''
userInfo.value = {}
localStorage.removeItem(TOKEN_KEY)
}
return {token, userInfo, logout, login, fetchUserInfo}
})

View File

@@ -1,32 +0,0 @@
// 格式化日期时间(带时间)
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

@@ -1,16 +1,22 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), //代码里写 @/api/user 时,自动当成 ./src/api/user 来找文件
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})

1670
yarn.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,342 +0,0 @@
# 蜜雪冰城企业管理系统 — 使用说明
> 本文档面向系统管理员及各部门工作人员,指导如何从零开始搭建和使用本系统。
---
## 一、系统总览
本系统用于蜜雪冰城总部管理加盟商、合同、员工、产品、供应商及售后事务。采用 **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. 总经理/财务登录,查看全局数据报表