feat: 完成空间管理模块

This commit is contained in:
Zzz1zz 2026-01-23 01:14:47 +08:00
parent a53cd0e8b8
commit f582098dc6
46 changed files with 3231 additions and 2309 deletions

BIN
dist.zip Normal file

Binary file not shown.

View file

@ -4,6 +4,7 @@ import {
ProjectListRes,
CreateProjectParam,
ProjectDetailRes,
TopBuildingTagsRes,
} from '@/types/business';
import {
ProjectPaginationParams,
@ -50,8 +51,11 @@ export function updateProject(
export function getProjectCityList(): Promise<CommonCityRes> {
return axios.get(`project/getCityList`);
}
// ------------- 空间管理 -------------
// 删除项目
export function delProject(id: number): Promise<HttpResponse> {
return axios.get(`project/delProject?id=${id}`);
}
// ------------- 空间管理 -------------
// 创建房源
export function createRoom(params: any): Promise<HttpResponse> {
return axios.post('rooms/createRoom', params);
@ -79,7 +83,7 @@ export function changeRoomStatus(params: any): Promise<HttpResponse> {
// 房源获取头部楼栋tags
export function getTopBuildingTagsByProjectId(
id: number
): Promise<HttpResponse> {
): Promise<TopBuildingTagsRes> {
return axios.get(`roomOver/getTopBuildingTags?project_id=${id}`);
}
// 房源剖视-数据统计
@ -90,3 +94,7 @@ export function getStatisticsData(params: any): Promise<HttpResponse> {
export function getRoomOverList(params: any): Promise<HttpResponse> {
return axios.post('roomOver/getRoomList', params);
}
// 房源图谱列表
export function getRoomsGraphList(params: any): Promise<HttpResponse> {
return axios.post('roomsGraph/getRoomList', params);
}

View file

@ -1,22 +0,0 @@
import axios from 'axios';
import type { TableData } from '@arco-design/web-vue/es/table/interface';
export interface ContentDataRecord {
x: string;
y: number;
}
export function queryContentData() {
return axios.get<ContentDataRecord[]>('/api/content-data');
}
export interface PopularRecord {
key: number;
clickNumber: string;
title: string;
increases: number;
}
export function queryPopularList(params: { type: string }) {
return axios.get<TableData[]>('/api/popular/list', { params });
}

View file

@ -1,21 +0,0 @@
import axios from 'axios';
export interface BaseInfoModel {
activityName: string;
channelType: string;
promotionTime: string[];
promoteLink: string;
}
export interface ChannelInfoModel {
advertisingSource: string;
advertisingMedia: string;
keyword: string[];
pushNotify: boolean;
advertisingContent: string;
}
export type UnitChannelModel = BaseInfoModel & ChannelInfoModel;
export function submitChannelForm(data: UnitChannelModel) {
return axios.post('/api/channel-form/submit', { data });
}

View file

@ -1,56 +0,0 @@
import axios from 'axios';
import qs from 'query-string';
import type { DescData } from '@arco-design/web-vue/es/descriptions/interface';
export interface PolicyRecord {
id: string;
number: number;
name: string;
contentType: 'img' | 'horizontalVideo' | 'verticalVideo';
filterType: 'artificial' | 'rules';
count: number;
status: 'online' | 'offline';
createdTime: string;
}
export interface PolicyParams extends Partial<PolicyRecord> {
current: number;
pageSize: number;
}
export interface PolicyListRes {
list: PolicyRecord[];
total: number;
}
export function queryPolicyList(params: PolicyParams) {
return axios.get<PolicyListRes>('/api/list/policy', {
params,
paramsSerializer: (obj) => {
return qs.stringify(obj);
},
});
}
export interface ServiceRecord {
id: number;
title: string;
description: string;
name?: string;
actionType?: string;
icon?: string;
data?: DescData[];
enable?: boolean;
expires?: boolean;
}
export function queryInspectionList() {
return axios.get('/api/list/quality-inspection');
}
export function queryTheServiceList() {
return axios.get('/api/list/the-service');
}
export function queryRulesPresetList() {
return axios.get('/api/list/rules-preset');
}

View file

@ -24,15 +24,3 @@ interface MessageStatus {
export function setMessageStatus(data: MessageStatus) {
return axios.post<MessageListType>('/api/message/read', data);
}
export interface ChatRecord {
id: number;
username: string;
content: string;
time: string;
isCollect: boolean;
}
export function queryChatList() {
return axios.post<ChatRecord[]>('/api/chat/list');
}

View file

@ -1,49 +0,0 @@
import axios from 'axios';
export interface ProfileBasicRes {
status: number;
video: {
mode: string;
acquisition: {
resolution: string;
frameRate: number;
};
encoding: {
resolution: string;
rate: {
min: number;
max: number;
default: number;
};
frameRate: number;
profile: string;
};
};
audio: {
mode: string;
acquisition: {
channels: number;
};
encoding: {
channels: number;
rate: number;
profile: string;
};
};
}
export function queryProfileBasic() {
return axios.get<ProfileBasicRes>('/api/profile/basic');
}
export type operationLogRes = Array<{
key: string;
contentNumber: string;
updateContent: string;
status: number;
updateTime: string;
}>;
export function queryOperationLog() {
return axios.get<operationLogRes>('/api/operation/log');
}

View file

@ -1,88 +0,0 @@
import axios from 'axios';
export interface MyProjectRecord {
id: number;
name: string;
description: string;
peopleNumber: number;
contributors: {
name: string;
email: string;
avatar: string;
}[];
}
export function queryMyProjectList() {
return axios.post('/api/user/my-project/list');
}
export interface MyTeamRecord {
id: number;
avatar: string;
name: string;
peopleNumber: number;
}
export function queryMyTeamList() {
return axios.post('/api/user/my-team/list');
}
export interface LatestActivity {
id: number;
title: string;
description: string;
avatar: string;
}
export function queryLatestActivity() {
return axios.post<LatestActivity[]>('/api/user/latest-activity');
}
export function saveUserInfo() {
return axios.post('/api/user/save-info');
}
export interface BasicInfoModel {
email: string;
nickname: string;
countryRegion: string;
area: string;
address: string;
profile: string;
}
export interface EnterpriseCertificationModel {
accountType: number;
status: number;
time: string;
legalPerson: string;
certificateType: string;
authenticationNumber: string;
enterpriseName: string;
enterpriseCertificateType: string;
organizationCode: string;
}
export type CertificationRecord = Array<{
certificationType: number;
certificationContent: string;
status: number;
time: string;
}>;
export interface UnitCertification {
enterpriseInfo: EnterpriseCertificationModel;
record: CertificationRecord;
}
export function queryCertification() {
return axios.post<UnitCertification>('/api/user/certification');
}
export function userUploadApi(
data: FormData,
config: {
controller: AbortController;
onUploadProgress?: (progressEvent: any) => void;
}
) {
// const controller = new AbortController();
return axios.post('/api/user/upload', data, config);
}

View file

@ -14,6 +14,14 @@ export interface UserPermissionList {
permission: UserPermission[];
}
export interface UserProject {
id: number;
name: string;
image?: string;
imageUrl?: string;
[key: string]: any;
}
export interface UserState {
name?: string;
avatar?: string;
@ -32,6 +40,8 @@ export interface UserState {
certification?: number;
role: '' | '*' | 'admin' | 'user';
permission: UserPermission[];
projects?: UserProject[];
currentProjectId?: number;
}
export async function login(data: LoginData) {
const result = axios.post<LoginRes>('/auth/login', data);

View file

@ -1,73 +0,0 @@
import axios from 'axios';
import { GeneralChart } from '@/types/global';
export interface ChartDataRecord {
x: string;
y: number;
name: string;
}
export interface DataChainGrowth {
quota: string;
}
export interface DataChainGrowthRes {
count: number;
growth: number;
chartData: {
xAxis: string[];
data: { name: string; value: number[] };
};
}
export function queryDataChainGrowth(data: DataChainGrowth) {
return axios.post<DataChainGrowthRes>('/api/data-chain-growth', data);
}
export interface PopularAuthorRes {
list: {
ranking: number;
author: string;
contentCount: number;
clickCount: number;
}[];
}
export function queryPopularAuthor() {
return axios.get<PopularAuthorRes>('/api/popular-author/list');
}
export interface ContentPublishRecord {
x: string[];
y: number[];
name: string;
}
export function queryContentPublish() {
return axios.get<ContentPublishRecord[]>('/api/content-publish');
}
export function queryContentPeriodAnalysis() {
return axios.post<GeneralChart>('/api/content-period-analysis');
}
export interface PublicOpinionAnalysis {
quota: string;
}
export interface PublicOpinionAnalysisRes {
count: number;
growth: number;
chartData: ChartDataRecord[];
}
export function queryPublicOpinionAnalysis(data: DataChainGrowth) {
return axios.post<PublicOpinionAnalysisRes>(
'/api/public-opinion-analysis',
data
);
}
export interface DataOverviewRes {
xAxis: string[];
data: Array<{ name: string; value: number[]; count: number }>;
}
export function queryDataOverview() {
return axios.post<DataOverviewRes>('/api/data-overview');
}

View file

@ -1,236 +0,0 @@
# DataTable 组件使用指南
## 概述
`DataTable` 是一个基于 Arco Design Vue 的 `a-table` 组件封装,提供了统一的分页配置和事件处理,可以在多个页面中复用。
## 特性
- 🚀 统一的分页配置
- 📊 支持自定义列配置
- 🔄 内置加载状态管理
- 🎯 支持排序和筛选
- 📱 响应式设计
- 🎨 可自定义样式
## 基础用法
### 1. 使用 DataTable 组件
```vue
<template>
<DataTable
:data="tableData"
:columns="columns"
:loading="loading"
:total="total"
:current="current"
:page-size="pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
>
<!-- 自定义列插槽 -->
<template #status="{ record }">
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
{{ record.status }}
</a-tag>
</template>
<template #operate="{ record }">
<a-space>
<a-button size="small">查看</a-button>
<a-button size="small" type="primary">编辑</a-button>
</a-space>
</template>
</DataTable>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import type { TableColumnData, TableData } from '@arco-design/web-vue';
import useDataTable from '@/composables/useDataTable';
// 使用 DataTable composable
const {
data: tableData,
columns,
loading,
total,
current,
pageSize,
setData,
setColumns,
setLoading,
setTotal,
setCurrent,
setPageSize,
} = useDataTable({
initialPage: 1,
initialPageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
});
// 列配置
const columnConfig: TableColumnData[] = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '年龄', dataIndex: 'age', key: 'age' },
{ title: '状态', dataIndex: 'status', key: 'status', slotName: 'status' },
{ title: '操作', key: 'operate', slotName: 'operate', width: 200 },
];
// 模拟数据
const mockData: TableData[] = [
{ id: 1, name: '张三', age: 25, status: 'active' },
{ id: 2, name: '李四', age: 30, status: 'inactive' },
];
onMounted(() => {
setColumns(columnConfig);
setData(mockData);
setTotal(mockData.length);
});
// 分页事件处理
const handlePageChange = (page: number) => {
setCurrent(page);
// 调用 API 获取数据
fetchData(page, pageSize.value);
};
const handlePageSizeChange = (pageSize: number) => {
setPageSize(pageSize);
// 调用 API 获取数据
fetchData(1, pageSize);
};
const fetchData = async (page: number, size: number) => {
setLoading(true);
try {
// 调用 API
// const { data, total } = await api.getList({ page, size });
// setData(data);
// setTotal(total);
} finally {
setLoading(false);
}
};
</script>
```
### 2. 高级配置
```vue
<template>
<DataTable
:data="tableData"
:columns="columns"
:loading="loading"
:total="total"
:current="current"
:page-size="pageSize"
:bordered="true"
:stripe="true"
:size="'small'"
:scroll="{ x: 1200, y: 400 }"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@selection-change="handleSelectionChange"
@sorter-change="handleSorterChange"
@filter-change="handleFilterChange"
>
<!-- 插槽内容 -->
</DataTable>
</template>
```
## Props 说明
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| data | TableData[] | [] | 表格数据 |
| columns | TableColumnData[] | [] | 列配置 |
| loading | boolean | false | 加载状态 |
| rowKey | string | 'id' | 行键 |
| pagination | boolean \| PaginationProps | true | 分页配置 |
| bordered | boolean | false | 是否显示边框 |
| size | 'mini' \| 'small' \| 'medium' \| 'large' | 'medium' | 表格大小 |
| stripe | boolean | false | 是否显示斑马纹 |
| tableLayout | 'auto' \| 'fixed' | 'auto' | 表格布局 |
| scroll | object | - | 滚动配置 |
| current | number | 1 | 当前页码 |
| pageSize | number | 10 | 每页条数 |
| total | number | 0 | 总条数 |
| showTotal | boolean | true | 是否显示总数 |
| pageSizeOptions | number[] | [10, 20, 50, 100] | 每页条数选项 |
| showJumper | boolean | true | 是否显示快速跳转 |
| showPageSize | boolean | true | 是否显示每页条数选择器 |
## Events 说明
| 事件名 | 参数 | 说明 |
|--------|------|------|
| page-change | (page: number) | 页码变化 |
| page-size-change | (pageSize: number) | 每页条数变化 |
| selection-change | (selection: TableData[]) | 选择变化 |
| sorter-change | (sorter: any) | 排序变化 |
| filter-change | (filters: any) | 筛选变化 |
## useDataTable Composable
`useDataTable` 是一个用于管理表格状态的 composable,提供了以下功能:
### 返回值
| 属性 | 类型 | 说明 |
|------|------|------|
| data | TableData[] | 表格数据 |
| columns | TableColumnData[] | 列配置 |
| loading | boolean | 加载状态 |
| pagination | PaginationProps | 分页配置 |
| current | number | 当前页码 |
| pageSize | number | 每页条数 |
| total | number | 总条数 |
| setData | (data: TableData[]) => void | 设置数据 |
| setColumns | (columns: TableColumnData[]) => void | 设置列配置 |
| setLoading | (loading: boolean) => void | 设置加载状态 |
| setTotal | (total: number) => void | 设置总条数 |
| setCurrent | (current: number) => void | 设置当前页码 |
| setPageSize | (pageSize: number) => void | 设置每页条数 |
| resetPagination | () => void | 重置分页 |
### 配置选项
```typescript
interface UseDataTableOptions {
initialPage?: number; // 初始页码
initialPageSize?: number; // 初始每页条数
pageSizeOptions?: number[]; // 每页条数选项
showTotal?: boolean; // 是否显示总数
showJumper?: boolean; // 是否显示快速跳转
showPageSize?: boolean; // 是否显示每页条数选择器
rowKey?: string; // 行键
bordered?: boolean; // 是否显示边框
size?: 'mini' | 'small' | 'medium' | 'large'; // 表格大小
stripe?: boolean; // 是否显示斑马纹
tableLayout?: 'auto' | 'fixed'; // 表格布局
}
```
## 最佳实践
1. **统一使用 DataTable 组件**:在项目中统一使用 DataTable 组件,避免重复配置分页逻辑。
2. **使用 useDataTable composable**:利用 composable 管理表格状态,提高代码复用性。
3. **合理配置列插槽**:对于需要自定义渲染的列,使用插槽进行自定义。
4. **处理加载状态**:在数据请求时正确设置 loading 状态。
5. **分页事件处理**:在分页事件中调用 API 获取对应页的数据。
## 注意事项
1. 确保在 `main.ts` 中注册了 `DataTable` 组件。
2. 列配置中的 `slotName` 要与模板中的插槽名称一致。
3. 数据源需要包含 `rowKey` 指定的字段。
4. 分页事件处理中应该调用实际的 API 接口。

View file

@ -1,116 +0,0 @@
# ImageUpload 图片上传组件
一个可复用的图片上传组件,支持自定义上传类型、文件大小限制等功能。
## 功能特性
- 支持图片格式:JPG、PNG、GIF、WEBP
- 可自定义文件大小限制
- 支持上传进度显示
- 支持图片预览
- 支持多文件上传
- 上传成功后自动回传 URL
## Props
| 参数 | 类型 | 默认值 | 说明 |
| ---------- | ------------ | ---------------- | ---------------------------- |
| modelValue | UploadFile[] | [] | 文件列表,支持 v-model |
| uploadType | UploadType | - | 上传类型:1 合同模版,2 图片 |
| maxSize | number | 10 _ 1024 _ 1024 | 最大文件大小(字节) |
| maxCount | number | - | 最大上传数量 |
## Events
| 事件名 | 参数 | 说明 |
| ----------------- | -------------------------------------------- | ------------------ |
| update:modelValue | (value: UploadFile[]) | 文件列表更新时触发 |
| upload-success | (file: UploadFile, response: UploadResponse) | 上传成功时触发 |
| upload-error | (error: Error) | 上传失败时触发 |
## 类型定义
```typescript
interface UploadFile {
uid: string;
name: string;
url?: string;
status: 'done' | 'uploading' | 'error';
percent?: number;
}
interface UploadResponse {
filename: string;
size: number;
signedPutURL: string;
objectKey?: string;
}
type UploadType = 1 | 2; // 1合同模版,2图片
```
## 使用示例
### 基础用法
```vue
<template>
<ImageUpload
v-model="images"
:upload-type="2"
@upload-success="handleUploadSuccess"
@upload-error="handleUploadError"
/>
</template>
<script setup>
import { ref } from 'vue';
import ImageUpload from '@/components/common/ImageUpload.vue';
const images = ref([]);
const handleUploadSuccess = (file, response) => {
console.log('上传成功:', response.url);
};
const handleUploadError = (error) => {
console.error('上传失败:', error.message);
};
</script>
```
### 自定义文件大小限制
```vue
<template>
<ImageUpload
v-model="images"
:upload-type="2"
:max-size="5 * 1024 * 1024"
@upload-success="handleUploadSuccess"
/>
</template>
```
### 在表单中使用
```vue
<template>
<a-form-item label="项目图片" field="images">
<ImageUpload
v-model="form.images"
:upload-type="2"
:max-size="10 * 1024 * 1024"
@upload-success="handleUploadSuccess"
@upload-error="handleUploadError"
/>
</a-form-item>
</template>
```
## 注意事项
1. 组件会自动处理文件验证,包括文件大小和格式检查
2. 上传成功后,文件对象会自动更新 URL 和状态
3. 支持 v-model 双向绑定,父组件可以直接操作文件列表
4. 上传类型必须从父组件传入,用于区分不同的上传场景

View file

@ -1,12 +0,0 @@
## 组件说明
该组件非官方最终设计规范,以单独组件存在。
同时仅仅提供最基本的功能,后续进行优化及更改。
## Component description
The component unofficial final design specification exists as a separate component.
At the same time, only the most basic functions are provided, and subsequent optimizations and changes will be made.

View file

@ -1,135 +1,202 @@
<template>
<div class="top-menu">
<a-select :style="{ width: '200px' }" placeholder="请选择" :bordered="false">
<template #label="{ data }">
<div class="current-building">
<img class="img" src="@/assets/images/building1.png" alt="">
<p class="building-name">星河商务中心</p>
</div>
</template>
<a-option>
<p class="building-name">星河商务中心</p>
</a-option>
</a-select>
<a-menu :style="{ width: '100%' }" mode="horizontal" :selected-keys="[selectedKey]"
@menu-item-click="onMenuClick" style="width: 100%">
<a-menu-item v-for="item in topLevelMenus" :key="item.path">
{{ t(item.meta?.locale || '') }}
</a-menu-item>
</a-menu>
</div>
<div class="top-menu">
<a-select
v-model="selectedProjectId"
:style="{ width: '200px' }"
placeholder="请选择"
:bordered="false"
@change="handleProjectChange"
>
<template #label>
<div v-if="currentProject" class="current-building">
<img
class="img"
:src="
currentProject.imageUrl || currentProject.image || defaultImage
"
:alt="currentProject.name"
/>
<p class="building-name">{{ currentProject.name }}</p>
</div>
<div v-else class="current-building">
<p class="building-name">请选择项目</p>
</div>
</template>
<a-option
v-for="project in projectList"
:key="project.id"
:value="project.id"
>
<div>
<div class="flex items-center">
<img
class="w-[30px] h-[30px] object-contain"
:src="project.imageUrl || project.image || defaultImage"
:alt="project.name"
/>
<p class="ml-[10px]">{{ project.name }}</p>
</div>
</div>
</a-option>
</a-select>
<a-menu
:style="{ width: '100%' }"
mode="horizontal"
:selected-keys="[selectedKey]"
style="width: 100%"
@menu-item-click="onMenuClick"
>
<a-menu-item v-for="item in topLevelMenus" :key="item.path">
{{ t(item.meta?.locale || '') }}
</a-menu-item>
</a-menu>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import type { RouteRecordNormalized } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ref, computed, watch, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import type { RouteRecordNormalized } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/store';
import type { UserProject } from '@/store/modules/user/types';
const router = useRouter();
const { t } = useI18n();
// 默认图片路径
const defaultImage = new URL('@/assets/images/building1.png', import.meta.url)
.href;
// 获取一级路由(顶级菜单)
const topLevelMenus = computed(() => {
return router.getRoutes()
.filter(route => {
// 过滤出一级路由:有 meta 且有 children
return route.meta &&
route.children?.length > 0 &&
route.meta.isFirst &&
route.meta.locale;
})
.map(route => ({
path: route.path,
name: route.name,
meta: route.meta
}));
});
const router = useRouter();
const { t } = useI18n();
const userStore = useUserStore();
const buildingList = ref([
{ logo: new URL('@/assets/images/building1.png', import.meta.url).href, title: '星河商务中心', id: 1 },
{ logo: new URL('@/assets/images/building1.png', import.meta.url).href, title: '南山科学科学园', id: 2 },
{ logo: new URL('@/assets/images/building1.png', import.meta.url).href, title: '金融科技大厦', id: 3 },
{ logo: new URL('@/assets/images/building1.png', import.meta.url).href, title: '福田平安大厦', id: 4 },
]);
// 获取一级路由(顶级菜单)
const topLevelMenus = computed(() => {
return router
.getRoutes()
.filter((route) => {
// 过滤出一级路由:有 meta 且有 children
return (
route.meta &&
route.children?.length > 0 &&
route.meta.isFirst &&
route.meta.locale
);
})
.map((route) => ({
path: route.path,
name: route.name,
meta: route.meta,
}));
});
const currentBuildingId = ref(1);
// 项目列表
const projectList = computed<UserProject[]>(() => {
return userStore.projects || [];
});
const getCurrentBuilding = (id: number) => {
return buildingList.value.find(building => building.id === id);
};
// 选中的项目ID,优先使用 store 中保存的值
const selectedProjectId = ref<number | undefined>(userStore.currentProjectId);
defineProps<{
// 当前选中的项目
const currentProject = computed(() => {
if (!selectedProjectId.value) return null;
return projectList.value.find(
(project) => project.id === selectedProjectId.value
);
});
// 项目切换处理
const handleProjectChange = (projectId: number) => {
selectedProjectId.value = projectId;
// 保存到 store
userStore.setInfo({ currentProjectId: projectId });
};
// 监听 projects 变化,设置默认选中第一个
watch(
() => userStore.projects,
(projects) => {
if (projects && projects.length > 0) {
// 优先使用 store 中保存的项目ID,否则使用第一个项目
const projectId = userStore.currentProjectId || projects[0].id;
selectedProjectId.value = projectId;
// 如果 store 中没有保存,则保存第一个项目ID
if (!userStore.currentProjectId) {
userStore.setInfo({ currentProjectId: projectId });
}
}
},
{ immediate: true }
);
// 组件挂载时,如果已有项目数据,设置默认选中
onMounted(() => {
if (projectList.value.length > 0) {
// 优先使用 store 中保存的项目ID,否则使用第一个项目
const projectId = userStore.currentProjectId || projectList.value[0].id;
selectedProjectId.value = projectId;
// 如果 store 中没有保存,则保存第一个项目ID
if (!userStore.currentProjectId) {
userStore.setInfo({ currentProjectId: projectId });
}
}
});
defineProps<{
selectedKey: string;
}>();
}>();
const emit = defineEmits(['menu-click']);
const emit = defineEmits(['menu-click']);
const onMenuClick = (key: string) => {
const onMenuClick = (key: string) => {
// 查找对应的路由
const targetRoute = topLevelMenus.value.find(item => item.path === key);
const targetRoute = topLevelMenus.value.find((item) => item.path === key);
if (targetRoute) {
// 跳转到对应路由
router.push(targetRoute.path);
// 跳转到对应路由
router.push(targetRoute.path);
}
// 触发父组件事件
emit('menu-click', key);
};
};
</script>
<style lang="less">
.top-menu {
.top-menu {
display: flex;
align-items: center;
width: 100%;
.current-building,
.building {
display: flex;
align-items: center;
.img {
width: 30px;
height: 30px;
}
.building-name {
margin-left: 10px;
}
}
:deep(.arco-select-option-content) {
display: flex !important;
align-items: center !important;
.current-building,
.building {
display: flex;
align-items: center;
.img {
width: 30px;
height: 30px;
width: 30px;
height: 30px;
}
.building-name {
margin-left: 10px;
margin-left: 10px;
}
}
}
.building-item {
.building {
display: flex;
align-items: center;
.img {
width: 30px;
height: 30px;
}
.building-name {
margin-left: 10px;
}
}
}
:deep(.arco-select-option-content) {
display: flex !important;
align-items: center !important;
.current-building,
.building {
display: flex;
align-items: center;
.img {
width: 30px;
height: 30px;
}
.building-name {
margin-left: 10px;
}
}
}
}
</style>
}
</style>

View file

@ -1,196 +0,0 @@
# 分页处理 Composable 使用指南
## 概述
本项目提供了两个用于处理表格分页的 composable:
1. `usePagination` - 基础分页事件处理
2. `useTableWithPagination` - 完整的分页和数据获取集成
## usePagination
### 基础用法
```typescript
import usePagination from '@/composables/usePagination';
const { handlePageChange, handlePageSizeChange } = usePagination({
onPageChange: () => getUserListData(),
onPageSizeChange: () => getUserListData(),
resetToFirstPage: true,
});
// 在分页事件中使用
const onPageChange = (page: number) => {
handlePageChange(page, setCurrent);
};
const onPageSizeChange = (pageSize: number) => {
handlePageSizeChange(pageSize, setPageSize, setCurrent);
};
```
### 配置选项
| 选项 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| onPageChange | Function | - | 页码变化时的回调函数 |
| onPageSizeChange | Function | - | 每页条数变化时的回调函数 |
| resetToFirstPage | boolean | true | 切换每页条数时是否重置到第一页 |
## useTableWithPagination
### 完整用法
```typescript
import useTableWithPagination from '@/composables/useTableWithPagination';
const {
data: renderData,
columns: projectColumn,
loading,
total,
current,
pageSize,
setColumns,
onPageChange,
onPageSizeChange,
refresh,
updateParams,
} = useTableWithPagination({
// 数据获取函数
fetchData: async (params) => {
const { code, data } = await getUserList({
dept_id: selectedKeys.value[0],
keyword: searchKeyWord.value,
page: params.page,
page_size: params.pageSize,
});
if (code === 200) {
const { page, user } = data;
return {
data: Array.isArray(user) ? user : [],
total: typeof page.total === 'number' ? page.total : 0,
};
}
return { data: [], total: 0 };
},
initialPage: 1,
initialPageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
autoFetch: false, // 不自动获取,等选中部门后再获取
});
```
### 配置选项
| 选项 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| fetchData | Function | - | 数据获取函数,必须返回 { data, total } |
| initialPage | number | 1 | 初始页码 |
| initialPageSize | number | 10 | 初始每页条数 |
| pageSizeOptions | number[] | [10, 20, 50, 100] | 每页条数选项 |
| extraParams | object | {} | 额外的查询参数 |
| autoFetch | boolean | true | 是否自动获取数据 |
| resetToFirstPage | boolean | true | 切换每页条数时是否重置到第一页 |
### 返回值
| 属性 | 类型 | 说明 |
|------|------|------|
| data | Ref<TableData[]> | 表格数据 |
| columns | Ref<TableColumnData[]> | 列配置 |
| loading | Ref<boolean> | 加载状态 |
| total | Ref<number> | 总条数 |
| current | Ref<number> | 当前页码 |
| pageSize | Ref<number> | 每页条数 |
| setColumns | Function | 设置列配置 |
| onPageChange | Function | 页码变化处理 |
| onPageSizeChange | Function | 每页条数变化处理 |
| refresh | Function | 刷新数据 |
| updateParams | Function | 更新查询参数 |
## 最佳实践
### 1. 使用 usePagination(推荐用于简单场景)
```typescript
// 在现有的 useDataTable 基础上添加分页处理
const { handlePageChange, handlePageSizeChange } = usePagination({
onPageChange: () => getUserListData(),
onPageSizeChange: () => getUserListData(),
});
const onPageChange = (page: number) => {
handlePageChange(page, setCurrent);
};
const onPageSizeChange = (pageSize: number) => {
handlePageSizeChange(pageSize, setPageSize, setCurrent);
};
```
### 2. 使用 useTableWithPagination(推荐用于新页面)
```typescript
// 完整的分页和数据获取集成
const {
data: renderData,
columns: projectColumn,
loading,
total,
current,
pageSize,
onPageChange,
onPageSizeChange,
refresh,
} = useTableWithPagination({
fetchData: async (params) => {
// 你的数据获取逻辑
return { data: [], total: 0 };
},
autoFetch: false,
});
// 搜索时刷新数据
const handleSearch = () => {
refresh();
};
// 删除后刷新数据
const handleDelete = async (id: number) => {
await deleteItem(id);
refresh();
};
```
### 3. 处理搜索和筛选
```typescript
// 使用 updateParams 更新查询参数
const handleSearch = () => {
updateParams({ keyword: searchKeyWord.value });
};
// 或者使用 refresh 直接刷新
const handleSearch = () => {
refresh();
};
```
## 优势
1. **代码复用**:避免在每个页面重复编写分页逻辑
2. **统一处理**:确保所有页面的分页行为一致
3. **类型安全**:完整的 TypeScript 类型支持
4. **易于维护**:分页逻辑集中管理,便于修改和扩展
5. **自动优化**:自动处理页码重置、加载状态等细节
## 注意事项
1. `fetchData` 函数必须返回 `{ data, total }` 格式
2. 使用 `useTableWithPagination` 时,确保 `fetchData` 函数能正确处理错误
3. 如果需要自定义分页行为,可以使用 `usePagination` 进行更精细的控制
4. `autoFetch: false` 适用于需要等待用户操作后再获取数据的场景

View file

@ -1,10 +1,6 @@
import localeMessageBox from '@/components/message-box/locale/en-US';
import localeLogin from '@/views/login/locale/en-US';
import localeWorkplace from '@/views/dashboard/workplace/locale/en-US';
import localeMonitor from '@/views/dashboard/monitor/locale/en-US';
import locale403 from '@/views/exception/403/locale/en-US';
import locale404 from '@/views/exception/404/locale/en-US';
import locale500 from '@/views/exception/500/locale/en-US';
@ -30,9 +26,6 @@ export default {
...localeSettings,
...localeMessageBox,
...localeLogin,
...localeWorkplace,
...localeMonitor,
...locale403,
...locale404,
...locale500,

View file

@ -1,10 +1,6 @@
import localeMessageBox from '@/components/message-box/locale/zh-CN';
import localeLogin from '@/views/login/locale/zh-CN';
import localeWorkplace from '@/views/dashboard/workplace/locale/zh-CN';
import localeMonitor from '@/views/dashboard/monitor/locale/zh-CN';
import locale403 from '@/views/exception/403/locale/zh-CN';
import locale404 from '@/views/exception/404/locale/zh-CN';
import locale500 from '@/views/exception/500/locale/zh-CN';
@ -47,6 +43,7 @@ export default {
'menu.business.space': '空间管理',
'menu.business.house-view': '房源剖视',
'menu.business.house-list': '房源列表',
'menu.business.house-atlas': '房源图谱',
'menu.business.contract': '合同管理',
'menu.business.contract-list': '合同列表',
'menu.business.contract-file': '合同归档',
@ -131,9 +128,6 @@ export default {
...localeSettings,
...localeMessageBox,
...localeLogin,
...localeWorkplace,
...localeMonitor,
...locale403,
...locale404,
...locale500,

View file

@ -1,40 +0,0 @@
import { DEFAULT_LAYOUT } from '../base';
import { AppRouteRecordRaw } from '../types';
const RESULT: AppRouteRecordRaw = {
path: '/result',
name: 'result',
component: DEFAULT_LAYOUT,
redirect: '/result/success',
meta: {
locale: 'menu.result',
icon: 'icon-check-circle',
requiresAuth: true,
order: 5,
isFirst: true,
},
children: [
{
path: 'success',
name: 'Success',
component: () => import('@/views/result/success/index.vue'),
meta: {
locale: 'menu.result.success',
requiresAuth: true,
roles: ['admin'],
},
},
{
path: 'error',
name: 'Error',
component: () => import('@/views/result/error/index.vue'),
meta: {
locale: 'menu.result.error',
requiresAuth: true,
roles: ['admin'],
},
},
],
};
export default RESULT;

View file

@ -1,41 +0,0 @@
import { DEFAULT_LAYOUT } from '../base';
import { AppRouteRecordRaw } from '../types';
const VISUALIZATION: AppRouteRecordRaw = {
path: '/visualization',
name: 'visualization',
component: DEFAULT_LAYOUT,
redirect: '/visualization/data-analysis',
meta: {
locale: 'menu.visualization',
requiresAuth: true,
icon: 'icon-apps',
order: 1,
isFirst: true,
},
children: [
{
path: 'data-analysis',
name: 'DataAnalysis',
component: () => import('@/views/visualization/data-analysis/index.vue'),
meta: {
locale: 'menu.visualization.dataAnalysis',
requiresAuth: true,
roles: ['admin'],
},
},
{
path: 'multi-dimension-data-analysis',
name: 'MultiDimensionDataAnalysis',
component: () =>
import('@/views/visualization/multi-dimension-data-analysis/index.vue'),
meta: {
locale: 'menu.visualization.multiDimensionDataAnalysis',
requiresAuth: true,
roles: ['admin'],
},
},
],
};
export default VISUALIZATION;

View file

@ -69,10 +69,7 @@ const BUSINESS: AppRouteRecordRaw = {
path: 'house-list',
name: 'business-space-house-list',
component: () =>
import(
/* webpackChunkName: "business-space" */
'@/views/business/space/house-list/index.vue'
),
import('@/views/business/space/house-list/index.vue'),
meta: {
locale: 'menu.business.house-list',
requiresAuth: true,
@ -81,6 +78,20 @@ const BUSINESS: AppRouteRecordRaw = {
permissionKey: 'businessHouseList',
},
},
// 房源图谱
{
path: 'house-atlas',
name: 'business-space-house-atlas',
component: () =>
import('@/views/business/space/house-atlas/index.vue'),
meta: {
locale: 'menu.business.house-atlas',
requiresAuth: true,
roles: ['*'],
icon: 'icon-brush',
permissionKey: 'businessHouseAtlas',
},
},
],
},
// 合同管理

View file

@ -1,73 +0,0 @@
import PageLayout from '@/layout/page-layout.vue';
import { DEFAULT_LAYOUT } from '../base';
import { AppRouteRecordRaw } from '../types';
const DASHBOARD: AppRouteRecordRaw = {
path: '/dashboard',
name: 'dashboard',
component: DEFAULT_LAYOUT,
redirect: '/dashboard/workplace',
meta: {
locale: 'menu.dashboard.workplace',
requiresAuth: true,
icon: 'icon-dashboard',
order: 1,
isFirst: true,
permissionKey: 'dashboard',
},
children: [
{
path: 'workplace',
name: 'Workplace',
component: PageLayout,
redirect: '/dashboard/workplace/step',
meta: {
locale: 'menu.dashboard.workplace',
requiresAuth: true,
roles: ['*'],
icon: 'icon-dashboard',
permissionKey: 'workplace',
// activeMenu: 'dashboard',
},
children: [
{
path: 'step',
name: 'Step',
component: () =>
import(
/* webpackChunkName: "dashboard-workplace" */
/* webpackPreload: true */
'@/views/dashboard/workplace/index.vue'
),
meta: {
locale: 'menu.form.step',
requiresAuth: true,
roles: ['*'],
icon: 'icon-exclamation-circle',
permissionKey: 'step',
},
},
],
},
{
path: 'monitor',
name: 'Monitor',
component: () =>
import(
/* webpackChunkName: "dashboard-monitor" */
'@/views/dashboard/monitor/index.vue'
),
meta: {
locale: 'menu.dashboard.monitor',
requiresAuth: true,
roles: ['*'],
icon: 'icon-settings',
permissionKey: 'monitor',
// activeMenu: 'dashboard',
},
},
],
};
export default DASHBOARD;

View file

@ -30,6 +30,8 @@ const useUserStore = defineStore('user', {
certification: undefined,
role: '',
permission: [],
projects: undefined,
currentProjectId: undefined,
}),
// 配置持久化
@ -37,7 +39,16 @@ const useUserStore = defineStore('user', {
key: 'user-store',
storage: localStorage,
// 只持久化关键的用户信息,排除敏感信息
pick: ['name', 'avatar', 'role', 'permission', 'accountId', 'email'],
pick: [
'name',
'avatar',
'role',
'permission',
'accountId',
'email',
'projects',
'currentProjectId',
],
},
getters: {

View file

@ -1,6 +1,15 @@
import { UserPermission } from '@/types/api';
export type RoleType = '' | '*' | 'admin' | 'user';
export interface UserProject {
id: number;
name: string;
image?: string;
imageUrl?: string;
[key: string]: any;
}
export interface UserState {
name?: string;
avatar?: string;
@ -19,4 +28,6 @@ export interface UserState {
certification?: number;
role: RoleType;
permission: UserPermission[];
projects?: UserProject[];
currentProjectId?: number;
}

View file

@ -51,6 +51,7 @@ export interface ProjectItem extends BaseEntity {
roomArea: number;
rentArea: number;
useArea: number;
keepArea: number;
rentRate: number;
RoomAreaStr: string; // 列表显示的计租面积
RentAreaStr: string; // 列表显示的招商面积
@ -115,6 +116,17 @@ export type ProjectDetailRes = HttpResponse<{
project: ProjectItem;
}>;
// 楼栋标签响应类型
export interface BuildingTag {
buildingId: number;
projectName: string;
buildingName: string;
}
export type TopBuildingTagsRes = HttpResponse<{
tags: BuildingTag[];
}>;
// 创建房源参数
export interface CreateHouseParam {
id?: number; // 编辑时需要

View file

@ -15,12 +15,6 @@ function preloadRouteComponent(routeName: string): void {
'@/views/business/project/index.vue'
);
break;
case 'dashboard-workplace':
import(
/* webpackChunkName: "dashboard-workplace" */
'@/views/dashboard/workplace/index.vue'
);
break;
default:
// 未知路由,跳过预加载
break;
@ -63,7 +57,7 @@ export function smartPreload(
userRole: string,
currentRoute?: RouteLocationNormalized
): void {
const commonRoutes = ['dashboard-workplace'];
const commonRoutes: string[] = [];
// 根据用户角色预加载不同的路由
const roleBasedRoutes: Record<string, string[]> = {

View file

@ -7,20 +7,70 @@
unmount-on-close
@cancel="handleCancel"
>
项目详情
<div class="flex items-center justify-between">
<div>
<p>{{ detailInfo?.name }}</p>
<p class="mt-[10px]">
<a-tag :color="getTypeColor(detailInfo?.type)">{{
detailInfo?.typeName
}}</a-tag>
</p>
</div>
<div class="flex items-center gap-[10px]">
<p>{{ detailInfo?.updatedAt }}</p>
<div>
<a-button @click="handleEdit">
<template #icon>
<icon-edit />
</template>
{{ $t('edit') }}
</a-button>
</div>
<!-- <a-button type="primary" @click="handleDelete">删除</a-button> -->
</div>
</div>
<div class="mt-[10px] grid grid-cols-3 gap-[10px]">
<a-card title="面积信息">
<div class="ring-chart-container">
<Chart :options="ringChartOption" height="200px" />
</div>
</a-card>
<a-card title="房源统计">
<div class="grid grid-cols-3 gap-[10px] text-center">
<div>
<p class="text-[18px] font-bold">{{ detailInfo?.roomNum }}</p>
<p>房源总数</p>
</div>
<div>
<p class="text-[18px] font-bold">{{ detailInfo?.roomsRentNum }}</p>
<p>已出租</p>
</div>
<div>
<p class="text-[18px] font-bold">{{
detailInfo?.roomsUnRentNum
}}</p>
<p>空置</p>
</div>
</div>
</a-card>
<a-card title="租客信息"></a-card>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { watch, onMounted } from 'vue';
import { watch, onMounted, computed } from 'vue';
// import { Message } from '@arco-design/web-vue';
// import { projectTypeList } from '@/utils/constant';
import { ProjectItem } from '@/types/business';
import Chart from '@/components/chart/index.vue';
import type { EChartsOption } from 'echarts';
const props = defineProps<{
visible: boolean;
projectId: number | null;
detailInfo: ProjectItem | null;
projectTypeList: any[];
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
@ -36,6 +86,158 @@
const handleCancel = () => {
emit('update:visible', false);
};
const handleEdit = () => {
emit('update:visible', false);
};
const getTypeColor = (type: number | undefined) => {
if (!type) return '';
const color = props.projectTypeList.find((item) => item.id === type)?.color;
return color || '';
};
// 格式化面积数值,保留2位小数
const formatArea = (value: number): number => {
return Math.round(value * 100) / 100;
};
// 格式化面积显示,添加千分位分隔符
const formatAreaDisplay = (value: number): string => {
if (!value && value !== 0) return '0';
return formatArea(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
// 计算环形图数据
const ringChartData = computed(() => {
if (!props.detailInfo) {
return [];
}
const { roomArea = 0, rentArea = 0, keepArea = 0 } = props.detailInfo;
// 确保数值有效
const totalArea = Number(roomArea) || 0;
const rentedArea = Number(rentArea) || 0;
const reservedArea = Number(keepArea) || 0;
// 可租面积 = 计租面积 - 在租面积 - 保留面积
const availableArea = Math.max(0, totalArea - rentedArea - reservedArea);
// 构建图表数据,如果总面积大于0,显示所有三个部分(即使某些为0)
const data: Array<{ name: string; value: number }> = [];
// 如果总面积大于0,显示所有分类
if (totalArea > 0) {
// 在租面积
data.push({
name: '在租面积',
value: formatArea(rentedArea),
});
// 保留面积
data.push({
name: '保留面积',
value: formatArea(reservedArea),
});
// 可租面积
data.push({
name: '可租面积',
value: formatArea(availableArea),
});
} else {
// 如果所有数据都为0,显示占位数据
data.push({
name: '计租面积',
value: 0,
});
}
// 调试信息(开发时使用)
console.log('环形图数据:', {
roomArea: totalArea,
rentArea: rentedArea,
keepArea: reservedArea,
availableArea,
chartData: data,
});
return data;
});
// 环形图配置
const ringChartOption = computed<EChartsOption>(() => {
const data = ringChartData.value;
if (data.length === 0) {
return {
title: {
text: '暂无数据',
left: 'center',
top: 'middle',
textStyle: {
color: '#999',
fontSize: 14,
},
},
};
}
return {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const { name, value, percent } = params;
return `${name}<br/>${formatAreaDisplay(value)}㎡ (${percent}%)`;
},
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
itemWidth: 12,
itemHeight: 12,
textStyle: {
fontSize: 12,
},
},
series: [
{
name: '面积信息',
type: 'pie',
radius: ['40%', '70%'], // 环形图:内半径40%,外半径70%
center: ['60%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 4,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
formatter: '{b}\n{d}%',
fontSize: 12,
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
data: data,
},
],
};
});
onMounted(() => {});
</script>
@ -43,4 +245,33 @@
:deep(label.arco-form-item-label) {
width: 100% !important;
}
.ring-chart-container {
position: relative;
min-height: 300px;
.area-summary {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border-2);
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.summary-label {
color: var(--color-text-2);
font-size: 14px;
}
.summary-value {
color: var(--color-text-1);
font-size: 16px;
font-weight: 500;
}
}
}
}
</style>

View file

@ -41,8 +41,8 @@
<div class="slider">
<a-slider
v-model="rentRate"
:min="0"
:max="100"
:min="rentRateMin"
:max="rentRateMax"
range
:marks="marks"
></a-slider>
@ -51,12 +51,12 @@
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from 'vue';
import { getProjectCityList } from '@/api/business';
import { CommonCityItem } from '@/types/comm';
import { ref, watch, onMounted, computed } from 'vue';
import { getAllProjectTypeList } from '@/api/setting';
const props = defineProps<{
visible: boolean;
projectList?: any[]; // 项目列表数据,用于提取筛选选项
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
@ -66,30 +66,120 @@
typeIds: number[] | null;
cityIds: number[] | null;
rentRate: number | number[];
filteredList: any[]; // 筛选后的列表数据
}
): void;
}>();
// 保存的筛选状态
const savedFilterState = ref<{
projectTypeChecked: number[];
projectCityChecked: number[];
rentRate: number | number[];
} | null>(null);
const projectTypeChecked = ref<number[]>([0]);
const projectCityChecked = ref<number[]>([0]);
const rentRate = ref<number | number[]>(0);
const projectTypeList = [
{ text: '全部', value: 0 },
{ text: '写字楼', value: 1 },
{ text: '园区', value: 2 },
{ text: '商业综合体', value: 3 },
];
const marks = {
0: '0%',
100: '100%',
};
const projectCityList = ref<CommonCityItem[]>([]);
const getProjectCityListData = async () => {
const { code, data } = await getProjectCityList();
if (code === 200 && data) {
projectCityList.value = Array.isArray(data.cityList) ? data.cityList : [];
projectCityList.value.unshift({ id: 0, code: '0', name: '全部' });
const rentRate = ref<number | number[]>([0, 100]);
// 所有项目类型列表(从API获取)
const allProjectTypeList = ref<any[]>([]);
// 获取项目类型列表
const getAllProjectTypeListData = async () => {
try {
const { code, data } = await getAllProjectTypeList();
if (code === 200 && data) {
allProjectTypeList.value = data?.projectTypesList || [];
}
} catch (error) {
// console.error(error);
}
};
// 项目类型列表(从项目列表中提取实际存在的类型)
const projectTypeList = computed(() => {
const result = [{ text: '全部', value: 0 }];
// 如果项目列表为空,返回所有项目类型
if (!props.projectList || props.projectList.length === 0) {
if (allProjectTypeList.value.length > 0) {
allProjectTypeList.value.forEach((type) => {
result.push({
text: type.name,
value: type.id,
});
});
}
return result;
}
// 从项目列表中提取实际存在的类型ID
const existingTypeIds = new Set<number>();
props.projectList.forEach((project) => {
if (project.type) {
existingTypeIds.add(project.type);
}
});
// 从所有项目类型中筛选出实际存在的类型
allProjectTypeList.value.forEach((type) => {
if (existingTypeIds.has(type.id)) {
result.push({
text: type.name,
value: type.id,
});
}
});
return result;
});
// 城市列表(从项目列表中提取唯一值)
const projectCityList = computed(() => {
if (!props.projectList || props.projectList.length === 0) {
return [{ id: 0, code: '0', name: '全部' }];
}
const cityMap = new Map();
// 添加"全部"选项
cityMap.set(0, { id: 0, code: '0', name: '全部' });
// 从项目列表中提取城市
props.projectList.forEach((project) => {
if (project.cityId && project.city) {
if (!cityMap.has(project.cityId)) {
cityMap.set(project.cityId, {
id: project.cityId,
code: String(project.cityId),
name: project.city,
});
}
}
});
return Array.from(cityMap.values());
});
// 出租率范围
const rentRateMin = computed(() => {
if (!props.projectList || props.projectList.length === 0) return 0;
const rates = props.projectList
.map((project) => Number(project.rentRate))
.filter((rate) => !isNaN(rate) && rate >= 0);
return rates.length > 0 ? Math.min(...rates) : 0;
});
const rentRateMax = computed(() => {
if (!props.projectList || props.projectList.length === 0) return 100;
const rates = props.projectList
.map((project) => Number(project.rentRate))
.filter((rate) => !isNaN(rate) && rate >= 0);
return rates.length > 0 ? Math.max(...rates) : 100;
});
const marks = computed(() => {
return {
[rentRateMin.value]: `${rentRateMin.value}%`,
[rentRateMax.value]: `${rentRateMax.value}%`,
};
});
const handleCheckTypeIds = (id: number) => {
if (typeof id !== 'number') return;
if (id === 0) {
@ -129,26 +219,114 @@
const handleCancel = () => {
emit('update:visible', false);
};
// 筛选项目列表数据
const filterProjectList = () => {
if (!props.projectList || props.projectList.length === 0) return [];
let filtered = [...props.projectList];
// 筛选项目类型
const normalizedTypeIds = normalizeIds(projectTypeChecked.value);
if (normalizedTypeIds && normalizedTypeIds.length > 0) {
filtered = filtered.filter((project) =>
normalizedTypeIds.includes(project.type)
);
}
// 筛选城市
const normalizedCityIds = normalizeIds(projectCityChecked.value);
if (normalizedCityIds && normalizedCityIds.length > 0) {
filtered = filtered.filter((project) =>
normalizedCityIds.includes(project.cityId)
);
}
// 筛选出租率
const [minRate, maxRate] = Array.isArray(rentRate.value)
? rentRate.value
: [0, 100];
filtered = filtered.filter((project) => {
const rate = Number(project.rentRate);
return !isNaN(rate) && rate >= minRate && rate <= maxRate;
});
return filtered;
};
const normalizeIds = (ids: number[]) =>
ids.length === 1 && ids[0] === 0 ? null : ids;
const handleOk = () => {
// 保存当前筛选状态
savedFilterState.value = {
projectTypeChecked: [...projectTypeChecked.value],
projectCityChecked: [...projectCityChecked.value],
rentRate: Array.isArray(rentRate.value)
? [...rentRate.value]
: rentRate.value,
};
const filteredList = filterProjectList();
emit('confirm', {
typeIds: normalizeIds(projectTypeChecked.value),
cityIds: normalizeIds(projectCityChecked.value),
rentRate: rentRate.value,
filteredList, // 筛选后的列表数据
});
handleCancel();
};
// 初始化筛选表单
const initFilterForm = () => {
// 如果有保存的状态,恢复它
if (savedFilterState.value) {
projectTypeChecked.value = [...savedFilterState.value.projectTypeChecked];
projectCityChecked.value = [...savedFilterState.value.projectCityChecked];
rentRate.value = Array.isArray(savedFilterState.value.rentRate)
? [...savedFilterState.value.rentRate]
: savedFilterState.value.rentRate;
} else {
// 否则使用默认值
projectTypeChecked.value = [0];
projectCityChecked.value = [0];
const min = rentRateMin.value || 0;
const max = rentRateMax.value || 100;
rentRate.value = [min, max];
}
};
// 监听项目列表变化,更新出租率范围
watch(
() => props.projectList,
() => {
if (props.visible) {
const min = rentRateMin.value || 0;
const max = rentRateMax.value || 100;
// 如果当前出租率范围不在新的范围内,则更新
const currentRate = Array.isArray(rentRate.value)
? rentRate.value
: [0, 100];
if (currentRate[0] < min || currentRate[1] > max) {
rentRate.value = [min, max];
}
}
}
);
watch(
() => props.visible,
async (val) => {
if (val && projectCityList.value.length === 0) {
getProjectCityListData();
(val) => {
if (val) {
initFilterForm();
if (allProjectTypeList.value.length === 0) {
getAllProjectTypeListData();
}
}
},
{ immediate: true }
);
onMounted(() => {});
onMounted(() => {
getAllProjectTypeListData();
});
</script>
<style lang="less" scoped>

View file

@ -47,7 +47,7 @@
@page-size-change="onPageSizeChange"
>
<template #type="{ record }">
<a-tag checkable :color="record.color">
<a-tag checkable :color="getTypeColor(record.type)">
{{ record.typeName }}
</a-tag>
</template>
@ -71,9 +71,20 @@
<a-button @click="handleWatchHouse(record.id)">
查看房源
</a-button>
<a-button status="danger" @click="handleDel(record.id)">
删除项目
</a-button>
<a-popconfirm
:ok-text="$t('cancel')"
:cancel-text="$t('delete')"
:ok-button-props="{ type: 'outline' }"
:cancel-button-props="{
type: 'primary',
status: 'danger',
}"
content="确定删除该项目?"
type="error"
@cancel="handleDel(record?.id)"
>
<a-button status="danger">删除项目</a-button>
</a-popconfirm>
</a-space>
</template>
</a-popover>
@ -91,9 +102,11 @@
v-model:visible="showProjectInfoDrawer"
:project-id="currentProjectId"
:detail-info="currentProjectInfo"
:project-type-list="projectTypeList"
/>
<projectListFilterDrawer
v-model:visible="showListFilterDrawer"
:project-list="projectList"
@confirm="handleFilterConfirm"
/>
</div>
@ -106,12 +119,13 @@
import type { TableColumnData } from '@arco-design/web-vue';
import useDataTable from '@/composables/useDataTable';
import { ProjectItem } from '@/types/business';
import { getProjectList, getProjectDetail } from '@/api/business';
import { getProjectList, getProjectDetail, delProject } from '@/api/business';
import { getAllProjectTypeList } from '@/api/setting';
import { ProjectPaginationParams } from '@/types/comm';
import projectFormDrawer from './drawer/project-form-drawer.vue';
import projectInfoDrawer from './drawer/project-info-drawer.vue';
import projectListFilterDrawer from './drawer/project-list-filter-drawer.vue';
import { Message } from '@arco-design/web-vue';
const { t } = useI18n();
const { loading } = useLoading(false);
const showDrawer = ref<boolean>(false);
@ -155,7 +169,13 @@
},
{ title: '房源数量', dataIndex: 'roomNum', key: 'roomNum' },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt' },
{ title: '操作', key: 'operate', slotName: 'operate', width: 200 },
{
title: '操作',
key: 'operate',
align: 'center',
slotName: 'operate',
width: 300,
},
];
const queryParams = ref<ProjectPaginationParams>({
page: current.value,
@ -203,15 +223,36 @@
// 处理错误
}
};
const handleFilterConfirm = ({ typeIds, cityIds, rentRate }) => {
queryParams.value.type = typeIds;
queryParams.value.province_ids = cityIds;
queryParams.value.rent_rate_min = rentRate[0];
queryParams.value.rent_rate_max = rentRate[1];
getProjectListData();
const handleFilterConfirm = (payload: {
typeIds: number[] | null;
cityIds: number[] | null;
rentRate: number | number[];
filteredList: any[]; // 筛选后的列表数据
}) => {
// 更新查询参数(用于后端筛选)
queryParams.value.type = payload.typeIds;
queryParams.value.province_ids = payload.cityIds;
const rentRateArray = Array.isArray(payload.rentRate)
? payload.rentRate
: [0, 100];
queryParams.value.rent_rate_min = rentRateArray[0];
queryParams.value.rent_rate_max = rentRateArray[1];
// 使用筛选后的列表数据更新表格
if (payload.filteredList) {
setData(payload.filteredList);
setTotal(payload.filteredList.length);
} else {
// 如果没有筛选数据,重新加载列表数据
getProjectListData();
}
};
const handleDel = (id: number) => {
// TODO: 实现删除功能
const handleDel = async (id: number) => {
const { code, message } = await delProject(id);
if (code === 200) {
Message.success(message);
getProjectListData();
}
};
// 分页事件处理
const onPageChange = (page: number) => {
@ -244,8 +285,20 @@
const handleFilter = () => {
showListFilterDrawer.value = true;
};
onMounted(() => {
setColumns(columns);
getProjectListData();
const projectTypeList = ref<any[]>([]);
const getAllProjectTypeListData = async () => {
const { code, data } = await getAllProjectTypeList();
if (code === 200 && data) {
projectTypeList.value = data?.projectTypesList;
}
};
const getTypeColor = (type: number) => {
const color = projectTypeList.value.find((item) => item.id === type)?.color;
return color || '';
};
onMounted(async () => {
await setColumns(columns);
await getAllProjectTypeListData();
await getProjectListData();
});
</script>

View file

@ -0,0 +1,272 @@
<template>
<div
class="cross-year-timeline-container"
:style="{
position: 'absolute',
left: '0',
width: `${yearRange.length * 200}px`,
height: '40px',
zIndex: 2,
}"
>
<!-- 年份分隔线 -->
<template v-for="(year, index) in yearRange" :key="`divider-${year}`">
<div
v-if="index < yearRange.length - 1"
class="year-divider"
:style="{
position: 'absolute',
left: `${(index + 1) * 200}px`,
top: '0',
bottom: '0',
width: '1px',
backgroundColor: '#e5e6eb',
zIndex: 1,
}"
></div>
</template>
<!-- 连续时间线(跨列显示) -->
<div
class="timeline-bar"
:style="{
position: 'absolute',
left: '0',
right: '0',
top: '0',
bottom: '0',
}"
>
<!-- 空置状态 -->
<div
v-for="(segment, index) in allSegments"
:key="`segment-${index}`"
class="timeline-segment"
:class="segment.type"
:style="{
position: 'absolute',
left: `${segment.startPercentage}%`,
width: `${segment.percentage}%`,
height: '100%',
backgroundColor:
segment.type === 'vacant'
? '#f2f3f5'
: segment.type === 'lease'
? '#00b42a'
: 'transparent',
backgroundImage:
segment.type === 'unarranged'
? 'repeating-linear-gradient(45deg, #f2f3f5, #f2f3f5 4px, #d9d9d9 4px, #d9d9d9 8px)'
: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
}"
>
<span
v-if="segment.label && segment.percentage > 5"
class="segment-label"
:style="{
fontSize: '12px',
color: segment.type === 'lease' ? '#fff' : '#86909c',
fontWeight: segment.type === 'lease' ? '500' : 'normal',
padding: '0 4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}"
>
{{ segment.label }}
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
interface YearData {
year: string;
allDays: number;
allData: Array<{
companyName: string;
days: number;
type: number; // 1: 在租, 其他可能是空置
}>;
}
interface Props {
yearsData?: YearData[];
yearRange: number[];
}
const props = withDefaults(defineProps<Props>(), {
yearsData: () => [],
yearRange: () => [],
});
// 计算总天数(5年)
const totalDays = computed(() => {
// 如果数据为空,使用默认的5年天数
if (!props.yearsData || props.yearsData.length === 0) {
return 365 * props.yearRange.length;
}
// 确保所有年份都有数据,缺失的年份使用365天
let total = 0;
props.yearRange.forEach((year) => {
const yearData = props.yearsData?.find((d) => d.year === String(year));
total += yearData?.allDays || 365;
});
return total;
});
// 合并所有年份的数据,创建连续的时间段
interface TimelineSegment {
type: 'vacant' | 'lease' | 'unarranged';
percentage: number;
startPercentage: number; // 在整个时间线中的起始位置百分比
days: number;
label?: string;
startDay: number; // 在整个5年时间线中的起始天数
endDay: number; // 在整个5年时间线中的结束天数
}
const allSegments = computed(() => {
if (!props.yearsData || props.yearsData.length === 0) {
// 如果没有数据,全部显示为未安排
return [
{
type: 'unarranged' as const,
percentage: 100,
startPercentage: 0,
days: totalDays.value,
startDay: 0,
endDay: totalDays.value,
},
];
}
const segments: TimelineSegment[] = [];
let globalOffset = 0; // 在整个5年时间线中的累计天数
// 按年份顺序处理数据,确保年份顺序正确
const sortedYearsData = [...props.yearsData].sort((a, b) => {
return Number(a.year) - Number(b.year);
});
// 创建年份数据映射
const yearDataMap = new Map<string, YearData>();
sortedYearsData.forEach((data) => {
yearDataMap.set(data.year, data);
});
// 按年份范围顺序处理
props.yearRange.forEach((year) => {
const yearStr = String(year);
const yearData = yearDataMap.get(yearStr);
const yearDays = yearData?.allDays || 365;
const yearStartDay = globalOffset;
const yearEndDay = globalOffset + yearDays;
if (!yearData || !yearData.allData || yearData.allData.length === 0) {
// 如果该年份没有数据,全部显示为未安排
segments.push({
type: 'unarranged',
percentage: (yearDays / totalDays.value) * 100,
startPercentage: (yearStartDay / totalDays.value) * 100,
days: yearDays,
startDay: yearStartDay,
endDay: yearEndDay,
});
globalOffset = yearEndDay;
return;
}
// 收集该年份的所有时间段,按顺序排列
const yearSegments: Array<{
type: 'vacant' | 'lease';
days: number;
label: string;
}> = [];
yearData.allData.forEach((item) => {
if (item.type === 1 && item.days > 0) {
// 在租
yearSegments.push({
type: 'lease',
days: item.days,
label: item.companyName || '',
});
} else if (item.type !== 1 && item.days > 0) {
// 空置
yearSegments.push({
type: 'vacant',
days: item.days,
label: `空置${item.days}天`,
});
}
});
// 计算已安排的天数
const arrangedDays = yearSegments.reduce((sum, seg) => sum + seg.days, 0);
const unarrangedDays = yearDays - arrangedDays;
// 添加已安排的时间段(连续显示)
let offset = 0;
yearSegments.forEach((seg) => {
const startDay = yearStartDay + offset;
const endDay = startDay + seg.days;
segments.push({
type: seg.type,
percentage: (seg.days / totalDays.value) * 100,
startPercentage: (startDay / totalDays.value) * 100,
days: seg.days,
label: seg.label,
startDay,
endDay,
});
offset += seg.days;
});
// 添加未安排的时间段
if (unarrangedDays > 0) {
const startDay = yearStartDay + offset;
segments.push({
type: 'unarranged',
percentage: (unarrangedDays / totalDays.value) * 100,
startPercentage: (startDay / totalDays.value) * 100,
days: unarrangedDays,
startDay,
endDay: yearEndDay,
});
}
globalOffset = yearEndDay;
});
return segments;
});
</script>
<style scoped>
.cross-year-timeline-container {
position: relative;
overflow: visible;
}
.timeline-bar {
border-radius: 2px;
overflow: visible;
}
.timeline-segment {
transition: all 0.3s;
min-width: 0;
}
.segment-label {
user-select: none;
}
</style>

View file

@ -0,0 +1,216 @@
<template>
<div
class="year-timeline-container"
:style="{ position: 'relative', width: '100%', height: '40px' }"
>
<div
class="timeline-bar"
:style="{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
}"
>
<!-- 空置状态 -->
<div
v-for="(vacant, index) in vacantSegments"
:key="`vacant-${index}`"
class="timeline-segment vacant-segment"
:style="{
width: `${vacant.percentage}%`,
height: '100%',
backgroundColor: '#f2f3f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
marginRight: '2px',
}"
>
<span
v-if="vacant.days > 0"
class="segment-label"
:style="{ fontSize: '12px', color: '#86909c' }"
>
空置{{ vacant.days }}天
</span>
</div>
<!-- 在租状态 -->
<div
v-for="(lease, index) in leaseSegments"
:key="`lease-${index}`"
class="timeline-segment lease-segment"
:style="{
width: `${lease.percentage}%`,
height: '100%',
backgroundColor: '#00b42a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
marginRight: '2px',
}"
>
<span
v-if="lease.companyName"
class="segment-label"
:style="{
fontSize: '12px',
color: '#fff',
fontWeight: '500',
padding: '0 4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}"
>
{{ lease.companyName }}
</span>
</div>
<!-- 未安排状态(斜线) -->
<div
v-if="unarrangedPercentage > 0"
class="timeline-segment unarranged-segment"
:style="{
width: `${unarrangedPercentage}%`,
height: '100%',
backgroundImage:
'repeating-linear-gradient(45deg, #f2f3f5, #f2f3f5 4px, #d9d9d9 4px, #d9d9d9 8px)',
position: 'relative',
marginRight: '2px',
}"
></div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
interface YearData {
year: string;
allDays: number;
allData: Array<{
companyName: string;
days: number;
type: number; // 1: 在租, 其他可能是空置
}>;
}
interface Props {
yearData?: YearData;
}
const props = withDefaults(defineProps<Props>(), {
yearData: undefined,
});
// 计算各个状态的百分比和显示信息
// 根据需求:进度条仅显示空置、在租(有合同),未有安排的用斜线代替
const vacantSegments = computed(() => {
if (
!props.yearData ||
!props.yearData.allData ||
props.yearData.allData.length === 0
) {
// 如果没有数据,不显示空置段,全部显示为未安排(斜线)
return [];
}
const segments: Array<{ percentage: number; days: number }> = [];
const totalDays = props.yearData.allDays || 365;
// 计算在租天数
const leaseDays = props.yearData.allData
.filter((item) => item.type === 1)
.reduce((sum, item) => sum + (item.days || 0), 0);
// 计算其他已安排天数(可能是空置记录,type !== 1)
const otherArrangedDays = props.yearData.allData
.filter((item) => item.type !== 1)
.reduce((sum, item) => sum + (item.days || 0), 0);
// 总已安排天数
const totalArrangedDays = leaseDays + otherArrangedDays;
// 如果总已安排天数小于总天数,剩余部分可能是空置(如果是在租期间的空置)
// 但根据需求,只有明确标记为空置的才显示,否则显示为未安排
if (otherArrangedDays > 0) {
// 如果有其他类型的数据(非在租),可能是空置记录
segments.push({
percentage: (otherArrangedDays / totalDays) * 100,
days: otherArrangedDays,
});
} else if (totalArrangedDays < totalDays && leaseDays > 0) {
// 如果有在租数据,但总天数大于已安排天数,剩余部分可能是空置
// 这里需要根据实际业务逻辑判断,暂时不显示为空置,显示为未安排
}
return segments;
});
const leaseSegments = computed(() => {
if (!props.yearData || !props.yearData.allData) return [];
const totalDays = props.yearData.allDays || 365;
const segments: Array<{
percentage: number;
companyName: string;
days: number;
}> = [];
// type === 1 表示在租(有合同)
props.yearData.allData?.forEach((item) => {
if (item.type === 1 && item.days > 0) {
segments.push({
percentage: (item.days / totalDays) * 100,
companyName: item.companyName || '',
days: item.days,
});
}
});
return segments;
});
const unarrangedPercentage = computed(() => {
if (!props.yearData) return 100; // 如果没有数据,全部显示为未安排
const totalDays = props.yearData.allDays || 365;
// 只计算已安排的天数(空置 + 在租)
let arrangedDays = 0;
if (props.yearData.allData && props.yearData.allData.length > 0) {
props.yearData.allData.forEach((item) => {
arrangedDays += item.days || 0;
});
}
const unarrangedDays = totalDays - arrangedDays;
return unarrangedDays > 0 ? (unarrangedDays / totalDays) * 100 : 0;
});
</script>
<style scoped>
.year-timeline-container {
min-width: 200px;
}
.timeline-bar {
border-radius: 2px;
overflow: hidden;
}
.timeline-segment {
transition: all 0.3s;
}
.segment-label {
user-select: none;
}
</style>

View file

@ -0,0 +1,413 @@
<template>
<div class="business_project_wrap">
<Breadcrumb :items="['menu.business', 'menu.business.house-atlas']" />
<a-card class="general-card full_height">
<template #title>
<div class="card-title_between">
<p class="title">{{ t('menu.business.house-atlas') }}</p>
<div class="flex_center_between">
<a-space>
<a-input-search
v-model="queryParams.keyword"
:style="{ width: '320px' }"
:placeholder="t('please.enter', { placeholder: t('content') })"
@search="handleSearch"
/>
<a-button @click="handleFilter">
<template #icon>
<icon-filter />
</template>
{{ t('table.filter') }}
</a-button>
<a-button>
<template #icon>
<icon-export />
</template>
{{ t('table.export') }}
</a-button>
<a-button>
<template #icon>
<icon-download />
</template>
{{ t('table.import') }}
</a-button>
</a-space>
</div>
</div>
</template>
<!-- 使用新的 DataTable 组件 -->
<div class="table-container" :style="{ position: 'relative' }">
<DataTable
:data="renderData"
:columns="projectColumn"
:loading="loading"
:total="total"
:current="current"
:page-size="pageSize"
@page-change="onPageChange"
@page-size-change="onPageSizeChange"
>
<template #status="{ record }">
<a-tag :color="getTagsColor(record?.status)">
{{ record?.statusStr }}</a-tag
>
</template>
<template
v-for="(year, index) in yearRange"
:key="year"
#[`year-${year}`]="{ record }"
>
<div class="year-cell" :class="{ 'first-year': index === 0 }">
<!-- 时间线只在第一个列中渲染,使用绝对定位跨越所有列 -->
<div
v-if="index === 0"
class="timeline-row-wrapper"
:style="{ width: `${timelineWidth}px` }"
>
<CrossYearTimeline
:years-data="getAllYearsData(record)"
:year-range="yearRange"
/>
</div>
</div>
</template>
<template #operate="{ record }">
<a-space>
<a-button @click="handleDetail(record.id)">查看</a-button>
<a-popover trigger="click" position="bottom">
<a-button>{{ $t('more') }}</a-button>
<template #content>
<a-space direction="vertical">
<a-button @click="handleEditProject(record.id)">
编辑房源
</a-button>
<a-popconfirm
:ok-text="$t('cancel')"
:cancel-text="$t('delete')"
:ok-button-props="{ type: 'outline' }"
:cancel-button-props="{
type: 'primary',
status: 'danger',
}"
content="确定删除该房源?"
type="error"
@cancel="handleDel(record?.id)"
>
<a-button status="danger">删除房源</a-button>
</a-popconfirm>
</a-space>
</template>
</a-popover>
</a-space>
</template>
</DataTable>
</div>
</a-card>
<houseFormDrawer
v-model:visible="showDrawer"
:is-edit="isEdit"
:record="currentRecordInfo"
@submitted="getHouseListData"
/>
<houseInfoDrawer
v-model:visible="showProjectInfoDrawer"
:project-id="currentProjectId"
:detail-info="currentRecordInfo"
/>
<houseListFilterDrawer
v-model:visible="showListFilterDrawer"
:room-list="projectList"
:project-name="currentProjectName"
@confirm="handleFilterConfirm"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue';
import useLoading from '@/hooks/loading';
import { useI18n } from 'vue-i18n';
import type { TableColumnData } from '@arco-design/web-vue';
import useDataTable from '@/composables/useDataTable';
import { getRoomsGraphList, getRoom, delRoom } from '@/api/business';
import { ProjectPaginationParams } from '@/types/comm';
import houseFormDrawer from '../drawer/house-form-drawer.vue';
import houseInfoDrawer from '../drawer/house-info-drawer.vue';
import houseListFilterDrawer from '../drawer/house-list-filter-drawer.vue';
import { Message } from '@arco-design/web-vue';
import CrossYearTimeline from './components/cross-year-timeline.vue';
const { t } = useI18n();
const { loading } = useLoading(false);
const showDrawer = ref<boolean>(false);
const showProjectInfoDrawer = ref<boolean>(false);
const showListFilterDrawer = ref<boolean>(false);
const currentProjectId = ref<number | null>(null);
const isLoadingEdit = ref<boolean>(false);
const isLoadingDetail = ref<boolean>(false);
const isEdit = ref<boolean>(false);
const currentRecordInfo = ref<any | null>(null);
const projectList = ref<any[]>([]);
const currentProjectName = ref<string>(''); // 当前项目名称
const {
data: renderData,
columns: projectColumn,
total,
current,
pageSize,
setData,
setColumns,
setTotal,
setCurrent,
setPageSize,
setLoading,
} = useDataTable({
initialPage: 1,
initialPageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
});
// 计算5年年份范围(当前年的前1年到后3年)
const currentYear = new Date().getFullYear();
const yearRange = computed(() => {
const years: number[] = [];
for (let i = -1; i <= 3; i++) {
years.push(currentYear + i);
}
return years;
});
// 计算时间线总宽度
const timelineWidth = computed(() => {
return yearRange.value.length * 200; // 每个年份列200px
});
// 通用空值处理函数
const renderEmpty = (value: any) => value ?? '-';
// 获取所有年份的数据
const getAllYearsData = (record: any) => {
if (!record?.yearsData || !Array.isArray(record.yearsData)) {
return [];
}
// 只返回在年份范围内的数据
return record.yearsData.filter((item: any) => {
const year = Number(item.year);
return yearRange.value.includes(year);
});
};
// 动态生成列配置
const generateColumns = (): TableColumnData[] => {
const baseColumns: TableColumnData[] = [
{
title: '房源名称',
dataIndex: 'name',
key: 'name',
width: 150,
render: ({ record }) => renderEmpty(record.name),
},
{
title: '所属',
dataIndex: 'projectName',
key: 'projectName',
width: 200,
render: ({ record }) => {
const buildingName = record.buildingName
? ` ${record.buildingName}座`
: '';
const floorName = record.floorName ? `・${record.floorName}层` : '';
return renderEmpty(record.projectName) + buildingName + floorName;
},
},
{
title: '面积(㎡)',
dataIndex: 'rentArea',
key: 'rentArea',
width: 100,
render: ({ record }) => renderEmpty(record.rentArea),
},
];
// 添加年份列(每个年份独立一列,各自显示标题)
const yearColumns: TableColumnData[] = yearRange.value.map((year) => ({
title: String(year),
key: `year-${year}`,
slotName: `year-${year}`,
width: 200,
align: 'center' as const,
}));
// 添加状态和操作列
const otherColumns: TableColumnData[] = [
{
title: '当前状态',
key: 'statusStr',
slotName: 'status',
width: 120,
},
{
title: '操作',
key: 'operate',
slotName: 'operate',
width: 200,
},
];
return [...baseColumns, ...yearColumns, ...otherColumns];
};
// 列配置
const columns = ref<TableColumnData[]>(generateColumns());
const queryParams = ref<ProjectPaginationParams>({
page: current.value,
page_size: pageSize.value,
keyword: '',
});
const getHouseListData = async () => {
setLoading(true);
const { code, data } = await getRoomsGraphList(queryParams.value);
if (code === 200 && data) {
const { list, page } = data as {
list?: any[];
page?: { total?: number };
};
projectList.value = Array.isArray(list) ? list : [];
// 提取项目名称(从第一条数据中获取,假设所有房源属于同一项目)
if (projectList.value.length > 0 && projectList.value[0].projectName) {
currentProjectName.value = projectList.value[0].projectName;
}
setData(projectList.value);
setTotal(page?.total ? page.total : 0);
}
setLoading(false);
};
const getRoomData = async (id: number, edit: boolean) => {
if (currentRecordInfo.value && currentRecordInfo.value?.id === id && !edit)
return;
if (edit) {
isLoadingEdit.value = true;
} else {
isLoadingDetail.value = true;
}
const { code, data } = await getRoom(id);
if (code === 200 && data) {
currentRecordInfo.value = (data as { room?: any }).room || null;
}
isLoadingEdit.value = false;
isLoadingDetail.value = false;
};
const handleFilterConfirm = (payload: {
buildingIds: number[] | null;
floorIds: number[] | null;
statusIds: number[] | null;
rentArea: number[];
tagIds: number[] | null;
filteredList: any[]; // 筛选后的列表数据
}) => {
// 更新查询参数(用于后端筛选)
(queryParams.value as any).building_ids = payload.buildingIds;
(queryParams.value as any).floor_ids = payload.floorIds;
(queryParams.value as any).status_ids = payload.statusIds;
(queryParams.value as any).rent_area_min = payload.rentArea?.[0];
(queryParams.value as any).rent_area_max = payload.rentArea?.[1];
(queryParams.value as any).tag_ids = payload.tagIds;
// 使用筛选后的列表数据更新表格
if (payload.filteredList) {
setData(payload.filteredList);
setTotal(payload.filteredList.length);
} else {
// 如果没有筛选数据,重新加载列表数据
getHouseListData();
}
};
const handleDel = async (id: number) => {
const { code, message } = await delRoom(id);
if (code === 200) {
Message.success(message || '删除成功');
// 重新加载列表数据
await getHouseListData();
} else {
Message.error(message || '删除失败');
}
};
// 分页事件处理
const onPageChange = (page: number) => {
setCurrent(page);
current.value = page;
};
const onPageSizeChange = (size: number) => {
setPageSize(size);
pageSize.value = size;
};
const handleEditProject = async (id: number) => {
if (isLoadingEdit.value) return;
isEdit.value = true;
await getRoomData(id, true);
showDrawer.value = true;
};
const handleDetail = async (id: number) => {
if (isLoadingDetail.value) return;
await getRoomData(id, false);
showProjectInfoDrawer.value = true;
};
const handleSearch = () => {
getHouseListData();
};
const handleFilter = () => {
showListFilterDrawer.value = true;
};
const getTagsColor = (status) => {
const colorMap = {
1: 'orangered',
2: 'orange',
3: 'red',
4: 'green',
};
return colorMap[status];
};
onMounted(() => {
setColumns(columns.value);
getHouseListData();
});
</script>
<style scoped lang="less">
.table-container {
position: relative;
}
.year-cell {
position: relative;
padding: 8px;
overflow: visible;
height: 56px;
min-height: 56px;
&.first-year {
overflow: visible;
}
}
.timeline-row-wrapper {
position: absolute;
left: 0;
top: 0;
height: 100%;
z-index: 2;
pointer-events: none;
padding: 8px;
box-sizing: border-box;
}
// 确保表格行可以包含绝对定位的元素
:deep(.arco-table-tr) {
position: relative;
}
:deep(.arco-table-td) {
position: relative;
overflow: visible;
}
</style>

View file

@ -91,7 +91,7 @@
type="error"
@cancel="handleDel(record?.id)"
>
<a-button status="danger">删除项目</a-button>
<a-button status="danger">删除房源</a-button>
</a-popconfirm>
</a-space>
</template>
@ -132,6 +132,7 @@
import houseInfoDrawer from '../drawer/house-info-drawer.vue';
import houseListFilterDrawer from '../drawer/house-list-filter-drawer.vue';
import { Message } from '@arco-design/web-vue';
import { PRICE_UNIT_MAP } from '@/constants';
const { t } = useI18n();
const { loading } = useLoading(false);
const showDrawer = ref<boolean>(false);
@ -201,7 +202,11 @@
title: '租金(实时)',
dataIndex: 'price',
key: 'price',
render: ({ record }) => (Number(record.price) <= 0 ? '-' : record?.price),
render: ({ record }) => {
if (Number(record.price) <= 0) return '-';
const unit = PRICE_UNIT_MAP.get(record?.priceType);
return `${record?.price} ${unit}`;
},
},
{ title: '标签', dataIndex: 'tags', key: 'tags', slotName: 'tags' },
{

View file

@ -0,0 +1,595 @@
<template>
<div class="house-view-content px-[24px]">
<!-- 统计数据区域 -->
<div class="flex items-center justify-between bg-white pb-[5px] w-full">
<div class="flex items-center bg-[#f2f3f5] flex-1">
<div class="flex items-center justify-center flex-1">
<div class="flex flex-col items-center justify-center flex-1">
<span class="text-sm text-[#86909c]">房源</span>
<span class="text-[28px] text-[#121111] mt-[8px]"
>{{ props.statisticsData.roomNum || 0 }}套</span
>
</div>
<div class="flex flex-col items-center justify-center flex-1">
<span class="text-sm text-[#86909c]">出租率</span>
<span class="text-[28px] text-[#121111] mt-[8px]"
>{{ props.statisticsData.rentRateStr || '0.00' }}%</span
>
</div>
<div class="flex flex-col items-center justify-center flex-1">
<span class="text-sm text-[#86909c]">累计出租率</span>
<span class="text-[28px] text-[#121111] mt-[8px]"
>{{ props.statisticsData.totalRentRateStr || '0.00' }}%</span
>
</div>
<div class="flex flex-col items-center justify-center flex-1">
<span class="text-sm text-[#86909c]">计租面积</span>
<span class="text-[28px] text-[#121111] mt-[8px]">{{
props.statisticsData.rentAreaStr || '0.00㎡'
}}</span>
</div>
</div>
<div class="w-[300px] h-[150px] ml-[50px] flex-shrink-0 relative">
<Chart :options="chartOption" />
</div>
</div>
</div>
<!-- 筛选区域 -->
<div class="filter-section mt-[24px]">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- 状态筛选 -->
<div class="flex items-center">
<div
v-for="status in statusOptions"
:key="status.id"
class="flex items-center mr-[20px] last:mr-0 rounded cursor-pointer transition-all border border-transparent hover:bg-[#f2f3f5]"
:class="{
'bg-[#e8f3ff] border-[#165dff]': selectedStatus.includes(
status.id
),
}"
@click="toggleStatus(status.id)"
>
<span
class="w-[20px] h-[20px] rounded-full"
:style="{ backgroundColor: status.color }"
></span>
<span class="text-[14px] ml-[8px] text-[#333]">{{
status.text
}}</span>
</div>
</div>
</div>
<div class="flex items-center">
<a-input-search
v-model="searchKeyword"
placeholder="请输入内容"
style="width: 200px"
@search="handleSearch"
/>
<a-button class="mx-[8px]" @click="showFilterDrawer = true">
<template #icon>
<icon-filter />
</template>
筛选
</a-button>
<a-button-group>
<a-button
:type="viewMode === 'grid' ? 'primary' : 'outline'"
@click="viewMode = 'grid'"
>
<template #icon>
<icon-apps />
</template>
</a-button>
<a-button
:type="viewMode === 'list' ? 'primary' : 'outline'"
@click="viewMode = 'list'"
>
<template #icon>
<icon-list />
</template>
</a-button>
</a-button-group>
<a-button type="primary" class="ml-[8px]" @click="handleAddHouse">
<template #icon>
<icon-plus />
</template>
新建房源
</a-button>
</div>
</div>
</div>
<!-- 房源网格区域 -->
<div class="room-grid-section">
<div v-for="floor in floorList" :key="floor.floorId">
<div class="flex items-center my-[10px]">
<span class="text-base font-semibold text-[#1d2129]"
>{{ floor.floorName }}·</span
>
<span class="text-sm text-[#86909c]">{{ floor.totalArea }}m²</span>
</div>
<div>
<div
v-for="(row, rowIndex) in floor.rows"
:key="rowIndex"
class="grid grid-cols-8 gap-x-[8px] mb-2"
>
<div
v-for="(cell, cellIndex) in row"
:key="cellIndex"
class="h-[74px] border-2 rounded p-2 relative cursor-pointer transition-all hover:shadow-md p-[5px]"
:class="getRoomCellClass(cell)"
:style="getRoomCellStyle(cell)"
@click="handleRoomClick(cell.room)"
>
<template v-if="cell.room">
<div class="h-full flex flex-col">
<div
class="text-sm font-semibold text-[#1d2129] overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ cell.room.name || cell.room.roomName }}
</div>
<div class="text-xs text-[#86909c]"
>{{ cell.room.rentArea || cell.room.area }}m²</div
>
<div class="text-xs text-[#4e5969] flex-1">{{
getRoomInfo(cell.room)
}}</div>
<div
v-if="cell.room.status === 4"
class="absolute top-1 right-1 px-1.5 py-0.5 bg-[#00b42a] text-white rounded text-[10px]"
>
{{ cell.room.statusStr || '在租' }}
</div>
<div
v-if="cell.room.isExpired"
class="absolute top-1 right-1 px-1.5 py-0.5 bg-[#f53f3f] text-white rounded text-[10px]"
>
逾
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<!-- 筛选弹窗 -->
<HouseViewFilterDrawer
v-model:visible="showFilterDrawer"
:room-list="props.roomList"
:project-name="props.projectName"
@confirm="handleFilterConfirm"
/>
<!-- 房源详情弹窗 -->
<HouseInfoDrawer
v-model:visible="showRoomDetailDrawer"
:project-id="currentProjectId"
:detail-info="roomDetailInfo"
/>
<!-- 新建/编辑房源弹窗 -->
<HouseFormDrawer
v-model:visible="showHouseFormDrawer"
:is-edit="false"
@submitted="handleHouseFormSubmitted"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { Message } from '@arco-design/web-vue';
import Chart from '@/components/chart/index.vue';
import HouseViewFilterDrawer from './house-view-filter-drawer.vue';
import HouseInfoDrawer from '@/views/business/space/drawer/house-info-drawer.vue';
import HouseFormDrawer from '@/views/business/space/drawer/house-form-drawer.vue';
import { getRoom } from '@/api/business';
import { useUserStore } from '@/store';
interface Props {
statisticsData: any;
roomList: any[];
loading?: boolean;
projectName?: string;
}
const props = withDefaults(defineProps<Props>(), {
statisticsData: () => ({}),
roomList: () => [],
loading: false,
projectName: '',
});
const userStore = useUserStore();
const showRoomDetailDrawer = ref(false);
const roomDetailInfo = ref<any>(null);
const showHouseFormDrawer = ref(false);
const currentProjectId = computed(() => userStore.currentProjectId ?? null);
const emit = defineEmits<{
search: [keyword: string];
filterChange: [
filters: {
buildingIds?: number[] | null;
floorIds?: number[] | null;
status?: number[];
rentArea?: number[];
vacantDays?: number[];
contractStatuses?: string[] | null;
}
];
refresh: [];
}>();
const searchKeyword = ref('');
const selectedStatus = ref<number[]>([]);
const viewMode = ref<'grid' | 'list'>('grid');
const showFilterDrawer = ref(false);
// 房源状态选项(6个状态)
const statusOptions = [
{ id: 4, text: '在租', color: '#00b42a' },
{ id: 1, text: '招商', color: '#165dff' },
{ id: 2, text: '保留', color: '#ff7d00' },
{ id: 5, text: '即将到期', color: '#f53f3f' },
{ id: 6, text: '到期', color: '#f53f3f' },
{ id: 3, text: '下架', color: '#86909c' },
];
// 切换状态筛选
const toggleStatus = (statusId: number) => {
const index = selectedStatus.value.indexOf(statusId);
if (index > -1) {
selectedStatus.value.splice(index, 1);
} else {
selectedStatus.value.push(statusId);
}
emit('filterChange', { status: selectedStatus.value });
};
// 环形图配置
const chartOption = computed(() => {
const data = [
{
name: '下架面积',
value: parseFloat(
props.statisticsData.removalAreaStr?.replace('㎡', '') || '0'
),
},
{
name: '保留面积',
value: parseFloat(
props.statisticsData.keepAreaStr?.replace('㎡', '') || '0'
),
},
{
name: '可租面积',
value: parseFloat(
props.statisticsData.rentAreaStr?.replace('㎡', '') || '0'
),
},
{
name: '已租面积',
value: parseFloat(
props.statisticsData.useAreaStr?.replace('㎡', '') || '0'
),
},
].filter((item) => item.value > 0);
return {
tooltip: {
trigger: 'item',
formatter: '{b}: {c}㎡ ({d}%)',
confine: true, // 将 tooltip 限制在图表区域内
appendToBody: true, // 将 tooltip 追加到 body,避免被遮挡
extraCssText: 'z-index: 9999;', // 确保 tooltip 在最上层
},
legend: {
orient: 'vertical',
right: 0,
top: 'middle',
itemWidth: 10,
itemHeight: 10,
itemGap: 8,
textStyle: {
fontSize: 11,
color: '#4e5969',
},
formatter: (name: string) => {
// 限制图例文本长度,避免过长
return name.length > 6 ? name.substring(0, 6) + '...' : name;
},
},
grid: {
left: 0,
right: '25%', // 为图例留出空间
top: 10,
bottom: 10,
},
series: [
{
name: '面积分布',
type: 'pie',
radius: ['35%', '65%'], // 稍微缩小半径,为图例留出空间
center: ['40%', '50%'], // 将图表向左移动
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
},
labelLine: {
show: false,
},
emphasis: {
label: {
show: true,
fontSize: 12,
fontWeight: 'bold',
},
},
data: data,
},
],
};
});
// 计算房源占用的格子数
const getRoomCells = (area: number): number => {
if (area <= 250) return 1;
if (area <= 500) return 2;
if (area <= 750) return 3;
if (area <= 1000) return 4;
if (area <= 1250) return 5;
if (area <= 1500) return 6;
if (area <= 1750) return 7;
return 8;
};
// 按楼层组织房源数据(数据已经是按楼层分组的结构)
const floorList = computed(() => {
// 数据已经是按楼层分组的结构:list 中每个元素是一个楼层,包含 rooms 数组
return props.roomList
.map((floorData: any) => {
const floorId = floorData.id || floorData.floorId || 0;
const floorName = floorData.floorName || `楼层${floorId}`;
const rooms = floorData.rooms || [];
// 计算该楼层所有房间的总面积(使用 rentArea 累加)
const totalArea = rooms.reduce((sum: number, room: any) => {
return sum + parseFloat(room.rentArea || room.area || 0);
}, 0);
// 对每个楼层进行网格布局
const rows: any[][] = [];
let currentRow: any[] = [];
if (viewMode.value === 'list') {
// 列表模式:每个房源固定占用1格,一行最多8个
rooms.forEach((room: any) => {
// 如果当前行已满8个,换行
if (currentRow.length >= 8) {
rows.push(currentRow);
currentRow = [];
}
// 添加房源到当前行,固定占用1格
currentRow.push({ room, cells: 1 });
});
// 添加最后一行
if (currentRow.length > 0) {
rows.push(currentRow);
}
// 填充空单元格到8格
rows.forEach((row) => {
while (row.length < 8) {
row.push({ room: null, cells: 1 });
}
});
} else {
// 网格模式:按面积计算格子数
let currentRowArea = 0;
let currentRowTotalCells = 0; // 当前行的总格子数
rooms.forEach((room: any) => {
const area = parseFloat(room.rentArea || room.area || 0);
const cells = getRoomCells(area);
// 如果当前行加上这个房源会超过8格或2000平,则先处理当前行,然后换行
if (
currentRowTotalCells + cells > 8 ||
currentRowArea + area > 2000
) {
if (currentRow.length > 0) {
// 如果当前行的总格子数小于8,按比例放大到8格
if (currentRowTotalCells < 8) {
const scale = 8 / currentRowTotalCells;
currentRow.forEach((cell) => {
cell.cells = Math.round(cell.cells * scale);
});
}
rows.push(currentRow);
}
currentRow = [];
currentRowArea = 0;
currentRowTotalCells = 0;
}
// 添加房源到当前行
currentRow.push({ room, cells });
currentRowArea += area;
currentRowTotalCells += cells;
// 如果当前行刚好8格,处理并换行
if (currentRowTotalCells === 8) {
rows.push(currentRow);
currentRow = [];
currentRowArea = 0;
currentRowTotalCells = 0;
} else if (currentRowTotalCells > 8) {
// 如果超过8格,需要将最后一个房源移除,处理当前行,然后重新添加
const lastCell = currentRow.pop();
currentRowTotalCells -= lastCell.cells;
currentRowArea -= parseFloat(
lastCell.room.rentArea || lastCell.room.area || 0
);
// 如果当前行的总格子数小于8,按比例放大到8格
if (currentRowTotalCells < 8) {
const scale = 8 / currentRowTotalCells;
currentRow.forEach((cell) => {
cell.cells = Math.round(cell.cells * scale);
});
}
rows.push(currentRow);
// 重新开始一行,添加刚才移除的房源
currentRow = [lastCell];
currentRowArea = parseFloat(
lastCell.room.rentArea || lastCell.room.area || 0
);
currentRowTotalCells = lastCell.cells;
}
});
// 添加最后一行
if (currentRow.length > 0) {
// 如果最后一行总格子数小于8,按比例放大到8格
if (currentRowTotalCells < 8) {
const scale = 8 / currentRowTotalCells;
currentRow.forEach((cell) => {
cell.cells = Math.round(cell.cells * scale);
});
}
rows.push(currentRow);
}
}
return {
floorId,
floorName,
totalArea,
rows,
};
})
.sort((a: any, b: any) => b.floorId - a.floorId); // 按楼层ID排序(降序)
});
// 获取房源单元格样式
const getRoomCellStyle = (cell: any) => {
if (!cell.room) return {};
const cells = cell.cells || 1;
return {
gridColumn: `span ${cells}`,
};
};
// 获取房源单元格类名
const getRoomCellClass = (cell: any) => {
if (!cell.room) return 'bg-[#f7f8fa] border-[#e5e6eb]';
const status = cell.room.status;
const statusMap: Record<number, string> = {
1: 'border-[#165dff] bg-[#f0f5ff]', // 招商
2: 'border-[#ff7d00] bg-[#fff7e6]', // 保留
3: 'border-[#86909c] bg-[#f2f3f5]', // 下架
4: 'border-[#00b42a] bg-[#e8f8e8]', // 在租
5: 'border-[#f53f3f] bg-[#ffece8]', // 即将到期
6: 'border-[#f53f3f] bg-[#ffece8]', // 到期
};
return statusMap[status] || 'border-[#e5e6eb]';
};
// 获取房源信息文本
const getRoomInfo = (room: any) => {
if (room.status === 4) {
// 在租状态
if (room.dateStr) {
return room.dateStr;
}
if (room.days !== undefined && room.days > 0) {
return `剩余${room.days}天`;
}
if (room.days !== undefined && room.days < 0) {
return `到期${Math.abs(room.days)}天`;
}
return '租物';
} else if (room.status === 1) {
// 招商状态
if (room.days !== undefined) {
return `空置 ${room.days}天`;
}
return '空置';
} else if (room.status === 2) {
// 保留状态
return '客户已下订,保留中';
} else if (room.status === 3) {
// 下架状态
return '暂时下架,不出租了';
}
return '';
};
// 搜索处理
const handleSearch = () => {
emit('search', searchKeyword.value);
};
// 筛选确认处理
const handleFilterConfirm = (filters: {
buildingIds: number[] | null;
floorIds: number[] | null;
statusIds: number[] | null;
rentArea: number[];
vacantDays: number[];
contractStatuses: string[] | null;
}) => {
emit('filterChange', {
buildingIds: filters.buildingIds,
floorIds: filters.floorIds,
status: filters.statusIds || undefined,
rentArea: filters.rentArea,
vacantDays: filters.vacantDays,
contractStatuses: filters.contractStatuses,
});
};
// 点击房源查看详情
const handleRoomClick = async (room: any) => {
if (!room || !room.id) {
Message.warning('房源信息不完整');
return;
}
try {
// 调用 API 获取房源详情
const response = await getRoom(room.id);
if (response.code === 200 && response.data) {
roomDetailInfo.value = response.data;
showRoomDetailDrawer.value = true;
} else {
Message.error(response.message || '获取房源详情失败');
}
} catch (error) {
console.error('获取房源详情失败:', error);
Message.error('获取房源详情失败');
}
};
// 新建房源
const handleAddHouse = () => {
showHouseFormDrawer.value = true;
};
// 房源表单提交成功后的回调
const handleHouseFormSubmitted = () => {
// 触发父组件刷新数据
emit('refresh');
};
</script>

View file

@ -0,0 +1,630 @@
<template>
<a-drawer
:width="420"
:visible="visible"
title="筛选"
unmount-on-close
@ok="handleOk"
@cancel="handleCancel"
>
<a-form :model="filterForm" layout="vertical">
<!-- 项目(仅显示) -->
<a-form-item label="项目">
<span
class="w-full bg-[#f2f3f5] h-[32px] px-[8px] leading-[32px] block rounded"
>
{{ props.projectName || '-' }}
</span>
</a-form-item>
<!-- 楼栋(多选) -->
<a-form-item label="楼栋">
<a-select
v-model="filterForm.buildingIds"
multiple
placeholder="请选择楼栋"
:max-tag-count="3"
>
<a-option :value="ALL_BUILDING_ID">所有楼栋</a-option>
<a-option
v-for="item in buildingList"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
<!-- 楼层(多选) -->
<a-form-item label="楼层">
<a-select
v-model="filterForm.floorIds"
multiple
placeholder="请选择楼层"
:max-tag-count="3"
>
<a-option :value="ALL_FLOOR_ID">所有楼层</a-option>
<a-option v-for="item in floorList" :key="item.id" :value="item.id">
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
<!-- 房源状态(多选) -->
<a-form-item label="房源状态">
<a-select
v-model="filterForm.statusIds"
multiple
placeholder="请选择房源状态"
:max-tag-count="3"
>
<a-option
v-for="item in HOUSE_STATUS"
:key="item.id"
:value="item.id"
>
{{ item.text }}
</a-option>
</a-select>
</a-form-item>
<!-- 计租面积(滑块) -->
<a-form-item label="计租面积(㎡)">
<a-slider
v-model="filterForm.rentArea"
range
:min="rentAreaMin"
:max="rentAreaMax"
:marks="rentAreaMarks"
/>
</a-form-item>
<!-- 空置天数(滑块) -->
<a-form-item label="空置天数(天)">
<a-slider
v-model="filterForm.vacantDays"
range
:min="vacantDaysMin"
:max="vacantDaysMax"
:marks="vacantDaysMarks"
/>
</a-form-item>
<!-- 房源合同(多选) -->
<a-form-item label="房源合同">
<a-select
v-model="filterForm.contractStatuses"
multiple
placeholder="请选择房源合同"
:max-tag-count="3"
>
<a-option
v-for="item in contractStatusList"
:key="item"
:value="item"
>
{{ item }}
</a-option>
</a-select>
</a-form-item>
</a-form>
</a-drawer>
</template>
<script lang="ts" setup>
import { ref, watch, computed, onMounted, nextTick } from 'vue';
import { HOUSE_STATUS } from '@/constants';
import { getRoomTagList } from '@/api/setting';
// 特殊值用于表示"所有"选项(使用负数避免与真实ID冲突)
const ALL_BUILDING_ID = -1;
const ALL_FLOOR_ID = -2;
const props = defineProps<{
visible: boolean;
roomList?: any[]; // 房源列表数据,用于提取筛选选项
projectName?: string; // 项目名称(仅显示)
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(
e: 'confirm',
payload: {
buildingIds: number[] | null;
floorIds: number[] | null;
statusIds: number[] | null;
rentArea: number[];
vacantDays: number[];
contractStatuses: string[] | null;
}
): void;
}>();
// 保存的筛选状态
const savedFilterState = ref<{
buildingIds: number[];
floorIds: number[];
statusIds: number[];
rentArea: number[];
vacantDays: number[];
contractStatuses: string[];
} | null>(null);
// 筛选表单数据
const filterForm = ref({
buildingIds: [] as number[],
floorIds: [] as number[],
statusIds: [] as number[],
rentArea: [0, 0] as number[],
vacantDays: [0, 0] as number[],
contractStatuses: [] as string[],
});
// 楼栋列表(从房源列表中提取唯一值)
const buildingList = computed(() => {
if (!props.roomList || props.roomList.length === 0) return [];
const buildingMap = new Map();
props.roomList.forEach((floor) => {
if (floor.rooms && Array.isArray(floor.rooms)) {
floor.rooms.forEach((room: any) => {
if (room.buildingId && room.buildingName) {
if (!buildingMap.has(room.buildingId)) {
buildingMap.set(room.buildingId, {
id: room.buildingId,
name: room.buildingName,
});
}
}
});
}
});
return Array.from(buildingMap.values());
});
// 楼层列表(从房源列表中提取唯一值)
const floorList = computed(() => {
if (!props.roomList || props.roomList.length === 0) return [];
const floorMap = new Map();
props.roomList.forEach((floor) => {
if (floor.id !== undefined && floor.floorName) {
if (!floorMap.has(floor.id)) {
floorMap.set(floor.id, {
id: floor.id,
name: floor.floorName,
});
}
}
});
return Array.from(floorMap.values());
});
// 房源合同状态列表(从房源列表中提取唯一值)
const contractStatusList = computed(() => {
if (!props.roomList || props.roomList.length === 0) return [];
const statusSet = new Set<string>();
props.roomList.forEach((floor) => {
if (floor.rooms && Array.isArray(floor.rooms)) {
floor.rooms.forEach((room: any) => {
if (room.contractStatusStr && room.contractStatusStr.trim()) {
statusSet.add(room.contractStatusStr);
}
});
}
});
return Array.from(statusSet).sort();
});
// 计租面积范围(从所有房源中计算)
const rentAreaMin = computed(() => {
if (!props.roomList || props.roomList.length === 0) return 0;
const areas: number[] = [];
props.roomList.forEach((floor) => {
if (floor.rooms && Array.isArray(floor.rooms)) {
floor.rooms.forEach((room: any) => {
const area = parseFloat(room.rentArea || 0);
if (!isNaN(area) && area > 0) {
areas.push(area);
}
});
}
});
return areas.length > 0 ? Math.min(...areas) : 0;
});
const rentAreaMax = computed(() => {
if (!props.roomList || props.roomList.length === 0) return 100;
const areas: number[] = [];
props.roomList.forEach((floor) => {
if (floor.rooms && Array.isArray(floor.rooms)) {
floor.rooms.forEach((room: any) => {
const area = parseFloat(room.rentArea || 0);
if (!isNaN(area) && area > 0) {
areas.push(area);
}
});
}
});
return areas.length > 0 ? Math.max(...areas) : 100;
});
const rentAreaMarks = computed(() => {
return {
[rentAreaMin.value]: `${rentAreaMin.value}㎡`,
[rentAreaMax.value]: `${rentAreaMax.value}㎡`,
};
});
// 空置天数范围(只从招商状态的房源中计算)
const vacantDaysMin = computed(() => {
if (!props.roomList || props.roomList.length === 0) return 0;
const days: number[] = [];
props.roomList.forEach((floor) => {
if (floor.rooms && Array.isArray(floor.rooms)) {
floor.rooms.forEach((room: any) => {
// 只计算招商状态(status === 1)的房源
if (
room.status === 1 &&
room.days !== undefined &&
room.days !== null
) {
const day = Number(room.days);
if (!isNaN(day) && day >= 0) {
days.push(day);
}
}
});
}
});
return days.length > 0 ? Math.min(...days) : 0;
});
const vacantDaysMax = computed(() => {
if (!props.roomList || props.roomList.length === 0) return 100;
const days: number[] = [];
props.roomList.forEach((floor) => {
if (floor.rooms && Array.isArray(floor.rooms)) {
floor.rooms.forEach((room: any) => {
// 只计算招商状态(status === 1)的房源
if (
room.status === 1 &&
room.days !== undefined &&
room.days !== null
) {
const day = Number(room.days);
if (!isNaN(day) && day >= 0) {
days.push(day);
}
}
});
}
});
return days.length > 0 ? Math.max(...days) : 100;
});
const vacantDaysMarks = computed(() => {
return {
[vacantDaysMin.value]: `${vacantDaysMin.value}天`,
[vacantDaysMax.value]: `${vacantDaysMax.value}天`,
};
});
// 用于防止 watch 循环更新的标志
const isUpdatingBuilding = ref(false);
const isUpdatingFloor = ref(false);
// 监听楼栋选择变化
watch(
() => filterForm.value.buildingIds,
(newVal, oldVal) => {
if (isUpdatingBuilding.value) return;
if (!oldVal || oldVal.length === 0) return; // 初始化时跳过
const isAllSelected = newVal.includes(ALL_BUILDING_ID);
const wasAllOnly = oldVal.length === 1 && oldVal[0] === ALL_BUILDING_ID;
const hasSpecificSelected = newVal.some((id) => id !== ALL_BUILDING_ID);
const specificIds = newVal.filter((id) => id !== ALL_BUILDING_ID);
const allBuildingIds = buildingList.value.map((b) => b.id);
// 情况1:用户选择了"所有"选项(从非"所有"状态切换到包含"所有")
if (isAllSelected && hasSpecificSelected && !wasAllOnly) {
isUpdatingBuilding.value = true;
filterForm.value.buildingIds = [ALL_BUILDING_ID];
nextTick(() => {
isUpdatingBuilding.value = false;
});
return;
}
// 情况2:用户从"所有"切换到选择具体选项
if (!isAllSelected && wasAllOnly && hasSpecificSelected) {
if (
specificIds.length === allBuildingIds.length &&
allBuildingIds.every((id) => specificIds.includes(id))
) {
isUpdatingBuilding.value = true;
filterForm.value.buildingIds = [ALL_BUILDING_ID];
nextTick(() => {
isUpdatingBuilding.value = false;
});
}
return;
}
// 情况3:用户选择了具体选项,但当前值还包含"所有"
if (hasSpecificSelected && isAllSelected && wasAllOnly) {
isUpdatingBuilding.value = true;
filterForm.value.buildingIds = specificIds;
nextTick(() => {
isUpdatingBuilding.value = false;
if (
specificIds.length === allBuildingIds.length &&
allBuildingIds.every((id) => specificIds.includes(id))
) {
isUpdatingBuilding.value = true;
filterForm.value.buildingIds = [ALL_BUILDING_ID];
nextTick(() => {
isUpdatingBuilding.value = false;
});
}
});
return;
}
// 情况4:用户一个个勾选具体选项,当选中数量等于总数时,自动切换为"所有"
if (
!isAllSelected &&
hasSpecificSelected &&
specificIds.length === allBuildingIds.length
) {
if (allBuildingIds.every((id) => specificIds.includes(id))) {
isUpdatingBuilding.value = true;
filterForm.value.buildingIds = [ALL_BUILDING_ID];
nextTick(() => {
isUpdatingBuilding.value = false;
});
}
}
}
);
// 监听楼层选择变化
watch(
() => filterForm.value.floorIds,
(newVal, oldVal) => {
if (isUpdatingFloor.value) return;
if (!oldVal || oldVal.length === 0) return; // 初始化时跳过
const isAllSelected = newVal.includes(ALL_FLOOR_ID);
const wasAllOnly = oldVal.length === 1 && oldVal[0] === ALL_FLOOR_ID;
const hasSpecificSelected = newVal.some((id) => id !== ALL_FLOOR_ID);
const specificIds = newVal.filter((id) => id !== ALL_FLOOR_ID);
const allFloorIds = floorList.value.map((f) => f.id);
// 情况1:用户选择了"所有"选项(从非"所有"状态切换到包含"所有")
if (isAllSelected && hasSpecificSelected && !wasAllOnly) {
isUpdatingFloor.value = true;
filterForm.value.floorIds = [ALL_FLOOR_ID];
nextTick(() => {
isUpdatingFloor.value = false;
});
return;
}
// 情况2:用户从"所有"切换到选择具体选项
if (!isAllSelected && wasAllOnly && hasSpecificSelected) {
if (
specificIds.length === allFloorIds.length &&
allFloorIds.every((id) => specificIds.includes(id))
) {
isUpdatingFloor.value = true;
filterForm.value.floorIds = [ALL_FLOOR_ID];
nextTick(() => {
isUpdatingFloor.value = false;
});
}
return;
}
// 情况3:用户选择了具体选项,但当前值还包含"所有"
if (hasSpecificSelected && isAllSelected && wasAllOnly) {
isUpdatingFloor.value = true;
filterForm.value.floorIds = specificIds;
nextTick(() => {
isUpdatingFloor.value = false;
if (
specificIds.length === allFloorIds.length &&
allFloorIds.every((id) => specificIds.includes(id))
) {
isUpdatingFloor.value = true;
filterForm.value.floorIds = [ALL_FLOOR_ID];
nextTick(() => {
isUpdatingFloor.value = false;
});
}
});
return;
}
// 情况4:用户一个个勾选具体选项,当选中数量等于总数时,自动切换为"所有"
if (
!isAllSelected &&
hasSpecificSelected &&
specificIds.length === allFloorIds.length
) {
if (allFloorIds.every((id) => specificIds.includes(id))) {
isUpdatingFloor.value = true;
filterForm.value.floorIds = [ALL_FLOOR_ID];
nextTick(() => {
isUpdatingFloor.value = false;
});
}
}
}
);
// 初始化筛选表单
const initFilterForm = () => {
const minArea = rentAreaMin.value || 0;
const maxArea = rentAreaMax.value || 100;
const minDays = vacantDaysMin.value || 0;
const maxDays = vacantDaysMax.value || 100;
// 如果有保存的状态,恢复它(但要过滤掉不存在的ID)
if (savedFilterState.value) {
const allBuildingIds = buildingList.value.map((b) => b.id);
const allFloorIds = floorList.value.map((f) => f.id);
// 处理楼栋ID
let buildingIds = savedFilterState.value.buildingIds;
if (!buildingIds.includes(ALL_BUILDING_ID)) {
const validBuildingIds = buildingIds.filter((id) =>
allBuildingIds.includes(id)
);
if (
validBuildingIds.length === allBuildingIds.length &&
allBuildingIds.length > 0
) {
buildingIds = [ALL_BUILDING_ID];
} else if (validBuildingIds.length > 0) {
buildingIds = validBuildingIds;
} else {
buildingIds = [ALL_BUILDING_ID];
}
}
// 处理楼层ID
let floorIds = savedFilterState.value.floorIds;
if (!floorIds.includes(ALL_FLOOR_ID)) {
const validFloorIds = floorIds.filter((id) => allFloorIds.includes(id));
if (
validFloorIds.length === allFloorIds.length &&
allFloorIds.length > 0
) {
floorIds = [ALL_FLOOR_ID];
} else if (validFloorIds.length > 0) {
floorIds = validFloorIds;
} else {
floorIds = [ALL_FLOOR_ID];
}
}
filterForm.value = {
buildingIds,
floorIds,
statusIds: [...savedFilterState.value.statusIds],
rentArea: [...savedFilterState.value.rentArea],
vacantDays: [...savedFilterState.value.vacantDays],
contractStatuses: [...savedFilterState.value.contractStatuses],
};
} else {
// 否则初始化,楼栋和楼层默认选中"所有"
filterForm.value = {
buildingIds: [ALL_BUILDING_ID],
floorIds: [ALL_FLOOR_ID],
statusIds: [],
rentArea: [minArea, maxArea],
vacantDays: [minDays, maxDays],
contractStatuses: [],
};
}
};
const handleCancel = () => {
emit('update:visible', false);
};
const handleOk = () => {
// 处理"所有"选项:如果同时选择了"所有"和具体选项,只保留"所有"
let finalBuildingIds = [...filterForm.value.buildingIds];
let finalFloorIds = [...filterForm.value.floorIds];
if (
finalBuildingIds.includes(ALL_BUILDING_ID) &&
finalBuildingIds.length > 1
) {
finalBuildingIds = [ALL_BUILDING_ID];
}
if (finalFloorIds.includes(ALL_FLOOR_ID) && finalFloorIds.length > 1) {
finalFloorIds = [ALL_FLOOR_ID];
}
// 保存当前筛选状态(保存处理后的值)
savedFilterState.value = {
buildingIds: finalBuildingIds,
floorIds: finalFloorIds,
statusIds: [...filterForm.value.statusIds],
rentArea: [...filterForm.value.rentArea],
vacantDays: [...filterForm.value.vacantDays],
contractStatuses: [...filterForm.value.contractStatuses],
};
// 标准化ID数组:如果选择了"所有"选项,则返回null(表示全部)
const normalizeIds = (ids: number[], allOptionId: number) => {
if (ids.length === 0) return null;
// 如果选择了"所有"选项,则返回null
if (ids.includes(allOptionId)) {
return null;
}
return ids;
};
emit('confirm', {
buildingIds: normalizeIds(finalBuildingIds, ALL_BUILDING_ID),
floorIds: normalizeIds(finalFloorIds, ALL_FLOOR_ID),
statusIds:
filterForm.value.statusIds.length === 0
? null
: filterForm.value.statusIds,
rentArea: filterForm.value.rentArea,
vacantDays: filterForm.value.vacantDays,
contractStatuses:
filterForm.value.contractStatuses.length === 0
? null
: filterForm.value.contractStatuses,
});
handleCancel();
};
// 监听抽屉打开,初始化数据
watch(
() => props.visible,
(val) => {
if (val) {
// 延迟初始化,确保 roomList 已更新
setTimeout(() => {
initFilterForm();
}, 0);
}
},
{ immediate: true }
);
// 监听楼栋和楼层列表变化,如果当前没有保存状态且列表为空,则默认选中"所有"
watch(
[buildingList, floorList],
() => {
if (props.visible && !savedFilterState.value) {
// 如果楼栋列表为空,默认选中"所有楼栋"
if (filterForm.value.buildingIds.length === 0) {
filterForm.value.buildingIds = [ALL_BUILDING_ID];
}
// 如果楼层列表为空,默认选中"所有楼层"
if (filterForm.value.floorIds.length === 0) {
filterForm.value.floorIds = [ALL_FLOOR_ID];
}
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
:deep(label.arco-form-item-label) {
width: 100% !important;
}
:deep(.arco-slider-mark) {
text-wrap: nowrap;
}
</style>

View file

@ -1,3 +1,351 @@
<template>
<div>house-view</div>
<div class="house_view_wrap">
<Breadcrumb :items="['menu.business', 'menu.business.house-view']" />
<div class="bg-[#fff] flex flex-col">
<a-tabs
v-if="topBuildList && topBuildList.length > 0"
v-model="activeBuildId"
type="card"
class="flex-shrink-0"
@change="handleTabChange"
>
<a-tab-pane
v-for="item in topBuildList"
:key="item.buildingId"
:title="item.buildingName"
>
<div class="house-view-scroll-container">
<HouseViewContent
v-if="
currentProjectId &&
activeBuildId &&
Number(activeBuildId) === Number(item.buildingId)
"
:key="`building-${activeBuildId}-project-${currentProjectId}`"
:statistics-data="statisticsData"
:room-list="roomList"
:loading="contentLoading"
:project-name="currentProjectName"
@search="handleSearch"
@filter-change="handleFilterChange"
@refresh="handleRefresh"
/>
</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted, computed } from 'vue';
import {
getTopBuildingTagsByProjectId,
getStatisticsData,
getRoomOverList,
} from '@/api/business';
import { useUserStore } from '@/store';
import { Message } from '@arco-design/web-vue';
import type { BuildingTag } from '@/types/business';
import HouseViewContent from './components/house-view-content.vue';
const userStore = useUserStore();
const topBuildList = ref<BuildingTag[]>([]);
const loading = ref(false);
const activeBuildId = ref<number | null>(null);
const statisticsData = ref<any>({});
const roomList = ref<any[]>([]);
const contentLoading = ref(false);
const searchKeyword = ref('');
const selectedStatus = ref<number[]>([]);
const selectedBuildingIds = ref<number[] | null>(null);
const selectedFloorIds = ref<number[] | null>(null);
const rentAreaRange = ref<number[]>([0, 0]);
const vacantDaysRange = ref<number[]>([0, 0]);
const selectedContractStatuses = ref<string[] | null>(null);
const isInitialized = ref(false); // 标记是否已经初始化加载过数据
// 计算当前项目ID
const currentProjectId = computed(() => userStore.currentProjectId);
const currentProjectName = computed(() => {
const id = userStore.currentProjectId;
const list = userStore.projects || [];
const found = list.find((p: any) => p.id === id);
return found?.name || '';
});
// 加载统计数据
const loadStatisticsData = async () => {
if (!currentProjectId.value || !activeBuildId.value) return;
try {
const response = await getStatisticsData({
keyword: searchKeyword.value,
projectId: currentProjectId.value,
buildingId: activeBuildId.value,
floorId: 0,
rentAreaMin: rentAreaRange.value[0] || 0,
rentAreaMax: rentAreaRange.value[1] || 0,
status: selectedStatus.value.length > 0 ? selectedStatus.value : [],
});
if (response.code === 200 && response.data) {
const data = response.data as { statisticsData?: any };
if (data.statisticsData) {
// statisticsData.value = data.statisticsData
statisticsData.value = {
roomNum: 25,
roomAreaStr: '1000.㎡',
rentAreaStr: '1380.00㎡',
keepAreaStr: '600.00㎡',
useAreaStr: '809.00㎡',
removalAreaStr: '320.00㎡',
rentRateStr: '67.80',
totalRentRateStr: '79.10',
};
}
}
} catch (error) {
console.error('获取统计数据失败:', error);
}
};
// 加载房源列表数据
const loadRoomData = async () => {
if (!currentProjectId.value || !activeBuildId.value) return;
contentLoading.value = true;
try {
const response = await getRoomOverList({
keyword: searchKeyword.value,
projectId: currentProjectId.value,
buildingId: activeBuildId.value,
floorId: 0,
rentAreaMin: rentAreaRange.value[0] || 0,
rentAreaMax: rentAreaRange.value[1] || 0,
status: selectedStatus.value.length > 0 ? selectedStatus.value : [],
// 注意:如果接口支持空置天数和标签筛选,可以在这里添加
// vacantDaysMin: vacantDaysRange.value[0] || 0,
// vacantDaysMax: vacantDaysRange.value[1] || 0,
// tagIds: selectedTagIds.value.length > 0 ? selectedTagIds.value : [],
});
if (response.code === 200 && response.data) {
const data = response.data as { list?: any[] };
// 数据已经是按楼层分组的结构,直接使用
roomList.value = Array.isArray(data.list) ? data.list : [];
}
} catch (error) {
console.error('获取房源列表失败:', error);
Message.error('获取房源列表失败');
} finally {
contentLoading.value = false;
}
};
// 加载所有数据
const loadAllData = async () => {
if (!currentProjectId.value || !activeBuildId.value) return;
await Promise.all([loadStatisticsData(), loadRoomData()]);
};
// 搜索处理
const handleSearch = (keyword: string) => {
searchKeyword.value = keyword;
loadAllData();
};
// 筛选变化处理
const handleFilterChange = (filters: {
buildingIds?: number[] | null;
floorIds?: number[] | null;
status?: number[];
rentArea?: number[];
vacantDays?: number[];
contractStatuses?: string[] | null;
}) => {
if (filters.buildingIds !== undefined) {
selectedBuildingIds.value = filters.buildingIds;
}
if (filters.floorIds !== undefined) {
selectedFloorIds.value = filters.floorIds;
}
if (filters.status !== undefined) {
selectedStatus.value = filters.status;
}
if (filters.rentArea !== undefined) {
rentAreaRange.value = filters.rentArea;
}
if (filters.vacantDays !== undefined) {
vacantDaysRange.value = filters.vacantDays;
}
if (filters.contractStatuses !== undefined) {
selectedContractStatuses.value = filters.contractStatuses;
}
loadAllData();
};
// 刷新数据(新建房源成功后调用)
const handleRefresh = () => {
loadAllData();
};
// Tab 切换处理
const handleTabChange = (activeKey: string | number) => {
console.log('handleTabChange 被调用', {
activeKey,
activeKeyType: typeof activeKey,
activeBuildId: activeBuildId.value,
activeBuildIdType: typeof activeBuildId.value,
currentProjectId: currentProjectId.value,
});
// 确保 activeBuildId 被正确设置(转换为数字)
const newBuildingId =
typeof activeKey === 'string' ? Number(activeKey) : activeKey;
// 确保 activeBuildId 也是数字类型
if (newBuildingId && !isNaN(newBuildingId)) {
activeBuildId.value = newBuildingId;
console.log('Tab 切换,设置 activeBuildId 并加载数据', {
newBuildingId,
currentBuildId: activeBuildId.value,
});
// activeBuildId 变化会触发 watch,这里不需要再次调用 loadAllData
}
};
async function getTopBuildingTagsData() {
const projectId = userStore.currentProjectId;
if (!projectId) {
Message.warning('请先选择项目');
return;
}
loading.value = true;
try {
const response = await getTopBuildingTagsByProjectId(projectId);
if (response.code === 200 && response.data?.tags) {
topBuildList.value = Array.isArray(response.data.tags)
? response.data.tags
: [];
if (topBuildList.value.length > 0) {
// 如果当前选中的楼栋不在新列表中,或者没有选中楼栋,则选择第一个
const currentBuilding = topBuildList.value.find(
(item) => item.buildingId === activeBuildId.value
);
if (!currentBuilding) {
activeBuildId.value = topBuildList.value[0]?.buildingId;
// activeBuildId 变化会触发 watch,不需要手动调用
}
// 如果楼栋ID没有变化(可能是同一个项目重新加载),但需要初始化加载数据
// 这种情况下 watch 不会触发,需要手动调用一次
else if (
activeBuildId.value &&
currentProjectId.value &&
!isInitialized.value
) {
isInitialized.value = true;
loadAllData();
}
} else {
activeBuildId.value = null;
}
} else {
topBuildList.value = [];
activeBuildId.value = null;
Message.error(response.message || '获取楼栋列表失败');
}
} catch (error) {
console.error('获取楼栋列表失败:', error);
topBuildList.value = [];
activeBuildId.value = null;
Message.error('获取楼栋列表失败');
} finally {
loading.value = false;
}
}
// 监听 activeBuildId 变化,重新加载数据
watch(
activeBuildId,
(newBuildingId, oldBuildingId) => {
console.log('watch activeBuildId 触发', {
newBuildingId,
oldBuildingId,
currentProjectId: currentProjectId.value,
isEqual: newBuildingId === oldBuildingId,
});
// 如果 buildingId 有值且项目ID也有值
if (newBuildingId && currentProjectId.value) {
// 如果值变化了(从 null 变为有值,或者从一个值变为另一个值),就加载数据
if (newBuildingId !== oldBuildingId) {
console.log('activeBuildId 变化,准备加载数据', {
newBuildingId,
oldBuildingId,
});
isInitialized.value = true;
loadAllData();
} else {
console.log('activeBuildId 没有变化,跳过加载', {
newBuildingId,
oldBuildingId,
});
}
} else {
console.log('条件不满足,清空数据', {
newBuildingId,
currentProjectId: currentProjectId.value,
});
statisticsData.value = {};
roomList.value = [];
isInitialized.value = false;
}
},
{ immediate: false }
);
// 监听项目ID变化,重新获取数据
watch(
() => userStore.currentProjectId,
(newProjectId) => {
if (newProjectId) {
isInitialized.value = false; // 重置初始化标志
getTopBuildingTagsData();
} else {
topBuildList.value = [];
statisticsData.value = {};
roomList.value = [];
isInitialized.value = false;
}
}
);
// 组件挂载时获取数据
onMounted(() => {
if (userStore.currentProjectId) {
getTopBuildingTagsData();
} else {
// 如果没有选中的项目,尝试使用第一个项目
if (userStore.projects && userStore.projects.length > 0) {
userStore.setInfo({ currentProjectId: userStore.projects[0].id });
getTopBuildingTagsData();
} else {
Message.warning('请先选择项目');
}
}
});
</script>
<style scoped lang="less">
.house_view_wrap {
display: flex;
flex-direction: column;
height: 100%;
}
.house-view-scroll-container {
max-height: calc(100vh - 56px - 58px - 40px);
padding-bottom: 20px;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch; // iOS 平滑滚动
}
</style>

View file

@ -1,3 +0,0 @@
<template>
<div>monitor</div>
</template>

View file

@ -1,48 +0,0 @@
export default {
'menu.dashboard.monitor': 'Real-time Monitor',
'monitor.title.chatPanel': 'Chat Window',
'monitor.title.quickOperation': 'Quick Operation',
'monitor.title.studioInfo': 'Studio Information',
'monitor.title.studioPreview': 'Studio Preview',
'monitor.chat.options.all': 'All',
'monitor.chat.placeholder.searchCategory': 'Search Category',
'monitor.chat.update': 'Update',
'monitor.list.title.order': 'Order',
'monitor.list.title.cover': 'Cover',
'monitor.list.title.name': 'Name',
'monitor.list.title.duration': 'Duration',
'monitor.list.title.id': 'ID',
'monitor.list.tip.rotations': 'Rotations ',
'monitor.list.tip.rest': ', The program list is not visible to viewers',
'monitor.list.tag.auditFailed': 'Audit Failed',
'monitor.tab.title.liveMethod': 'Live Method',
'monitor.tab.title.onlinePopulation': 'Online Population',
'monitor.liveMethod.normal': 'Normal Live',
'monitor.liveMethod.flowControl': 'Flow Control Live',
'monitor.liveMethod.video': 'Video Live',
'monitor.liveMethod.web': 'Web Live',
'monitor.editCarousel': 'Edit',
'monitor.startCarousel': 'Start',
'monitor.quickOperation.changeClarity': 'Change the Clarity',
'monitor.quickOperation.switchStream': 'Switch Stream',
'monitor.quickOperation.removeClarity': 'Remove the Clarity',
'monitor.quickOperation.pushFlowGasket': 'Push Flow Gasket',
'monitor.studioInfo.label.studioTitle': 'Studio Title',
'monitor.studioInfo.label.onlineNotification': 'Online Notification',
'monitor.studioInfo.label.studioCategory': 'Studio Category',
'monitor.studioInfo.placeholder.studioTitle': "'s Studio",
'monitor.studioInfo.btn.fresh': 'Fresh',
'monitor.studioStatus.title.studioStatus': 'Studio Status',
'monitor.studioStatus.title.pictureInfo': 'Picture Information',
'monitor.studioStatus.smooth': 'Smooth',
'monitor.studioStatus.frameRate': 'Frame',
'monitor.studioStatus.bitRate': 'Bit',
'monitor.studioStatus.mainstream': 'Main',
'monitor.studioStatus.hotStandby': 'Hot',
'monitor.studioStatus.coldStandby': 'Cold',
'monitor.studioStatus.line': 'Line',
'monitor.studioStatus.play': 'Format',
'monitor.studioStatus.pictureQuality': 'Quality',
'monitor.studioPreview.studio': 'Studio',
'monitor.studioPreview.watching': 'watching',
};

View file

@ -1,48 +0,0 @@
export default {
'menu.dashboard.monitor': '实时监控',
'monitor.title.chatPanel': '聊天窗口',
'monitor.title.quickOperation': '快捷操作',
'monitor.title.studioInfo': '直播信息',
'monitor.title.studioPreview': '直播预览',
'monitor.chat.options.all': '全部',
'monitor.chat.placeholder.searchCategory': '搜索类目',
'monitor.chat.update': '更新',
'monitor.list.title.order': '序号',
'monitor.list.title.cover': '封面',
'monitor.list.title.name': '名称',
'monitor.list.title.duration': '视频时长',
'monitor.list.title.id': '视频Id',
'monitor.list.tip.rotations': '轮播次数',
'monitor.list.tip.rest': ',节目单观众不可见',
'monitor.list.tag.auditFailed': '审核未通过',
'monitor.tab.title.liveMethod': '直播方式',
'monitor.tab.title.onlinePopulation': '在线人数',
'monitor.liveMethod.normal': '普通直播',
'monitor.liveMethod.flowControl': '控流直播',
'monitor.liveMethod.video': '视频直播',
'monitor.liveMethod.web': '网页开播',
'monitor.editCarousel': '编辑轮播',
'monitor.startCarousel': '开始轮播',
'monitor.quickOperation.changeClarity': '切换清晰度',
'monitor.quickOperation.switchStream': '主备流切换',
'monitor.quickOperation.removeClarity': '摘除清晰度',
'monitor.quickOperation.pushFlowGasket': '推流垫片',
'monitor.studioInfo.label.studioTitle': '直播标题',
'monitor.studioInfo.label.onlineNotification': '上线通知',
'monitor.studioInfo.label.studioCategory': '直播类目',
'monitor.studioInfo.placeholder.studioTitle': '的直播间',
'monitor.studioInfo.btn.fresh': '更新',
'monitor.studioStatus.title.studioStatus': '直播状态',
'monitor.studioStatus.title.pictureInfo': '画面信息',
'monitor.studioStatus.smooth': '流畅',
'monitor.studioStatus.frameRate': '帧率',
'monitor.studioStatus.bitRate': '码率',
'monitor.studioStatus.mainstream': '主流',
'monitor.studioStatus.hotStandby': '热备',
'monitor.studioStatus.coldStandby': '冷备',
'monitor.studioStatus.line': '线路',
'monitor.studioStatus.play': '播放格式',
'monitor.studioStatus.pictureQuality': '画质',
'monitor.studioPreview.studio': '直播间',
'monitor.studioPreview.watching': '在看',
};

View file

@ -1,3 +0,0 @@
<template>
<div>workplace</div>
</template>

View file

@ -1,38 +0,0 @@
export default {
'menu.dashboard.workplace': 'Workplace',
'workplace.welcome': 'Welcome!',
'workplace.balance': 'Balance (CNY)',
'workplace.order.pending': 'Pending',
'workplace.order.pendingRenewal': 'Renewal Order',
'workplace.onlineContent': 'Online Content',
'workplace.putIn': 'Put In',
'workplace.newDay': 'Daily Additional Comments',
'workplace.newFromYesterday': 'New From Yesterday',
'workplace.minute': 'Min',
'workplace.docs': 'Documents',
'workplace.docs.productOverview': 'Product Overview',
'workplace.docs.userGuide': 'User Guide',
'workplace.docs.workflow': 'Workflow',
'workplace.docs.interfaceDocs': 'Interface Docs',
//
'workplace.contentManagement': 'Content Management',
'workplace.contentStatistical': 'Content Statistical',
'workplace.advanced': 'Advanced',
'workplace.onlinePromotion': 'Online Promotion',
'workplace.contentPutIn': 'Put In',
'workplace.announcement': 'Announcement',
'workplace.recently.visited': 'Recently Visited',
'workplace.record.nodata': 'No data',
'workplace.quick.operation': 'Quick Operation',
'workplace.quickOperation.setup': 'Setup',
'workplace.allProject': 'All',
'workplace.loadMore': 'More',
'workplace.viewMore': 'More',
'workplace.contentData': 'Content Data',
'workplace.popularContent': 'Popular Content',
'workplace.popularContent.text': 'text',
'workplace.popularContent.image': 'image',
'workplace.popularContent.video': 'video',
'workplace.categoriesPercent': 'Categories Percent',
'workplace.pecs': 'pecs',
};

View file

@ -1,37 +0,0 @@
export default {
'menu.dashboard.workplace': '工作台',
'workplace.welcome': '欢迎回来!',
'workplace.balance': '余额(元)',
'workplace.order.pending': '待支付',
'workplace.order.pendingRenewal': '待续费订单',
'workplace.onlineContent': '线上总内容',
'workplace.putIn': '投放中内容',
'workplace.newDay': '日新增评论',
'workplace.newFromYesterday': '较昨日新增',
'workplace.minute': '分钟',
'workplace.docs': '帮助文档',
'workplace.docs.productOverview': '产品概要',
'workplace.docs.userGuide': '使用指南',
'workplace.docs.workflow': '接入流程',
'workplace.docs.interfaceDocs': '接口文档',
'workplace.contentManagement': '内容管理',
'workplace.contentStatistical': '内容分析',
'workplace.advanced': '高级管理',
'workplace.onlinePromotion': '线上推广',
'workplace.contentPutIn': '内容投放',
'workplace.announcement': '公告',
'workplace.recently.visited': '最近访问',
'workplace.record.nodata': '暂无数据',
'workplace.quick.operation': '快捷操作',
'workplace.quickOperation.setup': '管理',
'workplace.allProject': '所有项目',
'workplace.loadMore': '加载更多',
'workplace.viewMore': '查看更多',
'workplace.contentData': '内容数据',
'workplace.popularContent': '线上热门内容',
'workplace.popularContent.text': '文本',
'workplace.popularContent.image': '图片',
'workplace.popularContent.video': '视频',
'workplace.categoriesPercent': '内容类型占比',
'workplace.pecs': '个',
};

View file

@ -1,44 +0,0 @@
<template>
<div class="error-container">
<a-result
status="error"
title="操作失败"
sub-title="很抱歉,您的操作未能完成,请检查并重试"
>
<template #extra>
<a-space>
<a-button key="retry" type="primary">重试</a-button>
<a-button key="back" @click="back">返回上一页</a-button>
</a-space>
</template>
</a-result>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useRouter } from 'vue-router';
export default defineComponent({
name: 'ResultError',
setup() {
const router = useRouter();
const back = () => {
router.back();
};
return {
back,
};
},
});
</script>
<style scoped lang="less">
.error-container {
padding: 24px;
background-color: var(--color-bg-2);
height: 100%;
}
</style>

View file

@ -1,51 +0,0 @@
<template>
<div class="success-container">
<a-result
status="success"
title="操作成功"
sub-title="操作已成功完成,您可以继续其他操作或返回首页"
>
<template #extra>
<a-space>
<a-button key="home" type="primary" @click="toHome"
>返回首页</a-button
>
<a-button key="back" @click="back">返回上一页</a-button>
</a-space>
</template>
</a-result>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useRouter } from 'vue-router';
export default defineComponent({
name: 'ResultSuccess',
setup() {
const router = useRouter();
const toHome = () => {
router.push('/');
};
const back = () => {
router.back();
};
return {
toHome,
back,
};
},
});
</script>
<style scoped lang="less">
.success-container {
padding: 24px;
background-color: var(--color-bg-2);
height: 100%;
}
</style>

View file

@ -1,268 +0,0 @@
{
"permission": [
{
"system_tenant_permission_id": 100,
"tenant_id": 1,
"perm_id": 1,
"id": 1,
"parent_id": 0,
"prem_name": "业务",
"level": 1,
"perm_key": "business",
"prem_type": 1,
"path": "/1/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 10:00:00",
"updatedAt": "2024-06-20 10:00:00",
"children": [
{
"system_tenant_permission_id": 101,
"tenant_id": 1,
"perm_id": 2,
"id": 2,
"parent_id": 1,
"prem_name": "项目管理",
"level": 2,
"perm_key": "project_manage",
"prem_type": 1,
"path": "/1/2/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 10:01:00",
"updatedAt": "2024-06-20 10:01:00",
"children": [
{
"system_tenant_permission_id": 102,
"tenant_id": 1,
"perm_id": 3,
"id": 3,
"parent_id": 2,
"prem_name": "查看项目",
"level": 3,
"perm_key": "view_project",
"prem_type": 4,
"path": "/1/2/3/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 10:02:00",
"updatedAt": "2024-06-20 10:02:00",
"children": [
{
"system_tenant_permission_id": 103,
"tenant_id": 1,
"perm_id": 4,
"id": 4,
"parent_id": 3,
"prem_name": "添加收入目标",
"level": 4,
"perm_key": "add_income_target",
"prem_type": 2,
"path": "/1/2/3/4/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 10:03:00",
"updatedAt": "2024-06-20 10:03:00",
"children": []
},
{
"system_tenant_permission_id": 104,
"tenant_id": 1,
"perm_id": 5,
"id": 5,
"parent_id": 3,
"prem_name": "编辑收入目标",
"level": 4,
"perm_key": "edit_income_target",
"prem_type": 2,
"path": "/1/2/3/5/",
"sort": 2,
"visible": 1,
"createdAt": "2024-06-20 10:04:00",
"updatedAt": "2024-06-20 10:04:00",
"children": []
}
]
},
{
"system_tenant_permission_id": 105,
"tenant_id": 1,
"perm_id": 6,
"id": 6,
"parent_id": 2,
"prem_name": "编辑项目",
"level": 3,
"perm_key": "edit_project",
"prem_type": 4,
"path": "/1/2/6/",
"sort": 2,
"visible": 1,
"createdAt": "2024-06-20 10:05:00",
"updatedAt": "2024-06-20 10:05:00",
"children": [
{
"system_tenant_permission_id": 106,
"tenant_id": 1,
"perm_id": 7,
"id": 7,
"parent_id": 6,
"prem_name": "删除收入目标",
"level": 4,
"perm_key": "delete_income_target",
"prem_type": 2,
"path": "/1/2/6/7/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 10:06:00",
"updatedAt": "2024-06-20 10:06:00",
"children": []
}
]
}
]
},
{
"system_tenant_permission_id": 107,
"tenant_id": 1,
"perm_id": 8,
"id": 8,
"parent_id": 1,
"prem_name": "合同管理",
"level": 2,
"perm_key": "contract_manage",
"prem_type": 1,
"path": "/1/8/",
"sort": 2,
"visible": 1,
"createdAt": "2024-06-20 10:07:00",
"updatedAt": "2024-06-20 10:07:00",
"children": [
{
"system_tenant_permission_id": 108,
"tenant_id": 1,
"perm_id": 9,
"id": 9,
"parent_id": 8,
"prem_name": "查看合同",
"level": 3,
"perm_key": "view_contract",
"prem_type": 4,
"path": "/1/8/9/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 10:08:00",
"updatedAt": "2024-06-20 10:08:00",
"children": [
{
"system_tenant_permission_id": 109,
"tenant_id": 1,
"perm_id": 10,
"id": 10,
"parent_id": 9,
"prem_name": "导出合同",
"level": 4,
"perm_key": "export_contract",
"prem_type": 2,
"path": "/1/8/9/10/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 10:09:00",
"updatedAt": "2024-06-20 10:09:00",
"children": []
}
]
}
]
}
]
},
{
"system_tenant_permission_id": 200,
"tenant_id": 1,
"perm_id": 20,
"id": 20,
"parent_id": 0,
"prem_name": "房源视图",
"level": 1,
"perm_key": "house_view",
"prem_type": 1,
"path": "/20/",
"sort": 2,
"visible": 1,
"createdAt": "2024-06-20 11:00:00",
"updatedAt": "2024-06-20 11:00:00",
"children": [
{
"system_tenant_permission_id": 201,
"tenant_id": 1,
"perm_id": 21,
"id": 21,
"parent_id": 20,
"prem_name": "新建房源",
"level": 2,
"perm_key": "add_house",
"prem_type": 2,
"path": "/20/21/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 11:01:00",
"updatedAt": "2024-06-20 11:01:00",
"children": []
},
{
"system_tenant_permission_id": 202,
"tenant_id": 1,
"perm_id": 22,
"id": 22,
"parent_id": 20,
"prem_name": "导入房源",
"level": 2,
"perm_key": "import_house",
"prem_type": 3,
"path": "/20/22/",
"sort": 2,
"visible": 1,
"createdAt": "2024-06-20 11:02:00",
"updatedAt": "2024-06-20 11:02:00",
"children": [
{
"system_tenant_permission_id": 203,
"tenant_id": 1,
"perm_id": 23,
"id": 23,
"parent_id": 22,
"prem_name": "批量导入",
"level": 3,
"perm_key": "batch_import",
"prem_type": 3,
"path": "/20/22/23/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 11:03:00",
"updatedAt": "2024-06-20 11:03:00",
"children": [
{
"system_tenant_permission_id": 204,
"tenant_id": 1,
"perm_id": 24,
"id": 24,
"parent_id": 23,
"prem_name": "导入校验",
"level": 4,
"perm_key": "import_check",
"prem_type": 2,
"path": "/20/22/23/24/",
"sort": 1,
"visible": 1,
"createdAt": "2024-06-20 11:04:00",
"updatedAt": "2024-06-20 11:04:00",
"children": []
}
]
}
]
}
]
}
]
}

View file

@ -1,257 +0,0 @@
<template>
<div class="container">
<a-card class="general-card" title="数据分析">
<a-row :gutter="16">
<a-col :span="24" :lg="16">
<!-- 主要图表区域 -->
<a-card class="content-card">
<template #title>销售趋势分析</template>
<div class="chart-wrapper">
<div ref="chartRef" class="chart"></div>
</div>
</a-card>
</a-col>
<a-col :span="24" :lg="8">
<!-- 数据统计卡片 -->
<a-space direction="vertical" fill style="width: 100%">
<a-card class="content-card">
<a-statistic
title="总销售额"
:value="12345678"
:precision="2"
show-group-separator
>
<template #prefix>
<icon-thumb-up />
</template>
</a-statistic>
<div class="statistic-footer">
<div class="footer-item">
<span>日环比</span>
<span class="green">
<icon-arrow-rise />
10.5%
</span>
</div>
<div class="footer-item">
<span>周环比</span>
<span class="green">
<icon-arrow-rise />
12.8%
</span>
</div>
</div>
</a-card>
<a-card class="content-card">
<a-statistic
title="访问量"
:value="8846"
:precision="0"
show-group-separator
>
<template #prefix>
<icon-user />
</template>
</a-statistic>
<div class="statistic-footer">
<div class="footer-item">
<span>日环比</span>
<span class="red">
<icon-arrow-fall />
2.5%
</span>
</div>
<div class="footer-item">
<span>周环比</span>
<span class="green">
<icon-arrow-rise />
15.2%
</span>
</div>
</div>
</a-card>
</a-space>
</a-col>
</a-row>
<a-divider />
<!-- 数据表格 -->
<a-card class="content-card">
<template #title>销售数据详情</template>
<a-table :data="tableData" :pagination="{ pageSize: 5 }">
<template #columns>
<a-table-column title="ID" data-index="id" />
<a-table-column title="商品名称" data-index="name" />
<a-table-column title="销售额" data-index="sales">
<template #cell="{ record }">{{
record.sales.toLocaleString()
}}</template>
</a-table-column>
<a-table-column title="销售量" data-index="count" />
<a-table-column title="日期" data-index="date" />
</template>
</a-table>
</a-card>
</a-card>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
import * as echarts from 'echarts';
export default defineComponent({
name: 'DataAnalysis',
setup() {
const chartRef = ref<HTMLElement | null>(null);
const chart = ref<echarts.ECharts | null>(null);
const tableData = [
{ id: 1, name: '商品A', sales: 12345, count: 123, date: '2023-01-01' },
{ id: 2, name: '商品B', sales: 23456, count: 234, date: '2023-01-02' },
{ id: 3, name: '商品C', sales: 34567, count: 345, date: '2023-01-03' },
{ id: 4, name: '商品D', sales: 45678, count: 456, date: '2023-01-04' },
{ id: 5, name: '商品E', sales: 56789, count: 567, date: '2023-01-05' },
{ id: 6, name: '商品F', sales: 67890, count: 678, date: '2023-01-06' },
{ id: 7, name: '商品G', sales: 78901, count: 789, date: '2023-01-07' },
];
const initChart = () => {
if (!chartRef.value) return;
chart.value = echarts.init(chartRef.value);
const option = {
tooltip: {
trigger: 'axis',
},
legend: {
data: ['销售额', '访问量'],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
},
yAxis: {
type: 'value',
},
series: [
{
name: '销售额',
type: 'line',
stack: '总量',
data: [120, 132, 101, 134, 90, 230, 210],
smooth: true,
lineStyle: {
width: 2,
color: '#1890ff',
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(24,144,255,0.3)' },
{ offset: 1, color: 'rgba(24,144,255,0.1)' },
]),
},
},
{
name: '访问量',
type: 'line',
stack: '总量',
data: [220, 182, 191, 234, 290, 330, 310],
smooth: true,
lineStyle: {
width: 2,
color: '#52c41a',
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(82,196,26,0.3)' },
{ offset: 1, color: 'rgba(82,196,26,0.1)' },
]),
},
},
],
};
chart.value.setOption(option);
};
onMounted(() => {
initChart();
window.addEventListener('resize', () => {
chart.value?.resize();
});
});
return {
chartRef,
tableData,
};
},
});
</script>
<style scoped lang="less">
.container {
padding: 20px;
.general-card {
margin-bottom: 24px;
}
.content-card {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.chart-wrapper {
height: 360px;
.chart {
height: 100%;
}
}
.statistic-footer {
display: flex;
justify-content: space-between;
margin-top: 16px;
.footer-item {
display: flex;
flex-direction: column;
align-items: flex-start;
span:first-child {
font-size: 12px;
color: var(--color-text-3);
}
span:last-child {
display: flex;
align-items: center;
}
.green {
color: #52c41a;
}
.red {
color: #f5222d;
}
}
}
}
</style>

View file

@ -1,298 +0,0 @@
<template>
<div class="container">
<a-card class="general-card" title="多维数据分析">
<a-row :gutter="16">
<a-col :span="24">
<!-- 图表切换控制 -->
<div class="chart-control">
<a-radio-group v-model="chartType" type="button">
<a-radio value="bar">柱状图</a-radio>
<a-radio value="line">折线图</a-radio>
<a-radio value="pie">饼图</a-radio>
</a-radio-group>
<a-select v-model="timeRange" style="width: 120px">
<a-option value="week">本周</a-option>
<a-option value="month">本月</a-option>
<a-option value="year">本年</a-option>
</a-select>
</div>
<!-- 图表区域 -->
<div class="chart-wrapper">
<div ref="chartRef" class="chart"></div>
</div>
</a-col>
</a-row>
<a-divider />
<!-- 数据维度选择 -->
<a-card class="content-card">
<template #title>数据维度配置</template>
<a-row :gutter="16">
<a-col :span="12">
<a-form :model="formData" layout="vertical">
<a-form-item field="dimension1" label="维度1">
<a-select v-model="formData.dimension1">
<a-option value="sales">销售额</a-option>
<a-option value="traffic">流量</a-option>
<a-option value="users">用户数</a-option>
</a-select>
</a-form-item>
<a-form-item field="dimension2" label="维度2">
<a-select v-model="formData.dimension2">
<a-option value="region">地区</a-option>
<a-option value="age">年龄段</a-option>
<a-option value="gender">性别</a-option>
</a-select>
</a-form-item>
</a-form>
</a-col>
<a-col :span="12">
<a-form :model="formData" layout="vertical">
<a-form-item field="dimension3" label="维度3">
<a-select v-model="formData.dimension3">
<a-option value="channel">渠道</a-option>
<a-option value="product">商品</a-option>
<a-option value="time">时间</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="updateChart"
>更新图表</a-button
>
</a-form-item>
</a-form>
</a-col>
</a-row>
</a-card>
<!-- 数据表格 -->
<a-card class="content-card">
<template #title>多维数据详情</template>
<a-table :data="tableData" :pagination="{ pageSize: 5 }">
<template #columns>
<a-table-column title="ID" data-index="id" />
<a-table-column title="维度1" data-index="dim1" />
<a-table-column title="维度2" data-index="dim2" />
<a-table-column title="维度3" data-index="dim3" />
<a-table-column title="数值" data-index="value">
<template #cell="{ record }">{{
record.value.toLocaleString()
}}</template>
</a-table-column>
</template>
</a-table>
</a-card>
</a-card>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, reactive, watch } from 'vue';
import * as echarts from 'echarts';
export default defineComponent({
name: 'MultiDimensionDataAnalysis',
setup() {
const chartRef = ref<HTMLElement | null>(null);
const chart = ref<echarts.ECharts | null>(null);
const chartType = ref('bar');
const timeRange = ref('month');
const formData = reactive({
dimension1: 'sales',
dimension2: 'region',
dimension3: 'channel',
});
// 模拟数据
const tableData = ref([
{ id: 1, dim1: '销售额', dim2: '华东', dim3: '线上', value: 4332 },
{ id: 2, dim1: '销售额', dim2: '华南', dim3: '线上', value: 2986 },
{ id: 3, dim1: '销售额', dim2: '华北', dim3: '线上', value: 3219 },
{ id: 4, dim1: '销售额', dim2: '华东', dim3: '线下', value: 3721 },
{ id: 5, dim1: '销售额', dim2: '华南', dim3: '线下', value: 2413 },
{ id: 6, dim1: '销售额', dim2: '华北', dim3: '线下', value: 1893 },
{ id: 7, dim1: '流量', dim2: '华东', dim3: '线上', value: 10243 },
{ id: 8, dim1: '流量', dim2: '华南', dim3: '线上', value: 8752 },
]);
const getChartData = () => {
// 模拟根据维度和时间范围获取不同的数据
// 实际应用中可以通过API获取
return {
xAxis: ['华东', '华南', '华北', '西南', '西北', '东北'],
series: [
{
name: '线上',
data: [4332, 2986, 3219, 2851, 1845, 2016],
},
{
name: '线下',
data: [3721, 2413, 1893, 2173, 1634, 1723],
},
],
};
};
const updateChart = () => {
if (!chart.value) return;
const chartData = getChartData();
let option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
legend: {
data: ['线上', '线下'],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
};
if (chartType.value === 'bar') {
option = {
...option,
xAxis: {
type: 'category',
data: chartData.xAxis,
},
yAxis: {
type: 'value',
},
series: chartData.series.map((item) => ({
name: item.name,
type: 'bar',
data: item.data,
})),
};
} else if (chartType.value === 'line') {
option = {
...option,
xAxis: {
type: 'category',
data: chartData.xAxis,
},
yAxis: {
type: 'value',
},
series: chartData.series.map((item) => ({
name: item.name,
type: 'line',
smooth: true,
data: item.data,
})),
};
} else if (chartType.value === 'pie') {
// 将数据转换为饼图格式
const pieData = chartData.xAxis.map((region, index) => ({
name: region,
value:
chartData.series[0].data[index] + chartData.series[1].data[index],
}));
option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
legend: {
orient: 'vertical',
left: 'left',
data: chartData.xAxis,
},
series: [
{
name: formData.dimension1,
type: 'pie',
radius: '50%',
center: ['50%', '60%'],
data: pieData,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
};
}
chart.value.setOption(option, true);
};
const initChart = () => {
if (!chartRef.value) return;
chart.value = echarts.init(chartRef.value);
updateChart();
window.addEventListener('resize', () => {
chart.value?.resize();
});
};
watch([chartType, timeRange], () => {
updateChart();
});
onMounted(() => {
initChart();
});
return {
chartRef,
chartType,
timeRange,
formData,
tableData,
updateChart,
};
},
});
</script>
<style scoped lang="less">
.container {
padding: 20px;
.general-card {
margin-bottom: 24px;
}
.content-card {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.chart-control {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
.chart-wrapper {
height: 400px;
margin-bottom: 24px;
.chart {
height: 100%;
}
}
}
</style>