init
continuous-integration/drone Build is failing Details

master
chenyuepan 2 weeks ago
commit 39c7f0efdf

@ -0,0 +1,53 @@
kind: pipeline
type: docker
name: venue_reservation_manage
steps:
- name: prepare
image: node:18-alpine # 继续使用Node 18
volumes:
- name: node-model
path: /app/model
- name: node-cache
path: /app/cache
- name: node-build
path: /app/build
commands:
- npm install -g pnpm # 安装pnpm
- pnpm config set store-dir /app/model # 设置pnpm存储目录
- pnpm config set registry https://registry.npmjs.org # 使用官方npm registry更稳定
- pnpm install --frozen-lockfile # 安装依赖使用现有lockfile
- pnpm run build # 构建项目
- cp -r dist /app/build/
- cp Dockerfile /app/build/
- cp default.conf /app/build/
- cp run.sh /app/build/
- name: build
image: plugins/docker
volumes:
- name: node-build
path: /app/build
- name: docker
path: /var/run/docker.sock
settings:
dockerfile: /app/build/Dockerfile
commands:
- cd /app/build
- chmod +x run.sh
- sh run.sh
- docker ps
volumes:
- name: node-build
host:
path: /home/docker/drone/node/build
- name: node-model
host:
path: /home/docker/drone/node/model
- name: node-cache
host:
path: /home/docker/drone/node/cache
- name: docker
host:
path: /var/run/docker.sock

@ -0,0 +1,42 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
'prettier/prettier': [
'warn',
{
singleQuote: true, // 单引号
semi: false, // 无分号
printWidth: 80, // 每行宽度至多80字符
trailingComma: 'none', // 不加对象|数组最后逗号
endOfLine: 'auto' // 换行符号不限制win mac 不一致)
}
],
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index', 'Chart'] // vue组件名称多单词组成忽略index.vue
}
],
'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验
// 💡 添加未定义变量错误提示create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
'no-undef': 'error'
},
// 声明全局变量名
globals: {
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly',
ElNotification: 'readonly'
}
}

30
.gitignore vendored

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

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

@ -0,0 +1,10 @@
# 设置基础镜像
FROM nginx
#设置CTS时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY ./dist /usr/share/nginx/html/
#用本地的 default.conf 配置来替换nginx镜像里的默认配置
COPY default.conf /etc/nginx/conf.d/default.conf
EXPOSE 8092
CMD ["nginx","-g","daemon off;"]

@ -0,0 +1,35 @@
# [校园 e 站通・华交场馆管控一体化平台]
<!-- ## 目录
- [项目概述](#项目概述)
- [安装指南](#安装指南) -->
## 项目概述
**项目定位**: 基于Vue3 + ElementPlus实现的在线运动场馆管理后台
<!-- [演示地址](http://119.29.191.232:8092/login) -->
**功能模块**:
- 首页:数据大屏,显示平台场馆数量、当日营收情况、及平台使用人数
- 场馆管理对平台场馆进行CRUD操作。包括新增场馆信息、上传/修改场馆图片、调整场馆信息(营业时间、场馆名称等)、移除场馆信息等
<<<<<<< HEAD
- 场地管理基于已经存在的场馆对其中的场地信息进行CRUD操作。包括新增场地信息、调整场地使用价格】'
- 测试
=======
- 场地管理基于已经存在的场馆对其中的场地信息进行CRUD操作。包括新增场地信息、调整场地使用价格】
- update
>>>>>>> c20a175172b43fa5bd9b62ae2bae9f536664e22c
- ...
## 安装指南 <!--网页3][网页5-->
### 先决条件(运行环境)
- Node.js v18.x
### 安装步骤
```bash
# 克隆仓库
git clone https://git.code.tencent.com/chenyuepan/venue_reservation_manage.git
# 选择本地文件夹
cd your-project
# 依赖安装
pnpm i -save 或 npm i -save
# 运行
pnpm dev 或 npm run dev
```

@ -0,0 +1,23 @@
server {
listen 8092;
server_name localhost;
#charset koi8-r;
access_log /var/log/nginx/host.access.log main;
error_log /var/log/nginx/error.log error;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/title.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>预约管理端</title>
<script>
// 解决 global 未定义问题
if (typeof global === 'undefined') {
window.global = window;
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

5187
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,53 @@
{
"name": "venue_reservation_manage",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"prepare": "husky install",
"lint-staged": "lint-staged"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@icon-park/vue-next": "^1.4.2",
"@stomp/stompjs": "^7.1.1",
"@vueup/vue-quill": "^1.2.0",
"axios": "^1.6.8",
"echarts": "^5.5.1",
"element-plus": "^2.7.2",
"epic-spinners": "^2.0.0",
"markdown-it": "^14.1.0",
"pinia": "^2.1.7",
"sockjs-client": "^1.6.1",
"stompjs": "^2.3.3",
"vue": "^3.4.21",
"vue-echarts": "^7.0.3",
"vue-router": "^4.3.0",
"vue3-websocket": "^2.2.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.8.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-prettier": "^9.0.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.23.0",
"husky": "^8.0.0",
"lint-staged": "^15.2.2",
"pinia-plugin-persistedstate": "^3.2.1",
"prettier": "^3.2.5",
"sass": "^1.77.0",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.2.8"
},
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix"
]
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

@ -0,0 +1,22 @@
#!/bin/sh
# 定义应用组名
group_name='nnzmr'
# 定义应用名称
app_name='venue_reservation_manage'
# 定义应用版本
app_version='latest'
echo '----copy jar----'
docker stop ${app_name}
echo '----stop container----'
docker rm ${app_name}
echo '----rm container----'
docker rmi ${group_name}/${app_name}:${app_version}
echo '----rm image----'
# 打包编译docker镜像
docker build -t ${group_name}/${app_name}:${app_version} .
echo '----build image----'
docker run -p 8092:8092 --name ${app_name} \
-e TZ="Asia/Shanghai" \
-v /etc/localtime:/etc/localtime \
-d ${group_name}/${app_name}:${app_version}
echo '----start container----'

@ -0,0 +1,14 @@
<script setup>
// Vue3 CompositionAPI
// router -> const router = useRouter()
// route -> const route = useRoute()
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<template>
<el-config-provider :locale="zhCn">
<router-view></router-view>
</el-config-provider>
</template>
<style scoped></style>

@ -0,0 +1,12 @@
import request from '@/utils/request'
// 获取管理的场馆信息
export const QueryService = (id) => request.get('/admin/query?userId=' + id)
// 管理员添加场馆信息
export const manageService = (formData) =>
request.post('/admin/manage', formData)
// 管理员移除场馆信息
export const cancelService = (formData) =>
request.post('/admin/cancel', formData)

@ -0,0 +1,9 @@
import request from '@/utils/request'
// 管理员查询敏感词列表
export const CommentQueryService = (data) =>
request.post('/comment/admin', data)
// 管理员撤回评论
export const CommentWithdrawService = (id) =>
request.get(`/comment/withdraw/${id}`)

@ -0,0 +1,4 @@
import request from '@/utils/request'
// 物理设备加载
export const getLatestDevices = () => request.get('/device/latest')

@ -0,0 +1,19 @@
import request from '@/utils/request'
// 获取热点数据
export const hotQueryService = (data) => request.post('hot/query', data)
// 添加热点数据
export const hotAddService = (data) => request.post('hot/create', data)
// 修改热点数据
export const hotUpdateService = (data) => request.post('hot/update', data)
// 上传图片
export const hotImg = (formData) => request.post('hot/upload', formData)
// 删除
export const hotDelService = (id) => request.get('hot/remove?id=' + id)
// 获取营业额
export const MoneyService = () => request.get('hot/money')

@ -0,0 +1,65 @@
import request from '@/utils/request'
import axios from 'axios'
import { baseURL } from '@/utils/request'
// 获取场地信息
export const informationQueryService = (data) =>
request.post('/information/query', data)
// 添加场地信息
export const informationCreateService = (data) =>
request.post('/information/create', data)
// 修改场地信息
export const informationUpdateService = (data) =>
request.post('/information/update', data)
// 修改场地价格
export const moneyUpdateService = (data) =>
request.post('/information/money', data)
// 获取场地的分类列表
export const typeQueryService = () => request.get('/information/classify')
// 获取场地二维码信息
export const codeQueryService = () => request.get('/information/code')
// 上传场地照片
export const informationImgUploadService = (formData) =>
request.post('/information/img', formData)
// 获取场地的所有分类
export const typeListService = () => request.get('/information/types')
// 移除场地信息
export const informationRemoveService = (id) =>
request.get('/information/remove?id=' + id)
// 下载二维码图片
export const QRDownload = async (id, name) => {
try {
const response = await axios.get(baseURL + '/information/qr?id=' + id, {
responseType: 'blob'
})
// 创建下载链接
const url = window.URL.createObjectURL(response.data)
const a = document.createElement('a')
a.href = url
a.download = name + '.png'
a.click()
// 释放 URL 对象
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('下载出错:', error)
alert('文件下载失败,请重试')
}
}
// 获取某个场馆下的所有场地信息
export const informationListService = (id) =>
request.get('/information/detail?type_id=' + id)
// 获取某个场地的可用时间集合
export const timeUseService = (data) =>
request.post('/information/condition', data)

@ -0,0 +1,5 @@
import request from '@/utils/request'
// 获取系统日志信息
export const logQueryService = (data, key) =>
request.post('/log/query?key=' + key, data)

@ -0,0 +1,13 @@
import request from '@/utils/request'
// 获取提问列表
export const questionQueryService = (data) =>
request.post('/question/page', data)
// 加载回复数据
export const replyQueryService = (id) =>
request.get('/reply/view?questionId=' + id)
// 提交问题
export const questionSubmitService = (data) =>
request.post('/question/create', data)

@ -0,0 +1,13 @@
import request from '@/utils/request'
// 提交预约
export const reservationSubmitService = (data) =>
request.post('/reservation/create', data)
// 查询预约记录
export const recordQueryService = (data) =>
request.post('/reservation/query', data)
// 获取预约数据
export const dataQueryService = (time) =>
request.get(`/reservation/data?time=${time}`)

@ -0,0 +1,4 @@
import request from '@/utils/request'
// 获取单位数据
export const collegeQueryService = () => request.get('/school/college')

@ -0,0 +1,37 @@
import request from '@/utils/request'
// 查询时间段
export const slotPageService = (data) => request.post('/slot/page', data)
// 查询时间段下的可用场地信息
export const venuesQuery = (id) => request.get('/slot/venue?id=' + id)
// 添加禁用时间段
export const slotAdd = (data) => request.post('/slot/add', data)
// 向禁用时间段下添加场地信息
export const slotSet = (formData) => request.post('/slot/set', formData)
// 查询某禁用时间段下的已添加场地信息
export const venuesInquire = (id) => request.get('/slot/inquire?slotId=' + id)
// 移除场地数据
export const slotDel = (formData) => request.post('/slot/del', formData)
// 查询时间段下,可添加的场地信息
export const venueGetService = (id) => request.get('/time/get?id=' + id)
// 移除当前时间段下的场地
export const venueDeleteService = (fromData) =>
request.post('/time/delete', fromData)
// 向当前时间段添加场地
export const venueAddService = (fromData) => request.post('/time/add', fromData)
// 添加时间段
export const timeAddService = (fromData) =>
request.post('/time/create', fromData)
// 移除时间段
export const timeRemoveService = (id) =>
request.get('/time/remove?timeId=' + id)

@ -0,0 +1,26 @@
import request from '@/utils/request'
// 获取场馆信息
export const typeQueryService = (data) => request.post('/type/queryAll', data)
// 修改场馆信息
export const typeUpdateService = (data) => request.post('/type/update', data)
// 添加场馆信息
export const typeCreateService = (data) => request.post('/type/create', data)
// 获取场馆照片资源
export const typeImgService = (tid) => request.get('/type/data?tid=' + tid)
// 上传场馆照片
export const siteImgUploadService = (formData) =>
request.post('/type/upload', formData)
// 获取场馆名称列表
export const typeNameService = () => request.get('/type/name')
// 移除场馆信息
export const typeDelService = (tid) => request.get('/type/remove?id=' + tid)
// 查询某个场馆信息
export const typeOneService = (id) => request.get(`/type/select/${id}`)

@ -0,0 +1,44 @@
import request from '@/utils/request'
// 超级管理员登录
export const userLoginService = ({ account, passwd }) => {
return request.post('/user/admin', { account, passwd })
}
// 获取用户列表
export const userListService = (data) => request.post('/user/list', data)
// 查看用户的详细信息
export const userDetailService = (id) => request.get('/user/detail?id=' + id)
// 获取用户的数量
export const userNum = () => request.get('/user/num')
// 充值
export const userRechargeService = (formData) =>
request.post('/user/recharge', formData)
// 获取管理员列表
export const adminListService = (data) => request.post('/user/admininfo', data)
// 添加管理员信息
export const adminAddService = (data) => request.post('/user/create', data)
// 移除管理员信息
export const adminDelService = (id) => request.get('/user/remove?userId=' + id)
// 修改密码
export const PasswdService = (data) => request.post('/user/passwd', data)
// 获取用户信息
export const UserInfoService = (id) => request.get(`/user/info/${id}`)
// 上传头像图片
export const ImgUploadService = (formData) =>
request.post('/user/upload', formData)
// 修改用户信息
export const UpdateService = (data) => request.post('/user/update', data)
// qq邮箱重置
export const ResetService = (data) => request.post('/user/reset', data)

@ -0,0 +1,16 @@
import request from '@/utils/request'
// 查询敏感词列表
export const WordQueryService = (data) => request.post('/word/query', data)
// 添加敏感词
export const WordCreateService = (data) => request.post('/word/create', data)
// 修改敏感词
export const WordUpdateService = (data) => request.post('/word/update', data)
// 移除敏感词
export const WordRemoveService = (id) => request.delete('/word/remove?id=' + id)
// 启用 or 禁用 敏感词
export const WordSetService = (id) => request.get(`/word/set/${id}`)

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@ -0,0 +1,20 @@
body {
margin: 0;
background-color: #f5f5f5;
}
/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.fade-slide-leave-to {
transform: translateX(30px);
opacity: 0;
}

@ -0,0 +1,20 @@
body {
margin: 0;
background-color: #f5f5f5;
}
/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.fade-slide-leave-to {
transform: translateX(30px);
opacity: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -0,0 +1,396 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores'
import DefaultImg from '@/assets/default.png'
const userStore = useUserStore()
const userInfo = ref({ ...userStore.user })
const router = useRouter()
const showDropdown = ref(false)
//
const handleClickOutside = (event) => {
const profileElement = document.querySelector('.user-profile')
const dropdownElement = document.querySelector('.user-dropdown')
if (profileElement && dropdownElement) {
if (
!profileElement.contains(event.target) &&
!dropdownElement.contains(event.target)
) {
showDropdown.value = false
}
}
}
//
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
//
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
// 退
const logout = () => {
showDropdown.value = false
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
userStore.setToken('')
userStore.setUser({})
//
router.push('/login')
ElMessage.success('已退出登录')
})
.catch(() => {})
}
</script>
<template>
<div class="app-container">
<!-- 公共头部 -->
<header class="header">
<div class="header-content">
<div class="logo">
<span class="logo-icon">🏫</span>
<span class="logo-text">校园 e 站通</span>
</div>
<div class="main-nav">
<ul class="nav-list">
<li class="nav-item">
<router-link to="/mobile/index" class="nav-link"
>首页</router-link
>
</li>
<li class="nav-item">
<router-link to="/mobile/record" class="nav-link"
>我的订单</router-link
>
</li>
<li class="nav-item">
<router-link to="/mobile/userinfo" class="nav-link"
>个人信息</router-link
>
</li>
<li class="nav-item">
<router-link to="/mobile/message" class="nav-link"
>聊天窗</router-link
>
</li>
<li class="nav-item">
<router-link to="/mobile/question" class="nav-link"
>我的提问</router-link
>
</li>
</ul>
</div>
<div class="user-profile" @click="showDropdown = !showDropdown">
<div class="user-avatar-container">
<img
class="user-avatar"
:src="userInfo.isUpload === 1 ? userInfo.avatar : DefaultImg"
alt="用户头像"
/>
</div>
</div>
</div>
<!-- 用户下拉菜单 -->
<div v-show="showDropdown" class="user-dropdown">
<div class="dropdown-menu">
<el-button type="text" class="logout-btn" @click.stop="logout">
<i class="el-icon-switch-button" style="margin-right: 8px"></i
>退出登录
</el-button>
</div>
</div>
</header>
<!-- 主体内容插槽 -->
<main class="main-content">
<slot></slot>
</main>
<!-- 公共底部 -->
<footer class="footer">
<div class="footer-content">
<div class="footer-info">
<div class="footer-logo">
<span class="logo-icon">🏫</span>
<span class="logo-text">华交场馆管控一体化平台</span>
</div>
</div>
<div class="footer-contact">
<p>© 2025 华东交通大学 All Rights Reserved.</p>
</div>
</div>
</footer>
</div>
</template>
<style scoped>
/* 全局样式 */
:root {
--primary-color: #1e40af;
--secondary-color: #3b82f6;
--text-color: #334155;
--bg-gradient: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
--card-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
--card-hover-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
/* 应用容器 */
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #6c83a1 100%);
}
/* 头部样式 */
.header {
background: var(--bg-gradient);
color: white;
padding: 16px 0;
position: relative;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1440px;
margin: 0 auto;
padding: 0 32px;
}
.logo {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 600;
}
.logo-icon {
margin-right: 8px;
font-size: 24px;
}
.logo-text {
color: black;
}
/* 新增的顶部导航 */
.main-nav {
flex: 1;
max-width: 500px;
margin: 0 20px;
}
.nav-list {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.nav-item {
margin: 0 15px;
}
.nav-link {
text-decoration: none;
font-weight: 500;
padding: 8px 0;
position: relative;
transition: all 0.3s;
color: black;
}
.nav-link:hover {
opacity: 0.8;
}
.nav-link::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background-color: white;
transition: width 0.3s;
}
.nav-link:hover::after {
width: 100%;
}
/* 用户头像区域 */
.user-avatar-container {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
transition: transform 0.3s;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.user-avatar-container:hover {
transform: rotate(10deg);
}
.user-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 用户下拉菜单 */
.user-dropdown {
position: absolute;
top: 100%;
right: 32px;
width: 180px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 1000;
animation: fadeIn 0.2s ease-in-out;
margin-top: 8px;
}
.logout-btn {
width: 100%;
text-align: left;
padding: 12px 16px;
font-weight: 500;
}
.logout-btn:hover {
background-color: #fef2f2;
color: #ef4444;
}
/* 主要内容区域 */
.main-content {
flex: 1;
max-width: 1550px;
width: 90%;
margin: 0 auto;
padding: 32px;
}
/* 页脚样式 */
.footer {
background: var(--bg-gradient);
color: white;
padding: 32px 0 24px;
}
.footer-content {
max-width: 1440px;
margin: 0 auto;
padding: 0 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-logo {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 0;
}
.footer-desc {
font-size: 14px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.8);
margin-top: 8px;
max-width: 300px;
}
.footer-contact {
text-align: right;
}
.footer-contact p {
margin: 4px 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
}
.footer-copyright {
text-align: center;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
margin-top: 16px;
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式布局 */
@media (max-width: 1024px) {
.header-content {
padding: 0 24px;
}
.main-nav {
max-width: 300px;
}
.footer-content {
flex-direction: column;
text-align: center;
}
.footer-contact {
text-align: center;
margin-top: 16px;
}
}
@media (max-width: 768px) {
.header-content {
flex-wrap: wrap;
}
.main-nav {
order: 3;
margin: 16px 0;
width: 100%;
justify-content: center;
}
.nav-list {
justify-content: center;
}
.nav-item {
margin: 0 10px;
}
}
</style>

@ -0,0 +1,60 @@
<template>
<div ref="chartRef" :style="{ height: height, width: width }"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
option: {
type: Object,
required: true
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
}
})
const chartRef = ref(null)
let chart = null
onMounted(() => {
const initChart = () => {
chart = echarts.init(chartRef.value)
chart.setOption(props.option)
}
initChart()
//
const handleResize = () => {
chart && chart.resize()
}
window.addEventListener('resize', handleResize)
//
const handleOptionChange = (newOption) => {
chart && chart.setOption(newOption)
}
watch(() => props.option, handleOptionChange, { deep: true })
// DOM
const resizeObserver = new ResizeObserver(() => {
chart && chart.resize()
})
resizeObserver.observe(chartRef.value)
})
onUnmounted(() => {
window.removeEventListener('resize', () => {
chart && chart.resize()
})
chart && chart.dispose()
chart = null
})
</script>

@ -0,0 +1,6 @@
<script setup>
// import { ref } from 'vue'
</script>
<template>
<page-container title="首页"> </page-container>
</template>

@ -0,0 +1,33 @@
<script setup>
defineProps({
title: {
required: true,
type: String
}
})
</script>
<template>
<el-card class="page-container">
<template #header>
<div class="header">
<span>{{ title }}</span>
<div class="extra">
<slot name="extra"></slot>
</div>
</div>
</template>
<slot></slot>
</el-card>
</template>
<style lang="scss" scoped>
.page-container {
min-height: 100%;
box-sizing: border-box;
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
</style>

@ -0,0 +1,145 @@
<script setup>
import { ref } from 'vue'
import { User } from '@element-plus/icons-vue'
import { PasswdService } from '@/api/user'
const dialogTableVisible = ref(false)
const form = ref()
const formModel = ref({
id: '',
oldPasswd: '',
newPasswd: '',
rePasswd: ''
})
//
const rules = {
oldPasswd: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
newPasswd: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{
pattern: /^\S{8,30}$/,
message: '新密码必须是 8~30 位非空字符 ',
trigger: 'blur'
},
{
validator: (rule, value, callback) => {
// value from password
if (value === formModel.value.oldPasswd) {
callback(new Error('新旧密码不能一致'))
} else {
// callback()
callback()
}
},
trigger: 'blur'
}
],
rePasswd: [
{ required: true, message: '请输入确认密码', trigger: 'blur' },
{
pattern: /^\S{8,30}$/,
message: '密码必须是 8~30 位非空字符 ',
trigger: 'blur'
},
{
validator: (rule, value, callback) => {
// value from password
if (value !== formModel.value.newPasswd) {
callback(new Error('两次输入的密码不一致'))
} else {
// callback()
callback()
}
},
trigger: 'blur'
}
]
}
// open open
const open = (userId) => {
formModel.value.id = userId
dialogTableVisible.value = true
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
const close = () => {
dialogTableVisible.value = false
form.value.clearValidate()
//
formModel.value = {
id: '',
oldPasswd: '',
newPasswd: '',
rePasswd: ''
}
}
//
const submit = async () => {
await form.value.validate()
ElMessageBox.confirm('确认提交?', '提示', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
})
.then(async () => {
//
await PasswdService(formModel.value)
ElMessage.success('密码修改成功')
close()
emit('success')
})
.catch(() => {})
}
</script>
<template>
<el-dialog
v-model="dialogTableVisible"
title="修改密码"
width="500"
@close="close"
>
<h1>提示新密码至少长度大于10同时至少包含大小写字符数字和特殊字符</h1>
<hr />
<el-form
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
>
<el-form-item prop="oldPasswd">
<el-input
v-model="formModel.oldPasswd"
:prefix-icon="User"
placeholder="请输入旧密码"
></el-input>
</el-form-item>
<el-form-item prop="newPasswd">
<el-input
:prefix-icon="User"
placeholder="请输入新密码"
v-model="formModel.newPasswd"
></el-input>
</el-form-item>
<el-form-item prop="rePasswd">
<el-input
v-model="formModel.rePasswd"
:prefix-icon="User"
placeholder="请输入确认密码"
></el-input>
</el-form-item>
<el-form-item>
<el-button
class="button"
type="success"
auto-insert-space
@click="submit"
>提交修改</el-button
>
</el-form-item>
</el-form>
</el-dialog>
</template>

@ -0,0 +1,34 @@
<script setup>
import { ref } from 'vue'
import defaultImg from '@/assets/cover.jpg'
const dialogVisible = ref(false)
const imgValue = ref()
// open open
const open = (imgUrl) => {
imgValue.value = imgUrl
console.log('img', imgUrl)
dialogVisible.value = true
}
//
defineExpose({
open
})
// const emit = defineEmits(['success'])
// emit('success')
</script>
<template>
<el-dialog v-model="dialogVisible" draggable>
<img
:src="
imgValue === null || imgValue === '' || imgValue === ' '
? defaultImg
: imgValue
"
style="height: 100%; width: 100%"
alt="图片加载失败"
/>
</el-dialog>
</template>

@ -0,0 +1,200 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { Plus, Upload } from '@element-plus/icons-vue'
import { siteImgUploadService } from '@/api/type'
import { informationImgUploadService } from '@/api/information'
import { hotImg } from '@/api/hot'
import DefaultImg from '@/assets/cover.jpg'
//
const isMobile = ref(window.innerWidth < 768)
const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
const dialogVisible = ref(false)
const uploadRef = ref()
const imgUrl = ref()
const formData = new FormData()
const onUploadFile = (file) => {
// FileReader
const reader = new FileReader()
reader.readAsDataURL(file.raw)
reader.onload = () => {
imgUrl.value = reader.result
}
console.log(imgUrl)
formData.append('file', file.raw)
// imgUrl.value = URL.createObjectURL(file.raw)
}
// open
// tid ->
// tUrl ->
// type -> venue site hot
const typeValue = ref('')
const open = (tid, tUrl, type) => {
imgUrl.value = tUrl === null || tUrl === '' || tUrl === ' ' ? '' : tUrl
typeValue.value = type
formData.append('id', tid)
dialogVisible.value = true
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
//
const uploadPhoto = async () => {
const isFile = formData.get('file')
if (isFile !== null) {
if (typeValue.value === 'venue') {
/* 调用场馆上传照片接口 */
await siteImgUploadService(formData)
ElMessage.success('场馆照片上传成功')
} else if (typeValue.value === 'site') {
//
await informationImgUploadService(formData)
ElMessage.success('场地照片上传成功')
} else if (typeValue.value === 'hot') {
//
await hotImg(formData)
ElMessage.success('照片上传成功')
} else {
ElMessage.info('暂无信息')
}
dialogVisible.value = false
formData.delete('id')
formData.delete('file')
} else {
ElMessage.error('请选择图片')
}
emit('success')
}
</script>
<template>
<el-dialog
v-model="dialogVisible"
title="上传图片"
:width="isMobile ? '90%' : '40%'"
:height="isMobile ? '80vh' : '80vh'"
:center="true"
:close-on-click-modal="false"
:modal="true"
:draggable="true"
style="max-width: 1000px"
>
<div class="upload-container">
<!-- 图片预览 -->
<div class="preview-box">
<el-upload
ref="uploadRef"
class="avatar-uploader"
:auto-upload="false"
:show-file-list="false"
:on-change="onUploadFile"
>
<img
class="preview-img"
:src="imgUrl !== '' ? imgUrl : DefaultImg"
alt="预览图"
/>
</el-upload>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
@click="uploadRef.$el.querySelector('input').click()"
type="primary"
:icon="Plus"
size="large"
style="margin: 0 15px"
>
选择图片
</el-button>
<el-button
type="success"
:icon="Upload"
size="large"
@click="uploadPhoto"
>
上传图片
</el-button>
</div>
</div>
</el-dialog>
</template>
<style scoped>
.upload-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
}
.preview-box {
flex: 1;
min-height: 200px;
border: 2px dashed #dcdfe6;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
position: relative;
overflow: hidden;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
}
.preview-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
transition: transform 0.3s ease;
}
.preview-img:hover {
transform: scale(1.02);
}
.avatar-uploader:hover {
border-color: #409eff;
background-color: #f5f7fa;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap;
margin-top: auto;
}
:deep(.el-upload) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
:deep(.el-dialog__body) {
padding: 0 !important;
}
</style>

@ -0,0 +1,18 @@
import { createApp } from 'vue'
// 选择需要的组件[7](@ref)
import '@/assets/main.scss'
import App from './App.vue'
import router from './router'
// 引入echarts
import * as echarts from 'echarts'
// 引入IconPark
import '@icon-park/vue-next/styles/index.css'
import pinia from '@/stores/index'
const app = createApp(App)
// vue3 给原型上挂载属性
app.config.globalProperties.$echarts = echarts
app.use(pinia)
app.use(router)
app.mount('#app')

@ -0,0 +1,174 @@
import { useUserStore } from '@/stores'
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// 登录模块
{
path: '/login',
component: () => import('@/views/login/LoginPage.vue')
},
// 密码重置
{
path: '/password/reset',
component: () => import('@/views/password/PasswordReset.vue'),
meta: { noAuth: true } // 添加不需要认证的标记
},
// 移动端模块 start
{
// 首页
path: '/mobile/index',
component: () => import('@/views/mobile/IndexPage.vue')
},
{
// 场地信息
path: '/mobile/detail',
component: () => import('@/views/mobile/VenueDetail.vue')
},
{
// 时间段信息
path: '/mobile/time',
component: () => import('@/views/mobile/VenueTime.vue')
},
{
// 预约成功响应页面
path: '/mobile/result/success',
component: () => import('@/views/mobile/result/SuccessResult.vue')
},
{
// 预约失败响应页面
path: '/mobile/result/fail',
component: () => import('@/views/mobile/result/FailureResult.vue')
},
{
// 预约记录页面
path: '/mobile/record',
component: () => import('@/views/mobile/RecordPage.vue')
},
{
// 个人信息页面
path: '/mobile/userinfo',
component: () => import('@/views/mobile/UserInfo.vue')
},
{
// 聊天页面
path: '/mobile/message',
component: () => import('@/views/mobile/MessagePage.vue')
},
{
// 提问页面
path: '/mobile/question',
component: () => import('@/views/mobile/QuestionPage.vue')
},
// 移动端模块 end
// 超级管理员
{
path: '/',
component: () => import('@/views/layout/LayoutContainer.vue'),
redirect: '/index',
children: [
// 系统首页
{
path: '/index',
component: () => import('@/views/index/indexPage.vue')
},
// 轮播事件管理
{
path: '/carousel',
component: () => import('@/views/carousel/CarouselManage.vue')
},
// 场馆信息管理
{
path: '/type/manage',
component: () => import('@/views/type/TypeManage.vue')
},
// 场地信息管理
{
path: '/field/manage',
component: () => import('@/views/field/FieldManage.vue')
},
// 预约时间管理
{
path: '/time/manage',
component: () => import('@/views/time/VenueTime.vue')
},
// 评论管理 -- 用户评论
{
path: '/comment/user',
component: () => import('@/views/comment/CommentPage.vue')
},
// 评论管理 -- 敏感词
{
path: '/comment/word',
component: () => import('@/views/word/WordPage.vue')
},
// 后台数据可视化
{
path: '/data/visible',
component: () => import('@/views/data/DataVisualization.vue')
},
// 用户信息查看
{
path: '/user/manage',
component: () => import('@/views/manage/UserManage.vue')
},
// 管理员信息查看
{
path: '/user/admin',
component: () => import('@/views/manage/AdminManage.vue')
},
// 系统日志
{
path: '/system/log',
component: () => import('@/views/log/SystemMessage.vue')
},
// 个人中心
{
path: '/user/info',
component: () => import('@/views/user/UserPage.vue')
},
// 留言箱基于websocket实现
{
path: '/message/box',
component: () => import('@/views/message/MessagePage.vue')
},
// 留言箱基于netty实现
{
path: '/message/netty',
component: () => import('@/views/message/NettyPage.vue')
},
// ai问答
{
path: '/ai',
component: () => import('@/views/ai/AnsweringPage.vue')
}
]
},
// 默认重定向到登录页
{
path: '/:pathMatch(.*)*',
redirect: '/login'
}
]
})
// 登录拦截访问
router.beforeEach((to) => {
const useStore = useUserStore()
// 不需要认证的页面直接放行
if (to.meta.noAuth) {
return true
}
// 如果没有 token 且访问的是非登录页,拦截到登录
if (!useStore.token && to.path !== '/login') {
return '/login'
}
// 其他情况正常放行
return true
})
export default router

