代码4:
import Vue from 'vue'
import VueRouter from 'vue-router'
import store from '../store'
Vue.use(VueRouter)
const routes = [
{
path: '/',
redirect: '/home'
},
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/register',
name: 'Register',
component: () => import('../views/Register.vue')
},
{
path: '/home',
name: 'Home',
component: () => import('../views/Home.vue')
},
{
path: '/house/detail/:id',
name: 'HouseDetail',
component: () => import('../views/house/Detail.vue')
},
{
path: '/user',
component: () => import('../views/user/Layout.vue'),
redirect: '/user/profile',
meta: { requireAuth: true },
children: [
{
path: 'profile',
name: 'UserProfile',
component: () => import('../views/user/Profile.vue')
},
{
path: 'favorite',
name: 'UserFavorite',
component: () => import('../views/user/Favorite.vue')
},
{
path: 'verification',
name: 'UserVerification',
component: () => import('../views/user/Verification.vue')
}
]
},
{
path: '/landlord',
component: () => import('../views/landlord/Layout.vue'),
redirect: '/landlord/house',
meta: { requireAuth: true, roles: ['landlord'] },
children: [
{
path: 'house',
name: 'LandlordHouse',
component: () => import('../views/landlord/House.vue')
},
{
path: 'house/add',
name: 'LandlordHouseAdd',
component: () => import('../views/landlord/HouseForm.vue')
},
{
path: 'house/edit/:id',
name: 'LandlordHouseEdit',
component: () => import('../views/landlord/HouseForm.vue')
}
]
},
{
path: '/admin',
component: () => import('../views/admin/Layout.vue'),
redirect: '/admin/user',
meta: { requireAuth: true, roles: ['admin'] },
children: [
{
path: 'user',
name: 'AdminUser',
component: () => import('../views/admin/User.vue')
},
{
path: 'house',
name: 'AdminHouse',
component: () => import('../views/admin/House.vue')
},
{
path: 'verification',
name: 'AdminVerification',
component: () => import('../views/admin/Verification.vue')
}
]
},
{
path: '*',
name: 'NotFound',
component: () => import('../views/NotFound.vue')
}
]
const router = new VueRouter({
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
// 需要登录的页面
if (to.matched.some(record => record.meta.requireAuth)) {
// 未登录,跳转到登录页
if (!store.state.isLogin) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else {
// 角色权限验证
if (to.matched.some(record => record.meta.roles)) {
const userType = store.getters.userType
if (to.meta.roles.includes(userType)) {
next()
} else {
next('/home')
}
} else {
next()
}
}
} else {
next()
}
})
export default router
frontend\src\router\modules\appointment.js
import Layout from '@/layout';
const appointmentRouter = {
path: '/appointment',
component: Layout,
redirect: '/appointment/list',
name: 'Appointment',
meta: {
title: '预约管理',
icon: 'el-icon-date',
roles: ['admin', 'user', 'landlord']
},
children: [
{
path: 'list',
component: () => import('@/views/appointment/AppointmentList'),
name: 'AppointmentList',
meta: { title: '我的预约', icon: 'el-icon-tickets' }
},
{
path: 'calendar',
component: () => import('@/views/appointment/AppointmentCalendar'),
name: 'AppointmentCalendar',
meta: { title: '预约日程', icon: 'el-icon-date' }
},
{
path: 'detail/:id',
component: () => import('@/views/appointment/AppointmentDetail'),
name: 'AppointmentDetail',
meta: { title: '预约详情', activeMenu: '/appointment/list' },
hidden: true
}
]
};
export default appointmentRouter;
3.4 Store Configuration
frontend\src\store\index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
token: localStorage.getItem('token') || '',
userInfo: JSON.parse(localStorage.getItem('userInfo')) || {},
isLogin: localStorage.getItem('token') ? true : false
},
mutations: {
// 设置用户信息
SET_USER_INFO(state, data) {
state.token = data.token
state.userInfo = data.userInfo
state.isLogin = true
// 保存到本地存储
localStorage.setItem('token', data.token)
localStorage.setItem('userInfo', JSON.stringify(data.userInfo))
},
// 清除用户信息
CLEAR_USER_INFO(state) {
state.token = ''
state.userInfo = {}
state.isLogin = false
// 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
},
// 更新用户信息
UPDATE_USER_INFO(state, userInfo) {
state.userInfo = { ...state.userInfo, ...userInfo }
localStorage.setItem('userInfo', JSON.stringify(state.userInfo))
}
},
actions: {
// 登录
login({ commit }, userInfo) {
commit('SET_USER_INFO', userInfo)
},
// 退出登录
logout({ commit }) {
commit('CLEAR_USER_INFO')
},
// 更新用户信息
updateUserInfo({ commit }, userInfo) {
commit('UPDATE_USER_INFO', userInfo)
}
},
getters: {
// 获取用户ID
userId: state => {
return state.userInfo.id
},
// 获取用户名
username: state => {
return state.userInfo.username
},
// 获取用户类型
userType: state => {
return state.userInfo.userType
},
// 是否是管理员
isAdmin: state => {
return state.userInfo.userType === 'admin'
},
// 是否是房东
isLandlord: state => {
return state.userInfo.userType === 'landlord'
},
// 是否是学生
isStudent: state => {
return state.userInfo.userType === 'student'
}
}
})
frontend\src\store\modules\appointment.js
import appointmentApi from '@/api/appointment';
const state = {
appointments: [],
appointmentStatistics: null
};
const mutations = {
SET_APPOINTMENTS: (state, appointments) => {
state.appointments = appointments;
},
SET_APPOINTMENT_STATISTICS: (state, statistics) => {
state.appointmentStatistics = statistics;
}
};
const actions = {
// 获取用户预约列表
getUserAppointments({ commit }) {
return new Promise((resolve, reject) => {
appointmentApi.getUserAppointments()
.then(response => {
commit('SET_APPOINTMENTS', response.data);
resolve(response);
})
.catch(error => {
reject(error);
});
});
},
// 获取房东预约列表
getLandlordAppointments({ commit }) {
return new Promise((resolve, reject) => {
appointmentApi.getLandlordAppointments()
.then(response => {
commit('SET_APPOINTMENTS', response.data);
resolve(response);
})
.catch(error => {
reject(error);
});
});
},
// 获取预约统计数据
getAppointmentStatistics({ commit }) {
return new Promise((resolve, reject) => {
appointmentApi.getAppointmentStatistics()
.then(response => {
commit('SET_APPOINTMENT_STATISTICS', response.data);
resolve(response);
})
.catch(error => {
reject(error);
});
});
}
};
export default {
namespaced: true,
state,
mutations,
actions
};
frontend\src\utils\auth.js
import Cookies from 'js-cookie';
const TokenKey = 'rental_token';
const UserKey = 'rental_user';
// Token相关操作
export function getToken() {
return Cookies.get(TokenKey);
}
export function setToken(token) {
return Cookies.set(TokenKey, token);
}
export function removeToken() {
return Cookies.remove(TokenKey);
}
// 用户信息相关操作
export function getUserInfo() {
const userStr = localStorage.getItem(UserKey);
return userStr ? JSON.parse(userStr) : null;
}
export function setUserInfo(userInfo) {
return localStorage.setItem(UserKey, JSON.stringify(userInfo));
}
export function removeUserInfo() {
return localStorage.removeItem(UserKey);
}
// 判断用户角色
export function hasRole(role) {
const userInfo = getUserInfo();
if (!userInfo || !userInfo.roles) {
return false;
}
return userInfo.roles.includes(role);
}
// 判断是否是学生
export function isStudent() {
return hasRole('ROLE_STUDENT');
}
// 判断是否是房东
export function isLandlord() {
return hasRole('ROLE_LANDLORD');
}
// 判断是否是管理员
export function isAdmin() {
return hasRole('ROLE_ADMIN');
}
frontend\src\utils\date.js
/**
* 日期格式化
* @param {Date} date 日期
* @param {String} fmt 格式化字符串,默认:yyyy-MM-dd
* @returns {String} 格式化后的日期字符串
*/
export function formatDate(date, fmt = 'yyyy-MM-dd') {
if (!date) {
return '';
}
if (typeof date === 'string') {
date = new Date(date);
}
if (!(date instanceof Date)) {
return '';
}
const o = {
'M+': date.getMonth() + 1, // 月份
'd+': date.getDate(), // 日
'h+': date.getHours() % 12 === 0 ? 12 : date.getHours() % 12, // 小时
'H+': date.getHours(), // 小时
'm+': date.getMinutes(), // 分
's+': date.getSeconds(), // 秒
'q+': Math.floor((date.getMonth() + 3) / 3), // 季度
'S': date.getMilliseconds() // 毫秒
};
if (/(y+)/.test(fmt)) {
fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length));
}
for (let k in o) {
if (new RegExp('(' + k + ')').test(fmt)) {
fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? (o[k]) : (('00' + o[k]).substr(('' + o[k]).length)));
}
}
return fmt;
}
/**
* 计算两个日期之间的天数
* @param {Date} date1 日期1
* @param {Date} date2 日期2
* @returns {Number} 天数
*/
export function daysBetween(date1, date2) {
const time1 = date1.getTime();
const time2 = date2.getTime();
const diff = Math.abs(time2 - time1);
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
/**
* 日期加减
* @param {Date} date 日期
* @param {Number} days 天数
* @returns {Date} 新日期
*/
export function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/**
* 获取当前月的第一天
* @param {Date} date 日期
* @returns {Date} 当月第一天
*/
export function getFirstDayOfMonth(date) {
const result = new Date(date);
result.setDate(1);
return result;
}
/**
* 获取当前月的最后一天
* @param {Date} date 日期
* @returns {Date} 当月最后一天
*/
export function getLastDayOfMonth(date) {
const result = new Date(date);
result.setMonth(result.getMonth() + 1);
result.setDate(0);
return result;
}
frontend\src\utils\request.js
import axios from 'axios';
import { Message } from 'element-ui';
import store from '@/store';
import router from '@/router';
// 创建axios实例
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API || '/api', // url = base url + request url
timeout: 5000 // 请求超时时间
});
// 请求拦截器
service.interceptors.request.use(
config => {
// 在发送请求之前做些什么
if (store.getters.token) {
// 让每个请求携带token
config.headers['Authorization'] = 'Bearer ' + store.getters.token;
}
return config;
},
error => {
// 对请求错误做些什么
console.log(error); // for debug
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
/**
* 如果您想获取http信息,如头信息或状态
* 请返回response => response
*/
/**
* 通过自定义代码确定请求状态
* 这里只是一个例子
* 您还可以通过HTTP状态码判断状态
*/
response => {
const res = response.data;
// 如果自定义代码不是200,则判断为错误
if (res.code !== 200) {
Message({
message: res.message || 'Error',
type: 'error',
duration: 5 * 1000
});
// 401: 未登录或token过期
if (res.code === 401) {
// 重新登录
store.dispatch('resetToken').then(() => {
// 为了重新实例化vue-router对象,避免bug
location.reload();
});
}
return Promise.reject(new Error(res.message || 'Error'));
} else {
return res;
}
},
error => {
console.log('err' + error); // for debug
let message = error.message;
if (error.response) {
switch (error.response.status) {
case 401:
message = '未授权,请重新登录';
// 清除token并跳转登录页
store.dispatch('resetToken');
router.replace({
path: '/login',
query: { redirect: router.currentRoute.fullPath }
});
break;
case 403:
message = '拒绝访问';
break;
case 404:
message = `请求地址出错: ${error.response.config.url}`;
break;
case 408:
message = '请求超时';
break;
case 500:
message = '服务器内部错误';
break;
case 501:
message = '服务未实现';
break;
case 502:
message = '网关错误';
break;
case 503:
message = '服务不可用';
break;
case 504:
message = '网关超时';
break;
case 505:
message = 'HTTP版本不受支持';
break;
default:
message = `连接出错(${error.response.status})!`;
}
}
Message({
message: message,
type: 'error',
duration: 5 * 1000
});
return Promise.reject(error);
}
);
export default service;
frontend\src\views\Home.vue
<template>
<div class="home-container">
<!-- 头部导航 -->
<div class="header">
<div class="container">
<div class="logo">大学生租房管理系统</div>
<div class="nav">
<router-link to="/home" class="nav-item active">首页</router-link>
<router-link to="/house/map" class="nav-item">地图找房</router-link>
<router-link to="/news" class="nav-item">租房资讯</router-link>
</div>
<div class="user-info">
<template v-if="isLogin">
<el-dropdown @command="handleCommand">
<span class="el-dropdown-link">
{{ username }}<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item command="favorite">我的收藏</el-dropdown-item>
<el-dropdown-item v-if="isAdmin" command="admin">管理后台</el-dropdown-item>
<el-dropdown-item v-if="isLandlord" command="landlord">房源管理</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
<template v-else>
<router-link to="/login" class="login-btn">登录</router-link>
<router-link to="/register" class="register-btn">注册</router-link>
</template>
</div>
</div>
</div>
<!-- 搜索区域 -->
<div class="search-section">
<div class="container">
<div class="search-box">
<h2>找到你心仪的房源</h2>
<div class="search-form">
<el-input v-model="searchForm.keyword" placeholder="请输入小区名、地址等" class="search-input">
<el-button slot="append" icon="el-icon-search" @click="handleSearch"></el-button>
</el-input>
<div class="search-options">
<el-select v-model="searchForm.houseType" placeholder="房型" clearable>
<el-option label="一室" value="1"></el-option>
<el-option label="二室" value="2"></el-option>
<el-option label="三室" value="3"></el-option>
<el-option label="四室及以上" value="4"></el-option>
</el-select>
<el-select v-model="searchForm.priceRange" placeholder="租金" clearable>
<el-option label="1000元以下" value="0-1000"></el-option>
<el-option label="1000-2000元" value="1000-2000"></el-option>
<el-option label="2000-3000元" value="2000-3000"></el-option>
<el-option label="3000-5000元" value="3000-5000"></el-option>
<el-option label="5000元以上" value="5000-999999"></el-option>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 房源列表 -->
<div class="house-list-section">
<div class="container">
<div class="section-title">
<h3>最新房源</h3>
<router-link to="/house/list" class="more">查看更多</router-link>
</div>
<el-row :gutter="20">
<el-col :span="6" v-for="(house, index) in houseList" :key="index">
<div class="house-card" @click="goToDetail(house.id)">
<div class="house-img">
<img :src="house.coverUrl || 'https://via.placeholder.com/300x200'" alt="房源图片">
</div>
<div class="house-info">
<div class="house-title">{{ house.title }}</div>
<div class="house-tags">
<span class="tag">{{ house.houseType }}</span>
<span class="tag">{{ house.area }}㎡</span>
<span class="tag">{{ house.orientation }}</span>
</div>
<div class="house-address">
<i class="el-icon-location"></i>
{{ house.community }} - {{ house.address }}
</div>
<div class="house-price">
<span class="price">{{ house.price }}</span> 元/月
</div>
</div>
</div>
</el-col>
</el-row>
<div v-if="houseList.length === 0" class="empty-data">
暂无房源数据
</div>
</div>
</div>
<!-- 底部区域 -->
<div class="footer">
<div class="container">
<p>© 2025 大学生租房管理系统 版权所有</p>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Home',
data() {
return {
searchForm: {
keyword: '',
houseType: '',
priceRange: ''
},
houseList: []
}
},
computed: {
...mapGetters(['isLogin', 'username', 'isAdmin', 'isLandlord'])
},
created() {
this.getHouseList()
},
methods: {
async getHouseList() {
try {
const { data: res } = await this.$http.post('/house/search', {
pageNum: 1,
pageSize: 8,
status: 1
})
if (res.code === 200) {
this.houseList = res.data.list || []
}
} catch (error) {
console.error(error)
this.$message.error('获取房源列表失败')
}
},
handleSearch() {
let params = {
keyword: this.searchForm.keyword,
houseType: this.searchForm.houseType,
pageNum: 1,
pageSize: 20,
status: 1
}
// 处理价格范围
if (this.searchForm.priceRange) {
const [min, max] = this.searchForm.priceRange.split('-')
params.minPrice = min
params.maxPrice = max
}
// 跳转到列表页并传递参数
this.$router.push({
path: '/house/list',
query: params
})
},
goToDetail(id) {
this.$router.push(`/house/detail/${id}`)
},
handleCommand(command) {
switch (command) {
case 'profile':
this.$router.push('/user/profile')
break
case 'favorite':
this.$router.push('/user/favorite')
break
case 'admin':
this.$router.push('/admin')
break
case 'landlord':
this.$router.push('/landlord')
break
case 'logout':
this.$store.dispatch('logout')
this.$message.success('退出登录成功')
this.$router.push('/home')
break
}
}
}
}
</script>
<style scoped>
.home-container {
min-height: 100%;
display: flex;
flex-direction: column;
}
.header {
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
height: 60px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.header .container {
display: flex;
align-items: center;
height: 100%;
}
.logo {
font-size: 20px;
font-weight: bold;
color: #409EFF;
margin-right: 50px;
}
.nav {
display: flex;
flex: 1;
}
.nav-item {
margin-right: 30px;
color: #333;
text-decoration: none;
font-size: 16px;
}
.nav-item.active, .nav-item:hover {
color: #409EFF;
}
.user-info {
display: flex;
align-items: center;
}
.el-dropdown-link {
cursor: pointer;
color: #409EFF;
}
.login-btn, .register-btn {
margin-left: 15px;
color: #409EFF;
text-decoration: none;
}
.search-section {
background-image: url('https://via.placeholder.com/1920x400');
background-size: cover;
background-position: center;
height: 400px;
margin-top: 60px;
display: flex;
align-items: center;
}
.search-box {
background-color: rgba(255, 255, 255, 0.9);
padding: 30px;
border-radius: 5px;
width: 100%;
}
.search-box h2 {
text-align: center;
margin-bottom: 20px;
color: #333;
}
.search-input {
margin-bottom: 15px;
}
.search-options {
display: flex;
justify-content: space-between;
}
.search-options .el-select {
width: 48%;
}
.house-list-section {
padding: 40px 0;
flex: 1;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-title h3 {
font-size: 20px;
margin: 0;
}
.more {
color: #409EFF;
text-decoration: none;
}
.house-card {
background-color: #fff;
border-radius: 5px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
cursor: pointer;
transition: transform 0.3s;
}
.house-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.house-img {
height: 180px;
overflow: hidden;
}
.house-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.house-info {
padding: 15px;
}
.house-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.house-tags {
margin-bottom: 10px;
}
.house-tags .tag {
display: inline-block;
padding: 2px 6px;
background-color: #f5f5f5;
color: #666;
border-radius: 2px;
font-size: 12px;
margin-right: 5px;
}
.house-address {
color: #999;
font-size: 12px;
margin-bottom: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.house-price {
font-size: 14px;
color: #666;
}
.house-price .price {
font-size: 18px;
color: #ff6700;
font-weight: bold;
}
.empty-data {
text-align: center;
padding: 50px 0;
color: #999;
}
.footer {
background-color: #2d3a4b;
color: #fff;
padding: 20px 0;
text-align: center;
}
</style>
frontend\src\views\Login.vue
<template>
<div class="login-container">
<div class="login-box">
<div class="login-title">大学生租房管理系统</div>
<el-form :model="loginForm" :rules="loginRules" ref="loginForm" label-width="0px">
<el-form-item prop="username">
<el-input v-model="loginForm.username" prefix-icon="el-icon-user" placeholder="用户名"></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" prefix-icon="el-icon-lock" placeholder="密码" type="password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleLogin" class="login-button">登录</el-button>
</el-form-item>
<div class="login-options">
<span>没有账号?<router-link to="/register">立即注册</router-link></span>
<router-link to="/home">返回首页</router-link>
</div>
</el-form>
</div>
</div>
</template>
<script>
export default {
name: 'Login',
data() {
return {
loginForm: {
username: '',
password: ''
},
loginRules: {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' }
]
},
loading: false,
redirect: undefined
}
},
watch: {
$route: {
handler: function(route) {
this.redirect = route.query && route.query.redirect
},
immediate: true
}
},
methods: {
handleLogin() {
this.$refs.loginForm.validate(async valid => {
if (valid) {
this.loading = true
try {
const { data: res } = await this.$http.post('/user/login', this.loginForm)
if (res.code === 200) {
this.$message.success('登录成功')
// 存储用户信息
this.$store.dispatch('login', {
token: res.data.token,
userInfo: res.data
})
// 跳转到首页或重定向页面
const path = this.redirect || '/'
this.$router.push(path)
} else {
this.$message.error(res.message || '登录失败')
}
} catch (error) {
console.error(error)
this.$message.error('登录失败,请稍后再试')
} finally {
this.loading = false
}
}
})
}
}
}
</script>
<style scoped>
.login-container {
height: 100%;
background-color: #2d3a4b;
display: flex;
justify-content: center;
align-items: center;
}
.login-box {
width: 400px;
padding: 30px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.login-title {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 30px;
color: #409EFF;
}
.login-button {
width: 100%;
}
.login-options {
display: flex;
justify-content: space-between;
margin-top: 15px;
font-size: 14px;
}
.login-options a {
color: #409EFF;
text-decoration: none;
}
</style>
frontend\src\views\Register.vue
<template>
<div class="register-container">
<div class="register-box">
<div class="register-title">用户注册</div>
<el-form :model="registerForm" :rules="registerRules" ref="registerForm" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="registerForm.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="registerForm.password" type="password" placeholder="请输入密码"></el-input>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="registerForm.confirmPassword" type="password" placeholder="请再次输入密码"></el-input>
</el-form-item>
<el-form-item label="手机号码" prop="phone">
<el-input v-model="registerForm.phone" placeholder="请输入手机号码"></el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="registerForm.email" placeholder="请输入邮箱"></el-input>
</el-form-item>
<el-form-item label="用户类型" prop="userType">
<el-radio-group v-model="registerForm.userType">
<el-radio label="student">学生</el-radio>
<el-radio label="landlord">房东</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleRegister">注册</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
<div class="register-options">
<span>已有账号?<router-link to="/login">立即登录</router-link></span>
<router-link to="/home">返回首页</router-link>
</div>
</el-form>
</div>
</div>
</template>
<script>
export default {
name: 'Register',
data() {
// 密码确认验证
const validateConfirmPassword = (rule, value, callback) => {
if (value !== this.registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
return {
registerForm: {
username: '',
password: '',
confirmPassword: '',
phone: '',
email: '',
userType: 'student'
},
registerRules: {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号码', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
userType: [
{ required: true, message: '请选择用户类型', trigger: 'change' }
]
},
loading: false
}
},
methods: {
handleRegister() {
this.$refs.registerForm.validate(async valid => {
if (valid) {
this.loading = true
try {
const { data: res } = await this.$http.post('/user/register', {
username: this.registerForm.username,
password: this.registerForm.password,
phone: this.registerForm.phone,
email: this.registerForm.email,
userType: this.registerForm.userType
})
if (res.code === 200) {
this.$message.success('注册成功,请登录')
this.$router.push('/login')
} else {
this.$message.error(res.message || '注册失败')
}
} catch (error) {
console.error(error)
this.$message.error('注册失败,请稍后再试')
} finally {
this.loading = false
}
}
})
},
resetForm() {
this.$refs.registerForm.resetFields()
}
}
}
</script>
<style scoped>
.register-container {
height: 100%;
background-color: #2d3a4b;
display: flex;
justify-content: center;
align-items: center;
}
.register-box {
width: 500px;
padding: 30px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.register-title {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 30px;
color: #409EFF;
}
.register-options {
display: flex;
justify-content: space-between;
margin-top: 15px;
font-size: 14px;
}
.register-options a {
color: #409EFF;
text-decoration: none;
}
</style>
frontend\src\views\admin\Dashboard.vue
<template>
<div class="dashboard-container">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="data-card">
<div class="data-header">
<div class="data-title">总用户数</div>
<i class="el-icon-user data-icon"></i>
</div>
<div class="data-content">
<div class="data-value">{{ statistics.userCount || 0 }}</div>
<div class="data-change">
<span :class="statistics.userIncrease > 0 ? 'increase' : 'decrease'">
<i :class="statistics.userIncrease > 0 ? 'el-icon-top' : 'el-icon-bottom'"></i>
{{ Math.abs(statistics.userIncrease || 0) }}%
</span>
较上周
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="data-card">
<div class="data-header">
<div class="data-title">总房源数</div>
<i class="el-icon-house data-icon"></i>
</div>
<div class="data-content">
<div class="data-value">{{ statistics.houseCount || 0 }}</div>
<div class="data-change">
<span :class="statistics.houseIncrease > 0 ? 'increase' : 'decrease'">
<i :class="statistics.houseIncrease > 0 ? 'el-icon-top' : 'el-icon-bottom'"></i>
{{ Math.abs(statistics.houseIncrease || 0) }}%
</span>
较上周
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="data-card">
<div class="data-header">
<div class="data-title">待审核认证</div>
<i class="el-icon-document-checked data-icon"></i>
</div>
<div class="data-content">
<div class="data-value">{{ statistics.pendingVerification || 0 }}</div>
<div class="data-change">
<router-link to="/admin/verification" class="link">查看详情</router-link>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="data-card">
<div class="data-header">
<div class="data-title">今日访问量</div>
<i class="el-icon-view data-icon"></i>
</div>
<div class="data-content">
<div class="data-value">{{ statistics.todayVisits || 0 }}</div>
<div class="data-change">
<span :class="statistics.visitsIncrease > 0 ? 'increase' : 'decrease'">
<i :class="statistics.visitsIncrease > 0 ? 'el-icon-top' : 'el-icon-bottom'"></i>
{{ Math.abs(statistics.visitsIncrease || 0) }}%
</span>
较昨日
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="chart-row">
<el-col :span="12">
<el-card class="chart-card">
<div slot="header" class="chart-header">
<span>用户注册统计</span>
<el-radio-group v-model="userChartType" size="mini">
<el-radio-button label="week">本周</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="year">本年</el-radio-button>
</el-radio-group>
</div>
<div class="chart-container" ref="userChart"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="chart-card">
<div slot="header" class="chart-header">
<span>房源发布统计</span>
<el-radio-group v-model="houseChartType" size="mini">
<el-radio-button label="week">本周</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="year">本年</el-radio-button>
</el-radio-group>
</div>
<div class="chart-container" ref="houseChart"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-card class="table-card">
<div slot="header" class="table-header">
<span>最新用户</span>
<el-button type="text" @click="$router.push('/admin/user')">查看更多</el-button>
</div>
<el-table :data="latestUsers" style="width: 100%" size="mini">
<el-table-column prop="username" label="用户名" width="120"></el-table-column>
<el-table-column prop="realName" label="真实姓名" width="120"></el-table-column>
<el-table-column prop="roleName" label="角色"></el-table-column>
<el-table-column prop="createTime" label="注册时间" :formatter="formatDate"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="table-card">
<div slot="header" class="table-header">
<span>最新房源</span>
<el-button type="text" @click="$router.push('/admin/house')">查看更多</el-button>
</div>
<el-table :data="latestHouses" style="width: 100%" size="mini">
<el-table-column prop="title" label="标题" width="180"></el-table-column>
<el-table-column prop="price" label="租金" width="100">
<template slot-scope="scope">
{{ scope.row.price }}元/月
</template>
</el-table-column>
<el-table-column prop="statusText" label="状态">
<template slot-scope="scope">
<el-tag :type="getStatusType(scope.row.status)">{{ scope.row.statusText }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="发布时间" :formatter="formatDate"></el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { formatDate } from '@/utils/date';
import * as echarts from 'echarts';
export default {
name: 'AdminDashboard',
data() {
return {
statistics: {
userCount: 0,
userIncrease: 0,
houseCount: 0,
houseIncrease: 0,
pendingVerification: 0,
todayVisits: 0,
visitsIncrease: 0
},
userChartType: 'week',
houseChartType: 'week',
latestUsers: [],
latestHouses: [],
userChart: null,
houseChart: null
};
},
created() {
this.fetchStatistics();
this.fetchLatestUsers();
this.fetchLatestHouses();
},
mounted() {
this.initCharts();
window.addEventListener('resize', this.resizeCharts);
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeCharts);
if (this.userChart) {
this.userChart.dispose();
}
if (this.houseChart) {
this.houseChart.dispose();
}
},
watch: {
userChartType() {
this.updateUserChart();
},
houseChartType() {
this.updateHouseChart();
}
},
methods: {
formatDate(row, column, cellValue) {
return formatDate(cellValue);
},
getStatusType(status) {
const typeMap = {
0: 'info', // 待审核
1: 'success', // 已上架
2: 'warning', // 已下架
3: 'danger' // 已出租
};
return typeMap[status] || 'info';
},
async fetchStatistics() {
// 模拟数据,实际项目中应该从API获取
this.statistics = {
userCount: 256,
userIncrease: 5.2,
houseCount: 128,
houseIncrease: 3.7,
pendingVerification: 12,
todayVisits: 1024,
visitsIncrease: -2.1
};
},
async fetchLatestUsers() {
// 模拟数据,实际项目中应该从API获取
this.latestUsers = [
{ id: 1, username: 'student1', realName: '张三', roleName: '学生', createTime: new Date() },
{ id: 2, username: 'landlord1', realName: '李四', roleName: '房东', createTime: new Date() },
{ id: 3, username: 'student2', realName: '王五', roleName: '学生', createTime: new Date() },
{ id: 4, username: 'landlord2', realName: '赵六', roleName: '房东', createTime: new Date() },
{ id: 5, username: 'student3', realName: '钱七', roleName: '学生', createTime: new Date() }
];
},
async fetchLatestHouses() {
// 模拟数据,实际项目中应该从API获取
this.latestHouses = [
{ id: 1, title: '温馨一室一厅', price: 1500, status: 1, statusText: '已上架', createTime: new Date() },
{ id: 2, title: '精装两室一厅', price: 2500, status: 1, statusText: '已上架', createTime: new Date() },
{ id: 3, title: '阳光三室两厅', price: 3500, status: 0, statusText: '待审核', createTime: new Date() },
{ id: 4, title: '豪华四室两厅', price: 4500, status: 2, statusText: '已下架', createTime: new Date() },
{ id: 5, title: '舒适单间', price: 1000, status: 3, statusText: '已出租', createTime: new Date() }
];
},
initCharts() {
// 初始化用户注册统计图表
this.userChart = echarts.init(this.$refs.userChart);
this.updateUserChart();
// 初始化房源发布统计图表
this.houseChart = echarts.init(this.$refs.houseChart);
this.updateHouseChart();
},
updateUserChart() {
// 根据选择的类型生成不同的数据
let xData = [];
let yData = [];
if (this.userChartType === 'week') {
xData = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
yData = [5, 7, 3, 9, 12, 6, 8];
} else if (this.userChartType === 'month') {
xData = Array.from({ length: 30 }, (_, i) => i + 1 + '日');
yData = Array.from({ length: 30 }, () => Math.floor(Math.random() * 10) + 1);
} else {
xData = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
yData = [30, 25, 35, 45, 55, 40, 35, 30, 50, 60, 45, 40];
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: xData,
axisTick: {
alignWithLabel: true
}
},
yAxis: {
type: 'value'
},
series: [{
name: '注册用户数',
type: 'bar',
barWidth: '60%',
data: yData,
itemStyle: {
color: '#409EFF'
}
}]
};
this.userChart.setOption(option);
},
updateHouseChart() {
// 根据选择的类型生成不同的数据
let xData = [];
let yData = [];
if (this.houseChartType === 'week') {
xData = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
yData = [3, 5, 2, 7, 9, 4, 6];
} else if (this.houseChartType === 'month') {
xData = Array.from({ length: 30 }, (_, i) => i + 1 + '日');
yData = Array.from({ length: 30 }, () => Math.floor(Math.random() * 5) + 1);
} else {
xData = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
yData = [20, 15, 25, 30, 40, 25, 20, 15, 35, 45, 30, 25];
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: xData,
axisTick: {
alignWithLabel: true
}
},
yAxis: {
type: 'value'
},
series: [{
name: '发布房源数',
type: 'line',
smooth: true,
data: yData,
itemStyle: {
color: '#67C23A'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'rgba(103, 194, 58, 0.3)'
}, {
offset: 1, color: 'rgba(103, 194, 58, 0.1)'
}]
}
}
}]
};
this.houseChart.setOption(option);
},
resizeCharts() {
if (this.userChart) {
this.userChart.resize();
}
if (this.houseChart) {
this.houseChart.resize();
}
}
}
};
</script>
<style scoped>
.dashboard-container {
padding: 20px;
}
.data-card {
height: 120px;
margin-bottom: 20px;
}
.data-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.data-title {
font-size: 16px;
color: #666;
}
.data-icon {
font-size: 24px;
color: #409EFF;
}
.data-content {
display: flex;
flex-direction: column;
}
.data-value {
font-size: 24px;
font-weight: bold;
margin-bottom: 5px;
}
.data-change {
font-size: 12px;
color: #999;
}
.increase {
color: #67C23A;
margin-right: 5px;
}
.decrease {
color: #F56C6C;
margin-right: 5px;
}
.link {
color: #409EFF;
text-decoration: none;
}
.chart-row {
margin-bottom: 20px;
}
.chart-card {
margin-bottom: 20px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-container {
height: 300px;
}
.table-card {
margin-bottom: 20px;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
Main Application Entry Point
frontend\src\views\appointment\AppointmentCalendar.vue
<template>
<div class="appointment-calendar-container">
<el-card class="calendar-card">
<div slot="header" class="card-header">
<span>预约日程</span>
<div class="header-actions">
<el-radio-group v-model="calendarView" size="small">
<el-radio-button label="month">月视图</el-radio-button>
<el-radio-button label="week">周视图</el-radio-button>
<el-radio-button label="day">日视图</el-radio-button>
</el-radio-group>
<el-button type="primary" size="small" @click="fetchAppointments">刷新</el-button>
</div>
</div>
<div class="calendar-container" v-loading="loading">
<full-calendar
ref="calendar"
:events="calendarEvents"
:config="calendarConfig"
@event-selected="handleEventSelected"
@day-click="handleDayClick">
</full-calendar>
</div>
</el-card>
<!-- 预约详情对话框 -->
<el-dialog title="预约详情" :visible.sync="appointmentDialogVisible" width="600px">
<div v-if="selectedAppointment" class="appointment-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="预约编号">{{ selectedAppointment.id }}</el-descriptions-item>
<el-descriptions-item label="预约状态">
<el-tag :type="getStatusType(selectedAppointment.status)">{{ getStatusText(selectedAppointment.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="预约时间">{{ formatDate(selectedAppointment.appointmentTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(selectedAppointment.createTime) }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ selectedAppointment.contactName }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ selectedAppointment.contactPhone }}</el-descriptions-item>
<el-descriptions-item label="预约备注" :span="2">
{{ selectedAppointment.appointmentNotes || '无' }}
</el-descriptions-item>
</el-descriptions>
<div class="dialog-actions">
<el-button
v-if="selectedAppointment.status === 0"
type="danger"
@click="handleCancel(selectedAppointment)">取消预约</el-button>
<el-button
v-if="isLandlord && selectedAppointment.status === 0"
type="primary"
@click="handleConfirm(selectedAppointment)">确认预约</el-button>
<el-button
v-if="isLandlord && selectedAppointment.status === 1"
type="success"
@click="handleComplete(selectedAppointment)">完成预约</el-button>
<el-button
type="info"
@click="viewAppointmentDetail(selectedAppointment)">查看详情</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import FullCalendar from 'vue-fullcalendar';
import appointmentApi from '@/api/appointment';
import { formatDate } from '@/utils/date';
import { mapGetters } from 'vuex';
export default {
name: 'AppointmentCalendar',
components: {
FullCalendar
},
data() {
return {
loading: false,
calendarView: 'month',
appointments: [],
selectedAppointment: null,
appointmentDialogVisible: false,
calendarConfig: {
header: {
left: 'prev,next today',
center: 'title',
right: ''
},
defaultView: 'month',
locale: 'zh-cn',
buttonText: {
today: '今天',
month: '月',
week: '周',
day: '日'
},
timeFormat: 'HH:mm',
eventLimit: true,
eventLimitText: '更多',
firstDay: 1, // 周一开始
height: 'auto'
}
};
},
computed: {
...mapGetters(['userId', 'userInfo']),
isLandlord() {
return this.selectedAppointment && this.userInfo && this.selectedAppointment.landlordId === this.userId;
},
calendarEvents() {
return this.appointments.map(appointment => {
const statusColorMap = {
0: '#909399', // 待确认 - 灰色
1: '#E6A23C', // 已确认 - 黄色
2: '#F56C6C', // 已取消 - 红色
3: '#67C23A' // 已完成 - 绿色
};
return {
id: appointment.id,
title: this.getEventTitle(appointment),
start: appointment.appointmentTime,
end: this.getEndTime(appointment.appointmentTime),
backgroundColor: statusColorMap[appointment.status],
borderColor: statusColorMap[appointment.status],
extendedProps: appointment
};
});
}
},
created() {
this.fetchAppointments();
},
watch: {
calendarView(val) {
if (this.$refs.calendar) {
this.$refs.calendar.fireMethod('changeView', val);
}
}
},
methods: {
formatDate,
getStatusType(status) {
const typeMap = {
0: 'info', // 待确认
1: 'warning', // 已确认
2: 'danger', // 已取消
3: 'success' // 已完成
};
return typeMap[status] || 'info';
},
getStatusText(status) {
const textMap = {
0: '待确认',
1: '已确认',
2: '已取消',
3: '已完成'
};
return textMap[status] || '未知';
},
getEventTitle(appointment) {
const statusText = this.getStatusText(appointment.status);
return `[${statusText}] ${appointment.contactName}`;
},
getEndTime(startTime) {
const end = new Date(startTime);
end.setHours(end.getHours() + 1); // 默认预约时长1小时
return end;
},
fetchAppointments() {
this.loading = true;
// 获取当前日历视图的日期范围
let startDate, endDate;
if (this.$refs.calendar) {
const view = this.$refs.calendar.fireMethod('getView');
startDate = view.start.format('YYYY-MM-DD');
endDate = view.end.format('YYYY-MM-DD');
} else {
// 默认获取当前月
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
startDate = formatDate(firstDay, 'yyyy-MM-dd');
endDate = formatDate(lastDay, 'yyyy-MM-dd');
}
appointmentApi.getAppointmentsByDateRange(startDate, endDate)
.then(response => {
this.appointments = response.data || [];
})
.catch(error => {
this.$message.error(error.message || '获取预约列表失败');
})
.finally(() => {
this.loading = false;
});
},
handleEventSelected(event) {
this.selectedAppointment = event.extendedProps;
this.appointmentDialogVisible = true;
},
handleDayClick(date) {
// 可以在这里实现点击日期创建新预约的功能
console.log('Day clicked:', date);
},
handleCancel(appointment) {
this.$confirm('确定要取消此预约吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
appointmentApi.cancelAppointment(appointment.id)
.then(() => {
this.$message.success('预约已取消');
this.appointmentDialogVisible = false;
this.fetchAppointments();
})
.catch(error => {
this.$message.error(error.message || '取消预约失败');
});
}).catch(() => {
// 取消操作
});
},
handleConfirm(appointment) {
this.$confirm('确定要确认此预约吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
appointmentApi.confirmAppointment(appointment.id)
.then(() => {
this.$message.success('预约已确认');
this.appointmentDialogVisible = false;
this.fetchAppointments();
})
.catch(error => {
this.$message.error(error.message || '确认预约失败');
});
}).catch(() => {
// 取消操作
});
},
handleComplete(appointment) {
this.$confirm('确定要完成此预约吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
appointmentApi.completeAppointment(appointment.id)
.then(() => {
this.$message.success('预约已完成');
this.appointmentDialogVisible = false;
this.fetchAppointments();
})
.catch(error => {
this.$message.error(error.message || '完成预约失败');
});
}).catch(() => {
// 取消操作
});
},
viewAppointmentDetail(appointment) {
this.appointmentDialogVisible = false;
this.$router.push(`/appointment/detail/${appointment.id}`);
}
}
};
</script>
<style scoped>
.appointment-calendar-container {
padding: 20px;
}
.calendar-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.calendar-container {
min-height: 600px;
}
.appointment-detail {
margin-bottom: 20px;
}
.dialog-actions {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
frontend\src\views\appointment\AppointmentDetail.vue
<template>
<div class="appointment-detail-container">
<el-card class="appointment-card" v-loading="loading">
<div slot="header" class="card-header">
<span>预约详情</span>
<el-button icon="el-icon-back" @click="goBack">返回</el-button>
</div>
<div v-if="appointment" class="appointment-info">
<el-descriptions :column="2" border>
<el-descriptions-item label="预约编号">{{ appointment.id }}</el-descriptions-item>
<el-descriptions-item label="预约状态">
<el-tag :type="getStatusType(appointment.status)">{{ getStatusText(appointment.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="预约时间">{{ formatDate(appointment.appointmentTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(appointment.createTime) }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ appointment.contactName }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ appointment.contactPhone }}</el-descriptions-item>
<el-descriptions-item label="预约备注" :span="2">
{{ appointment.appointmentNotes || '无' }}
</el-descriptions-item>
</el-descriptions>
</div>
<div v-if="house" class="house-info">
<h3>房源信息</h3>
<el
The model produced an invalid tool call, trying again.
No credits consumed on this tool call.
Erroran internal error occurred (error ID: 0bf2dd709b094428b66199d732f6548e)
DoneFeedback has been submitted
Me
补全不完整的代码, 及遗漏的文件。
Let me complete the unfinished AppointmentDetail.vue file and provide the remaining frontend components for the appointment module:
Completing the AppointmentDetail.vue file
frontend\src\views\appointment\AppointmentDetail.vue (continuation)
<el-card class="house-card" shadow="hover">
<div class="house-title">{{ house.title }}</div>
<div class="house-info-grid">
<div class="info-item">
<span class="label">价格:</span>
<span class="value">{{ house.price }}元/月</span>
</div>
<div class="info-item">
<span class="label">面积:</span>
<span class="value">{{ house.area }}㎡</span>
</div>
<div class="info-item">
<span class="label">户型:</span>
<span class="value">{{ house.houseType }}</span>
</div>
<div class="info-item">
<span class="label">朝向:</span>
<span class="value">{{ house.orientation }}</span>
</div>
<div class="info-item">
<span class="label">楼层:</span>
<span class="value">{{ house.floor }}</span>
</div>
<div class="info-item">
<span class="label">装修:</span>
<span class="value">{{ house.decoration }}</span>
</div>
</div>
<div class="house-address">
<span class="label">地址:</span>
<span class="value">{{ house.community }} {{ house.address }}</span>
</div>
<div class="house-contact">
<span class="label">联系人:</span>
<span class="value">{{ house.contact }} {{ house.contactPhone }}</span>
</div>
</el-card>
</div>
<div v-if="feedback" class="feedback-info">
<h3>看房反馈</h3>
<el-card class="feedback-card" shadow="hover">
<div class="feedback-header">
<el-rate
v-model="feedback.satisfactionLevel"
disabled
show-score
text-color="#ff9900">
</el-rate>
<span class="feedback-time">{{ formatDate(feedback.createTime) }}</span>
</div>
<div class="feedback-content">{{ feedback.feedbackContent }}</div>
</el-card>
</div>
<div class="action-buttons" v-if="appointment">
<el-button
v-if="appointment.status === 0"
type="danger"
@click="handleCancel">取消预约</el-button>
<el-button
v-if="isLandlord && appointment.status === 0"
type="primary"
@click="handleConfirm">确认预约</el-button>
<el-button
v-if="isLandlord && appointment.status === 1"
type="success"
@click="handleComplete">完成预约</el-button>
<el-button
v-if="appointment.status === 3 && !feedback"
type="primary"
@click="handleFeedback">提交反馈</el-button>
</div>
<!-- 反馈表单对话框 -->
<el-dialog title="看房反馈" :visible.sync="feedbackDialogVisible" width="600px">
<el-form ref="feedbackForm" :model="feedbackForm" :rules="feedbackRules" label-width="100px">
<el-form-item label="满意度" prop="satisfactionLevel">
<el-rate
v-model="feedbackForm.satisfactionLevel"
:colors="['#99A9BF', '#F7BA2A', '#FF9900']"
:texts="['失望', '一般', '满意', '很满意', '非常满意']"
show-text>
</el-rate>
</el-form-item>
<el-form-item label="反馈内容" prop="feedbackContent">
<el-input
v-model="feedbackForm.feedbackContent"
type="textarea"
:rows="6"
placeholder="请详细描述您的看房体验,包括房源状况、交通便利性、周边环境等">
</el-input>
</el-form-item>
<el-form-item label="是否公开">
<el-switch
v-model="feedbackForm.isPublic"
:active-value="1"
:inactive-value="0"
active-text="公开"
inactive-text="私密">
</el-switch>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="feedbackDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitFeedback" :loading="feedbackLoading">提交</el-button>
</div>
</el-dialog>
</el-card>
</div>
</template>
<script>
import appointmentApi from '@/api/appointment';
import feedbackApi from '@/api/feedback';
import houseApi from '@/api/house';
import { formatDate } from '@/utils/date';
import { mapGetters } from 'vuex';
export default {
name: 'AppointmentDetail',
data() {
return {
loading: false,
appointment: null,
house: null,
feedback: null,
// 反馈相关
feedbackDialogVisible: false,
feedbackLoading: false,
feedbackForm: {
appointmentId: null,
satisfactionLevel: 3,
feedbackContent: '',
isPublic: 1
},
feedbackRules: {
satisfactionLevel: [
{ required: true, message: '请选择满意度', trigger: 'change' }
],
feedbackContent: [
{ required: true, message: '请输入反馈内容', trigger: 'blur' },
{ min: 10, max: 500, message: '长度在 10 到 500 个字符', trigger: 'blur' }
]
}
};
},
computed: {
...mapGetters(['userId', 'userInfo']),
appointmentId() {
return this.$route.params.id;
},
isLandlord() {
return this.appointment && this.userInfo && this.appointment.landlordId === this.userId;
}
},
created() {
this.fetchAppointmentDetail();
},
methods: {
formatDate,
getStatusType(status) {
const typeMap = {
0: 'info', // 待确认
1: 'warning', // 已确认
2: 'danger', // 已取消
3: 'success' // 已完成
};
return typeMap[status] || 'info';
},
getStatusText(status) {
const textMap = {
0: '待确认',
1: '已确认',
2: '已取消',
3: '已完成'
};
return textMap[status] || '未知';
},
fetchAppointmentDetail() {
this.loading = true;
appointmentApi.getAppointmentDetail(this.appointmentId)
.then(response => {
this.appointment = response.data;
this.fetchHouseInfo();
this.fetchFeedback();
})
.catch(error => {
this.$message.error(error.message || '获取预约详情失败');
})
.finally(() => {
this.loading = false;
});
},
fetchHouseInfo() {
if (!this.appointment) return;
houseApi.getHouseDetail(this.appointment.houseId)
.then(response => {
this.house = response.data;
})
.catch(error => {
this.$message.error(error.message || '获取房源信息失败');
});
},
fetchFeedback() {
if (!this.appointment) return;
feedbackApi.getAppointmentFeedback(this.appointment.id)
.then(response => {
if (response.code === 200) {
this.feedback = response.data;
}
})
.catch(() => {
// 忽略错误
});
},
goBack() {
this.$router.go(-1);
},
handleCancel() {
this.$confirm('确定要取消此预约吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
appointmentApi.cancelAppointment(this.appointment.id)
.then(() => {
this.$message.success('预约已取消');
this.fetchAppointmentDetail();
})
.catch(error => {
this.$message.error(error.message || '取消预约失败');
});
}).catch(() => {
// 取消操作
});
},
handleConfirm() {
this.$confirm('确定要确认此预约吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
appointmentApi.confirmAppointment(this.appointment.id)
.then(() => {
this.$message.success('预约已确认');
this.fetchAppointmentDetail();
})
.catch(error => {
this.$message.error(error.message || '确认预约失败');
});
}).catch(() => {
// 取消操作
});
},
handleComplete() {
this.$confirm('确定要完成此预约吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
appointmentApi.completeAppointment(this.appointment.id)
.then(() => {
this.$message.success('预约已完成');
this.fetchAppointmentDetail();
})
.catch(error => {
this.$message.error(error.message || '完成预约失败');
});
}).catch(() => {
// 取消操作
});
},
handleFeedback() {
this.feedbackForm.appointmentId = this.appointment.id;
this.feedbackForm.satisfactionLevel = 3;
this.feedbackForm.feedbackContent = '';
this.feedbackForm.isPublic = 1;
this.feedbackDialogVisible = true;
},
submitFeedback() {
this.$refs.feedbackForm.validate(valid => {
if (valid) {
this.feedbackLoading = true;
feedbackApi.submitFeedback(this.feedbackForm)
.then(() => {
this.$message.success('反馈提交成功');
this.feedbackDialogVisible = false;
this.fetchFeedback();
})
.catch(error => {
this.$message.error(error.message || '反馈提交失败');
})
.finally(() => {
this.feedbackLoading = false;
});
} else {
return false;
}
});
}
}
};
</script>
<style scoped>
.appointment-detail-container {
padding: 20px;
}
.appointment-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.appointment-info {
margin-bottom: 30px;
}
.house-info {
margin-bottom: 30px;
}
.house-card {
padding: 15px;
}
.house-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 15px;
}
.house-info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-bottom: 15px;
}
.info-item {
display: flex;
}
.label {
color: #909399;
margin-right: 5px;
}
.house-address, .house-contact {
margin-top: 10px;
}
.feedback-info {
margin-bottom: 30px;
}
.feedback-card {
padding: 15px;
}
.feedback-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.feedback-time {
color: #909399;
font-size: 14px;
}
.feedback-content {
line-height: 1.6;
}
.action-buttons {
margin-top: 20px;
}
</style>
frontend\src\views\appointment\AppointmentForm.vue
<template>
<div class="appointment-form-container">
<el-card class="appointment-card">
<div slot="header" class="card-header">
<span>预约看房</span>
</div>
<el-form ref="appointmentForm" :model="appointmentForm" :rules="rules" label-width="100px">
<el-form-item label="联系人" prop="contactName">
<el-input v-model="appointmentForm.contactName" placeholder="请输入联系人姓名"></el-input>
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input v-model="appointmentForm.contactPhone" placeholder="请输入联系电话"></el-input>
</el-form-item>
<el-form-item label="预约时间" prop="appointmentTime">
<el-date-picker
v-model="appointmentForm.appointmentTime"
type="datetime"
placeholder="选择预约时间"
:picker-options="pickerOptions"
value-format="yyyy-MM-dd HH:mm:ss"
format="yyyy-MM-dd HH:mm">
</el-date-picker>
</el-form-item>
<el-form-item label="预约备注" prop="appointmentNotes">
<el-input
v-model="appointmentForm.appointmentNotes"
type="textarea"
:rows="4"
placeholder="请输入预约备注,如特殊要求等">
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitAppointment" :loading="loading">提交预约</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import appointmentApi from '@/api/appointment';
import { formatDate } from '@/utils/date';
export default {
name: 'AppointmentForm',
props: {
houseId: {
type: [Number, String],
required: true
}
},
data() {
// 验证手机号
const validatePhone = (rule, value, callback) => {
if (!/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的手机号码'));
} else {
callback();
}
};
// 验证预约时间
const validateAppointmentTime = (rule, value, callback) => {
if (!value) {
callback(new Error('请选择预约时间'));
return;
}
const now = new Date();
const appointmentTime = new Date(value);
if (appointmentTime <= now) {
callback(new Error('预约时间必须是将来的时间'));
return;
}
callback();
};
return {
appointmentForm: {
houseId: this.houseId,
contactName: '',
contactPhone: '',
appointmentTime: '',
appointmentNotes: ''
},
rules: {
contactName: [
{ required: true, message: '请输入联系人姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
contactPhone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ validator: validatePhone, trigger: 'blur' }
],
appointmentTime: [
{ required: true, message: '请选择预约时间', trigger: 'change' },
{ validator: validateAppointmentTime, trigger: 'change' }
]
},
pickerOptions: {
disabledDate(time) {
return time.getTime() < Date.now() - 8.64e7; // 禁用今天之前的日期
},
selectableRange: '09:00:00 - 18:00:00' // 可选时间范围
},
loading: false
};
},
methods: {
submitAppointment() {
this.$refs.appointmentForm.validate(valid => {
if (valid) {
this.loading = true;
appointmentApi.createAppointment(this.appointmentForm)
.then(response => {
this.$message.success('预约提交成功,请等待房东确认');
this.$emit('success', response.data);
this.resetForm();
})
.catch(error => {
this.$message.error(error.message || '预约提交失败,请稍后重试');
})
.finally(() => {
this.loading = false;
});
} else {
return false;
}
});
},
resetForm() {
this.$refs.appointmentForm.resetFields();
}
}
};
</script>
<style scoped>
.appointment-form-container {
max-width: 600px;
margin: 0 auto;
}
.appointment-card {
margin-bottom: 20px;
}
.card-header {
font-size: 18px;
font-weight: bold;
}
</style>
frontend\src\views\appointment\AppointmentList.vue
<template>
<div class="appointment-list-container">
<el-card class="appointment-card">
<div slot="header" class="card-header">
<span>我的预约</span>
<el-radio-group v-model="activeTab" size="small" @change="handleTabChange">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="pending">待确认</el-radio-button>
<el-radio-button label="confirmed">已确认</el-radio-button>
<el-radio-button label="completed">已完成</el-radio-button>
<el-radio-button label="canceled">已取消</el-radio-button>
</el-radio-group>
</div>
<div class="filter-container">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
@change="handleDateRangeChange">
</el-date-picker>
<el-button type="primary" icon="el-icon-search" @click="fetchAppointments">查询</el-button>
</div>
<el-table
v-loading="loading"
:data="appointmentList"
style="width: 100%"
@row-click="handleRowClick">
<el-table-column prop="id" label="预约编号" width="100"></el-table-column>
<el-table-column label="房源信息" min-width="200">
<template slot-scope="scope">
<div v-if="scope.row.house">{{ scope.row.house.title }}</div>
<div v-else>房源信息加载中...</div>
</template>
</el-table-column>
<el-table-column label="预约时间" width="180">
<template slot-scope="scope">
{{ formatDate(scope.row.appointmentTime) }}
</template>
</el-table-column>
<el-table-column label="联系人" width="120">
<template slot-scope="scope">
{{ scope.row.contactName }}<br>
{{ scope.row.contactPhone }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<el-tag :type="getStatusType(scope.row.status)">{{ getStatusText(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template slot-scope="scope">
<el-button
v-if="scope.row.status === 0"
size="mini"
type="danger"
@click.stop="handleCancel(scope.row)">取消预约</el-button>
<el-button
v-if="scope.row.status === 3 && !scope.row.hasFeedback"
size="mini"
type="primary"
@click.stop="handleFeedback(scope.row)">提交反馈</el-button>
<el-button
size="mini"
type="info"
@click.stop="handleDetail(scope.row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total">
</el-pagination>
</div>
</el-card>
<!-- 反馈表单对话框 -->
<el-dialog title="看房反馈" :visible.sync="feedbackDialogVisible" width="600px">
<el-form ref="feedbackForm" :model="feedbackForm" :rules="feedbackRules" label-width="100px">
<el-form-item label="满意度" prop="satisfactionLevel">
<el-rate
v-model="feedbackForm.satisfactionLevel"
:colors="['#99A9BF', '#F7BA2A', '#FF9900']"
:texts="['失望', '一般', '满意', '很满意', '非常满意']"
show-text>
</el-rate>
</el-form-item>
<el-form-item label="反馈内容" prop="feedbackContent">
<el-input
v-model="feedbackForm.feedbackContent"
type="textarea"
:rows="6"
placeholder="请详细描述您的看房体验,包括房源状况、交通便利性、周边环境等">
</el-input>
</el-form-item>
<el-form-item label="是否公开">
<el-switch
v-model="feedbackForm.isPublic"
:active-value="1"
:inactive-value="0"
active-text="公开"
inactive-text="私密">
</el-switch>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="feedbackDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitFeedback" :loading="feedbackLoading">提交</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import appointmentApi from '@/api/appointment';
import feedbackApi from '@/api/feedback';
import houseApi from '@/api/house';
import { formatDate } from '@/utils/date';
export default {
name: 'AppointmentList',
data() {
return {
activeTab: 'all',
appointmentList: [],
loading: false,
currentPage: 1,
pageSize: 10,
total: 0,
dateRange: null,
// 反馈相关
feedbackDialogVisible: false,
feedbackLoading: false,
currentAppointment: null,
feedbackForm: {
appointmentId: null,
satisfactionLevel: 3,
feedbackContent: '',
isPublic: 1
},
feedbackRules: {
satisfactionLevel: [
{ required: true, message: '请选择满意度', trigger: 'change' }
],
feedbackContent: [
{ required: true, message: '请输入反馈内容', trigger: 'blur' },
{ min: 10, max: 500, message: '长度在 10 到 500 个字符', trigger: 'blur' }
]
}
};
},
created() {
this.fetchAppointments();
},
methods: {
formatDate,
getStatusType(status) {
const typeMap = {
0: 'info', // 待确认
1: 'warning', // 已确认
2: 'danger', // 已取消
3: 'success' // 已完成
};
return typeMap[status] || 'info';
},
getStatusText(status) {
const textMap = {
0: '待确认',
1: '已确认',
2: '已取消',
3: '已完成'
};
return textMap[status] || '未知';
},
fetchAppointments() {
this.loading = true;
let promise;
if (this.dateRange && this.dateRange.length === 2) {
promise = appointmentApi.getAppointmentsByDateRange(this.dateRange[0], this.dateRange[1]);
} else {
promise = appointmentApi.getUserAppointments();
}
promise.then(response => {
let appointments = response.data || [];
// 根据标签过滤
if (this.activeTab !== 'all') {
const statusMap = {
'pending': 0,
'confirmed': 1,
'canceled': 2,
'completed': 3
};
appointments = appointments.filter(item => item.status === statusMap[this.activeTab]);
}
this.total = appointments.length;
// 分页处理
const start = (this.currentPage - 1) * this.pageSize;
const end = start + this.pageSize;
this.appointmentList = appointments.slice(start, end);
// 加载房源信息
this.loadHouseInfo();
// 检查是否已提交反馈
this.checkFeedback();
}).catch(error => {
this.$message.error(error.message || '获取预约列表失败');
}).finally(() => {
this.loading = false;
});
},
loadHouseInfo() {
const houseIds = this.appointmentList.map(item => item.houseId);
houseIds.forEach(houseId => {
houseApi.getHouseDetail(houseId).then(response => {
const house = response.data;
this.appointmentList.forEach(item => {
if (item.houseId === houseId) {
this.$set(item, 'house', house);
}
});
}).catch(() => {
// 忽略错误
});
});
},
checkFeedback() {
const completedAppointments = this.appointmentList.filter(item => item.status === 3);
completedAppointments.forEach(appointment => {
feedbackApi.getAppointmentFeedback(appointment.id).then(response => {
if (response.code === 200) {
this.$set(appointment, 'hasFeedback', true);
} else {
this.$set(appointment, 'hasFeedback', false);
}
}).catch(() => {
this.$set(appointment, 'hasFeedback', false);
});
});
},
handleTabChange() {
this.currentPage = 1;
this.fetchAppointments();
},
handleDateRangeChange() {
this.currentPage = 1;
},
handleSizeChange(val) {
this.pageSize = val;
this.fetchAppointments();
},
handleCurrentChange(val) {
this.currentPage = val;
this.fetchAppointments();
},
handleRowClick(row) {
this.handleDetail(row);
},
handleDetail(row) {
this.$router.push(`/appointment/detail/${row.id}`);
},
handleCancel(row) {
this.$confirm('确定要取消此预约吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
appointmentApi.cancelAppointment(row.id).then(() => {
this.$message.success('预约已取消');
this.fetchAppointments();
}).catch(error => {
this.$message.error(error.message || '取消预约失败');
});
}).catch(() => {
// 取消操作
});
},
handleFeedback(row) {
this.currentAppointment = row;
this.feedbackForm.appointmentId = row.id;
this.feedbackForm.satisfactionLevel = 3;
this.feedbackForm.feedbackContent = '';
this.feedbackForm.isPublic = 1;
this.feedbackDialogVisible = true;
},
submitFeedback() {
this.$refs.feedbackForm.validate(valid => {
if (valid) {
this.feedbackLoading = true;
feedbackApi.submitFeedback(this.feedbackForm).then(() => {
this.$message.success('反馈提交成功');
this.feedbackDialogVisible = false;
this.fetchAppointments();
}).catch(error => {
this.$message.error(error.message || '反馈提交失败');
}).finally(() => {
this.feedbackLoading = false;
});
} else {
return false;
}
});
}
}
};
</script>
<style scoped>
.appointment-list-container {
padding: 20px;
}
.appointment-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-container {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
</style>