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

157
src/api/app/referral.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* 推荐客户管理 API
*/
import request from '@/utils/request';
import { MODULES_API_URL } from '@/config/setting';
/** 推荐状态枚举 */
export const ReferralStatusOptions = [
{ value: 0, label: '待确认' },
{ value: 1, label: '有效' },
{ value: 2, label: '无效' },
{ value: 3, label: '已结算' }
];
/** 推荐记录 */
export interface ReferralRecord {
id?: number;
referralCode?: string;
referrerId?: number;
referrerName?: string;
referrerPhone?: string;
customerName?: string;
customerPhone?: string;
customerCompany?: string;
requirement?: string;
appointmentTime?: string;
remarks?: string;
referralFee?: string | number;
referralStatus?: number;
invalidReason?: string;
invalidTime?: string;
confirmedTime?: string;
settledTime?: string;
createTime?: string;
}
/** 查询参数 */
export interface ReferralParam {
pageNum?: number;
pageSize?: number;
referralStatus?: number;
referrerId?: number;
customerName?: string;
customerPhone?: string;
referralCode?: string;
startDate?: string;
endDate?: string;
}
/**
* 分页查询推荐记录
*/
export async function pageReferral(params: ReferralParam) {
const res = await request.get<{
code: number;
message: string;
data: {
list: ReferralRecord[];
total: number;
pageNum: number;
pageSize: number;
};
}>(MODULES_API_URL + '/app/lead/referral/admin/page', { params });
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 获取推荐统计
*/
export async function getStatistics(params: ReferralParam) {
const res = await request.get<{
code: number;
message: string;
data: Record<string, number>;
}>(MODULES_API_URL + '/app/lead/referral/admin/statistics', { params });
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 确认推荐有效
*/
export async function confirmReferral(id: number) {
const res = await request.put<{ code: number; message: string }>(
MODULES_API_URL + '/app/lead/referral/admin/confirm/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 作废推荐
*/
export async function invalidateReferral(id: number, reason?: string) {
const res = await request.put<{ code: number; message: string }>(
MODULES_API_URL + '/app/lead/referral/admin/invalid/' + id,
{ reason }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 结算推荐费
*/
export async function settleReferral(
id: number,
amount?: number,
remarks?: string
) {
const res = await request.put<{ code: number; message: string }>(
MODULES_API_URL + '/app/lead/referral/admin/settle/' + id,
{ amount, remarks }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 批量结算推荐费
*/
export async function batchSettleReferrals(ids: number[]) {
const res = await request.put<{ code: number; message: string }>(
MODULES_API_URL + '/app/lead/referral/admin/settle/batch',
ids
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 导出推荐数据
*/
export async function exportReferrals(params: ReferralParam) {
const res = await request.get<{
code: number;
message: string;
data: Record<string, unknown>[];
}>(MODULES_API_URL + '/app/lead/referral/admin/export', { params });
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}

121
src/api/house/info/index.ts Normal file
View File

@@ -0,0 +1,121 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { HouseInfo, HouseInfoParam } from '@/api/house/info/model';
/**
* 分页查询房源信息
*/
export async function pageHouseInfo(params: HouseInfoParam) {
const res = await request.get<ApiResult<PageResult<HouseInfo>>>(
'/house/info/page',
{
params
}
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 查询房源信息列表
*/
export async function listHouseInfo(params?: HouseInfoParam) {
const res = await request.get<ApiResult<HouseInfo[]>>('/house/info', {
params
});
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 根据id查询房源信息
*/
export async function getHouseInfo(id: number) {
const res = await request.get<ApiResult<HouseInfo>>('/house/info/' + id);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 添加房源信息
*/
export async function addHouseInfo(data: HouseInfo) {
const res = await request.post<ApiResult<unknown>>('/house/info', data);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 修改房源信息
*/
export async function updateHouseInfo(data: HouseInfo) {
const res = await request.put<ApiResult<unknown>>('/house/info', data);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 绑定房源信息
*/
export async function bindHouseInfo(data: HouseInfo) {
const res = await request.put<ApiResult<unknown>>('/house/info/bind', data);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 批量添加设备
*/
export async function addBatchHouseInfo(data: HouseInfo[]) {
const res = await request.post<ApiResult<unknown>>('/house/info/batch', data);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 删除房源信息
*/
export async function removeHouseInfo(id?: number) {
const res = await request.delete<ApiResult<unknown>>('/house/info/' + id);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 批量删除房源信息
*/
export async function removeBatchHouseInfo(data: (number | undefined)[]) {
const res = await request.delete<ApiResult<unknown>>('/house/info/batch', {
data
});
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 批量修改房源信息
*/
export async function updateBatchHouseInfo(data: any) {
const res = await request.put<ApiResult<unknown>>('/house/info/batch', data);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}

View File

@@ -0,0 +1,60 @@
import type { PageParam } from '@/api';
export interface HouseInfo {
houseId?: number;
userId?: number;
houseTitle?: string;
cityByHouse?: string;
houseType?: string;
leaseMethod?: string;
rent?: number;
monthlyRent?: number;
extent?: number;
floor?: string;
roomNumber?: string;
realName?: string;
nickname?: string;
houseLabel?: any;
address?: string;
longitude?: string;
latitude?: string;
phone?: string;
password?: string;
toward?: string;
files?: string;
videoUrl?: string;
content?: string;
recommend?: number;
mustSee?: number;
expirationTime?: string;
province?: string;
city?: string;
region?: string;
area?: string;
status?: number;
comments?: any;
sortNumber?: number;
deleted?: number;
tenantId?: number;
createTime?: string;
updateTime?: string;
isEdit?: boolean;
commission?: number;
premium?: string;
propertyFees?: string;
tenancy?: string;
supporting?: string;
}
/**
* 搜索条件
*/
export interface HouseInfoParam extends PageParam {
houseId?: number;
houseTitle?: string;
userId?: number;
region?: string;
status?: number;
nickname?: string;
keywords?: any;
}

View File

@@ -0,0 +1,119 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type {
Reservation,
ReservationParam
} from '@/api/house/reservation/model';
/**
* 分页查询用户详细资料
*/
export async function pageReservation(params: ReservationParam) {
const res = await request.get<ApiResult<PageResult<Reservation>>>(
'/house/reservation/page',
{
params
}
);
if (res.data.code === 0) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 查询用户详细资料列表
*/
export async function listReservation(params?: ReservationParam) {
const res = await request.get<ApiResult<Reservation[]>>(
'/house/reservation',
{
params
}
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 添加用户详细资料
*/
export async function addReservation(data: Reservation) {
const res = await request.post<ApiResult<unknown>>(
'/house/reservation',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 修改用户详细资料
*/
export async function updateReservation(data: Reservation) {
const res = await request.put<ApiResult<unknown>>('/house/reservation', data);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 绑定用户详细资料
*/
export async function bindReservation(data: Reservation) {
const res = await request.put<ApiResult<unknown>>(
'/house/reservation/bind',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 批量添加设备
*/
export async function addBatchReservation(data: Reservation[]) {
const res = await request.post<ApiResult<unknown>>(
'/house/reservation/batch',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 删除用户详细资料
*/
export async function removeReservation(id?: number) {
const res = await request.delete<ApiResult<unknown>>(
'/house/reservation/' + id
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 批量删除用户详细资料
*/
export async function removeBatchReservation(data: (number | undefined)[]) {
const res = await request.delete<ApiResult<unknown>>(
'/house/reservation/batch',
{
data
}
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}

View File

@@ -0,0 +1,37 @@
import type { PageParam } from '@/api';
export interface Reservation {
logId?: number;
logNo?: string;
type?: string;
money?: number;
houseId?: number;
realName?: string;
phone?: string;
payTime?: string;
payStatus?: number;
expirationTime?: string;
province?: string;
city?: string;
region?: string;
area?: string;
address?: string;
isSettled?: string;
status?: number;
comments?: any;
sortNumber?: number;
deleted?: number;
tenantId?: number;
createTime?: string;
updateTime?: string;
}
/**
* 搜索条件
*/
export interface ReservationParam extends PageParam {
logId?: number;
userId?: number;
status?: number;
keywords?: any;
}

View File

@@ -0,0 +1,646 @@
<template>
<div class="recommendation-container">
<!-- 搜索表单 -->
<div class="search-form">
<el-form :inline="true" :model="searchForm" class="search-form-inline">
<el-form-item label="关键词">
<el-input
v-model="searchForm.customerName"
placeholder="客户姓名/电话/公司"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="推荐状态">
<el-select
v-model="searchForm.referralStatus"
placeholder="请选择"
clearable
>
<el-option
v-for="item in ReferralStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="推荐码">
<el-input
v-model="searchForm.referralCode"
placeholder="推荐码"
clearable
/>
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="handleDateChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 统计卡片 -->
<div class="statistics-cards">
<el-row :gutter="16">
<el-col :span="6">
<div class="stat-card">
<div class="stat-value">{{ statistics.totalCount || 0 }}</div>
<div class="stat-label">总推荐</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card warning">
<div class="stat-value">{{ statistics.pendingCount || 0 }}</div>
<div class="stat-label">待确认</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card success">
<div class="stat-value">{{ statistics.validCount || 0 }}</div>
<div class="stat-label">有效推荐</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card primary">
<div class="stat-value">{{ statistics.settledCount || 0 }}</div>
<div class="stat-label">已结算</div>
</div>
</el-col>
</el-row>
</div>
<!-- 操作按钮 -->
<div class="table-toolbar">
<div class="toolbar-left">
<el-button
type="success"
:disabled="selectedRows.length === 0"
@click="handleBatchSettle"
>
批量结算 ({{ selectedRows.length }})
</el-button>
<el-button @click="handleExport">导出数据</el-button>
</div>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
border
stripe
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="referralCode" label="推荐码" width="120" />
<el-table-column prop="referrerName" label="推荐人" width="110">
<template #default="{ row }">
<span v-if="row.referrerName">{{ row.referrerName }}</span>
<el-tag v-else type="info" size="small">匿名</el-tag>
</template>
</el-table-column>
<el-table-column prop="referrerPhone" label="推荐人电话" width="120" />
<el-table-column prop="customerName" label="客户姓名" width="100" />
<el-table-column prop="customerPhone" label="客户电话" width="130">
<template #default="{ row }">
<span>{{ row.customerPhone }}</span>
</template>
</el-table-column>
<el-table-column
prop="customerCompany"
label="公司名称"
min-width="150"
show-overflow-tooltip
/>
<el-table-column
prop="requirement"
label="需求描述"
min-width="180"
show-overflow-tooltip
/>
<el-table-column
prop="referralFee"
label="推荐费"
width="100"
align="right"
>
<template #default="{ row }">
<span v-if="row.referralFee" class="text-red font-bold"
>¥{{ row.referralFee }}</span
>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="referralStatus" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.referralStatus)">{{
getStatusText(row.referralStatus)
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="推荐时间" width="170" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<!-- 待确认: 确认 / 作废 -->
<template v-if="row.referralStatus === 0">
<el-button
link
type="success"
size="small"
@click="handleConfirm(row)"
>确认</el-button
>
<el-button
link
type="danger"
size="small"
@click="handleInvalid(row)"
>作废</el-button
>
</template>
<!-- 有效: 结算 -->
<template v-else-if="row.referralStatus === 1">
<el-button
link
type="primary"
size="small"
@click="handleSettle(row)"
>结算</el-button
>
<el-button
link
type="danger"
size="small"
@click="handleInvalid(row)"
>作废</el-button
>
</template>
<!-- 已结算: 查看 -->
<template v-else-if="row.referralStatus === 3">
<el-tag type="success" size="small">已结算</el-tag>
</template>
<!-- 无效 -->
<template v-else-if="row.referralStatus === 2">
<el-tooltip
:content="row.invalidReason || '无原因'"
placement="top"
>
<el-tag type="info" size="small">无效</el-tag>
</el-tooltip>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 结算弹窗 -->
<el-dialog
v-model="settleDialogVisible"
title="结算推荐费"
width="450px"
:close-on-click-modal="false"
>
<el-form ref="settleFormRef" :model="settleForm" label-width="90px">
<el-form-item label="推荐费金额">
<el-input-number
v-model="settleForm.amount"
:precision="2"
:min="0"
:max="99999"
:step="10"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结算备注">
<el-input
v-model="settleForm.remarks"
type="textarea"
:rows="3"
placeholder="选填"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="settleDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="settleLoading"
@click="handleSettleSubmit"
>确定结算</el-button
>
</template>
</el-dialog>
<!-- 作废弹窗 -->
<el-dialog
v-model="invalidDialogVisible"
title="作废推荐"
width="450px"
:close-on-click-modal="false"
>
<el-form
ref="invalidFormRef"
:model="invalidForm"
:rules="invalidFormRules"
label-width="90px"
>
<el-form-item label="作废原因" prop="reason">
<el-input
v-model="invalidForm.reason"
type="textarea"
:rows="3"
placeholder="请输入作废原因"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="invalidDialogVisible = false">取消</el-button>
<el-button
type="danger"
:loading="invalidLoading"
@click="handleInvalidSubmit"
>确认作废</el-button
>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import {
pageReferral,
getStatistics,
confirmReferral,
invalidateReferral,
settleReferral,
batchSettleReferrals,
exportReferrals,
ReferralStatusOptions,
type ReferralRecord,
type ReferralParam
} from '@/api/app/referral';
// 搜索表单
const searchForm = reactive<ReferralParam>({
referralStatus: undefined,
referralCode: undefined,
customerName: undefined,
startDate: undefined,
endDate: undefined
});
const dateRange = ref<string[]>([]);
const loading = ref(false);
const tableData = ref<ReferralRecord[]>([]);
const selectedRows = ref<ReferralRecord[]>([]);
const statistics = ref<Record<string, number>>({});
// 分页
const pagination = reactive({
pageNum: 1,
pageSize: 10,
total: 0
});
// 结算弹窗
const settleDialogVisible = ref(false);
const settleLoading = ref(false);
const settleFormRef = ref<FormInstance>();
const settleForm = reactive({
id: 0,
amount: 0,
remarks: ''
});
// 作废弹窗
const invalidDialogVisible = ref(false);
const invalidLoading = ref(false);
const invalidFormRef = ref<FormInstance>();
const invalidForm = reactive({
id: 0,
reason: ''
});
const invalidFormRules: FormRules = {
reason: [{ required: true, message: '请输入作废原因', trigger: 'blur' }]
};
// 状态映射
function getStatusText(status?: number) {
if (status === null || status === undefined) return '-';
const item = ReferralStatusOptions.find((s) => s.value === status);
return item ? item.label : '-';
}
function getStatusType(status?: number) {
switch (status) {
case 0:
return 'warning';
case 1:
return 'success';
case 2:
return 'info';
case 3:
return '';
default:
return 'info';
}
}
// 日期处理
function handleDateChange(val: string[] | null) {
if (val && val.length === 2) {
searchForm.startDate = val[0];
searchForm.endDate = val[1];
} else {
searchForm.startDate = undefined;
searchForm.endDate = undefined;
}
}
// 加载统计数据
async function loadStatistics() {
try {
statistics.value = await getStatistics({ ...searchForm });
} catch {
// ignore
}
}
// 加载列表
async function loadData() {
loading.value = true;
try {
const result = await pageReferral({
...searchForm,
pageNum: pagination.pageNum,
pageSize: pagination.pageSize
});
tableData.value = result.list;
pagination.total = result.total;
} catch (err: unknown) {
ElMessage.error((err as Error).message || '加载失败');
} finally {
loading.value = false;
}
}
// 搜索/重置
function handleSearch() {
pagination.pageNum = 1;
loadStatistics();
loadData();
}
function handleReset() {
searchForm.referralStatus = undefined;
searchForm.referralCode = undefined;
searchForm.customerName = undefined;
searchForm.startDate = undefined;
searchForm.endDate = undefined;
dateRange.value = [];
pagination.pageNum = 1;
loadStatistics();
loadData();
}
// 分页
function handleSizeChange() {
pagination.pageNum = 1;
loadData();
}
function handlePageChange() {
loadData();
}
// 表格选择
function handleSelectionChange(rows: ReferralRecord[]) {
selectedRows.value = rows;
}
// 确认有效
async function handleConfirm(row: ReferralRecord) {
try {
await ElMessageBox.confirm('确认该推荐有效?', '确认推荐');
await confirmReferral(row.id!);
ElMessage.success('确认成功');
loadData();
loadStatistics();
} catch (err: unknown) {
if ((err as Error).message) {
ElMessage.error((err as Error).message);
}
}
}
// 作废
function handleInvalid(row: ReferralRecord) {
invalidForm.id = row.id!;
invalidForm.reason = '';
invalidDialogVisible.value = true;
}
async function handleInvalidSubmit() {
if (!invalidFormRef.value) return;
try {
await invalidFormRef.value.validate();
invalidLoading.value = true;
await invalidateReferral(invalidForm.id, invalidForm.reason);
ElMessage.success('已作废');
invalidDialogVisible.value = false;
loadData();
loadStatistics();
} catch (err: unknown) {
if ((err as Error).message) {
ElMessage.error((err as Error).message);
}
} finally {
invalidLoading.value = false;
}
}
// 结算
function handleSettle(row: ReferralRecord) {
settleForm.id = row.id!;
settleForm.amount = parseFloat(String(row.referralFee || 0));
settleForm.remarks = '';
settleDialogVisible.value = true;
}
async function handleSettleSubmit() {
if (!settleFormRef.value) return;
settleLoading.value = true;
try {
await settleReferral(
settleForm.id,
settleForm.amount,
settleForm.remarks
);
ElMessage.success('结算成功');
settleDialogVisible.value = false;
loadData();
loadStatistics();
} catch (err: unknown) {
if ((err as Error).message) {
ElMessage.error((err as Error).message);
}
} finally {
settleLoading.value = false;
}
}
// 批量结算
async function handleBatchSettle() {
const validRows = selectedRows.value.filter((r) => r.referralStatus === 1);
if (validRows.length === 0) {
ElMessage.warning('请选择状态为「有效」的记录');
return;
}
try {
await ElMessageBox.confirm(
`确认结算选中的 ${validRows.length} 条推荐?`,
'批量结算'
);
const ids = validRows.map((r) => r.id!);
await batchSettleReferrals(ids);
ElMessage.success('批量结算成功');
selectedRows.value = [];
loadData();
loadStatistics();
} catch (err: unknown) {
if ((err as Error).message) {
ElMessage.error((err as Error).message);
}
}
}
// 导出
async function handleExport() {
try {
const data = await exportReferrals({ ...searchForm });
if (!data || data.length === 0) {
ElMessage.warning('无数据可导出');
return;
}
// 生成 CSV
const headers = Object.keys(data[0]);
const csvContent = [
headers.join(','),
...data.map((row) =>
headers
.map((h) => {
const val = (row as Record<string, unknown>)[h];
return typeof val === 'string' && val.includes(',')
? `"${val}"`
: val ?? '';
})
.join(',')
)
].join('\n');
const blob = new Blob(['\ufeff' + csvContent], {
type: 'text/csv;charset=utf-8'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `推荐记录_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success('导出成功');
} catch (err: unknown) {
ElMessage.error((err as Error).message || '导出失败');
}
}
onMounted(() => {
loadStatistics();
loadData();
});
</script>
<style scoped lang="scss">
.recommendation-container {
padding: 16px;
}
.statistics-cards {
margin-bottom: 16px;
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
text-align: center;
border: 1px solid #ebeef5;
.stat-value {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #909399;
}
&.warning .stat-value {
color: #e6a23c;
}
&.success .stat-value {
color: #67c23a;
}
&.primary .stat-value {
color: #409eff;
}
}
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.text-red {
color: #f56c6c;
}
.font-bold {
font-weight: bold;
}
</style>

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>

View File

@@ -0,0 +1,127 @@
<!-- 搜索表单 -->
<template>
<a-space>
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon>
<PlusOutlined />
</template>
<span>添加</span>
</a-button>
<a-button
danger
type="primary"
class="ele-btn-icon"
:disabled="selection.length === 0"
@click="removeBatch"
>
<template #icon>
<DeleteOutlined />
</template>
<span>批量删除</span>
</a-button>
<a-button
danger
type="primary"
class="ele-btn-icon"
:disabled="selection.length === 0"
@click="banBatch"
>
<template #icon>
<DeleteOutlined />
</template>
<span>下架</span>
</a-button>
<SelectUser
placeholder="请选择用户"
v-model:value="where.nickname"
@done="chooseUserId"
/>
<DictSelect
dict-code="region"
v-model:value="where.region"
style="width: 180px"
placeholder="请选择所在区域"
@change="onChange"
/>
<a-input-search
allow-clear
placeholder="请输入搜索关键词"
v-model:value="where.keywords"
@pressEnter="search"
@search="search"
style="width: 240px"
/>
<a-button @click="reset">重置</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import useSearch from '@/utils/use-search';
import { watch } from 'vue';
import type { HouseInfoParam } from '@/api/house/info/model';
import { User } from '@/api/system/user/model';
import DictSelect from '@/components/DictSelect/index.vue';
const emit = defineEmits<{
(e: 'search', where?: HouseInfoParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'ban'): void;
}>();
const props = defineProps<{
// 勾选的项目
selection?: [];
}>();
// 表单数据
const { where, resetFields } = useSearch<HouseInfoParam>({
userId: undefined,
nickname: '',
region: undefined,
keywords: undefined
});
const chooseUserId = (data: User) => {
where.userId = data.userId;
where.nickname = data.nickname;
search();
};
const onChange = (text) => {
where.region = text;
search();
};
/* 搜索 */
const search = () => {
emit('search', where);
};
// 新增
const add = () => {
emit('add');
};
// 批量删除
const removeBatch = () => {
emit('remove');
};
const banBatch = () => {
emit('ban')
}
/* 重置 */
const reset = () => {
resetFields();
search();
};
// 监听字典id变化
watch(
() => props.selection,
() => {}
);
</script>

View File

@@ -0,0 +1,426 @@
<template>
<div class="ele-body">
<a-card :bordered="false">
<!-- 表格 -->
<ele-pro-table
ref="tableRef"
row-key="houseId"
:columns="columns"
:datasource="datasource"
v-model:selection="selection"
:scroll="{ x: 1200 }"
:customRow="customRow"
cache-key="proHouseInfoTable"
>
<template #toolbar>
<search
:selection="selection"
@search="reload"
@add="openEdit"
@remove="removeBatch"
@ban="banBatch"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nickname'">
<div class="user-box">
<a-avatar
:size="30"
:src="`${record.avatar}`"
style="margin-right: 4px"
>
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<div class="user-info">
<span>{{ record.realName }}</span>
<span class="ele-text-placeholder">{{ record.nickname }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'houseLabel'">
<template v-if="JSON.parse(record.houseLabel)">
<a-tag v-for="item in JSON.parse(record.houseLabel)" :key="item">
{{ item }}
</a-tag>
</template>
</template>
<template v-if="column.key === 'status'">
<a-switch
:checked="record.status === 0"
@change="(checked: boolean) => editStatus(checked, record)"
/>
</template>
<template v-if="column.key === 'recommend'">
<a-switch
:checked="record.recommend !== 0"
@change="(checked: boolean) => editRecommend(checked, record)"
/>
</template>
<template v-else-if="column.key === 'mustSee'">
<a-switch
:checked="record.mustSee !== 0"
@change="(checked: boolean) => editMustSee(checked, record)"
/>
</template>
<template v-if="column.key === 'rent'">
<span class="ele-text-danger">
{{ formatNumber(record.rent) }}
</span>
</template>
<template v-if="column.key === 'monthlyRent'">
<span class="ele-text-danger">
{{ formatNumber(record.monthlyRent) }}
</span>
</template>
<template v-if="column.key === 'extent'">
<span>
{{ record.extent }}
</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<!-- <a-divider type="vertical" />-->
<!-- <a-button @click="openEdit(record)">修改</a-button>-->
<!-- <a-divider type="vertical" />-->
<!-- <a-button @click="resetPsw(record)">重置密码</a-button>-->
<!-- <a-divider type="vertical" />-->
<!-- <a-popconfirm-->
<!-- placement="topRight"-->
<!-- title="确定要删除此用户吗?"-->
<!-- @confirm="remove(record)"-->
<!-- >-->
<!-- <a class="ele-text-danger">删除</a>-->
<!-- </a-popconfirm>-->
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<InfoEdit
v-model:visible="showEdit"
:data="current"
:organization-list="data"
@done="reload"
/>
</div>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue/es';
import {
ExclamationCircleOutlined,
UserOutlined
} from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro/es';
import { formatNumber, messageLoading, toDateString } from 'ele-admin-pro/es';
import type {
ColumnItem,
DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types';
import InfoEdit from './components/info-edit.vue';
import {
pageHouseInfo,
removeBatchHouseInfo,
updateHouseInfo,
updateBatchHouseInfo
} from '@/api/house/info';
import type { HouseInfo, HouseInfoParam } from '@/api/house/info/model';
import { Organization } from '@/api/system/organization/model';
import Search from './components/search.vue';
// 树形数据
const data = ref<Organization[]>([]);
// 表格选中数据
const selection = ref<HouseInfo[]>([]);
// 当前编辑数据
const current = ref<HouseInfo | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格列配置
const columns = ref<ColumnItem[]>([
// {
// key: 'index',
// width: 48,
// align: 'center',
// fixed: 'left',
// hideInSetting: true,
// customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
// },
{
title: 'ID',
dataIndex: 'houseId',
width: 80,
showSorterTooltip: false
},
{
title: '经纪人',
dataIndex: 'nickname',
key: 'nickname',
ellipsis: true,
width: 160
},
{
title: '标题',
dataIndex: 'houseTitle',
width: 180
},
{
title: '区域',
dataIndex: 'region'
},
{
title: '户型',
key: 'houseType',
dataIndex: 'houseType'
},
{
title: '租赁方式',
dataIndex: 'leaseMethod'
},
{
title: '租金(元/m²)',
dataIndex: 'rent',
key: 'rent',
sorter: true
},
{
title: '月租金',
dataIndex: 'monthlyRent',
key: 'monthlyRent',
sorter: true
},
{
title: '面积(m²)',
dataIndex: 'extent',
key: 'extent',
sorter: true
},
{
title: '楼层',
dataIndex: 'floor'
},
{
title: '朝向',
dataIndex: 'toward'
},
{
title: '房号',
dataIndex: 'roomNumber'
},
{
title: '入房密码',
dataIndex: 'password'
},
{
title: '上架',
key: 'status',
dataIndex: 'status',
width: 90,
align: 'center'
},
{
title: '推荐',
key: 'recommend',
dataIndex: 'recommend',
width: 90,
align: 'center'
},
{
title: '必看',
key: 'mustSee',
dataIndex: 'mustSee',
width: 90,
align: 'center'
},
// {
// title: '详细地址',
// dataIndex: 'address',
// width: 300
// },
// {
// title: '房屋标签',
// dataIndex: 'houseLabel',
// key: 'houseLabel',
// width: 300
// },
{
title: '创建时间',
dataIndex: 'createTime',
width: 170,
showSorterTooltip: false,
ellipsis: true,
customRender: ({ text }) => toDateString(text)
}
]);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
where.showProfile = true;
where.roleId = filters.roles;
return pageHouseInfo({ page, limit, ...where, ...orders });
};
/* 搜索 */
const reload = (where?: HouseInfoParam) => {
selection.value = [];
tableRef?.value?.reload({ where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: HouseInfo) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 修改推荐状态 */
const editRecommend = (checked: boolean, row: HouseInfo) => {
const recommend = checked ? 1 : 0;
updateHouseInfo({
houseId: row.houseId,
userId: row.userId,
recommend
})
.then((msg) => {
row.recommend = recommend;
message.success(msg);
})
.catch((e) => {
message.error(e.message);
});
};
/* 修改上架状态 */
const editStatus = (checked: boolean, row: HouseInfo) => {
const status = checked ? 0 : 10;
updateHouseInfo({
houseId: row.houseId,
userId: row.userId,
status
})
.then((msg) => {
row.status = status;
message.success(msg);
})
.catch((e) => {
message.error(e.message);
});
};
const editMustSee = (checked: boolean, row: HouseInfo) => {
const mustSee = checked ? 1 : 0;
updateHouseInfo({
houseId: row.houseId,
userId: row.userId,
mustSee
})
.then((msg) => {
row.mustSee = mustSee;
message.success(msg);
})
.catch((e) => {
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的房源吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = messageLoading('请求中..', 0);
removeBatchHouseInfo(selection.value.map((d) => d.houseId))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 批量下架 */
const banBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要下架选中的房源吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = messageLoading('请求中..', 0);
updateBatchHouseInfo({
ids:selection.value.map((d) => d.houseId),
data: {
status: 10
}
})
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 自定义行属性 */
const customRow = (record: HouseInfo) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
</script>
<script lang="ts">
export default {
name: 'HouseInfo'
};
</script>
<style lang="less" scoped>
.user-box {
display: flex;
align-items: center;
.user-info {
display: flex;
flex-direction: column;
align-items: start;
}
}
</style>

View File

@@ -0,0 +1,739 @@
<!-- 用户编辑弹窗 -->
<template>
<ele-modal
width="75%"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '房源信息' : '房源信息'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@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">
<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="请输入月租金"
v-model:value="form.monthlyRent"
/>
<span v-else>{{ form.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">
<div class="content">
<ele-image-upload
v-model:value="files"
:limit="9"
:drag="true"
:disabled="true"
:item-style="{ width: '150px', height: '113px' }"
/>
</div>
</a-card>
<a-divider style="height: 8px" />
<a-card title="详细介绍" :bordered="false">
<!-- 预览 -->
<tinymce-editor
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 } 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, getHouseInfo } 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";
// 是否是修改
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 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,
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
});
/* 更新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'
// }
// ]
});
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('大小不能超过 2MB');
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)
};
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;
const houseId = Number(props.data?.houseId);
getHouseInfo(houseId).then(data => {
assignFields({
...data
});
if(data.files){
const arr = JSON.parse(data.files);
arr.map((d, i) => {
files.value.push({
uid: d.uid,
url: d.url,
status: 'done'
});
});
}
})
};
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 {
editStatus.value = true;
isUpdate.value = false;
}
} 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>

View File

@@ -0,0 +1,42 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-button type="primary" class="ele-btn-icon">
<template #icon>
<PlusOutlined />
</template>
<span>添加</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model';
import { watch } from 'vue';
const props = withDefaults(
defineProps<{
// 选中的角色
selection?: [];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: GradeParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
// 新增
const add = () => {
emit('add');
};
watch(
() => props.selection,
() => {}
);
</script>

View File

@@ -0,0 +1,360 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="logId"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
v-model:selection="selection"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'money'">
<div class="ele-text-danger" @click="openEdit(record)"
>{{ record.money }}</div
>
</template>
<template v-if="column.key === 'payStatus'">
<a-tag v-if="record.payStatus === 20" color="green">已支付</a-tag>
<a-tag
v-if="record.payStatus === 10"
color="red"
@click="onPay(record)"
>待支付</a-tag
>
</template>
<template v-if="column.key === 'isSettled'">
<a-tag v-if="record.isSettled === 1" color="green">已结算</a-tag>
<a-tag v-if="record.isSettled === 0" color="red">待结算</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="success">已处理</a-tag>
<a-tag
v-if="record.status === 0"
color="red"
@click.stop="onStatus(record)"
>待处理</a-tag
>
</template>
<template v-if="column.key === 'nickname'">
<a-tooltip :title="`${record.nickname} (${record.userId})`">
<a-avatar :src="record.userAvatar" size="small" />
<span style="padding-left: 3px">{{ record.nickname }}</span>
</a-tooltip>
</template>
<template v-if="column.key === 'idCard'">
<div>{{ record.realName }} {{ record.phone }}</div>
<div>{{ record.idCard }}</div>
</template>
<template v-if="column.key === 'createTime'">
<a-tooltip :title="`${toDateString(record.createTime)}`">
{{ timeAgo(record.createTime) }}
</a-tooltip>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">房源信息</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<ReservationEdit
v-model:visible="showEdit"
:data="current"
@done="reload"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { toDateString } from 'ele-admin-pro';
import Search from './components/search.vue';
import {
pageReservation,
removeReservation,
removeBatchReservation,
updateReservation
} from '@/api/house/reservation';
import ReservationEdit from './components/reservation-edit.vue';
import { timeAgo } from 'ele-admin-pro';
import type {
Reservation,
ReservationParam
} from '@/api/house/reservation/model';
import { useUserStore } from '@/store/modules/user';
const userStore = useUserStore();
// 当前用户信息
const loginUser = computed(() => userStore.info ?? {});
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<Reservation[]>([]);
// 当前编辑数据
const current = ref<Reservation | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 加载状态
const loading = ref(true);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
return pageReservation({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
key: 'index',
width: 48,
align: 'center',
fixed: 'left',
hideInSetting: true,
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
},
{
title: '订单号',
dataIndex: 'logId'
},
// {
// title: '房源信息',
// dataIndex: 'houseId',
// key: 'houseId'
// },
// {
// title: '付款金额',
// dataIndex: 'money',
// key: 'money',
// sorter: true
// },
{
title: '客户信息',
dataIndex: 'idCard',
key: 'idCard'
},
{
title: '看房日期',
dataIndex: 'expirationTime'
},
{
title: '备注信息',
dataIndex: 'comments',
key: 'comments'
},
{
title: '处理状态',
dataIndex: 'status',
key: 'status'
},
{
title: '创建时间',
dataIndex: 'createTime',
sorter: true
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ReservationParam) => {
console.log(where);
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: Reservation) => {
if (row?.payStatus == 10) {
current.value = row ?? null;
showEdit.value = true;
}
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
const onPay = (item) => {
Modal.confirm({
title: '提示',
content: '确认已收到款项并改变订单支付状态',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
item.payStatus = 20;
item.comments = '人工确认收款结算方式';
updateReservation(item)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
const onStatus = (item) => {
Modal.confirm({
title: '提示',
content: '确定要改变该订单的状态吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
item.status = 1;
updateReservation(item)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 删除单个 */
const remove = (row: Reservation) => {
const hide = message.loading('请求中..', 0);
removeReservation(row.logId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchReservation(selection.value.map((d) => d.logId))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 查询 */
const query = () => {
loading.value = true;
};
/* 自定义行属性 */
const customRow = (record: Reservation) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
export default {
name: 'Reservation'
};
</script>
<style lang="less" scoped>
.sys-org-table :deep(.ant-table-body) {
overflow: auto !important;
overflow: overlay !important;
}
.sys-org-table :deep(.ant-table-pagination.ant-pagination) {
padding: 0 4px;
margin-bottom: 0;
}
</style>