Files
SBrainCO/client/src/pages/ProductLifecyclePage.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

210 lines
11 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } 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 { formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const STAGE_COLORS: Record<string, string> = { '爬坡': '#3b82f6', '成熟': '#22c55e', '衰退': '#eab308', '淘汰': '#ef4444' }
export function ProductLifecyclePage() {
const [tab, setTab] = useState('lifecycle')
const [stage, setStage] = useState('')
const queryClient = useQueryClient()
const { data: productData, isLoading: productLoading } = useQuery({
queryKey: ['product/products', stage],
queryFn: () => api.get('/product/products', { params: stage ? { stage } : {} }),
})
const { data: reviewOverview, isLoading: reviewLoading } = useQuery({
queryKey: ['product/reviews/overview'],
queryFn: () => api.get('/product/reviews/overview'),
})
const { data: reviewData, isLoading: reviewListLoading } = useQuery({
queryKey: ['product/reviews'],
queryFn: () => api.get('/product/reviews', { params: { page: 1, page_size: 100 } }),
})
const trackMutation = useMutation({
mutationFn: (id: number) => api.post(`/product/products/${id}/track`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product/products'] }),
})
if (tab === 'lifecycle' && productLoading) return <LoadingSpinner text="加载产品生命周期数据..." />
if (tab === 'reviews' && reviewLoading) return <LoadingSpinner text="加载评价数据..." />
const products = (productData as any)?.data || []
const reviews = (reviewData as any)?.data || []
const overview = (reviewOverview as any)?.data || {}
const stageDistribution = products.reduce((acc: Record<string, number>, r: any) => {
acc[r.lifecycle_stage] = (acc[r.lifecycle_stage] || 0) + 1
return acc
}, {})
const stagePieData = Object.entries(stageDistribution).map(([name, value]) => ({ name, value }))
const categoryData = [
{ name: '口味', value: Number(overview.taste_count || 0) },
{ name: '服务', value: Number(overview.service_count || 0) },
{ name: '环境', value: Number(overview.env_count || 0) },
{ name: '异物', value: Number(overview.foreign_matter_count || 0) },
{ name: '分量', value: Number(overview.portion_count || 0) },
].filter(d => d.value > 0)
const totalReviews = Number(overview.total_reviews || 0)
const positiveRate = totalReviews > 0 ? (Number(overview.positive || 0) / totalReviews * 100) : 0
const avgRating = Number(overview.avg_rating || 0)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">SKU生命周期追踪 · NLP归因 · </p>
</div>
</div>
<Tabs tabs={[
{ key: 'lifecycle', label: '产品生命周期' },
{ key: 'reviews', label: '评价分析' },
]} active={tab} onChange={setTab} />
{tab === 'lifecycle' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="产品总数" value={products.length} format="number" description="在追踪的SKU数量" />
<MetricCard title="爬坡期" value={stageDistribution['爬坡'] || 0} format="number" description="上市7天内" status="warn" />
<MetricCard title="成熟期" value={stageDistribution['成熟'] || 0} format="number" description="上市7-90天" status="good" />
<MetricCard title="衰退期" value={stageDistribution['衰退'] || 0} format="number" description="上市90天+" status="bad" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="生命周期阶段分布" subtitle="各阶段SKU数量">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={stagePieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{stagePieData.map((entry, i) => <Cell key={i} fill={STAGE_COLORS[entry.name] || '#999'} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), 'SKU数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="各阶段产品列表" subtitle="按上市日期排序">
<FilterableTable
data={products}
filterKey="lifecycle_stage"
filterLabel="全部阶段"
sortOptions={[
{ key: 'launch_date', label: '上市日期' },
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'lifecycle_stage', label: '阶段' },
]}
defaultSort="launch_date"
defaultOrder="desc"
columns={[
{ key: 'sku_code', label: 'SKU编码' },
{ key: 'sku_name', label: 'SKU名称' },
{ key: 'launch_date', label: '上市日期', render: (r) => (r.launch_date || '').toString().substring(0, 10) },
{ key: 'lifecycle_stage', label: '阶段', align: 'center', render: (r) => {
const colors: Record<string, string> = { '爬坡': 'text-blue-600', '成熟': 'text-green-600', '衰退': 'text-yellow-600', '淘汰': 'text-red-600' }
return <span className={`font-bold ${colors[r.lifecycle_stage] || ''}`}>{r.lifecycle_stage}</span>
}},
{ key: 'survival_status', label: '存活状态', align: 'center' },
{ key: 'first_week_report_date', label: '首周战报', render: (r) => r.first_week_report_date ? (r.first_week_report_date as string).substring(0, 10) : '-' },
{ key: 'day90_report_date', label: '90天判定', render: (r) => r.day90_report_date ? (r.day90_report_date as string).substring(0, 10) : '-' },
{ key: 'action', label: '操作', align: 'center', render: (r) => (
<button onClick={() => trackMutation.mutate(r.id)} className="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground"></button>
)},
]}
/>
</CollapsibleSection>
</div>
</>
)}
{tab === 'reviews' && (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="评价总数" value={totalReviews} format="number" description="所有来源评价" />
<MetricCard title="平均评分" value={avgRating} format="number" description="满分5分" status={avgRating >= 4 ? 'good' : avgRating >= 3 ? 'warn' : 'bad'} />
<MetricCard title="好评率" value={positiveRate} format="percent" description="正面评价占比" status={positiveRate >= 80 ? 'good' : positiveRate >= 60 ? 'warn' : 'bad'} />
<MetricCard title="差评数" value={Number(overview.negative || 0)} format="number" description="负面评价数" status="bad" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="评价归因分布" subtitle="NLP自动分类">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={categoryData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
{categoryData.map((_, i) => <Cell key={i} fill={['#ef4444', '#3b82f6', '#22c55e', '#eab308', '#a855f7'][i]} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '条']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="评价趋势" subtitle="按日期统计">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={reviews.slice(0, 30).reverse().map((r: any) => ({
date: (r.review_date || '').substring(0, 10),
rating: Number(r.rating || 0),
}))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={10} />
<YAxis domain={[0, 5]} />
<Tooltip />
<Legend />
<Bar dataKey="rating" fill="#3b82f6" name="评分" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
<CollapsibleSection title={`评价明细 (${reviews.length})`} subtitle="按日期降序">
<FilterableTable
data={reviews}
filterKey="nlp_category"
filterLabel="全部分类"
sortOptions={[
{ key: 'review_date', label: '评价日期' },
{ key: 'rating', label: '评分' },
]}
defaultSort="review_date"
defaultOrder="desc"
searchKeys={['content', 'store_name', 'sku_name']}
searchPlaceholder="搜索评价内容..."
columns={[
{ key: 'review_date', label: '日期', render: (r) => (r.review_date || '').toString().substring(0, 10) },
{ key: 'review_source', label: '来源' },
{ key: 'store_name', label: '门店' },
{ key: 'sku_name', label: 'SKU' },
{ key: 'rating', label: '评分', align: 'center', render: (r) => {
const colors: Record<number, string> = { 1: 'text-red-600', 2: 'text-red-500', 3: 'text-yellow-600', 4: 'text-blue-600', 5: 'text-green-600' }
return <span className={`font-bold ${colors[r.rating] || ''}`}>{r.rating}</span>
}},
{ key: 'nlp_category', label: '归因', align: 'center' },
{ key: 'nlp_sentiment', label: '情感', align: 'center', render: (r) => {
const colors: Record<string, string> = { '正面': 'text-green-600', '负面': 'text-red-600', '中性': 'text-yellow-600' }
return <span className={colors[r.nlp_sentiment] || ''}>{r.nlp_sentiment}</span>
}},
{ key: 'content', label: '评价内容' },
]}
/>
</CollapsibleSection>
</>
)}
</div>
)
}