代码7:
...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>
File: d:\htsun\Docs\资料\文章\rental-system\frontend\src\views\appointment\AppointmentCalendar.vue
vue
CopyInsert
<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>
File: d:\htsun\Docs\资料\文章\rental-system\frontend\src\views\house\components\HouseReviews.vue
vue
CopyInsert
<template>
<div class="house-reviews-container">
<el-card class="review-card">
<div slot="header" class="card-header">
<span>房源评价</span>
<el-button
v-if="canReview"
type="primary"
size="small"
@click="handleAddReview">写评价</el-button>
</div>
<div v-if="reviewStatistics" class="review-statistics">
<div class="rating-overview">
<div class="average-rating">
<span class="rating-number">{{ reviewStatistics.averageRating.toFixed(1) }}</span>
<el-rate
v-model="reviewStatistics.averageRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
<span class="total-reviews">{{ reviewStatistics.totalCount }}条评价</span>
</div>
<div class="rating-details">
<div class="rating-item">
<span class="rating-label">位置:</span>
<el-rate
v-model="reviewStatistics.averageLocationRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
</div>
<div class="rating-item">
<span class="rating-label">清洁度:</span>
<el-rate
v-model="reviewStatistics.averageCleanlinessRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
</div>
<div class="rating-item">
<span class="rating-label">性价比:</span>
<el-rate
v-model="reviewStatistics.averageValueRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
</div>
<div class="rating-item">
<span class="rating-label">房东:</span>
<el-rate
v-model="reviewStatistics.averageLandlordRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
</div>
</div>
</div>
<div class="rating-distribution">
<div v-for="(count, index) in reviewStatistics.ratingDistribution" :key="index" class="distribution-item">
<span class="star-label">{{ 5 - index }}星</span>
<el-progress
:percentage="getDistributionPercentage(count)"
:color="getDistributionColor(5 - index)"
:show-text="false">
</el-progress>
<span class="count-label">{{ count }}</span>
</div>
</div>
</div>
<div v-if="reviews.length === 0" class="no-reviews">
暂无评价
</div>
<div v-else class="review-list">
<div v-for="review in reviews" :key="review.id" class="review-item">
<div class="review-header">
<div class="user-info">
<el-avatar :size="40" :src="review.userAvatar || defaultAvatar"></el-avatar>
<span class="username">{{ review.userName || '匿名用户' }}</span>
</div>
<div class="review-rating">
<el-rate
v-model="review.rating"
disabled
text-color="#ff9900">
</el-rate>
<span class="review-time">{{ formatDate(review.createTime) }}</span>
</div>
</div>
<div class="review-content">{{ review.content }}</div>
<div v-if="review.images" class="review-images">
<el-image
v-for="(image, index) in review.images.split(',')"
:key="index"
:src="image"
:preview-src-list="review.images.split(',')"
fit="cover"
class="review-image">
</el-image>
</div>
<!-- 评价回复 -->
<div v-if="review.replies && review.replies.length > 0" class="review-replies">
<div v-for="reply in review.replies" :key="reply.id" class="reply-item">
<div class="reply-header">
<span class="reply-user">{{ reply.userName || '匿名用户' }}:</span>
<span class="reply-time">{{ formatDate(reply.createTime) }}</span>
</div>
<div class="reply-content">{{ reply.content }}</div>
</div>
</div>
<div class="review-actions">
<el-button
v-if="canReply"
type="text"
@click="handleReply(review)">回复</el-button>
<el-button
v-if="userId === review.userId"
type="text"
@click="handleEditReview(review)">编辑</el-button>
<el-button
v-if="userId === review.userId"
type="text"
@click="handleDeleteReview(review)">删除</el-button>
</div>
</div>
</div>
<div class="pagination-container" v-if="reviews.length > 0">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[5, 10, 20, 50]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total">
</el-pagination>
</div>
</el-card>
<!-- 评价表单对话框 -->
<el-dialog :title="reviewFormTitle" :visible.sync="reviewDialogVisible" width="650px">
<el-form ref="reviewForm" :model="reviewForm" :rules="reviewRules" label-width="100px">
<el-form-item label="总体评分" prop="rating">
<el-rate
v-model="reviewForm.rating"
:colors="['#99A9BF', '#F7BA2A', '#FF9900']"
:texts="['失望', '一般', '满意', '很满意', '非常满意']"
show-text>
</el-rate>
</el-form-item>
<el-form-item label="位置评分" prop="locationRating">
<el-rate v-model="reviewForm.locationRating"></el-rate>
</el-form-item>
<el-form-item label="清洁度评分" prop="cleanlinessRating">
<el-rate v-model="reviewForm.cleanlinessRating"></el-rate>
</el-form-item>
<el-form-item label="性价比评分" prop="valueRating">
<el-rate v-model="reviewForm.valueRating"></el-rate>
</el-form-item>
<el-form-item label="房东评分" prop="landlordRating">
<el-rate v-model="reviewForm.landlordRating"></el-rate>
</el-form-item>
<el-form-item label="评价内容" prop="content">
<el-input
v-model="reviewForm.content"
type="textarea"
:rows="6"
placeholder="请详细描述您的居住体验,包括房源状况、交通便利性、周边环境等">
</el-input>
</el-form-item>
<el-form-item label="上传图片">
<el-upload
action="/api/upload/image"
list-type="picture-card"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove"
:on-success="handleUploadSuccess"
:before-upload="beforeUpload"
:file-list="fileList">
<i class="el-icon-plus"></i>
</el-upload>
<el-dialog :visible.sync="dialogImageVisible">
<img width="100%" :src="dialogImageUrl" alt="">
</el-dialog>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="reviewDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitReview" :loading="reviewLoading">提交</el-button>
</div>
</el-dialog>
<!-- 回复表单对话框 -->
<el-dialog title="回复评价" :visible.sync="replyDialogVisible" width="600px">
<el-form ref="replyForm" :model="replyForm" :rules="replyRules" label-width="100px">
<el-form-item label="回复内容" prop="content">
<el-input
v-model="replyForm.content"
type="textarea"
:rows="4"
placeholder="请输入回复内容">
</el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="replyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitReply" :loading="replyLoading">提交</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import reviewApi from '@/api/review';
import userApi from '@/api/user';
import { formatDate } from '@/utils/date';
import { mapGetters } from 'vuex';
export default {
name: 'HouseReviews',
props: {
houseId: {
type: [Number, String],
required: true
}
},
data() {
return {
reviews: [],
reviewStatistics: null,
loading: false,
currentPage: 1,
pageSize: 10,
total: 0,
// 评价表单
reviewDialogVisible: false,
reviewFormTitle: '写评价',
reviewLoading: false,
reviewForm: {
id: null,
houseId: this.houseId,
rating: 5,
locationRating: 5,
cleanlinessRating: 5,
valueRating: 5,
landlordRating: 5,
content: '',
images: []
},
reviewRules: {
rating: [
{ required: true, message: '请选择评分', trigger: 'change' }
],
locationRating: [
{ required: true, message: '请选择位置评分', trigger: 'change' }
],
cleanlinessRating: [
{ required: true, message: '请选择清洁度评分', trigger: 'change' }
],
valueRating: [
{ required: true, message: '请选择性价比评分', trigger: 'change' }
],
landlordRating: [
{ required: true, message: '请选择房东评分', trigger: 'change' }
],
content: [
{ required: true, message: '请输入评价内容', trigger: 'blur' },
{ min: 10, max: 500, message: '长度在 10 到 500 个字符', trigger: 'blur' }
]
},
// 回复表单
replyDialogVisible: false,
replyLoading: false,
currentReview: null,
replyForm: {
reviewId: null,
content: ''
},
replyRules: {
content: [
{ required: true, message: '请输入回复内容', trigger: 'blur' },
{ min: 1, max: 200, message: '长度在 1 到 200 个字符', trigger: 'blur' }
]
},
// 图片上传
fileList: [],
dialogImageUrl: '',
dialogImageVisible: false,
// 默认头像
defaultAvatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
// 用户信息缓存
userInfoCache: {}
};
},
computed: {
...mapGetters(['userId', 'userInfo']),
canReview() {
return this.userId && !this.hasReviewed;
},
canReply() {
return this.userId && this.userInfo && this.userInfo.role === 'landlord';
},
hasReviewed() {
return this.reviews.some(review => review.userId === this.userId);
}
},
created() {
this.fetchReviews();
this.fetchReviewStatistics();
this.checkUserReviewed();
},
methods: {
formatDate,
fetchReviews() {
this.loading = true;
reviewApi.getHouseReviews(this.houseId)
.then(response => {
this.reviews = response.data || [];
this.total = this.reviews.length;
// 获取用户信息和回复
this.fetchUserInfo();
this.fetchReplies();
})
.catch(error => {
this.$message.error(error.message || '获取评价列表失败');
})
.finally(() => {
this.loading = false;
});
},
fetchReviewStatistics() {
reviewApi.getHouseReviewStatistics(this.houseId)
.then(response => {
this.reviewStatistics = response.data;
})
.catch(() => {
// 忽略错误
});
},
checkUserReviewed() {
if (!this.userId) return;
reviewApi.checkUserReviewed(this.houseId)
.then(response => {
this.hasReviewed = response.data;
})
.catch(() => {
// 忽略错误
});
},
fetchUserInfo() {
const userIds = [...new Set(this.reviews.map(review => review.userId))];
userIds.forEach(userId => {
if (this.userInfoCache[userId]) return;
userApi.getUserInfo(userId)
.then(response => {
this.userInfoCache[userId] = response.data;
// 更新评价中的用户信息
this.reviews.forEach(review => {
if (review.userId === userId) {
this.$set(review, 'userName', response.data.username);
this.$set(review, 'userAvatar', response.data.avatar);
}
});
})
.catch(() => {
// 忽略错误
});
});
},
fetchReplies() {
this.reviews.forEach(review => {
reviewApi.getReviewReplies(review.id)
.then(response => {
this.$set(review, 'replies', response.data || []);
// 获取回复用户信息
if (response.data && response.data.length > 0) {
const replyUserIds = [...new Set(response.data.map(reply => reply.userId))];
this.fetchReplyUserInfo(replyUserIds, review);
}
})
.catch(() => {
// 忽略错误
});
});
},
fetchReplyUserInfo(userIds, review) {
userIds.forEach(userId => {
if (this.userInfoCache[userId]) {
// 使用缓存的用户信息
review.replies.forEach(reply => {
if (reply.userId === userId) {
this.$set(reply, 'userName', this.userInfoCache[userId].username);
this.$set(reply, 'userAvatar', this.userInfoCache[userId].avatar);
}
});
return;
}
userApi.getUserInfo(userId)
.then(response => {
this.userInfoCache[userId] = response.data;
// 更新回复中的用户信息
review.replies.forEach(reply => {
if (reply.userId === userId) {
this.$set(reply, 'userName', response.data.username);
this.$set(reply, 'userAvatar', response.data.avatar);
}
});
})
.catch(() => {
// 忽略错误
});
});
},
getDistributionPercentage(count) {
if (!this.reviewStatistics || this.reviewStatistics.totalCount === 0) return 0;
return Math.round((count / this.reviewStatistics.totalCount) * 100);
},
getDistributionColor(stars) {
const colorMap = {
5: '#67C23A', // 绿色
4: '#85CE61',
3: '#E6A23C', // 黄色
2: '#F56C6C',
1: '#F56C6C' // 红色
};
return colorMap[stars] || '#909399';
},
handleSizeChange(val) {
this.pageSize = val;
},
handleCurrentChange(val) {
this.currentPage = val;
},
handleAddReview() {
this.reviewFormTitle = '写评价';
this.reviewForm = {
id: null,
houseId: this.houseId,
rating: 5,
locationRating: 5,
cleanlinessRating: 5,
valueRating: 5,
landlordRating: 5,
content: '',
images: []
};
this.fileList = [];
this.reviewDialogVisible = true;
},
handleEditReview(review) {
this.reviewFormTitle = '编辑评价';
this.reviewForm = {
id: review.id,
houseId: this.houseId,
rating: review.rating,
locationRating: review.locationRating,
cleanlinessRating: review.cleanlinessRating,
valueRating: review.valueRating,
landlordRating: review.landlordRating,
content: review.content,
images: review.images ? review.images.split(',') : []
};
// 设置文件列表
this.fileList = [];
if (review.images) {
const images = review.images.split(',');
images.forEach((url, index) => {
this.fileList.push({
name: `image-${index}`,
url
});
});
}
this.reviewDialogVisible = true;
},
handleDeleteReview(review) {
this.$confirm('确定要删除此评价吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
reviewApi.deleteReview(review.id)
.then(() => {
this.$message.success('评价已删除');
this.fetchReviews();
this.fetchReviewStatistics();
this.checkUserReviewed();
})
.catch(error => {
this.$message.error(error.message || '删除评价失败');
});
}).catch(() => {
// 取消操作
});
},
handleReply(review) {
this.currentReview = review;
this.replyForm = {
reviewId: review.id,
content: ''
};
this.replyDialogVisible = true;
},
submitReview() {
this.$refs.reviewForm.validate(valid => {
if (valid) {
this.reviewLoading = true;
// 处理图片
const reviewData = { ...this.reviewForm };
if (reviewData.images && reviewData.images.length > 0) {
reviewData.images = reviewData.images;
}
const request = reviewData.id
? reviewApi.updateReview(reviewData.id, reviewData)
: reviewApi.submitReview(reviewData);
request.then(() => {
this.$message.success(reviewData.id ? '评价已更新' : '评价已提交');
this.reviewDialogVisible = false;
this.fetchReviews();
this.fetchReviewStatistics();
this.checkUserReviewed();
}).catch(error => {
this.$message.error(error.message || (reviewData.id ? '更新评价失败' : '提交评价失败'));
}).finally(() => {
this.reviewLoading = false;
});
} else {
return false;
}
});
},
submitReply() {
this.$refs.replyForm.validate(valid => {
if (valid) {
this.replyLoading = true;
reviewApi.submitReply(this.replyForm)
.then(() => {
this.$message.success('回复已提交');
this.replyDialogVisible = false;
this.fetchReplies();
})
.catch(error => {
this.$message.error(error.message || '提交回复失败');
})
.finally(() => {
this.replyLoading = false;
});
} else {
return false;
}
});
},
handleRemove(file, fileList) {
this.fileList = fileList;
// 从images数组中移除
const index = this.reviewForm.images.indexOf(file.url || file.response.data);
if (index !== -1) {
this.reviewForm.images.splice(index, 1);
}
},
handlePictureCardPreview(file) {
this.dialogImageUrl = file.url || file.response.data;
this.dialogImageVisible = true;
},
handleUploadSuccess(response, file, fileList) {
this.fileList = fileList;
if (response.code === 200) {
if (!this.reviewForm.images) {
this.reviewForm.images = [];
}
this.reviewForm.images.push(response.data);
} else {
this.$message.error(response.message || '上传图片失败');
}
},
beforeUpload(file) {
const isImage = file.type.startsWith('image/');
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
this.$message.error('只能上传图片文件!');
return false;
}
if (!isLt2M) {
this.$message.error('图片大小不能超过 2MB!');
return false;
}
return true;
}
}
};
</script>
<style scoped>
.house-reviews-container {
margin-bottom: 30px;
}
.review-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.review-statistics {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #ebeef5;
}
.rating-overview {
flex: 1;
margin-right: 20px;
}
.average-rating {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.rating-number {
font-size: 36px;
font-weight: bold;
color: #ff9900;
margin-right: 10px;
}
.total-reviews {
margin-left: 10px;
color: #909399;
}
.rating-details {
margin-top: 10px;
}
.rating-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.rating-label {
width: 70px;
color: #606266;
}
.rating-distribution {
flex: 1;
max-width: 300px;
}
.distribution-item {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.star-label {
width: 40px;
color: #606266;
}
.count-label {
width: 30px;
text-align: right;
color: #909399;
margin-left: 10px;
}
.no-reviews {
text-align: center;
color: #909399;
padding: 30px 0;
}
.review-list {
margin-top: 20px;
}
.review-item {
padding: 20px 0;
border-bottom: 1px solid #ebeef5;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.user-info {
display: flex;
align-items: center;
}
.username {
margin-left: 10px;
font-weight: bold;
}
.review-rating {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.review-time {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.review-content {
line-height: 1.6;
margin-bottom: 15px;
}
.review-images {
display: flex;
flex-wrap: wrap;
margin-bottom: 15px;
}
.review-image {
width: 100px;
height: 100px;
margin-right: 10px;
margin-bottom: 10px;
border-radius: 4px;
object-fit: cover;
}
.review-replies {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.reply-item {
margin-bottom: 10px;
}
.reply-item:last-child {
margin-bottom: 0;
}
.reply-header {
margin-bottom: 5px;
}
.reply-user {
font-weight: bold;
}
.reply-time {
font-size: 12px;
color: #909399;
margin-left: 10px;
}
.reply-content {
line-height: 1.6;
}
.review-actions {
display: flex;
justify-content: flex-end;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
</style>
File: d:\htsun\Docs\资料\文章\rental-system\frontend\src\views\house\components\HouseFeedbacks.vue
vue
CopyInsert
<template>
<div class="house-feedbacks-container">
<el-card class="feedback-card">
<div slot="header" class="card-header">
<span>看房反馈</span>
</div>
<div v-if="feedbacks.length === 0" class="no-feedbacks">
暂无看房反馈
</div>
<div v-else class="feedback-list">
<div v-for="feedback in feedbacks" :key="feedback.id" class="feedback-item">
<div class="feedback-header">
<div class="user-info">
<el-avatar :size="40" :src="feedback.userAvatar || defaultAvatar"></el-avatar>
<span class="username">{{ feedback.userName || '匿名用户' }}</span>
</div>
<div class="feedback-rating">
<el-rate
v-model="feedback.satisfactionLevel"
disabled
show-score
text-color="#ff9900">
</el-rate>
<span class="feedback-time">{{ formatDate(feedback.createTime) }}</span>
</div>
</div>
<div class="feedback-content">{{ feedback.feedbackContent }}</div>
</div>
</div>
<div class="pagination-container" v-if="feedbacks.length > 0">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[5, 10, 20, 50]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total">
</el-pagination>
</div>
</el-card>
</div>
</template>
<script>
import feedbackApi from '@/api/feedback';
import userApi from '@/api/user';
import { formatDate } from '@/utils/date';
export default {
name: 'HouseFeedbacks',
props: {
houseId: {
type: [Number, String],
required: true
}
},
data() {
return {
feedbacks: [],
loading: false,
currentPage: 1,
pageSize: 10,
total: 0,
// 默认头像
defaultAvatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
// 用户信息缓存
userInfoCache: {}
};
},
created() {
this.fetchFeedbacks();
},
methods: {
formatDate,
fetchFeedbacks() {
this.loading = true;
feedbackApi.getHousePublicFeedbacks(this.houseId)
.then(response => {
this.feedbacks = response.data || [];
this.total = this.feedbacks.length;
// 获取用户信息
this.fetchUserInfo();
})
.catch(error => {
this.$message.error(error.message || '获取看房反馈失败');
})
.finally(() => {
this.loading = false;
});
},
fetchUserInfo() {
const userIds = [...new Set(this.feedbacks.map(feedback => feedback.userId))];
userIds.forEach(userId => {
if (this.userInfoCache[userId]) return;
userApi.getUserInfo(userId)
.then(response => {
this.userInfoCache[userId] = response.data;
// 更新反馈中的用户信息
this.feedbacks.forEach(feedback => {
if (feedback.userId === userId) {
this.$set(feedback, 'userName', response.data.username);
this.$set(feedback, 'userAvatar', response.data.avatar);
}
});
})
.catch(() => {
// 忽略错误
});
});
},
handleSizeChange(val) {
this.pageSize = val;
},
handleCurrentChange(val) {
this.currentPage = val;
}
}
};
</script>
<style scoped>
.house-feedbacks-container {
margin-bottom: 30px;
}
.feedback-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.no-feedbacks {
text-align: center;
color: #909399;
padding: 30px 0;
}
.feedback-list {
margin-top: 20px;
}
.feedback-item {
padding: 20px 0;
border-bottom: 1px solid #ebeef5;
}
.feedback-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.user-info {
display: flex;
align-items: center;
}
.username {
margin-left: 10px;
font-weight: bold;
}
.feedback-rating {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.feedback-time {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.feedback-content {
line-height: 1.6;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
</style>
3.3 Router Configuration
File: d:\htsun\Docs\资料\文章\rental-system\frontend\src\router\modules\appointment.js
javascript
CopyInsert
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
File: d:\htsun\Docs\资料\文章\rental-system\frontend\src\store\modules\appointment.js
javascript
CopyInsert
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\views\house\components\HouseFeedbacks.vue
```vue
<template>
<div class="house-feedbacks-container">
<el-card class="feedback-card">
<div slot="header" class="card-header">
<span>看房反馈</span>
</div>
<div v-if="feedbacks.length === 0" class="no-feedbacks">
暂无看房反馈
</div>
<div v-else class="feedback-list">
<div v-for="feedback in feedbacks" :key="feedback.id" class="feedback-item">
<div class="feedback-header">
<div class="user-info">
<el-avatar :size="40" :src="feedback.userAvatar || defaultAvatar"></el-avatar>
<span class="username">{{ feedback.userName || '匿名用户' }}</span>
</div>
<div class="feedback-rating">
<el-rate
v-model="feedback.satisfactionLevel"
disabled
show-score
text-color="#ff9900">
</el-rate>
<span class="feedback-time">{{ formatDate(feedback.createTime) }}</span>
</div>
</div>
<div class="feedback-content">{{ feedback.feedbackContent }}</div>
</div>
</div>
<div class="pagination-container" v-if="feedbacks.length > 0">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[5, 10, 20, 50]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total">
</el-pagination>
</div>
</el-card>
</div>
</template>
<script>
import feedbackApi from '@/api/feedback';
import userApi from '@/api/user';
import { formatDate } from '@/utils/date';
export default {
name: 'HouseFeedbacks',
props: {
houseId: {
type: [Number, String],
required: true
}
},
data() {
return {
feedbacks: [],
loading: false,
currentPage: 1,
pageSize: 10,
total: 0,
// 默认头像
defaultAvatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
// 用户信息缓存
userInfoCache: {}
};
},
created() {
this.fetchFeedbacks();
},
methods: {
formatDate,
fetchFeedbacks() {
this.loading = true;
feedbackApi.getHousePublicFeedbacks(this.houseId)
.then(response => {
this.feedbacks = response.data || [];
this.total = this.feedbacks.length;
// 获取用户信息
this.fetchUserInfo();
})
.catch(error => {
this.$message.error(error.message || '获取看房反馈失败');
})
.finally(() => {
this.loading = false;
});
},
fetchUserInfo() {
const userIds = [...new Set(this.feedbacks.map(feedback => feedback.userId))];
userIds.forEach(userId => {
if (this.userInfoCache[userId]) return;
userApi.getUserInfo(userId)
.then(response => {
this.userInfoCache[userId] = response.data;
// 更新反馈中的用户信息
this.feedbacks.forEach(feedback => {
if (feedback.userId === userId) {
this.$set(feedback, 'userName', response.data.username);
this.$set(feedback, 'userAvatar', response.data.avatar);
}
});
})
.catch(() => {
// 忽略错误
});
});
},
handleSizeChange(val) {
this.pageSize = val;
},
handleCurrentChange(val) {
this.currentPage = val;
}
}
};
</script>
<style scoped>
.house-feedbacks-container {
margin-bottom: 30px;
}
.feedback-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.no-feedbacks {
text-align: center;
color: #909399;
padding: 30px 0;
}
.feedback-list {
margin-top: 20px;
}
.feedback-item {
padding: 20px 0;
border-bottom: 1px solid #ebeef5;
}
.feedback-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.user-info {
display: flex;
align-items: center;
}
.username {
margin-left: 10px;
font-weight: bold;
}
.feedback-rating {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.feedback-time {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.feedback-content {
line-height: 1.6;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
</style>
3.3 Router Configuration
frontend\src\views\house\components\HouseReviews.vue
<template>
<div class="house-reviews-container">
<el-card class="review-card">
<div slot="header" class="card-header">
<span>房源评价</span>
<el-button
v-if="canReview"
type="primary"
size="small"
@click="handleAddReview">写评价</el-button>
</div>
<div v-if="reviewStatistics" class="review-statistics">
<div class="rating-overview">
<div class="average-rating">
<span class="rating-number">{{ reviewStatistics.averageRating.toFixed(1) }}</span>
<el-rate
v-model="reviewStatistics.averageRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
<span class="total-reviews">{{ reviewStatistics.totalCount }}条评价</span>
</div>
<div class="rating-details">
<div class="rating-item">
<span class="rating-label">位置:</span>
<el-rate
v-model="reviewStatistics.averageLocationRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
</div>
<div class="rating-item">
<span class="rating-label">清洁度:</span>
<el-rate
v-model="reviewStatistics.averageCleanlinessRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
</div>
<div class="rating-item">
<span class="rating-label">性价比:</span>
<el-rate
v-model="reviewStatistics.averageValueRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
</div>
<div class="rating-item">
<span class="rating-label">房东:</span>
<el-rate
v-model="reviewStatistics.averageLandlordRating"
disabled
show-score
text-color="#ff9900">
</el-rate>
</div>
</div>
</div>
<div class="rating-distribution">
<div v-for="(count, index) in reviewStatistics.ratingDistribution" :key="index" class="distribution-item">
<span class="star-label">{{ 5 - index }}星</span>
<el-progress
:percentage="getDistributionPercentage(count)"
:color="getDistributionColor(5 - index)"
:show-text="false">
</el-progress>
<span class="count-label">{{ count }}</span>
</div>
</div>
</div>
<div v-if="reviews.length === 0" class="no-reviews">
暂无评价
</div>
<div v-else class="review-list">
<div v-for="review in reviews" :key="review.id" class="review-item">
<div class="review-header">
<div class="user-info">
<el-avatar :size="40" :src="review.userAvatar || defaultAvatar"></el-avatar>
<span class="username">{{ review.userName || '匿名用户' }}</span>
</div>
<div class="review-rating">
<el-rate
v-model="review.rating"
disabled
text-color="#ff9900">
</el-rate>
<span class="review-time">{{ formatDate(review.createTime) }}</span>
</div>
</div>
<div class="review-content">{{ review.content }}</div>
<div v-if="review.images" class="review-images">
<el-image
v-for="(image, index) in review.images.split(',')"
:key="index"
:src="image"
:preview-src-list="review.images.split(',')"
fit="cover"
class="review-image">
</el-image>
</div>
<!-- 评价回复 -->
<div v-if="review.replies && review.replies.length > 0" class="review-replies">
<div v-for="reply in review.replies" :key="reply.id" class="reply-item">
<div class="reply-header">
<span class="reply-user">{{ reply.userName || '匿名用户' }}:</span>
<span class="reply-time">{{ formatDate(reply.createTime) }}</span>
</div>
<div class="reply-content">{{ reply.content }}</div>
</div>
</div>
<div class="review-actions">
<el-button
v-if="canReply"
type="text"
@click="handleReply(review)">回复</el-button>
<el-button
v-if="userId === review.userId"
type="text"
@click="handleEditReview(review)">编辑</el-button>
<el-button
v-if="userId === review.userId"
type="text"
@click="handleDeleteReview(review)">删除</el-button>
</div>
</div>
</div>
<div class="pagination-container" v-if="reviews.length > 0">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[5, 10, 20, 50]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total">
</el-pagination>
</div>
</el-card>
<!-- 评价表单对话框 -->
<el-dialog :title="reviewFormTitle" :visible.sync="reviewDialogVisible" width="650px">
<el-form ref="reviewForm" :model="reviewForm" :rules="reviewRules" label-width="100px">
<el-form-item label="总体评分" prop="rating">
<el-rate
v-model="reviewForm.rating"
:colors="['#99A9BF', '#F7BA2A', '#FF9900']"
:texts="['失望', '一般', '满意', '很满意', '非常满意']"
show-text>
</el-rate>
</el-form-item>
<el-form-item label="位置评分" prop="locationRating">
<el-rate v-model="reviewForm.locationRating"></el-rate>
</el-form-item>
<el-form-item label="清洁度评分" prop="cleanlinessRating">
<el-rate v-model="reviewForm.cleanlinessRating"></el-rate>
</el-form-item>
<el-form-item label="性价比评分" prop="valueRating">
<el-rate v-model="reviewForm.valueRating"></el-rate>
</el-form-item>
<el-form-item label="房东评分" prop="landlordRating">
<el-rate v-model="reviewForm.landlordRating"></el-rate>
</el-form-item>
<el-form-item label="评价内容" prop="content">
<el-input
v-model="reviewForm.content"
type="textarea"
:rows="6"
placeholder="请详细描述您的居住体验,包括房源状况、交通便利性、周边环境等">
</el-input>
</el-form-item>
<el-form-item label="上传图片">
<el-upload
action="/api/upload/image"
list-type="picture-card"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove"
:on-success="handleUploadSuccess"
:before-upload="beforeUpload"
:file-list="fileList">
<i class="el-icon-plus"></i>
</el-upload>
<el-dialog :visible.sync="dialogImageVisible">
<img width="100%" :src="dialogImageUrl" alt="">
</el-dialog>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="reviewDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitReview" :loading="reviewLoading">提交</el-button>
</div>
</el-dialog>
<!-- 回复表单对话框 -->
<el-dialog title="回复评价" :visible.sync="replyDialogVisible" width="600px">
<el-form ref="replyForm" :model="replyForm" :rules="replyRules" label-width="100px">
<el-form-item label="回复内容" prop="content">
<el-input
v-model="replyForm.content"
type="textarea"
:rows="4"
placeholder="请输入回复内容">
</el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="replyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitReply" :loading="replyLoading">提交</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import reviewApi from '@/api/review';
import userApi from '@/api/user';
import { formatDate } from '@/utils/date';
import { mapGetters } from 'vuex';
export default {
name: 'HouseReviews',
props: {
houseId: {
type: [Number, String],
required: true
}
},
data() {
return {
reviews: [],
reviewStatistics: null,
loading: false,
currentPage: 1,
pageSize: 10,
total: 0,
// 评价表单
reviewDialogVisible: false,
reviewFormTitle: '写评价',
reviewLoading: false,
reviewForm: {
id: null,
houseId: this.houseId,
rating: 5,
locationRating: 5,
cleanlinessRating: 5,
valueRating: 5,
landlordRating: 5,
content: '',
images: []
},
reviewRules: {
rating: [
{ required: true, message: '请选择评分', trigger: 'change' }
],
locationRating: [
{ required: true, message: '请选择位置评分', trigger: 'change' }
],
cleanlinessRating: [
{ required: true, message: '请选择清洁度评分', trigger: 'change' }
],
valueRating: [
{ required: true, message: '请选择性价比评分', trigger: 'change' }
],
landlordRating: [
{ required: true, message: '请选择房东评分', trigger: 'change' }
],
content: [
{ required: true, message: '请输入评价内容', trigger: 'blur' },
{ min: 10, max: 500, message: '长度在 10 到 500 个字符', trigger: 'blur' }
]
},
// 回复表单
replyDialogVisible: false,
replyLoading: false,
currentReview: null,
replyForm: {
reviewId: null,
content: ''
},
replyRules: {
content: [
{ required: true, message: '请输入回复内容', trigger: 'blur' },
{ min: 1, max: 200, message: '长度在 1 到 200 个字符', trigger: 'blur' }
]
},
// 图片上传
fileList: [],
dialogImageUrl: '',
dialogImageVisible: false,
// 默认头像
defaultAvatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
// 用户信息缓存
userInfoCache: {}
};
},
computed: {
...mapGetters(['userId', 'userInfo']),
canReview() {
return this.userId && !this.hasReviewed;
},
canReply() {
return this.userId && this.userInfo && this.userInfo.role === 'landlord';
},
hasReviewed() {
return this.reviews.some(review => review.userId === this.userId);
}
},
created() {
this.fetchReviews();
this.fetchReviewStatistics();
this.checkUserReviewed();
},
methods: {
formatDate,
fetchReviews() {
this.loading = true;
reviewApi.getHouseReviews(this.houseId)
.then(response => {
this.reviews = response.data || [];
this.total = this.reviews.length;
// 获取用户信息和回复
this.fetchUserInfo();
this.fetchReplies();
})
.catch(error => {
this.$message.error(error.message || '获取评价列表失败');
})
.finally(() => {
this.loading = false;
});
},
fetchReviewStatistics() {
reviewApi.getHouseReviewStatistics(this.houseId)
.then(response => {
this.reviewStatistics = response.data;
})
.catch(() => {
// 忽略错误
});
},
checkUserReviewed() {
if (!this.userId) return;
reviewApi.checkUserReviewed(this.houseId)
.then(response => {
this.hasReviewed = response.data;
})
.catch(() => {
// 忽略错误
});
},
fetchUserInfo() {
const userIds = [...new Set(this.reviews.map(review => review.userId))];
userIds.forEach(userId => {
if (this.userInfoCache[userId]) return;
userApi.getUserInfo(userId)
.then(response => {
this.userInfoCache[userId] = response.data;
// 更新评价中的用户信息
this.reviews.forEach(review => {
if (review.userId === userId) {
this.$set(review, 'userName', response.data.username);
this.$set(review, 'userAvatar', response.data.avatar);
}
});
})
.catch(() => {
// 忽略错误
});
});
},
fetchReplies() {
this.reviews.forEach(review => {
reviewApi.getReviewReplies(review.id)
.then(response => {
this.$set(review, 'replies', response.data || []);
// 获取回复用户信息
if (response.data && response.data.length > 0) {
const replyUserIds = [...new Set(response.data.map(reply => reply.userId))];
this.fetchReplyUserInfo(replyUserIds, review);
}
})
.catch(() => {
// 忽略错误
});
});
},
fetchReplyUserInfo(userIds, review) {
userIds.forEach(userId => {
if (this.userInfoCache[userId]) {
// 使用缓存的用户信息
review.replies.forEach(reply => {
if (reply.userId === userId) {
this.$set(reply, 'userName', this.userInfoCache[userId].username);
this.$set(reply, 'userAvatar', this.userInfoCache[userId].avatar);
}
});
return;
}
userApi.getUserInfo(userId)
.then(response => {
this.userInfoCache[userId] = response.data;
// 更新回复中的用户信息
review.replies.forEach(reply => {
if (reply.userId === userId) {
this.$set(reply, 'userName', response.data.username);
this.$set(reply, 'userAvatar', response.data.avatar);
}
});
})
.catch(() => {
// 忽略错误
});
});
},
getDistributionPercentage(count) {
if (!this.reviewStatistics || this.reviewStatistics.totalCount === 0) return 0;
return Math.round((count / this.reviewStatistics.totalCount) * 100);
},
getDistributionColor(stars) {
const colorMap = {
5: '#67C23A', // 绿色
4: '#85CE61',
3: '#E6A23C', // 黄色
2: '#F56C6C',
1: '#F56C6C' // 红色
};
return colorMap[stars] || '#909399';
},
handleSizeChange(val) {
this.pageSize = val;
},
handleCurrentChange(val) {
this.currentPage = val;
},
handleAddReview() {
this.reviewFormTitle = '写评价';
this.reviewForm = {
id: null,
houseId: this.houseId,
rating: 5,
locationRating: 5,
cleanlinessRating: 5,
valueRating: 5,
landlordRating: 5,
content: '',
images: []
};
this.fileList = [];
this.reviewDialogVisible = true;
},
handleEditReview(review) {
this.reviewFormTitle = '编辑评价';
this.reviewForm = {
id: review.id,
houseId: this.houseId,
rating: review.rating,
locationRating: review.locationRating,
cleanlinessRating: review.cleanlinessRating,
valueRating: review.valueRating,
landlordRating: review.landlordRating,
content: review.content,
images: review.images ? review.images.split(',') : []
};
// 设置文件列表
this.fileList = [];
if (review.images) {
const images = review.images.split(',');
images.forEach((url, index) => {
this.fileList.push({
name: `image-${index}`,
url
});
});
}
this.reviewDialogVisible = true;
},
handleDeleteReview(review) {
this.$confirm('确定要删除此评价吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
reviewApi.deleteReview(review.id)
.then(() => {
this.$message.success('评价已删除');
this.fetchReviews();
this.fetchReviewStatistics();
this.checkUserReviewed();
})
.catch(error => {
this.$message.error(error.message || '删除评价失败');
});
}).catch(() => {
// 取消操作
});
},
handleReply(review) {
this.currentReview = review;
this.replyForm = {
reviewId: review.id,
content: ''
};
this.replyDialogVisible = true;
},
submitReview() {
this.$refs.reviewForm.validate(valid => {
if (valid) {
this.reviewLoading = true;
// 处理图片
const reviewData = { ...this.reviewForm };
if (reviewData.images && reviewData.images.length > 0) {
reviewData.images = reviewData.images;
}
const request = reviewData.id
? reviewApi.updateReview(reviewData.id, reviewData)
: reviewApi.submitReview(reviewData);
request.then(() => {
this.$message.success(reviewData.id ? '评价已更新' : '评价已提交');
this.reviewDialogVisible = false;
this.fetchReviews();
this.fetchReviewStatistics();
this.checkUserReviewed();
}).catch(error => {
this.$message.error(error.message || (reviewData.id ? '更新评价失败' : '提交评价失败'));
}).finally(() => {
this.reviewLoading = false;
});
} else {
return false;
}
});
},
submitReply() {
this.$refs.replyForm.validate(valid => {
if (valid) {
this.replyLoading = true;
reviewApi.submitReply(this.replyForm)
.then(() => {
this.$message.success('回复已提交');
this.replyDialogVisible = false;
this.fetchReplies();
})
.catch(error => {
this.$message.error(error.message || '提交回复失败');
})
.finally(() => {
this.replyLoading = false;
});
} else {
return false;
}
});
},
handleRemove(file, fileList) {
this.fileList = fileList;
// 从images数组中移除
const index = this.reviewForm.images.indexOf(file.url || file.response.data);
if (index !== -1) {
this.reviewForm.images.splice(index, 1);
}
},
handlePictureCardPreview(file) {
this.dialogImageUrl = file.url || file.response.data;
this.dialogImageVisible = true;
},
handleUploadSuccess(response, file, fileList) {
this.fileList = fileList;
if (response.code === 200) {
if (!this.reviewForm.images) {
this.reviewForm.images = [];
}
this.reviewForm.images.push(response.data);
} else {
this.$message.error(response.message || '上传图片失败');
}
},
beforeUpload(file) {
const isImage = file.type.startsWith('image/');
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
this.$message.error('只能上传图片文件!');
return false;
}
if (!isLt2M) {
this.$message.error('图片大小不能超过 2MB!');
return false;
}
return true;
}
}
};
</script>
<style scoped>
.house-reviews-container {
margin-bottom: 30px;
}
.review-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.review-statistics {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #ebeef5;
}
.rating-overview {
flex: 1;
margin-right: 20px;
}
.average-rating {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.rating-number {
font-size: 36px;
font-weight: bold;
color: #ff9900;
margin-right: 10px;
}
.total-reviews {
margin-left: 10px;
color: #909399;
}
.rating-details {
margin-top: 10px;
}
.rating-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.rating-label {
width: 70px;
color: #606266;
}
.rating-distribution {
flex: 1;
max-width: 300px;
}
.distribution-item {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.star-label {
width: 40px;
color: #606266;
}
.count-label {
width: 30px;
text-align: right;
color: #909399;
margin-left: 10px;
}
.no-reviews {
text-align: center;
color: #909399;
padding: 30px 0;
}
.review-list {
margin-top: 20px;
}
.review-item {
padding: 20px 0;
border-bottom: 1px solid #ebeef5;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.user-info {
display: flex;
align-items: center;
}
.username {
margin-left: 10px;
font-weight: bold;
}
.review-rating {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.review-time {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.review-content {
line-height: 1.6;
margin-bottom: 15px;
}
.review-images {
display: flex;
flex-wrap: wrap;
margin-bottom: 15px;
}
.review-image {
width: 100px;
height: 100px;
margin-right: 10px;
margin-bottom: 10px;
border-radius: 4px;
object-fit: cover;
}
.review-replies {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.reply-item {
margin-bottom: 10px;
}
.reply-item:last-child {
margin-bottom: 0;
}
.reply-header {
margin-bottom: 5px;
}
.reply-user {
font-weight: bold;
}
.reply-time {
font-size: 12px;
color: #909399;
margin-left: 10px;
}
.reply-content {
line-height: 1.6;
}
.review-actions {
display: flex;
justify-content: flex-end;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
</style>
frontend\src\views\user\Favorite.vue
<template>
<div class="favorite-container">
<el-card class="favorite-card">
<div slot="header" class="card-header">
<span>我的收藏</span>
</div>
<div v-if="loading" class="loading-container">
<el-skeleton :rows="3" animated />
<el-skeleton :rows="3" animated />
</div>
<div v-else-if="favoriteList.length === 0" class="empty-container">
<el-empty description="暂无收藏房源">
<el-button type="primary" @click="$router.push('/home')">去浏览房源</el-button>
</el-empty>
</div>
<div v-else class="house-list">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="8" v-for="item in favoriteList" :key="item.id">
<el-card class="house-card" shadow="hover" @click.native="viewHouseDetail(item.houseId)">
<div class="house-image">
<img :src="item.coverUrl || 'https://via.placeholder.com/300x200'" alt="房源图片">
<div class="house-price">{{ item.price }}元/月</div>
</div>
<div class="house-info">
<h3 class="house-title">{{ item.title }}</h3>
<div class="house-tags">
<el-tag size="mini">{{ item.houseType }}</el-tag>
<el-tag size="mini" type="success">{{ item.area }}㎡</el-tag>
<el-tag size="mini" type="info">{{ item.orientation }}</el-tag>
</div>
<div class="house-address">
<i class="el-icon-location"></i>
<span>{{ item.community }}</span>
</div>
<div class="house-time">收藏时间:{{ formatDate(item.createTime) }}</div>
</div>
<div class="house-actions">
<el-button type="text" size="mini" @click.stop="cancelFavorite(item.id, item.houseId)">
<i class="el-icon-star-off"></i> 取消收藏
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
<el-pagination
v-if="total > 0"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[12, 24, 36, 48]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
class="pagination">
</el-pagination>
</el-card>
</div>
</template>
<script>
import { getFavoriteList, cancelFavorite } from '@/api/favorite';
import { formatDate } from '@/utils/date';
export default {
name: 'UserFavorite',
data() {
return {
favoriteList: [],
loading: true,
currentPage: 1,
pageSize: 12,
total: 0
};
},
created() {
this.fetchFavoriteList();
},
methods: {
formatDate,
async fetchFavoriteList() {
this.loading = true;
try {
const res = await getFavoriteList({
page: this.currentPage,
size: this.pageSize
});
if (res.code === 200) {
this.favoriteList = res.data.list;
this.total = res.data.total;
}
} catch (error) {
this.$message.error('获取收藏列表失败');
console.error(error);
} finally {
this.loading = false;
}
},
async cancelFavorite(id, houseId) {
try {
const res = await cancelFavorite(houseId);
if (res.code === 200) {
this.$message.success('取消收藏成功');
this.favoriteList = this.favoriteList.filter(item => item.id !== id);
if (this.favoriteList.length === 0 && this.currentPage > 1) {
this.currentPage--;
this.fetchFavoriteList();
}
}
} catch (error) {
this.$message.error('取消收藏失败');
console.error(error);
}
},
viewHouseDetail(houseId) {
this.$router.push(`/house/detail/${houseId}`);
},
handleSizeChange(val) {
this.pageSize = val;
this.fetchFavoriteList();
},
handleCurrentChange(val) {
this.currentPage = val;
this.fetchFavoriteList();
}
}
};
</script>
<style scoped>
.favorite-container {
max-width: 1200px;
margin: 20px auto;
}
.favorite-card {
margin-bottom: 20px;
}
.card-header {
font-size: 18px;
font-weight: bold;
}
.loading-container {
padding: 20px 0;
}
.empty-container {
padding: 40px 0;
text-align: center;
}
.house-list {
margin-bottom: 20px;
}
.house-card {
margin-bottom: 20px;
cursor: pointer;
transition: all 0.3s;
}
.house-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.house-image {
position: relative;
height: 180px;
overflow: hidden;
}
.house-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.house-price {
position: absolute;
bottom: 0;
right: 0;
background-color: rgba(255, 90, 95, 0.9);
color: #fff;
padding: 5px 10px;
font-weight: bold;
}
.house-info {
padding: 10px 0;
}
.house-title {
margin: 0 0 10px;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.house-tags {
margin-bottom: 10px;
}
.house-tags .el-tag {
margin-right: 5px;
}
.house-address {
color: #666;
font-size: 14px;
margin-bottom: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.house-time {
color: #999;
font-size: 12px;
}
.house-actions {
border-top: 1px solid #eee;
padding-top: 10px;
text-align: right;
}
.pagination {
text-align: center;
margin-top: 20px;
}
</style>
Admin Components
frontend\src\views\user\Profile.vue
<template>
<div class="profile-container">
<el-card class="profile-card">
<div slot="header" class="card-header">
<span>个人资料</span>
</div>
<el-form :model="userForm" :rules="rules" ref="userForm" label-width="100px">
<div class="avatar-container">
<el-avatar :size="100" :src="avatarUrl"></el-avatar>
<el-upload
class="avatar-uploader"
action="#"
:http-request="uploadAvatar"
:show-file-list="false"
:before-upload="beforeAvatarUpload">
<el-button size="small" type="primary">更换头像</el-button>
</el-upload>
</div>
<el-form-item label="用户名" prop="username">
<el-input v-model="userForm.username" disabled></el-input>
</el-form-item>
<el-form-item label="真实姓名" prop="realName">
<el-input v-model="userForm.realName"></el-input>
</el-form-item>
<el-form-item label="手机号码" prop="phone">
<el-input v-model="userForm.phone"></el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="userForm.email"></el-input>
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="userForm.gender">
<el-radio :label="1">男</el-radio>
<el-radio :label="2">女</el-radio>
<el-radio :label="0">保密</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('userForm')">保存修改</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="profile-card">
<div slot="header" class="card-header">
<span>修改密码</span>
</div>
<el-form :model="passwordForm" :rules="passwordRules" ref="passwordForm" label-width="100px">
<el-form-item label="原密码" prop="oldPassword">
<el-input v-model="passwordForm.oldPassword" type="password"></el-input>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="passwordForm.newPassword" type="password"></el-input>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="passwordForm.confirmPassword" type="password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitPasswordForm('passwordForm')">修改密码</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="profile-card">
<div slot="header" class="card-header">
<span>身份认证</span>
</div>
<div class="verification-container">
<div class="verification-item">
<div class="verification-title">学生认证</div>
<div class="verification-status">
<el-tag :type="studentVerificationStatusType">{{ studentVerificationStatusText }}</el-tag>
</div>
<div class="verification-action">
<el-button
type="primary"
size="small"
@click="goToStudentVerification"
:disabled="studentVerificationStatus === 1">
{{ studentVerificationButtonText }}
</el-button>
</div>
</div>
<div class="verification-item">
<div class="verification-title">房东认证</div>
<div class="verification-status">
<el-tag :type="landlordVerificationStatusType">{{ landlordVerificationStatusText }}</el-tag>
</div>
<div class="verification-action">
<el-button
type="primary"
size="small"
@click="goToLandlordVerification"
:disabled="landlordVerificationStatus === 1">
{{ landlordVerificationButtonText }}
</el-button>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script>
import { getUserInfo, updateUserInfo, changePassword, uploadAvatar } from '@/api/user';
import { getStudentVerificationStatus, getLandlordVerificationStatus } from '@/api/verification';
import { mapActions } from 'vuex';
export default {
name: 'UserProfile',
data() {
const validateConfirmPassword = (rule, value, callback) => {
if (value !== this.passwordForm.newPassword) {
callback(new Error('两次输入密码不一致'));
} else {
callback();
}
};
return {
userForm: {
username: '',
realName: '',
phone: '',
email: '',
gender: 0
},
passwordForm: {
oldPassword: '',
newPassword: '',
confirmPassword: ''
},
rules: {
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', 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' }
]
},
passwordRules: {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
},
avatarUrl: '',
studentVerificationStatus: 0, // 0-未认证,1-已认证,2-认证中,3-认证失败
landlordVerificationStatus: 0
};
},
computed: {
studentVerificationStatusText() {
const statusMap = {
0: '未认证',
1: '已认证',
2: '认证中',
3: '认证失败'
};
return statusMap[this.studentVerificationStatus] || '未认证';
},
landlordVerificationStatusText() {
const statusMap = {
0: '未认证',
1: '已认证',
2: '认证中',
3: '认证失败'
};
return statusMap[this.landlordVerificationStatus] || '未认证';
},
studentVerificationStatusType() {
const typeMap = {
0: 'info',
1: 'success',
2: 'warning',
3: 'danger'
};
return typeMap[this.studentVerificationStatus] || 'info';
},
landlordVerificationStatusType() {
const typeMap = {
0: 'info',
1: 'success',
2: 'warning',
3: 'danger'
};
return typeMap[this.landlordVerificationStatus] || 'info';
},
studentVerificationButtonText() {
const textMap = {
0: '去认证',
1: '已认证',
2: '认证中',
3: '重新认证'
};
return textMap[this.studentVerificationStatus] || '去认证';
},
landlordVerificationButtonText() {
const textMap = {
0: '去认证',
1: '已认证',
2: '认证中',
3: '重新认证'
};
return textMap[this.landlordVerificationStatus] || '去认证';
}
},
created() {
this.getUserInfo();
this.getVerificationStatus();
},
methods: {
...mapActions(['setUserInfo']),
async getUserInfo() {
try {
const res = await getUserInfo();
if (res.code === 200) {
const { username, realName, phone, email, gender, avatar } = res.data;
this.userForm = { username, realName, phone, email, gender };
this.avatarUrl = avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png';
}
} catch (error) {
this.$message.error('获取用户信息失败');
console.error(error);
}
},
async getVerificationStatus() {
try {
const studentRes = await getStudentVerificationStatus();
if (studentRes.code === 200) {
this.studentVerificationStatus = studentRes.data.status;
}
const landlordRes = await getLandlordVerificationStatus();
if (landlordRes.code === 200) {
this.landlordVerificationStatus = landlordRes.data.status;
}
} catch (error) {
console.error(error);
}
},
submitForm(formName) {
this.$refs[formName].validate(async (valid) => {
if (valid) {
try {
const res = await updateUserInfo(this.userForm);
if (res.code === 200) {
this.$message.success('个人资料更新成功');
this.setUserInfo(res.data);
}
} catch (error) {
this.$message.error('更新失败');
console.error(error);
}
} else {
return false;
}
});
},
submitPasswordForm(formName) {
this.$refs[formName].validate(async (valid) => {
if (valid) {
try {
const res = await changePassword(this.passwordForm);
if (res.code === 200) {
this.$message.success('密码修改成功,请重新登录');
this.$store.dispatch('logout');
this.$router.push('/login');
}
} catch (error) {
this.$message.error('密码修改失败');
console.error(error);
}
} else {
return false;
}
});
},
beforeAvatarUpload(file) {
const isJPG = file.type === 'image/jpeg' || file.type === 'image/png';
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isJPG) {
this.$message.error('上传头像图片只能是 JPG 或 PNG 格式!');
}
if (!isLt2M) {
this.$message.error('上传头像图片大小不能超过 2MB!');
}
return isJPG && isLt2M;
},
async uploadAvatar(options) {
const formData = new FormData();
formData.append('file', options.file);
try {
const res = await uploadAvatar(formData);
if (res.code === 200) {
this.avatarUrl = res.data;
this.$message.success('头像上传成功');
// 更新用户信息
await this.getUserInfo();
this.setUserInfo({ avatar: res.data });
}
} catch (error) {
this.$message.error('头像上传失败');
console.error(error);
}
},
goToStudentVerification() {
this.$router.push('/verification/student');
},
goToLandlordVerification() {
this.$router.push('/verification/landlord');
}
}
};
</script>
<style scoped>
.profile-container {
max-width: 800px;
margin: 20px auto;
}
.profile-card {
margin-bottom: 20px;
}
.card-header {
font-size: 18px;
font-weight: bold;
}
.avatar-container {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
}
.avatar-uploader {
margin-top: 10px;
}
.verification-container {
padding: 10px 0;
}
.verification-item {
display: flex;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #eee;
}
.verification-item:last-child {
border-bottom: none;
}
.verification-title {
flex: 1;
font-weight: bold;
}
.verification-status {
margin-right: 20px;
}
.verification-action {
width: 100px;
text-align: right;
}
</style>
Verification Components
frontend\src\views\verification\StudentVerification.vue
<template>
<div class="verification-container">
<el-card class="verification-card">
<div slot="header" class="card-header">
<span>学生认证</span>
</div>
<div v-if="verificationStatus === 1" class="verification-success">
<i class="el-icon-success"></i>
<p>恭喜您,学生认证已通过!</p>
<el-button type="primary" @click="$router.push('/user/profile')">返回个人中心</el-button>
</div>
<div v-else-if="verificationStatus === 2" class="verification-pending">
<i class="el-icon-loading"></i>
<p>您的学生认证申请正在审核中,请耐心等待...</p>
<el-button type="primary" @click="$router.push('/user/profile')">返回个人中心</el-button>
</div>
<div v-else-if="verificationStatus === 3" class="verification-failed">
<i class="el-icon-error"></i>
<p>很遗憾,您的学生认证申请未通过审核。</p>
<p class="reason">原因:{{ verificationRemark }}</p>
<el-button type="primary" @click="resetForm">重新认证</el-button>
</div>
<el-form
v-else
:model="verificationForm"
:rules="rules"
ref="verificationForm"
label-width="100px"
class="verification-form">
<el-form-item label="学号" prop="studentId">
<el-input v-model="verificationForm.studentId"></el-input>
</el-form-item>
<el-form-item label="学校" prop="school">
<el-input v-model="verificationForm.school"></el-input>
</el-form-item>
<el-form-item label="学院" prop="college">
<el-input v-model="verificationForm.college"></el-input>
</el-form-item>
<el-form-item label="专业" prop="major">
<el-input v-model="verificationForm.major"></el-input>
</el-form-item>
<el-form-item label="身份证号" prop="idCard">
<el-input v-model="verificationForm.idCard"></el-input>
</el-form-item>
<el-form-item label="身份证正面" prop="idCardFront">
<el-upload
class="upload-container"
action="#"
:http-request="uploadIdCardFront"
:show-file-list="false"
:before-upload="beforeUpload">
<img v-if="idCardFrontUrl" :src="idCardFrontUrl" class="upload-image">
<i v-else class="el-icon-plus upload-icon"></i>
<div class="upload-text">点击上传身份证正面照片</div>
</el-upload>
</el-form-item>
<el-form-item label="身份证背面" prop="idCardBack">
<el-upload
class="upload-container"
action="#"
:http-request="uploadIdCardBack"
:show-file-list="false"
:before-upload="beforeUpload">
<img v-if="idCardBackUrl" :src="idCardBackUrl" class="upload-image">
<i v-else class="el-icon-plus upload-icon"></i>
<div class="upload-text">点击上传身份证背面照片</div>
</el-upload>
</el-form-item>
<el-form-item label="学生证" prop="studentCard">
<el-upload
class="upload-container"
action="#"
:http-request="uploadStudentCard"
:show-file-list="false"
:before-upload="beforeUpload">
<img v-if="studentCardUrl" :src="studentCardUrl" class="upload-image">
<i v-else class="el-icon-plus upload-icon"></i>
<div class="upload-text">点击上传学生证照片</div>
</el-upload>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('verificationForm')">提交认证</el-button>
<el-button @click="$router.push('/user/profile')">返回</el-button>
</el-form-item>
</el-form>
<div class="verification-tips">
<h3>认证须知:</h3>
<p>1. 请确保上传的证件照片清晰可见,信息完整。</p>
<p>2. 身份证信息必须与实名认证信息一致。</p>
<p>3. 学生证必须在有效期内,且能清晰看到学校名称、学院、专业和学号。</p>
<p>4. 认证审核通常在1-3个工作日内完成,请耐心等待。</p>
</div>
</el-card>
</div>
</template>
<script>
import { submitStudentVerification, getStudentVerificationStatus } from '@/api/verification';
export default {
name: 'StudentVerification',
data() {
return {
verificationStatus: 0, // 0-未认证,1-已认证,2-认证中,3-认证失败
verificationRemark: '',
verificationForm: {
studentId: '',
school: '',
college: '',
major: '',
idCard: '',
idCardFront: '',
idCardBack: '',
studentCard: ''
},
idCardFrontUrl: '',
idCardBackUrl: '',
studentCardUrl: '',
rules: {
studentId: [
{ required: true, message: '请输入学号', trigger: 'blur' },
{ min: 5, max: 20, message: '长度在 5 到 20 个字符', trigger: 'blur' }
],
school: [
{ required: true, message: '请输入学校', trigger: 'blur' }
],
college: [
{ required: true, message: '请输入学院', trigger: 'blur' }
],
major: [
{ required: true, message: '请输入专业', trigger: 'blur' }
],
idCard: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{ pattern: /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/, message: '请输入正确的身份证号', trigger: 'blur' }
],
idCardFront: [
{ required: true, message: '请上传身份证正面照片', trigger: 'change' }
],
idCardBack: [
{ required: true, message: '请上传身份证背面照片', trigger: 'change' }
],
studentCard: [
{ required: true, message: '请上传学生证照片', trigger: 'change' }
]
}
};
},
created() {
this.getVerificationStatus();
},
methods: {
async getVerificationStatus() {
try {
const res = await getStudentVerificationStatus();
if (res.code === 200) {
this.verificationStatus = res.data.status;
this.verificationRemark = res.data.remark || '';
// 如果有已提交的认证信息,填充表单
if (res.data.verification) {
const { studentId, school, college, major, idCard, idCardFront, idCardBack, studentCard } = res.data.verification;
this.verificationForm = { studentId, school, college, major, idCard, idCardFront, idCardBack, studentCard };
this.idCardFrontUrl = idCardFront;
this.idCardBackUrl = idCardBack;
this.studentCardUrl = studentCard;
}
}
} catch (error) {
this.$message.error('获取认证状态失败');
console.error(error);
}
},
beforeUpload(file) {
const isJPG = file.type === 'image/jpeg' || file.type === 'image/png';
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isJPG) {
this.$message.error('上传图片只能是 JPG 或 PNG 格式!');
}
if (!isLt2M) {
this.$message.error('上传图片大小不能超过 2MB!');
}
return isJPG && isLt2M;
},
uploadIdCardFront(options) {
const formData = new FormData();
formData.append('file', options.file);
// 模拟上传
setTimeout(() => {
const url = URL.createObjectURL(options.file);
this.idCardFrontUrl = url;
this.verificationForm.idCardFront = url;
this.$refs.verificationForm.validateField('idCardFront');
}, 500);
},
uploadIdCardBack(options) {
const formData = new FormData();
formData.append('file', options.file);
// 模拟上传
setTimeout(() => {
const url = URL.createObjectURL(options.file);
this.idCardBackUrl = url;
this.verificationForm.idCardBack = url;
this.$refs.verificationForm.validateField('idCardBack');
}, 500);
},
uploadStudentCard(options) {
const formData = new FormData();
formData.append('file', options.file);
// 模拟上传
setTimeout(() => {
const url = URL.createObjectURL(options.file);
this.studentCardUrl = url;
this.verificationForm.studentCard = url;
this.$refs.verificationForm.validateField('studentCard');
}, 500);
},
submitForm(formName) {
this.$refs[formName].validate(async (valid) => {
if (valid) {
try {
const formData = new FormData();
Object.keys(this.verificationForm).forEach(key => {
formData.append(key, this.verificationForm[key]);
});
const res = await submitStudentVerification(formData);
if (res.code === 200) {
this.$message.success('认证申请提交成功,请等待审核');
this.verificationStatus = 2; // 认证中
}
} catch (error) {
this.$message.error('提交失败');
console.error(error);
}
} else {
return false;
}
});
},
resetForm() {
this.verificationStatus = 0;
this.verificationForm = {
studentId: '',
school: '',
college: '',
major: '',
idCard: '',
idCardFront: '',
idCardBack: '',
studentCard: ''
};
this.idCardFrontUrl = '';
this.idCardBackUrl = '';
this.studentCardUrl = '';
}
}
};
</script>
<style scoped>
.verification-container {
max-width: 800px;
margin: 20px auto;
}
.verification-card {
margin-bottom: 20px;
}
.card-header {
font-size: 18px;
font-weight: bold;
}
.verification-success,
.verification-pending,
.verification-failed {
text-align: center;
padding: 40px 0;
}
.verification-success i,
.verification-pending i,
.verification-failed i {
font-size: 60px;
margin-bottom: 20px;
}
.verification-success i {
color: #67C23A;
}
.verification-pending i {
color: #E6A23C;
}
.verification-failed i {
color: #F56C6C;
}
.verification-success p,
.verification-pending p,
.verification-failed p {
font-size: 18px;
margin-bottom: 20px;
}
.reason {
color: #F56C6C;
font-size: 14px;
}
.upload-container {
width: 220px;
height: 140px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.upload-container:hover {
border-color: #409EFF;
}
.upload-icon {
font-size: 28px;
color: #8c939d;
}
.upload-text {
color: #8c939d;
font-size: 14px;
margin-top: 10px;
}
.upload-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.verification-tips {
margin-top: 20px;
padding: 15px;
background-color: #f8f8f8;
border-radius: 4px;
}
.verification-tips h3 {
margin-top: 0;
margin-bottom: 10px;
font-size: 16px;
}
.verification-tips p {
margin: 5px 0;
font-size: 14px;
color: #666;
}
</style>
sql\01_create_tables.sql
-- 创建数据库
CREATE DATABASE IF NOT EXISTS rental_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE rental_system;
-- 用户表
CREATE TABLE IF NOT EXISTS `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '密码',
`real_name` varchar(50) DEFAULT NULL COMMENT '真实姓名',
`id_card` varchar(18) DEFAULT NULL COMMENT '身份证号',
`phone` varchar(11) NOT NULL COMMENT '手机号',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像',
`gender` tinyint(1) DEFAULT NULL COMMENT '性别:0-女,1-男',
`role` varchar(20) NOT NULL COMMENT '角色:STUDENT-学生,LANDLORD-房东,ADMIN-管理员',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:0-禁用,1-正常',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 学生认证表
CREATE TABLE IF NOT EXISTS `student_verification` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`student_id` varchar(50) NOT NULL COMMENT '学号',
`school` varchar(100) NOT NULL COMMENT '学校',
`college` varchar(100) DEFAULT NULL COMMENT '学院',
`major` varchar(100) DEFAULT NULL COMMENT '专业',
`admission_year` int(4) DEFAULT NULL COMMENT '入学年份',
`student_card_img` varchar(255) DEFAULT NULL COMMENT '学生证照片',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '认证状态:0-待审核,1-已认证,2-未通过',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_id` (`user_id`),
KEY `idx_student_id` (`student_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生认证表';
-- 房东认证表
CREATE TABLE IF NOT EXISTS `landlord_verification` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`id_card_front` varchar(255) NOT NULL COMMENT '身份证正面照',
`id_card_back` varchar(255) NOT NULL COMMENT '身份证背面照',
`house_property_cert` varchar(255) DEFAULT NULL COMMENT '房产证照片',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '认证状态:0-待审核,1-已认证,2-未通过',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房东认证表';
-- 角色表
CREATE TABLE IF NOT EXISTS `role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '角色名称',
`code` varchar(50) NOT NULL COMMENT '角色编码',
`description` varchar(255) DEFAULT NULL COMMENT '角色描述',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
-- 权限表
CREATE TABLE IF NOT EXISTS `permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '权限名称',
`code` varchar(50) NOT NULL COMMENT '权限编码',
`description` varchar(255) DEFAULT NULL COMMENT '权限描述',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表';
-- 角色权限关联表
CREATE TABLE IF NOT EXISTS `role_permission` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
`permission_id` bigint(20) NOT NULL COMMENT '权限ID',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_permission` (`role_id`,`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';
-- 用户角色关联表
CREATE TABLE IF NOT EXISTS `user_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_role` (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
-- 房源表
CREATE TABLE IF NOT EXISTS `house` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`landlord_id` bigint(20) NOT NULL COMMENT '房东ID',
`title` varchar(100) NOT NULL COMMENT '标题',
`address` varchar(255) NOT NULL COMMENT '地址',
`area` decimal(10,2) NOT NULL COMMENT '面积(平方米)',
`price` decimal(10,2) NOT NULL COMMENT '月租金',
`deposit` decimal(10,2) NOT NULL COMMENT '押金',
`house_type` varchar(50) NOT NULL COMMENT '户型',
`orientation` varchar(20) DEFAULT NULL COMMENT '朝向',
`floor` varchar(20) DEFAULT NULL COMMENT '楼层',
`has_elevator` tinyint(1) DEFAULT NULL COMMENT '是否有电梯',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:0-下架,1-上架',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_landlord_id` (`landlord_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房源表';
-- 房源详情表
CREATE TABLE IF NOT EXISTS `house_detail` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`house_id` bigint(20) NOT NULL COMMENT '房源ID',
`description` text COMMENT '详细描述',
`facility` varchar(255) DEFAULT NULL COMMENT '配套设施,逗号分隔',
`transportation` varchar(255) DEFAULT NULL COMMENT '交通情况',
`surrounding` varchar(255) DEFAULT NULL COMMENT '周边环境',
`requirement` varchar(255) DEFAULT NULL COMMENT '出租要求',
`payment_method` varchar(100) DEFAULT NULL COMMENT '付款方式',
`contact_name` varchar(50) DEFAULT NULL COMMENT '联系人',
`contact_phone` varchar(11) DEFAULT NULL COMMENT '联系电话',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_house_id` (`house_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房源详情表';
-- 房源图片表
CREATE TABLE IF NOT EXISTS `house_image` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`house_id` bigint(20) NOT NULL COMMENT '房源ID',
`url` varchar(255) NOT NULL COMMENT '图片URL',
`type` tinyint(1) NOT NULL DEFAULT '0' COMMENT '图片类型:0-普通,1-封面',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_house_id` (`house_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房源图片表';
-- 收藏表
CREATE TABLE IF NOT EXISTS `favorite` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`house_id` bigint(20) NOT NULL COMMENT '房源ID',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_house` (`user_id`,`house_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表';
-- 比较表
CREATE TABLE IF NOT EXISTS `comparison` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`house_ids` varchar(255) NOT NULL COMMENT '房源ID列表,逗号分隔',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='比较表';
sql\02_init_data.sql
USE rental_system;
-- 初始化角色数据
INSERT INTO `role` (`name`, `code`, `description`, `create_time`, `update_time`) VALUES
('管理员', 'ADMIN', '系统管理员', NOW(), NOW()),
('学生', 'STUDENT', '租房学生', NOW(), NOW()),
('房东', 'LANDLORD', '房源提供者', NOW(), NOW());
-- 初始化权限数据
INSERT INTO `permission` (`name`, `code`, `description`, `create_time`, `update_time`) VALUES
-- 用户管理权限
('用户查询', 'USER:VIEW', '查询用户信息', NOW(), NOW()),
('用户创建', 'USER:CREATE', '创建用户', NOW(), NOW()),
('用户编辑', 'USER:EDIT', '编辑用户信息', NOW(), NOW()),
('用户删除', 'USER:DELETE', '删除用户', NOW(), NOW()),
-- 房源管理权限
('房源查询', 'HOUSE:VIEW', '查询房源信息', NOW(), NOW()),
('房源创建', 'HOUSE:CREATE', '创建房源', NOW(), NOW()),
('房源编辑', 'HOUSE:EDIT', '编辑房源信息', NOW(), NOW()),
('房源删除', 'HOUSE:DELETE', '删除房源', NOW(), NOW()),
-- 认证管理权限
('认证查询', 'VERIFICATION:VIEW', '查询认证信息', NOW(), NOW()),
('认证审核', 'VERIFICATION:AUDIT', '审核认证申请', NOW(), NOW());
-- 初始化角色权限关联
-- 管理员权限
INSERT INTO `role_permission` (`role_id`, `permission_id`, `create_time`)
SELECT r.id, p.id, NOW()
FROM `role` r, `permission` p
WHERE r.code = 'ADMIN';
-- 学生权限
INSERT INTO `role_permission` (`role_id`, `permission_id`, `create_time`)
SELECT r.id, p.id, NOW()
FROM `role` r, `permission` p
WHERE r.code = 'STUDENT' AND p.code IN ('USER:VIEW', 'HOUSE:VIEW');
-- 房东权限
INSERT INTO `role_permission` (`role_id`, `permission_id`, `create_time`)
SELECT r.id, p.id, NOW()
FROM `role` r, `permission` p
WHERE r.code = 'LANDLORD' AND p.code IN ('USER:VIEW', 'HOUSE:VIEW', 'HOUSE:CREATE', 'HOUSE:EDIT', 'HOUSE:DELETE');
-- 初始化管理员账号
INSERT INTO `user` (`username`, `password`, `real_name`, `phone`, `email`, `role`, `status`, `create_time`, `update_time`) VALUES
('admin', '$2a$10$ySG2lkvjFHY5O0./CPIE1OI8VJsuKYEzOYzqIa7AJR6sEgSzUFOAm', '系统管理员', '13800000000', 'admin@example.com', 'ADMIN', 1, NOW(), NOW());
-- 添加管理员角色关联
INSERT INTO `user_role` (`user_id`, `role_id`, `create_time`)
SELECT u.id, r.id, NOW()
FROM `user` u, `role` r
WHERE u.username = 'admin' AND r.code = 'ADMIN';