feat: 添加爬虫状态监控功能

新增爬虫统计信息展示组件,包括后端数据查询接口和前端展示界面。同时简化日期显示格式并添加刷新提示功能。
This commit is contained in:
dmy
2026-01-14 09:26:04 +08:00
parent 571eea0f66
commit bcd7af4e69
7 changed files with 325 additions and 76 deletions

View File

@@ -43,6 +43,15 @@ type AiRecommendation struct {
CreatedAt string `json:"createdAt"`
}
// CrawlInfoStat 爬虫统计信息结构体
type CrawlInfoStat struct {
Source string `json:"source"`
Count int `json:"count"`
LatestUpdate string `json:"latestUpdate"`
LatestPublishDate string `json:"latestPublishDate"`
Error string `json:"error"`
}
// App struct
type App struct {
ctx context.Context
@@ -207,9 +216,9 @@ func (a *App) GetPinnedBidItems() ([]BidItem, error) {
return nil, fmt.Errorf("扫描行失败: %v", err)
}
item.PublishDate = publishDate.Format("2006-01-02 15:04:05")
item.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
item.UpdatedAt = updatedAt.Format("2006-01-02 15:04:05")
item.PublishDate = publishDate.Format("2006-01-02")
item.CreatedAt = createdAt.Format("2006-01-02")
item.UpdatedAt = updatedAt.Format("2006-01-02")
items = append(items, item)
}
@@ -265,7 +274,7 @@ func (a *App) GetAiRecommendations() ([]AiRecommendation, error) {
return nil, fmt.Errorf("扫描行失败: %v", err)
}
item.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
item.CreatedAt = createdAt.Format("2006-01-02")
items = append(items, item)
}
@@ -276,3 +285,87 @@ func (a *App) GetAiRecommendations() ([]AiRecommendation, error) {
return items, nil
}
// GetCrawlInfoStats 获取爬虫统计信息
func (a *App) GetCrawlInfoStats() ([]CrawlInfoStat, error) {
dsn := a.GetDatabaseDSN()
if dsn == "" {
return nil, fmt.Errorf("数据库配置未加载")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %v", err)
}
defer db.Close()
// 测试连接
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %v", err)
}
// 查询 crawl_info_add 表,按 source 分组获取最新记录
query := `SELECT
c1.source,
c1.count,
c1.createdAt as latestUpdate,
c1.latestPublishDate,
c1.error
FROM crawl_info_add c1
WHERE c1.createdAt = (
SELECT MAX(c2.createdAt)
FROM crawl_info_add c2
WHERE c2.source = c1.source
)
ORDER BY c1.source`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("查询失败: %v", err)
}
defer rows.Close()
var stats []CrawlInfoStat
for rows.Next() {
var stat CrawlInfoStat
var latestUpdate, latestPublishDate sql.NullTime
var errorStr sql.NullString
err := rows.Scan(
&stat.Source,
&stat.Count,
&latestUpdate,
&latestPublishDate,
&errorStr,
)
if err != nil {
return nil, fmt.Errorf("扫描行失败: %v", err)
}
if latestUpdate.Valid {
stat.LatestUpdate = latestUpdate.Time.Format("2006-01-02 15:04:05")
} else {
stat.LatestUpdate = ""
}
if latestPublishDate.Valid {
stat.LatestPublishDate = latestPublishDate.Time.Format("2006-01-02 15:04:05")
} else {
stat.LatestPublishDate = ""
}
if errorStr.Valid {
stat.Error = errorStr.String
} else {
stat.Error = ""
}
stats = append(stats, stat)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历行失败: %v", err)
}
return stats, nil
}

View File

