Files
SBrainCO/client/src/components/AlertLogDetailDialog.tsx
T
selfrelease 9b3a9cdb4b fix: 修复成本/库存/对账多页面数据准确性与展示问题
- 库存周转: 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>
2026-08-15 17:37:06 +08:00

370 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 预警日志详情弹窗
*
* 点击预警日志行后弹出,展示完整预警信息、指标解释、问题诊断和整改建议,
* 并支持处理操作。
*/
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>
)
}