@ -0,0 +1,15 @@
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(persist)
export default pinia
// 仓库统一导出
// import { useUserStore } from './modules/user'
// export { useUserStore }
// 简写
export * from './modules/user'
export * from './modules/info'

@ -0,0 +1,13 @@
// stores/loading.js
import { defineStore } from 'pinia'
export const useLoadingStore = defineStore('loading', {
state: () => ({ isLoading: false }),
actions: {
show() {
this.isLoading = true
},
hide() {
this.isLoading = false
}
}
})

@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 场地信息
export const useInfoStore = defineStore(
'big-info',
() => {
// 场馆 + 场地 基本信息
const info = ref({})
const setInfo = (obj) => {
info.value = { ...obj }
}
// 预约信息(成功 or 失败)
const responseInfo = ref({})
const setResponseInfo = (obj) => {
responseInfo.value = { ...obj }
}
// 订单信息
const paymentInfo = ref({})
const setPaymentInfo = (obj) => {
paymentInfo.value = { ...obj }
}
return {
info,
responseInfo,
paymentInfo,
setInfo,
setResponseInfo,
setPaymentInfo
}
},
{
persist: true
}
)

@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 用户模块 token setToken removeToken
export const useUserStore = defineStore(
'big-user',
() => {
const user = ref({})
const token = ref()
const setUser = (obj) => {
user.value = { ...obj }
}
const setToken = (obj) => {
token.value = obj
}
return {
user,
token,
setUser,
setToken
}
},
{
persist: true
}
)

@ -0,0 +1,22 @@
import { dayjs } from 'element-plus'
export const formatTime = (time) => dayjs(time).format('YYYY-MM-DD')
export const formatDateTime = (time) =>
dayjs(time).format('YYYY-MM-DD HH:mm:ss ')
// 时间字符串转换
export const timeToNumber = (timeStr) => Number(timeStr.split(':')[0])
// 时间戳转化
export const toTimestamp = (timeStr) => {
// 创建 Date 对象
const date = new Date(timeStr)
// 检查日期是否有效
if (isNaN(date.getTime())) {
throw new Error('Invalid date string')
}
// 返回时间戳(毫秒)
return date.getTime()
}

@ -0,0 +1,17 @@
// base64转图片格式
export const base64ImgtoFile = (dataurl, filename = 'file') => {
let arr = dataurl.split(',')
console.log(arr)
let mime = arr[0].match(/:(.*?);/)[1]
let suffix = mime.split('/')[1]
let bstr = atob(arr[1])
let n = bstr.length
let u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new File([u8arr], `${filename}.${suffix}`, {
type: mime
})
}

@ -0,0 +1,60 @@
import axios from 'axios'
import { useUserStore } from '@/stores'
import { ElMessage } from 'element-plus'
// import router from '@/router'
// const baseURL = 'https://cgyy.ecjtu.edu.cn/api'
const baseURL = 'https://www.ruanjiansc.cn/api'
// const baseURL = 'http://172.16.6.53:9020'
// const baseURL = 'http://localhost:9020/api'
const instance = axios.create({
// TODO 1. 基础地址,超时时间
baseURL,
timeout: 10000
})
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// TODO 2. 携带token
const useStore = useUserStore()
if (useStore.token) {
config.headers.Authorization = useStore.token
}
return config
},
(err) => Promise.reject(err)
)
// 响应拦截器
instance.interceptors.response.use(
(res) => {
// TODO 3. 处理业务失败
// TODO 4. 摘取核心响应数据
if (res.data.code === 200) {
// 成功
return res
}
// 失败返回
if (res.data.code === 201) {
ElNotification({
title: 'Warning',
message: res.data.message,
type: 'warning'
})
}
// 服务异常
if (res.data.code === 500) {
ElMessage.error('服务异常')
}
return Promise.reject(res.data)
},
(err) => {
// 错误的默认情况,只要给提示
ElMessage.error(err.response.data.message || '服务异常')
return Promise.reject(err)
}
)
export default instance
export { baseURL }

@ -0,0 +1,83 @@
import { Client } from '@stomp/stompjs'
import { ref } from 'vue'
// 解决 global 未定义问题
if (typeof global === 'undefined') {
window.global = window
}
export function useWebSocket() {
const isConnected = ref(false)
const userId = ref(`user_${Math.random().toString(36).substr(2, 9)}`)
const broadcastMessages = ref([])
const userMessages = ref([])
let stompClient = null
// 连接WebSocket
const connect = () => {
const url = 'ws://localhost:9020/ws' // 端口改为9020
stompClient = new Client({
brokerURL: url,
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
onConnect: () => {
isConnected.value = true
// 订阅广播频道
stompClient.subscribe('/topic/broadcast', (message) => {
broadcastMessages.value.push({
content: message.body,
timestamp: new Date().toLocaleTimeString()
})
})
// 订阅个人频道
stompClient.subscribe(`/user/queue/user`, (message) => {
userMessages.value.push({
content: message.body,
timestamp: new Date().toLocaleTimeString()
})
})
},
onStompError: (frame) => {
console.error('Broker reported error: ' + frame.headers['message'])
console.error('Additional details: ' + frame.body)
},
onWebSocketClose: () => {
isConnected.value = false
}
})
stompClient.activate()
}
// 断开连接
const disconnect = () => {
if (stompClient) {
stompClient.deactivate()
isConnected.value = false
}
}
// 清空广播消息
const clearBroadcastMessages = () => {
broadcastMessages.value = []
}
// 清空用户消息
const clearUserMessages = () => {
userMessages.value = []
}
return {
broadcastMessages,
userMessages,
isConnected,
userId,
connect,
disconnect,
clearBroadcastMessages,
clearUserMessages
}
}

