feat(project): 新增客流统计独立页面
- 新建 /ops/traffic 路由和客流统计页面 - 新建客流 API 模块(全局/设备/区域维度的实时和趋势接口) - 新建 AreaTree 组件(区域树选择、搜索过滤保留祖先节点、递归获取子孙ID) - 支持全局总览和按区域查看客流数据 - 今日/昨日小时客流趋势对比曲线图、近7天客流趋势折线图 - 核心指标卡片 + 客流态势分析面板 - 更新旧 API 路径适配新后端接口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -188,22 +188,22 @@ export interface DashboardStatsResp {
|
||||
completedTodayCount: number;
|
||||
completedTotalCount: number;
|
||||
trendData: {
|
||||
dates: string[];
|
||||
createdData: number[];
|
||||
completedData: number[];
|
||||
createdData: number[];
|
||||
dates: string[];
|
||||
};
|
||||
hourlyDistribution: { hours: string[]; data: number[] };
|
||||
hourlyDistribution: { data: number[]; hours: string[] };
|
||||
timeTrendData: {
|
||||
completionTimeData: number[];
|
||||
dates: string[];
|
||||
responseTimeData: number[];
|
||||
completionTimeData: number[];
|
||||
};
|
||||
funnelData: Array<{ name: string; value: number }>;
|
||||
heatmapData: { days: string[]; hours: string[]; data: number[][] };
|
||||
heatmapData: { data: number[][]; days: string[]; hours: string[] };
|
||||
functionTypeRanking: Array<{
|
||||
functionType: string;
|
||||
count: number;
|
||||
completed: number;
|
||||
count: number;
|
||||
functionType: string;
|
||||
rate: number;
|
||||
}>;
|
||||
}
|
||||
@@ -226,10 +226,10 @@ export interface TrafficRealtimeResp {
|
||||
areas: Array<{
|
||||
areaId: number;
|
||||
areaName: string;
|
||||
todayIn: number;
|
||||
todayOut: number;
|
||||
currentOccupancy: number;
|
||||
deviceCount: number;
|
||||
todayIn: number;
|
||||
todayOut: number;
|
||||
}>;
|
||||
hourlyTrend: { hours: string[]; inData: number[]; outData: number[] };
|
||||
}
|
||||
@@ -244,15 +244,15 @@ export interface WorkspaceStatsResp {
|
||||
todayOrderCount: number;
|
||||
newOrderCount: number;
|
||||
urgentTasks: Array<{
|
||||
assigneeName: string;
|
||||
createTime: string;
|
||||
id: number;
|
||||
title: string;
|
||||
location: string;
|
||||
priority: number;
|
||||
status: string;
|
||||
createTime: string;
|
||||
assigneeName: string;
|
||||
title: string;
|
||||
}>;
|
||||
workOrderTrend: { hours: string[]; data: number[] };
|
||||
workOrderTrend: { data: number[]; hours: string[] };
|
||||
}
|
||||
|
||||
/** 获取看板完整统计 */
|
||||
@@ -265,9 +265,7 @@ export function getDashboardStats(params?: DashboardStatsQuery) {
|
||||
|
||||
/** 获取实时客流数据 */
|
||||
export function getTrafficRealtime() {
|
||||
return requestClient.get<TrafficRealtimeResp>(
|
||||
'/ops/order-center/traffic-realtime',
|
||||
);
|
||||
return requestClient.get<TrafficRealtimeResp>('/ops/traffic/realtime');
|
||||
}
|
||||
|
||||
/** 获取工作台统计数据 */
|
||||
@@ -279,9 +277,7 @@ export function getWorkspaceStats() {
|
||||
|
||||
/** 获取近7天客流趋势统计 */
|
||||
export function getTrafficTrend() {
|
||||
return requestClient.get<TrafficTrendResp>(
|
||||
'/ops/order-center/traffic-trend',
|
||||
);
|
||||
return requestClient.get<TrafficTrendResp>('/ops/traffic/trend');
|
||||
}
|
||||
|
||||
// ==================== 工单操作接口 ====================
|
||||
|
||||
117
apps/web-antd/src/api/ops/traffic/index.ts
Normal file
117
apps/web-antd/src/api/ops/traffic/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,16 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
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',
|
||||
|
||||
159
apps/web-antd/src/views/ops/components/AreaTree.vue
Normal file
159
apps/web-antd/src/views/ops/components/AreaTree.vue
Normal 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
Reference in New Issue
Block a user