feat:"完成页面接口的对接"

This commit is contained in:
2026-01-29 17:58:19 +08:00
parent 2774a539bf
commit 2b69da3c15
98 changed files with 9504 additions and 592 deletions

152
管理后台/README.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
管理后台/package.json Normal file
View 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
View File

@@ -0,0 +1,13 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
#app {
width: 100%;
height: 100%;
}
</style>

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

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

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

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

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

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

View 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

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

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

View 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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

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

View 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. **用户体验**:错误提示清晰准确,不会出现重复提示或循环跳转