@ -0,0 +1,322 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { Search } from '@element-plus/icons-vue'
import MarkdownIt from 'markdown-it'
// Markdown
const md = MarkdownIt({
html: true,
linkify: true,
typographer: true
})
//
const messages = ref([
{
id: 1,
text: '您好我是智能AI助手很高兴为您服务。我可以回答各种问题例如科技、编程、生活常识等。请问有什么可以帮您的吗',
mdText:
'您好我是智能AI助手很高兴为您服务。我可以回答各种问题例如科技、编程、生活常识等。请问有什么可以帮您的吗',
sender: 'ai',
time: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
}),
avatar: 'AI'
}
])
//
const userInput = ref('')
const isThinking = ref(false)
const chatContainer = ref(null)
const isLoading = ref(false)
const currentAiMessageId = ref(null)
//
const addUserMessage = (message) => {
if (!message.trim()) return
const now = new Date()
messages.value.push({
id: Date.now(),
text: message.trim(),
mdText: message.trim(),
sender: 'user',
time: now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
avatar: 'U'
})
userInput.value = ''
isThinking.value = true
scrollToBottom()
// API
callBackendAPI(message)
}
// API
const callBackendAPI = async (userMessage) => {
isLoading.value = true
try {
createAiMessage()
// API
const response = await fetch('http://localhost:8080/api/ai/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
question: userMessage
})
})
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`)
}
//
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let partialData = ''
var dataStr = 'start'
while (dataStr !== '[DONE]') {
const { value, done } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
partialData += chunk
// SSE
const lines = partialData.split('\n')
partialData = lines.pop() || ''
for (const line of lines) {
if (line.trim() === '') continue
if (line.startsWith('data: ')) {
dataStr = line.substring(6).trim()
// if (dataStr === '[DONE]') {
// break
// }
try {
const data = JSON.parse(dataStr)
const content = data.content || ''
if (content) {
updateAiMessage(content)
}
} catch (error) {
console.warn('JSON解析错误:', error)
}
}
}
}
} catch (error) {
console.error('API调用失败:', error)
const aiMessageIndex = messages.value.findIndex(
(msg) => msg.id === currentAiMessageId.value
)
if (aiMessageIndex !== -1) {
messages.value[aiMessageIndex].mdText =
`抱歉,处理请求时出错: ${error.message}`
messages.value[aiMessageIndex].text = md.render(
messages.value[aiMessageIndex].mdText
)
scrollToBottom()
}
} finally {
isLoading.value = false
isThinking.value = false
}
}
// AI
const createAiMessage = () => {
const now = new Date()
const newMessage = {
id: Date.now(),
text: '',
mdText: '',
sender: 'ai',
time: now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
avatar: 'AI'
}
messages.value.push(newMessage)
currentAiMessageId.value = newMessage.id
scrollToBottom()
}
// AI
const updateAiMessage = (contentChunk) => {
const aiMessageIndex = messages.value.findIndex(
(msg) => msg.id === currentAiMessageId.value
)
if (aiMessageIndex !== -1) {
messages.value[aiMessageIndex].mdText += contentChunk
messages.value[aiMessageIndex].text = md.render(
messages.value[aiMessageIndex].mdText
)
scrollToBottom()
}
}
//
const scrollToBottom = () => {
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
})
}
//
const sendMessage = () => {
if (!userInput.value.trim() || isThinking.value) return
addUserMessage(userInput.value)
}
// Ctrl+Enter
const handleKeydown = (e) => {
if (e.ctrlKey && e.key === 'Enter' && userInput.value.trim()) {
sendMessage()
}
}
//
onMounted(() => {
scrollToBottom()
})
</script>
<template>
<page-container title="AI对话助手">
<div class="chat-container">
<div ref="chatContainer" class="chat-messages">
<div
v-for="message in messages"
:key="message.id"
class="message"
:class="message.sender"
>
<div class="avatar">{{ message.avatar }}</div>
<div class="content">
<div class="text" v-html="message.text"></div>
<div class="time">{{ message.time }}</div>
</div>
</div>
<div v-if="isThinking" class="message ai">
<div class="avatar">AI</div>
<div class="content">
<div class="text">思考中...</div>
</div>
</div>
</div>
<div class="input-area">
<el-input
v-model="userInput"
type="textarea"
:rows="3"
placeholder="请输入问题按Ctrl+Enter发送"
@keydown="handleKeydown"
:disabled="isThinking"
></el-input>
<el-button
type="primary"
:icon="Search"
class="send-button"
@click="sendMessage"
:loading="isLoading"
>发送</el-button
>
</div>
</div>
</page-container>
</template>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
max-width: 800px;
margin: 0 auto;
border: 1px solid #ebeef5;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background-color: #fafafa;
}
.message {
display: flex;
margin-bottom: 20px;
}
.message.user {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
line-height: 40px;
border-radius: 50%;
background-color: #409eff;
color: white;
text-align: center;
font-weight: bold;
margin: 0 10px;
}
.content {
max-width: 70%;
padding: 10px 15px;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.message.user .content {
background-color: #d9ecff;
}
.text {
word-wrap: break-word;
}
.time {
font-size: 12px;
color: #999;
text-align: right;
margin-top: 5px;
}
.input-area {
display: flex;
padding: 20px;
background-color: #fff;
border-top: 1px solid #ebeef5;
}
.input-area :deep(.el-textarea__inner) {
resize: none;
}
.send-button {
margin-left: 10px;
height: auto;
}
</style>

@ -0,0 +1,867 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { Link, Search, Document, Close, Plus } from '@element-plus/icons-vue'
import MarkdownIt from 'markdown-it'
import { baseURL } from '@/utils/request'
// Markdown
const md = MarkdownIt({
html: true,
linkify: true,
typographer: true
})
//
const showHistory = ref(false)
const sessionHistory = ref([])
const currentSessionId = ref(null)
const isNewSession = ref(true) //
// IDIP
const userId = ref(10)
const userIp = ref('192.168.1.100')
//
const messages = ref([
{
id: 1,
text: '您好我是智能AI助手很高兴为您服务。我可以回答各种问题例如科技、编程、生活常识等。请问有什么可以帮您的吗',
mdText:
'您好我是智能AI助手很高兴为您服务。我可以回答各种问题例如科技、编程、生活常识等。请问有什么可以帮您的吗',
sender: 'ai',
time: formatTime(new Date()),
avatar: 'AI',
isActive: true // AI
}
])
//
const userInput = ref('')
const isThinking = ref(false)
const chatContainer = ref(null)
const isLoading = ref(false)
// AIID
const currentAiMessageId = ref(null)
//
const showStopButton = ref(false)
//
const abortController = ref(null)
//
const loadSessionHistory = async () => {
try {
// API
const response = await fetch(
`${baseURL}/session/list?userId=${userId.value}`
)
if (!response.ok) {
throw new Error(`获取会话历史失败: ${response.status}`)
}
const data = await response.json()
sessionHistory.value = data
} catch (error) {
console.error('加载会话历史失败:', error)
}
}
// (HH:MM)
function formatTime(date) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
//
function formatDateTime(dateString) {
const date = new Date(dateString)
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${formatTime(date)}`
}
//
const loadSession = async (session) => {
try {
// API
const response = await fetch(`${baseURL}/session/message/${session.id}`)
if (!response.ok) {
throw new Error(`获取会话消息失败: ${response.status}`)
}
const messagesData = await response.json()
// -
messages.value = messagesData.map((msg) => {
const createdAt = new Date(msg.createdAt)
return {
...msg,
mdText: msg.content,
text: msg.sender === 'ai' ? md.render(msg.content) : msg.content,
avatar: msg.sender === 'user' ? 'U' : 'AI',
time: formatTime(createdAt)
}
})
currentSessionId.value = session.id
isNewSession.value = false //
showHistory.value = false
scrollToBottom()
} catch (error) {
console.error('加载会话失败:', error)
}
}
//
const createNewConversation = () => {
//
messages.value = []
userInput.value = ''
currentSessionId.value = null
isNewSession.value = true
// AI
const now = new Date()
messages.value.push({
id: Date.now(),
text: '您好我是智能AI助手很高兴为您服务。我可以回答各种问题例如科技、编程、生活常识等。请问有什么可以帮您的吗',
mdText:
'您好我是智能AI助手很高兴为您服务。我可以回答各种问题例如科技、编程、生活常识等。请问有什么可以帮您的吗',
sender: 'ai',
time: formatTime(now),
avatar: 'AI',
isActive: true
})
scrollToBottom()
}
//
onMounted(() => {
loadSessionHistory()
scrollToBottom()
// URLID
const urlParams = new URLSearchParams(window.location.search)
const sessionIdParam = urlParams.get('session')
if (sessionIdParam) {
//
const targetSession = sessionHistory.value.find(
(s) => s.id === parseInt(sessionIdParam)
)
if (targetSession) {
loadSession(targetSession)
}
}
})
//
const addUserMessage = async (message) => {
if (!message.trim()) return
const now = new Date()
const newMessage = {
id: Date.now(),
text: message.trim(),
mdText: message.trim(),
sender: 'user',
time: formatTime(now),
avatar: 'U'
}
//
if (isNewSession.value) {
// AI
if (messages.value.length === 1 && messages.value[0].sender === 'ai') {
messages.value = []
}
//
messages.value.push(newMessage)
await saveMessageToBackend(newMessage, true)
isNewSession.value = false
} else {
//
messages.value.push(newMessage)
await saveMessageToBackend(newMessage)
}
userInput.value = ''
isThinking.value = true
scrollToBottom()
// APIAI
callBackendAPI(message)
}
//
const saveMessageToBackend = async (message, createSession = false) => {
try {
//
if (createSession) {
const sessionData = {
userId: userId.value,
userIp: userIp.value,
sessionToken: `sess_${Date.now()}`,
title: message.text.substring(0, 20) + '...'
}
//
const sessionResponse = await fetch(`${baseURL}/session/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(sessionData)
})
if (!sessionResponse.ok) {
throw new Error(`创建会话失败: ${sessionResponse.status}`)
}
const sessionDataResult = await sessionResponse.json()
currentSessionId.value = sessionDataResult.id
// ID
message.sessionId = currentSessionId.value
}
//
const messageData = {
sessionId: currentSessionId.value,
content: message.mdText,
sender: message.sender
}
//
const response = await fetch(`${baseURL}/session/message/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(messageData)
})
if (!response.ok) {
throw new Error(`保存消息失败: ${response.status}`)
}
// ID
const data = await response.json()
message.id = data.id
//
if (createSession) {
loadSessionHistory()
}
} catch (error) {
console.error('保存消息失败:', error)
}
}
// API - 使fetch API
// API - 使fetch API
const callBackendAPI = async (userMessage) => {
isLoading.value = true
showStopButton.value = true
abortController.value = new AbortController()
try {
// AI
createAiMessage()
// API
const apiKey =
'eyJ0eXBlIjoiSldUIiwiYWxnIjoiSFM1MTIifQ.eyJqdGkiOiI4NjMwNjcyNyIsInJvbCI6IlJPTEVfUkVHSVNURVIiLCJpc3MiOiJPcGVuWExhYiIsImlhdCI6MTc1MTUzOTEzNCwiY2xpZW50SWQiOiJlYm1ydm9kNnlvMG5semFlazF5cCIsInBob25lIjoiMTc4NzAzNDI2ODUiLCJvcGVuSWQiOm51bGwsInV1aWQiOiIzNDg5ZTJkOC04ZGI4LTQyNDUtOTFiZC03YmRkN2Y3MTI0ZDEiLCJlbWFpbCI6IiIsImV4cCI6MTc2NzA5MTEzNH0.-Bfg41q0v4RDjN6mgfLmga1isL02KF5DdoA1LoiNmUQg8I19FjEMz2w9dOJsql3-MavE6UB5QlECQiDvk4t6XQ' // Bearer Token
// messages
const formattedMessages = [
//
// { role: 'system', content: 'You are a helpful assistant.' },
// API
...messages.value.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'assistant',
content: msg.mdText
})),
//
{ role: 'user', content: userMessage }
]
// 使fetch APIAPIcurl
const response = await fetch(
'https://chat.intern-ai.org.cn/api/v1/chat/completions',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({
model: 'internlm3-latest',
messages: formattedMessages,
temperature: 0.8,
top_p: 0.9,
stream: true
}),
signal: abortController.value.signal
}
)
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`)
}
//
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let partialData = ''
let aiMessageContent = ''
var trimmedChunk = 'start'
while (trimmedChunk !== '[DONE]') {
const { value, done } = await reader.read()
if (done) break
//
const chunk = decoder.decode(value, { stream: true })
partialData += chunk
//
const dataChunks = partialData.split('data: ')
partialData = dataChunks.pop() || '' //
for (const dataChunk of dataChunks) {
if (dataChunk.trim() === '') continue
trimmedChunk = dataChunk.trim()
if (trimmedChunk === '[DONE]') {
//
break
}
try {
const dataObj = JSON.parse(trimmedChunk)
const content = dataObj.choices?.[0]?.delta?.content || ''
if (content) {
updateAiMessage(content)
aiMessageContent += content
}
} catch (error) {
console.warn('JSON解析错误:', error)
}
}
}
// AI
if (currentAiMessageId.value) {
saveMessageToBackend({
id: currentAiMessageId.value,
mdText: aiMessageContent,
sender: 'ai',
time: formatTime(new Date())
})
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('API调用失败:', error)
// AI
const aiMessageIndex = messages.value.findIndex(
(msg) => msg.id === currentAiMessageId.value
)
if (aiMessageIndex !== -1) {
messages.value[aiMessageIndex].mdText =
`抱歉,处理请求时出错: ${error.message}`
messages.value[aiMessageIndex].text = md.render(
messages.value[aiMessageIndex].mdText
)
scrollToBottom()
}
}
} finally {
isLoading.value = false
isThinking.value = false
showStopButton.value = false
abortController.value = null
}
}
// AI
const createAiMessage = () => {
const now = new Date()
const newMessage = {
id: Date.now(),
text: '',
mdText: '',
sender: 'ai',
time: formatTime(now),
avatar: 'AI',
isActive: true // AI
}
//
messages.value.forEach((msg) => {
msg.isActive = false
})
messages.value.push(newMessage)
currentAiMessageId.value = newMessage.id
scrollToBottom()
return newMessage.id
}
// AI
const updateAiMessage = (contentChunk) => {
const aiMessageIndex = messages.value.findIndex(
(msg) => msg.id === currentAiMessageId.value
)
if (aiMessageIndex !== -1) {
// Markdown
messages.value[aiMessageIndex].mdText += contentChunk
// Markdown
messages.value[aiMessageIndex].text = md.render(
messages.value[aiMessageIndex].mdText
)
//
scrollToBottom()
}
}
//
const scrollToBottom = () => {
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
})
}
//
const sendMessage = () => {
if (!userInput.value.trim() || isThinking.value) return
addUserMessage(userInput.value)
}
//
const stopResponse = () => {
if (abortController.value) {
abortController.value.abort()
showStopButton.value = false
isThinking.value = false
isLoading.value = false
// AI
const aiMessageIndex = messages.value.findIndex(
(msg) => msg.id === currentAiMessageId.value
)
if (aiMessageIndex !== -1 && messages.value[aiMessageIndex].text === '') {
messages.value[aiMessageIndex].mdText = '已终止回答'
messages.value[aiMessageIndex].text = md.render('已终止回答')
scrollToBottom()
}
}
}
// Ctrl+Enter
const handleKeydown = (e) => {
if (e.ctrlKey && e.key === 'Enter' && userInput.value.trim()) {
sendMessage()
}
}
</script>
<template>
<div class="ai-container">
<!-- 头部标题区域 -->
<div class="ai-header">
<el-page-header icon="" title=" ">
<template #title>
<div class="ai-title">
<el-icon size="30" color="#3498db">
<component :is="Search" />
</el-icon>
<span>AI智能问答助手</span>
</div>
</template>
<template #content>
<div class="ai-subtitle">支持Markdown格式的智能问答系统</div>
</template>
<template #extra>
<el-button @click="showHistory = true" style="margin-right: 10px">
<el-icon><Document /></el-icon>
<span>会话历史</span>
</el-button>
<el-button type="primary" @click="createNewConversation">
<el-icon><Plus /></el-icon>
<span>创建新会话</span>
</el-button>
</template>
</el-page-header>
</div>
<!-- 历史记录面板 -->
<el-drawer v-model="showHistory" title="会话历史记录" :size="400">
<div class="history-panel">
<el-scrollbar height="100%">
<div
v-for="session in sessionHistory"
:key="session.id"
class="session-item"
@click="loadSession(session)"
>
<div class="session-title">{{ session.title }}</div>
<div class="session-date">
{{ formatDateTime(session.startTime) }}
</div>
</div>
<div v-if="sessionHistory.length === 0" class="empty-history">
<el-empty description="暂无历史会话" />
</div>
</el-scrollbar>
</div>
</el-drawer>
<!-- 聊天区域 -->
<div class="chat-container" ref="chatContainer">
<div
v-for="(message, index) in messages"
:key="index"
:class="['message', message.sender]"
>
<div class="message-header">
<el-avatar :class="message.sender" :size="36">
<span>{{ message.avatar }}</span>
</el-avatar>
<div>
<div class="message-sender">
{{ message.sender === 'user' ? '用户' : 'AI助手' }}
</div>
<div class="message-time">{{ message.time }}</div>
</div>
</div>
<div
class="message-content"
:class="[message.sender, { active: message.isActive }]"
>
<!-- 用户消息直接显示文本AI消息显示解析后的HTML -->
<span v-if="message.sender === 'user'">{{ message.text }}</span>
<div v-else class="markdown-content" v-html="message.text"></div>
</div>
</div>
<!-- 终止按钮 -->
<div v-if="showStopButton" class="stop-response">
<el-button type="danger" size="small" @click="stopResponse" round>
<el-icon><Close /></el-icon>
<span>终止回答</span>
</el-button>
</div>
<!-- 加载状态 -->
<div v-if="isThinking && !isLoading" class="thinking">
<div class="loader">
<div></div>
<div></div>
<div></div>
</div>
<span>思考中...</span>
</div>
<div v-if="isLoading" class="thinking">
<div class="loader">
<div></div>
<div></div>
<div></div>
</div>
<span>正在获取回答...</span>
</div>
</div>
<!-- 输入区域 -->
<div class="input-container">
<el-input
v-model="userInput"
type="textarea"
:rows="4"
placeholder="输入您的问题(支持普通文本),按 Ctrl+Enter 发送"
:disabled="isThinking"
@keydown="handleKeydown"
resize="none"
/>
<el-button
class="send-btn"
type="primary"
@click="sendMessage"
:disabled="isThinking || !userInput.trim()"
>
<el-icon>
<el-link type="primary">
<component :is="Link" />
</el-link>
</el-icon>
<span>发送</span>
</el-button>
</div>
</div>
</template>
<style scoped>
/* 新增历史记录面板样式 */
.history-panel {
height: 100%;
padding: 15px;
}
.session-item {
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
background-color: #f8f9fa;
cursor: pointer;
transition: background-color 0.3s;
}
.session-item:hover {
background-color: #e9ecef;
}
.session-title {
font-weight: bold;
margin-bottom: 5px;
color: #212529;
}
.session-date {
font-size: 12px;
color: #6c757d;
}
.empty-history {
text-align: center;
padding: 20px;
}
/* 原有样式保持不变 */
.ai-container {
margin: 0 auto;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
height: 80vh;
display: flex;
flex-direction: column;
}
.ai-header {
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.ai-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: bold;
font-size: 22px;
color: #3498db;
}
.ai-subtitle {
font-size: 14px;
color: #7f8c8d;
}
.chat-container {
flex: 1;
overflow-y: auto;
padding: 10px;
border: 1px solid #eee;
border-radius: 10px;
margin-bottom: 15px;
background: #fafafa;
position: relative;
}
.message {
margin-bottom: 20px;
border-radius: 10px;
padding: 15px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.message-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.message-header > div {
margin-left: 12px;
}
.message-sender {
font-weight: bold;
font-size: 16px;
color: #2c3e50;
}
.message-time {
font-size: 12px;
color: #95a5a6;
}
.message-content {
padding: 10px;
border-radius: 8px;
font-size: 15px;
line-height: 1.6;
}
.message-content.user {
background-color: #e8f4f8;
border-left: 4px solid #3498db;
}
.message-content.ai {
background-color: #f5f7fa;
border-left: 4px solid #9b59b6;
}
.message-content.ai.active {
background-color: #edf6ff;
border-left: 4px solid #3498db;
}
:deep(.markdown-content) h1,
:deep(.markdown-content) h2,
:deep(.markdown-content) h3 {
margin: 15px 0 10px;
color: #2c3e50;
font-weight: bold;
}
:deep(.markdown-content) p {
margin-bottom: 10px;
}
:deep(.markdown-content) ul,
:deep(.markdown-content) ol {
margin-left: 20px;
margin-bottom: 15px;
}
:deep(.markdown-content) li {
margin-bottom: 5px;
}
:deep(.markdown-content) code {
background-color: #f8f8f8;
padding: 2px 6px;
border-radius: 4px;
font-family: Consolas, monospace;
}
:deep(.markdown-content) pre {
background-color: #2c3e50;
color: #ecf0f1;
padding: 10px;
border-radius: 6px;
overflow-x: auto;
margin: 15px 0;
}
:deep(.markdown-content) pre code {
background: none;
padding: 0;
border-radius: 0;
}
:deep(.markdown-content) a {
color: #3498db;
text-decoration: none;
}
:deep(.markdown-content) a:hover {
text-decoration: underline;
}
.input-container {
display: flex;
gap: 10px;
}
.send-btn {
height: 90px;
width: 100px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0;
}
.thinking {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
color: #95a5a6;
}
.stop-response {
display: flex;
justify-content: center;
margin: 10px 0;
}
.avatar-user {
background-color: #3498db;
color: white;
}
.avatar-ai {
background-color: #9b59b6;
color: white;
}
.loader {
display: inline-flex;
gap: 5px;
margin-right: 10px;
}
.loader div {
width: 10px;
height: 10px;
background: #3498db;
border-radius: 50%;
display: inline-block;
animation: bounce 1.4s infinite ease-in-out both;
}
.loader div:nth-child(1) {
animation-delay: -0.32s;
}
.loader div:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
</style>

@ -0,0 +1,143 @@
<script setup>
import { ref } from 'vue'
import { formatDateTime } from '@/utils/format'
import { hotQueryService, hotDelService } from '@/api/hot'
import PhotoShow from '@/components/PhotoShow.vue'
import HotEdit from './components/HotEdit.vue'
import PhotoUpdate from '@/components/PhotoUpdate.vue'
import defaultImg from '@/assets/cover.jpg'
const loading = ref(false)
const hotList = ref()
const params = ref({
current: 1,
size: 10
})
const total = ref(0)
const getHotList = async () => {
loading.value = true
const {
data: { data }
} = await hotQueryService(params.value)
hotList.value = data.list
total.value = data.total
loading.value = false
}
getHotList()
//
const imgRef = ref()
const imgPreview = (imgUrl) => {
imgRef.value.open(imgUrl)
}
//
const editRef = ref()
const onEdit = (row) => {
editRef.value.open(row)
}
//
const uploadRef = ref()
const upload = (id, url) => {
uploadRef.value.open(id, url, 'hot')
}
//
const onDelete = (id) => {
ElMessageBox.confirm('确认删除该条信息?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await hotDelService(id)
ElMessage.success('删除成功')
getHotList()
})
.catch(() => {})
}
const onCurrentChange = (current) => {
params.value.current = current
getHotList()
}
const onSizeChange = (size) => {
params.value.current = 1
params.value.size = size
getHotList()
}
</script>
<template>
<page-container title="轮播事件管理">
<template #extra>
<el-button type="primary" @click="onEdit({ id: 0 })"
>+ 添加活动
</el-button>
</template>
<!-- 表格区域 -->
<el-table :data="hotList" stripe v-loading="loading">
<el-table-column label="场地照片" width="160px">
<template #default="{ row }">
<img
style="
height: 80px;
width: 120px;
cursor: pointer;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
"
:src="
row.imgUrl !== null && row.imgUrl !== '' && row.imgUrl != ' '
? row.imgUrl
: defaultImg
"
:title="row.venueName"
@click="imgPreview(row.imgUrl)"
/>
</template>
</el-table-column>
<el-table-column label="描述" prop="description"></el-table-column>
<el-table-column label="添加时间">
<template #default="{ row }">
{{ formatDateTime(row.addTime) }}
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button link type="success" @click="onEdit(row)" title="编辑描述"
>编辑</el-button
>
<el-button link type="primary" @click="onDelete(row.id)"
>删除</el-button
>
<el-button link type="info" @click="upload(row.id, row.imgUrl)"
>上传图片</el-button
>
</template>
</el-table-column>
<template #empty>
<el-empty description="无热点数据" />
</template>
</el-table>
<!-- 分页区域 -->
<el-pagination
v-model:current-page="params.current"
v-model:page-size="params.size"
:page-sizes="[10, 20, 50]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</page-container>
<!-- 图片预览框 -->
<PhotoShow ref="imgRef"></PhotoShow>
<!-- 事件编辑 -->
<HotEdit ref="editRef" @success="onSizeChange(5)"></HotEdit>
<!-- 上传图片 -->
<PhotoUpdate ref="uploadRef" @success="onSizeChange(5)"></PhotoUpdate>
</template>

@ -0,0 +1,86 @@
<script setup>
import { ref } from 'vue'
import { hotAddService, hotUpdateService } from '@/api/hot'
const dialogVisible = ref(false)
const formModel = ref({
id: '',
description: ''
})
const formRef = ref()
const rules = {
description: [{ required: true, message: '请输入描述信息', trigger: 'blur' }]
}
// open open
const open = (row) => {
if (row.id != 0) {
formModel.value = { ...row }
} else {
formModel.value.id = 0
}
dialogVisible.value = true
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
const onSubmit = async () => {
await formRef.value.validate()
ElMessageBox.confirm('确认提交修改?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
console.log(formModel.value)
formModel.value.id === 0
? await hotAddService(formModel.value)
: await hotUpdateService(formModel.value)
ElMessage.success('编辑成功')
onClose()
emit('success')
})
.catch(() => {})
}
const onClose = () => {
dialogVisible.value = false
formModel.value = {}
formRef.value.clearValidate()
}
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="formModel.id ? '编辑' : '添加'"
width="25%"
:before-close="onClose"
>
<div v-if="formModel.id && formModel.id !== 0">
<img :src="formModel.imgUrl" style="width: 400px; height: 300px" alt="" />
<hr />
</div>
<el-form ref="formRef" :model="formModel" :rules="rules" label-width="40px">
<el-form-item label="描述" prop="description" width="40px">
<el-input
v-model="formModel.description"
:rows="2"
type="textarea"
show-word-limit
placeholder="Please input"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onClose"></el-button>
<el-button type="primary" @click="onSubmit"> </el-button>
</span>
</template>
</el-dialog>
</template>

@ -0,0 +1,102 @@
<script setup>
import { ref } from 'vue'
import { CommentQueryService, CommentWithdrawService } from '@/api/comment'
const params = ref({
current: 1,
size: 10
})
const loading = ref(false)
const total = ref(0)
const commentList = ref([])
const getCommentList = async () => {
loading.value = true
const {
data: { data }
} = await CommentQueryService(params.value)
commentList.value = data.list
total.value = data.total
loading.value = false
}
getCommentList()
const onCurrentChange = (current) => {
params.value.current = current
getCommentList()
}
const onSizeChange = (size) => {
params.value.current = 1
params.value.size = size
getCommentList()
}
const result = ref(false)
const withDrawComment = async (row) => {
ElMessageBox.confirm(
row.status == 2 ? '即将撤回该条评论' : '恢复评论',
'提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'info'
}
)
.then(async () => {
const {
data: { message }
} = await CommentWithdrawService(row.id)
ElNotification({
title: '操作结果',
message: message,
type: 'success'
})
row.status = row.status === 2 ? 1 : 2
})
.catch(() => {})
}
//
const changeStatus = () => {
return result.value
}
</script>
<template>
<page-container title="评论管理">
<el-table :data="commentList" stripe v-loading="loading">
<el-table-column prop="id" label="序号" width="80" />
<el-table-column prop="content" label="评论内容" />
<el-table-column prop="createTime" label="发布时间" />
<el-table-column prop="updateTime" label="修改时间" />
<el-table-column label="操作(是否撤回)">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="2"
active-color="#409EFF"
inactive-color="#dcdfe6"
active-text="已撤回"
inactive-text="恢复"
inline-prompt
@click="withDrawComment(row)"
:before-change="changeStatus"
/>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无数据" />
</template>
</el-table>
<!-- 分页区域 -->
<el-pagination
v-model:current-page="params.current"
v-model:page-size="params.size"
:page-sizes="[10, 20, 50]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</page-container>
</template>

@ -0,0 +1,292 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import Chart from '@/components/Chart.vue'
import { dataQueryService } from '@/api/reservation'
import { ElLoading } from 'element-plus'
import { baseURL } from '@/utils/request.js'
//
const timeRangeOptions = ref([
{ label: '全部数据', value: '全部数据' },
{ label: '今年', value: '今年' },
{ label: '本月', value: '本月' },
{ label: '近三月', value: '近三月' }
])
//
const yearOptions = ref([
{ label: '本年账单', value: 'current' },
{ label: '去年账单', value: 'last' }
])
//
const selectedTimeRange = ref('全部数据')
//
const selectedYear = ref('current')
//
const reservationData = ref([])
const isLoading = ref(true)
//
const loadData = async (timeRange = '全部数据') => {
isLoading.value = true
try {
const {
data: { data }
} = await dataQueryService(timeRange)
//
if (Array.isArray(data)) {
reservationData.value = data
} else {
console.error('数据格式不正确,预期为数组')
reservationData.value = []
}
} catch (error) {
console.error('数据加载失败:', error)
reservationData.value = []
} finally {
isLoading.value = false
}
}
//
watch(selectedTimeRange, (newValue) => {
loadData(newValue)
})
//
const totalReservations = computed(() => reservationData.value.length)
const totalUsers = computed(() => {
const userIds = reservationData.value.map((item) => item.userId)
return [...new Set(userIds)].length
})
const averageDuration = computed(() => {
if (reservationData.value.length === 0) return '0.00'
const durations = reservationData.value.map((item) => {
const start = item.startTime.split(':').map(Number)
const end = item.endTime.split(':').map(Number)
const startInMinutes = start[0] * 60 + start[1]
const endInMinutes = end[0] * 60 + end[1]
return (endInMinutes - startInMinutes) / 60
})
return (durations.reduce((a, b) => a + b, 0) / durations.length).toFixed(2)
})
// 线
const lineOption = computed(() => {
const dailyCounts = getDailyReservationCounts()
return {
title: {
text: `(${getRangeText(selectedTimeRange.value)})每日预约数量趋势`
},
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: Object.keys(dailyCounts) },
yAxis: { type: 'value' },
series: [
{
data: Object.values(dailyCounts),
type: 'line'
}
]
}
})
//
const getDailyReservationCounts = () => {
const counts = {}
reservationData.value.forEach((item) => {
const date = item.reservationDate
counts[date] = (counts[date] || 0) + 1
})
return counts
}
//
const getRangeText = (range) => {
const textMap = {
all: '全部数据',
thisYear: '今年',
thisMonth: '本月',
lastThreeMonths: '近三月'
}
return textMap[range] || range
}
//
const exportPayments = async () => {
const loadingInstance = ElLoading.service({
lock: true,
text: `账单导出中...`,
background: 'rgba(0,0,0,0.7)'
})
try {
//
const iframe = document.createElement('iframe')
iframe.style.display = 'none'
iframe.src = baseURL + `/reservation/payment?year=${selectedYear.value}`
document.body.appendChild(iframe)
// 3
setTimeout(() => {
document.body.removeChild(iframe)
loadingInstance.close()
ElMessage.success(`账单导出成功,即将开始下载`)
}, 3000)
} catch (error) {
loadingInstance.close()
ElMessage.error('导出失败: ' + error.message)
}
}
//
onMounted(() => {
loadData()
})
</script>
<template>
<page-container title="预约数据分析">
<template #extra>
<el-row>
<el-select
v-model="selectedYear"
placeholder="选择年份"
style="width: 150px"
>
<el-option
v-for="option in yearOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button
type="primary"
@click="exportPayments"
style="margin-left: 10px"
>
账单导出
</el-button>
</el-row>
</template>
<!-- 添加时间范围选择下拉框 -->
<el-row :gutter="20" class="mb-30">
<el-col :span="6">
<el-select
v-model="selectedTimeRange"
placeholder="请选择时间范围"
style="width: 200px"
>
<el-option
v-for="option in timeRangeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-col>
</el-row>
<div class="chart-container">
<Chart
:option="lineOption"
width="100%"
height="500px"
v-if="!isLoading"
/>
<div class="data-summary" v-if="!isLoading">
<div class="summary-item">
<span class="label">总预约数</span>
<span class="value">{{ totalReservations }}</span>
</div>
<div class="summary-item">
<span class="label">总用户数</span>
<span class="value">{{ totalUsers }}</span>
</div>
<div class="summary-item">
<span class="label">平均预约时长</span>
<span class="value">{{ averageDuration }} 小时</span>
</div>
</div>
</div>
</page-container>
</template>
<style scoped>
.chart-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
position: relative;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-spinner {
text-align: center;
}
.loading-spinner i {
font-size: 40px;
color: #409eff;
animation: spin 1s linear infinite;
}
.loading-spinner p {
margin-top: 20px;
color: #606266;
font-size: 16px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.data-summary {
margin-top: 30px;
padding: 20px;
background: #f5f7fa;
border-radius: 8px;
display: flex;
justify-content: space-around;
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
}
.label {
font-weight: 500;
color: #606266;
margin-bottom: 8px;
}
.value {
font-weight: 600;
color: #303133;
font-size: 18px;
}
</style>

@ -0,0 +1,248 @@
<script setup>
import { ref } from 'vue'
import { Search } from '@element-plus/icons-vue'
import defaultImg from '@/assets/cover.jpg'
import {
informationQueryService,
informationRemoveService
} from '@/api/information'
import FieldEdit from './components/FiledEdit.vue'
import MoneyEdit from './components/MoneyEdit.vue'
import PhotoShow from '@/components/PhotoShow.vue'
import PhotoUpdate from '@/components/PhotoUpdate.vue'
import { typeNameService } from '@/api/type'
import QRView from './components/QRView.vue'
const loading = ref(false)
const fieldList = ref()
const total = ref(0)
const params = ref({
current: 1,
size: 10,
type: 0,
key: ''
})
//
const typeList = ref([])
const getTypeList = async () => {
typeList.value = []
const {
data: { data }
} = await typeNameService()
typeList.value.push({ label: '所有场馆', value: 0 })
data.forEach((item) => {
typeList.value.push({ label: item.vname, value: item.id })
})
}
getTypeList()
const getFieldList = async () => {
loading.value = true
const {
data: { data }
} = await informationQueryService(params.value)
loading.value = false
fieldList.value = data.list
total.value = data.total
}
getFieldList()
const onCurrentChange = (current) => {
params.value.current = current
getFieldList()
}
const onSizeChange = (size) => {
params.value.current = 1
params.value.size = size
getFieldList()
}
const onReset = () => {
params.value.status = -1
params.value.key = ''
params.value.type = 0
onSizeChange(10)
}
const fieldEditRef = ref()
const onEdit = (row) => {
fieldEditRef.value.open(row, typeList.value.slice(1, 4))
}
const moneyEditRef = ref()
const onSetMoney = (id, price) => {
moneyEditRef.value.open(id, price)
}
//
const imgRef = ref()
const imgPreview = (imgUrl) => {
imgRef.value.open(imgUrl)
}
//
const imgUpdateRef = ref()
const updateImg = (id, imgUrl) => {
imgUpdateRef.value.open(id, imgUrl, 'site')
}
//
const onDelete = (id) => {
ElMessageBox.confirm('确认删除该条场地信息?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await informationRemoveService(id)
ElMessage.success('删除成功')
getFieldList()
})
.catch(() => {})
}
const viewRef = ref()
const openQR = (id, url, name) => {
viewRef.value.open(id, url, name)
}
</script>
<template>
<page-container title="场地信息列表">
<!-- <img :src="imgValue" /> -->
<template #extra>
<el-button type="primary" @click="onEdit"></el-button>
</template>
<!-- 表单区域 -->
<el-form inline>
<el-form-item label="">
<el-input
v-model="params.key"
style="max-width: 600px"
placeholder="请输入场地关键词"
class="input-with-select"
size="default"
>
<template #prepend>
<el-select
v-model="params.type"
placeholder="所有场馆"
style="width: 145px"
@change="onSizeChange(10)"
>
<el-option
v-for="(t, i) of typeList"
:key="i"
:label="t.label"
:value="t.value"
></el-option>
</el-select>
</template>
<template #append>
<el-button @click="getFieldList()" :icon="Search" />
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button @click="onReset"></el-button>
</el-form-item>
</el-form>
<hr />
<!-- 表格区域 -->
<el-table :data="fieldList" stripe v-loading="loading">
<el-table-column type="index" label="序号" width="55vw" />
<el-table-column label="场地照片">
<template #default="{ row }">
<img
style="height: 100px; width: 120px; cursor: pointer"
:src="
row.imgUrl !== null && row.imgUrl !== '' && row.imgUrl != ' '
? row.imgUrl
: defaultImg
"
:title="row.venueName"
@click="imgPreview(row.imgUrl)"
/>
</template>
</el-table-column>
<el-table-column label="场地名称" prop="venueName">
<template #default="{ row }">
<el-link type="primary" :underline="false">{{
row.venueName
}}</el-link>
</template>
</el-table-column>
<el-table-column label="类型" prop="type"></el-table-column>
<el-table-column label="位置" prop="location"></el-table-column>
<el-table-column label="价格(1小时)">
<template #default="{ row }">
{{ row.price !== null ? row.price.toFixed(2) : 0.0 }}
</template>
</el-table-column>
<el-table-column label="操作" width="360vw">
<template #default="{ row }">
<el-button
link
type="success"
@click="onEdit(row)"
title="编辑场地信息"
>编辑</el-button
>
<el-button link type="primary" @click="onDelete(row.id)"
>删除</el-button
>
<el-button
link
type="info"
@click="onSetMoney(row.id, row.price)"
title="调整场地使用价格"
>调整价格</el-button
>
<el-button
link
type="danger"
@click="updateImg(row.id, row.imgUrl)"
title="调整场地图片"
>场地图片</el-button
>
<el-button
link
type="success"
@click="openQR(row.id, row.qrCode, row.venueName)"
title="查看场地二维码图片"
>二维码</el-button
>
</template>
</el-table-column>
<template #empty>
<el-empty description="无场地数据" />
</template>
</el-table>
<!-- 分页区域 -->
<el-pagination
v-model:current-page="params.current"
v-model:page-size="params.size"
:page-sizes="[10, 20, 50]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
<!-- 编辑场地信息 -->
<FieldEdit ref="fieldEditRef" @success="onSizeChange(10)"></FieldEdit>
<!-- 编辑场地的价格信息 -->
<MoneyEdit ref="moneyEditRef" @success="onSizeChange(10)"></MoneyEdit>
<!-- 图片预览框 -->
<PhotoShow ref="imgRef"></PhotoShow>
<!-- 图片修改框 -->
<PhotoUpdate ref="imgUpdateRef" @success="onSizeChange(10)"></PhotoUpdate>
<!-- 二维码查看 -->
<QRView ref="viewRef"></QRView>
</page-container>
</template>

@ -0,0 +1,251 @@
<script setup>
import { ref } from 'vue'
import {
informationCreateService,
informationUpdateService,
typeListService
} from '@/api/information'
const dialogVisible = ref(false)
const formModel = ref({
venueName: '',
type: '',
location: '',
capacity: 10,
typeId: 0
})
const isAdd = ref(false)
const formRef = ref()
const typeList = ref([])
const rules = {
typeId: [{ required: true, message: '请选择对应场馆', trigger: 'blur' }],
venueName: [
{ required: true, message: '请输入场地名称', trigger: 'blur' },
{
pattern: /^\S{1,15}$/,
message: '必须是 1~15 位的非空字符',
trigger: 'blur'
}
],
location: [
{ required: true, message: '请输入场地位置', trigger: 'blur' },
{
pattern: /^\S{1,15}$/,
message: '必须是 1~10 位的非空字符',
trigger: 'blur'
}
],
capacity: [
{ required: true, message: '请输入场地容量', trigger: 'blur' },
{
validator: (rule, value, callback) => {
// value 0
if (value <= 0) {
callback(new Error('场地容量需要大于0'))
} else {
// callback()
callback()
}
},
trigger: 'blur'
}
]
}
//
const typeOptions = ref([])
const getTypeOptions = async () => {
typeOptions.value = []
const {
data: { data }
} = await typeListService()
data.forEach((item) => {
typeOptions.value.push({ label: item, value: item })
})
}
//
const isAdding = ref(false)
const optionName = ref('')
const onAddOption = () => {
isAdding.value = true
}
const onConfirm = () => {
// let isAdd = true
if (optionName.value) {
typeOptions.value.forEach((item) => {
if (item.label === optionName.value) {
ElMessage.error('场地类型已经存在')
isAdding.value = false
}
})
if (isAdding.value) {
typeOptions.value.push({
label: optionName.value,
value: optionName.value
})
}
clear()
}
}
const clear = () => {
optionName.value = ''
isAdding.value = false
}
// open open
const open = (row, types) => {
if (!row.id) {
isAdd.value = true
} else {
formModel.value = { ...row }
}
getTypeOptions()
typeList.value = types
typeList.value.push({ label: '请选择场馆', value: 0 })
dialogVisible.value = true
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
const onSubmit = async () => {
if (formModel.value.typeId === 0) {
ElMessage.info('请选择对应场馆')
return
}
if (formModel.value.type === '') {
ElMessage.info('请选择分类')
return
}
console.log(formModel.value)
await formRef.value.validate()
ElMessageBox.confirm('确认提交?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
isAdd.value
? await informationCreateService(formModel.value)
: await informationUpdateService(formModel.value)
ElMessage.success('编辑成功')
dialogVisible.value = false
isAdd.value = false
emit('success')
formModel.value = {}
})
.catch(() => {})
}
const onClose = () => {
dialogVisible.value = false
formRef.value.clearValidate()
//
formModel.value = {
venueName: '',
type: '',
location: '',
capacity: 10,
typeId: 0
}
}
</script>
<template>
<el-drawer
v-model="dialogVisible"
:title="formModel.id ? '编辑场地' : '添加场地'"
width="30%"
@close="onClose"
>
<el-form
ref="formRef"
:model="formModel"
:rules="rules"
label-width="100px"
style="padding-right: 30px"
>
<el-form-item label="对应场馆" prop="venue">
<el-select
v-model="formModel.typeId"
placeholder="请选择场馆"
style="width: 160px"
>
<el-option
v-for="(t, i) of typeList"
:key="i"
:label="t.label"
:value="t.value"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="场地名称" prop="venueName">
<el-input
placeholder="请输入场地名称"
v-model="formModel.venueName"
style="width: 160px"
></el-input>
</el-form-item>
<el-form-item label="场地分类" prop="type">
<el-select
v-model="formModel.type"
placeholder="请选择场地类型"
style="width: 160px"
>
<el-option
v-for="item in typeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
<template #footer>
<el-button
v-if="!isAdding"
text
bg
size="small"
@click="onAddOption"
>
点击添加其他类型
</el-button>
<template v-else>
<el-input
v-model="optionName"
class="option-input"
placeholder="请输入场地类型"
size="small"
/>
<el-button type="primary" size="small" @click="onConfirm">
确认
</el-button>
<el-button size="small" @click="clear"></el-button>
</template>
</template>
</el-select>
<!-- <el-input placeholder="请输入分类" v-model="formModel.type"></el-input> -->
</el-form-item>
<el-form-item label="位置信息" prop="location">
<el-input
placeholder="请输入位置"
v-model="formModel.location"
style="width: 160px"
></el-input>
</el-form-item>
<!-- <el-form-item label="场地容量" prop="capacity">
<el-input-number v-model="formModel.capacity" :min="1" :max="100">
<template #suffix>
<span>RMB</span>
</template>
</el-input-number>
</el-form-item> -->
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onClose"></el-button>
<el-button type="primary" @click="onSubmit"> </el-button>
</span>
</template>
</el-drawer>
</template>

@ -0,0 +1,96 @@
<script setup>
import { ref } from 'vue'
import { moneyUpdateService } from '@/api/information'
const dialogVisible = ref(false)
const formModel = ref({
venueId: '',
price: 0
})
const formRef = ref()
const rules = {
price: [
{ required: true, message: '请输入费用', trigger: 'blur' },
{
validator: (rule, value, callback) => {
// value 0
if (value <= 0) {
callback(new Error('费用需要 >= 0'))
} else {
// callback()
callback()
}
},
trigger: 'blur'
}
]
}
// open open
const open = (id, price) => {
formModel.value.venueId = id
formModel.value.price = price
console.log(formModel.value)
dialogVisible.value = true
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
const onSubmit = async () => {
await formRef.value.validate()
ElMessageBox.confirm('确认提交修改?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await moneyUpdateService(formModel.value)
ElMessage.success('修改成功')
onClose()
emit('success')
})
.catch(() => {})
}
const onClose = () => {
dialogVisible.value = false
formModel.value.venueId = ''
formModel.value.price = 0
formRef.value.clearValidate()
}
</script>
<template>
<el-dialog v-model="dialogVisible" title="场地费用" width="30%">
<el-form
ref="formRef"
:model="formModel"
:rules="rules"
label-width="100px"
style="padding-right: 30px"
>
<el-form-item label="费用" prop="price">
<el-input-number
v-model="formModel.price"
:precision="2"
:min="1"
:max="100"
>
<template #suffix>
<span>RMB</span>
</template>
</el-input-number>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onClose"></el-button>
<el-button type="primary" @click="onSubmit"> </el-button>
</span>
</template>
</el-dialog>
</template>

@ -0,0 +1,124 @@
<script setup>
import { ref } from 'vue'
import defaultImg from '@/assets/cover.jpg'
import { QRDownload } from '@/api/information'
const dialogVisible = ref(false)
const imgUrl = ref('')
const nameValue = ref('')
const selectId = ref('')
const open = (id, url, name) => {
selectId.value = id
imgUrl.value = url
nameValue.value = name
dialogVisible.value = true
}
const download = async () => {
try {
await ElMessageBox.confirm('确认下载二维码?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'info',
center: true
})
await QRDownload(selectId.value, nameValue.value)
} catch {
//
}
}
defineExpose({ open })
</script>
<template>
<el-dialog
v-model="dialogVisible"
title="场地二维码"
class="qr-preview-dialog"
@close="imgUrl = ''"
>
<div class="image-container">
<el-image
:src="imgUrl || defaultImg"
:preview-src-list="[imgUrl]"
fit="contain"
class="preview-image"
hide-on-click-modal
>
<template #error>
<div class="image-error">
<el-icon :size="40"><Picture /></el-icon>
<span>图片加载失败</span>
</div>
</template>
</el-image>
</div>
<template #footer>
<el-button type="primary" plain :disabled="!imgUrl" @click="download">
<el-icon><Download /></el-icon>
<span>下载二维码</span>
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.qr-preview-dialog {
--dialog-max-width: min(90vw, 800px);
}
.image-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
padding: 12px;
border-radius: 8px;
background: #f8f9fa;
border: 1px solid #ebeef5;
}
.preview-image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border-radius: 4px;
}
.image-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #c0c4cc;
height: 200px;
gap: 8px;
}
:deep(.el-dialog) {
border-radius: 10px;
overflow: hidden;
}
:deep(.el-dialog__header) {
background: linear-gradient(120deg, #e0f2ff 0%, #f0f7ff 100%);
margin-right: 0;
padding: 16px 20px;
border-bottom: 1px solid #dcdfe6;
}
:deep(.el-dialog__title) {
color: #1c3f6e;
font-weight: 600;
}
:deep(.el-dialog__footer) {
padding: 16px 20px;
border-top: 1px solid #ebeef5;
text-align: center;
}
</style>

@ -0,0 +1,87 @@
<script setup>
import { ref } from 'vue'
import { hotAddService, hotUpdateService } from '@/api/hot'
const dialogVisible = ref(false)
const formModel = ref({
id: '',
description: ''
})
const formRef = ref()
const rules = {
id: [{ required: true, message: '请输入费用', trigger: 'blur' }],
description: [{ required: true, message: '请输入费用', trigger: 'blur' }]
}
// open
const open = (row) => {
if (row.id != 0) {
formModel.value = { ...row }
} else {
formModel.value.id = 0
}
dialogVisible.value = true
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
const onSubmit = async () => {
await formRef.value.validate()
ElMessageBox.confirm('确认提交修改?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
console.log(formModel.value)
formModel.value.id === 0
? await hotAddService(formModel.value)
: await hotUpdateService(formModel.value)
ElMessage.success('编辑成功')
onClose()
emit('success')
})
.catch(() => {})
}
const onClose = () => {
dialogVisible.value = false
formModel.value = {}
formRef.value.clearValidate()
}
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="formModel.id ? '编辑' : '添加'"
width="25%"
:before-close="onClose"
>
<div v-if="formModel.id && formModel.id !== 0">
<img :src="formModel.imgUrl" style="width: 400px; height: 300px" alt="" />
<hr />
</div>
<el-form ref="formRef" :model="formModel" :rules="rules" label-width="40px">
<el-form-item label="描述" prop="description" width="40px">
<el-input
v-model="formModel.description"
:rows="2"
type="textarea"
show-word-limit
placeholder="Please input"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onClose"></el-button>
<el-button type="primary" @click="onSubmit"> </el-button>
</span>
</template>
</el-dialog>
</template>

@ -0,0 +1,291 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { formatTime } from '@/utils/format'
import { Link, Warning, Connection } from '@element-plus/icons-vue'
import { MoneyService } from '@/api/hot'
import { userNum } from '@/api/user'
const router = useRouter()
//
const money = ref(0)
const users = ref(0)
// - 使
const venueUsage = ref([
{ name: '教学楼A', percent: 75 },
{ name: '图书馆', percent: 42 },
{ name: '体育馆', percent: 92 },
{ name: '实验楼', percent: 28 }
])
// -
const faultyDevices = ref([
{ name: '空调机组A3-1', time: '10:23' },
{ name: '闸机B2-5', time: '11:15' },
{ name: '灯光系统C1', time: '13:47' }
])
// - 线
const onlineDevices = ref([
{ name: '门禁系统A', status: true },
{ name: '监控摄像头B', status: true },
{ name: '智能电表C', status: false },
{ name: '消防报警器D', status: true }
])
//
const getProgressColor = (percent) => {
if (percent < 50) return '#67c23a'
if (percent < 80) return '#e69c19'
return '#f56c6c'
}
//
const getUsers = async () => {
//
const {
data: { data }
} = await userNum()
users.value = data
}
//
const getMoney = async () => {
//
const {
data: { data }
} = await MoneyService()
money.value = Number(data)
}
//
onMounted(async () => {
getUsers()
getMoney()
})
</script>
<template>
<page-container title="首页">
<!-- 主体看板区域 -->
<div class="dashboard-container">
<!-- 场馆总数 -->
<el-card class="dashboard-card">
<div class="card-content">
<div class="title">场馆总数</div>
<div class="value">
3
<el-icon
style="vertical-align: middle; cursor: pointer"
@click="router.push('/type/manage')"
>
<Link />
</el-icon>
</div>
</div>
</el-card>
<!-- 交易总额 -->
<el-card class="dashboard-card">
<div class="card-content">
<div class="title">交易总额 / </div>
<div class="value">
¥{{ money.toFixed(2) }}
<span class="time-tag">{{ formatTime(new Date()) }}</span>
</div>
</div>
</el-card>
<!-- 注册用户 -->
<el-card class="dashboard-card">
<div class="card-content">
<div class="title">累计注册用户</div>
<div class="value">{{ users }}</div>
</div>
</el-card>
<!-- 场馆使用率 -->
<el-card class="dashboard-card usage-card">
<template #header>
<div class="card-header">
<span>场馆实时使用率</span>
</div>
</template>
<div
v-for="(item, index) in venueUsage"
:key="index"
class="usage-item"
>
<div class="venue-name">{{ item.name }}</div>
<el-progress
:percentage="item.percent"
:color="getProgressColor(item.percent)"
:show-text="false"
/>
<div class="percent-text">{{ item.percent }}%</div>
</div>
</el-card>
<!-- 故障设备预警 -->
<el-card class="dashboard-card alert-card">
<template #header>
<div class="card-header">
<el-icon><Warning /></el-icon>
<span style="margin-left: 8px; color: #f56c6c">故障设备预警</span>
</div>
</template>
<div
v-for="(device, index) in faultyDevices"
:key="index"
class="alert-item"
>
<el-tag type="danger" class="device-tag"></el-tag>
<span class="device-name">{{ device.name }}</span>
<span class="device-time">{{ device.time }}</span>
</div>
</el-card>
<!-- 设备在线状态 -->
<el-card class="dashboard-card status-card">
<template #header>
<div class="card-header">
<el-icon><Connection /></el-icon>
<span style="margin-left: 8px">关键设备在线状态</span>
</div>
</template>
<div
v-for="(device, index) in onlineDevices"
:key="index"
class="status-item"
>
<el-switch
v-model="device.status"
:active-color="device.status ? '#1890ff' : '#f56c6c'"
:inactive-color="white"
/>
<span class="device-name">{{ device.name }}</span>
</div>
</el-card>
</div>
</page-container>
</template>
<style scoped>
.dashboard-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 20px;
padding: 20px;
}
.dashboard-card {
flex: 1 1 30%;
min-width: 280px;
height: auto;
border-radius: 12px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.dashboard-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.card-content {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
padding: 20px;
}
.title {
font-size: 16px;
color: #666;
font-weight: 500;
}
.value {
font-size: 28px;
font-weight: bold;
color: #333;
display: flex;
align-items: center;
gap: 10px;
}
.time-tag {
font-size: 12px;
padding: 2px 6px;
background: #f0f9eb;
color: #67c23a;
border-radius: 4px;
}
/* 场馆使用率样式 */
.usage-card .usage-item {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 10px;
}
.venue-name {
width: 100px;
font-weight: 500;
color: #333;
}
.percent-text {
font-size: 14px;
color: #999;
min-width: 40px;
text-align: right;
}
/* 故障预警样式 */
.alert-card .alert-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.device-tag {
width: 20px;
height: 20px;
font-size: 14px;
line-height: 20px;
text-align: center;
}
.device-name {
flex: 1;
color: #f56c6c;
font-weight: 500;
}
.device-time {
font-size: 12px;
color: #999;
}
/* 设备状态样式 */
.status-card .status-item {
display: flex;
align-items: center;
margin-bottom: 12px;
gap: 10px;
}
.device-name {
color: #333;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
</style>

@ -0,0 +1,393 @@
<script setup>
import {
Management,
Promotion,
User,
SwitchButton,
CaretBottom,
Timer,
Ship,
HomeFilled,
Connection,
Clock,
EditPen,
Comment,
Menu,
Avatar,
MessageBox,
ChatDotRound,
Service
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores'
import PasswdUpdate from '@/components/PasswdUpdate.vue'
import { ref } from 'vue'
import router from '@/router'
import UserImg from '@/assets/default.png'
const userStore = useUserStore()
const passwdUpdate = ref()
const isExpand = ref(true)
const menuWidth = ref(180)
const loading = ref(false) //
const userInfo = ref({ ...userStore.user })
const handleCommand = async (key) => {
if (key === 'logout') {
await handleLogout()
} else if (key === 'password') {
passwdUpdate.value.open(userStore.user.id)
} else {
await handleNavigation(`/user/${key}`)
}
}
const handleLogout = async () => {
try {
loading.value = true //
await ElMessageBox.confirm('确认退出吗?', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
// 退
const loadingInstance = ElLoading.service({
lock: true,
text: '账号退出中...',
background: 'rgba(0, 0, 0, 0.7)'
})
//
await new Promise((resolve) => setTimeout(resolve, 500))
//
userStore.setToken('')
userStore.setUser({})
//
await router.push('/login')
// loading
loadingInstance.close()
ElMessage.success('已安全退出')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('退出失败')
}
} finally {
loading.value = false
}
}
const handleNavigation = async (path) => {
try {
loading.value = true
const loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)'
})
await router.push(path)
//
await new Promise((resolve) => setTimeout(resolve, 100))
loadingInstance.close()
} catch (error) {
ElMessage.error('导航失败')
} finally {
loading.value = false
}
}
const reset = async () => {
loading.value = true
try {
const loadingInstance = ElLoading.service({
lock: true,
text: '重新登录中...',
background: 'rgba(0, 0, 0, 0.7)'
})
userStore.setToken('')
userStore.setUser({})
await router.push('/login')
loadingInstance.close()
ElMessage.info('请重新登录')
} finally {
loading.value = false
}
}
const handleChange = () => {
isExpand.value = !isExpand.value
menuWidth.value = isExpand.value ? 180 : 80
}
</script>
<template>
<!--
el-menu 整个菜单组件
:default-active="$route.path" 配置默认高亮的菜单项
router router选项开启el-menu-item index 就是点击跳转的路径
el-menu-item 菜单项
index="" 配置的是访问的跳转路径配合 default-active的值实现高亮
-->
<el-container class="layout-container">
<el-aside :width="menuWidth + 'px'">
<div
style="
color: white;
line-height: 60px;
padding-left: 15px;
font-size: 20px;
font-weight: 700;
border-bottom: 1px solid black;
"
>
<span v-show="isExpand"> </span>
</div>
<el-menu
name="menu-expand"
:collapse="!isExpand"
:collapse-transition="false"
active-text-color="#ffd04b"
background-color="rgb(47, 64, 80)"
:default-active="$route.path"
text-color="#fff"
router
show-timeout
hide-timeout
>
<el-menu-item index="/index">
<el-icon><HomeFilled /></el-icon>
<span>系统首页</span>
</el-menu-item>
<el-menu-item index="/carousel">
<el-icon><Connection /></el-icon>
<span>轮播管理</span>
</el-menu-item>
<el-menu-item index="/type/manage">
<el-icon><Management /></el-icon>
<span>场馆管理</span>
</el-menu-item>
<el-menu-item index="/field/manage">
<el-icon><Ship /></el-icon>
<span>场地管理</span>
</el-menu-item>
<el-menu-item index="/time/manage">
<el-icon><Timer /></el-icon>
<span>场地禁用</span>
</el-menu-item>
<el-sub-menu index="/comment">
<template #title>
<el-icon><Comment /></el-icon>
<span>评论管理</span>
</template>
<el-menu-item index="/comment/user">
<el-icon><Comment /></el-icon>
<span>用户评论</span>
</el-menu-item>
<el-menu-item index="/comment/word">
<el-icon><Comment /></el-icon>
<span>敏感词</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/user">
<template #title>
<el-icon><User /></el-icon>
<span>用户管理</span>
</template>
<el-menu-item index="/user/manage">
<el-icon><User /></el-icon>
<span>普通用户</span>
</el-menu-item>
<el-menu-item index="/user/admin">
<el-icon><User /></el-icon>
<span>管理员用户</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/data/visible">
<el-icon><Promotion /></el-icon>
<span>数据可视化</span>
</el-menu-item>
<el-menu-item index="/system/log">
<el-icon><Clock /></el-icon>
<span>系统日志</span>
</el-menu-item>
<el-menu-item index="/user/info">
<el-icon><Avatar /></el-icon>
<span>个人中心</span>
</el-menu-item>
<el-menu-item index="/message/box">
<el-icon><MessageBox /></el-icon>
<span>设备日志</span>
</el-menu-item>
<el-menu-item index="/message/netty">
<el-icon><ChatDotRound /></el-icon>
<span>聊天室</span>
</el-menu-item>
<el-menu-item index="/ai">
<el-icon><Service /></el-icon>
<span>ai问答</span>
</el-menu-item>
<!-- <el-sub-menu index="/user">
<template #title>
<el-icon><UserFilled /></el-icon>
<span>日志信息</span>
</template>
<el-menu-item index="/user/profile">
<el-icon><User /></el-icon>
<span>登录日志</span>
</el-menu-item>
<el-menu-item index="/user/profile">
<el-icon><User /></el-icon>
<span>系统日志</span>
</el-menu-item>
</el-sub-menu> -->
</el-menu>
</el-aside>
<el-container>
<el-header>
<div>
<div class="header-menu" @click="handleChange">
<el-icon><Menu /></el-icon>
</div>
当前账号<strong>{{
userStore.user.account || userStore.user.username
}}</strong>
</div>
<el-dropdown placement="bottom-end" @command="handleCommand">
<span class="el-dropdown__box">
<img
:src="userInfo.isUpload === 0 ? UserImg : userStore.user.avatar"
alt="用户头像"
style="width: 3vw; height: 5vh; border-radius: 5vw"
/>
<el-icon><CaretBottom /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="info" :icon="User"
>基本资料</el-dropdown-item
>
<!-- <el-dropdown-item command="avatar" :icon="Crop"
>更换头像</el-dropdown-item
> -->
<el-dropdown-item command="password" :icon="EditPen"
>重置密码</el-dropdown-item
>
<el-dropdown-item command="logout" :icon="SwitchButton"
>退出登录</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main>
<router-view></router-view>
</el-main>
<!-- <el-footer>{{ formatTime(new Date()) }}</el-footer> -->
</el-container>
</el-container>
<PasswdUpdate ref="passwdUpdate" @success="reset"> </PasswdUpdate>
</template>
<style lang="scss" scoped>
.layout-container {
height: 100vh;
.el-aside {
background-color: rgb(47, 64, 80);
&__logo {
height: 100px;
background: url('@/assets/login_bg.jpg') no-repeat center / 120px auto;
}
.el-menu {
border-right: none;
overflow-y: auto; //
overflow-x: hidden; //
padding-bottom: 20px; //
&::-webkit-scrollbar {
//
width: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
}
}
.el-header {
background-color: #fff;
display: flex;
height: 8vh;
align-items: center;
justify-content: space-between;
.el-dropdown__box {
display: flex;
align-items: center;
.el-icon {
color: #999;
margin-left: 10px;
}
&:active,
&:focus {
outline: none;
}
}
}
.el-footer {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #666;
}
}
.header-menu {
float: left;
margin-right: 10px;
font-size: 20px;
cursor: pointer;
}
.header-menu:hover {
color: skyblue;
}
.aside-transition {
transition: width 0.5s ease;
}
/* 菜单文字淡入淡出效果 */
.menu-expand-enter-active,
.menu-expand-leave-active {
transition:
opacity 0.4s ease,
max-width 1s ease;
}
.menu-expand-enter-from,
.menu-expand-leave-to {
opacity: 0;
max-width: 0;
}
.menu-expand-enter-to,
.menu-expand-leave-from {
opacity: 1;
max-width: 100px;
}
/* 菜单项文字容器 */
.el-menu-item span,
.el-sub-menu__title span {
display: inline-block;
overflow: hidden;
white-space: nowrap;
vertical-align: bottom;
}
/* 修复折叠菜单滚动条导致的宽度变化 */
:deep(.el-menu--collapse) {
width: 80px !important;
min-width: 80px !important;
max-width: 80px !important;
transition: width 0.3s ease;
}
</style>

@ -0,0 +1,70 @@
<script setup>
import { ref } from 'vue'
import { logQueryService } from '@/api/log'
const params = ref({
current: 1,
size: 10
})
const key = ref('')
const total = ref(0)
const loading = ref(false)
const logList = ref([])
const getLogs = async () => {
loading.value = true
const {
data: { data }
} = await logQueryService(params.value, key.value)
logList.value = data.list
total.value = data.total
loading.value = false
}
getLogs()
const onCurrentChange = (current) => {
params.value.current = current
getLogs()
}
const onSizeChange = (size) => {
params.value.current = 1
params.value.size = size
getLogs()
}
</script>
<template>
<page-container title="系统日志">
<template #extra>
<!-- <el-button type="primary" @click="onEdit"></el-button> -->
</template>
<!-- 表格区域 -->
<el-table :data="logList" stripe v-loading="loading">
<el-table-column type="index" label="序号" width="60" />
<el-table-column label="操作类型" prop="operationType" width="150">
</el-table-column>
<el-table-column
label="操作描述"
prop="operationDesc"
width="350"
></el-table-column>
<el-table-column label="结果" prop="result"></el-table-column>
<el-table-column label="执行时间(毫秒)" prop="time"></el-table-column>
<el-table-column label="状态" prop="status"></el-table-column>
<el-table-column label="记录时间" prop="createTime"></el-table-column>
<template #empty>
<el-empty description="无数据" />
</template>
</el-table>
<!-- 分页区域 -->
<el-pagination
v-model:current-page="params.current"
v-model:page-size="params.size"
:page-sizes="[10, 20, 50]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</page-container>
</template>

@ -0,0 +1,993 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { User, Lock, Bell } from '@element-plus/icons-vue'
import { userLoginService } from '@/api/user'
import { useUserStore } from '@/stores'
import { useRouter } from 'vue-router'
//
const loginFormRef = ref(null)
//
const currentStep = ref(0)
//
const loginForm = reactive({
account: '',
passwd: '',
verifyCode: '',
role: '',
rememberMe: false
})
//
const userStore = useUserStore()
//
const router = useRouter()
//
const roles = reactive([
{ id: 'user', name: '普通用户', desc: '学生/教师账号', icon: 'el-icon-user' },
{
id: 'admin',
name: '管理员',
desc: '场馆管理员账号',
icon: 'el-icon-s-management'
},
{
id: 'superAdmin',
name: '超级管理员',
desc: '系统超级管理员',
icon: 'el-icon-s-platform'
}
])
//
const selectedRole = ref('')
//
const loginRules = ref({
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
passwd: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少为6位', trigger: 'blur' }
],
verifyCode: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value.toLowerCase() !== verifyCodeActual.value.toLowerCase()) {
callback(new Error('验证码不正确'))
} else {
callback()
}
},
trigger: 'blur'
}
]
})
//
const loading = ref(false)
//
const verifyCodeImage = ref('')
const verifyCodeActual = ref('')
//
const selectRole = (roleId) => {
selectedRole.value = roleId
loginForm.role = roleId
}
//
const getRoleName = () => {
const role = roles.find((r) => r.id === selectedRole.value)
return role ? role.name : ''
}
//
const getRolePlaceholderText = () => {
const role = roles.find((r) => r.id === selectedRole.value)
if (!role) return '请输入账号'
switch (role.id) {
case 'user':
return '请输入用户账号'
case 'admin':
return '请输入管理员账号'
case 'superAdmin':
return '请输入超级管理员账号'
default:
return '请输入账号'
}
}
//
const nextStep = () => {
if (!selectedRole.value) {
ElMessage.warning('请选择您的身份')
return
}
currentStep.value++
refreshVerifyCode() //
}
//
const prevStep = () => {
currentStep.value--
}
//
const generateVerifyCode = () => {
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
let result = ''
for (let i = 0; i < 4; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
//
const createVerifyCodeImage = (text) => {
const width = 120
const height = 40
// 线
let lines = ''
for (let i = 0; i < 3; i++) {
const x1 = Math.random() * width
const y1 = Math.random() * height
const x2 = Math.random() * width
const y2 = Math.random() * height
const color = `rgb(${Math.random() * 100}, ${Math.random() * 100}, ${Math.random() * 100})`
lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="1" />`
}
//
let textElements = ''
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i)
const x = 20 + i * 20 + Math.random() * 5
const y = 25 + Math.random() * 10
const rotate = -10 + Math.random() * 20
const color = `rgb(${Math.random() * 150}, ${Math.random() * 150}, ${Math.random() * 150})`
textElements += `<text x="${x}" y="${y}" font-size="20" transform="rotate(${rotate}, ${x}, ${y})" fill="${color}" font-family="Arial, sans-serif">${char}</text>`
}
// SVG
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#f5f7fa" />
${lines}
${textElements}
</svg>`
// Base64
return btoa(unescape(encodeURIComponent(svg)))
}
//
const refreshVerifyCode = () => {
loginForm.verifyCode = ''
verifyCodeActual.value = generateVerifyCode()
verifyCodeImage.value = createVerifyCodeImage(verifyCodeActual.value)
}
//
const handleLogin = () => {
//
const loadingInstance = ElLoading.service({
lock: true,
text: '登录中...',
background: 'rgba(0, 0, 0, 0.7)'
})
try {
loginFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
if (
loginForm.verifyCode.toLowerCase() !==
verifyCodeActual.value.toLowerCase()
) {
ElMessage.error('验证码不正确,已刷新')
refreshVerifyCode()
loading.value = false
return
}
const {
data: { data }
} = await userLoginService(loginForm)
console.log(data)
//
const roleVal = getRoleName()
if (
(data.user.type === 4 && roleVal != '超级管理员') ||
(data.user.type === 3 && roleVal != '管理员')
) {
ElMessage.error('身份不匹配,请重新选择身份信息')
} else {
//
await new Promise((resolve) => setTimeout(resolve, 500))
//
userStore.setUser(data.user)
userStore.setToken(data.token)
//
if (data.user.type === 4) {
router.push('/index')
} else {
router.push('/mobile/index')
}
//
ElMessage.success({
message: '登录成功',
duration: 1500,
showClose: true
})
}
//
loginFormRef.value.resetFields()
currentStep.value = 0
selectedRole.value = ''
loading.value = false
//
// router.push({ name: 'Dashboard', params: { role: loginForm.role } })
} else {
loading.value = false
loadingInstance.close()
return false
}
})
} catch (error) {
loading.value = false
loadingInstance.close()
ElMessage.error('登录失败,请稍后重试')
} finally {
//
loadingInstance.close()
loading.value = false
}
}
//
onMounted(() => {
refreshVerifyCode()
userStore.setUser({})
userStore.setToken('')
})
</script>
<template>
<div class="login-container">
<!-- 动态渐变背景 -->
<div class="bg-gradient"></div>
<!-- 浮动装饰元素 -->
<div class="floating-elements">
<div class="float-element element-1"></div>
<div class="float-element element-2"></div>
<div class="float-element element-3"></div>
<div class="float-element element-4"></div>
</div>
<!-- 主内容区 -->
<div class="content-wrapper">
<!-- 居中面板 -->
<div class="centered-panel">
<!-- 左侧系统介绍 -->
<div class="panel-left">
<div class="panel-content">
<div class="art-title">
<h1>校园 e 站通</h1>
<p class="sub-title">华交场馆管控一体化平台</p>
</div>
<div class="system-info">
<p class="system-desc">
校园e站通是华东交通大学为提升场馆资源管理效率和服务质量打造的智能化场馆管控平台通过先进的信息化技术实现场馆预约设备管理数据分析等功能的一体化管理
</p>
<div class="features">
<div class="feature-item">
<div class="feature-icon">
<i class="el-icon-s-marketing"></i>
</div>
<div class="feature-content">
<h3>智能场馆预约</h3>
<p>实时查看场馆占用状态一键预约空闲场地</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">
<i class="el-icon-s-data"></i>
</div>
<div class="feature-content">
<h3>资源动态管理</h3>
<p>设备使用记录追踪耗材库存智能预警</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">
<i class="el-icon-s-check"></i>
</div>
<div class="feature-content">
<h3>使用数据分析</h3>
<p>场馆使用频率统计资源优化配置建议</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧登录区 -->
<div class="panel-right">
<div class="login-box">
<div class="login-header">
<div class="logo">
<img src="@/assets/login-avatar.jpg" alt="校园e站通logo" />
</div>
<h2 class="login-title">欢迎登录</h2>
</div>
<!-- 优化后的时间线 -->
<div class="enhanced-timeline">
<div
class="timeline-step"
:class="{ 'is-active': currentStep === 0 }"
>
<div class="step-circle">
<i class="el-icon-user-solid"></i>
</div>
<div class="step-text">
<div class="step-title">选择身份</div>
<div class="step-desc">请选择您的用户角色</div>
</div>
<div
class="step-line"
:class="{ 'line-active': currentStep >= 1 }"
></div>
</div>
<div
class="timeline-step"
:class="{ 'is-active': currentStep === 1 }"
>
<div class="step-circle">
<i class="el-icon-document"></i>
</div>
<div class="step-text">
<div class="step-title">账号登录</div>
<div class="step-desc">请输入账号密码登录</div>
</div>
</div>
</div>
<!-- 登录步骤内容 -->
<div class="login-steps">
<!-- 步骤1选择身份 -->
<div class="login-step" v-show="currentStep === 0">
<h3 class="step-title">请选择您的身份</h3>
<div class="role-options">
<el-card
v-for="role in roles"
:key="role.id"
:class="{ 'role-card-selected': selectedRole === role.id }"
@click="selectRole(role.id)"
shadow="hover"
>
<div class="role-content">
<div class="role-icon">
<i :class="role.icon"></i>
</div>
<div class="role-info">
<div class="role-name">{{ role.name }}</div>
</div>
<div class="role-desc">{{ role.desc }}</div>
</div>
</el-card>
</div>
<div class="step-actions">
<el-button
type="primary"
@click="nextStep"
:loading="loading"
:disabled="!selectedRole"
>
下一步
</el-button>
</div>
</div>
<!-- 步骤2账号密码 -->
<div class="login-step" v-show="currentStep === 1">
<h3 class="step-title">请输入登录信息</h3>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
label-position="left"
label-width="0px"
>
<el-form-item prop="account">
<el-input
:prefix-icon="User"
v-model="loginForm.account"
:placeholder="getRolePlaceholderText()"
clearable
>
</el-input>
</el-form-item>
<el-form-item prop="passwd">
<el-input
:prefix-icon="Lock"
v-model="loginForm.passwd"
type="password"
placeholder="请输入密码"
show-password
clearable
>
</el-input>
</el-form-item>
<el-form-item prop="verifyCode">
<div class="verify-code-wrapper">
<el-input
:prefix-icon="Bell"
v-model="loginForm.verifyCode"
placeholder="请输入验证码"
clearable
style="width: 70%; margin-right: 15px"
>
</el-input>
<div
class="verify-code"
@click="refreshVerifyCode"
:style="{
backgroundImage: `url('data:image/svg+xml;base64,${verifyCodeImage}')`
}"
>
<span v-if="!verifyCodeImage"></span>
</div>
</div>
</el-form-item>
<el-form-item>
<el-checkbox v-model="loginForm.rememberMe"
>记住密码</el-checkbox
>
<el-link
href="#"
type="primary"
class="float-right"
@click="router.push('/password/reset')"
>忘记密码?</el-link
>
</el-form-item>
<div class="step-actions">
<el-button @click="prevStep"></el-button>
<el-button
type="primary"
@click="handleLogin"
:loading="loading"
>
登录
</el-button>
</div>
</el-form>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 页脚 -->
<div class="footer">
<p>© 2025 华东交通大学 | 校园e站通华交场馆管控一体化平台</p>
<p>建议使用ChromeFirefoxEdge等现代浏览器访问本系统</p>
</div>
</div>
</template>
<style scoped>
/* 基础样式 */
.login-container {
position: relative;
width: 100%;
min-height: 100vh;
overflow: hidden;
}
/* 动态渐变背景 */
.bg-gradient {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1e88e5, #43a047, #00acc1);
background-size: 200% 200%;
animation: gradientMove 15s ease infinite;
}
@keyframes gradientMove {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 浮动装饰元素 */
.floating-elements {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
}
.float-element {
position: absolute;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 15s infinite ease-in-out;
}
.element-1 {
width: 200px;
height: 200px;
top: -100px;
left: -100px;
animation-delay: 0s;
}
.element-2 {
width: 150px;
height: 150px;
bottom: -75px;
right: -75px;
animation-delay: 2s;
}
.element-3 {
width: 100px;
height: 100px;
top: 30%;
right: 15%;
animation-delay: 4s;
}
.element-4 {
width: 120px;
height: 120px;
bottom: 20%;
left: 10%;
animation-delay: 6s;
}
@keyframes float {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
/* 主内容区 */
.content-wrapper {
position: relative;
z-index: 1;
width: 100%;
min-height: 95vh;
display: flex;
flex-direction: column;
justify-content: center;
}
/* 居中面板 */
.centered-panel {
display: flex;
flex-direction: column;
width: 100%;
max-width: 1200px;
margin: 0 auto;
background-color: white;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
/* 左侧面板 - 系统介绍 */
.panel-left {
flex: 1;
padding: 40px;
background: linear-gradient(135deg, #1e88e5, #00acc1);
color: white;
display: flex;
align-items: center;
}
.panel-content {
width: 100%;
}
.art-title {
text-align: center;
margin-bottom: 40px;
}
.art-title h1 {
font-size: 36px;
font-weight: bold;
margin-bottom: 10px;
position: relative;
display: inline-block;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.art-title h1::after {
content: '';
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 3px;
background: rgba(255, 255, 255, 0.5);
}
.sub-title {
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
margin-top: 20px;
letter-spacing: 1px;
}
.system-info {
margin-bottom: 40px;
}
.system-desc {
font-size: 16px;
line-height: 1.6;
margin-bottom: 30px;
text-align: center;
}
.features {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.feature-item {
display: flex;
align-items: center;
padding: 15px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
transition: all 0.3s ease;
}
.feature-item:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateX(5px);
}
.feature-icon {
font-size: 24px;
margin-right: 15px;
width: 30px;
text-align: center;
}
.feature-content h3 {
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
}
.feature-content p {
font-size: 14px;
opacity: 0.8;
}
.system-image {
display: none;
margin-top: 40px;
text-align: center;
}
.system-image img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
/* 右侧面板 - 登录区 */
.panel-right {
flex: 1;
padding: 40px;
display: flex;
align-items: center;
}
.login-box {
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.logo {
margin-bottom: 20px;
}
.logo img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.login-title {
font-size: 24px;
font-weight: bold;
color: #333;
}
/* 优化后的时间线 */
.enhanced-timeline {
display: flex;
margin-bottom: 40px;
}
.timeline-step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.step-circle {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #e4e7ed;
color: #909399;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
z-index: 2;
transition: all 0.3s ease;
}
.is-active .step-circle {
background-color: #1e88e5;
color: white;
transform: scale(1.1);
box-shadow: 0 0 15px rgba(30, 136, 229, 0.5);
}
.step-text {
margin-top: 10px;
text-align: center;
}
.step-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.step-desc {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.is-active .step-title {
color: #1e88e5;
}
.is-active .step-desc {
color: #666;
}
.step-line {
position: absolute;
top: 24px;
left: 50%;
width: 100%;
height: 2px;
background-color: #e4e7ed;
z-index: 1;
}
.line-active {
background-color: #1e88e5;
transition: all 0.5s ease;
}
/* 登录步骤 */
.login-step {
min-height: 250px;
}
.step-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
text-align: center;
color: #303133;
}
/* 角色选项 */
.role-options {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
margin-bottom: 30px;
}
.role-card-selected {
border-color: #1e88e5 !important;
background-color: #f5f7fa;
}
.role-card-selected .role-icon i {
color: #1e88e5;
}
.role-card-selected .role-name {
color: #1e88e5;
}
.role-card-selected::after {
content: '✓';
position: absolute;
right: 10px;
top: 10px;
width: 16px;
height: 16px;
background-color: #1e88e5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: bold;
}
.role-content {
display: flex;
align-items: center;
}
.role-icon {
font-size: 24px;
margin-right: 15px;
width: 30px;
text-align: center;
}
.role-name {
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
}
.role-desc {
font-size: 12px;
color: #909399;
margin-left: auto;
margin-right: 5vw;
}
.step-actions {
display: flex;
justify-content: space-between;
margin-top: 30px;
}
.step-actions button {
flex: 1;
margin: 0 5px;
}
.verify-code-wrapper {
display: flex;
align-items: center;
}
.verify-code {
flex: 1;
height: 40px;
width: 10vw;
border-radius: 4px;
background-color: #f5f7fa;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid #dcdfe6;
}
.float-right {
margin-left: 15vw;
}
/* 页脚 */
.footer {
color: white;
text-align: center;
opacity: 0.8;
font-size: 14px;
margin-top: 20px;
}
/* 响应式布局 */
@media (min-width: 768px) {
.centered-panel {
flex-direction: row;
}
.panel-left,
.panel-right {
min-height: 500px;
}
.features {
grid-template-columns: 1fr;
}
.system-image {
display: block;
}
.enhanced-timeline {
margin-bottom: 50px;
}
.step-line {
width: calc(100% - 48px);
}
}
@media (min-width: 1024px) {
.features {
grid-template-columns: 1fr;
}
.art-title h1 {
font-size: 42px;
}
.sub-title {
font-size: 20px;
}
}
</style>

@ -0,0 +1,183 @@
<script setup>
import { ref } from 'vue'
import { Plus, Delete, Edit, DocumentCopy } from '@element-plus/icons-vue'
import { adminListService, adminDelService } from '@/api/user'
import AdminAdd from './components/AdminAdd.vue'
import AdminDetail from './components/AdminDetail.vue'
import PasswdUpdate from '@/components/PasswdUpdate.vue'
const loading = ref(false)
const total = ref(0)
const adminList = ref()
const params = ref({
current: 1,
size: 10
})
const getAdminList = async () => {
loading.value = true
const {
data: { data }
} = await adminListService(params.value)
total.value = data.total
adminList.value = data.list
console.log(data.list)
loading.value = false
}
getAdminList()
//
const copyStatus = ref('')
const copyToClipboard = (copyText) => {
//
const tempInput = document.createElement('input')
tempInput.style.position = 'absolute'
tempInput.style.left = '-9999px'
tempInput.style.top = '-9999px'
document.body.appendChild(tempInput)
//
tempInput.value = copyText
tempInput.select()
//
try {
document.execCommand('copy')
copyStatus.value = '复制成功!'
} catch (err) {
copyStatus.value = '复制失败,请手动复制'
console.error('复制失败:', err)
} finally {
ElMessage.info(copyStatus.value)
//
document.body.removeChild(tempInput)
}
}
//
const adminAddRef = ref()
const adminAdd = () => {
adminAddRef.value.open()
}
//
const remove = (userId) => {
ElMessageBox.confirm('确认移除该条管理员信息?', '提示', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
})
.then(async () => {
await adminDelService(userId)
ElMessage.success('移除成功')
})
.catch(() => {})
}
//
const adminDetail = ref()
const manage = (userId) => {
adminDetail.value.open(userId)
}
//
const passwdRef = ref()
const passwdUpdate = (id) => {
passwdRef.value.open(id)
}
</script>
<template>
<page-container title="超级管理员">
<!-- 表格区域 -->
<el-table :data="adminList" stripe v-loading="loading">
<el-table-column type="index" label="序号" width="100" />
<el-table-column label="用户名" prop="username"></el-table-column>
<el-table-column label="密码">
<template #default="{ row }">
{{ '******' }}
<el-button
plain
:icon="DocumentCopy"
type="info"
title="查看用户认证信息"
size="small"
@click="copyToClipboard(row.accessCode)"
style="
width: 16px;
height: 16px;
font-size: 12px;
margin-left: 2%;
margin-top: -4%;
"
></el-button>
</template>
</el-table-column>
<el-table-column label="联系电话" prop="userPhone"></el-table-column>
<el-table-column label="邮箱信息">
<template #default="{ row }">
{{ row.email || '用户未设置邮箱信息' }}
</template>
</el-table-column>
<el-table-column label="类型">
<template #default="">
{{ '管理员' }}
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button
circle
plain
:icon="Plus"
title="管理场馆"
@click="manage(row.id)"
></el-button>
<el-button
circle
plain
:icon="Edit"
type="primary"
title="修改管理员账号密码"
@click="passwdUpdate(row.id)"
></el-button>
<el-button
circle
plain
:icon="Delete"
type="danger"
title="移除管理员"
@click="remove(row.id)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="无数据" />
</template>
</el-table>
<el-button class="mt-4" style="width: 100%" @click="adminAdd">
<el-icon
><Plus />
添加管理员信息
</el-icon>
</el-button>
<!-- 分页区域 -->
<el-pagination
v-model:current-page="params.current"
v-model:page-size="params.size"
:page-sizes="[10, 20, 50]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</page-container>
<!-- 添加管理员信息 -->
<AdminAdd ref="adminAddRef" @success="onSizeChange(10)"></AdminAdd>
<!-- 管理场馆 -->
<AdminDetail ref="adminDetail"></AdminDetail>
<!-- 修改管理员密码 -->
<PasswdUpdate ref="passwdRef" @success="onSizeChange(10)"></PasswdUpdate>
</template>

@ -0,0 +1,123 @@
<script setup>
import { ref } from 'vue'
import { Money, View } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores'
import { userListService } from '@/api/user'
import UserDetail from './components/UserDetail.vue'
import UserRecharge from './components/UserRecharge.vue'
const userStore = useUserStore()
const loading = ref(false)
const userList = ref()
const total = ref(0)
const params = ref({
current: 1,
size: 10,
userId: userStore.user.id || 0,
schoolId: ''
})
const getUserList = async () => {
loading.value = true
const {
data: { data }
} = await userListService(params.value)
userList.value = data.list
total.value = data.total
loading.value = false
}
getUserList()
const onCurrentChange = (current) => {
params.value.current = current
getUserList()
}
const onSizeChange = (size) => {
params.value.current = 1
params.value.size = size
getUserList()
}
// 10 20 50 100 150 200
const moneyRef = ref()
const addMoney = (userId, username) => {
moneyRef.value.open(userId, username)
}
// +
const userDetailRef = ref()
const userDetail = (id) => {
userDetailRef.value.open(id)
}
</script>
<template>
<page-container title="用户信息列表">
<!-- 表格区域 -->
<el-table :data="userList" stripe v-loading="loading">
<el-table-column type="index" label="序号" width="100" />
<el-table-column label="用户名" prop="username"></el-table-column>
<el-table-column label="联系电话" prop="userPhone"></el-table-column>
<el-table-column label="邮箱信息">
<template #default="{ row }">
{{ row.email || '用户未设置邮箱信息' }}
</template>
</el-table-column>
<el-table-column label="类型">
<template #default="{ row }">
<span v-if="row.type === 0" style="color: blueviolet"></span>
<span v-if="row.type === 1" style="color: skyblue"></span>
<span v-if="row.type === 2" style="color: black"></span>
</template>
</el-table-column>
<el-table-column label="账户余额(元)">
<template #default="{ row }">
{{ row.balance.toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<span
v-if="row.phone === '暂无用户联系方式'"
style="color: blueviolet"
>未认证电话号码</span
>
<el-button
circle
plain
:icon="Money"
title="充值"
@click="addMoney(row.id, row.username)"
v-if="row.phone !== '暂无用户联系方式'"
></el-button>
<el-button
circle
plain
v-if="row.type !== 0"
:icon="View"
type="success"
title="查看用户认证信息"
@click="userDetail(row.id)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="无数据" />
</template>
</el-table>
<!-- 分页区域 -->
<el-pagination
v-model:current-page="params.current"
v-model:page-size="params.size"
:page-sizes="[10, 20, 50]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
</page-container>
<!-- 用户信息查看 -->
<UserDetail ref="userDetailRef"></UserDetail>
<!-- 充值金额窗口 -->
<UserRecharge ref="moneyRef" @success="onSizeChange(5)"></UserRecharge>
</template>

@ -0,0 +1,103 @@
<script setup>
import { ref } from 'vue'
import { adminAddService } from '@/api/user'
import { User } from '@element-plus/icons-vue'
const dialogTableVisible = ref(false)
const form = ref()
const formModel = ref({
username: '',
phone: '',
email: ''
})
//
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
phone: [{ required: true, message: '请输入电话号码', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }]
}
// open open
const open = () => {
dialogTableVisible.value = true
}
const addAdmin = async () => {
await form.value.validate()
ElMessageBox.confirm('确认添加?', '提示', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
})
.then(async () => {
await adminAddService(formModel.value)
ElMessage.success('操作成功')
emit('success')
close()
})
.catch(() => {})
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
const close = () => {
dialogTableVisible.value = false
form.value.clearValidate()
//
formModel.value = {
username: '',
phone: '',
email: ''
}
}
</script>
<template>
<el-dialog
v-model="dialogTableVisible"
title="管理员信息添加"
width="500"
@close="close"
>
<el-form
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
>
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
</el-form-item>
<el-form-item prop="phone">
<el-input
:prefix-icon="User"
placeholder="请输入电话号码"
v-model="formModel.phone"
></el-input>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="formModel.email"
:prefix-icon="User"
placeholder="请输入邮箱"
></el-input>
</el-form-item>
<el-form-item>
<el-button
class="button"
type="success"
auto-insert-space
@click="addAdmin"
>点击添加</el-button
>
</el-form-item>
</el-form>
</el-dialog>
</template>

@ -0,0 +1,105 @@
<script setup>
import { ref } from 'vue'
import { QueryService, manageService, cancelService } from '@/api/admin'
const dialogTableVisible = ref(false)
const isManage = ref()
const typeList = ref()
const userId = ref()
const loading = ref(false)
const getTypeList = async (id) => {
loading.value = true
const {
data: { data }
} = await QueryService(id)
isManage.value = data.isManage
typeList.value = data.list
loading.value = false
}
// open open
const open = (id) => {
userId.value = id
getTypeList(id)
dialogTableVisible.value = true
}
//
const deleteRow = (id) => {
ElMessageBox.confirm('是否取消?', '提示', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
})
.then(async () => {
const fmdata = new FormData()
fmdata.append('userId', userId.value)
fmdata.append('typeId', id)
await cancelService(fmdata)
close()
})
.catch(() => {})
}
//
const addRow = async (id) => {
ElMessageBox.confirm('确认添加?', '提示', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
})
.then(async () => {
const fmdata = new FormData()
fmdata.append('userId', userId.value)
fmdata.append('typeId', id)
await manageService(fmdata)
close()
})
.catch(() => {})
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
const close = () => {
dialogTableVisible.value = false
ElMessage.success('操作成功')
emit('success')
}
</script>
<template>
<el-dialog v-model="dialogTableVisible" title="场馆信息" width="500">
<el-table :data="typeList" stripe v-loading="loading">
<el-table-column type="index" label="序号" width="100" />
<el-table-column label="场馆名称" prop="vname"></el-table-column>
<el-table-column fixed="right" label="操作" min-width="120">
<template #default="{ row }">
<el-button
link
type="primary"
size="small"
v-if="isManage"
@click="deleteRow(row.id)"
>
移除
</el-button>
<el-button
link
type="primary"
size="small"
v-else
@click.prevent="addRow(row.id)"
>
管理
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="无数据" />
</template>
</el-table>
</el-dialog>
</template>

@ -0,0 +1,103 @@
<script setup>
import { ref } from 'vue'
import { adminAddService } from '@/api/user'
import { User } from '@element-plus/icons-vue'
const dialogTableVisible = ref(false)
const form = ref()
const formModel = ref({
username: '',
phone: '',
email: ''
})
//
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
phone: [{ required: true, message: '请输入电话号码', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }]
}
// open open
const open = () => {
dialogTableVisible.value = true
}
const addAdmin = async () => {
await form.value.validate()
ElMessageBox.confirm('确认添加?', '提示', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
})
.then(async () => {
await adminAddService(formModel.value)
ElMessage.success('操作成功')
emit('success')
close()
})
.catch(() => {})
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
const close = () => {
dialogTableVisible.value = false
form.value.clearValidate()
//
formModel.value = {
username: '',
phone: '',
email: ''
}
}
</script>
<template>
<el-dialog
v-model="dialogTableVisible"
title="管理员信息添加"
width="500"
@close="close"
>
<el-form
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
>
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="请输入用户名"
></el-input>
</el-form-item>
<el-form-item prop="phone">
<el-input
:prefix-icon="User"
placeholder="请输入电话号码"
v-model="formModel.phone"
></el-input>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="formModel.email"
:prefix-icon="User"
placeholder="请输入邮箱"
></el-input>
</el-form-item>
<el-form-item>
<el-button
class="button"
type="success"
auto-insert-space
@click="addAdmin"
>点击添加</el-button
>
</el-form-item>
</el-form>
</el-dialog>
</template>

@ -0,0 +1,48 @@
<script setup>
import { ref } from 'vue'
import { userDetailService } from '@/api/user'
const drawerVisible = ref(false)
const userInfo = ref({})
const getUserInfo = async (id) => {
const {
data: { data }
} = await userDetailService(id)
console.log(data)
userInfo.value = data
}
// open open
const open = (id) => {
getUserInfo(id)
drawerVisible.value = true
}
//
defineExpose({
open
})
// const emit = defineEmits(['success'])
// emit('success')
</script>
<template>
<el-drawer v-model="drawerVisible" draggable title="认证信息">
<el-descriptions
class="margin-top"
title=""
:column="1"
:size="size"
border
>
<el-descriptions-item label="所属单位">{{
userInfo.college
}}</el-descriptions-item>
<el-descriptions-item label="学工号">{{
userInfo.schoolNumber
}}</el-descriptions-item>
<el-descriptions-item label="身份类型">
<el-tag size="small">{{ userInfo.type }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-drawer>
</template>

@ -0,0 +1,121 @@
<script setup>
import { ref } from 'vue'
// import { timeAddService } from '@/api/time'
import { userRechargeService } from '@/api/user'
const dialogVisible = ref(false)
const formData = new FormData()
// open open
const open = (id, username) => {
formData.append('id', id)
formData.append('username', username)
console.log(username)
dialogVisible.value = true
}
//
defineExpose({
open
})
const emit = defineEmits(['success'])
//
const selectEdOption = ref(0)
const options = ref([
{
label: 10.0,
value: 10.0
},
{
label: 20.0,
value: 20.0
},
{
label: 50.0,
value: 50.0
},
{
label: 100.0,
value: 100.0
},
{
label: 150.0,
value: 150.0
},
{
label: 200.0,
value: 200.0
}
])
const formDataDel = () => {
formData.delete('id')
formData.delete('amount')
formData.delete('username')
}
//
const onSubmit = () => {
if (selectEdOption.value === 0) {
ElMessage.info('请选择充值金额')
return
}
ElMessageBox.confirm(
'是否确认向 ' +
formData.get('username') +
' 充值 ' +
selectEdOption.value +
' 元?',
'提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(async () => {
formData.append('amount', selectEdOption.value)
await userRechargeService(formData)
ElMessage({
type: 'success',
message: '充值成功'
})
dialogVisible.value = false
formDataDel()
selectEdOption.value = 0
emit('success')
})
.catch(() => {
ElMessage({
type: 'info',
message: '充值取消'
})
formDataDel()
selectEdOption.value = 0
dialogVisible.value = false
})
}
</script>
<template>
<el-dialog v-model="dialogVisible" title="充值" width="450" draggable>
<el-select
v-model="selectEdOption"
placeholder="Select"
size="large"
style="width: 240px"
@change="moneyChange"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label.toFixed(2)"
:value="item.value"
/>
</el-select>
<el-button type="primary" style="margin-left: 30px" @click="onSubmit"
>确认</el-button
>
</el-dialog>
</template>

@ -0,0 +1,276 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as echarts from 'echarts'
import { getLatestDevices } from '@/api/device'
import { Stomp } from '@stomp/stompjs'
import SockJS from 'sockjs-client'
const chart = ref(null)
let chartInstance = null
const deviceList = ref([])
const latestDevice = ref(null)
const stompClient = ref(null)
//
const getTempColor = (temp) => {
if (!temp) return '#333'
if (temp > 30) return '#f56c6c' //
if (temp < 15) return '#409eff' //
return '#67c23a' // 绿
}
const getHumidityColor = (humidity) => {
if (!humidity) return '#333'
if (humidity > 70 || humidity < 30) return '#f56c6c' //
return '#67c23a' // 绿
}
//
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleString()
}
//
const initChart = () => {
chartInstance = echarts.init(chart.value)
updateChart()
}
//
const updateChart = () => {
const option = {
tooltip: {
trigger: 'axis',
formatter: (params) => {
const date = new Date(params[0].value[0])
return `${date.toLocaleTimeString()}<br/>
温度: ${params[0].value[1]}°C<br/>
湿度: ${params[1].value[2]}%`
}
},
legend: {
data: ['温度', '湿度']
},
xAxis: {
type: 'time'
},
yAxis: [
{
name: '温度(℃)',
type: 'value',
min: 10,
max: 40
},
{
name: '湿度(%)',
type: 'value',
min: 20,
max: 80
}
],
series: [
{
name: '温度',
type: 'line',
showSymbol: false,
data: deviceList.value.map((item) => [
item.timestamp,
item.temperature
]),
lineStyle: {
color: '#ff6384'
}
},
{
name: '湿度',
type: 'line',
showSymbol: false,
yAxisIndex: 1,
data: deviceList.value.map((item) => [item.timestamp, item.humidity]),
lineStyle: {
color: '#36a2eb'
}
}
]
}
chartInstance.setOption(option)
}
// WebSocket
const connectWebSocket = () => {
const socket = new SockJS('http://119.29.191.232:9020/api/ws')
stompClient.value = Stomp.over(socket)
stompClient.value.connect({}, () => {
stompClient.value.subscribe('/topic/devices', (message) => {
const newDevice = JSON.parse(message.body)
latestDevice.value = newDevice
deviceList.value.unshift(newDevice)
//
if (deviceList.value.length > 50) {
deviceList.value.pop()
}
updateChart()
})
})
}
//
const fetchInitialData = async () => {
const {
data: { data }
} = await getLatestDevices()
deviceList.value = data.reverse()
if (deviceList.value.length > 0) {
latestDevice.value = deviceList.value[0]
}
updateChart()
}
onMounted(() => {
initChart()
fetchInitialData()
connectWebSocket()
})
onBeforeUnmount(() => {
if (stompClient.value) {
stompClient.value.disconnect()
}
if (chartInstance) {
chartInstance.dispose()
}
})
</script>
<template>
<div class="dashboard-container">
<!-- 状态概览卡片 -->
<div class="status-cards">
<el-card class="status-card" shadow="hover">
<div class="card-title">当前温度</div>
<div
class="card-value"
:style="{ color: getTempColor(latestDevice?.temperature) }"
>
{{ latestDevice?.temperature?.toFixed(1) || '--' }}°C
</div>
</el-card>
<el-card class="status-card" shadow="hover">
<div class="card-title">当前湿度</div>
<div
class="card-value"
:style="{ color: getHumidityColor(latestDevice?.humidity) }"
>
{{ latestDevice?.humidity?.toFixed(1) || '--' }}%
</div>
</el-card>
<el-card class="status-card" shadow="hover">
<div class="card-title">风扇状态</div>
<div class="card-value">
<el-tag :type="latestDevice?.fanStatus ? 'success' : 'info'">
{{ latestDevice?.fanStatus ? '运行中' : '已关闭' }}
</el-tag>
</div>
</el-card>
<el-card class="status-card" shadow="hover">
<div class="card-title">设备状态</div>
<div class="card-value">
<el-tag :type="latestDevice?.warningStatus ? 'danger' : 'success'">
{{ latestDevice?.warningStatus ? '异常' : '正常' }}
</el-tag>
</div>
</el-card>
</div>
<!-- 数据图表 -->
<div class="chart-container">
<el-card shadow="never">
<div ref="chart" style="width: 100%; height: 400px"></div>
</el-card>
</div>
<!-- 数据表格 -->
<div class="table-container">
<el-card shadow="never">
<el-table :data="deviceList" style="width: 100%" height="400">
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="{ row }">
{{ formatTime(row.timestamp) }}
</template>
</el-table-column>
<el-table-column label="温度(℃)" width="120">
<template #default="{ row }">
<span :style="{ color: getTempColor(row.temperature) }">
{{ row.temperature.toFixed(1) }}
</span>
</template>
</el-table-column>
<el-table-column label="湿度(%)" width="120">
<template #default="{ row }">
<span :style="{ color: getHumidityColor(row.humidity) }">
{{ row.humidity.toFixed(1) }}
</span>
</template>
</el-table-column>
<el-table-column label="风扇状态" width="120">
<template #default="{ row }">
<el-tag :type="row.fanStatus ? 'success' : 'info'">
{{ row.fanStatus ? '运行中' : '已关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="警告状态">
<template #default="{ row }">
<el-tag :type="row.warningStatus ? 'danger' : 'success'">
{{ row.warningStatus ? '异常' : '正常' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</div>
</template>
<style scoped>
.dashboard-container {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
.status-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.status-card {
border-radius: 8px;
}
.card-title {
font-size: 14px;
color: #909399;
margin-bottom: 10px;
}
.card-value {
font-size: 24px;
font-weight: bold;
}
.chart-container,
.table-container {
margin-bottom: 20px;
}
.el-card {
border-radius: 8px;
}
</style>

@ -0,0 +1,495 @@
<template>
<div class="chat-container">
<div class="chat-header">
<h1>运动场地聊天室</h1>
<div class="user-info">
<span>{{ currentUsername }}</span>
<span class="role-badge" :class="getRoleClass(userRoleValue)">{{
getRoleName(userRoleValue)
}}</span>
</div>
</div>
<div class="chat-messages" ref="messageContainer">
<div v-if="loadingHistory" class="loading-more">...</div>
<button
v-if="hasMoreHistory"
class="load-more-btn"
@click="loadMoreHistory"
:disabled="loadingHistory"
>
{{ loadingHistory ? '加载中...' : '查看历史消息' }}
</button>
<div
v-for="(message, index) in chatMessages"
:key="index"
:class="getMessageClass(message)"
>
<div class="message-header">
<span class="sender" :class="getRoleClass(message.senderRole)">{{
message.sender
}}</span>
<span class="role-badge" :class="getRoleClass(message.senderRole)">{{
message.roleName || getRoleName(message.senderRole)
}}</span>
<span class="timestamp">{{
formatTimestamp(message.timestamp)
}}</span>
</div>
<div class="message-content">
{{ message.content || message.message }}
</div>
</div>
</div>
<div class="chat-input">
<input
v-model="messageInput"
:placeholder="isLoggedIn ? '输入消息...' : '请先登录'"
@keyup.enter="sendMessage"
:disabled="!isLoggedIn"
/>
<button
@click="sendMessage"
:disabled="!isLoggedIn || !messageInput.trim()"
>
{{ isLoggedIn ? '发送' : '请先登录' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useUserStore } from '@/stores'
const userStore = useUserStore()
const messageContainer = ref(null)
const messageInput = ref('')
const chatMessages = ref([])
const isLoggedIn = ref(false)
const currentUsername = ref('')
const userRoleValue = ref(0)
const loadingHistory = ref(false)
const hasMoreHistory = ref(true)
const oldestTimestamp = ref(0)
const ws = ref(null)
//
onMounted(() => {
userRoleValue.value = userStore.user.type || 0
initWebSocket()
})
// WebSocket
onBeforeUnmount(() => {
disconnect()
})
//
watch(
chatMessages,
() => {
scrollToBottom()
},
{ deep: true }
)
// WebSocket
const initWebSocket = () => {
try {
ws.value = new WebSocket('ws://localhost:9030/ws')
ws.value.onopen = () => {
console.log('WebSocket连接已建立')
login()
}
ws.value.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
if (message.type === 'history') {
handleHistoryMessage(message)
} else {
chatMessages.value.push(message)
}
} catch (error) {
console.error('解析消息失败:', error)
chatMessages.value.push({
type: 'error',
message: '收到格式错误的消息'
})
}
}
ws.value.onclose = () => {
console.log('WebSocket连接已关闭')
isLoggedIn.value = false
// 5
setTimeout(initWebSocket, 5000)
}
ws.value.onerror = (error) => {
console.error('WebSocket错误:', error)
chatMessages.value.push({
type: 'error',
message: 'WebSocket连接发生错误'
})
}
} catch (error) {
console.error('创建WebSocket失败:', error)
chatMessages.value.push({
type: 'error',
message: '无法连接到服务器'
})
}
}
//
const handleHistoryMessage = (message) => {
loadingHistory.value = false
if (message.messages && message.messages.length > 0) {
//
const formattedMessages = message.messages.map((msg) => ({
...msg,
timestamp: msg.timestamp
? new Date(msg.timestamp).toISOString()
: new Date().toISOString()
}))
//
chatMessages.value.unshift(...formattedMessages)
//
const oldestMsg = formattedMessages[formattedMessages.length - 1]
oldestTimestamp.value = new Date(oldestMsg.timestamp).getTime()
}
hasMoreHistory.value = message.hasMore
}
//
const loadMoreHistory = () => {
if (!isLoggedIn.value || loadingHistory.value) return
loadingHistory.value = true
const loadMessage = {
type: 'loadHistory',
username: currentUsername.value,
lastTimestamp: oldestTimestamp.value,
pageSize: 20
}
ws.value.send(JSON.stringify(loadMessage))
}
//
const login = () => {
if (!userStore.user.username) {
currentUsername.value = '游客' + Math.floor(Math.random() * 1000)
} else {
currentUsername.value = userStore.user.username
}
const loginMessage = {
type: 'login',
username: currentUsername.value,
userRole: userRoleValue.value
}
ws.value.send(JSON.stringify(loginMessage))
isLoggedIn.value = true
chatMessages.value.push({
type: 'system',
message: '已成功连接到服务器',
timestamp: new Date().toISOString()
})
//
loadMoreHistory()
}
//
const sendMessage = () => {
if (!isLoggedIn.value || !messageInput.value.trim()) {
return
}
const message = {
type: 'message',
username: currentUsername.value,
userRole: userRoleValue.value,
content: messageInput.value
}
ws.value.send(JSON.stringify(message))
messageInput.value = ''
}
//
const disconnect = () => {
if (ws.value) {
ws.value.close()
ws.value = null
}
}
//
const scrollToBottom = () => {
nextTick(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
})
}
//
const getMessageClass = (message) => {
if (message.type === 'system') {
return 'system-message'
} else if (message.type === 'error') {
return 'error-message'
} else {
return message.sender === currentUsername.value
? 'my-message'
: 'other-message'
}
}
//
const getRoleName = (role) => {
if (role === 0 || role === 1 || role === 2) return '普通用户'
if (role === 3) return '系统管理员'
if (role === 4) return '超级管理员'
return '未知角色'
}
//
const getRoleClass = (role) => {
if (role === 0 || role === 1 || role === 2) return 'role-user'
if (role === 3) return 'role-admin'
if (role === 4) return 'role-superadmin'
return 'role-unknown'
}
//
const formatTimestamp = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleString()
}
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 90vh;
margin: 20px auto;
border-radius: 8px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
overflow: hidden;
background-color: white;
}
.chat-header {
padding: 15px 20px;
background-color: #4a6fa5;
color: white;
border-bottom: 1px solid #ddd;
}
.chat-header h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
}
.user-info {
margin-top: 5px;
font-size: 14px;
display: flex;
align-items: center;
}
.role-badge {
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.role-user {
background-color: rgba(52, 152, 219, 0.2);
color: #3498db;
}
.role-admin {
background-color: rgba(230, 126, 34, 0.2);
color: #e67e22;
}
.role-superadmin {
background-color: rgba(192, 57, 43, 0.2);
color: #c0392b;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #f8f9fa;
display: flex;
flex-direction: column;
}
.loading-more {
text-align: center;
padding: 10px;
color: #6c757d;
font-size: 14px;
}
.load-more-btn {
margin: 10px auto;
padding: 8px 16px;
background-color: #4a6fa5;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.load-more-btn:hover {
background-color: #3a5a80;
}
.load-more-btn:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
}
.system-message {
text-align: center;
margin: 10px 0;
}
.system-message .message-content {
display: inline-block;
padding: 5px 15px;
background-color: #e9ecef;
border-radius: 15px;
color: #6c757d;
font-size: 14px;
}
.error-message {
text-align: center;
margin: 10px 0;
}
.error-message .message-content {
display: inline-block;
padding: 5px 15px;
background-color: #f8d7da;
border-radius: 15px;
color: #721c24;
font-size: 14px;
}
.message {
margin-bottom: 15px;
max-width: 80%;
}
.my-message {
margin-left: auto;
text-align: right;
}
.message-header {
margin-bottom: 5px;
font-size: 14px;
}
.sender {
font-weight: bold;
}
.timestamp {
margin-left: 10px;
color: #6c757d;
font-size: 12px;
}
.message-content {
display: inline-block;
padding: 10px 15px;
border-radius: 18px;
font-size: 16px;
line-height: 1.4;
}
.my-message .message-content {
background-color: #4a6fa5;
color: white;
text-align: left;
}
.other-message .message-content {
background-color: #e9ecef;
color: #212529;
}
.chat-input {
display: flex;
padding: 15px 20px;
background-color: white;
border-top: 1px solid #ddd;
}
.chat-input input {
flex: 1;
padding: 10px 15px;
margin-right: 15px;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 16px;
outline: none;
}
.chat-input input:focus {
border-color: #4a6fa5;
box-shadow: 0 0 0 2px rgba(74, 111, 165, 0.2);
}
.chat-input button {
padding: 10px 20px;
background-color: #4a6fa5;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.chat-input button:hover {
background-color: #3a5a80;
}
.chat-input button:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
}
</style>

@ -0,0 +1,457 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { hotQueryService } from '@/api/hot'
import { typeQueryService } from '@/api/type'
import { Location, Timer } from '@element-plus/icons-vue'
import BaseLayout from '@/components/BaseLayout.vue'
//
const showDropdown = ref(false)
//
const carouselItems = ref([])
//
const imgLoading = ref(false)
const getCarouselItems = async () => {
imgLoading.value = true
const {
data: { data }
} = await hotQueryService({ current: 1, size: 10 })
carouselItems.value = data.list
imgLoading.value = false
}
getCarouselItems()
//
const venues = ref([])
//
const venueLoading = ref(false)
const getVenues = async () => {
venueLoading.value = true
const {
data: { data }
} = await typeQueryService({ current: 1, size: 10 })
venues.value = data.list
venueLoading.value = false
}
//
const handleClickOutside = (event) => {
const profileElement = document.querySelector('.user-profile')
const dropdownElement = document.querySelector('.user-dropdown')
if (profileElement && dropdownElement) {
if (
!profileElement.contains(event.target) &&
!dropdownElement.contains(event.target)
) {
showDropdown.value = false
}
}
}
//
document.addEventListener('click', handleClickOutside)
//
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
onMounted(() => {
getVenues()
})
</script>
<template>
<BaseLayout>
<div class="app-container">
<!-- 主要内容区域 -->
<main class="main-content">
<!-- 轮播图 -->
<div class="carousel-container">
<el-carousel
height="60vh"
indicator-position=""
arrow="always"
v-loading="imgLoading"
>
<el-carousel-item
v-for="(item, index) in carouselItems"
:key="index"
>
<div class="carousel-item-content">
<img
:src="item.imgUrl"
:alt="item.title"
class="carousel-image"
/>
<div class="carousel-text">
<h3 class="carousel-title">{{ item.title }}</h3>
<p class="carousel-desc">{{ item.description }}</p>
<!-- <p class="carousel-desc">{{ item.addTime }}</p> -->
</div>
</div>
</el-carousel-item>
</el-carousel>
</div>
<hr />
<!-- 场馆列表 -->
<div class="venue-section">
<div class="section-header">
<h2 class="section-title">场馆列表</h2>
</div>
<div class="venue-grid">
<div
v-for="venue in venues"
:key="venue.id"
class="venue-card"
v-loading="venueLoading"
>
<div class="card-image-container">
<img
:src="venue.imgUrl"
:alt="venue.vname"
class="card-image"
/>
<div class="card-status status-available">
{{ '可预约' }}
</div>
</div>
<div class="card-content">
<h3 class="card-title">{{ venue.vname }}</h3>
<div class="card-info">
<div class="info-item">
<i class="el-icon-location-outline info-icon"></i>
<span
><el-icon><Location /></el-icon>{{
venue.location
}}</span
>
</div>
<div class="info-item">
<i class="el-icon-user info-icon"></i>
<span>
<el-icon><Timer /></el-icon>
开馆时间{{ venue.openTime }}</span
>
</div>
<div class="info-item">
<i class="el-icon-time info-icon"></i>
<span>
<el-icon><Timer /></el-icon>
闭馆时间{{ venue.closeTime }}</span
>
</div>
</div>
<p class="card-desc">{{ venue.description }}</p>
</div>
<div class="card-footer">
<router-link
:to="{ path: '/mobile/detail', query: { id: venue.id } }"
><el-button type="primary" class="reserve-btn"
>点击预约
</el-button></router-link
>
</div>
</div>
</div>
</div>
</main>
</div>
</BaseLayout>
</template>
<style scoped>
/* 全局样式 */
:root {
--primary-color: #1e40af;
--secondary-color: #3b82f6;
--text-color: #334155;
--bg-gradient: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
--card-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
--card-hover-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
a {
color: black;
text-decoration: none;
}
.router-link-active {
text-decoration: none;
}
/* 应用容器 */
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #6c83a1 100%);
}
/* 主要内容区域 */
.main-content {
flex: 1;
max-width: 1440px;
width: 100%;
margin: 0 auto;
padding: 32px;
}
/* 轮播图 */
.carousel-container {
border: 1px solid black;
margin-bottom: 40px;
border-radius: 12px;
/* height: 6vh; */
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.carousel-item-content {
position: relative;
height: 100%;
}
.carousel-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.carousel-text {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
padding: 24px;
color: white;
}
.carousel-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
}
.carousel-desc {
font-size: 16px;
margin-bottom: 0;
}
/* 场馆列表部分 */
.venue-section {
margin-bottom: 40px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.section-title {
font-size: 24px;
font-weight: 600;
color: var(--text-color);
}
.section-filter {
display: flex;
gap: 16px;
}
.search-input {
width: 280px;
}
.search-icon {
color: #94a3b8;
}
.filter-select {
width: 180px;
}
.venue-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.venue-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--card-shadow);
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
display: flex;
flex-direction: column;
height: 100%;
}
.venue-card:hover {
transform: translateY(-4px);
box-shadow: var(--card-hover-shadow);
}
.card-image-container {
position: relative;
height: 200px;
overflow: hidden;
}
.card-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.venue-card:hover .card-image {
transform: scale(1.05);
}
.card-status {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-available {
background: #10b981;
color: white;
}
.status-booked {
background: #ef4444;
color: white;
}
.card-content {
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 12px;
}
.card-info {
/* display: flex; */
/* flex-wrap: wrap; */
gap: 12px;
margin-bottom: 12px;
}
.info-item {
display: flex;
align-items: center;
font-size: 14px;
color: #64748b;
margin-top: 20px;
}
.info-icon {
margin-right: 4px;
color: var(--secondary-color);
}
.card-desc {
font-size: 14px;
color: #64748b;
line-height: 1.6;
flex: 1;
}
.card-footer {
padding: 16px;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: center;
}
.reserve-btn {
width: 100%;
padding: 10px 16px;
font-size: 16px;
font-weight: 500;
border-radius: 6px;
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式布局 */
@media (max-width: 1024px) {
.header-content,
.main-content,
.footer-content {
padding: 0 24px;
}
.title {
display: none;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.section-filter {
width: 100%;
flex-direction: column;
gap: 12px;
}
.search-input,
.filter-select {
width: 100%;
}
.footer-content {
flex-direction: column;
gap: 32px;
}
.footer-links {
flex-direction: column;
gap: 32px;
}
}
@media (max-width: 768px) {
.carousel-container {
height: 200px;
}
.carousel-title {
font-size: 18px;
}
.carousel-desc {
font-size: 14px;
}
.venue-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,479 @@
<template>
<BaseLayout>
<div class="chat-container user-chat">
<div class="chat-header">
<h1>运动场地聊天室</h1>
<div class="user-info">
<span>{{ currentUsername }}</span>
<span class="role-badge user-role">{{
getRoleName(userRoleValue)
}}</span>
</div>
</div>
<div class="chat-messages" ref="messageContainer">
<div v-if="loadingHistory" class="loading-more">
正在加载历史消息...
</div>
<button
v-if="hasMoreHistory"
class="load-more-btn"
@click="loadMoreHistory"
:disabled="loadingHistory"
>
{{ loadingHistory ? '加载中...' : '查看历史消息' }}
</button>
<div
v-for="(message, index) in chatMessages"
:key="index"
:class="getMessageClass(message)"
>
<div class="message-header">
<span class="sender" :class="getRoleClass(message.senderRole)">{{
message.sender
}}</span>
<span
class="role-badge"
:class="getRoleClass(message.senderRole)"
>{{ message.roleName || getRoleName(message.senderRole) }}</span
>
<span class="timestamp">{{
formatTimestamp(message.timestamp)
}}</span>
</div>
<div class="message-content">
{{ message.content || message.message }}
</div>
</div>
</div>
<div class="chat-input">
<input
v-model="messageInput"
placeholder="输入消息..."
@keyup.enter="sendMessage"
/>
<button @click="sendMessage" :disabled="!messageInput.trim()">
发送
</button>
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useUserStore } from '@/stores'
import BaseLayout from '@/components/BaseLayout.vue'
const userStore = useUserStore()
const messageContainer = ref(null)
const messageInput = ref('')
const chatMessages = ref([])
const currentUsername = ref('')
const userRoleValue = ref(1) //
const loadingHistory = ref(false)
const hasMoreHistory = ref(true)
const oldestTimestamp = ref(0)
const ws = ref(null)
//
onMounted(() => {
userRoleValue.value = userStore.user.type || 1 //
initWebSocket()
})
// WebSocket
onBeforeUnmount(() => {
disconnect()
})
//
watch(
chatMessages,
() => {
scrollToBottom()
},
{ deep: true }
)
// WebSocket
const initWebSocket = () => {
try {
ws.value = new WebSocket('ws://localhost:9030/ws')
ws.value.onopen = () => {
console.log('WebSocket连接已建立')
login()
}
ws.value.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
if (message.type === 'history') {
handleHistoryMessage(message)
} else {
chatMessages.value.push(message)
}
} catch (error) {
console.error('解析消息失败:', error)
chatMessages.value.push({
type: 'error',
message: '收到格式错误的消息'
})
}
}
ws.value.onclose = () => {
console.log('WebSocket连接已关闭')
// 5
setTimeout(initWebSocket, 5000)
}
ws.value.onerror = (error) => {
console.error('WebSocket错误:', error)
chatMessages.value.push({
type: 'error',
message: 'WebSocket连接发生错误'
})
}
} catch (error) {
console.error('创建WebSocket失败:', error)
chatMessages.value.push({
type: 'error',
message: '无法连接到服务器'
})
}
}
//
const handleHistoryMessage = (message) => {
loadingHistory.value = false
if (message.messages && message.messages.length > 0) {
const formattedMessages = message.messages.map((msg) => ({
...msg,
timestamp: msg.timestamp
? new Date(msg.timestamp).toISOString()
: new Date().toISOString()
}))
chatMessages.value.unshift(...formattedMessages)
const oldestMsg = formattedMessages[formattedMessages.length - 1]
oldestTimestamp.value = new Date(oldestMsg.timestamp).getTime()
}
hasMoreHistory.value = message.hasMore
}
//
const loadMoreHistory = () => {
if (loadingHistory.value) return
loadingHistory.value = true
const loadMessage = {
type: 'loadHistory',
username: currentUsername.value,
lastTimestamp: oldestTimestamp.value,
pageSize: 20
}
ws.value.send(JSON.stringify(loadMessage))
}
//
const login = () => {
currentUsername.value =
userStore.user.username || '用户' + Math.floor(Math.random() * 1000)
const loginMessage = {
type: 'login',
username: currentUsername.value,
userRole: userRoleValue.value
}
ws.value.send(JSON.stringify(loginMessage))
chatMessages.value.push({
type: 'system',
message: '已成功连接到聊天室',
timestamp: new Date().toISOString()
})
//
loadMoreHistory()
}
//
const sendMessage = () => {
if (!messageInput.value.trim()) return
const message = {
type: 'message',
username: currentUsername.value,
userRole: userRoleValue.value,
content: messageInput.value
}
ws.value.send(JSON.stringify(message))
messageInput.value = ''
}
//
const disconnect = () => {
if (ws.value) {
ws.value.close()
ws.value = null
}
}
//
const scrollToBottom = () => {
nextTick(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
})
}
//
const getMessageClass = (message) => {
if (message.type === 'system') {
return 'system-message'
} else if (message.type === 'error') {
return 'error-message'
} else {
return message.sender === currentUsername.value
? 'my-message'
: 'other-message'
}
}
//
const getRoleName = () => {
return '普通用户' //
}
//
const getRoleClass = () => {
return 'role-user' // 使
}
//
const formatTimestamp = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 90vh;
margin: 20px auto;
border-radius: 8px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
overflow: hidden;
background-color: white;
}
.chat-header {
padding: 15px 20px;
background-color: #4a6fa5;
color: white;
border-bottom: 1px solid #ddd;
}
.chat-header h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
}
.user-info {
margin-top: 5px;
font-size: 14px;
display: flex;
align-items: center;
}
.role-badge {
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.role-user {
background-color: rgba(52, 152, 219, 0.2);
color: #3498db;
}
.role-admin {
background-color: rgba(230, 126, 34, 0.2);
color: #e67e22;
}
.role-superadmin {
background-color: rgba(192, 57, 43, 0.2);
color: #c0392b;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #f8f9fa;
display: flex;
flex-direction: column;
}
.loading-more {
text-align: center;
padding: 10px;
color: #6c757d;
font-size: 14px;
}
.load-more-btn {
margin: 10px auto;
padding: 8px 16px;
background-color: #4a6fa5;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.load-more-btn:hover {
background-color: #3a5a80;
}
.load-more-btn:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
}
.system-message {
text-align: center;
margin: 10px 0;
}
.system-message .message-content {
display: inline-block;
padding: 5px 15px;
background-color: #e9ecef;
border-radius: 15px;
color: #6c757d;
font-size: 14px;
}
.error-message {
text-align: center;
margin: 10px 0;
}
.error-message .message-content {
display: inline-block;
padding: 5px 15px;
background-color: #f8d7da;
border-radius: 15px;
color: #721c24;
font-size: 14px;
}
.message {
margin-bottom: 15px;
max-width: 80%;
}
.my-message {
margin-left: auto;
text-align: right;
}
.message-header {
margin-bottom: 5px;
font-size: 14px;
}
.sender {
font-weight: bold;
}
.timestamp {
margin-left: 10px;
color: #6c757d;
font-size: 12px;
}
.message-content {
display: inline-block;
padding: 10px 15px;
border-radius: 18px;
font-size: 16px;
line-height: 1.4;
}
.my-message .message-content {
background-color: #4a6fa5;
color: white;
text-align: left;
}
.other-message .message-content {
background-color: #e9ecef;
color: #212529;
}
.chat-input {
display: flex;
padding: 15px 20px;
background-color: white;
border-top: 1px solid #ddd;
}
.chat-input input {
flex: 1;
padding: 10px 15px;
margin-right: 15px;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 16px;
outline: none;
}
.chat-input input:focus {
border-color: #4a6fa5;
box-shadow: 0 0 0 2px rgba(74, 111, 165, 0.2);
}
.chat-input button {
padding: 10px 20px;
background-color: #4a6fa5;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.chat-input button:hover {
background-color: #3a5a80;
}
.chat-input button:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
}
</style>

@ -0,0 +1,375 @@
<script setup>
import { ref, onMounted } from 'vue'
import {
questionQueryService,
replyQueryService,
questionSubmitService
} from '@/api/question'
import { typeNameService } from '@/api/type'
import { useUserStore } from '@/stores'
import BaseLayout from '@/components/BaseLayout.vue'
const userStore = useUserStore()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const params = ref({
current: 1,
size: 10,
// 012
status: 2,
userId: 1
})
//
const userInfo = ref({})
//
const typeList = ref([])
const queryTypes = async () => {
const {
data: { data }
} = await typeNameService()
typeList.value = data
}
//
const fetchQuestions = async () => {
try {
loading.value = true
const {
data: { data }
} = await questionQueryService(params.value)
//
const questionsWithReplies = await Promise.all(
data.list.map(async (question) => {
if (question.status === 1) {
try {
const {
data: { data }
} = await replyQueryService(question.id)
return { ...question, reply: data }
} catch (error) {
console.error('获取回复失败:', error)
return question
}
}
return question
})
)
list.value = questionsWithReplies
total.value = data.total
} catch (error) {
console.error('获取问题列表失败:', error)
} finally {
loading.value = false
}
}
//
const handleStatusChange = () => {
params.value.current = 1 //
fetchQuestions()
}
//
const handlePageChange = (page) => {
params.value.current = page
fetchQuestions()
}
onMounted(() => {
fetchQuestions()
userInfo.value = { ...userStore.user }
params.value.userId = userInfo.value.id
queryTypes()
})
//
const dialogVisible = ref(false)
const questionFormRef = ref()
const questionForm = ref({
questionType: '',
type: '',
description: ''
})
const rules = ref({
questionType: [
{ required: true, message: '请选择问题类型', trigger: 'change' }
],
type: [{ required: true, message: '请选择相关场地', trigger: 'change' }],
description: [
{ required: true, message: '请输入问题描述', trigger: 'blur' },
{ min: 5, message: '问题描述不能少于5个字', trigger: 'blur' }
]
})
//
const showQuestionDialog = () => {
//
Object.assign(questionForm.value, {
questionType: '',
type: '',
description: ''
})
dialogVisible.value = true
}
//
const submitQuestion = async () => {
await questionFormRef.value.validate()
const loadingInstance = ElLoading.service({
lock: true,
text: '提交中...',
background: 'rgba(0, 0, 0, 0.7)'
})
try {
// 0
// await new Promise((resolve) => setTimeout(resolve, 2000))
//
const formData = {
...questionForm.value,
userId: userInfo.value.id,
username: userInfo.value.username,
phone: userInfo.value.userPhone
}
await questionSubmitService(formData)
loadingInstance.close()
ElMessage.success('提问提交成功')
dialogVisible.value = false
//
params.value.current = 1
fetchQuestions()
} catch (error) {
loadingInstance.close()
if (error !== 'validate') {
ElMessage.error('提问提交失败: ' + (error.message || error))
}
}
}
</script>
<template>
<BaseLayout>
<div class="question-page">
<!-- 标题和筛选区域 -->
<div class="header">
<h2>我的提问</h2>
<div class="filter">
<el-radio-group v-model="params.status" @change="handleStatusChange">
<el-radio-button :label="2">全部</el-radio-button>
<el-radio-button :label="0">未回复</el-radio-button>
<el-radio-button :label="1">已回复</el-radio-button>
</el-radio-group>
</div>
<el-button type="primary" @click="showQuestionDialog"
>发起提问</el-button
>
</div>
<!-- 发起提问对话框 -->
<el-dialog
v-model="dialogVisible"
title="发起提问"
width="600px"
@closed="questionFormRef.clearValidate()"
>
<el-form
:model="questionForm"
:rules="rules"
ref="questionFormRef"
label-width="100px"
>
<el-form-item label="问题类型" prop="questionType">
<el-select
v-model="questionForm.questionType"
placeholder="请选择问题类型"
>
<el-option label="疑问" value="疑问"></el-option>
<el-option label="建议" value="建议"></el-option>
</el-select>
</el-form-item>
<el-form-item label="相关场地" prop="type">
<el-select v-model="questionForm.type" placeholder="请选择相关场地">
<div v-for="(item, index) in typeList" :key="index">
<el-option :label="item.vname" :value="item.vname"></el-option>
</div>
</el-select>
</el-form-item>
<el-form-item label="问题描述" prop="description">
<el-input
v-model="questionForm.description"
type="textarea"
:rows="5"
placeholder="请详细描述您的问题或建议"
maxlength="500"
show-word-limit
></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitQuestion"></el-button>
</span>
</template>
</el-dialog>
<!-- 问题列表 -->
<div class="question-list">
<div v-if="loading" class="loading">...</div>
<div v-else-if="list.length === 0" class="empty">暂无提问记录</div>
<div v-else v-for="item in list" :key="item.id" class="question-item">
<div class="question-header">
<span class="type">{{ item.questionType }} - {{ item.type }}</span>
<span class="status" :class="{ answered: item.status === 1 }">
{{ item.status === 1 ? '已回复' : '未回复' }}
</span>
</div>
<!-- 提问内容 -->
<div class="question-content">
<div class="question-title">我的提问</div>
<p>{{ item.description }}</p>
<div class="question-time">提问时间{{ item.createdTime }}</div>
</div>
<!-- 回复内容 -->
<div class="reply-content" v-if="item.status === 1 && item.reply">
<div class="reply-title">管理员回复</div>
<p>{{ item.reply.content }}</p>
<div class="reply-time">回复时间{{ item.reply.updatedTime }}</div>
</div>
<div class="no-reply" v-else-if="item.status === 0">
<p>还未回复消息哦~</p>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="params.current"
:page-size="params.size"
:total="total"
layout="prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</div>
</BaseLayout>
</template>
<style scoped>
.question-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.question-list {
margin-bottom: 20px;
}
.question-item {
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.question-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
}
.type {
color: #666;
}
.status {
color: #f56c6c;
font-weight: bold;
}
.status.answered {
color: #67c23a;
}
.question-content,
.reply-content {
margin-bottom: 15px;
line-height: 1.5;
padding: 10px;
background-color: #f8f8f8;
border-radius: 4px;
}
.question-title,
.reply-title {
font-weight: bold;
margin-bottom: 5px;
}
.question-time,
.reply-time {
font-size: 12px;
color: #999;
text-align: right;
margin-top: 5px;
}
.reply-content {
background-color: #f0f9eb;
border-left: 3px solid #67c23a;
}
.no-reply {
padding: 10px;
color: #999;
font-style: italic;
text-align: center;
background-color: #f8f8f8;
border-radius: 4px;
}
.loading,
.empty {
text-align: center;
padding: 20px;
color: #999;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.el-select,
.el-textarea {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
}
</style>

@ -0,0 +1,191 @@
<script setup>
import { ref } from 'vue'
import { recordQueryService } from '@/api/reservation'
import BaseLayout from '@/components/BaseLayout.vue'
import { useUserStore } from '@/stores'
const userStore = useUserStore()
const userInfo = ref({ ...userStore.user })
//
const bookingRecords = ref([])
//
const statusText = {
'-2': '已过期',
'-1': '已取消',
1: '待签到',
2: '已签到'
}
const statusTagType = {
'-2': 'danger',
'-1': 'warning',
1: 'info',
2: 'success'
}
//
const getStatusText = (status) => {
return statusText[String(status)] || '未知状态'
}
//
const getStatusTagType = (status) => {
return statusTagType[String(status)] || 'info'
}
//
const currentPage = ref(1)
const pageSize = ref(10)
const totalRecords = ref(bookingRecords.value.length)
//
const loading = ref(false)
const getBookingRecords = async () => {
loading.value = true
const {
data: { data }
} = await recordQueryService({
current: currentPage.value,
size: pageSize.value,
userId: userInfo.value.id,
status: 0
})
bookingRecords.value = data.list
totalRecords.value = data.total
loading.value = false
}
getBookingRecords()
//
const detailDialogVisible = ref(false)
const reserveDetail = ref(null)
const viewDetails = (record) => {
reserveDetail.value = record
detailDialogVisible.value = true
}
</script>
<template>
<BaseLayout>
<div class="booking-records-container">
<el-main class="records-content">
<div class="page-header">
<h3>我的预约记录</h3>
</div>
<el-table
:data="bookingRecords"
style="width: 100%"
border
v-loading="loading"
>
<el-table-column type="index" label="序号" width="80" />
<el-table-column prop="dateStr" label="预约日期" width="120" />
<el-table-column prop="venueName" label="场馆名称" width="150" />
<el-table-column prop="type" label="场地类型" width="120" />
<el-table-column prop="timeStr" label="预约时间" width="150" />
<el-table-column prop="price" label="费用(元)" width="100" />
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag :type="getStatusTagType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button
type="primary"
size="small"
@click="viewDetails(scope.row)"
>查看详情</el-button
>
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next"
:total="totalRecords"
:page-size="pageSize"
v-model:current-page="currentPage"
@current-change="handlePageChange"
/>
</el-main>
<!-- 预约详情弹窗 -->
<el-dialog v-model="detailDialogVisible" title="预约详情" width="600px">
<div v-if="reserveDetail">
<el-descriptions :column="1" border>
<el-descriptions-item label="预约编号">{{
reserveDetail.reservationId
}}</el-descriptions-item>
<el-descriptions-item label="场馆名称">{{
reserveDetail.venueName
}}</el-descriptions-item>
<el-descriptions-item label="场地类型">{{
reserveDetail.type
}}</el-descriptions-item>
<el-descriptions-item label="预约日期">{{
reserveDetail.dateStr
}}</el-descriptions-item>
<el-descriptions-item label="预约时间">{{
reserveDetail.timeStr
}}</el-descriptions-item>
<el-descriptions-item label="预约金额"
>{{ reserveDetail.price }} </el-descriptions-item
>
<el-descriptions-item label="支付单号">{{
reserveDetail.payStr
}}</el-descriptions-item>
<el-descriptions-item label="支付时间">{{
reserveDetail.payDate
}}</el-descriptions-item>
<el-descriptions-item label="预约状态">
<el-tag :type="getStatusTagType(reserveDetail.status)">
{{ getStatusText(reserveDetail.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注"
>请提前15分钟到达场馆签到</el-descriptions-item
>
</el-descriptions>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="detailDialogVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</div>
</BaseLayout>
</template>
<style scoped>
.booking-records-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.records-content {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.el-table {
margin-bottom: 20px;
}
.el-pagination {
display: flex;
justify-content: center;
}
</style>

@ -0,0 +1,172 @@
<script setup>
import { ref, onMounted } from 'vue'
import { UserInfoService } from '@/api/user'
import { useUserStore } from '@/stores'
import DefaultImg from '@/assets/default.png'
import BaseLayout from '@/components/BaseLayout.vue'
import { formatDateTime } from '@/utils/format'
const userStore = useUserStore()
//
const user = ref({
id: 10,
username: 'superadmin-test',
account: 'superadmin',
userPhone: 'superadmin12334',
email: 'superadmin@qq.com',
registerTime: '2025-06-03',
updateTime: '2025-06-04',
avatar:
'http://119.29.191.232:9000/venue-sport/7311dff9c47340cfa6db1ea5414371e4.png', //
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
//
const activeTab = ref('basic')
const getUserInfo = async () => {
const {
data: { data }
} = await UserInfoService(userStore.user.id)
user.value = { ...data }
console.log(data)
}
//
onMounted(() => {
getUserInfo()
})
</script>
<template>
<BaseLayout>
<div class="profile-container">
<el-main class="profile-content">
<el-card class="profile-card">
<template #header>
<div class="card-header">
<span>个人信息</span>
</div>
</template>
<el-row :gutter="20" class="profile-header">
<el-col :span="8">
<div class="avatar-container">
<el-avatar
:size="100"
:src="user.isUpload === 1 ? user.avatar : DefaultImg"
/>
</div>
</el-col>
<el-col :span="16">
<div class="user-info">
<h3> {{ user.username }}</h3>
<p> {{ user.account }}</p>
<p>
{{
user.registerTime === null
? formatDateTime(new Date())
: user.registerTime
}}
</p>
</div>
</el-col>
</el-row>
<el-tabs v-model="activeTab" class="profile-tabs">
<el-tab-pane label="基本信息" name="basic">
<el-descriptions :column="1" border class="profile-details">
<el-descriptions-item label="用户名">{{
user.username
}}</el-descriptions-item>
<el-descriptions-item label="账号">{{
user.account
}}</el-descriptions-item>
<el-descriptions-item label="手机号">{{
user.userPhone
}}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{
user.email
}}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{
user.updateTime === null
? formatDateTime(new Date())
: user.updateTime
}}</el-descriptions-item>
<el-descriptions-item label="账户余额">{{
Number(user.balance).toFixed(2)
}}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
</el-card>
</el-main>
</div>
</BaseLayout>
</template>
<style scoped>
.profile-container {
max-width: 1250px;
margin: 0 auto;
}
.profile-content {
padding: 20px;
}
.profile-card {
max-width: 1000px;
margin: 0 auto;
}
.profile-header {
display: flex;
margin-bottom: 20px;
}
.avatar-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-btn {
margin-top: 10px;
}
.user-info {
padding-left: 20px;
}
.user-info h3 {
margin-bottom: 10px;
}
.profile-tabs {
margin-top: 20px;
}
.profile-details {
width: 100%;
}
.settings-form {
max-width: 600px;
margin: 0 auto;
}
.profile-actions {
display: flex;
justify-content: center;
margin-top: 30px;
gap: 20px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
}
</style>

@ -0,0 +1,702 @@
<script setup>
import { ref, onMounted, computed, onBeforeUnmount } from 'vue'
import { typeOneService } from '@/api/type'
import { informationListService } from '@/api/information'
import { useRoute } from 'vue-router'
import { useInfoStore } from '@/stores'
import DefaultImg from '@/assets/cover_1.jpg'
import BaseLayout from '@/components/BaseLayout.vue'
//
const route = useRoute()
//
const showDropdown = ref(false)
const searchQuery = ref('')
//
const venueInfo = ref({})
//
const getVenueInfo = async () => {
const {
data: { data }
} = await typeOneService(route.query.id ? route.query.id : 1)
venueInfo.value = { ...data }
getVenueCourts()
}
onMounted(() => {
getVenueInfo()
})
//
const venueCourts = ref([])
//
const loading = ref(false)
const getVenueCourts = async () => {
loading.value = true
const {
data: { data }
} = await informationListService(venueInfo.value.id)
console.log(data)
venueCourts.value = data
loading.value = false
}
//
const filteredCourts = computed(() => {
return venueCourts.value.filter((court) =>
court.venueName.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
const infoStore = useInfoStore()
const reserveCourt = (id, name, price) => {
// pinia
infoStore.setInfo({
typeId: venueInfo.value.id,
typeName: venueInfo.value.vname,
informationId: id,
informationName: name,
price: price
})
}
//
const handleClickOutside = (event) => {
const profileElement = document.querySelector('.user-profile')
const dropdownElement = document.querySelector('.user-dropdown')
if (profileElement && dropdownElement) {
if (
!profileElement.contains(event.target) &&
!dropdownElement.contains(event.target)
) {
showDropdown.value = false
}
}
}
//
document.addEventListener('click', handleClickOutside)
//
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<BaseLayout>
<div class="venue-details-container">
<!-- 主要内容区域 -->
<main class="main-content">
<!-- 场馆信息卡片 -->
<div class="venue-info-card">
<div class="card-header">
<div class="card-title">{{ venueInfo.vname }}</div>
<div class="card-subtitle">场馆详情</div>
</div>
<div class="card-content">
<div class="venue-banner">
<img
:src="venueInfo.imgUrl"
:alt="venueInfo.vname"
class="banner-image"
/>
</div>
<div class="venue-details">
<div class="detail-item">
<i class="el-icon-location-outline detail-icon"></i>
<span class="detail-label">位置:</span>
<span class="detail-value">{{ venueInfo.location }}</span>
</div>
<div class="detail-item">
<i class="el-icon-time detail-icon"></i>
<span class="detail-label">开馆时间:</span>
<span class="detail-value">{{ venueInfo.openTime }}</span>
</div>
<div class="detail-item">
<i class="el-icon-document detail-icon"></i>
<span class="detail-label">闭馆时间:</span>
<span class="detail-value">{{ venueInfo.closeTime }}</span>
</div>
<div class="detail-item">
<i class="el-icon-coin detail-icon"></i>
<span class="detail-label"
>场地数量{{ venueCourts.length }}</span
>
<span class="detail-value"></span>
</div>
</div>
</div>
</div>
<!-- 场地列表 -->
<div class="court-list-container">
<div class="section-header">
<h2 class="section-title">场地列表</h2>
</div>
<div class="court-grid">
<div
v-for="court in filteredCourts"
:key="court.id"
class="court-card"
v-loading="loading"
>
<div class="card-content">
<div class="court-header">
<div class="court-name">{{ court.venueName }}</div>
<div class="court-type">
<i class="el-icon-ribbon"></i> {{ court.type }}
</div>
</div>
<div class="court-info">
<div class="info-item">
<img
:src="court.imgUrl === '' ? DefaultImg : court.imgUrl"
:alt="court.venueName + '图片信息'"
:title="court.venueName + '图片信息'"
style="width: 100%"
/>
</div>
<div class="info-item">
<i class="el-icon-location-outline info-icon"></i>
<span>具体位置{{ court.location }}</span>
</div>
<div class="info-item">
<i class="el-icon-coin info-icon"></i>
<span style="font-weight: 700"
>使用价格¥ <span>{{ court.price }}</span> /
小时</span
>
</div>
</div>
</div>
<div class="card-footer">
<router-link :to="{ path: '/mobile/time' }"
><el-button
type="primary"
class="reserve-btn"
@click="
reserveCourt(court.id, court.venueName, court.price)
"
>
预约场地
</el-button>
</router-link>
</div>
</div>
</div>
</div>
</main>
</div>
</BaseLayout>
</template>
<style scoped>
/* 全局样式 */
:root {
--primary-color: #1e40af;
--secondary-color: #3b82f6;
--text-color: #334155;
--bg-gradient: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
--card-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
--card-hover-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
/* 应用容器 */
.venue-details-container {
display: flex;
flex-direction: column;
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #6c83a1 100%);
}
/* 头部样式 */
.header {
background: var(--bg-gradient);
color: white;
padding: 16px 0;
position: relative;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1440px;
margin: 0 auto;
padding: 0 32px;
}
.logo {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 600;
}
.logo-icon {
margin-right: 8px;
font-size: 24px;
}
.title {
font-size: 20px;
font-weight: 500;
}
.user-profile {
display: flex;
align-items: center;
cursor: pointer;
transition: opacity 0.2s;
}
.user-profile:hover {
opacity: 0.9;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
object-fit: cover;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.user-name {
margin-right: 8px;
font-weight: 500;
}
.user-icon {
font-size: 14px;
}
/* 用户下拉菜单 */
.user-dropdown {
position: absolute;
top: 100%;
right: 32px;
width: 280px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 1000;
animation: fadeIn 0.2s ease-in-out;
margin-top: 8px;
overflow: hidden;
}
.user-info {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e2e8f0;
}
.dropdown-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-right: 12px;
object-fit: cover;
}
.dropdown-name {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
}
.dropdown-email {
font-size: 14px;
color: #64748b;
}
.dropdown-divider {
height: 1px;
background: #e2e8f0;
}
.dropdown-menu {
border: none !important;
}
.logout-item {
color: #ef4444 !important;
}
.logout-item:hover {
background: #fef2f2 !important;
}
/* 主要内容区域 */
.main-content {
flex: 1;
max-width: 1240px;
width: 100%;
margin: 0 auto;
padding: 32px;
}
/* 返回按钮 */
.back-button-container {
margin-bottom: 24px;
}
/* 场馆信息卡片 */
.venue-info-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--card-shadow);
margin-bottom: 40px;
}
.card-header {
background: var(--bg-gradient);
color: rgb(8, 7, 7);
padding: 20px 32px;
}
.card-title {
font-size: 24px;
font-weight: 600;
margin-bottom: 4px;
}
.card-subtitle {
font-size: 16px;
opacity: 0.8;
}
.card-content {
padding: 32px;
}
.venue-banner {
margin-bottom: 24px;
border-radius: 8px;
overflow: hidden;
}
.banner-image {
width: 70%;
height: 30%;
margin-left: 11vw;
object-fit: cover;
}
.venue-details {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.detail-item {
display: flex;
align-items: center;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
}
.detail-icon {
color: var(--secondary-color);
font-size: 18px;
margin-right: 12px;
}
.detail-label {
font-weight: 500;
color: #64748b;
margin-right: 8px;
}
.detail-value {
color: var(--text-color);
}
/* 场地列表部分 */
.court-list-container {
margin-bottom: 40px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.section-title {
font-size: 24px;
font-weight: 600;
color: var(--text-color);
}
.section-filter {
display: flex;
gap: 16px;
}
.search-input {
width: 280px;
}
.search-icon {
color: #94a3b8;
}
.court-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.court-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--card-shadow);
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
display: flex;
flex-direction: column;
height: 100%;
}
.court-card:hover {
transform: translateY(-4px);
box-shadow: var(--card-hover-shadow);
}
.card-content {
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
}
.court-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.court-name {
font-size: 18px;
font-weight: 600;
color: var(--text-color);
}
.court-type {
background: #e0f2fe;
color: #0284c7;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.court-info {
/* display: flex; */
flex-wrap: wrap;
gap: 12px;
}
.info-item {
/* display: flex; */
margin-bottom: 15px;
align-items: center;
font-size: 16px;
color: #64748b;
}
.info-icon {
margin-right: 4px;
color: var(--secondary-color);
}
.card-footer {
padding: 16px;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: center;
}
.reserve-btn {
width: 100%;
padding: 10px 16px;
font-size: 16px;
font-weight: 500;
border-radius: 6px;
}
/* 页脚样式 */
.footer {
background: var(--bg-gradient);
color: white;
padding: 40px 0;
}
.footer-content {
max-width: 1440px;
margin: 0 auto;
padding: 0 32px;
display: flex;
justify-content: space-between;
margin-bottom: 32px;
}
.footer-info {
max-width: 300px;
}
.footer-logo {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
}
.footer-desc {
font-size: 16px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.8);
}
.footer-links {
display: flex;
gap: 64px;
}
.footer-column {
min-width: 200px;
}
.column-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.column-list {
list-style: none;
padding: 0;
margin: 0;
}
.column-list li {
margin-bottom: 12px;
display: flex;
align-items: center;
color: rgba(255, 255, 255, 0.8);
}
.footer-icon {
margin-right: 8px;
width: 16px;
text-align: center;
}
.link-item {
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: color 0.2s;
}
.link-item:hover {
color: white;
text-decoration: underline;
}
.footer-copyright {
text-align: center;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式布局 */
@media (max-width: 1024px) {
.header-content,
.main-content,
.footer-content {
padding: 0 24px;
}
.title {
display: none;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.section-filter {
width: 100%;
flex-direction: column;
gap: 12px;
}
.search-input {
width: 100%;
}
.footer-content {
flex-direction: column;
gap: 32px;
}
.footer-links {
flex-direction: column;
gap: 32px;
}
.venue-banner {
height: 200px;
}
}
@media (max-width: 768px) {
.carousel-container {
height: 200px;
}
.carousel-title {
font-size: 18px;
}
.carousel-desc {
font-size: 14px;
}
.venue-grid,
.court-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,713 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUserStore, useInfoStore } from '@/stores'
import { timeUseService } from '@/api/information'
import { reservationSubmitService } from '@/api/reservation'
import { useRouter } from 'vue-router'
import { formatTime } from '@/utils/format'
import BaseLayout from '@/components/BaseLayout.vue'
import { ElMessage, ElButton } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const infoStore = useInfoStore()
//
const userInfo = ref({ ...userStore.user })
//
const baseInfo = ref({ ...infoStore.info })
//
const formModel = ref({
userId: 16,
venueId: 74,
times: [
{
id: 2,
startTime: '13:00:00',
endTime: '14:00:00'
}
],
reservationDate: '2025-03-07',
money: 9
})
//
const venueData = ref([
{ id: 1, startTime: '08:00', endTime: '09:00', disabled: false },
{ id: 2, startTime: '09:00', endTime: '10:00', disabled: false },
{ id: 3, startTime: '10:00', endTime: '11:00', disabled: false },
{ id: 4, startTime: '11:00', endTime: '12:00', disabled: false },
{ id: 5, startTime: '12:00', endTime: '13:00', disabled: false },
{ id: 6, startTime: '13:00', endTime: '14:00', disabled: false },
{ id: 7, startTime: '14:00', endTime: '15:00', disabled: false },
{ id: 8, startTime: '15:00', endTime: '16:00', disabled: false },
{ id: 9, startTime: '16:00', endTime: '17:00', disabled: false },
{ id: 10, startTime: '17:00', endTime: '18:00', disabled: false }
])
//
const getVenueData = async (dataStr) => {
const {
data: { data, message }
} = await timeUseService({
reservationDate: dataStr,
typeId: baseInfo.value.typeId,
informationId: baseInfo.value.informationId
})
console.log('data', data)
if (data === null) {
ElMessage.info(message)
venueData.value = []
} else {
venueData.value = data.times
}
}
//
const selectedDateIndex = ref(0)
const selectedTimes = ref([])
// const timeSlots = ref([...venueData])
const dateList = ref([])
//
const initDateList = () => {
const result = []
const today = new Date()
for (let i = 0; i < 7; i++) {
const date = new Date(today)
date.setDate(today.getDate() + i)
const dayOfWeek = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][
date.getDay()
]
const month = date.getMonth() + 1
const dateNum = date.getDate()
result.push({
dayOfWeek,
month,
date: dateNum,
fullDate: date
})
}
dateList.value = result
}
//
const totalPrice = computed(() => {
return selectedTimes.value.length * baseInfo.value.price
})
//
const selectDate = (index) => {
if (selectedDateIndex.value !== index) {
selectedDateIndex.value = index
selectedTimes.value = [] //
console.log(formatTime(dateList.value[index].fullDate))
getVenueData(formatTime(dateList.value[index].fullDate))
//
console.log('Selected date:', dateList.value[index].fullDate)
// API
}
}
const selectTime = (timeId) => {
//
if (selectedTimes.value.includes(timeId)) {
selectedTimes.value = selectedTimes.value.filter((id) => id !== timeId)
return
}
//
const newSelectedTimes = [...selectedTimes.value, timeId].sort(
(a, b) => a - b
)
//
if (newSelectedTimes.length > 1) {
let isContinuous = true
for (let i = 1; i < newSelectedTimes.length; i++) {
if (newSelectedTimes[i] - newSelectedTimes[i - 1] !== 1) {
isContinuous = false
break
}
}
if (!isContinuous) {
//
selectedTimes.value = [timeId]
ElMessage.warning('请选择连续的时间段,已自动清除之前的选择')
return
}
}
//
selectedTimes.value = newSelectedTimes
}
const getSelectedTimeDisplay = () => {
if (selectedTimes.value.length === 0) return '未选择时间段'
const selected = venueData.value.filter((time) =>
selectedTimes.value.includes(time.id)
)
if (selected.length === 0) return '未选择时间段'
// id
selected.sort((a, b) => a.id - b.id)
//
return selected.map((time) => `${time.startTime}-${time.endTime}`).join('、')
}
const selectedDateStr = ref('')
const getSelectedDateDisplay = () => {
if (dateList.value.length === 0) return ''
const date = dateList.value[selectedDateIndex.value]
selectedDateStr.value = formatTime(date.fullDate)
return `${date.month}${date.date}${date.dayOfWeek}${selectedDateIndex.value === 0 ? '(今天)' : ''}`
}
const submitBooking = () => {
if (selectedTimes.value.length === 0) {
ElMessage.warning('请选择预约时间段')
return
}
ElMessageBox.confirm(
`
<div style="text-align: left;font-weight: 700">
<p>您确认提交以下预约信息吗</p>
<p>用户${userInfo.value.username}</p>
<p>场馆${baseInfo.value.typeName}</p>
<p>场地${baseInfo.value.informationName}</p>
<p>预约日期${selectedDateStr.value}</p>
<p>预约时间段${getSelectedTimeDisplay()}</p>
<p>预约费用${totalPrice.value.toFixed(2)} </p>
</div>
`,
'确认预约',
{
confirmButtonText: '确认预约',
cancelButtonText: '取消',
type: 'none',
dangerouslyUseHTMLString: true
}
)
.then(async () => {
//
formModel.value = {
userId: userInfo.value.id,
venueId: baseInfo.value.informationId,
reservationDate: selectedDateStr.value,
times: getSelectedTimeDisplay()
.split('、')
.map((slot, index) => {
const [start, end] = slot.split('-')
return { id: index + 1, startTime: start, endTime: end }
}),
money: totalPrice.value.toFixed(2)
}
const {
data: { data }
} = await reservationSubmitService(formModel.value)
console.log(data)
//
infoStore.setResponseInfo({
user: userInfo.value.username,
venue: baseInfo.value.typeName,
site: baseInfo.value.informationName,
date: selectedDateStr.value,
timeSlots: getSelectedTimeDisplay(),
cost: totalPrice.value.toFixed(2)
})
//
infoStore.setPaymentInfo(data)
//
router.push('/mobile/result/success')
})
.catch(() => {
ElMessage({
type: 'info',
message: '预约已取消'
})
})
//
console.log('提交预约:', formModel.value)
ElMessage.success('预约提交成功请在30分钟内完成支付')
}
const resetSelection = () => {
selectedTimes.value = []
}
//
onMounted(() => {
console.log(infoStore.info)
initDateList()
getVenueData(formatTime(new Date()))
})
</script>
<template>
<BaseLayout>
<div class="app-container">
<!-- 主内容区 -->
<main>
<div class="content-container">
<div class="booking-container">
<!-- 左侧预约选择区 -->
<div class="booking-form">
<h2 class="section-title">场馆预约</h2>
<!-- 已选信息 -->
<div class="selected-info">
<h3 class="info-title">已选信息</h3>
<div class="info-grid">
<div class="info-item">
<p class="info-label">场馆名称</p>
<p class="info-value">{{ baseInfo.typeName }}</p>
</div>
<div class="info-item">
<p class="info-label">场地名称</p>
<p class="info-value">{{ baseInfo.informationName }}</p>
</div>
<div class="info-item">
<p class="info-label">场地单价</p>
<p class="info-value">
¥{{
baseInfo.price ? baseInfo.price.toFixed(2) : 0.0
}}/小时
</p>
</div>
<div class="info-item">
<p class="info-label">预约人</p>
<p class="info-value">{{ userInfo.username }}</p>
</div>
</div>
</div>
<!-- 选择日期 -->
<div class="date-selection">
<h3 class="selection-title">选择日期</h3>
<div class="date-grid">
<div
v-for="(dateItem, index) in dateList"
:key="index"
class="date-item"
:class="{
'date-item-selected': selectedDateIndex === index
}"
@click="selectDate(index)"
>
<div class="date-day">{{ dateItem.dayOfWeek }}</div>
<div class="date-date">
{{ dateItem.month }}{{ dateItem.date }}
</div>
<div v-if="index === 0" class="date-today"></div>
</div>
</div>
</div>
<!-- 选择时间段 -->
<div class="time-selection">
<h3 class="selection-title">选择时间段连续的时间段</h3>
<div class="time-grid">
<div v-if="venueData.length === 0"></div>
<div
v-for="time in venueData"
:key="time.id"
class="time-slot"
:class="{
'time-slot-selected': selectedTimes.includes(time.id),
'time-slot-disabled': time.disabled
}"
@click="!time.disabled && selectTime(time.id)"
>
{{ time.startTime }}-{{ time.endTime }}
</div>
</div>
<div
v-if="selectedTimes.length > 0"
class="selected-times-display"
>
已选时间段: {{ getSelectedTimeDisplay() }}
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
type="primary"
@click="submitBooking"
:disabled="selectedTimes.length === 0"
>确认预约</el-button
>
<el-button type="info" @click="resetSelection"></el-button>
<el-button type="info" @click="router.go(-1)"></el-button>
</div>
</div>
<!-- 右侧订单信息 -->
<div class="order-info">
<h2 class="section-title">订单信息</h2>
<div class="order-details">
<div class="order-item">
<span class="order-label">场馆名称</span>
<span class="order-value">{{ baseInfo.typeName }}</span>
</div>
<div class="order-item">
<span class="order-label">场地名称</span>
<span class="order-value">{{
baseInfo.informationName
}}</span>
</div>
<div class="order-item">
<span class="order-label">预约日期</span>
<span class="order-value">{{
getSelectedDateDisplay()
}}</span>
</div>
<div class="order-item">
<span class="order-label">预约时间</span>
<span class="order-value">
<li
v-for="(slot, index) in getSelectedTimeDisplay().split(
'、'
)"
:key="index"
>
<el-tag type="success">{{ slot }}</el-tag>
</li>
</span>
</div>
<div class="order-item">
<span class="order-label">预约时长</span>
<span class="order-value"
>{{ selectedTimes.length }}小时</span
>
</div>
<div class="order-item total">
<span class="order-label">订单总价</span>
<span class="order-value total-price"
>¥{{ totalPrice.toFixed(2) }}</span
>
</div>
</div>
<div class="order-notes">
<p class="note">* 预约成功后请在30分钟内完成支付</p>
<p class="note">* 如需取消预约请提前24小时操作</p>
</div>
</div>
</div>
</div>
</main>
</div>
</BaseLayout>
</template>
<style scoped>
/* 全局样式 */
.app-container {
background-color: #f9fafb;
min-height: 100vh;
font-family: 'Inter', sans-serif;
}
li {
text-decoration: none;
list-style: none;
margin-bottom: 3px;
}
/* 头部样式 */
header {
background-color: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header-content {
max-width: 1280px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo-section {
display: flex;
align-items: center;
}
.main-title {
color: #165dff;
font-size: 20px;
font-weight: bold;
margin-right: 8px;
}
.divider {
color: #94a3b8;
margin-right: 8px;
}
.sub-title {
color: #64748b;
font-size: 14px;
font-weight: 500;
}
.user-section {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e2e8f0;
}
.user-name {
margin-left: 8px;
color: #334155;
font-weight: 500;
}
.user-icon {
margin-left: 4px;
color: #94a3b8;
}
/* 主内容区样式 */
main {
max-width: 1280px;
margin: 0 auto;
padding: 32px 24px;
}
.content-container {
background-color: #ffffff;
border-radius: 12px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.booking-container {
display: grid;
grid-template-columns: 2fr 1fr;
}
/* 左侧表单样式 */
.booking-form {
padding: 32px;
border-right: 1px solid #e2e8f0;
}
.section-title {
font-size: 20px;
font-weight: bold;
color: #1e293b;
margin-bottom: 24px;
}
.selected-info {
background-color: #f8fafc;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.info-title {
font-weight: 500;
color: #475569;
margin-bottom: 16px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
}
.info-value {
font-weight: 500;
color: #1e293b;
}
.date-selection,
.time-selection {
margin-top: 20px;
margin-bottom: 24px;
}
.selection-title {
font-weight: 500;
color: #475569;
margin-bottom: 12px;
}
.date-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.date-item {
background-color: #f1f5f9;
border-radius: 8px;
padding: 12px 0;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.date-item:hover {
background-color: #e2e8f0;
}
.date-item-selected {
background-color: #165dff;
color: white;
}
.date-day {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.date-date {
font-size: 12px;
}
.date-today {
position: absolute;
top: -8px;
right: -8px;
background-color: #ef4444;
color: white;
font-size: 10px;
padding: 2px 4px;
border-radius: 4px;
}
.time-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.time-slot {
background-color: #f1f5f9;
border-radius: 6px;
padding: 8px 4px;
text-align: center;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.time-slot:hover:not(.time-slot-disabled) {
background-color: #e2e8f0;
}
.time-slot-selected {
background-color: #165dff;
color: white;
}
.time-slot-disabled {
background-color: #f8fafc;
color: #cbd5e1;
cursor: not-allowed;
}
.selected-times-display {
margin-top: 12px;
font-size: 14px;
color: #64748b;
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 右侧订单信息样式 */
.order-info {
padding: 32px;
background-color: #f8fafc;
}
.order-details {
background-color: #ffffff;
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.order-item {
display: flex;
padding: 8px 0;
border-bottom: 1px solid #f1f5f9;
}
.order-item:last-child {
border-bottom: none;
}
.order-label {
flex: 1;
color: #64748b;
}
.order-value {
flex: 1;
text-align: right;
font-weight: 500;
color: #1e293b;
}
.total {
margin-top: 8px;
}
.total-price {
color: #ef4444;
font-weight: bold;
}
.order-notes {
font-size: 12px;
color: #94a3b8;
}
.note {
margin-bottom: 4px;
}
/* Element UI 组件样式调整 */
.el-button {
padding: 8px 16px;
}
</style>

@ -0,0 +1,145 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
//
const errorData = {
subtitle: '很抱歉,您的预约未能成功提交',
reason: '您选择的时间段已被其他用户抢先预约,请选择其他时间段。',
solution: '建议您选择其他时间段或日期进行预约,或稍后再试。',
contact: '如需帮助请联系场馆管理处0791-12345678'
}
//
const errorSubTitle = ref(errorData.subtitle)
const errorInfo = ref({
reason: errorData.reason,
solution: errorData.solution,
contact: errorData.contact
})
//
const router = useRouter()
//
const retryBooking = () => {
router.push('/booking')
}
//
const goBack = () => {
router.push('/')
}
</script>
<template>
<div class="booking-error-container">
<el-header class="header">
<el-row justify="space-between" align="middle">
<h2>校园 e 站通华交场馆管控一体化平台</h2>
<el-dropdown>
<img
class="user-avatar"
src="https://cube.elemecdn.com/3/7c/3ea6beec24aa581799c9dec44b590jpeg.jpeg"
alt="用户头像"
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人信息</el-dropdown-item>
<el-dropdown-item>我的订单</el-dropdown-item>
<el-dropdown-item divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-row>
</el-header>
<el-main class="error-content">
<el-result icon="error" title="预约失败" :sub-title="errorSubTitle">
<template #extra>
<el-button type="primary" @click="retryBooking"
>修改预约信息</el-button
>
<el-button @click="goBack"></el-button>
</template>
</el-result>
<el-card class="error-details" v-if="errorInfo">
<template #header>
<div class="card-header">
<span>问题详情</span>
</div>
</template>
<div class="detail-item">
<span class="label">错误原因</span>
<span class="value">{{ errorInfo.reason }}</span>
</div>
<div class="detail-item" v-if="errorInfo.solution">
<span class="label">解决建议</span>
<span class="value">{{ errorInfo.solution }}</span>
</div>
<div class="detail-item" v-if="errorInfo.contact">
<span class="label">联系支持</span>
<span class="value">{{ errorInfo.contact }}</span>
</div>
</el-card>
</el-main>
</div>
</template>
<style scoped>
.booking-error-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.el-result {
margin-bottom: 30px;
}
.error-details {
max-width: 600px;
width: 100%;
}
.detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
padding: 10px 0;
border-bottom: 1px dashed #ebeef5;
}
.detail-item .label {
color: #909399;
width: 120px;
flex-shrink: 0;
}
.detail-item .value {
color: #303133;
font-weight: 500;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
}
</style>

@ -0,0 +1,197 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useInfoStore } from '@/stores'
import { toTimestamp } from '@/utils/format'
import BaseLayout from '@/components/BaseLayout.vue'
const infoStore = useInfoStore()
//
const bookingInfo = ref({ ...infoStore.responseInfo })
//
const payment = ref({ ...infoStore.paymentInfo })
console.log('payment', payment.value)
console.log('bookingInfo', bookingInfo.value)
//
const router = useRouter()
//
const viewOrder = () => {}
</script>
<template>
<BaseLayout>
<div class="booking-result-container">
<el-main class="result-content">
<el-result
icon="success"
title="预约成功!"
sub-title="您的预约信息已确认,我们期待您的光临"
>
<template #extra>
<el-button type="primary" @click="router.push('/mobile/index')"
>返回首页</el-button
>
<el-button @click="viewOrder"></el-button>
</template>
</el-result>
<el-row class="details-container" gutter="20">
<!-- 左侧预约详情 -->
<el-col :span="12">
<el-card class="details-card">
<template #header>
<div class="card-header">
<span>预约详情</span>
</div>
</template>
<div class="detail-item">
<span class="label">用户</span>
<span class="value">{{ bookingInfo.user }} </span>
</div>
<div class="detail-item">
<span class="label">场馆</span>
<span class="value">{{ bookingInfo.venue }}</span>
</div>
<div class="detail-item">
<span class="label">场地</span>
<span class="value">{{ bookingInfo.site }}</span>
</div>
<div class="detail-item">
<span class="label">预约日期</span>
<span class="value">{{ bookingInfo.date }}</span>
</div>
<div class="detail-item">
<span class="label">预约时间段</span>
<span class="value">{{ bookingInfo.timeSlots }}</span>
</div>
<div class="detail-item">
<span class="label">预约时长</span>
<span class="value">{{ bookingInfo.cost }} 小时</span>
</div>
</el-card>
</el-col>
<!-- 右侧订单详情 -->
<el-col :span="12">
<el-card class="details-card">
<template #header>
<div class="card-header">
<span>订单详情</span>
</div>
</template>
<div class="detail-item">
<span class="label">订单号</span>
<span class="value"
>{{ toTimestamp(payment.time) }} - {{ payment.id }}</span
>
</div>
<div class="detail-item">
<span class="label">预约费用</span>
<span class="value">{{ payment.amount }} </span>
</div>
<div class="detail-item">
<span class="label">支付状态</span>
<span class="value status paid">已支付</span>
</div>
<div class="detail-item">
<span class="label">订单创建时间</span>
<span class="value">{{ payment.time }}</span>
</div>
<div class="detail-item">
<span class="label">预约状态</span>
<span class="value status confirmed">余额支付</span>
</div>
<div class="detail-item">
<span class="label">备注</span>
<span class="value">至多只能提前15分钟到达场馆签到</span>
</div>
</el-card>
</el-col>
</el-row>
</el-main>
</div>
</BaseLayout>
</template>
<style scoped>
.booking-result-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.result-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.el-result {
margin-bottom: 30px;
width: 100%;
}
.details-container {
width: 100%;
margin-top: 20px;
}
.details-card {
height: 100%;
}
.detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
padding: 10px 0;
border-bottom: 1px dashed #ebeef5;
}
.detail-item:last-child {
border-bottom: none;
}
.detail-item .label {
color: #909399;
width: 120px;
flex-shrink: 0;
}
.detail-item .value {
color: #303133;
font-weight: 500;
}
.status {
padding: 2px 8px;
border-radius: 4px;
font-size: 14px;
}
.status.paid {
background-color: #f0f9eb;
color: #67c23a;
}
.status.confirmed {
background-color: #e8f4fc;
color: #1890ff;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
}
</style>

@ -0,0 +1,285 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Iphone, Message, Key } from '@element-plus/icons-vue'
import { ResetService } from '@/api/user'
const router = useRouter()
const captchaCanvas = ref(null)
const forgetForm = ref(null)
const submitting = ref(false)
//
const form = ref({
phone: '',
email: '',
captcha: ''
})
//
const captchaAnswer = ref('')
//
const rules = {
phone: [{ required: true, message: '请输入手机号码', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ validator: validateCaptcha, trigger: 'blur' }
]
}
//
function validateCaptcha(rule, value, callback) {
if (value.toLowerCase() !== captchaAnswer.value.toLowerCase()) {
callback(new Error('验证码不正确'))
} else {
callback()
}
}
//
const generateCaptcha = () => {
const canvas = captchaCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
//
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
let captcha = ''
//
ctx.fillStyle = '#f5f7fa'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 线
for (let i = 0; i < 5; i++) {
ctx.strokeStyle = getRandomColor(100, 200)
ctx.beginPath()
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height)
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height)
ctx.stroke()
}
//
for (let i = 0; i < 4; i++) {
const char = chars[Math.floor(Math.random() * chars.length)]
captcha += char
ctx.fillStyle = getRandomColor(50, 150)
ctx.font = `${Math.floor(20 + Math.random() * 10)}px Arial`
ctx.fillText(char, 10 + i * 30, 30 + (Math.random() * 10 - 5))
}
captchaAnswer.value = captcha
}
//
const getRandomColor = (min, max) => {
const r = min + Math.floor(Math.random() * (max - min))
const g = min + Math.floor(Math.random() * (max - min))
const b = min + Math.floor(Math.random() * (max - min))
return `rgb(${r}, ${g}, ${b})`
}
//
const refreshCaptcha = () => {
generateCaptcha()
}
//
const submitForm = () => {
forgetForm.value.validate(async (valid) => {
if (valid) {
submitting.value = true
const {
data: { message }
} = await ResetService(form.value)
submitting.value = false
resetForm()
ElMessage.success(message)
}
})
}
//
function resetForm() {
forgetForm.value.resetFields()
refreshCaptcha()
}
//
function goToLogin() {
router.push('/login')
}
onMounted(() => {
generateCaptcha()
})
</script>
<template>
<div class="forget-password-page">
<div class="brand-header">
<h1>校园 e 站通</h1>
<h2>华交场馆管控一体化平台</h2>
</div>
<div class="forget-password-container">
<div class="form-panel">
<h3 class="form-title">找回密码</h3>
<el-form
ref="forgetForm"
:model="form"
:rules="rules"
label-width="100px"
class="forget-form"
>
<el-form-item label="手机号码" prop="phone">
<el-input
v-model="form.phone"
placeholder="请输入注册时绑定的手机号"
clearable
>
<template #prefix>
<el-icon><Iphone /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="QQ邮箱" prop="email">
<el-input
v-model="form.email"
placeholder="请输入注册时绑定的QQ邮箱"
clearable
>
<template #prefix>
<el-icon><Message /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="captcha">
<div class="captcha-input">
<el-input
v-model="form.captcha"
placeholder="请输入右侧验证码"
clearable
>
<template #prefix>
<el-icon><Key /></el-icon>
</template>
</el-input>
<div class="captcha-image" @click="refreshCaptcha">
<canvas ref="captchaCanvas" width="250" height="40"></canvas>
</div>
</div>
</el-form-item>
<div>
<el-button
type="primary"
@click="submitForm"
:loading="submitting"
class="submit-btn"
>
提交验证
</el-button>
<el-button @click="resetForm" class="reset-btn">重置</el-button>
</div>
</el-form>
<div class="back-login">
<el-link type="primary" @click="goToLogin"></el-link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.forget-password-page {
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
display: flex;
flex-direction: column;
align-items: center;
padding-top: 50px;
}
.brand-header {
text-align: center;
margin-bottom: 40px;
color: #2c3e50;
}
.brand-header h1 {
font-size: 32px;
font-weight: bold;
margin-bottom: 10px;
}
.brand-header h2 {
font-size: 18px;
font-weight: normal;
color: #5a6a85;
}
.forget-password-container {
width: 100%;
max-width: 500px;
}
.form-panel {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 30px;
}
.form-title {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
font-size: 20px;
}
.forget-form {
margin-top: 20px;
}
.captcha-input {
display: flex;
align-items: center;
gap: 10px;
}
.captcha-image {
cursor: pointer;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.captcha-image:hover {
border-color: #c0c4cc;
}
.submit-btn {
width: 60%;
margin-left: 1.9vw;
}
.reset-btn {
width: 30%;
}
.back-login {
text-align: center;
margin-top: 20px;
}
.el-form-item {
margin-bottom: 22px;
}
</style>

@ -0,0 +1,158 @@
<script setup>
import { ref } from 'vue'
import { slotPageService, slotDel } from '@/api/slot'
import { View, Delete, Plus } from '@element-plus/icons-vue'
import VenueEdit from './components/VenueEdit.vue'
import VenueAdd from './components/VenueAdd.vue'
import TimeAdd from './components/TimeAdd.vue'
const loading = ref(false)
const total = ref(0)
const timeList = ref([])
const params = ref({
current: 1,
size: 10
})
//
const getTimeList = async () => {
loading.value = true
const {
data: { data }
} = await slotPageService(params.value)
timeList.value = data.list
total.value = data.total
loading.value = false
}
getTimeList()
//
const handleCurrentChange = (current) => {
params.value.current = current
getTimeList()
}
//
const handleSizeChange = (size) => {
params.value.current = 1
params.value.size = size
getTimeList()
}
//
const venueEditRef = ref()
const editMsg = (row) => {
venueEditRef.value.open(
row.slotStart + ' ~ ' + row.slotEnd + '(' + row.reservationDate + ')',
row.id
)
}
//
const venueAddRef = ref()
const addMsg = (row) => {
venueAddRef.value.open(row)
}
//
const timeAddRef = ref()
const onAdd = () => {
timeAddRef.value.open()
}
//
const onDelete = (sid) => {
ElMessageBox.confirm('确认移除此时间段?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const formData = new FormData()
formData.append('venueId', -1)
formData.append('slotId', sid)
await slotDel(formData)
ElMessage({
type: 'success',
message: '移除成功'
})
handleSizeChange(5)
})
.catch(() => {
ElMessage({
type: 'info',
message: '移除失败'
})
})
}
</script>
<template>
<page-container title="禁用记录">
<template #extra>
<el-button type="primary" @click="onAdd">+ </el-button>
</template>
<!-- 表格区域 -->
<el-table :data="timeList" stripe v-loading="loading">
<el-table-column type="index" label="序号" width="100" />
<!-- <el-table-column label="禁用场地(场馆)" prop="venueName"> -->
<!-- </el-table-column> -->
<el-table-column
label="禁用日期"
prop="reservationDate"
></el-table-column>
<el-table-column label="开始时间" prop="slotStart"></el-table-column>
<el-table-column label="结束时间" prop="slotEnd"></el-table-column>
<el-table-column label="设置人" prop="username"></el-table-column>
<el-table-column label="设置时间" prop="addTime"></el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button
circle
plain
type="success"
:icon="View"
@click="editMsg(row)"
title="查看当前禁用时间段下有哪些场地"
></el-button>
<el-button
circle
plain
type="primary"
:icon="Plus"
@click="addMsg(row)"
title="添加场地数据"
></el-button>
<el-button
circle
plain
type="danger"
:icon="Delete"
@click="onDelete(row.id)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="无禁用时间段数据" />
</template>
</el-table>
<!-- 分页框 -->
<el-pagination
v-model:current-page="params.current"
v-model:page-size="params.size"
:page-sizes="[10, 20, 50]"
background
layout="total,sizes, prev, pager, next"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
style="float: right; margin-top: 30px"
/>
</page-container>
<!-- 场地信息编辑框 -->
<VenueEdit ref="venueEditRef"></VenueEdit>
<!-- 场地信息添加框 -->
<VenueAdd ref="venueAddRef"></VenueAdd>
<!-- 添加禁用时间段 -->
<TimeAdd ref="timeAddRef" @success="getTimeList"></TimeAdd>
</template>

@ -0,0 +1,164 @@
<script setup>
import { ref } from 'vue'
import { timeToNumber, formatTime } from '@/utils/format'
import { slotAdd } from '@/api/slot'
import { useUserStore } from '@/stores'
const userStore = useUserStore()
const dialogVisible = ref(false)
const formRef = ref()
//
const slotForm = ref({
reservationDate: '',
slotStart: '',
slotEnd: '',
userId: ''
})
//
const rules = {
reservationDate: [
{ required: true, message: '请选择禁用日期', trigger: 'blur' }
],
slotStart: [
{ required: true, message: '请选择开始时间', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (
slotForm.value.slotEnd !== null &&
timeToNumber(value) >= timeToNumber(slotForm.value.slotEnd)
) {
callback(new Error('错误开始时间'))
} else {
// callback()
callback()
}
},
trigger: 'blur'
}
],
slotEnd: [
{ required: true, message: '请选择结束时间', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (
slotForm.value.slotStart !== null &&
timeToNumber(value) <= timeToNumber(slotForm.value.slotStart)
) {
callback(new Error('错误结束时间'))
} else {
// callback()
callback()
}
},
trigger: 'blur'
}
]
}
// open open
const open = () => {
slotForm.value.userId = userStore.user.id
dialogVisible.value = true
}
//
defineExpose({
open
})
//
const closeDrawer = () => {
slotForm.value = {
reservationDate: '',
slotStart: '',
slotEnd: '',
userId: ''
}
formRef.value.clearValidate()
dialogVisible.value = false
}
const emit = defineEmits(['success'])
//
const onSubmit = async () => {
//
await formRef.value.validate()
ElMessageBox.confirm('是否确认添加此时间段?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
// console.log(slotForm.value);
slotForm.value.reservationDate = formatTime(
slotForm.value.reservationDate
)
await slotAdd(slotForm.value)
ElMessage({
type: 'success',
message: '添加成功'
})
closeDrawer()
emit('success')
})
.catch((err) => {
ElNotification({
title: 'Error',
message: err.message,
type: 'error'
})
closeDrawer()
})
}
</script>
<template>
<el-dialog
v-model="dialogVisible"
title="添加时间段"
width="350"
draggable
@close="closeDrawer"
>
<el-form
ref="formRef"
:model="slotForm"
:rules="rules"
label-width="100px"
style="padding-right: 30px"
>
<el-form-item label="禁用日期" prop="reservationDate">
<el-date-picker
v-model="slotForm.reservationDate"
type="date"
placeholder="请选择禁用日期"
style="width: 170px"
/>
</el-form-item>
<el-form-item label="开始时间" prop="slotStart">
<el-time-select
v-model="slotForm.slotStart"
style="width: 170px"
start="08:00"
step="01:00"
end="18:00"
placeholder="请选择开始时间"
@change="changeTime"
/>
</el-form-item>
<el-form-item label="结束时间" prop="slotEnd">
<el-time-select
v-model="slotForm.slotEnd"
style="width: 170px"
start="08:00"
step="01:00"
end="18:00"
placeholder="请选择结束时间"
@change="changeTime"
/>
</el-form-item>
</el-form>
<el-button type="primary" style="margin-left: 30px" @click="onSubmit"
>点击提交</el-button
>
</el-dialog>
</template>

@ -0,0 +1,100 @@
<script setup>
import { ref } from 'vue'
// import { View, Delete, Plus } from '@element-plus/icons-vue'
import { venuesQuery, slotSet } from '@/api/slot'
const drawerVisible = ref(false)
//
const slotModel = ref({})
//
const venues = ref([])
//
const checkedGroup = ref([])
// open open
// const timeStr = ref('')
const open = async (venueData) => {
console.log(venueData)
venues.value = []
slotModel.value = { ...venueData }
const {
data: { data }
} = await venuesQuery(venueData.id)
console.log(data)
venues.value = data
drawerVisible.value = true
}
//
defineExpose({
open
})
//
const closeDrawer = () => {
drawerVisible.value = false
checkedGroup.value = []
}
//
const onSubmit = async () => {
ElMessageBox.confirm('是否确认添加?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const formData = new FormData()
formData.append('ids', checkedGroup.value)
formData.append('slotId', slotModel.value.id)
await slotSet(formData)
ElMessage({
type: 'success',
message: '添加成功'
})
closeDrawer()
})
.catch(() => {
ElMessage({
type: 'info',
message: '错误信息'
})
closeDrawer()
})
}
</script>
<template>
<el-drawer v-model="drawerVisible" title="详细信息" width="450" draggable>
<em>禁用日期: {{ slotModel.reservationDate }}</em>
<hr />
<em>禁用时间: {{ slotModel.slotStart + ' ~ ' + slotModel.slotEnd }}</em>
<hr />
<div v-if="venues.length === 0">
<el-empty description="暂无可添加场地信息"> </el-empty>
</div>
<div v-else>
<div v-for="(item, index) in venues" :key="index">
<h4>{{ item.vname }} 可选择场地:</h4>
<div v-if="item.list.length === 0">
<el-empty description="暂无可添加场地信息"> </el-empty>
</div>
<div v-else>
<el-checkbox-group v-model="checkedGroup">
<el-checkbox
v-for="(item1, index1) in item.list"
:key="index1"
:label="item1.id"
border
>{{ item1.venueName }}</el-checkbox
>
</el-checkbox-group>
</div>
<hr />
</div>
</div>
<span class="drawer-footer">
<el-button type="primary" @click="onSubmit"> </el-button>
</span>
</el-drawer>
</template>

@ -0,0 +1,82 @@
<script setup>
import { ref } from 'vue'
import { venuesInquire, slotDel } from '@/api/slot'
// import { View, Delete, Plus } from '@element-plus/icons-vue'
const loading = ref(false)
const dialogVisible = ref(false)
const formData = new FormData()
//
const venueList = ref([])
const getVenueList = async (id) => {
loading.value = true
const {
data: { data }
} = await venuesInquire(id)
venueList.value = data
loading.value = false
console.log(data)
}
// open open
const timeStr = ref('')
const open = (str, id) => {
getVenueList(id)
formData.append('slotId', id)
timeStr.value = str
dialogVisible.value = true
}
//
defineExpose({
open
})
//
const closeDialog = () => {
formData.delete('slotId')
formData.delete('venueId')
dialogVisible.value = false
}
//
const onRemove = (venueId) => {
ElMessageBox.confirm('是否确认移除该条场地信息?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
formData.append('venueId', venueId)
await slotDel(formData)
ElMessage({
type: 'success',
message: '移除成功'
})
closeDialog()
})
.catch(() => {
closeDialog()
})
}
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="'时间段:' + timeStr"
width="550"
draggable
>
<el-table :data="venueList" v-loading="loading">
<el-table-column type="index" label="序号" width="100" />
<el-table-column prop="venueName" label="场地名称" width="250" />
<el-table-column fixed="right" label="操作" min-width="120">
<template #default="{ row }">
<el-button link type="danger" @click="onRemove(row.id)"
>移除</el-button
>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>

@ -0,0 +1,154 @@
<script setup>
import { ref } from 'vue'
import { Edit, Delete, Upload } from '@element-plus/icons-vue'
import defaultImg from '@/assets/cover.jpg'
import { typeQueryService, typeDelService } from '@/api/type'
import TypeEdit from './components/TypeEdit.vue'
import PhotoUpdate from '@/components/PhotoUpdate.vue'
import PhotoShow from '@/components/PhotoShow.vue'
const loading = ref(false)
const typeList = ref()
const total = ref(0)
const params = ref({
current: 1,
size: 10
})
const getTypeList = async () => {
loading.value = true
const {
data: { data }
} = await typeQueryService(params.value)
loading.value = false
console.log(typeof data.list[0].openTime)
typeList.value = data.list
total.value = data.total
}
getTypeList()
const onCurrentChange = (current) => {
params.value.current = current
getTypeList()
}
const onSizeChange = (size) => {
params.value.current = 1
params.value.size = size
getTypeList()
}
const typeEditRef = ref()
const onEdit = (row) => {
typeEditRef.value.open(row)
}
//
const photoShowRef = ref()
const imgPreview = (imgUrl) => {
photoShowRef.value.open(imgUrl)
}
//
const photoUpdateRef = ref()
const photoUpdate = (tid, tUrl) => {
photoUpdateRef.value.open(tid, tUrl, 'venue')
}
//
const onDel = async (id) => {
ElMessageBox.confirm('确认移除该条数据?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await typeDelService(id)
ElMessage.success('移除成功')
getTypeList()
})
.catch(() => {})
}
</script>
<template>
<page-container title="场馆信息列表">
<template #extra>
<el-button type="primary" @click="onEdit"></el-button>
</template>
<!-- 表格区域 -->
<el-table
:data="typeList"
stripe
v-loading="loading"
element-loading-text="场馆数据加载中......"
>
<el-table-column type="index" label="序号" width="60" />
<el-table-column label="场馆照片">
<template #default="{ row }">
<img
style="height: 100px; width: 120px; cursor: pointer"
:src="
row.imgUrl === null || row.imgUrl === '' || row.imgUrl === ' '
? defaultImg
: row.imgUrl
"
:title="row.venueName"
@click="imgPreview(row.imgUrl)"
/>
</template>
</el-table-column>
<el-table-column label="场馆名称" prop="vname" />
<el-table-column label="位置" prop="location" />
<el-table-column label="开馆时间" prop="openTime" />
<el-table-column label="闭馆时间" prop="closeTime" />
<el-table-column label="操作">
<template #default="{ row }">
<el-button
circle
plain
type="primary"
:icon="Edit"
@click="onEdit(row)"
></el-button>
<el-button
circle
plain
type="danger"
:icon="Delete"
@click="onDel(row.id)"
></el-button>
<el-button
circle
plain
type="info"
:icon="Upload"
title="上传场馆图片"
@click="photoUpdate(row.id, row.imgUrl)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="无数据" />
</template>
</el-table>
<!-- 分页区域 -->
<el-pagination
v-model:current-page="params.current"
v-model:page-size="params.size"
:page-sizes="[10, 20, 50]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
<!-- 抽屉 -->
<TypeEdit ref="typeEditRef" @success="onSizeChange(5)"></TypeEdit>
<!-- 弹窗 -->
<PhotoUpdate ref="photoUpdateRef" @success="onSizeChange(5)"></PhotoUpdate>
<!-- 图片放大 -->
<PhotoShow ref="photoShowRef"></PhotoShow>
</page-container>
</template>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save