feat: Implement core backend services for team owner management, SQLite persistence, and logging, alongside frontend monitoring and record views.

This commit is contained in:
2026-01-30 18:15:50 +08:00
parent e61430b60d
commit 165c6d69b9
7 changed files with 844 additions and 125 deletions

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>