feat: 新增日报 Tab(时间线 + Boss 决策视图)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 23:18:54 +08:00
co-authored by Claude Sonnet 4.6
parent ea9a7e6f23
commit 0dddc1af11
4 changed files with 284 additions and 13 deletions
+16 -4
View File
@@ -5,16 +5,20 @@ import { useTaskStore } from './store/tasks'
import { TopBar } from './components/TopBar'
import { ProjectOverview } from './components/ProjectOverview'
import { KanbanBoard } from './components/KanbanBoard'
import { ActiveTasksView } from './components/ActiveTasksView'
import { TaskDetail } from './components/TaskDetail'
import { NewTaskModal } from './components/NewTaskModal'
import { ConfigView } from './components/ConfigView'
import { VisualView } from './components/VisualView'
import { InsightView } from './components/InsightView'
import { DailyReportView } from './components/DailyReportView'
import { ImportProjectModal } from './components/ImportProjectModal'
import { ToastContainer } from './components/Toast'
type Tab = 'overview' | 'tasks' | 'project' | 'config' | 'visual' | 'insight' | 'daily'
export default function App() {
const [tab, setTab] = useState<'overview' | 'kanban' | 'config' | 'visual' | 'insight'>('overview')
const [tab, setTab] = useState<Tab>('overview')
const [showNewTask, setShowNewTask] = useState(false)
const [showImport, setShowImport] = useState(false)
const setProjects = useTaskStore(s => s.setProjects)
@@ -28,7 +32,13 @@ export default function App() {
function handleSelectProject(name: string) {
setActiveProject(name)
setTab('kanban')
setTab('project')
}
function handleTabChange(t: Tab) {
// 离开项目看板时清除 activeProject
if (t !== 'project' && t !== 'config') setActiveProject(null)
setTab(t)
}
return (
@@ -37,7 +47,7 @@ export default function App() {
onNewTask={() => setShowNewTask(true)}
onImport={() => setShowImport(true)}
activeTab={tab}
onTabChange={setTab}
onTabChange={handleTabChange}
/>
<main className="flex-1 overflow-auto">
@@ -47,7 +57,8 @@ export default function App() {
onSelectProject={handleSelectProject}
/>
)}
{tab === 'kanban' && (
{tab === 'tasks' && <ActiveTasksView />}
{tab === 'project' && (
<KanbanBoard
project={activeProject ?? undefined}
onImport={() => setShowImport(true)}
@@ -60,6 +71,7 @@ export default function App() {
)}
{tab === 'visual' && <VisualView />}
{tab === 'insight' && <InsightView />}
{tab === 'daily' && <DailyReportView />}
</main>
<TaskDetail />
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import {
fetchTimeline, fetchBossSummary, fetchAvailableDates,
} from '../lib/daily'
import type { TimelineEntry, BossSummary } from '../lib/daily'
const STATUS_LABEL: Record<string, string> = {
done: '[done]', failed: '[failed]', pending: '[pending]', info: '[info]',
}
const STATUS_CLS: Record<string, string> = {
done: 'border-emerald-700/40 bg-emerald-900/10',
failed: 'border-red-700/40 bg-red-900/10',
pending: 'border-yellow-700/40 bg-yellow-900/10',
info: 'border-slate-700/40 bg-slate-800/10',
}
function TimelineCard({ entry }: { entry: TimelineEntry }) {
const [expanded, setExpanded] = useState(false)
return (
<div className={`rounded-lg border p-3 ${STATUS_CLS[entry.status] ?? STATUS_CLS.info}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[10px] font-mono text-slate-400 shrink-0">
{STATUS_LABEL[entry.status] ?? '[info]'}
</span>
<span className="text-[10px] text-slate-400 shrink-0">
{String(entry.report_hour).padStart(2, '0')}:xx
</span>
<span className="text-xs font-mono text-blue-400 shrink-0">[{entry.source_role}]</span>
<span className="text-xs text-slate-300 truncate">{entry.project}</span>
</div>
{entry.raw_output && (
<button onClick={() => setExpanded(e => !e)}
className="text-[10px] text-slate-500 hover:text-slate-300 shrink-0">
{expanded ? '收起' : '展开'}
</button>
)}
</div>
<div className="mt-1.5 text-xs text-slate-300 prose prose-sm dark:prose-invert
prose-slate max-w-none prose-p:my-0.5 prose-ul:my-0.5">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.summary}</ReactMarkdown>
</div>
{expanded && entry.raw_output && (
<div className="mt-2 pt-2 border-t border-slate-700/40">
<div className="text-xs text-slate-400 prose prose-sm dark:prose-invert prose-slate
max-w-none prose-p:my-0.5 max-h-48 overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.raw_output}</ReactMarkdown>
</div>
</div>
)}
</div>
)
}
function BossView({ summary }: { summary: BossSummary }) {
const done: { group: string; items: string[] }[] = JSON.parse(summary.done_json || '[]')
const pending: { group: string; issue: string; owner: string }[] = JSON.parse(summary.pending_json || '[]')
const health: Record<string, { done: number; running: number; failed: number }> =
JSON.parse(summary.org_health || '{}')
return (
<div className="space-y-4">
<div className="rounded-lg bg-blue-900/20 border border-blue-700/40 p-3">
<p className="text-xs text-blue-300 font-semibold mb-1"></p>
<p className="text-sm text-slate-200">{summary.summary}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<p className="text-[10px] text-emerald-400 uppercase tracking-wider font-semibold"></p>
{done.length === 0 && <p className="text-xs text-slate-500"></p>}
{done.map((g, i) => (
<div key={i} className="rounded-lg border border-emerald-700/30 bg-emerald-900/10 p-2.5">
<p className="text-xs font-medium text-emerald-300 mb-1">{g.group}</p>
<ul className="space-y-0.5">
{g.items.map((item, j) => (
<li key={j} className="text-xs text-slate-300"> {item}</li>
))}
</ul>
</div>
))}
</div>
<div className="space-y-2">
<p className="text-[10px] text-red-400 uppercase tracking-wider font-semibold"></p>
{pending.length === 0 && <p className="text-xs text-slate-500"></p>}
{pending.map((p, i) => (
<div key={i} className="rounded-lg border border-red-700/30 bg-red-900/10 p-2.5">
<p className="text-xs text-red-300">{p.issue}</p>
<p className="text-[10px] text-slate-500 mt-0.5">{p.group} · {p.owner}</p>
</div>
))}
</div>
</div>
{Object.keys(health).length > 0 && (
<div>
<p className="text-[10px] text-slate-400 uppercase tracking-wider font-semibold mb-2"></p>
<div className="space-y-1.5">
{Object.entries(health).map(([group, stats]) => {
const total = stats.done + stats.running + stats.failed
const pct = total > 0 ? Math.round(stats.done / total * 100) : 0
return (
<div key={group} className="flex items-center gap-3">
<span className="text-xs text-slate-400 w-28 truncate">{group}</span>
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full transition-all"
style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-slate-500 w-8 text-right">{pct}%</span>
{stats.failed > 0 && (
<span className="text-[10px] text-red-400">{stats.failed}</span>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
export function DailyReportView() {
const [dates, setDates] = useState<string[]>([])
const [selectedDate, setSelectedDate] = useState<string>('')
const [timeline, setTimeline] = useState<TimelineEntry[]>([])
const [bossSummary, setBossSummary] = useState<BossSummary | null>(null)
const [view, setView] = useState<'timeline' | 'boss'>('boss')
const [loading, setLoading] = useState(false)
useEffect(() => {
fetchAvailableDates().then(d => {
setDates(d)
if (d.length > 0) setSelectedDate(d[0])
else setSelectedDate(new Date().toISOString().slice(0, 10))
}).catch(() => {
setSelectedDate(new Date().toISOString().slice(0, 10))
})
}, [])
useEffect(() => {
if (!selectedDate) return
setLoading(true)
Promise.all([
fetchTimeline(selectedDate),
fetchBossSummary(selectedDate),
]).then(([tl, bs]) => {
setTimeline(tl)
setBossSummary(bs)
}).finally(() => setLoading(false))
}, [selectedDate])
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-700/50">
<span className="text-sm font-semibold text-slate-200"></span>
<select
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
className="text-xs bg-slate-800 border border-slate-700 rounded-lg px-2 py-1
text-slate-300 focus:outline-none"
>
{dates.length === 0 && (
<option value={selectedDate}>{selectedDate}</option>
)}
{dates.map(d => <option key={d} value={d}>{d}</option>)}
</select>
<div className="flex gap-1 bg-slate-800 rounded-lg p-0.5">
{(['boss', 'timeline'] as const).map(v => (
<button key={v} onClick={() => setView(v)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
view === v
? 'bg-slate-700 text-white'
: 'text-slate-500 hover:text-slate-300'
}`}>
{v === 'boss' ? '决策视图' : '时间线'}
</button>
))}
</div>
{loading && <span className="text-xs text-slate-500">...</span>}
</div>
<div className="flex-1 overflow-y-auto p-4">
{view === 'timeline' ? (
<div className="space-y-2 max-w-3xl">
{timeline.length === 0 && (
<p className="text-sm text-slate-500 text-center py-8">
{selectedDate}
</p>
)}
{timeline.map(entry => (
<TimelineCard key={entry.id} entry={entry} />
))}
</div>
) : (
<div className="max-w-2xl">
{bossSummary ? (
<BossView summary={bossSummary} />
) : (
<div className="text-center py-8 text-slate-500">
<p className="text-sm">{selectedDate} Boss </p>
<p className="text-xs mt-1">Ops Agent </p>
</div>
)}
</div>
)}
</div>
</div>
)
}
+15 -9
View File
@@ -4,11 +4,13 @@ import {
fetchExecutorStatus, startExecutor, stopExecutor, pauseProject, resumeProject,
} from '../lib/api'
type Tab = 'overview' | 'tasks' | 'project' | 'config' | 'visual' | 'insight' | 'daily'
interface Props {
onNewTask: () => void
onImport: () => void
activeTab: 'overview' | 'kanban' | 'config' | 'visual' | 'insight'
onTabChange: (t: 'overview' | 'kanban' | 'config' | 'visual' | 'insight') => void
activeTab: Tab
onTabChange: (t: Tab) => void
}
interface ExecStatus {
@@ -44,7 +46,7 @@ export function TopBar({ onNewTask, onImport, activeTab, onTabChange }: Props) {
return () => { alive = false; clearInterval(id) }
}, [])
const isProjectView = activeTab === 'kanban' && !!activeProject
const isProjectView = activeTab === 'project' && !!activeProject
// 当前项目是否被暂停
const isProjectPaused = !!activeProject && execStatus.paused_projects.includes(activeProject)
@@ -132,7 +134,7 @@ export function TopBar({ onNewTask, onImport, activeTab, onTabChange }: Props) {
bg-white/95 dark:bg-gray-900/95 backdrop-blur sticky top-0 z-30">
{/* Logo + 连接状态 */}
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-slate-900 dark:text-white">Rockchip Agents</span>
<span className="text-sm font-bold text-slate-900 dark:text-white">NMFS Agents</span>
<div className={`w-1.5 h-1.5 rounded-full ${
isConnected ? 'bg-green-400' : 'bg-yellow-400 animate-pulse'
}`} title={wsStatus} />
@@ -169,11 +171,15 @@ export function TopBar({ onNewTask, onImport, activeTab, onTabChange }: Props) {
{/* 右侧:视图切换 + 主题切换 + 操作按钮 */}
<div className="ml-auto flex items-center gap-3">
<div className="flex gap-1 bg-slate-100 dark:bg-gray-800 rounded-lg p-0.5">
<ViewTab label="概览" active={activeTab === 'overview'} onClick={() => onTabChange('overview')} />
<ViewTab label="任务" active={activeTab === 'kanban'} onClick={() => onTabChange('kanban')} />
<ViewTab label="配置" active={activeTab === 'config'} onClick={() => onTabChange('config')} />
<ViewTab label="Visual" active={activeTab === 'visual'} onClick={() => onTabChange('visual')} />
<ViewTab label="洞察" active={activeTab === 'insight'} onClick={() => onTabChange('insight')} />
<ViewTab label="概览" active={activeTab === 'overview'} onClick={() => onTabChange('overview')} />
<ViewTab label="任务" active={activeTab === 'tasks'} onClick={() => onTabChange('tasks')} />
{activeProject && (
<ViewTab label={activeProject} active={activeTab === 'project'} onClick={() => onTabChange('project')} />
)}
<ViewTab label="配置" active={activeTab === 'config'} onClick={() => onTabChange('config')} />
<ViewTab label="Visual" active={activeTab === 'visual'} onClick={() => onTabChange('visual')} />
<ViewTab label="洞察" active={activeTab === 'insight'} onClick={() => onTabChange('insight')} />
<ViewTab label="日报" active={activeTab === 'daily'} onClick={() => onTabChange('daily')} />
</div>
{/* 主题切换 */}
+42
View File
@@ -0,0 +1,42 @@
const BASE = (import.meta.env.VITE_API_URL ?? '') + '/api'
export interface TimelineEntry {
id: number
report_date: string
report_hour: number
event_type: string
source_role: string
project: string
summary: string
raw_output: string
status: string
created_at: string
}
export interface BossSummary {
report_date: string
summary: string
done_json: string
pending_json: string
org_health: string
updated_at: string
}
export async function fetchTimeline(date: string): Promise<TimelineEntry[]> {
const r = await fetch(`${BASE}/daily/timeline?date=${date}`)
if (!r.ok) throw new Error('fetch timeline failed')
return r.json()
}
export async function fetchBossSummary(date: string): Promise<BossSummary | null> {
const r = await fetch(`${BASE}/daily/boss?date=${date}`)
if (!r.ok) throw new Error('fetch boss summary failed')
const data = await r.json()
return data ?? null
}
export async function fetchAvailableDates(): Promise<string[]> {
const r = await fetch(`${BASE}/daily/dates`)
if (!r.ok) throw new Error('fetch dates failed')
return r.json()
}