d04e837d9c
- 新增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}对象格式
244 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|