Files
SBrainCO/client/src/pages/IntelligencePage.tsx
T
freedakgmail d04e837d9c V3.0: 门店分级与风险等级页面、数据填充脚本、后端API
- 新增V3.0数据库表结构(12-15)和数据填充脚本(16)
- 新增后端路由: store-grade, enterprise, product, intelligence, alert, scheduler, target
- 新增前端页面: StoreGradePage, EnterprisePage, ProductLifecyclePage, IntelligencePage, AlertManagementPage, DataImportPage, SchedulerManagementPage, TargetManagementPage
- 门店分级: 自动分级算法(达成率40%+毛利率25%+会员15%+风险20%)
- 风险等级: 基于mv_store_risk_rating模型展示风险分布和原因
- 弹窗: 雷达图+详细指标+分级原因+风险原因(primary_issue)
- FilterableTable: filterOptions支持{value,label}对象格式
2026-08-05 20:09:39 +08:00

244 lines
12 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Tabs } from '@/components/Tabs'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line } from 'recharts'
export function IntelligencePage() {
const [tab, setTab] = useState('iot')
const { data: deviceData, isLoading: deviceLoading } = useQuery({
queryKey: ['intelligence/iot/devices'],
queryFn: () => api.get('/intelligence/iot/devices'),
enabled: tab === 'iot',
})
const { data: forecastData, isLoading: forecastLoading } = useQuery({
queryKey: ['intelligence/ai/forecasts'],
queryFn: () => api.get('/intelligence/ai/forecasts'),
enabled: tab === 'ai',
})
const { data: roiData, isLoading: roiLoading } = useQuery({
queryKey: ['intelligence/strategy/roi'],
queryFn: () => api.get('/intelligence/strategy/roi'),
enabled: tab === 'strategy',
})
const { data: brandData, isLoading: brandLoading } = useQuery({
queryKey: ['intelligence/strategy/brand-assets'],
queryFn: () => api.get('/intelligence/strategy/brand-assets'),
enabled: tab === 'strategy',
})
const { data: realtimeData, isLoading: realtimeLoading } = useQuery({
queryKey: ['intelligence/realtime/dashboard'],
queryFn: () => api.get('/intelligence/realtime/dashboard'),
enabled: tab === 'realtime',
})
if ((tab === 'iot' && deviceLoading) || (tab === 'ai' && forecastLoading) || (tab === 'strategy' && roiLoading) || (tab === 'realtime' && realtimeLoading)) {
return <LoadingSpinner text="加载智能升级数据..." />
}
const devices = (deviceData as any)?.data || []
const forecasts = (forecastData as any)?.data || []
const rois = (roiData as any)?.data || []
const brands = (brandData as any)?.data || []
const realtime = (realtimeData as any)?.data || []
const onlineDevices = devices.filter((d: any) => d.status === 'online').length
const alarmDevices = devices.filter((d: any) => d.status === 'alarm').length
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">IoT设备 · AI预测 · · </p>
</div>
<Tabs tabs={[
{ key: 'iot', label: 'IoT设备' },
{ key: 'ai', label: 'AI预测' },
{ key: 'realtime', label: '实时看板' },
{ key: 'strategy', label: '战略驾驶舱' },
]} active={tab} onChange={setTab} />
{tab === 'iot' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="设备总数" value={devices.length} format="number" />
<MetricCard title="在线" value={onlineDevices} format="number" status="good" />
<MetricCard title="告警" value={alarmDevices} format="number" status="bad" />
<MetricCard title="离线" value={devices.length - onlineDevices - alarmDevices} format="number" status="warn" />
</div>
<CollapsibleSection title={`IoT设备列表 (${devices.length})`}>
<FilterableTable
data={devices}
filterKey="device_type"
filterLabel="全部类型"
sortOptions={[{ key: 'device_code', label: '编码' }, { key: 'updated_at', label: '更新时间' }]}
defaultSort="updated_at"
defaultOrder="desc"
columns={[
{ key: 'device_code', label: '设备编码' },
{ key: 'device_name', label: '设备名称' },
{ key: 'device_type', label: '类型' },
{ key: 'store_code', label: '门店' },
{ key: 'location', label: '位置' },
{ key: 'status', label: '状态', align: 'center', render: (r) => {
const colors: Record<string, string> = { online: 'text-green-600', offline: 'text-gray-600', alarm: 'text-red-600' }
return <span className={`font-bold ${colors[r.status] || ''}`}>{r.status}</span>
}},
{ key: 'last_reading_at', label: '最后读数', render: (r) => r.last_reading_at ? (r.last_reading_at as string).substring(0, 19).replace('T', ' ') : '-' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'ai' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="预测总数" value={forecasts.length} format="number" />
<MetricCard title="预测类型" value={new Set(forecasts.map((f: any) => f.forecast_type)).size} format="number" />
<MetricCard title="模型数" value={new Set(forecasts.map((f: any) => f.model_type)).size} format="number" />
</div>
{forecasts.length > 0 && (
<CollapsibleSection title="预测趋势" subtitle="按目标日期">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={forecasts.slice(0, 30).reverse().map((f: any) => ({
date: (f.target_date || '').substring(0, 10),
predicted: Number(f.predicted_value || 0),
lower: Number(f.confidence_lower || 0),
upper: Number(f.confidence_upper || 0),
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={10} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="predicted" stroke="#3b82f6" name="预测值" />
<Line type="monotone" dataKey="lower" stroke="#94a3b8" name="下界" strokeDasharray="5 5" />
<Line type="monotone" dataKey="upper" stroke="#94a3b8" name="上界" strokeDasharray="5 5" />
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`预测列表 (${forecasts.length})`}>
<FilterableTable
data={forecasts}
filterKey="forecast_type"
filterLabel="全部类型"
sortOptions={[{ key: 'target_date', label: '目标日期' }, { key: 'predicted_value', label: '预测值' }]}
defaultSort="target_date"
defaultOrder="desc"
columns={[
{ key: 'forecast_type', label: '类型' },
{ key: 'target_date', label: '目标日期', render: (r) => (r.target_date || '').substring(0, 10) },
{ key: 'store_code', label: '门店' },
{ key: 'predicted_value', label: '预测值', align: 'right', render: (r) => formatNumber(r.predicted_value) },
{ key: 'confidence_lower', label: '下界', align: 'right', render: (r) => formatNumber(r.confidence_lower) },
{ key: 'confidence_upper', label: '上界', align: 'right', render: (r) => formatNumber(r.confidence_upper) },
{ key: 'model_type', label: '模型', align: 'center' },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'realtime' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<MetricCard title="今日门店数" value={realtime.length} format="number" />
<MetricCard title="今日总营收" value={realtime.reduce((s: number, r: any) => s + Number(r.today_revenue || 0), 0)} format="currency" />
<MetricCard title="今日总单数" value={realtime.reduce((s: number, r: any) => s + Number(r.bill_count || 0), 0)} format="number" />
</div>
{realtime.length > 0 && (
<CollapsibleSection title="今日营收排行" subtitle="实时数据">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={realtime.slice(0, 20).map((r: any) => ({
store: r.store_code,
revenue: Number(r.today_revenue || 0),
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="store" fontSize={10} />
<YAxis />
<Tooltip formatter={(v: any) => [formatCurrency(v), '营收']} />
<Bar dataKey="revenue" fill="#3b82f6" name="今日营收" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`实时看板 (${realtime.length})`}>
<FilterableTable
data={realtime}
sortOptions={[{ key: 'today_revenue', label: '今日营收' }, { key: 'bill_count', label: '单数' }]}
defaultSort="today_revenue"
defaultOrder="desc"
columns={[
{ key: 'store_code', label: '门店' },
{ key: 'today_revenue', label: '今日营收', align: 'right', render: (r) => formatCurrency(r.today_revenue) },
{ key: 'bill_count', label: '单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'avg_transaction', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_transaction) },
]}
/>
</CollapsibleSection>
</>
)}
{tab === 'strategy' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="门店ROI数" value={rois.length} format="number" />
<MetricCard title="平均回本月数" value={rois.length > 0 ? rois.reduce((s: number, r: any) => s + Number(r.payback_months || 0), 0) / rois.length : 0} format="number" />
<MetricCard title="品牌追踪天数" value={brands.length} format="number" />
<MetricCard title="最新NPS" value={brands[0]?.nps_score || 0} format="number" status={(brands[0]?.nps_score || 0) >= 50 ? 'good' : 'warn'} />
</div>
{brands.length > 0 && (
<CollapsibleSection title="品牌资产趋势" subtitle="NPS + 搜索指数">
<ResponsiveContainer width="100%" height={250}>
<LineChart data={brands.slice(0, 30).reverse().map((b: any) => ({
date: (b.tracking_date || '').substring(0, 10),
nps: Number(b.nps_score || 0),
search: Number(b.search_index || 0),
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={10} />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="nps" stroke="#22c55e" name="NPS" />
<Line type="monotone" dataKey="search" stroke="#3b82f6" name="搜索指数" />
</LineChart>
</ResponsiveContainer>
</CollapsibleSection>
)}
<CollapsibleSection title={`门店ROI (${rois.length})`}>
<FilterableTable
data={rois}
sortOptions={[{ key: 'payback_months', label: '回本月数' }, { key: 'roi_pct', label: 'ROI' }, { key: 'store_name', label: '门店' }]}
defaultSort="payback_months"
defaultOrder="asc"
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'initial_investment', label: '初始投资', align: 'right', render: (r) => formatCurrency(r.initial_investment) },
{ key: 'monthly_revenue', label: '月营收', align: 'right', render: (r) => formatCurrency(r.monthly_revenue) },
{ key: 'monthly_profit', label: '月利润', align: 'right', render: (r) => formatCurrency(r.monthly_profit) },
{ key: 'payback_months', label: '回本月数', align: 'right', render: (r) => r.payback_months ? `${Number(r.payback_months).toFixed(1)}` : '-' },
{ key: 'roi_pct', label: 'ROI', align: 'right', render: (r) => formatPercent(r.roi_pct) },
]}
/>
</CollapsibleSection>
</>
)}
</div>
)
}