feat(house-info): 新增房源与预订相关接口及房源编辑组件

- 新增房源信息和预订信息的数据模型定义
- 实现房源信息和预订信息的增删改查及批量操作API
- 新增房源编辑弹窗组件,实现房源信息的填写、编辑与预览
- 实现房源编辑中图片、视频上传及地理位置选择功能
- 新增房源搜索组件,支持用户、区域及关键词筛选
- 新增房源列表页面,支持房源状态、推荐、必看开关操作及数据展示
- 优化房源编辑表单显示与校验规则
- 支持房源标签多选及办公室配套和详细介绍的富文本编辑
This commit is contained in:
2026-06-01 18:12:17 +08:00
parent 1d95891ef3
commit a7e88a8f0c
12 changed files with 3663 additions and 0 deletions

View File

@@ -0,0 +1,829 @@
<!-- 用户编辑弹窗 -->
<template>
<ele-modal
width="75%"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '修改房源' : '添加房源'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
:destroy-on-close="true"
@ok="save"
>
<div style="background-color: #f3f3f3; padding: 8px">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ md: { span: 7 }, sm: { span: 4 }, xs: { span: 24 } }"
:wrapper-col="{ md: { span: 17 }, sm: { span: 20 }, xs: { span: 24 } }"
>
<a-card title="基本信息" :bordered="false">
<template #extra
><a-button type="link" @click="handleEditStatus">{{
editStatus ? '预览' : '编辑'
}}</a-button></template
>
<a-row :gutter="16">
<a-col
v-bind="styleResponsive ? { md: 8, sm: 24, xs: 24 } : { span: 8 }"
>
<a-form-item label="房源ID" name="houseId" v-if="isUpdate">
<span>{{ form.houseId }}</span>
</a-form-item>
<a-form-item label="标题" name="houseTitle">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入标题"
v-model:value="form.houseTitle"
/>
<span v-else>{{ form.houseTitle }}</span>
</a-form-item>
<a-form-item label="业主电话" name="phone">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入业主电话"
v-model:value="form.phone"
/>
<span v-else>{{ form.phone }}</span>
</a-form-item>
<a-form-item label="面积(m²)" name="extent">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入该房屋面积"
v-model:value="form.extent"
/>
<span v-else>{{ form.extent }}</span>
</a-form-item>
<a-form-item label="租金(元/m²)" name="rent">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入租金"
v-model:value="form.rent"
/>
<span v-else>{{ form.rent }}</span>
</a-form-item>
<a-form-item label="月租金(每月)" name="monthlyRent">
<!-- <a-input-->
<!-- v-if="editStatus"-->
<!-- allow-clear-->
<!-- placeholder="请输入月租金"-->
<!-- :value="monthlyRent"-->
<!-- />-->
<span>{{ monthlyRent }}</span>
</a-form-item>
<a-form-item label="房号" name="roomNumber">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入房号"
v-model:value="form.roomNumber"
/>
<span v-else>{{ form.roomNumber }}</span>
</a-form-item>
<a-form-item label="入房密码" name="password">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入进入房屋的密码"
v-model:value="form.password"
/>
<span v-else>{{ form.password }}</span>
</a-form-item>
<a-form-item label="佣金" name="commission">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入佣金"
v-model:value="form.commission"
/>
<span v-else>{{ form.commission }}</span>
</a-form-item>
<a-form-item label="物业费" name="propertyFees">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入物业费"
v-model:value="form.propertyFees"
/>
<span v-else>{{ form.propertyFees }}</span>
</a-form-item>
</a-col>
<a-col
v-bind="styleResponsive ? { md: 8, sm: 24, xs: 24 } : { span: 8 }"
>
<a-form-item label="户型" name="houseType">
<DictSelect
v-if="editStatus"
dict-code="houseType"
v-model:value="form.houseType"
placeholder="请选择户型"
/>
<span v-else>{{ form.houseType }}</span>
</a-form-item>
<a-form-item label="楼层" name="floor">
<DictSelect
v-if="editStatus"
dict-code="floor"
v-model:value="form.floor"
placeholder="请选择楼层"
/>
<span v-else>{{ form.floor }}</span>
</a-form-item>
<a-form-item label="租赁方式" name="leaseMethod">
<DictSelect
v-if="editStatus"
dict-code="leaseMethod"
v-model:value="form.leaseMethod"
placeholder="请选择租赁方式"
/>
<span v-else>{{ form.leaseMethod }}</span>
</a-form-item>
<a-form-item label="房源朝向" name="toward">
<DictSelect
v-if="editStatus"
dict-code="toward"
v-model:value="form.toward"
placeholder="请选择房源朝向"
/>
<span v-else>{{ form.toward }}</span>
</a-form-item>
<a-form-item label="是否可溢价" name="premium">
<DictSelect
v-if="editStatus"
dict-code="premium"
v-model:value="form.premium"
placeholder="是否可溢价"
/>
<span v-else>{{ form.premium }}</span>
</a-form-item>
<a-form-item label="租期" name="tenancy">
<DictSelect
v-if="editStatus"
dict-code="tenancy"
v-model:value="form.tenancy"
placeholder="租期"
/>
<span v-else>{{ form.tenancy }}</span>
</a-form-item>
<a-form-item label="房产经纪人" name="nickname">
<SelectUser
:placeholder="`请选择发布人`"
v-model:value="form.nickname"
v-if="editStatus"
@done="chooseUserId"
/>
<span v-else>{{ form.nickname }}</span>
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-if="editStatus"
:rows="4"
:maxlength="200"
placeholder="请输入备注"
v-model:value="form.comments"
/>
<div v-else>{{ form.comments }}</div>
</a-form-item>
</a-col>
<a-col
v-bind="styleResponsive ? { md: 8, sm: 24, xs: 24 } : { span: 8 }"
>
<a-form-item label="所在地区" name="region">
<a-input-group compact v-if="editStatus">
<a-input
disabled
style="width: calc(100% - 32px)"
v-model:value="form.region"
placeholder="所属区域"
/>
<a-tooltip title="选择位置">
<a-button @click="openMapPicker">
<template #icon><EnvironmentOutlined /></template>
</a-button>
</a-tooltip>
</a-input-group>
<span v-else>{{ form.region }}</span>
</a-form-item>
<a-form-item label="详细地址" name="address">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入详细地址"
v-model:value="form.address"
/>
<span v-else>{{ form.address }}</span>
</a-form-item>
<a-form-item label="所在省份" name="province">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入所在省份"
v-model:value="form.province"
/>
<span v-else>{{ form.province }}</span>
</a-form-item>
<a-form-item label="所在城市" name="city">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入所在城市"
v-model:value="form.city"
/>
<span v-else>{{ form.city }}</span>
</a-form-item>
<a-form-item label="经度" name="longitude">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入经度"
v-model:value="form.longitude"
/>
<span v-else>{{ form.longitude }}</span>
</a-form-item>
<a-form-item label="纬度" name="latitude">
<a-input
v-if="editStatus"
allow-clear
placeholder="请输入纬度"
v-model:value="form.latitude"
/>
<span v-else>{{ form.latitude }}</span>
</a-form-item>
</a-col>
</a-row>
</a-card>
<a-divider style="height: 8px" />
<a-card title="房源相册" :bordered="false">
<!-- <template #extra><a-button type="link" @click="handleEditStatus">上传</a-button></template>-->
<div class="content">
<ele-image-upload
v-model:value="files"
:limit="9"
:drag="true"
:item-style="{ width: '150px', height: '113px' }"
:upload-handler="uploadHandlerImages"
@upload="onUploadImages"
/>
<small class="ele-text-placeholder">
请上传应用截图(最多9张)建议宽度800*600像素小于20M/
</small>
</div>
</a-card>
<a-divider style="height: 8px" />
<a-card title="房源视频" :bordered="false">
<!-- <template #extra><a-button type="link" @click="handleEditStatus">上传</a-button></template>-->
<div class="content">
<UploadFile
accept="video/*"
v-model:value="form.videoUrl"
:maxCount="1"
:drag="true"
:item-style="{ width: '150px', height: '113px' }"
:showUploadList="true"
/>
<small class="ele-text-placeholder">
</small>
</div>
</a-card>
<a-divider style="height: 8px" />
<a-card title="房源标签" :bordered="false">
<DictSelectMultiple
v-if="editStatus"
dict-code="houseLabel"
v-model:value="houseLabelData"
placeholder="房源标签"
/>
<span v-else>
<a-space>
<a-tag v-for="tag in houseLabelData">{{ tag }}</a-tag>
</a-space>
</span>
</a-card>
<a-card title="办公室配套" :bordered="false">
<a-textarea
v-if="editStatus"
:rows="4"
:maxlength="200"
placeholder="请输入备注"
v-model:value="form.supporting"
/>
<div v-else>{{ form.supporting }}</div>
</a-card>
<a-divider style="height: 8px" />
<a-card title="详细介绍" :bordered="false">
<template #extra
><a-button type="link" @click="handleEditStatus">{{
editStatus ? '预览' : '编辑'
}}</a-button></template
>
<!-- 编辑器 -->
<tinymce-editor
v-if="editStatus"
v-model:value="content"
:init="config"
placeholder="图片直接粘贴自动上传"
@change="onChange"
@paste="onPaste"
/>
<!-- 预览 -->
<tinymce-editor
v-else
v-model:value="content"
:init="viewConfig"
/>
</a-card>
</a-form>
<!-- 地图位置选择弹窗 -->
<ele-map-picker
:need-city="true"
:dark-mode="darkMode"
v-model:visible="showMap"
:center="[108.374959, 22.767024]"
:search-type="1"
:zoom="12"
@done="onDone"
/>
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import {ref, reactive, watch, computed} from 'vue';
import { message } from 'ant-design-vue';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { EnvironmentOutlined } from '@ant-design/icons-vue';
import { FormInstance } from 'ant-design-vue/es/form';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { HouseInfo } from '@/api/house/info/model';
import {uploadFile, uploadOss} from '@/api/system/file';
import { CenterPoint } from 'ele-admin-pro/es/ele-map-picker/types';
import useFormData from '@/utils/use-form-data';
import { addHouseInfo, updateHouseInfo } from '@/api/house/info';
import { User } from '@/api/system/user/model';
import zh_Hans from 'bytemd/locales/zh_Hans.json';
import TinymceEditor from "@/components/TinymceEditor/index.vue";
import {listDictionaryData} from "@/api/system/dictionary-data";
import {fo} from "../../../../../dist/assets/index.561c6735";
// 是否是修改
const isUpdate = ref(false);
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: HouseInfo | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 用户头像
const avatar = ref<ItemType[]>([]);
// 已上传数据
const files = ref<ItemType[]>([]);
// 已上传数据
const files2 = ref<ItemType[]>([]);
// 是否显示地图选择弹窗
const showMap = ref(false);
// 省市区
const city = ref<string[]>([]);
const { darkMode } = storeToRefs(themeStore);
const editStatus = ref(false);
const formRef = ref<FormInstance | null>(null);
const uploadImgContent = ref<string>('');
const content = ref('');
// 表单数据
const { form, resetFields, assignFields } = useFormData<HouseInfo>({
houseId: undefined,
userId: undefined,
houseTitle: undefined,
cityByHouse: undefined,
houseType: undefined,
leaseMethod: undefined,
rent: undefined,
monthlyRent: undefined,
extent: undefined,
floor: undefined,
roomNumber: undefined,
realName: undefined,
nickname: undefined,
houseLabel: undefined,
address: undefined,
longitude: undefined,
latitude: undefined,
phone: undefined,
password: undefined,
toward: undefined,
files: undefined,
videoUrl: undefined,
content: undefined,
expirationTime: undefined,
province: undefined,
city: undefined,
region: undefined,
area: undefined,
status: undefined,
comments: '',
sortNumber: undefined,
deleted: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
isEdit: undefined,
commission: undefined,
premium: undefined,
propertyFees: undefined,
tenancy: undefined,
supporting: undefined
});
const monthlyRent = computed<number>(() => {
const {extent, rent} = form
if(extent && rent) {
return extent * rent
}else {
return 0
}
})
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
houseTitle: [
{
required: true,
type: 'string',
message: '请输入房源标题',
trigger: 'blur'
}
],
// phone: [
// {
// required: true,
// type: 'string',
// message: '请输入合法手机号码',
// trigger: 'blur'
// }
// ],
nickname: [
{
required: true,
type: 'string',
message: '请输入昵称',
trigger: 'blur'
}
],
roles: [
{
required: true,
message: '请选择角色',
type: 'array',
trigger: 'blur'
}
]
// securityStatus: [
// {
// required: true,
// type: 'string',
// message: '请选择安全状态',
// trigger: 'blur'
// }
// ],
// companyName: [
// {
// required: true,
// type: 'string',
// message: '请选择租赁单位',
// trigger: 'blur'
// }
// ],
// customerName: [
// {
// required: true,
// type: 'string',
// message: '请选择承租单位',
// trigger: 'blur'
// }
// ],
// projectRegion: [
// {
// required: true,
// type: 'string',
// message: '请输入房源地址',
// trigger: 'blur'
// }
// ]
});
let houseLabelData = ref<any[]>([])
const initHouseLabel = ()=> {
if(form.houseLabel) {
houseLabelData.value = JSON.parse(form.houseLabel)
}
}
const handleEditStatus = () => {
editStatus.value = !editStatus.value;
};
const chooseUserId = (data: User) => {
form.nickname = data.nickname;
form.userId = data.userId;
};
/* 地图选择后回调 */
const onDone = (location: CenterPoint) => {
console.log(location);
city.value = [
`${location.city?.province}`,
`${location.city?.city}`,
`${location.city?.district}`
];
form.province = `${location.city?.province}`;
form.city = `${location.city?.city}`;
form.region = `${location.city?.district}`;
form.address = `${location.address}`;
form.latitude = `${location.lat}`;
form.longitude = `${location.lng}`;
showMap.value = false;
};
/* 打开位置选择 */
const openMapPicker = () => {
showMap.value = true;
};
/* 上传事件 */
const uploadHandler = (file: File) => {
const item: ItemType = {
file,
uid: (file as any).uid,
name: file.name
};
if (!file.type.startsWith('image')) {
message.error('只能选择图片');
return;
}
if (file.size / 1024 / 1024 > 20) {
message.error('大小不能超过 20MB');
return;
}
onUpload(item);
};
// 上传文件
const onUpload = (item) => {
const { file } = item;
uploadOss(file)
.then((data) => {
avatar.value.push({
uid: data.id,
url: data.url,
status: 'done'
});
})
.catch((e) => {
message.error(e.message);
});
};
/* 上传事件 */
const uploadHandlerImages = (file: File) => {
const item: ItemType = {
file,
uid: (file as any).uid,
name: file.name
};
if (!file.type.startsWith('image')) {
message.error('只能选择图片');
return;
}
if (file.size / 1024 / 1024 > 20) {
message.error('大小不能超过 2MB');
return;
}
onUploadImages(item);
};
// 上传文件
const onUploadImages = (item) => {
const { file } = item;
uploadOss(file)
.then((data) => {
files.value.push({
uid: data.id,
url: data.url,
status: 'done'
});
})
.catch((e) => {
message.error(e.message);
});
};
const onChange = (e) => {
uploadImgContent.value = e.originalEvent.value.content;
}
const editorRef = ref<InstanceType<typeof TinymceEditor> | null>(null);
const config = ref({
height: 500,
images_upload_handler: (blobInfo, success, error) => {
const file = blobInfo.blob();
// 使用 axios 上传,实际开发这段建议写在 api 中再调用 api
const formData = new FormData();
formData.append('file', file, file.name);
uploadFile(<File>file)
.then((result) => {
if (result.length) {
console.log(file.size / 1024);
if (file.size / 1024 / 1024 > 2) {
error('图片大小不能超过 2MB');
}
success(result.url);
} else {
error('上传失败');
}
})
.catch((e) => {
message.error(e.message);
});
},
});
const viewConfig = ref({
toolbar: false,
menubar: false,
height: 620,
darkTheme: true,
inline: true
// quickbars_insert_toolbar: false
});
/* 粘贴图片上传服务器并插入编辑器 */
const onPaste = (e) => {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
console.log(items.length);
let hasFile = false;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
let file = items[i].getAsFile();
const item: ItemType = {
file,
uid: (file as any).lastModified,
name: file.name
};
console.log(item.file);
console.log(uploadImgContent.value);
uploadFile(<File>item.file)
.then((result) => {
const addPath = `<img class="content-img" src="${result.url}">\n\r`;
content.value = content.value.replace(uploadImgContent.value,addPath)
})
.catch((e) => {
message.error(e.message);
});
hasFile = true;
}
}
if (hasFile) {
e.preventDefault();
}
}
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const formData = {
...form,
content: content.value,
files: JSON.stringify(files.value),
houseLabel: JSON.stringify(houseLabelData.value),
monthlyRent: monthlyRent.value
};
const saveOrUpdate = isUpdate.value ? updateHouseInfo : addHouseInfo;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
const reload = () => {
loading.value = true;
};
reload();
watch(
() => props.visible,
(visible) => {
if (visible) {
content.value = '';
if (props.data) {
assignFields({
...props.data
});
files.value = [];
avatar.value = [];
city.value = [
`${props.data.province}`,
`${props.data.city}`,
`${props.data.region}`
];
if (props.data.content) {
content.value = props.data.content;
}
if(props.data.files){
const arr = JSON.parse(props.data.files);
arr.map((d, i) => {
files.value.push({
uid: d.uid,
url: d.url,
status: 'done'
});
});
}
reload();
isUpdate.value = true;
} else {
files.value = []
files2.value = []
houseLabelData.value = []
editStatus.value = true;
isUpdate.value = false;
}
initHouseLabel()
} else {
resetFields();
}
}
);
</script>
<style lang="less">
.tab-pane {
min-height: 300px;
}
.ml-10 {
margin-left: 5px;
}
.upload-text {
margin-right: 70px;
}
.upload-image {
margin-bottom: 30px;
display: flex;
justify-content: center;
text-align: center;
}
</style>