feat: Implement core backend services for team owner management, SQLite persistence, and logging, alongside frontend monitoring and record views.
This commit is contained in:
204
frontend/src/components/LiveLogViewer.tsx
Normal file
204
frontend/src/components/LiveLogViewer.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Terminal, Play, Pause, Trash2, ChevronDown } from 'lucide-react'
|
||||
|
||||
interface LogEntry {
|
||||
type: string
|
||||
timestamp: string
|
||||
level: string
|
||||
message: string
|
||||
email?: string
|
||||
module?: string
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
success: 'text-green-400',
|
||||
error: 'text-red-400',
|
||||
warning: 'text-yellow-400',
|
||||
info: 'text-cyan-400',
|
||||
}
|
||||
|
||||
const levelBgColors: Record<string, string> = {
|
||||
success: 'bg-green-900/20',
|
||||
error: 'bg-red-900/20',
|
||||
warning: 'bg-yellow-900/20',
|
||||
info: 'bg-cyan-900/20',
|
||||
}
|
||||
|
||||
interface LiveLogViewerProps {
|
||||
maxLogs?: number
|
||||
autoScroll?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LiveLogViewer({
|
||||
maxLogs = 200,
|
||||
autoScroll: initialAutoScroll = true,
|
||||
className = '',
|
||||
}: LiveLogViewerProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [autoScroll, setAutoScroll] = useState(initialAutoScroll)
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const pausedLogsRef = useRef<LogEntry[]>([])
|
||||
|
||||
// 连接 SSE
|
||||
const connect = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
const es = new EventSource('/api/logs/stream')
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.onopen = () => {
|
||||
setIsConnected(true)
|
||||
}
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as LogEntry
|
||||
|
||||
if (data.type === 'connected') {
|
||||
console.log('SSE connected:', data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === 'log') {
|
||||
if (isPaused) {
|
||||
pausedLogsRef.current.push(data)
|
||||
} else {
|
||||
setLogs((prev) => {
|
||||
const newLogs = [...prev, data]
|
||||
return newLogs.slice(-maxLogs)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
setIsConnected(false)
|
||||
// 自动重连
|
||||
setTimeout(connect, 3000)
|
||||
}
|
||||
}, [isPaused, maxLogs])
|
||||
|
||||
// 组件挂载时连接
|
||||
useEffect(() => {
|
||||
connect()
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [connect])
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [logs, autoScroll])
|
||||
|
||||
// 恢复暂停的日志
|
||||
const handleResume = () => {
|
||||
setIsPaused(false)
|
||||
if (pausedLogsRef.current.length > 0) {
|
||||
setLogs((prev) => {
|
||||
const newLogs = [...prev, ...pausedLogsRef.current]
|
||||
pausedLogsRef.current = []
|
||||
return newLogs.slice(-maxLogs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setLogs([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-slate-900 rounded-xl border border-slate-700 overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-800/50 border-b border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm font-medium text-slate-200">实时日志</span>
|
||||
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-slate-400">
|
||||
{isConnected ? '已连接' : '连接中...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{logs.length} 条日志</span>
|
||||
<button
|
||||
onClick={() => isPaused ? handleResume() : setIsPaused(true)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${isPaused
|
||||
? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
|
||||
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'
|
||||
}`}
|
||||
title={isPaused ? '继续' : '暂停'}
|
||||
>
|
||||
{isPaused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 rounded-lg bg-slate-700 text-slate-400 hover:bg-slate-600 transition-colors"
|
||||
title="清空"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${autoScroll
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-slate-700 text-slate-400'
|
||||
}`}
|
||||
title={autoScroll ? '自动滚动: 开' : '自动滚动: 关'}
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Content */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="h-80 overflow-y-auto p-3 font-mono text-xs space-y-0.5"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-slate-500">
|
||||
等待日志...
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-2 px-2 py-0.5 rounded ${levelBgColors[log.level] || ''}`}
|
||||
>
|
||||
<span className="text-slate-500 flex-shrink-0">{log.timestamp}</span>
|
||||
<span className={`flex-shrink-0 uppercase font-semibold w-16 ${levelColors[log.level] || 'text-slate-400'}`}>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="text-slate-400 flex-shrink-0">[{log.module}]</span>
|
||||
{log.email && (
|
||||
<span className="text-purple-400 flex-shrink-0 truncate max-w-[150px]">{log.email}</span>
|
||||
)}
|
||||
<span className="text-slate-200 flex-1">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paused indicator */}
|
||||
{isPaused && pausedLogsRef.current.length > 0 && (
|
||||
<div className="px-4 py-2 bg-yellow-500/10 border-t border-yellow-500/30 text-yellow-400 text-xs text-center">
|
||||
已暂停 - 有 {pausedLogsRef.current.length} 条新日志等待显示
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input, Switch } from '../components/common'
|
||||
import LiveLogViewer from '../components/LiveLogViewer'
|
||||
import type { DashboardStats } from '../types'
|
||||
|
||||
interface PoolStatus {
|
||||
@@ -736,6 +737,8 @@ export default function Monitor() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* 实时日志 */}
|
||||
<LiveLogViewer className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,92 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Trash2, Calendar } from 'lucide-react'
|
||||
import { RecordList, RecordStats } from '../components/records'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RefreshCw, Calendar, TrendingUp, CheckCircle, Clock, AlertCircle } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useRecords } from '../hooks/useRecords'
|
||||
|
||||
interface BatchRun {
|
||||
id: number
|
||||
started_at: string
|
||||
finished_at: string
|
||||
total_owners: number
|
||||
total_registered: number
|
||||
total_added_to_s2a: number
|
||||
success_rate: number
|
||||
duration_seconds: number
|
||||
status: string
|
||||
errors: string
|
||||
}
|
||||
|
||||
interface BatchStats {
|
||||
total_added: number
|
||||
today_added: number
|
||||
avg_success_rate: number
|
||||
week_added: number
|
||||
}
|
||||
|
||||
export default function Records() {
|
||||
const { records, deleteRecord, clearRecords, getStats } = useRecords()
|
||||
const [runs, setRuns] = useState<BatchRun[]>([])
|
||||
const [stats, setStats] = useState<BatchStats>({ total_added: 0, today_added: 0, avg_success_rate: 0, week_added: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
|
||||
const stats = useMemo(() => getStats(), [getStats])
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [runsRes, statsRes] = await Promise.all([
|
||||
fetch('/api/batch/runs'),
|
||||
fetch('/api/batch/stats')
|
||||
])
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (!startDate && !endDate) return records
|
||||
if (runsRes.ok) {
|
||||
const data = await runsRes.json()
|
||||
if (data.code === 0) {
|
||||
setRuns(data.data || [])
|
||||
}
|
||||
}
|
||||
|
||||
return records.filter((record) => {
|
||||
const recordDate = new Date(record.timestamp)
|
||||
const start = startDate ? new Date(startDate) : null
|
||||
const end = endDate ? new Date(endDate + 'T23:59:59') : null
|
||||
|
||||
if (start && recordDate < start) return false
|
||||
if (end && recordDate > end) return false
|
||||
return true
|
||||
})
|
||||
}, [records, startDate, endDate])
|
||||
|
||||
const handleClearFilter = () => {
|
||||
setStartDate('')
|
||||
setEndDate('')
|
||||
if (statsRes.ok) {
|
||||
const data = await statsRes.json()
|
||||
if (data.code === 0) {
|
||||
setStats(data.data)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取数据失败:', e)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
if (window.confirm('确定要清空所有记录吗?此操作不可恢复。')) {
|
||||
clearRecords()
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// 筛选记录
|
||||
const filteredRuns = runs.filter((run) => {
|
||||
if (!startDate && !endDate) return true
|
||||
const recordDate = new Date(run.started_at)
|
||||
const start = startDate ? new Date(startDate) : null
|
||||
const end = endDate ? new Date(endDate + 'T23:59:59') : null
|
||||
if (start && recordDate < start) return false
|
||||
if (end && recordDate > end) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (seconds < 60) return `${seconds}秒`
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}分${secs}秒`
|
||||
}
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -44,20 +97,67 @@ export default function Records() {
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">加号记录</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">查看历史入库记录</p>
|
||||
</div>
|
||||
{records.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
>
|
||||
清空记录
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchData}
|
||||
loading={loading}
|
||||
icon={<RefreshCw className="h-4 w-4" />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<RecordStats stats={stats} />
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border-blue-200 dark:border-blue-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">总入库</div>
|
||||
<div className="text-2xl font-bold text-blue-700 dark:text-blue-300">{stats.total_added}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border-green-200 dark:border-green-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<div className="text-xs text-green-600 dark:text-green-400">成功率</div>
|
||||
<div className="text-2xl font-bold text-green-700 dark:text-green-300">{stats.avg_success_rate.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 border-orange-200 dark:border-orange-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||
<div>
|
||||
<div className="text-xs text-orange-600 dark:text-orange-400">今日入库</div>
|
||||
<div className="text-2xl font-bold text-orange-700 dark:text-orange-300">{stats.today_added}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border-purple-200 dark:border-purple-800">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
<div>
|
||||
<div className="text-xs text-purple-600 dark:text-purple-400">本周入库</div>
|
||||
<div className="text-2xl font-bold text-purple-700 dark:text-purple-300">{stats.week_added}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Card>
|
||||
@@ -66,11 +166,6 @@ export default function Records() {
|
||||
<Calendar className="h-5 w-5" />
|
||||
日期筛选
|
||||
</CardTitle>
|
||||
{(startDate || endDate) && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilter}>
|
||||
清除筛选
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
@@ -91,8 +186,7 @@ export default function Records() {
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
共 {filteredRecords.length} 条记录
|
||||
{filteredRecords.length !== records.length && <span className="ml-1">(已筛选)</span>}
|
||||
共 {filteredRuns.length} 条记录
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -104,7 +198,69 @@ export default function Records() {
|
||||
<CardTitle>记录列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecordList records={filteredRecords} onDelete={deleteRecord} />
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-slate-400" />
|
||||
<span className="ml-2 text-slate-500">加载中...</span>
|
||||
</div>
|
||||
) : filteredRuns.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-slate-400">
|
||||
<CheckCircle className="h-12 w-12 mb-3 opacity-50" />
|
||||
<span>暂无加号记录</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
|
||||
<th className="pb-3 font-medium">时间</th>
|
||||
<th className="pb-3 font-medium">母号数</th>
|
||||
<th className="pb-3 font-medium">注册</th>
|
||||
<th className="pb-3 font-medium">入库</th>
|
||||
<th className="pb-3 font-medium">成功率</th>
|
||||
<th className="pb-3 font-medium">耗时</th>
|
||||
<th className="pb-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{filteredRuns.map((run) => (
|
||||
<tr key={run.id} className="text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td className="py-3">{formatTime(run.started_at)}</td>
|
||||
<td className="py-3">{run.total_owners}</td>
|
||||
<td className="py-3">{run.total_registered}</td>
|
||||
<td className="py-3">
|
||||
<span className="font-semibold text-green-600 dark:text-green-400">{run.total_added_to_s2a}</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span className={`font-medium ${run.success_rate >= 80 ? 'text-green-600' : run.success_rate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{run.success_rate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-slate-500">{formatDuration(run.duration_seconds)}</td>
|
||||
<td className="py-3">
|
||||
{run.status === 'completed' ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
完成
|
||||
</span>
|
||||
) : run.status === 'running' ? (
|
||||
<span className="inline-flex items-center gap-1 text-blue-600">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
运行中
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{run.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user