Java全栈项目-大学生租房管理系统(7)

代码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';
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

天天进步2015

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值