9b3a9cdb4b
- 库存周转: turnover_days零消耗时返回NULL并显示"无消耗",排序至末尾; 门店名称三级回退(dim_store→mv_distribution_monthly→硬编码中央厨房) - 成本管理: estimated_inventory_days为NULL时显示"-"而非"0.0天"; 补充成本率口径与指标关系说明 - 成本分析: 修复理论成本率计算(按单位售价而非单价),新增sales_quantity展示; 区分菜品级(Excel)与门店级(倒挤)成本指标,说明差异原因 - 配送对账: 区分中央厨房/门店,分层展示耗用口径 - 告警/任务/目标管理: 新增详情弹窗组件,优化交互体验 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
/**
|
||
* 预警日志详情弹窗
|
||
*
|
||
* 点击预警日志行后弹出,展示完整预警信息、指标解释、问题诊断和整改建议,
|
||
* 并支持处理操作。
|
||
*/
|
||
import { useState } from 'react'
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { X, AlertTriangle, CheckCircle, EyeOff, Stethoscope, Wrench } from 'lucide-react'
|
||
import api from '@/lib/api'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
interface AlertLog {
|
||
id: number
|
||
rule_id: number | null
|
||
rule_name: string
|
||
store_code: string
|
||
store_name: string
|
||
metric_value: number | string
|
||
threshold: number | string
|
||
severity: string
|
||
push_status: string | null
|
||
handle_status: string
|
||
handle_comment: string | null
|
||
triggered_at: string
|
||
handled_at: string | null
|
||
}
|
||
|
||
interface AlertLogDetailDialogProps {
|
||
log: AlertLog
|
||
onClose: () => void
|
||
}
|
||
|
||
const SEVERITY_LABELS: Record<string, string> = { red: '红灯', yellow: '黄灯', green: '绿灯' }
|
||
const SEVERITY_COLORS: Record<string, string> = {
|
||
red: 'bg-red-100 text-red-700 border-red-200',
|
||
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||
green: 'bg-green-100 text-green-700 border-green-200',
|
||
}
|
||
const STATUS_LABELS: Record<string, string> = { pending: '待处理', handling: '处理中', resolved: '已解决', ignored: '已忽略' }
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
pending: 'bg-gray-100 text-gray-600 border-gray-200',
|
||
handling: 'bg-blue-100 text-blue-700 border-blue-200',
|
||
resolved: 'bg-green-100 text-green-700 border-green-200',
|
||
ignored: 'bg-gray-100 text-gray-500 border-gray-200',
|
||
}
|
||
const PUSH_LABELS: Record<string, string> = {
|
||
pending: '待推送',
|
||
pushed: '已推送',
|
||
failed: '推送失败',
|
||
null: '未推送',
|
||
}
|
||
|
||
/** 按规则名称映射:指标解释、问题诊断、整改建议 */
|
||
const RULE_DETAILS: Record<string, {
|
||
metricLabel: string
|
||
metricUnit: string
|
||
thresholdDesc: string
|
||
problemDiagnosis: string[]
|
||
remediation: string[]
|
||
}> = {
|
||
'营收达成率红灯': {
|
||
metricLabel: '营收达成率',
|
||
metricUnit: '%',
|
||
thresholdDesc: '阈值 80%,低于此值表示门店日营收远低于目标,存在严重经营风险',
|
||
problemDiagnosis: [
|
||
'门店实际营收显著低于目标设定值,达成率不足 80%',
|
||
'可能原因:客流下降、客单价下滑、促销活动效果不佳、周边竞争加剧',
|
||
'需排查是目标设定过高还是实际经营下滑',
|
||
],
|
||
remediation: [
|
||
'店长复盘当日客流与客单价数据,定位下滑原因',
|
||
'区域经理 48 小时内到店诊断,提交改善方案',
|
||
'检查近期是否有竞争对手开业或促销活动影响',
|
||
'若目标设定不合理(如新店按成熟店标准),申请调整目标基准',
|
||
'连续 3 天低于 80% 升级为专项整改任务',
|
||
],
|
||
},
|
||
'营收达成率黄灯': {
|
||
metricLabel: '营收达成率',
|
||
metricUnit: '%',
|
||
thresholdDesc: '阈值 90%,低于此值表示门店营收未达预期,需关注',
|
||
problemDiagnosis: [
|
||
'门店营收达成率在 80%-90% 之间,未达预期但尚未严重恶化',
|
||
'可能原因:天气影响、节假日因素、短期客流波动',
|
||
],
|
||
remediation: [
|
||
'店长关注每日营收趋势,记录异常原因',
|
||
'区域经理周检时复盘,判断是否为偶发或趋势性下滑',
|
||
'若连续一周低于 90%,启动改善行动',
|
||
],
|
||
},
|
||
'毛利率异常': {
|
||
metricLabel: '理论毛利率',
|
||
metricUnit: '%',
|
||
thresholdDesc: '阈值 40%,低于此值表示食材成本占比过高,利润空间被压缩',
|
||
problemDiagnosis: [
|
||
'理论毛利率低于 40%,食材成本占营收比超过 60%',
|
||
'可能原因:菜品定价偏低、食材采购价上涨、BOM 配方不合理、高成本菜品占比过高',
|
||
],
|
||
remediation: [
|
||
'菜品利润优化师核查 BOM 配方与实际用量差异',
|
||
'供应链排查近期食材采购单价是否异常上涨',
|
||
'菜单工程分析:评估低毛利菜品占比,考虑优化或淘汰',
|
||
'门店检查是否存在超量出品或标准执行偏差',
|
||
],
|
||
},
|
||
'会员渗透率低': {
|
||
metricLabel: '会员账单占比',
|
||
metricUnit: '%',
|
||
thresholdDesc: '阈值 20%,低于此值表示会员消费占比过低,复购潜力未释放',
|
||
problemDiagnosis: [
|
||
'会员账单占比低于 20%,非会员消费为主,顾客粘性不足',
|
||
'可能原因:会员权益吸引力不够、店员未主动引导注册、周边客群流动性大',
|
||
],
|
||
remediation: [
|
||
'会员运营师分析该店会员画像与复购率',
|
||
'门店加强收银环节会员注册引导,设定周注册目标',
|
||
'评估会员权益是否需要针对该店客群调整',
|
||
'推送定向优惠券激活沉默会员',
|
||
],
|
||
},
|
||
'客诉率偏高': {
|
||
metricLabel: '客诉率',
|
||
metricUnit: '%',
|
||
thresholdDesc: '阈值 5%,超过此值表示顾客投诉比例过高,严重影响品牌口碑',
|
||
problemDiagnosis: [
|
||
'客诉率超过 5%,顾客投诉频次异常偏高',
|
||
'可能原因:出品质量不稳定、服务态度差、环境卫生问题、等待时间过长',
|
||
],
|
||
remediation: [
|
||
'店长 24 小时内复盘投诉内容,分类归因',
|
||
'出品问题:核查食材新鲜度与制作标准执行',
|
||
'服务问题:安排员工服务培训,加强现场管理',
|
||
'连续 2 周客诉率超 5% 升级为总部督办事项',
|
||
],
|
||
},
|
||
'巡检低分': {
|
||
metricLabel: '食安巡检得分',
|
||
metricUnit: '分',
|
||
thresholdDesc: '阈值 90 分,低于此值表示食品安全巡检不达标,存在合规风险',
|
||
problemDiagnosis: [
|
||
'食安巡检得分低于 90 分,食品安全管理存在漏洞',
|
||
'可能原因:食材储存不规范、加工区域卫生不达标、员工操作不规范',
|
||
],
|
||
remediation: [
|
||
'店长立即整改巡检扣分项,48 小时内提交整改报告',
|
||
'区域经理安排复查,确认整改到位',
|
||
'对扣分项涉及员工进行专项培训',
|
||
'连续 2 次低分暂停该店评级资格',
|
||
],
|
||
},
|
||
'离职率偏高': {
|
||
metricLabel: '月度离职率',
|
||
metricUnit: '%',
|
||
thresholdDesc: '阈值 15%,超过此值表示人员流失严重,影响门店稳定运营',
|
||
problemDiagnosis: [
|
||
'月度离职率超过 15%,人员流失过快',
|
||
'可能原因:薪酬竞争力不足、管理风格问题、工作强度过大、晋升通道不清晰',
|
||
],
|
||
remediation: [
|
||
'区域经理访谈离职员工,收集真实离职原因',
|
||
'人力部门评估该店薪酬水平与市场对标',
|
||
'店长管理风格评估,必要时安排管理培训',
|
||
'制定留人方案:调薪、排班优化、职业发展规划',
|
||
],
|
||
},
|
||
}
|
||
|
||
/** 通用兜底:未匹配到规则名时使用 */
|
||
const DEFAULT_DETAIL = {
|
||
metricLabel: '监控指标',
|
||
metricUnit: '',
|
||
thresholdDesc: '指标超出预设阈值范围',
|
||
problemDiagnosis: ['指标异常触发预警,需进一步排查具体原因'],
|
||
remediation: ['请相关负责人核查指标数据并制定改善方案'],
|
||
}
|
||
|
||
function formatTime(s: string | null): string {
|
||
if (!s) return '-'
|
||
return s.substring(0, 19).replace('T', ' ')
|
||
}
|
||
|
||
export function AlertLogDetailDialog({ log, onClose }: AlertLogDetailDialogProps) {
|
||
const queryClient = useQueryClient()
|
||
const [comment, setComment] = useState(log.handle_comment || '')
|
||
|
||
const handleMutation = useMutation({
|
||
mutationFn: (data: { id: number; handle_status: string; handle_comment?: string }) =>
|
||
api.patch(`/alert/logs/${data.id}`, { handle_status: data.handle_status, handle_comment: data.handle_comment }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['alert/logs'] })
|
||
queryClient.invalidateQueries({ queryKey: ['alert/overview'] })
|
||
onClose()
|
||
},
|
||
})
|
||
|
||
const isPending = log.handle_status === 'pending'
|
||
const detail = RULE_DETAILS[log.rule_name] || DEFAULT_DETAIL
|
||
const metricValue = Number(log.metric_value || 0)
|
||
const threshold = Number(log.threshold || 0)
|
||
const deviation = metricValue - threshold
|
||
const isLowerBetter = log.rule_name.includes('达成率') || log.rule_name.includes('毛利率') || log.rule_name.includes('渗透率') || log.rule_name.includes('巡检')
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onClose}>
|
||
<div
|
||
className="max-h-[88vh] w-[640px] max-w-[95vw] overflow-y-auto rounded-lg border bg-card p-6 shadow-lg"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 标题栏 */}
|
||
<div className="mb-4 flex items-start justify-between">
|
||
<div className="flex items-start gap-3">
|
||
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', SEVERITY_COLORS[log.severity] || 'bg-gray-100')}>
|
||
<AlertTriangle className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-base font-bold">{log.rule_name}</h2>
|
||
<p className="text-xs text-muted-foreground">预警 #{log.id} · {log.store_name || '-'}</p>
|
||
</div>
|
||
</div>
|
||
<button onClick={onClose} className="rounded-md p-1 hover:bg-muted">
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 状态标签 */}
|
||
<div className="mb-4 flex flex-wrap gap-2">
|
||
<span className={cn('rounded border px-2 py-0.5 text-xs font-medium', SEVERITY_COLORS[log.severity] || '')}>
|
||
{SEVERITY_LABELS[log.severity] || log.severity}
|
||
</span>
|
||
<span className={cn('rounded border px-2 py-0.5 text-xs font-medium', STATUS_COLORS[log.handle_status] || '')}>
|
||
{STATUS_LABELS[log.handle_status] || log.handle_status}
|
||
</span>
|
||
<span className="rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-600">
|
||
推送:{PUSH_LABELS[log.push_status || 'null'] || log.push_status || '-'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 指标对比 */}
|
||
<section className="mb-4">
|
||
<h3 className="mb-2 text-sm font-bold">指标对比</h3>
|
||
<div className="grid grid-cols-3 gap-2 rounded-lg border bg-muted/20 p-3">
|
||
<div className="text-center">
|
||
<p className="text-[10px] text-muted-foreground">{detail.metricLabel}(当前)</p>
|
||
<p className={cn('text-lg font-bold', isLowerBetter ? 'text-red-600' : 'text-green-600')}>
|
||
{metricValue.toFixed(2)}{detail.metricUnit}
|
||
</p>
|
||
</div>
|
||
<div className="text-center">
|
||
<p className="text-[10px] text-muted-foreground">预警阈值</p>
|
||
<p className="text-lg font-bold text-muted-foreground">
|
||
{threshold.toFixed(2)}{detail.metricUnit}
|
||
</p>
|
||
</div>
|
||
<div className="text-center">
|
||
<p className="text-[10px] text-muted-foreground">偏离值</p>
|
||
<p className={cn('text-lg font-bold', deviation < 0 ? 'text-red-600' : 'text-amber-600')}>
|
||
{deviation > 0 ? '+' : ''}{deviation.toFixed(2)}{detail.metricUnit}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<p className="mt-2 text-xs text-muted-foreground">{detail.thresholdDesc}</p>
|
||
</section>
|
||
|
||
{/* 基本信息字段 */}
|
||
<section className="mb-4">
|
||
<h3 className="mb-2 text-sm font-bold">基本信息</h3>
|
||
<div className="grid grid-cols-2 gap-2 rounded-lg border bg-muted/20 p-3 text-sm sm:grid-cols-3">
|
||
<div>
|
||
<p className="text-[10px] text-muted-foreground">门店名称</p>
|
||
<p className="font-medium">{log.store_name || '-'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] text-muted-foreground">规则ID</p>
|
||
<p className="font-medium">{log.rule_id || '-'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] text-muted-foreground">触发时间</p>
|
||
<p className="font-medium">{formatTime(log.triggered_at)}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-[10px] text-muted-foreground">处理时间</p>
|
||
<p className="font-medium">{formatTime(log.handled_at)}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 问题诊断 */}
|
||
<section className="mb-4">
|
||
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-bold">
|
||
<Stethoscope className="h-4 w-4 text-amber-600" />
|
||
问题诊断
|
||
</h3>
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
|
||
<ul className="space-y-1.5">
|
||
{detail.problemDiagnosis.map((d, i) => (
|
||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-foreground">
|
||
<span className="mt-0.5 shrink-0 text-amber-600">•</span>
|
||
<span>{d}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 整改建议 */}
|
||
<section className="mb-4">
|
||
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-bold">
|
||
<Wrench className="h-4 w-4 text-blue-600" />
|
||
整改建议
|
||
</h3>
|
||
<div className="rounded-lg border border-blue-200 bg-blue-50/40 p-3">
|
||
<ol className="space-y-1.5">
|
||
{detail.remediation.map((r, i) => (
|
||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-foreground">
|
||
<span className="mt-0.5 shrink-0 font-bold text-blue-600">{i + 1}.</span>
|
||
<span>{r}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 处理备注 */}
|
||
<section className="mb-4">
|
||
<h3 className="mb-1 text-sm font-bold">处理备注</h3>
|
||
<textarea
|
||
className="w-full rounded-lg border p-2 text-sm"
|
||
rows={2}
|
||
placeholder="输入处理说明..."
|
||
value={comment}
|
||
onChange={(e) => setComment(e.target.value)}
|
||
disabled={!isPending}
|
||
/>
|
||
</section>
|
||
|
||
{/* 底部操作 */}
|
||
{isPending ? (
|
||
<div className="mt-4 flex gap-2 border-t pt-4">
|
||
<button
|
||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||
onClick={() => handleMutation.mutate({ id: log.id, handle_status: 'resolved', handle_comment: comment || '已处理' })}
|
||
disabled={handleMutation.isPending}
|
||
>
|
||
<CheckCircle className="h-4 w-4" />
|
||
{handleMutation.isPending ? '处理中…' : '标记为已解决'}
|
||
</button>
|
||
<button
|
||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||
onClick={() => handleMutation.mutate({ id: log.id, handle_status: 'ignored', handle_comment: comment || '已忽略' })}
|
||
disabled={handleMutation.isPending}
|
||
>
|
||
<EyeOff className="h-4 w-4" />
|
||
忽略
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="mt-4 border-t pt-4">
|
||
<p className="text-xs text-muted-foreground">
|
||
此预警已处理({STATUS_LABELS[log.handle_status] || log.handle_status})
|
||
{log.handle_comment && <>,备注:{log.handle_comment}</>}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|