@@ -2,25 +2,43 @@
import { ref, onMounted, onUnmounted } from 'vue'
import PinnedBids from './components/PinnedBids.vue'
import AiRecommendations from './components/AiRecommendations.vue'
import WidgetCrawlInfo from './components/WidgetCrawlInfo.vue'
const activeTab = ref('pinned')
const pinnedBidsRef = ref<InstanceType<typeof PinnedBids>>()
const aiRecommendationsRef = ref<InstanceType<typeof AiRecommendations>>()
const widgetCrawlInfoRef = ref<InstanceType<typeof WidgetCrawlInfo>>()
let refreshTimer: number | null = null
const showToast = ref(false)
const toastMessage = ref('')
const tabs = [
{ id: 'pinned', label: '置顶项目' },
{ id: 'ai', label: 'AI 推荐' }
{ id: 'ai', label: 'AI 推荐' },
{ id: 'status', label: '状态' }
]
const handleRefresh = () => {
if (activeTab.value === 'pinned' && pinnedBidsRef.value) {
pinnedBidsRef.value.loadPinnedBids()
showToastMessage('置顶项目已刷新')
} else if (activeTab.value === 'ai' && aiRecommendationsRef.value) {
aiRecommendationsRef.value.loadRecommendations()
showToastMessage('AI 推荐已刷新')
} else if (activeTab.value === 'status' && widgetCrawlInfoRef.value) {
widgetCrawlInfoRef.value.loadCrawlStats()
showToastMessage('状态已刷新')
}
}
const showToastMessage = (message: string) => {
toastMessage.value = message
showToast.value = true
setTimeout(() => {
showToast.value = false
}, 2000)
}
const startAutoRefresh = () => {
// 每5分钟300000毫秒自动刷新
refreshTimer = window.setInterval(() => {
@@ -63,6 +81,11 @@ onUnmounted(() => {
<div class="tab-content">
<PinnedBids v-if="activeTab === 'pinned'" ref="pinnedBidsRef"/>
<AiRecommendations v-else-if="activeTab === 'ai'" ref="aiRecommendationsRef"/>
<WidgetCrawlInfo v-else-if="activeTab === 'status'" ref="widgetCrawlInfoRef"/>
</div>
<div v-if="showToast" class="toast">
{{ toastMessage }}
</div>
</div>
</template>
@@ -146,4 +169,27 @@ body {
color: #999;
font-size: 12px;
}
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 12px 24px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -1,71 +0,0 @@
<script lang="ts" setup>
import {reactive} from 'vue'
import {Greet} from '../../wailsjs/go/main/App'
const data = reactive({
name: "",
resultText: "Please enter your name below 👇",
})
function greet() {
Greet(data.name).then(result => {
data.resultText = result
})
}
</script>
<template>
<main>
<div id="result" class="result">{{ data.resultText }}</div>
<div id="input" class="input-box">
<input id="name" v-model="data.name" autocomplete="off" class="input" type="text"/>
<button class="btn" @click="greet">Greet</button>
</div>
</main>
</template>
<style scoped>
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
</style>

View File

@@ -0,0 +1,155 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { GetCrawlInfoStats } from '../../wailsjs/go/main/App'
interface CrawlInfoStat {
source: string
count: number
latestUpdate: string
latestPublishDate: string
error: string
}
const crawlStats = ref<CrawlInfoStat[]>([])
const loading = ref(true)
const error = ref('')
const loadCrawlStats = async () => {
try {
loading.value = true
error.value = ''
const stats = await GetCrawlInfoStats()
crawlStats.value = stats
} catch (err) {
error.value = `加载失败: ${err}`
console.error('加载爬虫统计信息失败:', err)
} finally {
loading.value = false
}
}
const getStatusText = (stat: CrawlInfoStat) => {
if (stat.error) {
return '出错'
}
if (stat.count > 0) {
return '正常'
}
return '无数据'
}
const getStatusClass = (stat: CrawlInfoStat) => {
if (stat.error) {
return 'status-error'
}
if (stat.count > 0) {
return 'status-success'
}
return 'status-info'
}
onMounted(() => {
loadCrawlStats()
})
defineExpose({
loadCrawlStats
})
</script>
<template>
<div class="crawl-info-container">
<div v-if="loading" class="loading">
加载中...
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else-if="crawlStats.length === 0" class="empty">
暂无爬虫统计信息
</div>
<div v-else class="crawl-list">
<div
v-for="stat in crawlStats"
:key="stat.source"
class="crawl-item"
>
<span class="source">{{ stat.source }}</span>
<span :class="['status', getStatusClass(stat)]">
{{ getStatusText(stat) }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.crawl-info-container {
padding: 8px;
}
.loading,
.error,
.empty {
text-align: center;
padding: 20px;
color: #666;
font-size: 12px;
}
.error {
color: #e74c3c;
}
.crawl-list {
display: grid;
gap: 8px;
}
.crawl-item {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
}
.crawl-item:hover {
border-color: #3498db;
box-shadow: 0 1px 4px rgba(52, 152, 219, 0.15);
}
.source {
font-size: 12px;
font-weight: 500;
color: #333;
}
.status {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
font-weight: 500;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.status-info {
background: #e2e3e5;
color: #383d41;
}
</style>

View File

@@ -4,6 +4,8 @@ import {main} from '../models';
export function GetAiRecommendations():Promise<Array<main.AiRecommendation>>;
export function GetCrawlInfoStats():Promise<Array<main.CrawlInfoStat>>;
export function GetDatabaseConfig():Promise<main.DatabaseConfig>;
export function GetDatabaseDSN():Promise<string>;

View File

@@ -6,6 +6,10 @@ export function GetAiRecommendations() {
return window['go']['main']['App']['GetAiRecommendations']();
}
export function GetCrawlInfoStats() {
return window['go']['main']['App']['GetCrawlInfoStats']();
}
export function GetDatabaseConfig() {
return window['go']['main']['App']['GetDatabaseConfig']();
}

View File

@@ -44,6 +44,26 @@ export namespace main {
this.updatedAt = source["updatedAt"];
}
}
export class CrawlInfoStat {
source: string;
count: number;
latestUpdate: string;
latestPublishDate: string;
error: string;
static createFrom(source: any = {}) {
return new CrawlInfoStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.source = source["source"];
this.count = source["count"];
this.latestUpdate = source["latestUpdate"];
this.latestPublishDate = source["latestPublishDate"];
this.error = source["error"];
}
}
export class DatabaseConfig {
Type: string;
Host: string;