feat: 新增智脑决策中心模块(决策中心/AI员工组织/知识资产)

- 新增 DecisionsPage、AgentsPage、KnowledgePage 三个页面
- 新增 DecisionCard、DecisionDrawer、DecisionSummary、KnowledgeCard、LoopHealthBar、AgentCard 组件
- 新增 decision-aggregator 决策聚合逻辑和 agents 数据
- Layout 导航重构:新增智脑决策分组,调整系统配置和经营本体分组
- BossPage 集成决策摘要和闭环健康度,利润机会池支持警告样式
- TasksPage 增强任务展示
- 抽屉式详情统一改为居中弹窗风格
- data.ts 后端调整
- run.md 更新数据库连接说明
- 新增玄谋智脑实际版建设方案文档

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-14 19:34:04 +08:00
parent 8fd74fd78c
commit d373fb5f0c
19 changed files with 2231 additions and 22 deletions
+6
View File
@@ -24,6 +24,9 @@ import { SiteSelectionPage } from '@/pages/SiteSelectionPage'
import { SmartSchedulingPage } from '@/pages/SmartSchedulingPage'
import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage'
import { BossPage } from '@/pages/BossPage'
import { DecisionsPage } from '@/pages/DecisionsPage'
import { AgentsPage } from '@/pages/AgentsPage'
import { KnowledgePage } from '@/pages/KnowledgePage'
import { RevenuePage } from '@/pages/RevenuePage'
import { BankPage } from '@/pages/BankPage'
import { CentralKitchenPage } from '@/pages/CentralKitchenPage'
@@ -92,6 +95,9 @@ export default function App() {
<Routes>
<Route path="/" element={user?.role === 'platform_admin' ? <Navigate to="/tenant-management" /> : user?.role === 'store' ? <Navigate to="/store" /> : user?.role === 'regional' ? <Navigate to="/regional" /> : <DashboardPage />} />
<Route path="/boss" element={<RoleRoute roles={['hq','dept']}><BossPage /></RoleRoute>} />
<Route path="/decisions" element={<RoleRoute roles={['hq','dept']}><DecisionsPage /></RoleRoute>} />
<Route path="/agents" element={<RoleRoute roles={['hq','dept']}><AgentsPage /></RoleRoute>} />
<Route path="/knowledge" element={<RoleRoute roles={['hq','dept']}><KnowledgePage /></RoleRoute>} />
<Route path="/revenue" element={<RoleRoute roles={['hq','dept']}><RevenuePage /></RoleRoute>} />
<Route path="/bank" element={<RoleRoute roles={['hq']}><BankPage /></RoleRoute>} />
<Route path="/central-kitchen" element={<RoleRoute roles={['hq','dept']}><CentralKitchenPage /></RoleRoute>} />
+74
View File
@@ -0,0 +1,74 @@
/**
* AI 员工卡片组件
*
* 展示单个 AI 员工的岗位、职责、自治等级和今日交付数。
*/
import { Bot } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AgentConfig } from '@/data/agents'
interface AgentCardProps {
agent: AgentConfig
/** 今日交付数(null 表示加载中) */
deliveries: number | null
onClick?: () => void
}
/** 自治等级颜色 */
const AUTONOMY_COLORS: Record<string, string> = {
L1: 'bg-red-50 text-red-600 border border-red-200',
L2: 'bg-amber-50 text-amber-600 border border-amber-200',
L3: 'bg-green-50 text-green-600 border border-green-200',
}
/** 状态指示灯颜色 */
const STATUS_DOT: Record<string, string> = {
'运行中': 'bg-green-500',
'待审批': 'bg-amber-500',
'受控': 'bg-blue-500',
}
export function AgentCard({ agent, deliveries, onClick }: AgentCardProps) {
return (
<div
className={cn(
'rounded-lg border bg-card p-4 transition-shadow hover:shadow-sm',
onClick && 'cursor-pointer',
)}
onClick={onClick}
>
{/* 头部:图标 + 状态灯 */}
<div className="mb-2 flex items-start justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
<Bot className="h-5 w-5 text-blue-600" />
</div>
<span
className={cn(
'mt-1 h-2 w-2 rounded-full',
STATUS_DOT[agent.status] || 'bg-gray-400',
)}
title={agent.status}
/>
</div>
{/* 名称 + 领域 */}
<small className="text-[10px] text-muted-foreground">{agent.domain}</small>
<h3 className="text-sm font-bold">{agent.name}</h3>
<p className="mt-1 text-xs text-muted-foreground">{agent.roleTarget}</p>
{/* 底部:自治等级 + 交付数 */}
<footer className="mt-3 flex items-center justify-between border-t pt-2">
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', AUTONOMY_COLORS[agent.autonomyLevel])}>
{agent.autonomyLevel}
</span>
<span className="text-xs text-muted-foreground">
{deliveries === null ? (
<span className="text-muted-foreground"></span>
) : (
<><b className="text-foreground">{deliveries}</b> </>
)}
</span>
</footer>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 决策卡组件
*
* 在决策中心列表中展示单个决策卡,点击可展开详情抽屉。
*/
import { ChevronRight } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import type { DecisionCard } from '@/lib/decision-aggregator'
import { SOURCE_LABELS, LEVEL_LABELS } from '@/lib/decision-aggregator'
interface DecisionCardProps {
decision: DecisionCard
index: number
approved: boolean
onOpen: (d: DecisionCard) => void
}
/** 来源类型颜色 */
const SOURCE_COLORS: Record<string, string> = {
profit_opportunity: 'bg-purple-100 text-purple-700',
risk_alert: 'bg-red-100 text-red-700',
task_anomaly: 'bg-amber-100 text-amber-700',
}
/** 等级颜色 */
const LEVEL_COLORS: Record<string, string> = {
L1: 'bg-red-50 text-red-600 border border-red-200',
L2: 'bg-amber-50 text-amber-600 border border-amber-200',
L3: 'bg-green-50 text-green-600 border border-green-200',
}
export function DecisionCardItem({ decision, index, approved, onOpen }: DecisionCardProps) {
const isApproved = approved || decision.status === '已批准'
return (
<button
className="flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors hover:bg-muted/40"
onClick={() => onOpen(decision)}
>
{/* 排名序号 */}
<span
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold',
index < 3 ? 'bg-purple-600 text-white' : 'bg-muted text-muted-foreground',
)}
>
{index + 1}
</span>
{/* 主体内容 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', SOURCE_COLORS[decision.sourceType])}>
{SOURCE_LABELS[decision.sourceType]}
</span>
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', LEVEL_COLORS[decision.level])}>
{LEVEL_LABELS[decision.level]}
</span>
<span className="text-[10px] text-muted-foreground">{decision.agentName}</span>
</div>
<p className="mt-1 truncate text-sm font-medium">{decision.title}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{decision.reason}</p>
</div>
{/* 影响金额 + 状态 */}
<div className="shrink-0 text-right">
{decision.impactAmount > 0 ? (
<p className="text-sm font-bold text-green-600">{formatCurrency(decision.impactAmount)}</p>
) : (
<p className="text-sm font-medium text-muted-foreground">{decision.impactDescription}</p>
)}
<p className="text-[10px] text-muted-foreground">{decision.deadline}</p>
</div>
{/* 状态标签 */}
<span
className={cn(
'shrink-0 rounded px-2 py-0.5 text-[10px] font-medium',
isApproved ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600',
)}
>
{isApproved ? '已批准' : decision.status}
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
)
}
+161
View File
@@ -0,0 +1,161 @@
/**
* 决策详情弹窗
*
* 点击决策卡后居中弹出,展示证据链、影响评估和审批操作。
* 弹窗风格与 BusinessReportDialog 保持统一。
*/
import { X, BrainCircuit } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import type { DecisionCard } from '@/lib/decision-aggregator'
import { SOURCE_LABELS, LEVEL_LABELS } from '@/lib/decision-aggregator'
interface DecisionDrawerProps {
decision: DecisionCard
approved: boolean
onClose: () => void
onApprove: () => void
onReject: () => void
}
export function DecisionDrawer({ decision, approved, onClose, onApprove, onReject }: DecisionDrawerProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onClose}>
<div
className="max-h-[85vh] 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>
<span className="text-xs text-muted-foreground">{decision.id} · {SOURCE_LABELS[decision.sourceType]}</span>
<h2 className="mt-1 text-base font-bold">{decision.title}</h2>
</div>
<button onClick={onClose} className="rounded-md p-1 hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
{/* 内容 */}
<div className="space-y-4">
{/* 置信度 */}
<div className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<BrainCircuit className="h-5 w-5 text-purple-600" />
<div className="flex-1">
<p className="text-xs text-muted-foreground">AI </p>
<p className="text-lg font-bold">{decision.confidence}%</p>
</div>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
'h-full rounded-full transition-all',
decision.confidence >= 85 ? 'bg-green-500' : decision.confidence >= 70 ? 'bg-amber-500' : 'bg-red-500',
)}
style={{ width: `${decision.confidence}%` }}
/>
</div>
</div>
{/* 为什么现在行动 */}
<div>
<h3 className="mb-1 text-sm font-bold"></h3>
<p className="text-sm text-muted-foreground">{decision.reason}</p>
</div>
{/* 影响评估 */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-sm font-bold text-green-600">
{decision.impactAmount > 0 ? formatCurrency(decision.impactAmount) : decision.impactDescription}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-sm font-bold">{LEVEL_LABELS[decision.level]}</p>
</div>
</div>
{/* 证据链 */}
<div>
<h3 className="mb-2 text-sm font-bold"></h3>
<ul className="space-y-1.5">
{decision.evidence.map((e, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
<span className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-purple-400" />
{e}
</li>
))}
</ul>
</div>
{/* 详细信息(如果有) */}
{decision.detail && (
<div>
<h3 className="mb-2 text-sm font-bold"></h3>
<div className="rounded-lg border bg-muted/20 p-3">
{decision.detail.split('\n').map((line, i) => (
<p
key={i}
className={cn(
'text-xs leading-relaxed',
line.startsWith('⚠')
? 'mt-2 rounded bg-amber-50 px-2 py-1 font-medium text-amber-700'
: line.endsWith('') || line.endsWith(':')
? 'mt-2 font-semibold text-foreground'
: 'text-muted-foreground',
)}
>
{line || '\u00A0'}
</p>
))}
</div>
</div>
)}
{/* 责任与执行 */}
<div>
<h3 className="mb-2 text-sm font-bold"></h3>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg border p-2 text-center">
<p className="text-[10px] text-muted-foreground"></p>
<p className="mt-0.5 text-xs font-medium">{decision.owner}</p>
</div>
<div className="rounded-lg border p-2 text-center">
<p className="text-[10px] text-muted-foreground"> AI</p>
<p className="mt-0.5 text-xs font-medium">{decision.agentName}</p>
</div>
<div className="rounded-lg border p-2 text-center">
<p className="text-[10px] text-muted-foreground"></p>
<p className="mt-0.5 text-xs font-medium">{decision.deadline}</p>
</div>
</div>
</div>
</div>
{/* 底部操作 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<button
onClick={onReject}
className="flex-1 rounded-md border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
>
退
</button>
<button
onClick={onApprove}
disabled={approved}
className={cn(
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors',
approved
? 'cursor-not-allowed bg-green-100 text-green-600'
: 'bg-purple-600 text-white hover:bg-purple-700',
)}
>
{approved ? '已批准 · 等待执行' : '批准并生成整改任务'}
</button>
</div>
</div>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 指挥舱待决策摘要组件
*
* 在 BossPage 顶部展示 TOP3 待决策,点击跳转到决策中心。
*/
import { useNavigate } from 'react-router-dom'
import { Sparkles, ChevronRight } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import type { DecisionCard } from '@/lib/decision-aggregator'
import { SOURCE_LABELS } from '@/lib/decision-aggregator'
interface DecisionSummaryProps {
decisions: DecisionCard[]
}
const SOURCE_COLORS: Record<string, string> = {
profit_opportunity: 'bg-purple-100 text-purple-700',
risk_alert: 'bg-red-100 text-red-700',
task_anomaly: 'bg-amber-100 text-amber-700',
}
export function DecisionSummary({ decisions }: DecisionSummaryProps) {
const navigate = useNavigate()
const top3 = decisions.slice(0, 3)
if (top3.length === 0) return null
const totalImpact = top3.reduce((s, d) => s + d.impactAmount, 0)
return (
<div className="rounded-lg border bg-gradient-to-r from-purple-50 to-white p-4">
{/* 标题行 */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-600" />
<h2 className="text-sm font-bold"></h2>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="text-right">
<span className="text-xs text-muted-foreground"></span>
<b className="ml-1 text-sm text-green-600">{formatCurrency(totalImpact)}</b>
</div>
</div>
{/* 决策列表 */}
<div className="space-y-1">
{top3.map((d, i) => (
<button
key={d.id}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-white/60"
onClick={() => navigate('/decisions')}
>
<span
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
i === 0 ? 'bg-purple-600 text-white' : 'bg-purple-100 text-purple-600',
)}
>
{i + 1}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={cn('rounded px-1 py-0.5 text-[9px] font-medium', SOURCE_COLORS[d.sourceType])}>
{SOURCE_LABELS[d.sourceType]}
</span>
<span className="truncate text-xs font-medium">{d.title}</span>
</div>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">{d.reason}</p>
</div>
{d.impactAmount > 0 && (
<b className="shrink-0 text-xs text-green-600">{formatCurrency(d.impactAmount)}</b>
)}
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
</button>
))}
</div>
{/* 查看全部 */}
<button
className="mt-2 flex w-full items-center justify-center gap-1 rounded-md border border-purple-200 py-1.5 text-xs font-medium text-purple-600 hover:bg-purple-50"
onClick={() => navigate('/decisions')}
>
<ChevronRight className="h-3 w-3" />
</button>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
/**
* 知识条目卡片组件
*
* 展示单个标杆实践(知识资产)的模块、标杆门店、关键行动和验证指标。
*/
import { ShieldCheck, ChevronRight } from 'lucide-react'
import { Badge } from '@/components/Badge'
interface KnowledgeCardProps {
practice: any
onClick?: () => void
}
/** 状态颜色映射 */
const STATUS_BADGE: Record<string, 'default' | 'status' | 'review'> = {
'待推广': 'default',
'推广中': 'status',
'已推广': 'review',
}
export function KnowledgeCard({ practice, onClick }: KnowledgeCardProps) {
return (
<div
className="flex cursor-pointer items-center gap-3 rounded-lg border bg-card p-3 transition-shadow hover:shadow-sm"
onClick={onClick}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-teal-50">
<ShieldCheck className="h-5 w-5 text-teal-600" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded bg-teal-50 px-1.5 py-0.5 text-[10px] font-medium text-teal-700">
{practice.practice_module}
</span>
<Badge type={STATUS_BADGE[practice.status] || 'default'} text={practice.status} />
</div>
<p className="mt-1 truncate text-sm font-medium">
{practice.key_actions?.substring(0, 60) || '未填写关键行动'}
{practice.key_actions?.length > 60 ? '…' : ''}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
: {practice.benchmark_store_name || '-'}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
)
}
+28 -15
View File
@@ -1,6 +1,6 @@
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2, Target, Bell, Timer, TrafficCone, Boxes, Download, Briefcase, Cpu, Tag } from 'lucide-react'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2, Target, Bell, Timer, TrafficCone, Boxes, Download, Briefcase, Cpu, Tag, Sparkles, Bot, Library } from 'lucide-react'
import { cn } from '@/lib/utils'
import { StatusBanner } from '@/components/StatusBanner'
@@ -32,14 +32,24 @@ const menuGroups: MenuGroup[] = [
{ path: '/bank', label: '银行授信', icon: Landmark, roles: ['hq'] },
],
},
{
title: '智脑决策',
items: [
{ path: '/decisions', label: '决策中心', icon: Sparkles, roles: ['hq', 'dept'] },
{ path: '/agents', label: 'AI员工组织', icon: Bot, roles: ['hq', 'dept'] },
{ path: '/knowledge', label: '知识资产', icon: Library, roles: ['hq', 'dept'] },
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional'] },
{ path: '/alert', label: '预警管理', icon: Bell, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/target', label: '目标管理', icon: Target, roles: ['hq', 'dept', 'regional'] },
],
},
{
title: '角色工作台',
items: [
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional'] },
{ path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept', 'regional'] },
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'store'] },
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional'] },
],
},
{
@@ -83,20 +93,9 @@ const menuGroups: MenuGroup[] = [
],
},
{
title: '系统管理',
title: '系统配置',
items: [
{ path: '/tenant-users', label: '用户管理', icon: Users, roles: ['hq'] },
{ path: '/data-quality', label: '数据质量', icon: Database, roles: ['hq', 'dept'] },
{ path: '/indicators', label: '指标字典', icon: Settings, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/ontology', label: '本体标准', icon: Receipt, roles: ['hq', 'dept'] },
],
},
{
title: 'V3.0 系统闭环',
items: [
{ path: '/target', label: '目标管理', icon: Target, roles: ['hq', 'dept', 'regional'] },
{ path: '/store-grade', label: '门店分级', icon: TrafficCone, roles: ['hq', 'dept', 'regional'] },
{ path: '/alert', label: '预警管理', icon: Bell, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/scheduler', label: '调度管理', icon: Timer, roles: ['hq'] },
{ path: '/product-lifecycle', label: '产品生命周期', icon: Boxes, roles: ['hq', 'dept'] },
{ path: '/data-import', label: '数据采集', icon: Download, roles: ['hq', 'dept'] },
@@ -104,6 +103,20 @@ const menuGroups: MenuGroup[] = [
{ path: '/intelligence', label: '智能升级', icon: Cpu, roles: ['hq', 'dept'] },
],
},
{
title: '系统管理',
items: [
{ path: '/tenant-users', label: '用户管理', icon: Users, roles: ['hq'] },
{ path: '/data-quality', label: '数据质量', icon: Database, roles: ['hq', 'dept'] },
{ path: '/indicators', label: '指标字典', icon: Settings, roles: ['hq', 'dept', 'regional', 'store'] },
],
},
{
title: '经营本体',
items: [
{ path: '/ontology', label: '本体标准', icon: Receipt, roles: ['hq', 'dept'] },
],
},
{
title: '平台管理',
items: [
+74
View File
@@ -0,0 +1,74 @@
/**
* 闭环健康度条组件
*
* 展示"信号→决策→任务→验收→知识"5 阶段转化率。
* 数据来源:/tasks/loop-health API。
*/
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LoopHealthBarProps {
/** 闭环健康度数据 */
data: {
task_generation_rate?: number
store_execution_rate?: number
weekly_check_rate?: number
monthly_review_rate?: number
practice_promotion_rate?: number
} | null
/** 各阶段数量(可选) */
counts?: {
signals?: number
decisions?: number
tasks?: number
checks?: number
reviews?: number
practices?: number
}
}
/** 5 阶段定义 */
const STAGES = [
{ key: 'task_generation_rate', label: '经营信号', countKey: 'signals', placeholder: '1,284' },
{ key: 'store_execution_rate', label: '有效决策', countKey: 'decisions', placeholder: '46' },
{ key: 'store_execution_rate', label: '执行任务', countKey: 'tasks', placeholder: '38' },
{ key: 'weekly_check_rate', label: '完成验收', countKey: 'reviews', placeholder: '28' },
{ key: 'practice_promotion_rate', label: '沉淀案例', countKey: 'practices', placeholder: '14' },
]
export function LoopHealthBar({ data, counts }: LoopHealthBarProps) {
const stages = STAGES.map((s) => {
const rate = data ? (data as any)[s.key] : null
const count = counts ? (counts as any)[s.countKey] : null
return {
...s,
rate: rate != null ? Math.round(Number(rate) * 100) / 100 : null,
count: count != null ? String(count) : s.placeholder,
}
})
return (
<div className="flex flex-wrap items-center gap-1 rounded-lg border bg-card p-3">
{stages.map((s, i) => (
<div key={s.label} className="flex items-center gap-1">
<div className="flex flex-col items-center px-2">
<span
className={cn(
'flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold',
i === 0 ? 'bg-blue-600 text-white' : 'bg-muted text-muted-foreground',
)}
>
{i + 1}
</span>
<small className="mt-1 text-[10px] text-muted-foreground">{s.label}</small>
<b className="text-sm font-bold">{s.count}</b>
{s.rate != null && (
<em className="text-[10px] text-green-600">{s.rate}% </em>
)}
</div>
{i < stages.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
/**
* AI 员工配置定义
*
* 定义玄谋智脑的 10 个 AI 员工角色,包括岗位目标、负责领域、自治等级和对应的 SBrainCO 能力。
* 阶段一为静态配置,阶段二将迁移至 analytics.agent_config 表动态管理。
*/
export interface AgentConfig {
/** 员工编码(唯一标识) */
code: string
/** 员工名称 */
name: string
/** 岗位目标 */
roleTarget: string
/** 负责领域 */
domain: string
/** 自治等级:L1 人工审批 / L2 受控 / L3 自动 */
autonomyLevel: 'L1' | 'L2' | 'L3'
/** 对应 SBrainCO 能力描述 */
capability: string
/** 状态 */
status: '运行中' | '待审批' | '受控'
/** 对应的 API 路径(用于动态获取今日交付数) */
deliveryApi?: string
/** 交付数提取路径(从 API 响应中提取数字) */
deliveryField?: string
}
/** 经营参谋长(首席 AI 员工) */
export const chiefOfStaff: AgentConfig = {
code: 'chief_of_staff',
name: '经营参谋长',
roleTarget: '消解目标冲突、生成老板决策清单',
domain: '跨系统',
autonomyLevel: 'L1',
capability: '聚合利润机会、风险评级、闭环健康度,生成晨会简报',
status: '运行中',
deliveryApi: '/overview/profit-opportunity',
deliveryField: 'items.length',
}
/** 9 个 AI 员工(不含经营参谋长) */
export const agents: AgentConfig[] = [
{
code: 'store_analyst',
name: '门店经营分析师',
roleTarget: '门店分级、异常归因与增收机会',
domain: '门店增长',
autonomyLevel: 'L1',
capability: '风险评级 + 利润机会池 + 门店深度诊断',
status: '运行中',
deliveryApi: '/stores/risk',
deliveryField: 'data.length',
},
{
code: 'dish_profit_optimizer',
name: '菜品利润优化师',
roleTarget: '菜品真实贡献与菜单工程',
domain: '商品利润',
autonomyLevel: 'L1',
capability: '成本分析(9个Tab+ 菜单工程 + BOM穿透',
status: '运行中',
deliveryApi: '/cost-analysis/store-overview',
deliveryField: 'data.stores.length',
},
{
code: 'expense_auditor',
name: '费用稽核员',
roleTarget: '门店损益、费用异常与追责',
domain: '利润费用',
autonomyLevel: 'L1',
capability: '门店费用分析 + 人工水电费率监控',
status: '运行中',
deliveryApi: '/store-expense/overview',
deliveryField: 'data.stores.length',
},
{
code: 'demand_forecaster',
name: '需求预测员',
roleTarget: '菜品与原料日周需求预测',
domain: '供应链',
autonomyLevel: 'L1',
capability: 'AI 预测模型 + 历史趋势分析',
status: '运行中',
deliveryApi: '/intelligence/ai/forecasts',
deliveryField: 'data.length',
},
{
code: 'smart_replenisher',
name: '智能补货员',
roleTarget: '订货量、调拨与最晚下单日',
domain: '供应链',
autonomyLevel: 'L2',
capability: 'MRP 物料需求计划 + 库存周转分析',
status: '受控',
deliveryApi: '/intelligence/chain/mrp',
deliveryField: 'data.length',
},
{
code: 'operations_supervisor',
name: '经营督办员',
roleTarget: '异常转任务、周检与月度验收',
domain: '组织执行',
autonomyLevel: 'L1',
capability: '任务自动生成 + 周检 + 月度验收 + 闭环健康度',
status: '运行中',
deliveryApi: '/tasks',
deliveryField: 'meta.total',
},
{
code: 'compliance_auditor',
name: '经营稽核员',
roleTarget: '退款、抹零与异常操作审查',
domain: '风险内控',
autonomyLevel: 'L1',
capability: '告警规则 + 告警日志 + 风险内控',
status: '运行中',
deliveryApi: '/alert/overview',
deliveryField: 'data.total_alerts',
},
{
code: 'member_operator',
name: '会员运营师',
roleTarget: '复购、流失与下一最佳行动',
domain: '顾客增长',
autonomyLevel: 'L2',
capability: '会员复购分析 + 会员 LTV + 促销增量',
status: '待审批',
deliveryApi: '/member',
deliveryField: 'data.length',
},
{
code: 'knowledge_manager',
name: '知识管理员',
roleTarget: 'SOP、标杆门店与案例资产化',
domain: '组织能力',
autonomyLevel: 'L1',
capability: '标杆实践管理 + 经验推广 + 知识沉淀',
status: '运行中',
deliveryApi: '/tasks/practices',
deliveryField: 'data.length',
},
]
+203
View File
@@ -0,0 +1,203 @@
/**
* 决策聚合逻辑
*
* 将利润机会、风险告警、异常任务 3 类信号统一聚合为"决策卡"格式。
* 阶段一为前端聚合(不持久化),阶段二将迁移至后端 analytics.decision_card 表。
*/
/** 决策来源类型 */
export type DecisionSource = 'profit_opportunity' | 'risk_alert' | 'task_anomaly'
/** 决策自治等级 */
export type DecisionLevel = 'L1' | 'L2' | 'L3'
/** 决策状态 */
export type DecisionStatus = '待审批' | '已批准' | '已退回' | '执行中' | '已验证'
/** 统一决策卡数据结构 */
export interface DecisionCard {
/** 决策编号 */
id: string
/** 来源类型 */
sourceType: DecisionSource
/** 来源数据 ID */
sourceId: string
/** 自治等级 */
level: DecisionLevel
/** 经营域 */
domain: string
/** 决策标题 */
title: string
/** 决策原因 */
reason: string
/** 预计影响金额 */
impactAmount: number
/** 影响描述 */
impactDescription: string
/** AI 建议置信度 0-100 */
confidence: number
/** 证据链 */
evidence: string[]
/** 责任人 */
owner: string
/** 责任 AI 员工 */
agentName: string
/** 截止时间 */
deadline: string
/** 状态 */
status: DecisionStatus
/** 详细信息(原始 API 返回的 detail 字段) */
detail?: string
}
/** 置信度文字转数字 */
function confidenceToNum(confidence: string): number {
if (confidence?.includes('高')) return 90
if (confidence?.includes('中高')) return 85
if (confidence?.includes('中')) return 75
if (confidence?.includes('中低')) return 65
if (confidence?.includes('低')) return 50
return 70
}
/** 利润机会 → 决策卡 */
function opportunityToDecisions(items: any[]): DecisionCard[] {
return items.map((o, i) => ({
id: `OPP-${String(i + 1).padStart(3, '0')}`,
sourceType: 'profit_opportunity' as DecisionSource,
sourceId: o.category,
level: 'L1' as DecisionLevel,
domain: o.owner?.split('/')[0]?.trim() || '经营',
title: o.category,
reason: o.evidence || '基于利润机会池分析',
impactAmount: Math.max(Number(o.opportunity || 0), 0),
impactDescription: `预计月度利润提升 ${Math.max(Number(o.opportunity || 0), 0).toLocaleString('zh-CN')}`,
confidence: confidenceToNum(o.confidence),
evidence: [
`基线: ${o.baseline}`,
`验收方式: ${o.evidence}`,
`责任方: ${o.owner}`,
],
owner: o.owner || '待分配',
agentName: mapOwnerToAgent(o.owner),
deadline: '30天内',
status: '待审批' as DecisionStatus,
detail: o.detail,
}))
}
/** 风险门店 → 决策卡 */
function riskToDecisions(riskRows: any[]): DecisionCard[] {
// 只取红色和黄色风险门店
const flagged = riskRows.filter((r) => r.risk_level === '红色' || r.risk_level === '黄色')
return flagged.slice(0, 10).map((r, i) => ({
id: `RISK-${String(i + 1).padStart(3, '0')}`,
sourceType: 'risk_alert' as DecisionSource,
sourceId: r.store_code || r.store_name,
level: (r.risk_level === '红色' ? 'L1' : 'L2') as DecisionLevel,
domain: '门店经营',
title: `${r.store_name} ${r.risk_level}门店整改`,
reason: `风险评级为${r.risk_level},营收 ${Number(r.received || 0).toLocaleString('zh-CN')} 元,需关注经营异常`,
impactAmount: Number(r.received || 0) * 0.05,
impactDescription: `预计挽回营收损失 ${Math.round(Number(r.received || 0) * 0.05).toLocaleString('zh-CN')}`,
confidence: r.risk_level === '红色' ? 92 : 78,
evidence: [
`门店: ${r.store_name}${r.store_code || '-'}`,
`风险等级: ${r.risk_level}`,
`实收: ¥${Number(r.received || 0).toLocaleString('zh-CN')}`,
`风险评分: ${r.risk_score || '-'}`,
],
owner: '区域经理',
agentName: '门店经营分析师',
deadline: r.risk_level === '红色' ? '7天内' : '14天内',
status: '待审批' as DecisionStatus,
}))
}
/** 异常任务 → 决策卡 */
function taskAnomalyToDecisions(tasks: any[]): DecisionCard[] {
// 筛选逾期或待启动的高优先级任务
const today = new Date()
const anomalous = tasks.filter((t) => {
const isOverdue = t.deadline && new Date(t.deadline) < today && t.status !== '已验收'
const isPendingP0 = t.priority?.startsWith('P0') && t.status === '待启动'
return isOverdue || isPendingP0
})
return anomalous.slice(0, 10).map((t, i) => ({
id: `TASK-${String(i + 1).padStart(3, '0')}`,
sourceType: 'task_anomaly' as DecisionSource,
sourceId: String(t.task_id),
level: (t.priority?.startsWith('P0') ? 'L1' : 'L2') as DecisionLevel,
domain: '组织执行',
title: `${t.store_name} - ${t.problem_indicator || '任务异常'}`,
reason: t.problem_description || t.action_required || '任务存在异常需关注',
impactAmount: 0,
impactDescription: t.verification_result || '需评估验收价值',
confidence: 80,
evidence: [
`门店: ${t.store_name}`,
`问题指标: ${t.problem_indicator || '-'}`,
`当前值: ${t.current_value ?? '-'} / 目标值: ${t.target_value ?? '-'}`,
`负责人: ${t.owner}`,
`截止日: ${t.deadline?.substring(0, 10) || '-'}`,
],
owner: t.owner || '待分配',
agentName: '经营督办员',
deadline: t.deadline?.substring(0, 10) || '尽快',
status: '待审批' as DecisionStatus,
}))
}
/** 根据责任方映射到 AI 员工名称 */
function mapOwnerToAgent(owner: string): string {
if (!owner) return '经营参谋长'
if (owner.includes('供应链') || owner.includes('商品')) return '菜品利润优化师'
if (owner.includes('运营') || owner.includes('人力')) return '费用稽核员'
if (owner.includes('工程') || owner.includes('门店')) return '门店经营分析师'
if (owner.includes('营销')) return '会员运营师'
if (owner.includes('外卖') || owner.includes('采购')) return '智能补货员'
return '经营参谋长'
}
/** 聚合所有信号为决策卡列表 */
export function aggregateDecisions(
profitOpp: any[],
riskRows: any[],
tasks: any[],
): DecisionCard[] {
const oppDecisions = opportunityToDecisions(profitOpp)
const riskDecisions = riskToDecisions(riskRows)
const taskDecisions = taskAnomalyToDecisions(tasks)
// 按预计影响金额降序排列
return [...oppDecisions, ...riskDecisions, ...taskDecisions].sort(
(a, b) => b.impactAmount - a.impactAmount,
)
}
/** 筛选决策 */
export function filterDecisions(
decisions: DecisionCard[],
filter: 'all' | 'pending' | 'approved' | 'verified',
approvedIds: string[],
): DecisionCard[] {
if (filter === 'all') return decisions
if (filter === 'pending') return decisions.filter((d) => d.status === '待审批' && !approvedIds.includes(d.id))
if (filter === 'approved') return decisions.filter((d) => approvedIds.includes(d.id))
if (filter === 'verified') return decisions.filter((d) => d.status === '已验证')
return decisions
}
/** 决策来源标签 */
export const SOURCE_LABELS: Record<DecisionSource, string> = {
profit_opportunity: '利润机会',
risk_alert: '风险告警',
task_anomaly: '任务异常',
}
/** 自治等级标签 */
export const LEVEL_LABELS: Record<DecisionLevel, string> = {
L1: 'L1 人工审批',
L2: 'L2 受控',
L3: 'L3 自动',
}
+111
View File
@@ -0,0 +1,111 @@
/**
* AI 员工组织页面
*
* 展示玄谋智脑的 10 个 AI 员工角色,包括经营参谋长(首席)和 9 个专业员工。
* 今日交付数从对应 API 动态获取。
*/
import { useQuery } from '@tanstack/react-query'
import { BrainCircuit } from 'lucide-react'
import api from '@/lib/api'
import { AgentCard } from '@/components/AgentCard'
import { chiefOfStaff, agents, type AgentConfig } from '@/data/agents'
import { cn } from '@/lib/utils'
export function AgentsPage() {
// 批量获取各 AI 员工的今日交付数
const queries = agents.map((agent, idx) =>
useQuery({
queryKey: ['agent-delivery', agent.code],
queryFn: () => api.get(agent.deliveryApi || ''),
enabled: !!agent.deliveryApi,
staleTime: 5 * 60 * 1000,
}),
)
// 经营参谋长的交付数(利润机会数量)
const chiefQuery = useQuery({
queryKey: ['overview/profit-opportunity', 'chief'],
queryFn: () => api.get('/overview/profit-opportunity', { params: { month: '2026-04' } }),
staleTime: 5 * 60 * 1000,
})
/** 从 API 响应中提取交付数 */
function extractDelivery(queryResult: any, field: string): number | null {
if (!queryResult) return null
const data = (queryResult as any)?.data
if (!data) return null
// 支持 a.b.c 路径
const parts = field.split('.')
let val: any = data
for (const p of parts) {
val = val?.[p]
if (val === undefined) return null
}
const num = typeof val === 'number' ? val : parseInt(String(val), 10)
return isNaN(num) ? null : num
}
const chiefDeliveries = chiefQuery.data
? extractDelivery(chiefQuery.data, 'items.length') ?? (chiefQuery.data as any)?.data?.items?.length ?? 0
: null
return (
<div className="space-y-4">
{/* 页面标题 */}
<div>
<h1 className="flex items-center gap-2 text-xl font-bold">
<BrainCircuit className="h-5 w-5 text-blue-600" />
AI
</h1>
<p className="mt-1 text-sm text-muted-foreground">
AI SOP
</p>
</div>
{/* 经营参谋长(首席卡片) */}
<div className="rounded-lg border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-white p-4">
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-blue-600">
<BrainCircuit className="h-7 w-7 text-white" />
</div>
<div className="flex-1">
<small className="text-[10px] font-medium text-blue-600">AI CHIEF OF STAFF</small>
<h2 className="text-lg font-bold">{chiefOfStaff.name}</h2>
<p className="text-sm text-muted-foreground">{chiefOfStaff.roleTarget}</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground"></p>
<b className="text-2xl font-bold text-blue-600">{chiefDeliveries ?? '…'}</b>
<p className="text-[10px] text-muted-foreground"></p>
</div>
</div>
</div>
{/* AI 员工网格 */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent, idx) => (
<AgentCard
key={agent.code}
agent={agent}
deliveries={
queries[idx]?.data
? extractDelivery(queries[idx].data, agent.deliveryField || '') ?? 0
: null
}
/>
))}
</div>
{/* 底部说明 */}
<div className="rounded-lg border bg-muted/20 p-3 text-xs text-muted-foreground">
<p>
<b className="text-foreground"></b>
<span className={cn('ml-1 rounded px-1 py-0.5 text-[10px]', 'bg-red-50 text-red-600')}>L1</span> ·
<span className={cn('ml-1 rounded px-1 py-0.5 text-[10px]', 'bg-amber-50 text-amber-600')}>L2</span> ·
<span className={cn('ml-1 rounded px-1 py-0.5 text-[10px]', 'bg-green-50 text-green-600')}>L3</span>
</p>
<p className="mt-1"> AI API </p>
</div>
</div>
)
}
+33
View File
@@ -11,6 +11,9 @@ import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Tar
import { MonthPicker } from '@/components/MonthPicker'
import { KPISection } from '@/components/KPISection'
import { BusinessReportDialog } from '@/components/BusinessReportDialog'
import { DecisionSummary } from '@/components/DecisionSummary'
import { LoopHealthBar } from '@/components/LoopHealthBar'
import { aggregateDecisions } from '@/lib/decision-aggregator'
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
@@ -44,11 +47,13 @@ function ProfitOppItem({ index, o, opp, pct, confColor }: { index: number; o: an
{o.detail.split('\n').map((line: string, idx: number) => {
const isHeader = line.endsWith('') || line.endsWith(':')
const isAction = line.startsWith('行动指向') || /^\d/.test(line)
const isWarning = line.startsWith('⚠')
const isStoreLine = line.includes('费率') || line.includes('差异') || line.includes('优惠率') || line.includes('佣金') || line.includes('收入')
return (
<p
key={idx}
className={`text-xs leading-relaxed ${
isWarning ? 'mt-2 rounded bg-amber-50 px-2 py-1 font-medium text-amber-700' :
isHeader ? 'mt-2 font-semibold text-foreground' :
isAction ? 'text-foreground' :
isStoreLine ? 'text-muted-foreground' :
@@ -120,6 +125,16 @@ export function BossPage() {
queryFn: () => api.get('/overview/profit-opportunity', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: loopHealthData } = useQuery({
queryKey: ['tasks/loop-health'],
queryFn: () => api.get('/tasks/loop-health'),
staleTime: 5 * 60 * 1000,
})
const { data: bossTaskData } = useQuery({
queryKey: ['tasks', month, 'boss-summary'],
queryFn: () => api.get('/tasks', { params: { month, page_size: 200 } }),
staleTime: 5 * 60 * 1000,
})
const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading
@@ -134,6 +149,9 @@ export function BossPage() {
const profitRanking = (profitRankingData as any)?.data || []
const profitOpp = (profitOppData as any)?.data?.items || []
const totalOpportunity = profitOpp.reduce((s: number, o: any) => s + Math.max(Number(o.opportunity || 0), 0), 0)
const bossTasks = (bossTaskData as any)?.data || []
const loopHealth = (loopHealthData as any)?.data || null
const bossDecisions = aggregateDecisions(profitOpp, riskRows, bossTasks)
// 风险分布
const riskSummary = riskRows.reduce((acc: any, r: any) => {
@@ -256,6 +274,21 @@ export function BossPage() {
{/* ①b KPI达成率 */}
<KPISection month={month} level="hq" />
{/* ①c 待决策清单 + 闭环健康度 */}
<div className="space-y-3">
<DecisionSummary decisions={bossDecisions} />
<LoopHealthBar
data={loopHealth}
counts={{
signals: 1284,
decisions: bossDecisions.length,
tasks: bossTasks.length,
reviews: bossTasks.filter((t: any) => t.status === '已验收').length,
practices: 14,
}}
/>
</div>
{/* ② 利润瀑布 */}
{wf && (
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 门店贡献利润估算">
+178
View File
@@ -0,0 +1,178 @@
/**
* 决策中心页面
*
* 聚合利润机会、风险告警、异常任务 3 类信号为统一决策卡列表,
* 支持筛选、查看详情和审批操作。
* 阶段一审批状态暂存 localStorage,阶段二将持久化到后端。
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Sparkles } from 'lucide-react'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { DecisionCardItem } from '@/components/DecisionCard'
import { DecisionDrawer } from '@/components/DecisionDrawer'
import { aggregateDecisions, filterDecisions, type DecisionCard } from '@/lib/decision-aggregator'
import { cn } from '@/lib/utils'
type FilterType = 'all' | 'pending' | 'approved' | 'verified'
const FILTERS: { key: FilterType; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待审批' },
{ key: 'approved', label: '已批准' },
{ key: 'verified', label: '已验证' },
]
export function DecisionsPage() {
const [month, setMonth] = useState('2026-04')
const [filter, setFilter] = useState<FilterType>('all')
const [selected, setSelected] = useState<DecisionCard | null>(null)
// 审批状态(阶段一暂存 localStorage
const [approvedIds, setApprovedIds] = useState<string[]>(() => {
try {
return JSON.parse(localStorage.getItem('approvedDecisions') || '[]')
} catch {
return []
}
})
// 并行获取 3 类信号
const { data: profitOppData, isLoading: oppLoading } = useQuery({
queryKey: ['overview/profit-opportunity', month],
queryFn: () => api.get('/overview/profit-opportunity', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: riskData, isLoading: riskLoading } = useQuery({
queryKey: ['stores/risk', month],
queryFn: () => api.get('/stores/risk', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: taskData, isLoading: taskLoading } = useQuery({
queryKey: ['tasks', month, '', ''],
queryFn: () => api.get('/tasks', { params: { month, page_size: 200 } }),
staleTime: 5 * 60 * 1000,
})
const profitOpp = (profitOppData as any)?.data?.items || []
const riskRows = (riskData as any)?.data || []
const tasks = (taskData as any)?.data || []
// 聚合决策
const allDecisions = useMemo(
() => aggregateDecisions(profitOpp, riskRows, tasks),
[profitOpp, riskRows, tasks],
)
const filtered = useMemo(
() => filterDecisions(allDecisions, filter, approvedIds),
[allDecisions, filter, approvedIds],
)
const pageLoading = oppLoading || riskLoading || taskLoading
// 审批操作
const handleApprove = () => {
if (!selected) return
const newIds = [...new Set([...approvedIds, selected.id])]
setApprovedIds(newIds)
localStorage.setItem('approvedDecisions', JSON.stringify(newIds))
setSelected(null)
}
const handleReject = () => {
setSelected(null)
}
// 统计
const pendingCount = allDecisions.filter((d) => !approvedIds.includes(d.id)).length
const totalImpact = allDecisions
.filter((d) => !approvedIds.includes(d.id))
.reduce((s, d) => s + d.impactAmount, 0)
return (
<div className="space-y-4">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold">
<Sparkles className="h-5 w-5 text-purple-600" />
</h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<div className="text-right text-sm">
<p className="text-muted-foreground"> <b className="text-foreground">{pendingCount}</b> </p>
<p className="text-xs text-muted-foreground"> <b className="text-green-600">¥{totalImpact.toLocaleString('zh-CN')}</b></p>
</div>
</div>
{/* 筛选栏 */}
<div className="flex flex-wrap gap-2">
{FILTERS.map((f) => (
<button
key={f.key}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
filter === f.key ? 'bg-purple-600 text-white' : 'border hover:bg-muted',
)}
onClick={() => setFilter(f.key)}
>
{f.label} {f.key === 'all' ? allDecisions.length : f.key === 'pending' ? pendingCount : f.key === 'approved' ? approvedIds.length : 0}
</button>
))}
<div className="flex-1" />
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded-md border px-2 py-1 text-sm"
/>
</div>
{/* 决策列表 */}
{pageLoading ? (
<LoadingSpinner />
) : filtered.length === 0 ? (
<div className="rounded-lg border bg-card py-20 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="overflow-hidden rounded-lg border bg-card">
{/* 表头 */}
<div className="flex items-center gap-3 border-b bg-muted/30 px-4 py-2 text-xs font-medium text-muted-foreground">
<span className="w-7" />
<span className="flex-1"> / </span>
<span className="w-24 text-right"></span>
<span className="w-16 text-center"></span>
<span className="w-4" />
</div>
{/* 决策卡列表 */}
{filtered.map((d, i) => (
<DecisionCardItem
key={d.id}
decision={d}
index={i}
approved={approvedIds.includes(d.id)}
onOpen={setSelected}
/>
))}
</div>
)}
{/* 详情抽屉 */}
{selected && (
<DecisionDrawer
decision={selected}
approved={approvedIds.includes(selected.id)}
onClose={() => setSelected(null)}
onApprove={handleApprove}
onReject={handleReject}
/>
)}
</div>
)
}
+268
View File
@@ -0,0 +1,268 @@
/**
* 知识资产页面
*
* 展示标杆实践(standardized_practice)列表,支持按模块和状态筛选,
* 点击可查看推广结果,支持推广到指定门店。
*/
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Library, X } from 'lucide-react'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { KnowledgeCard } from '@/components/KnowledgeCard'
import { Badge } from '@/components/Badge'
import { cn } from '@/lib/utils'
export function KnowledgePage() {
const queryClient = useQueryClient()
const [moduleFilter, setModuleFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [selected, setSelected] = useState<any | null>(null)
// 获取标杆实践列表
const { data: practicesData, isLoading } = useQuery({
queryKey: ['tasks/practices', moduleFilter, statusFilter],
queryFn: () =>
api.get('/tasks/practices', {
params: { module: moduleFilter || undefined, status: statusFilter || undefined },
}),
})
const practices = (practicesData as any)?.data || []
// 获取选中实践的推广结果
const { data: replicationData } = useQuery({
queryKey: ['tasks/practices', selected?.id, 'replication-result'],
queryFn: () => api.get(`/tasks/practices/${selected?.id}/replication-result`),
enabled: !!selected?.id,
})
const replications = (replicationData as any)?.data || []
// 推广操作
const replicateMutation = useMutation({
mutationFn: (data: { trial_store_code: string; trial_store_name: string; observation_weeks?: number }) =>
api.post(`/tasks/practices/${selected?.id}/replicate`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks/practices', selected?.id, 'replication-result'] })
},
})
const promoteMutation = useMutation({
mutationFn: () => api.post(`/tasks/practices/${selected?.id}/promote`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks/practices'] })
setSelected(null)
},
})
// 统计
const stats = useMemo(() => {
const total = practices.length
const byModule = practices.reduce((acc: any, p: any) => {
acc[p.practice_module] = (acc[p.practice_module] || 0) + 1
return acc
}, {})
const byStatus = practices.reduce((acc: any, p: any) => {
acc[p.status] = (acc[p.status] || 0) + 1
return acc
}, {})
return { total, byModule, byStatus }
}, [practices])
// 模块列表(从数据中提取)
const modules = useMemo(
() => Array.from(new Set(practices.map((p: any) => p.practice_module))).filter(Boolean),
[practices],
)
const statuses = ['待推广', '推广中', '已推广']
return (
<div className="space-y-4">
{/* 页面标题 */}
<div>
<h1 className="flex items-center gap-2 text-xl font-bold">
<Library className="h-5 w-5 text-teal-600" />
</h1>
<p className="mt-1 text-sm text-muted-foreground">
AI
</p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2">
<Library className="h-4 w-4 text-teal-600" />
<span className="text-xs text-muted-foreground"></span>
</div>
<b className="mt-1 block text-2xl font-bold text-teal-600">{stats.total}</b>
</div>
{Object.entries(stats.byStatus).map(([status, count]: any) => (
<div key={status} className="rounded-lg border bg-card p-3">
<span className="text-xs text-muted-foreground">{status}</span>
<b className="mt-1 block text-2xl font-bold">{count}</b>
</div>
))}
</div>
{/* 筛选栏 */}
<div className="flex flex-wrap gap-2">
<button
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium',
!moduleFilter ? 'bg-teal-600 text-white' : 'border hover:bg-muted',
)}
onClick={() => setModuleFilter('')}
>
</button>
{modules.map((m: any) => (
<button
key={m}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium',
moduleFilter === m ? 'bg-teal-600 text-white' : 'border hover:bg-muted',
)}
onClick={() => setModuleFilter(m)}
>
{m}
</button>
))}
<div className="flex-1" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-md border px-2 py-1 text-sm"
>
<option value=""></option>
{statuses.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* 知识列表 */}
{isLoading ? (
<LoadingSpinner />
) : practices.length === 0 ? (
<div className="rounded-lg border bg-card py-20 text-center">
<Library className="mx-auto h-12 w-12 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground"></p>
<p className="mt-1 text-xs text-muted-foreground"></p>
</div>
) : (
<div className="space-y-2">
{practices.map((p: any) => (
<KnowledgeCard key={p.id} practice={p} onClick={() => setSelected(p)} />
))}
</div>
)}
{/* 详情弹窗 */}
{selected && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setSelected(null)}>
<div
className="max-h-[85vh] 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>
<span className="rounded bg-teal-50 px-1.5 py-0.5 text-[10px] font-medium text-teal-700">
{selected.practice_module}
</span>
<h2 className="mt-1 text-base font-bold"> #{selected.id}</h2>
</div>
<button onClick={() => setSelected(null)} className="rounded-md p-1 hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
{/* 内容 */}
<div className="space-y-4">
{/* 标杆门店 */}
<div>
<h3 className="mb-1 text-sm font-bold"></h3>
<p className="text-sm">{selected.benchmark_store_name}{selected.benchmark_store_code}</p>
</div>
{/* 关键行动 */}
<div>
<h3 className="mb-1 text-sm font-bold"></h3>
<div className="rounded-lg border bg-muted/20 p-3">
{selected.key_actions?.split('\n').map((line: string, i: number) => (
<p key={i} className="text-xs leading-relaxed text-muted-foreground">
{line || '\u00A0'}
</p>
)) || <p className="text-xs text-muted-foreground"></p>}
</div>
</div>
{/* 验证指标 */}
{selected.verification_indicators && (
<div>
<h3 className="mb-1 text-sm font-bold"></h3>
<p className="text-sm text-muted-foreground">{selected.verification_indicators}</p>
</div>
)}
{/* 状态 */}
<div>
<h3 className="mb-1 text-sm font-bold"></h3>
<Badge type="default" text={selected.status} />
</div>
{/* 推广结果 */}
<div>
<h3 className="mb-2 text-sm font-bold">广</h3>
{replications.length === 0 ? (
<p className="text-xs text-muted-foreground">广</p>
) : (
<div className="space-y-2">
{replications.map((r: any) => (
<div key={r.id} className="rounded-lg border p-2 text-xs">
<div className="flex items-center justify-between">
<b>{r.trial_store_name}</b>
<Badge type="status" text={r.status || '观察中'} />
</div>
<p className="mt-1 text-muted-foreground">
: {r.observation_weeks} · : {r.before_value ?? '-'} · : {r.after_value ?? '-'}
</p>
</div>
))}
</div>
)}
</div>
</div>
{/* 底部操作 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<button
className="flex-1 rounded-md border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
onClick={() => {
const code = prompt('请输入试点门店编码')
if (!code) return
const name = prompt('请输入试点门店名称') || code
replicateMutation.mutate({ trial_store_code: code, trial_store_name: name })
}}
>
广
</button>
{selected.status !== '已推广' && (
<button
className="flex-1 rounded-md bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700"
onClick={() => promoteMutation.mutate()}
disabled={promoteMutation.isPending}
>
{promoteMutation.isPending ? '处理中…' : '标记为已推广'}
</button>
)}
</div>
</div>
</div>
)}
</div>
)
}
+30
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { FilterableTable } from '@/components/FilterableTable'
@@ -18,6 +19,11 @@ export function TasksPage() {
queryKey: ['tasks', month, priority, status],
queryFn: () => api.get('/tasks', { params: { month, priority, status, page_size: 200 } }),
})
const { data: loopHealthData } = useQuery({
queryKey: ['tasks/loop-health'],
queryFn: () => api.get('/tasks/loop-health'),
staleTime: 5 * 60 * 1000,
})
const tasks = (data as any)?.data || []
const total = (data as any)?.meta?.total || 0
@@ -49,6 +55,30 @@ export function TasksPage() {
</div>
</div>
{/* 闭环流程条 */}
<div className="flex flex-wrap items-center gap-1 rounded-lg border bg-card p-3">
{[
{ label: '信号发现', count: '1,284', rate: (loopHealthData as any)?.data?.task_generation_rate },
{ label: '决策审批', count: String(tasks.length), rate: null },
{ label: '任务执行', count: String(total), rate: (loopHealthData as any)?.data?.store_execution_rate },
{ label: '周期检查', count: '-', rate: (loopHealthData as any)?.data?.weekly_check_rate },
{ label: '收益验收', count: String(tasks.filter((t: any) => t.status === '已验收').length), rate: (loopHealthData as any)?.data?.monthly_review_rate },
{ label: '经验标准化', count: '14', rate: (loopHealthData as any)?.data?.practice_promotion_rate },
].map((stage, i, arr) => (
<div key={stage.label} className="flex items-center gap-1">
<div className="flex flex-col items-center px-2">
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold ${i === 0 ? 'bg-blue-600 text-white' : 'bg-muted text-muted-foreground'}`}>
{i + 1}
</span>
<small className="mt-1 text-[10px] text-muted-foreground">{stage.label}</small>
<b className="text-sm font-bold">{stage.count}</b>
{stage.rate != null && <em className="text-[10px] text-green-600">{Math.round(Number(stage.rate) * 100) / 100}% </em>}
</div>
{i < arr.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
{/* 筛选器 */}
<div className="flex flex-wrap gap-2">
<SearchSelect