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;
|
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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 工单操作接口 ====================
|
// ==================== 工单操作接口 ====================
|
||||||
|
|||||||
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'),
|
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',
|
||||||
|
|||||||
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