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}对象格式
210 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|