feat: 新增日报 Tab(时间线 + Boss 决策视图)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+16
-4
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 主题切换 */}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user