feat(project): 新增客流统计独立页面

- 新建 /ops/traffic 路由和客流统计页面
- 新建客流 API 模块(全局/设备/区域维度的实时和趋势接口)
- 新建 AreaTree 组件(区域树选择、搜索过滤保留祖先节点、递归获取子孙ID)
- 支持全局总览和按区域查看客流数据
- 今日/昨日小时客流趋势对比曲线图、近7天客流趋势折线图
- 核心指标卡片 + 客流态势分析面板
- 更新旧 API 路径适配新后端接口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-02-26 16:52:37 +08:00
parent f1284142ac
commit 9bf042f817
5 changed files with 969 additions and 582 deletions

View File

@@ -188,22 +188,22 @@ export interface DashboardStatsResp {
completedTodayCount: number; completedTodayCount: number;
completedTotalCount: number; completedTotalCount: number;
trendData: { trendData: {
dates: string[];
createdData: number[];
completedData: number[]; completedData: number[];
createdData: number[];
dates: string[];
}; };
hourlyDistribution: { hours: string[]; data: number[] }; hourlyDistribution: { data: number[]; hours: string[] };
timeTrendData: { timeTrendData: {
completionTimeData: number[];
dates: string[]; dates: string[];
responseTimeData: number[]; responseTimeData: number[];
completionTimeData: number[];
}; };
funnelData: Array<{ name: string; value: number }>; funnelData: Array<{ name: string; value: number }>;
heatmapData: { days: string[]; hours: string[]; data: number[][] }; heatmapData: { data: number[][]; days: string[]; hours: string[] };
functionTypeRanking: Array<{ functionTypeRanking: Array<{
functionType: string;
count: number;
completed: number; completed: number;
count: number;
functionType: string;
rate: number; rate: number;
}>; }>;
} }
@@ -226,10 +226,10 @@ export interface TrafficRealtimeResp {
areas: Array<{ areas: Array<{
areaId: number; areaId: number;
areaName: string; areaName: string;
todayIn: number;
todayOut: number;
currentOccupancy: number; currentOccupancy: number;
deviceCount: number; deviceCount: number;
todayIn: number;
todayOut: number;
}>; }>;
hourlyTrend: { hours: string[]; inData: number[]; outData: number[] }; hourlyTrend: { hours: string[]; inData: number[]; outData: number[] };
} }
@@ -244,15 +244,15 @@ export interface WorkspaceStatsResp {
todayOrderCount: number; todayOrderCount: number;
newOrderCount: number; newOrderCount: number;
urgentTasks: Array<{ urgentTasks: Array<{
assigneeName: string;
createTime: string;
id: number; id: number;
title: string;
location: string; location: string;
priority: number; priority: number;
status: string; status: string;
createTime: string; title: string;
assigneeName: string;
}>; }>;
workOrderTrend: { hours: string[]; data: number[] }; workOrderTrend: { data: number[]; hours: string[] };
} }
/** 获取看板完整统计 */ /** 获取看板完整统计 */
@@ -265,9 +265,7 @@ export function getDashboardStats(params?: DashboardStatsQuery) {
/** 获取实时客流数据 */ /** 获取实时客流数据 */
export function getTrafficRealtime() { export function getTrafficRealtime() {
return requestClient.get<TrafficRealtimeResp>( return requestClient.get<TrafficRealtimeResp>('/ops/traffic/realtime');
'/ops/order-center/traffic-realtime',
);
} }
/** 获取工作台统计数据 */ /** 获取工作台统计数据 */
@@ -279,9 +277,7 @@ export function getWorkspaceStats() {
/** 获取近7天客流趋势统计 */ /** 获取近7天客流趋势统计 */
export function getTrafficTrend() { export function getTrafficTrend() {
return requestClient.get<TrafficTrendResp>( return requestClient.get<TrafficTrendResp>('/ops/traffic/trend');
'/ops/order-center/traffic-trend',
);
} }
// ==================== 工单操作接口 ==================== // ==================== 工单操作接口 ====================

View File

@@ -0,0 +1,117 @@
import { requestClient } from '#/api/request';
// ==================== 类型定义 ====================
/** 实时客流区域信息 */
export interface TrafficAreaItem {
areaId: number;
areaName: string;
todayIn: number;
todayOut: number;
currentOccupancy: number;
deviceCount: number;
}
/** 全局实时客流响应 */
export interface TrafficRealtimeResp {
totalIn: number;
totalOut: number;
currentOccupancy: number;
areas: TrafficAreaItem[];
hourlyTrend: {
hours: string[];
inData: number[];
outData: number[];
};
yesterdayHourlyTrend?: {
hours: string[];
inData: number[];
outData: number[];
};
message?: string; // 提示信息,如"该区域暂未配置客流设备"
}
/** 客流趋势响应(全局/设备/区域通用) */
export interface TrafficTrendResp {
deviceId?: null | number;
areaId?: null | number;
dates: string[];
inData: number[];
outData: number[];
netData: number[];
totalIn: number;
totalOut: number;
}
/** 设备/区域实时客流响应(单条) */
export interface DeviceTrafficRealtimeResp {
deviceId: number;
deviceName: null | string;
areaId: null | number;
areaName: null | string;
todayIn: number;
todayOut: number;
currentOccupancy: number;
hourlyTrend: {
hours: string[];
inData: number[];
outData: number[];
};
yesterdayHourlyTrend?: {
hours: string[];
inData: number[];
outData: number[];
};
}
/** 趋势查询参数 */
export interface TrafficTrendQuery {
deviceId?: number;
areaId?: number;
areaIds?: string; // 逗号分隔的区域ID列表
startDate?: string; // yyyy-MM-dd
endDate?: string; // yyyy-MM-dd
}
// ==================== 迁移接口(路径变更) ====================
/** 获取全局实时客流数据 */
export function getTrafficRealtime() {
return requestClient.get<TrafficRealtimeResp>('/ops/traffic/realtime');
}
/** 获取全局近7天客流趋势 */
export function getTrafficTrend() {
return requestClient.get<TrafficTrendResp>('/ops/traffic/trend');
}
// ==================== 新增接口 ====================
/** 获取单设备实时客流 */
export function getDeviceRealtime(deviceId: number) {
return requestClient.get<DeviceTrafficRealtimeResp>(
'/ops/traffic/device/realtime',
{ params: { deviceId } },
);
}
/** 获取区域实时客流(汇总,返回与全局一致的结构) */
export function getAreaRealtime(areaIds: number[]) {
return requestClient.get<TrafficRealtimeResp>('/ops/traffic/area/realtime', {
params: { areaIds: areaIds.join(',') },
});
}
/** 获取单设备客流趋势 */
export function getDeviceTrend(params: TrafficTrendQuery) {
return requestClient.get<TrafficTrendResp>('/ops/traffic/device/trend', {
params,
});
}
/** 获取区域客流趋势(汇总) */
export function getAreaTrend(params: TrafficTrendQuery) {
return requestClient.get<TrafficTrendResp>('/ops/traffic/area/trend', {
params,
});
}

View File

@@ -21,6 +21,16 @@ const routes: RouteRecordRaw[] = [
}, },
component: () => import('#/views/ops/area/index.vue'), component: () => import('#/views/ops/area/index.vue'),
}, },
// 客流统计
{
path: 'traffic',
name: 'OpsTraffic',
meta: {
title: '客流统计',
activePath: '/ops/traffic',
},
component: () => import('#/views/ops/traffic/index.vue'),
},
// 保洁工单详情 // 保洁工单详情
{ {
path: 'cleaning/work-order/detail/:id', path: 'cleaning/work-order/detail/:id',

View File

@@ -0,0 +1,159 @@
<script lang="ts" setup>
import type { OpsAreaApi } from '#/api/ops/area';
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { handleTree } from '@vben/utils';
import { Input, Spin, Tree } from 'ant-design-vue';
import { getAreaTree } from '#/api/ops/area';
const props = withDefaults(
defineProps<{
/** 仅查询启用的区域 */
activeOnly?: boolean;
}>(),
{ activeOnly: true },
);
const emit = defineEmits<{
/** 选中区域节点,传出完整节点对象;取消选中时传 null */
select: [area: null | OpsAreaApi.BusArea];
}>();
const areaList = ref<OpsAreaApi.BusArea[]>([]);
const areaTree = ref<any[]>([]);
const loading = ref(false);
const searchValue = ref('');
/** 搜索过滤:保留匹配节点及其祖先,维持树结构 */
function handleSearch(e: any) {
const value = e.target.value;
searchValue.value = value;
if (!value) {
areaTree.value = handleTree(areaList.value, 'id', 'parentId', 'children');
return;
}
const lower = value.toLowerCase();
// 找到匹配的节点 ID
const matchedIds = new Set<number>();
for (const item of areaList.value) {
if (item.areaName.toLowerCase().includes(lower)) {
matchedIds.add(item.id!);
}
}
// 向上补全所有祖先节点
const idMap = new Map(areaList.value.map((a) => [a.id, a]));
const keepIds = new Set<number>(matchedIds);
for (const id of matchedIds) {
let current = idMap.get(id);
while (current?.parentId) {
keepIds.add(current.parentId);
current = idMap.get(current.parentId);
}
}
const filtered = areaList.value.filter((item) => keepIds.has(item.id!));
areaTree.value = handleTree(filtered, 'id', 'parentId', 'children');
}
/** 选中节点 */
function handleSelect(selectedKeys: any[], info: any) {
if (selectedKeys.length === 0) {
emit('select', null);
return;
}
const node = info.node.dataRef || info.node;
emit('select', node);
}
/** 加载区域树 */
async function loadTree() {
loading.value = true;
try {
const data = await getAreaTree(
props.activeOnly ? { isActive: true } : undefined,
);
areaList.value = data;
areaTree.value = handleTree(data, 'id', 'parentId', 'children');
} catch (error) {
console.error('获取区域树失败', error);
} finally {
loading.value = false;
}
}
/** 根据 id 向上查找,拼接 "父级 / 子级" 路径 */
function getAreaPath(id: number | undefined): string {
if (!id) return '';
const map = new Map(areaList.value.map((a) => [a.id, a]));
const parts: string[] = [];
let current = map.get(id);
while (current) {
parts.unshift(current.areaName);
current = current.parentId ? map.get(current.parentId) : undefined;
}
return parts.join(' / ');
}
/** 递归查找某区域及其所有子孙区域的 ID 列表 */
function getDescendantIds(id: number): number[] {
const result: number[] = [id];
const visited = new Set<number>([id]);
function collect(parentId: number) {
for (const area of areaList.value) {
if (
area.parentId === parentId &&
area.id !== null &&
area.id !== undefined &&
!visited.has(area.id)
) {
visited.add(area.id);
result.push(area.id);
collect(area.id);
}
}
}
collect(id);
return result;
}
/** 暴露刷新方法和路径查询供父组件调用 */
defineExpose({ refresh: loadTree, getAreaPath, getDescendantIds });
onMounted(loadTree);
</script>
<template>
<div>
<Input
v-model:value="searchValue"
placeholder="搜索区域"
allow-clear
class="w-full"
@change="handleSearch"
>
<template #prefix>
<IconifyIcon icon="lucide:search" class="size-4" />
</template>
</Input>
<Spin :spinning="loading" wrapper-class-name="w-full">
<Tree
v-if="areaTree.length > 0"
class="pt-2"
:tree-data="areaTree"
:default-expand-all="true"
:field-names="{
title: 'areaName',
key: 'id',
children: 'children',
}"
@select="handleSelect"
/>
<div v-else-if="!loading" class="py-4 text-center text-gray-500">
暂无区域数据
</div>
</Spin>
</div>
</template>

File diff suppressed because it is too large Load Diff