feat:"完成页面接口的对接"
This commit is contained in:
152
管理后台/README.md
Normal file
152
管理后台/README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 优艺家沙发翻新管理后台
|
||||
|
||||
基于 Vue 3 + Element Plus 的后台管理系统
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: Vue 3.4 + TypeScript 5.3
|
||||
- **UI 组件**: Element Plus 2.5
|
||||
- **状态管理**: Pinia 2.1
|
||||
- **路由**: Vue Router 4.2
|
||||
- **HTTP 客户端**: Axios 1.6
|
||||
- **构建工具**: Vite 5
|
||||
- **日期处理**: Day.js
|
||||
|
||||
## 功能模块
|
||||
|
||||
### 1. 数据概览
|
||||
- 案例、服务、预约、用户总数统计
|
||||
- 最新预约列表
|
||||
- 快捷操作入口
|
||||
|
||||
### 2. 案例管理
|
||||
- 案例列表查看(分页、搜索、筛选)
|
||||
- 案例新增/编辑/删除
|
||||
- 图片预览
|
||||
- 状态管理
|
||||
|
||||
### 3. 服务管理
|
||||
- 服务列表查看
|
||||
- 服务新增/编辑/删除
|
||||
- 状态开关切换
|
||||
- 排序管理
|
||||
|
||||
### 4. 预约管理
|
||||
- 预约列表查看
|
||||
- 预约详情查看
|
||||
- 状态更新(待确认/已确认/进行中/已完成/已取消)
|
||||
- 多条件搜索
|
||||
|
||||
### 5. 用户管理
|
||||
- 用户列表查看
|
||||
- 用户信息编辑
|
||||
- 角色管理(管理员/普通用户)
|
||||
- 状态管理(启用/禁用)
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问地址:http://localhost:5173
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 预览生产构建
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### API 代理配置
|
||||
|
||||
开发环境下,所有 `/api` 请求会被代理到后端服务器 `http://localhost:3000`
|
||||
|
||||
配置文件:`vite.config.ts`
|
||||
|
||||
### 环境变量
|
||||
|
||||
可在项目根目录创建 `.env.development` 和 `.env.production` 文件配置不同环境的变量。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
管理后台/
|
||||
├── src/
|
||||
│ ├── api/ # API 接口定义
|
||||
│ │ ├── auth.ts # 认证相关
|
||||
│ │ ├── case.ts # 案例管理
|
||||
│ │ ├── service.ts # 服务管理
|
||||
│ │ ├── booking.ts # 预约管理
|
||||
│ │ └── user.ts # 用户管理
|
||||
│ ├── layout/ # 布局组件
|
||||
│ │ └── index.vue # 主布局
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ └── index.ts
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ │ └── user.ts # 用户状态
|
||||
│ ├── styles/ # 全局样式
|
||||
│ │ └── index.css
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ └── request.ts # Axios 封装
|
||||
│ ├── views/ # 页面组件
|
||||
│ │ ├── dashboard/ # 数据概览
|
||||
│ │ ├── case/ # 案例管理
|
||||
│ │ ├── service/ # 服务管理
|
||||
│ │ ├── booking/ # 预约管理
|
||||
│ │ ├── user/ # 用户管理
|
||||
│ │ └── login/ # 登录页
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.ts # 入口文件
|
||||
├── index.html
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 默认账号
|
||||
|
||||
```
|
||||
用户名:admin
|
||||
密码:admin123
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **后端服务**: 确保后端服务已启动并运行在 `http://localhost:3000`
|
||||
2. **认证**: 使用 JWT Token 进行身份认证,token 存储在 localStorage 中(key: `admin_token`)
|
||||
3. **权限**: 所有管理接口需要 admin 角色权限
|
||||
4. **图片上传**: 当前版本使用 URL 输入方式,后续可集成文件上传功能
|
||||
|
||||
## 界面特色
|
||||
|
||||
- ✨ 渐变设计,美观大方
|
||||
- 🎨 品牌色 #d4a574(金棕色)贯穿全局
|
||||
- 📱 响应式布局,适配不同屏幕
|
||||
- 🎭 平滑动画过渡
|
||||
- 🎯 清晰的操作提示和反馈
|
||||
- 🔒 完善的权限控制
|
||||
|
||||
## 技术亮点
|
||||
|
||||
- 组合式 API (Composition API)
|
||||
- TypeScript 类型安全
|
||||
- 自动导入 Vue、Pinia、Router API
|
||||
- Element Plus 按需引入
|
||||
- 统一的 HTTP 请求封装和错误处理
|
||||
- 路由守卫自动鉴权
|
||||
87
管理后台/auto-imports.d.ts
vendored
Normal file
87
管理后台/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
51
管理后台/components.d.ts
vendored
Normal file
51
管理后台/components.d.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
13
管理后台/index.html
Normal file
13
管理后台/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>优艺家沙发翻新 - 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2536
管理后台/package-lock.json
generated
Normal file
2536
管理后台/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
管理后台/package.json
Normal file
27
管理后台/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "sofa-admin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0",
|
||||
"unplugin-auto-import": "^0.17.0",
|
||||
"unplugin-vue-components": "^0.26.0"
|
||||
}
|
||||
}
|
||||
13
管理后台/src/App.vue
Normal file
13
管理后台/src/App.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
22
管理后台/src/api/auth.ts
Normal file
22
管理后台/src/api/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
user: any
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = (data: LoginParams) => {
|
||||
return request.post<any, { code: number; message: string; data: LoginResult }>('/auth/login', data)
|
||||
}
|
||||
|
||||
// 刷新 token
|
||||
export const refreshToken = (refreshToken: string) => {
|
||||
return request.post('/auth/refresh', { refresh_token: refreshToken })
|
||||
}
|
||||
54
管理后台/src/api/booking.ts
Normal file
54
管理后台/src/api/booking.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface Booking {
|
||||
id: number
|
||||
bookingNumber: string
|
||||
serviceId: number
|
||||
userId: number
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
address: string
|
||||
appointmentTime: string
|
||||
requirements: string
|
||||
images: string[]
|
||||
status: string
|
||||
quotedPrice: number
|
||||
finalPrice: number
|
||||
notes: string
|
||||
assignedWorkerId: number
|
||||
service: any
|
||||
user: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
status?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// 获取预约列表
|
||||
export const getBookingList = (params?: QueryParams) => {
|
||||
return request.get<any, { code: number; message: string; data: { list: Booking[]; total: number; page: number; pageSize: number } }>('/booking', { params })
|
||||
}
|
||||
|
||||
// 获取预约详情
|
||||
export const getBookingDetail = (id: number) => {
|
||||
return request.get<any, { code: number; message: string; data: Booking }>(`/booking/${id}`)
|
||||
}
|
||||
|
||||
// 更新预约
|
||||
export const updateBooking = (id: number, data: Partial<Booking>) => {
|
||||
return request.patch<any, { code: number; message: string; data: Booking }>(`/booking/${id}`, data)
|
||||
}
|
||||
|
||||
// 取消预约
|
||||
export const cancelBooking = (id: number) => {
|
||||
return request.post<any, { code: number; message: string }>(`/booking/${id}/cancel`)
|
||||
}
|
||||
|
||||
// 删除预约
|
||||
export const deleteBooking = (id: number) => {
|
||||
return request.delete<any, { code: number; message: string }>(`/booking/${id}`)
|
||||
}
|
||||
56
管理后台/src/api/case.ts
Normal file
56
管理后台/src/api/case.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface Case {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
beforeImages: string[]
|
||||
afterImages: string[]
|
||||
serviceType: string
|
||||
price: number
|
||||
materials: string
|
||||
duration: number
|
||||
status: string
|
||||
views: number
|
||||
likes: number
|
||||
user: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
serviceType?: string
|
||||
status?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// 获取案例列表
|
||||
export const getCaseList = (params?: QueryParams) => {
|
||||
return request.get<any, { code: number; message: string; data: { list: Case[]; total: number; page: number; pageSize: number } }>('/cases', { params })
|
||||
}
|
||||
|
||||
// 获取案例详情
|
||||
export const getCaseDetail = (id: number) => {
|
||||
return request.get<any, { code: number; message: string; data: Case }>(`/cases/${id}`)
|
||||
}
|
||||
|
||||
// 创建案例
|
||||
export const createCase = (data: Partial<Case>) => {
|
||||
return request.post<any, { code: number; message: string; data: Case }>('/cases', data)
|
||||
}
|
||||
|
||||
// 更新案例
|
||||
export const updateCase = (id: number, data: Partial<Case>) => {
|
||||
return request.patch<any, { code: number; message: string; data: Case }>(`/cases/${id}`, data)
|
||||
}
|
||||
|
||||
// 删除案例
|
||||
export const deleteCase = (id: number) => {
|
||||
return request.delete<any, { code: number; message: string }>(`/cases/${id}`)
|
||||
}
|
||||
|
||||
// 点赞案例
|
||||
export const likeCase = (id: number) => {
|
||||
return request.post<any, { code: number; message: string }>(`/cases/${id}/like`)
|
||||
}
|
||||
66
管理后台/src/api/service.ts
Normal file
66
管理后台/src/api/service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface Service {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
type: string // 服务类型:leather, fabric, functional等
|
||||
category?: string // 别名,与type相同
|
||||
basePrice: number // 后端字段
|
||||
price?: number // 前端别名
|
||||
images: string[]
|
||||
features: string[]
|
||||
estimatedDays: number
|
||||
icon?: string // 图标URL
|
||||
duration?: string // 时长描述
|
||||
status: string
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
type?: string
|
||||
status?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// 获取服务列表
|
||||
export const getServiceList = (params?: QueryParams) => {
|
||||
return request.get<any, { code: number; message: string; data: { list: Service[]; total: number } }>('/services', { params })
|
||||
}
|
||||
|
||||
// 获取服务详情
|
||||
export const getServiceDetail = (id: number) => {
|
||||
return request.get<any, { code: number; message: string; data: Service }>(`/services/${id}`)
|
||||
}
|
||||
|
||||
// 创建服务
|
||||
export const createService = (data: Partial<Service>) => {
|
||||
return request.post<any, { code: number; message: string; data: Service }>('/services', data)
|
||||
}
|
||||
|
||||
// 更新服务
|
||||
export const updateService = (id: number, data: Partial<Service>) => {
|
||||
return request.patch<any, { code: number; message: string; data: Service }>(`/services/${id}`, data)
|
||||
}
|
||||
|
||||
// 删除服务
|
||||
export const deleteService = (id: number) => {
|
||||
return request.delete<any, { code: number; message: string }>(`/services/${id}`)
|
||||
}
|
||||
|
||||
// 切换服务状态
|
||||
export const toggleServiceStatus = (id: number) => {
|
||||
return request.patch<any, { code: number; message: string }>(`/services/${id}/toggle-status`)
|
||||
}
|
||||
|
||||
// 更新排序
|
||||
export const updateSortOrder = (serviceOrders: { id: number; sortOrder: number }[]) => {
|
||||
return request.patch<any, { code: number; message: string }>('/services/sort-order', serviceOrders)
|
||||
}
|
||||
|
||||
// 获取有效服务列表
|
||||
export const getActiveServices = () => {
|
||||
return request.get<any, { code: number; message: string; data: { list: Service[]; total: number } }>('/services/active')
|
||||
}
|
||||
41
管理后台/src/api/user.ts
Normal file
41
管理后台/src/api/user.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
realName: string
|
||||
phone: string
|
||||
avatar: string
|
||||
role: string
|
||||
status: string
|
||||
openid: string
|
||||
unionid: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
export const getUserList = () => {
|
||||
return request.get<any, { code: number; message: string; data: { list: User[]; total: number } }>('/users')
|
||||
}
|
||||
|
||||
// 获取用户详情
|
||||
export const getUserDetail = (id: number) => {
|
||||
return request.get<any, { code: number; message: string; data: User }>(`/users/${id}`)
|
||||
}
|
||||
|
||||
// 更新用户
|
||||
export const updateUser = (id: number, data: Partial<User>) => {
|
||||
return request.patch<any, { code: number; message: string; data: User }>(`/users/${id}`, data)
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export const deleteUser = (id: number) => {
|
||||
return request.delete<any, { code: number; message: string }>(`/users/${id}`)
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
export const getUserProfile = () => {
|
||||
return request.get<any, { code: number; message: string; data: User }>('/users/profile')
|
||||
}
|
||||
278
管理后台/src/layout/index.vue
Normal file
278
管理后台/src/layout/index.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
|
||||
<div class="logo" :class="{ collapse: isCollapse }">
|
||||
<el-icon :size="32" color="#d4a574">
|
||||
<House />
|
||||
</el-icon>
|
||||
<span v-if="!isCollapse" class="logo-text">优艺家管理</span>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapse"
|
||||
:unique-opened="true"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
>
|
||||
<template v-for="route in routes" :key="route.path">
|
||||
<el-menu-item
|
||||
v-if="!route.children || route.children.length === 1"
|
||||
:index="route.redirect || route.path"
|
||||
>
|
||||
<el-icon v-if="route.meta?.icon">
|
||||
<component :is="route.meta.icon" />
|
||||
</el-icon>
|
||||
<template #title>{{ route.meta?.title }}</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu v-else :index="route.path">
|
||||
<template #title>
|
||||
<el-icon v-if="route.meta?.icon">
|
||||
<component :is="route.meta.icon" />
|
||||
</el-icon>
|
||||
<span>{{ route.meta?.title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="child in route.children"
|
||||
:key="child.path"
|
||||
:index="`${route.path}/${child.path}`"
|
||||
>
|
||||
<el-icon v-if="child.meta?.icon">
|
||||
<component :is="child.meta.icon" />
|
||||
</el-icon>
|
||||
<template #title>{{ child.meta?.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-icon" @click="toggleCollapse">
|
||||
<Expand v-if="isCollapse" />
|
||||
<Fold v-else />
|
||||
</el-icon>
|
||||
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item
|
||||
v-for="(item, index) in breadcrumbs"
|
||||
:key="index"
|
||||
:to="index === breadcrumbs.length - 1 ? '' : item.path"
|
||||
>
|
||||
{{ item.meta?.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleCommand">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="36" :src="userStore.userInfo?.avatar || ''">
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="username">{{ userStore.userInfo?.realName || userStore.userInfo?.username }}</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
House,
|
||||
Expand,
|
||||
Fold,
|
||||
User,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isCollapse = ref(false)
|
||||
|
||||
const routes = computed(() => {
|
||||
return router.options.routes.filter((route) => route.path !== '/login')
|
||||
})
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
return route.matched.filter((item) => item.meta?.title)
|
||||
})
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapse.value = !isCollapse.value
|
||||
}
|
||||
|
||||
const handleCommand = (command: string) => {
|
||||
if (command === 'logout') {
|
||||
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
})
|
||||
} else if (command === 'profile') {
|
||||
// TODO: 跳转到个人中心
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: linear-gradient(180deg, #2c3e50 0%, #34495e 100%);
|
||||
transition: width 0.3s;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.logo.collapse {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
margin-left: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover),
|
||||
:deep(.el-sub-menu__title:hover) {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background: linear-gradient(90deg, #d4a574 0%, rgba(212, 165, 116, 0.8) 100%) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.collapse-icon:hover {
|
||||
color: #d4a574;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 20px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.fade-transform-enter-active,
|
||||
.fade-transform-leave-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fade-transform-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-transform-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
23
管理后台/src/main.ts
Normal file
23
管理后台/src/main.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './styles/index.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
app.mount('#app')
|
||||
103
管理后台/src/router/index.ts
Normal file
103
管理后台/src/router/index.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import Layout from '@/layout/index.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '数据概览', icon: 'DataLine' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/case',
|
||||
component: Layout,
|
||||
redirect: '/case/list',
|
||||
meta: { title: '案例管理', icon: 'Picture' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'CaseList',
|
||||
component: () => import('@/views/case/list.vue'),
|
||||
meta: { title: '案例列表', icon: 'List' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/service',
|
||||
component: Layout,
|
||||
redirect: '/service/list',
|
||||
meta: { title: '服务管理', icon: 'Setting' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'ServiceList',
|
||||
component: () => import('@/views/service/list.vue'),
|
||||
meta: { title: '服务列表', icon: 'List' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/booking',
|
||||
component: Layout,
|
||||
redirect: '/booking/list',
|
||||
meta: { title: '预约管理', icon: 'Calendar' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'BookingList',
|
||||
component: () => import('@/views/booking/list.vue'),
|
||||
meta: { title: '预约列表', icon: 'List' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: Layout,
|
||||
redirect: '/user/list',
|
||||
meta: { title: '用户管理', icon: 'User' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'UserList',
|
||||
component: () => import('@/views/user/list.vue'),
|
||||
meta: { title: '用户列表', icon: 'List' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (to.path === '/login') {
|
||||
next()
|
||||
} else {
|
||||
if (userStore.token) {
|
||||
next()
|
||||
} else {
|
||||
next('/login')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
53
管理后台/src/stores/user.ts
Normal file
53
管理后台/src/stores/user.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { login as loginApi } from '@/api/auth'
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
realName: string
|
||||
phone: string
|
||||
avatar: string
|
||||
role: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string>(localStorage.getItem('admin_token') || '')
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
const setToken = (newToken: string) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('admin_token', newToken)
|
||||
}
|
||||
|
||||
const setUserInfo = (info: UserInfo) => {
|
||||
userInfo.value = info
|
||||
}
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const res = await loginApi({ username, password })
|
||||
if (res.data) {
|
||||
setToken(res.data.access_token)
|
||||
setUserInfo(res.data.user)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem('admin_token')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
setToken,
|
||||
setUserInfo,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
})
|
||||
51
管理后台/src/styles/index.css
Normal file
51
管理后台/src/styles/index.css
Normal file
@@ -0,0 +1,51 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #d4a574;
|
||||
--primary-light: #e8c9a8;
|
||||
--primary-dark: #b88d5f;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d4a574;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #b88d5f;
|
||||
}
|
||||
|
||||
/* Element Plus 主题色覆盖 */
|
||||
:root {
|
||||
--el-color-primary: #d4a574;
|
||||
--el-color-primary-light-3: #e8c9a8;
|
||||
--el-color-primary-light-5: #f3e4d4;
|
||||
--el-color-primary-light-7: #f9f2ea;
|
||||
--el-color-primary-light-8: #fcf7f2;
|
||||
--el-color-primary-light-9: #fdfbf8;
|
||||
--el-color-primary-dark-2: #b88d5f;
|
||||
}
|
||||
99
管理后台/src/utils/request.ts
Normal file
99
管理后台/src/utils/request.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import router from '@/router'
|
||||
|
||||
interface ResponseData<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const userStore = useUserStore()
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse<ResponseData>) => {
|
||||
const res = response.data
|
||||
|
||||
// 如果返回的状态码不是 0 或 200,则判断为错误
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
const isLoginRequest = response.config.url?.includes('/auth/login')
|
||||
// 登录接口的错误:不显示消息,直接抛出(由登录页面处理)
|
||||
if (isLoginRequest) {
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
|
||||
// 其他接口的错误
|
||||
if (res.code === 401) {
|
||||
// Token 过期或无效
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
} else {
|
||||
// 其他错误
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
|
||||
return res
|
||||
},
|
||||
(error) => {
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
console.error('请求错误:', status, data)
|
||||
|
||||
if (status === 401) {
|
||||
const isLoginRequest = error.response.config.url?.includes('/auth/login')
|
||||
// 登录接口的错误:不显示消息,直接抛出(由登录页面处理)
|
||||
if (isLoginRequest) {
|
||||
return Promise.reject(new Error(data.message || '请求失败'))
|
||||
}
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
} else if (status === 403) {
|
||||
ElMessage.error('没有权限访问')
|
||||
} else if (status === 404) {
|
||||
ElMessage.error('请求的资源不存在')
|
||||
} else if (status === 500) {
|
||||
ElMessage.error('服务器错误')
|
||||
} else {
|
||||
ElMessage.error(data?.message || '请求失败')
|
||||
}
|
||||
} else if (error.request) {
|
||||
ElMessage.error('网络连接失败')
|
||||
} else {
|
||||
ElMessage.error('请求配置错误')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
239
管理后台/src/views/booking/list.vue
Normal file
239
管理后台/src/views/booking/list.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div class="booking-list">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">预约管理</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" :model="queryForm" class="search-form">
|
||||
<el-form-item label="预约编号">
|
||||
<el-input v-model="queryForm.bookingNumber" placeholder="请输入预约编号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="queryForm.contactName" placeholder="请输入联系人" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryForm.status" placeholder="请选择" clearable>
|
||||
<el-option label="待确认" value="pending" />
|
||||
<el-option label="已确认" value="confirmed" />
|
||||
<el-option label="进行中" value="in_progress" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" stripe>
|
||||
<el-table-column prop="bookingNumber" label="预约编号" width="160" />
|
||||
<el-table-column prop="contactName" label="联系人" width="100" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" width="130" />
|
||||
<el-table-column prop="service.name" label="服务类型" min-width="120" />
|
||||
<el-table-column prop="address" label="地址" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="appointmentTime" label="预约时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.appointmentTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link :icon="View" @click="handleView(row)">查看</el-button>
|
||||
<el-dropdown @command="(cmd) => handleStatusCommand(cmd, row)">
|
||||
<el-button type="primary" link>
|
||||
更新状态 <el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="confirmed">确认</el-dropdown-item>
|
||||
<el-dropdown-item command="in_progress">进行中</el-dropdown-item>
|
||||
<el-dropdown-item command="completed">完成</el-dropdown-item>
|
||||
<el-dropdown-item command="cancelled">取消</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="queryForm.page"
|
||||
v-model:page-size="queryForm.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadData"
|
||||
@current-change="loadData"
|
||||
style="margin-top: 20px; justify-content: center"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="预约详情" width="700px">
|
||||
<el-descriptions :column="2" border v-if="currentRow">
|
||||
<el-descriptions-item label="预约编号">{{ currentRow.bookingNumber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="getStatusType(currentRow.status)">{{ getStatusText(currentRow.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">{{ currentRow.contactName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ currentRow.contactPhone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务类型" :span="2">{{ currentRow.service?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预约时间" :span="2">
|
||||
{{ formatDate(currentRow.appointmentTime) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服务地址" :span="2">{{ currentRow.address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="问题描述" :span="2">
|
||||
{{ currentRow.description || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="图片" :span="2" v-if="currentRow.images && currentRow.images.length">
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap">
|
||||
<el-image
|
||||
v-for="(img, index) in currentRow.images"
|
||||
:key="index"
|
||||
:src="img"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 100px; border-radius: 4px"
|
||||
:preview-src-list="currentRow.images"
|
||||
:initial-index="index"
|
||||
/>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
{{ formatDate(currentRow.createdAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, RefreshLeft, View, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { getBookingList, updateBooking } from '@/api/booking'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const queryForm = reactive({
|
||||
bookingNumber: '',
|
||||
contactName: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
limit: 50 // 增加默认显示数量
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const currentRow = ref<any>(null)
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
...queryForm,
|
||||
bookingNumber: queryForm.bookingNumber || undefined,
|
||||
contactName: queryForm.contactName || undefined,
|
||||
status: queryForm.status || undefined
|
||||
}
|
||||
const res = await getBookingList(params)
|
||||
tableData.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryForm.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryForm.bookingNumber = ''
|
||||
queryForm.contactName = ''
|
||||
queryForm.status = ''
|
||||
queryForm.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleView = (row: any) => {
|
||||
currentRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleStatusCommand = async (command: string, row: any) => {
|
||||
try {
|
||||
await updateBooking(row.id, { status: command })
|
||||
ElMessage.success('状态更新成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('更新失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, any> = {
|
||||
pending: 'warning',
|
||||
confirmed: 'success',
|
||||
in_progress: 'primary',
|
||||
completed: 'info',
|
||||
cancelled: 'danger'
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待确认',
|
||||
confirmed: '已确认',
|
||||
in_progress: '进行中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.booking-list {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
455
管理后台/src/views/case/list.vue
Normal file
455
管理后台/src/views/case/list.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<div class="case-list">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">案例管理</span>
|
||||
<el-button type="primary" :icon="Plus" @click="handleAdd">新增案例</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" :model="queryForm" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="queryForm.keyword" placeholder="搜索标题或描述" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务类型">
|
||||
<el-select v-model="queryForm.serviceType" placeholder="请选择" clearable>
|
||||
<el-option label="真皮翻新" value="leather" />
|
||||
<el-option label="布艺翻新" value="fabric" />
|
||||
<el-option label="功能维修" value="functional" />
|
||||
<el-option label="古董修复" value="antique" />
|
||||
<el-option label="办公沙发" value="office" />
|
||||
<el-option label="清洁保养" value="cleaning" />
|
||||
<el-option label="维修改装" value="repair" />
|
||||
<el-option label="定制沙发" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryForm.status" placeholder="请选择" clearable>
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="已发布" value="published" />
|
||||
<el-option label="已归档" value="archived" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="封面图" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
:src="row.images[0] || '/placeholder.png'"
|
||||
fit="cover"
|
||||
style="width: 80px; height: 60px; border-radius: 4px"
|
||||
:preview-src-list="row.images"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="serviceType" label="服务类型" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getServiceTypeText(row.serviceType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="location" label="地点" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="likes" label="点赞数" width="100" align="center" />
|
||||
<el-table-column prop="views" label="浏览数" width="100" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'published' ? 'success' : row.status === 'draft' ? 'warning' : 'info'">
|
||||
{{ row.status === 'published' ? '已发布' : row.status === 'draft' ? '草稿' : '已归档' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link :icon="View" @click="handleView(row)">查看</el-button>
|
||||
<el-button type="primary" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="queryForm.page"
|
||||
v-model:page-size="queryForm.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadData"
|
||||
@current-change="loadData"
|
||||
style="margin-top: 20px; justify-content: center"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="800px"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="formData.title" placeholder="请输入案例标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务类型" prop="serviceType">
|
||||
<el-select v-model="formData.serviceType" placeholder="请选择服务类型">
|
||||
<el-option label="真皮翻新" value="leather" />
|
||||
<el-option label="布艺翻新" value="fabric" />
|
||||
<el-option label="功能维修" value="functional" />
|
||||
<el-option label="古董修复" value="antique" />
|
||||
<el-option label="办公沙发" value="office" />
|
||||
<el-option label="清洁保养" value="cleaning" />
|
||||
<el-option label="维修改装" value="repair" />
|
||||
<el-option label="定制沙发" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="地点" prop="location">
|
||||
<el-input v-model="formData.location" placeholder="请输入地点" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入案例<E6A188><E4BE8B>述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图片" prop="imagesText">
|
||||
<el-input
|
||||
v-model="formData.imagesText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入图片URL,多个用逗号分隔"
|
||||
/>
|
||||
<div style="color: #909399; font-size: 12px; margin-top: 5px">
|
||||
示例:https://example.com/1.jpg,https://example.com/2.jpg
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="改造前图片">
|
||||
<el-input
|
||||
v-model="formData.beforeImagesText"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入改造前图片URL,多个用逗号分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="改造后图片">
|
||||
<el-input
|
||||
v-model="formData.afterImagesText"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入改造后图片URL,多个用逗号分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-input
|
||||
v-model="formData.tagsText"
|
||||
placeholder="请输入标签,多个用逗号分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio label="draft">草稿</el-radio>
|
||||
<el-radio label="published">已发布</el-radio>
|
||||
<el-radio label="archived">已归档</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||||
import { Plus, Search, RefreshLeft, View, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { getCaseList, createCase, updateCase, deleteCase } from '@/api/case'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const queryForm = reactive({
|
||||
keyword: '',
|
||||
serviceType: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
limit: 10
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增案例')
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitLoading = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
id: 0,
|
||||
title: '',
|
||||
description: '',
|
||||
serviceType: '',
|
||||
location: '',
|
||||
images: [] as string[],
|
||||
imagesText: '',
|
||||
beforeImages: [] as string[],
|
||||
beforeImagesText: '',
|
||||
afterImages: [] as string[],
|
||||
afterImagesText: '',
|
||||
tags: [] as string[],
|
||||
tagsText: '',
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
serviceType: [{ required: true, message: '请选择服务类型', trigger: 'change' }],
|
||||
description: [{ required: true, message: '请输入描述', trigger: 'blur' }],
|
||||
imagesText: [{ required: true, message: '请输入图片URL', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
...queryForm,
|
||||
keyword: queryForm.keyword || undefined,
|
||||
serviceType: queryForm.serviceType || undefined,
|
||||
status: queryForm.status || undefined
|
||||
}
|
||||
const res = await getCaseList(params)
|
||||
tableData.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryForm.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryForm.keyword = ''
|
||||
queryForm.serviceType = ''
|
||||
queryForm.status = ''
|
||||
queryForm.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = '新增案例'
|
||||
formData.value = {
|
||||
id: 0,
|
||||
title: '',
|
||||
description: '',
|
||||
serviceType: '',
|
||||
location: '',
|
||||
images: [],
|
||||
imagesText: '',
|
||||
beforeImages: [],
|
||||
beforeImagesText: '',
|
||||
afterImages: [],
|
||||
afterImagesText: '',
|
||||
tags: [],
|
||||
tagsText: '',
|
||||
status: 'active'
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleView = (row: any) => {
|
||||
ElMessage.info('查看功能开发中')
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑案例'
|
||||
formData.value = {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
serviceType: row.serviceType,
|
||||
location: row.location,
|
||||
images: row.images || [],
|
||||
imagesText: (row.images || []).join(','),
|
||||
beforeImages: row.beforeImages || [],
|
||||
beforeImagesText: (row.beforeImages || []).join(','),
|
||||
afterImages: row.afterImages || [],
|
||||
afterImagesText: (row.afterImages || []).join(','),
|
||||
tags: row.tags || [],
|
||||
tagsText: (row.tags || []).join(','),
|
||||
status: row.status
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (row: any) => {
|
||||
ElMessageBox.confirm('确定要删除该案例吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteCase(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
ElMessage.warning('请填写完整的表单信息')
|
||||
return
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
// 转换文本为数组
|
||||
const images = formData.value.imagesText.split(',').map(s => s.trim()).filter(Boolean)
|
||||
const beforeImages = formData.value.beforeImagesText.split(',').map(s => s.trim()).filter(Boolean)
|
||||
const afterImages = formData.value.afterImagesText.split(',').map(s => s.trim()).filter(Boolean)
|
||||
const tags = formData.value.tagsText.split(',').map(s => s.trim()).filter(Boolean)
|
||||
|
||||
// 二次验证图片数据
|
||||
if (images.length === 0) {
|
||||
ElMessage.error('请至少输入一张图片URL')
|
||||
submitLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const data = {
|
||||
title: formData.value.title,
|
||||
description: formData.value.description,
|
||||
serviceType: formData.value.serviceType,
|
||||
location: formData.value.location,
|
||||
images,
|
||||
beforeImages,
|
||||
afterImages,
|
||||
tags
|
||||
}
|
||||
|
||||
console.log('提交数据:', data)
|
||||
|
||||
if (formData.value.id) {
|
||||
// 编辑时才允许修改状态
|
||||
await updateCase(formData.value.id, { ...data, status: formData.value.status })
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
// 创建时不发送status,使用后端默认值
|
||||
await createCase(data)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
handleClose()
|
||||
loadData()
|
||||
} catch (error: any) {
|
||||
console.error('提交失败:', error)
|
||||
const errorMsg = error?.response?.data?.message || error?.message || '操作失败'
|
||||
ElMessage.error('提交失败:' + errorMsg)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getServiceTypeText = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
leather: '真皮翻新',
|
||||
fabric: '布艺翻新',
|
||||
functional: '功能维修',
|
||||
antique: '古董修复',
|
||||
office: '办公沙发',
|
||||
cleaning: '清洁保养',
|
||||
repair: '维修改装',
|
||||
custom: '定制沙发'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.case-list {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 保证每个表单项有合理的最小宽度,避免被压缩隐藏 */
|
||||
.search-form .el-form-item {
|
||||
flex: 0 0 auto;
|
||||
min-width: 220px; /* 可视宽度下的首选最小宽度 */
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
/* 搜索按钮项靠右且不被压缩 */
|
||||
.search-form .el-form-item:last-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 缩小屏幕时让表单项换行并铺满宽度 */
|
||||
@media (max-width: 900px) {
|
||||
.search-form {
|
||||
gap: 10px;
|
||||
}
|
||||
.search-form .el-form-item {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小幅适配:确保 select / input 控件内部不被压缩 */
|
||||
.search-form .el-input,.search-form .el-select,.search-form .el-input-number {
|
||||
min-width: 160px;
|
||||
}
|
||||
</style>
|
||||
254
管理后台/src/views/dashboard/index.vue
Normal file
254
管理后台/src/views/dashboard/index.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card stat-card-1">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总案例数</div>
|
||||
<div class="stat-value">{{ stats.totalCases }}</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" :size="60" color="#409eff">
|
||||
<Picture />
|
||||
</el-icon>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card stat-card-2">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总服务数</div>
|
||||
<div class="stat-value">{{ stats.totalServices }}</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" :size="60" color="#67c23a">
|
||||
<Setting />
|
||||
</el-icon>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card stat-card-3">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">待处理预约</div>
|
||||
<div class="stat-value">{{ stats.pendingBookings }}</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" :size="60" color="#e6a23c">
|
||||
<Calendar />
|
||||
</el-icon>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="6">
|
||||
<el-card class="stat-card stat-card-4">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">总用户数</div>
|
||||
<div class="stat-value">{{ stats.totalUsers }}</div>
|
||||
</div>
|
||||
<el-icon class="stat-icon" :size="60" color="#f56c6c">
|
||||
<User />
|
||||
</el-icon>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="16">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">最新预约</span>
|
||||
<el-button text @click="$router.push('/booking/list')">查看更多</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="latestBookings" style="width: 100%">
|
||||
<el-table-column prop="bookingNumber" label="预约编号" width="150" />
|
||||
<el-table-column prop="contactName" label="联系人" width="100" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" width="120" />
|
||||
<el-table-column prop="service.name" label="服务类型" width="120" />
|
||||
<el-table-column prop="appointmentTime" label="预约时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.appointmentTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span class="card-title">快捷操作</span>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/case/list')">
|
||||
新建案例
|
||||
</el-button>
|
||||
<el-button type="success" :icon="Plus" @click="$router.push('/service/list')">
|
||||
新建服务
|
||||
</el-button>
|
||||
<el-button type="warning" :icon="Calendar" @click="$router.push('/booking/list')">
|
||||
查看预约
|
||||
</el-button>
|
||||
<el-button type="info" :icon="User" @click="$router.push('/user/list')">
|
||||
用户管理
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Picture, Setting, Calendar, User, Plus } from '@element-plus/icons-vue'
|
||||
import { getBookingList } from '@/api/booking'
|
||||
import { getCaseList } from '@/api/case'
|
||||
import { getServiceList } from '@/api/service'
|
||||
import { getUserList } from '@/api/user'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const stats = ref({
|
||||
totalCases: 0,
|
||||
totalServices: 0,
|
||||
pendingBookings: 0,
|
||||
totalUsers: 0
|
||||
})
|
||||
|
||||
const latestBookings = ref<any[]>([])
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [casesRes, servicesRes, bookingsRes, usersRes] = await Promise.all([
|
||||
getCaseList({ page: 1, limit: 1 }),
|
||||
getServiceList(),
|
||||
getBookingList({ status: 'pending' }),
|
||||
getUserList()
|
||||
])
|
||||
|
||||
stats.value.totalCases = casesRes.data.total || 0
|
||||
stats.value.totalServices = servicesRes.data.total || 0
|
||||
stats.value.pendingBookings = bookingsRes.data.total || 0
|
||||
stats.value.totalUsers = usersRes.data.total || 0
|
||||
|
||||
// 获取最新预约
|
||||
const allBookingsRes = await getBookingList({ page: 1, limit: 5 })
|
||||
latestBookings.value = allBookingsRes.data.list || []
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, any> = {
|
||||
pending: 'warning',
|
||||
confirmed: 'success',
|
||||
in_progress: 'primary',
|
||||
completed: 'info',
|
||||
cancelled: 'danger'
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待确认',
|
||||
confirmed: '已确认',
|
||||
in_progress: '进行中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.quick-actions .el-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
249
管理后台/src/views/login/index.vue
Normal file
249
管理后台/src/views/login/index.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="login-header">
|
||||
<div class="logo">
|
||||
<el-icon :size="40" color="#d4a574">
|
||||
<House />
|
||||
</el-icon>
|
||||
</div>
|
||||
<h1 class="title">优艺家沙发翻新</h1>
|
||||
<p class="subtitle">管理后台系统</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="rules"
|
||||
class="login-form"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-button"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>默认账号:admin / admin123</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-bg">
|
||||
<div class="bg-shape shape-1"></div>
|
||||
<div class="bg-shape shape-2"></div>
|
||||
<div class="bg-shape shape-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, House } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
await loginFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(loginForm.username, loginForm.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
// 显示后端返回的具体错误信息
|
||||
const errorMessage = error.message || '登录失败,请检查用户名和密码'
|
||||
ElMessage.error(errorMessage)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 420px;
|
||||
padding: 50px 40px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(135deg, #f3e4d4 0%, #e8c9a8 100%);
|
||||
border-radius: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 10px 30px rgba(212, 165, 116, 0.3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #d4a574 0%, #e8c9a8 100%);
|
||||
opacity: 0.1;
|
||||
animation: float 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
top: -150px;
|
||||
left: -150px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
bottom: -200px;
|
||||
right: -200px;
|
||||
animation-delay: 5s;
|
||||
}
|
||||
|
||||
.shape-3 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
top: 50%;
|
||||
right: 10%;
|
||||
animation-delay: 10s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) rotate(120deg);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) rotate(240deg);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
padding: 12px 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:deep(.el-button--primary) {
|
||||
background: linear-gradient(135deg, #d4a574 0%, #b88d5f 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary:hover) {
|
||||
background: linear-gradient(135deg, #e8c9a8 0%, #d4a574 100%);
|
||||
}
|
||||
</style>
|
||||
452
管理后台/src/views/service/list.vue
Normal file
452
管理后台/src/views/service/list.vue
Normal file
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<div class="service-list">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">服务管理</span>
|
||||
<el-button type="primary" :icon="Plus" @click="handleAdd">新增服务</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" :model="queryForm" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="queryForm.keyword" placeholder="搜索名称或描述" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="queryForm.type" placeholder="请选择" clearable>
|
||||
<el-option label="真皮翻新" value="leather" />
|
||||
<el-option label="布艺翻新" value="fabric" />
|
||||
<el-option label="功能维修" value="functional" />
|
||||
<el-option label="古董修复" value="antique" />
|
||||
<el-option label="办公沙发" value="office" />
|
||||
<el-option label="清洁保养" value="cleaning" />
|
||||
<el-option label="维修改装" value="repair" />
|
||||
<el-option label="定制沙发" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryForm.status" placeholder="请选择" clearable>
|
||||
<el-option label="启用" value="active" />
|
||||
<el-option label="禁用" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.icon"
|
||||
:src="row.icon"
|
||||
fit="contain"
|
||||
style="width: 40px; height: 40px"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="服务名称" min-width="120" />
|
||||
<el-table-column prop="type" label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getCategoryText(row.type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="basePrice" label="价格" width="100">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.basePrice }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
active-value="active"
|
||||
inactive-value="inactive"
|
||||
@change="handleStatusChange(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="queryForm.page"
|
||||
v-model:page-size="queryForm.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadData"
|
||||
@current-change="loadData"
|
||||
style="margin-top: 20px; justify-content: center"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="700px"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="服务名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类" prop="type">
|
||||
<el-select v-model="formData.type" placeholder="请选择分类">
|
||||
<el-option label="真皮翻新" value="leather" />
|
||||
<el-option label="布艺翻新" value="fabric" />
|
||||
<el-option label="功能维修" value="functional" />
|
||||
<el-option label="古董修复" value="antique" />
|
||||
<el-option label="办公沙发" value="office" />
|
||||
<el-option label="清洁保养" value="cleaning" />
|
||||
<el-option label="维修改装" value="repair" />
|
||||
<el-option label="定制沙发" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入服务描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="图标URL">
|
||||
<el-input v-model="formData.icon" placeholder="请输入图标URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="basePrice">
|
||||
<el-input-number v-model="formData.basePrice" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="预估天数">
|
||||
<el-input-number v-model="formData.estimatedDays" :min="1" placeholder="预估工期(天)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sortOrder">
|
||||
<el-input-number v-model="formData.sortOrder" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="特点">
|
||||
<el-input
|
||||
v-model="formData.featuresText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入特点,多个用逗号分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio label="active">启用</el-radio>
|
||||
<el-radio label="inactive">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||||
import { Plus, Search, RefreshLeft, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getServiceList,
|
||||
createService,
|
||||
updateService,
|
||||
deleteService,
|
||||
toggleServiceStatus
|
||||
} from '@/api/service'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const queryForm = reactive({
|
||||
keyword: '',
|
||||
type: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
limit: 10
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增服务')
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitLoading = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
description: '',
|
||||
type: '',
|
||||
icon: '',
|
||||
basePrice: 0,
|
||||
estimatedDays: 1,
|
||||
features: [] as string[],
|
||||
featuresText: '',
|
||||
sortOrder: 0,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择分类', trigger: 'change' }],
|
||||
description: [{ required: true, message: '请输入描述', trigger: 'blur' }],
|
||||
basePrice: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
keyword: queryForm.keyword || undefined,
|
||||
type: queryForm.type || undefined,
|
||||
status: queryForm.status || undefined
|
||||
}
|
||||
console.log('正在加载服务列表...', params)
|
||||
const res = await getServiceList(params)
|
||||
console.log('服务列表响应:', res)
|
||||
|
||||
if (res && res.data) {
|
||||
tableData.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
console.log(`成功加载 ${tableData.value.length} 条服务数据`)
|
||||
} else {
|
||||
console.error('响应格式错误:', res)
|
||||
ElMessage.error('数据格式错误')
|
||||
tableData.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载数据失败:', error)
|
||||
ElMessage.error('加载数据失败:' + (error.message || '未知错误'))
|
||||
tableData.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryForm.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryForm.keyword = ''
|
||||
queryForm.type = ''
|
||||
queryForm.status = ''
|
||||
queryForm.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = '新增服务'
|
||||
// 重置表单
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
formData.value = {
|
||||
id: 0,
|
||||
name: '',
|
||||
description: '',
|
||||
type: '',
|
||||
icon: '',
|
||||
basePrice: 0,
|
||||
estimatedDays: 1,
|
||||
features: [],
|
||||
featuresText: '',
|
||||
sortOrder: 0,
|
||||
status: 'active'
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑服务'
|
||||
formData.value = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
type: row.type,
|
||||
icon: row.icon || '',
|
||||
basePrice: row.basePrice,
|
||||
estimatedDays: row.estimatedDays || 1,
|
||||
features: row.features || [],
|
||||
featuresText: (row.features || []).join(','),
|
||||
sortOrder: row.sortOrder,
|
||||
status: row.status
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (row: any) => {
|
||||
ElMessageBox.confirm('确定要删除该服务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteService(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row: any) => {
|
||||
try {
|
||||
await toggleServiceStatus(row.id)
|
||||
ElMessage.success('状态更新成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('状态更新失败:', error)
|
||||
// 恢复原状态
|
||||
row.status = row.status === 'active' ? 'inactive' : 'active'
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
// 重置表单
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
// 清空表单数据
|
||||
formData.value = {
|
||||
id: 0,
|
||||
name: '',
|
||||
description: '',
|
||||
type: '',
|
||||
icon: '',
|
||||
basePrice: 0,
|
||||
estimatedDays: 1,
|
||||
features: [] as string[],
|
||||
featuresText: '',
|
||||
sortOrder: 0,
|
||||
status: 'active'
|
||||
}
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
ElMessage.warning('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (formData.value.id) {
|
||||
// 更新服务 - 可以包含status
|
||||
const data = {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description,
|
||||
type: formData.value.type,
|
||||
icon: formData.value.icon || undefined,
|
||||
basePrice: formData.value.basePrice,
|
||||
estimatedDays: formData.value.estimatedDays,
|
||||
features: formData.value.featuresText.split(',').map(s => s.trim()).filter(Boolean),
|
||||
sortOrder: formData.value.sortOrder,
|
||||
status: formData.value.status
|
||||
}
|
||||
console.log('更新服务数据:', data)
|
||||
const res = await updateService(formData.value.id, data)
|
||||
console.log('更新响应:', res)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
// 创建服务 - 不包含status(使用后端默认值)
|
||||
const data = {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description,
|
||||
type: formData.value.type,
|
||||
icon: formData.value.icon || undefined,
|
||||
basePrice: formData.value.basePrice,
|
||||
estimatedDays: formData.value.estimatedDays,
|
||||
features: formData.value.featuresText.split(',').map(s => s.trim()).filter(Boolean),
|
||||
sortOrder: formData.value.sortOrder
|
||||
}
|
||||
console.log('创建服务数据:', data)
|
||||
const res = await createService(data)
|
||||
console.log('创建响应:', res)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
dialogVisible.value = false
|
||||
// 重新加载数据
|
||||
await loadData()
|
||||
// 重置表单
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
console.error('提交失败:', error)
|
||||
const errorMsg = error?.response?.data?.message || error?.message || '操作失败'
|
||||
ElMessage.error('提交失败:' + errorMsg)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getCategoryText = (category: string) => {
|
||||
const map: Record<string, string> = {
|
||||
leather: '真皮翻新',
|
||||
fabric: '布艺翻新',
|
||||
functional: '功能维修',
|
||||
antique: '古董修复',
|
||||
office: '办公沙发',
|
||||
cleaning: '清洁保养',
|
||||
repair: '维修改装',
|
||||
custom: '定制沙发'
|
||||
}
|
||||
return map[category] || category
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.service-list {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
358
管理后台/src/views/user/list.vue
Normal file
358
管理后台/src/views/user/list.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">用户管理</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" :model="queryForm" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="queryForm.keyword" placeholder="搜索用户名/姓名/手机号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="queryForm.role" placeholder="请选择" clearable>
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="普通用户" value="user" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryForm.status" placeholder="请选择" clearable>
|
||||
<el-option label="启用" value="active" />
|
||||
<el-option label="禁用" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="头像" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :src="row.avatar || '/default-avatar.png'" :size="40" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="realName" label="真实姓名" width="100" />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'primary'">
|
||||
{{ row.role === 'admin' ? '管理员' : '用户' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
active-value="active"
|
||||
inactive-value="inactive"
|
||||
@change="handleStatusChange(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link :icon="View" @click="handleView(row)">查看</el-button>
|
||||
<el-button type="primary" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="row.role !== 'admin'"
|
||||
type="danger"
|
||||
link
|
||||
:icon="Delete"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="queryForm.page"
|
||||
v-model:page-size="queryForm.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadData"
|
||||
@current-change="loadData"
|
||||
style="margin-top: 20px; justify-content: center"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="用户详情" width="600px">
|
||||
<el-descriptions :column="2" border v-if="currentRow">
|
||||
<el-descriptions-item label="头像" :span="2">
|
||||
<el-avatar :src="currentRow.avatar || '/default-avatar.png'" :size="80" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="ID">{{ currentRow.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ currentRow.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="真实姓名">{{ currentRow.realName || '未设置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ currentRow.phone || '未设置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱" :span="2">{{ currentRow.email || '未设置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">
|
||||
<el-tag :type="currentRow.role === 'admin' ? 'danger' : 'primary'">
|
||||
{{ currentRow.role === 'admin' ? '管理员' : '用户' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="currentRow.status === 'active' ? 'success' : 'info'">
|
||||
{{ currentRow.status === 'active' ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="微信OpenID" :span="2">
|
||||
{{ currentRow.wechatOpenid || '未绑定' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">
|
||||
{{ formatDate(currentRow.createdAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">
|
||||
{{ formatDate(currentRow.updatedAt) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="formData.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="真实姓名">
|
||||
<el-input v-model="formData.realName" placeholder="请输入真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="formData.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="formData.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-select v-model="formData.role" placeholder="请选择角色">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="普通用户" value="user" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio label="active">启用</el-radio>
|
||||
<el-radio label="inactive">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||||
import { Search, RefreshLeft, View, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { getUserList, updateUser, deleteUser } from '@/api/user'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const queryForm = reactive({
|
||||
keyword: '',
|
||||
role: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
limit: 10
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const editVisible = ref(false)
|
||||
const dialogTitle = ref('编辑用户')
|
||||
const currentRow = ref<any>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitLoading = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
id: 0,
|
||||
username: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
role: 'user',
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
...queryForm,
|
||||
keyword: queryForm.keyword || undefined,
|
||||
role: queryForm.role || undefined,
|
||||
status: queryForm.status || undefined
|
||||
}
|
||||
const res = await getUserList(params)
|
||||
tableData.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryForm.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryForm.keyword = ''
|
||||
queryForm.role = ''
|
||||
queryForm.status = ''
|
||||
queryForm.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleView = (row: any) => {
|
||||
currentRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑用户'
|
||||
formData.value = {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
realName: row.realName || '',
|
||||
phone: row.phone || '',
|
||||
email: row.email || '',
|
||||
role: row.role,
|
||||
status: row.status
|
||||
}
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (row: any) => {
|
||||
ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row: any) => {
|
||||
try {
|
||||
await updateUser(row.id, { status: row.status })
|
||||
ElMessage.success('状态更新成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('状态更新失败:', error)
|
||||
// 恢复原状态
|
||||
row.status = row.status === 'active' ? 'inactive' : 'active'
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
editVisible.value = false
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const data = {
|
||||
realName: formData.value.realName || undefined,
|
||||
phone: formData.value.phone || undefined,
|
||||
email: formData.value.email || undefined,
|
||||
role: formData.value.role,
|
||||
status: formData.value.status
|
||||
}
|
||||
|
||||
await updateUser(formData.value.id, data)
|
||||
ElMessage.success('更新成功')
|
||||
|
||||
handleClose()
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-list {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
25
管理后台/tsconfig.json
Normal file
25
管理后台/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
管理后台/tsconfig.node.json
Normal file
10
管理后台/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
33
管理后台/vite.config.ts
Normal file
33
管理后台/vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia']
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()]
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
139
管理后台/登录逻辑说明.md
Normal file
139
管理后台/登录逻辑说明.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 登录接口逻辑说明
|
||||
|
||||
## 问题描述
|
||||
原先的实现中,登录失败时后端会抛出 `UnauthorizedException`(HTTP 401),前端响应拦截器收到 401 后会自动清空 token 并跳转到登录页,这在登录页面本身会造成逻辑混乱。
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 1. 后端修改 (`后端/src/auth/auth.controller.ts`)
|
||||
|
||||
**修改前**:
|
||||
```typescript
|
||||
async login(@Body() loginDto: LoginDto): Promise<any> {
|
||||
const result = await this.authService.login(loginDto);
|
||||
return { code: 0, message: 'success', data: result };
|
||||
}
|
||||
```
|
||||
- 如果 auth.service 抛出 `UnauthorizedException`,会直接返回 HTTP 401 状态码
|
||||
|
||||
**修改后**:
|
||||
```typescript
|
||||
async login(@Body() loginDto: LoginDto): Promise<any> {
|
||||
try {
|
||||
const result = await this.authService.login(loginDto);
|
||||
return {
|
||||
code: 0,
|
||||
message: '登录成功',
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
// 登录失败返回统一格式,不抛出 HTTP 异常
|
||||
return {
|
||||
code: 401,
|
||||
message: error.message || '用户名或密码错误',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键变化**:
|
||||
- ✅ HTTP 状态码始终为 200
|
||||
- ✅ 通过响应体的 `code` 字段区分成功(0)和失败(401)
|
||||
- ✅ 错误信息通过 `message` 字段返回
|
||||
|
||||
### 2. 前端修改 (`管理后台/src/utils/request.ts`)
|
||||
|
||||
**修改前**:
|
||||
```typescript
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
|
||||
if (res.code === 401) {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
```
|
||||
- 所有 401 都会跳转登录页
|
||||
|
||||
**修改后**:
|
||||
```typescript
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
const isLoginRequest = response.config.url?.includes('/auth/login')
|
||||
|
||||
if (res.code === 401 && !isLoginRequest) {
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
} else if (!isLoginRequest) {
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
```
|
||||
|
||||
**关键变化**:
|
||||
- ✅ 区分登录请求和其他请求
|
||||
- ✅ 登录接口的 401 不会触发自动跳转
|
||||
- ✅ 登录接口的错误消息不在拦截器中显示(由登录页面处理)
|
||||
|
||||
### 3. 前端登录页面 (`管理后台/src/views/login/index.vue`)
|
||||
|
||||
保持原有的错误处理逻辑:
|
||||
```typescript
|
||||
try {
|
||||
await userStore.login(loginForm.username, loginForm.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || '登录失败,请检查用户名和密码'
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ 登录页面自己处理并显示错误信息
|
||||
- ✅ 错误消息来自后端返回的 `message` 字段
|
||||
|
||||
## 最终效果
|
||||
|
||||
### 登录成功
|
||||
```
|
||||
请求: POST /api/auth/login
|
||||
响应: { code: 0, message: '登录成功', data: { user: {...}, access_token: '...', refresh_token: '...' } }
|
||||
结果: 显示"登录成功",跳转到首页
|
||||
```
|
||||
|
||||
### 登录失败(用户名或密码错误)
|
||||
```
|
||||
请求: POST /api/auth/login
|
||||
响应: { code: 401, message: '用户名或密码错误', data: null }
|
||||
结果: 显示"用户名或密码错误",停留在登录页
|
||||
```
|
||||
|
||||
### 登录失败(账户被禁用)
|
||||
```
|
||||
请求: POST /api/auth/login
|
||||
响应: { code: 401, message: '账户已被禁用,请联系管理员', data: null }
|
||||
结果: 显示"账户已被禁用,请联系管理员",停留在登录页
|
||||
```
|
||||
|
||||
### 其他接口 Token 过期
|
||||
```
|
||||
请求: GET /api/case/list
|
||||
响应: { code: 401, message: 'Unauthorized', data: null }
|
||||
结果: 显示"登录已过期,请重新登录",清空 token,跳转到登录页
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
这个修改确保了:
|
||||
1. **登录接口**:失败时返回友好的错误信息,不会触发自动跳转
|
||||
2. **其他接口**:Token 过期时自动清理状态并跳转到登录页
|
||||
3. **用户体验**:错误提示清晰准确,不会出现重复提示或循环跳转
|
||||
Reference in New Issue
Block a user