Files
mp-vue/src/views/house/info/components/info-edit.vue
赵忠林 a7e88a8f0c feat(house-info): 新增房源与预订相关接口及房源编辑组件
- 新增房源信息和预订信息的数据模型定义
- 实现房源信息和预订信息的增删改查及批量操作API
- 新增房源编辑弹窗组件,实现房源信息的填写、编辑与预览
- 实现房源编辑中图片、视频上传及地理位置选择功能
- 新增房源搜索组件,支持用户、区域及关键词筛选
- 新增房源列表页面,支持房源状态、推荐、必看开关操作及数据展示
- 优化房源编辑表单显示与校验规则
- 支持房源标签多选及办公室配套和详细介绍的富文本编辑
2026-06-01 18:12:17 +08:00

830 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- 用户编辑弹窗 -->
<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>