Files
getDomain/domain-web/src/views/detect/DetectView.vue
Your Name 7cbde2aa78 d
2026-04-22 14:13:21 +08:00

1043 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageCard title="检测控制" description="已升级为任务中心:支持启停控制、阶段查看、日志常驻和会话回看,并沉淀线程/代理/进度快照。">
<div class="actions">
<el-button type="primary" :loading="actionLoading === 'start'" @click="invoke('start')">启动检测</el-button>
<el-button :loading="actionLoading === 'stop'" @click="invoke('stop')">停止检测</el-button>
<el-button plain :loading="loading" @click="loadStatus">刷新状态</el-button>
<el-button plain @click="goRuntime">前往运行中心</el-button>
<el-button-group>
<el-button plain :type="status.worker_log_sync_enabled ? '' : 'primary'" @click="updateWorkerLogSync(false, 'key')">关闭回传</el-button>
<el-button plain :type="status.worker_log_sync_enabled && status.worker_log_sync_mode === 'key' ? 'primary' : ''" @click="updateWorkerLogSync(true, 'key')">关键回传</el-button>
<el-button plain :type="status.worker_log_sync_enabled && status.worker_log_sync_mode === 'full' ? 'primary' : ''" @click="updateWorkerLogSync(true, 'full')">全量回传</el-button>
</el-button-group>
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
<span class="updated-at">最近同步{{ lastUpdatedAt || "暂无" }}</span>
</div>
<el-alert
v-if="lastAction.message"
:title="`${lastAction.label}${lastAction.message}${lastAction.at ? `${lastAction.at}` : ''}`"
:type="lastAction.type"
:closable="false"
show-icon
class="action-alert"
/>
<el-descriptions :column="2" border>
<el-descriptions-item label="本机 Worker 在线">{{ status.worker_online ? "是" : "否" }}</el-descriptions-item>
<el-descriptions-item label="运行模式">{{ status.worker_mode || "windows-local" }}</el-descriptions-item>
<el-descriptions-item label="Worker 服务">{{ status.worker_service_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="API 服务">{{ status.api_service_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="检测进程数">{{ status.worker_process_count }}</el-descriptions-item>
<el-descriptions-item label="本机线程配置">{{ status.thread_count }}</el-descriptions-item>
<el-descriptions-item label="线程来源">{{ threadCountSummary }}</el-descriptions-item>
<el-descriptions-item v-if="isAggregateDetectView" label="当前汇总线程">{{ threadSnapshotActive }} / {{ threadSnapshotMax }}</el-descriptions-item>
<el-descriptions-item v-if="isAggregateDetectView" label="当前参与节点">{{ participatingNodeCount }}</el-descriptions-item>
<el-descriptions-item label="代理启用">{{ status.proxy_enable ? "是" : "否" }}</el-descriptions-item>
<el-descriptions-item label="允许直连">{{ status.allow_direct ? "是" : "否" }}</el-descriptions-item>
<el-descriptions-item label="代理池数量">{{ status.proxy_pool_count }}</el-descriptions-item>
<el-descriptions-item label="可用代理数">{{ status.available_proxy_count }}</el-descriptions-item>
<el-descriptions-item label="代理状态">{{ status.proxy_runtime_label || "-" }}</el-descriptions-item>
<el-descriptions-item label="代理诊断">{{ proxyDiagnosisText }}</el-descriptions-item>
<el-descriptions-item label="最近代理刷新">{{ status.proxy_last_refresh_time || "暂无" }}</el-descriptions-item>
<el-descriptions-item label="最近 Worker 日志时间">{{ status.last_worker_log_time || "暂无" }}</el-descriptions-item>
<el-descriptions-item label="最近启动时间">{{ status.worker_latest_start_time || "暂无" }}</el-descriptions-item>
<el-descriptions-item label="远端日志回传">{{ status.worker_log_sync_enabled ? "已开启" : "已关闭" }}</el-descriptions-item>
<el-descriptions-item label="回传级别">{{ status.worker_log_sync_enabled ? (status.worker_log_sync_mode === "full" ? "全量" : "关键") : "-" }}</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="status.proxy_runtime_detail"
:type="proxyAlertType"
:closable="false"
show-icon
:title="status.proxy_runtime_detail"
style="margin-top: 16px"
/>
<el-alert
v-if="status.proxy_supplier_empty"
type="warning"
:closable="false"
show-icon
title="代理供应侧最近返回 200但原始代理数为 0当前判断是供应池为空不是程序拉取失败。"
style="margin-top: 12px"
/>
<el-alert
v-if="status.recent_warning"
type="warning"
:closable="false"
show-icon
:title="status.recent_warning"
style="margin-top: 16px"
/>
<el-alert
v-for="alert in status.dependency_alerts || []"
:key="`${alert.kind}-${alert.detail}`"
:type="alert.level === 'error' ? 'error' : 'warning'"
:closable="false"
show-icon
:title="`${alert.title}${alert.detail}`"
style="margin-top: 12px"
/>
<el-alert
v-if="status.worker_runtime_message"
type="info"
:closable="false"
show-icon
:title="status.worker_runtime_message"
style="margin-top: 12px"
/>
<el-alert
v-if="status.recent_event"
type="success"
:closable="false"
show-icon
:title="status.recent_event"
style="margin-top: 12px"
/>
<el-alert
:closable="false"
show-icon
type="info"
:title="detectScopeSummaryText"
style="margin-top: 12px"
/>
<el-alert
:closable="false"
show-icon
:type="status.worker_log_sync_enabled ? 'info' : 'warning'"
:title="remoteLogMirrorText"
style="margin-top: 12px"
/>
<el-row :gutter="12" style="margin-top: 16px; margin-bottom: 16px">
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="待检测" :value="status.progress.pending || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测中" :value="displayRunningStat" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测通过" :value="status.progress.completed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="黑名单" :value="status.progress.blacklisted || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测失败" :value="status.progress.failed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="可注册" :value="registerableStat" /></el-col>
</el-row>
<div class="summary-grid">
<div class="summary-card">
<div class="summary-title">当前任务</div>
<div class="summary-value task-code">{{ activeJob?.job_code || "-" }}</div>
<div class="summary-note">{{ activeJobSummaryText }}</div>
</div>
<div class="summary-card">
<div class="summary-title">检测进度</div>
<div class="summary-value">{{ status.progress_percent || 0 }}%</div>
<el-progress :percentage="Number(status.progress_percent || 0)" :stroke-width="10" />
</div>
<div class="summary-card">
<div class="summary-title">当前阶段</div>
<div class="summary-value phase-value">{{ currentPhaseLabel }}</div>
<div class="summary-note">{{ currentPhaseDetail }}</div>
</div>
<div class="summary-card">
<div class="summary-title">活跃线程</div>
<div class="summary-value">{{ threadSnapshotActive }} / {{ threadSnapshotMax }}</div>
<div class="summary-note">当前参与节点实际活跃线程 / 配置线程上限</div>
</div>
<div class="summary-card">
<div class="summary-title">当前速度</div>
<div class="summary-value">{{ queueProcessedPerMinute }}</div>
<div class="summary-note"> {{ queueProcessedPerHour }} /小时</div>
</div>
<div class="summary-card">
<div class="summary-title">预计剩余</div>
<div class="summary-value">{{ estimatedRemainingText }}</div>
<div class="summary-note">按当前吞吐推算剩余 {{ remainingItemsEstimate }} </div>
</div>
<div class="summary-card">
<div class="summary-title">代理快照</div>
<div class="summary-value">{{ status.available_proxy_count || 0 }} / {{ status.proxy_pool_count || 0 }}</div>
<div class="summary-note">{{ proxyDiagnosisText }}</div>
</div>
</div>
<el-row v-if="activeJob" :gutter="12" style="margin-bottom: 16px">
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务待领" :value="activeJob.items_pending || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务已领" :value="activeJobDisplayClaimed" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务执行中" :value="activeJobDisplayRunning" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="活跃线程" :value="threadSnapshotActive" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务完成" :value="activeJob.items_completed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务黑名单" :value="activeJob.items_blacklisted || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务失败" :value="activeJob.items_failed || 0" /></el-col>
</el-row>
<div v-if="activeJob" class="summary-grid summary-grid-tight">
<div class="summary-card">
<div class="summary-title">任务进度</div>
<div class="summary-value">{{ activeJob.progress_percent || 0 }}%</div>
<el-progress :percentage="Number(activeJob.progress_percent || 0)" :stroke-width="10" />
</div>
<div class="summary-card">
<div class="summary-title">最近任务事件</div>
<div class="summary-value event-value">{{ activeJob.latest_event?.event_type || "-" }}</div>
<div class="summary-note">{{ activeJob.latest_event?.message || "当前还没有任务事件" }}</div>
</div>
</div>
<div class="history-panel">
<div class="history-header">
<h3>队列健康度</h3>
<span class="history-note">聚合近 {{ queueSummary.window_minutes || 15 }} 分钟吞吐租约和积压便于后续多机扩容判断</span>
</div>
<div class="summary-grid summary-grid-tight">
<div class="summary-card">
<div class="summary-title">近窗吞吐</div>
<div class="summary-value">{{ queueSummary.throughput?.processed_recent || 0 }}</div>
<div class="summary-note"> {{ queueSummary.window_minutes || 15 }} 分钟处理 {{ queueSummary.throughput?.processed_per_minute || 0 }} /分钟</div>
</div>
<div class="summary-card">
<div class="summary-title">租约风险</div>
<div class="summary-value">{{ queueSummary.queue?.overdue_leases || 0 }}</div>
<div class="summary-note">过期 {{ queueSummary.queue?.overdue_leases || 0 }} / 5 分钟内到期 {{ queueSummary.queue?.expiring_soon_leases || 0 }}</div>
</div>
<div class="summary-card">
<div class="summary-title">最老待领</div>
<div class="summary-value">{{ queueSummary.queue?.oldest_pending_age_minutes || 0 }} 分钟</div>
<div class="summary-note">{{ queueSummary.queue?.oldest_pending_at || "当前没有待领取任务项" }}</div>
</div>
<div class="summary-card">
<div class="summary-title">任务收敛</div>
<div class="summary-value">{{ queueSummary.queue?.terminal_percent || 0 }}%</div>
<div class="summary-note">已收敛 {{ queueSummary.queue?.terminal || 0 }} / {{ queueSummary.queue?.items_total || 0 }}</div>
</div>
</div>
<el-alert
:closable="false"
show-icon
:type="queueAlertType"
style="margin-bottom: 12px"
:title="queueAlertText"
/>
<el-table :data="queueSummary.nodes || []" border style="margin-bottom: 16px" empty-text="当前没有可展示的节点吞吐摘要">
<el-table-column prop="node_code" label="节点" min-width="180" />
<el-table-column prop="items_running" label="任务执行中" min-width="100" />
<el-table-column prop="items_claimed" label="已领未跑" min-width="100" />
<el-table-column prop="processed_recent" label="近窗处理" min-width="100" />
<el-table-column prop="processed_per_minute" label="项/分钟" min-width="100" />
<el-table-column prop="completed_recent" label="完成" min-width="90" />
<el-table-column prop="blacklisted_recent" label="黑名单" min-width="90" />
<el-table-column prop="failed_recent" label="失败" min-width="90" />
</el-table>
</div>
<el-table v-if="activeJobDisplayNodeStats.length" :data="activeJobDisplayNodeStats" border style="margin-bottom: 16px">
<el-table-column prop="node_code" label="节点" min-width="180" />
<el-table-column prop="items_total" label="任务总数" min-width="100" />
<el-table-column prop="items_pending" label="待领" min-width="90" />
<el-table-column prop="items_claimed" label="已领" min-width="90" />
<el-table-column prop="items_running" label="任务执行中" min-width="100" />
<el-table-column label="活跃线程" min-width="130">
<template #default="{ row }">
<span>{{ row.active_threads || 0 }} / {{ row.max_threads || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="current_load" label="在途负载" min-width="100" />
<el-table-column prop="items_completed" label="完成" min-width="90" />
<el-table-column prop="items_failed" label="失败" min-width="90" />
</el-table>
<el-table :data="runs" border highlight-current-row @current-change="handleCurrentRunChange">
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="dark">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="阶段" min-width="110">
<template #default="{ row }">
<el-tag :type="phaseTagType(row.phase_label, row.status)" plain>{{ row.phase_label || "-" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="会话信息" min-width="280" show-overflow-tooltip />
<el-table-column prop="started_at" label="开始时间" min-width="160" />
<el-table-column prop="completed_at" label="结束时间" min-width="160" />
<el-table-column label="线程/代理" min-width="160">
<template #default="{ row }">
<span>线程 {{ row.settings_summary?.thread_count || 0 }} / 代理 {{ row.settings_summary?.proxy_pool_count || 0 }}</span>
</template>
</el-table-column>
<el-table-column label="进度快照" min-width="220">
<template #default="{ row }">
<span>
{{ row.progress?.pending || 0 }}
/ {{ row.progress?.running || 0 }}
/ {{ row.progress?.completed || 0 }}
/ {{ row.progress?.blacklisted || 0 }}
/ 失败 {{ row.progress?.failed || 0 }}
</span>
</template>
</el-table-column>
</el-table>
<div class="task-detail">
<div class="task-detail-header">
<div>
<div class="task-detail-title">任务日志控制台</div>
<div class="task-detail-subtitle">
{{ selectedRun ? `当前查看会话:${selectedRun.run_id}` : "当前暂无检测会话,日志窗口会显示最近 Worker 输出" }}
</div>
</div>
<div class="task-detail-meta">
<el-switch v-model="logAutoFollow" inline-prompt active-text="跟随日志" inactive-text="暂停跟随" size="small" />
<el-button text size="small" @click="scrollLogToBottom(true)">回到底部</el-button>
<span>{{ isAggregateDetectView ? "参与节点" : "本机 Worker" }}{{ isAggregateDetectView ? participatingNodeCount : (status.worker_online ? "在线" : "离线") }}</span>
<span v-if="selectedRun?.phase_label">阶段{{ selectedRun.phase_label }}</span>
<span>运行中{{ displayRunningStat }}</span>
<span>线程{{ runtimeThreadSummary }}</span>
</div>
</div>
<div v-if="selectedRun?.phase_detail" class="phase-detail">{{ selectedRun.phase_detail }}</div>
<pre ref="logConsoleRef" class="log-console" @scroll="handleLogScroll">{{ selectedLogText }}</pre>
</div>
<div
v-if="(activeJob?.current_cycle_events || activeJob?.recent_events)?.length"
class="event-stream-panel"
>
<div class="event-stream-header">
<div>
<div class="event-stream-title">检测事件流</div>
<div class="event-stream-subtitle">事件列表单独滚动避免检测进行越久页面越被撑长</div>
</div>
<span class="event-stream-count">
{{ (activeJob.current_cycle_events || activeJob.recent_events || []).length }}
</span>
</div>
<el-table
:data="activeJob.current_cycle_events || activeJob.recent_events"
border
max-height="320"
>
<el-table-column prop="created_at" label="事件时间" min-width="160" />
<el-table-column prop="node_code" label="节点" min-width="160" />
<el-table-column prop="event_type" label="事件类型" min-width="160" />
<el-table-column prop="message" label="事件说明" min-width="320" show-overflow-tooltip />
</el-table>
</div>
<el-table v-if="phaseHistory.length" :data="phaseHistory" border style="margin-top: 16px">
<el-table-column prop="at" label="切换时间" min-width="180" />
<el-table-column prop="label" label="阶段" min-width="120" />
<el-table-column prop="detail" label="阶段说明" min-width="360" show-overflow-tooltip />
</el-table>
</PageCard>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { detectApi, settingsApi } from "@/api/modules";
const DETECT_LAST_ACTION_KEY = "domaincheck:detect:last-action";
const router = useRouter();
const status = ref({
worker_online: false,
worker_mode: "windows-local",
worker_service_name: "",
api_service_name: "",
worker_process_count: 0,
worker_latest_start_time: "",
worker_runtime_message: "",
thread_count: 0,
thread_count_default: 0,
thread_count_source: "default",
thread_count_override: null as number | null,
thread_count_node_code: "",
active_thread_count: 0,
max_thread_count: 0,
proxy_enable: false,
allow_direct: false,
proxy_pool_count: 0,
available_proxy_count: 0,
last_worker_log_time: "",
recent_event: "",
recent_warning: "",
proxy_runtime_state: "",
proxy_runtime_label: "",
proxy_runtime_detail: "",
proxy_runtime_reason: "",
proxy_supplier_empty: false,
proxy_direct_fallback_active: false,
proxy_last_refresh_status: "",
proxy_last_refresh_time: "",
dependency_alerts: [] as any[],
aggregate_detect_view: false,
log_lines: [] as string[],
remote_log_lines: [] as string[],
remote_log_line_count: 0,
remote_log_last_at: "",
remote_log_last_line: "",
remote_log_nodes: [] as string[],
remote_log_node_count: 0,
runs: [] as any[],
worker_log_sync_enabled: false,
worker_log_sync_mode: "key",
progress_percent: 0,
active_job: null as any,
progress: {
pending: 0,
running: 0,
completed: 0,
failed: 0,
blacklisted: 0
}
});
const threadCountSummary = computed(() => {
const effective = Number(status.value.thread_count || 0);
const defaultValue = Number(status.value.thread_count_default || 0);
const nodeCode = String(status.value.thread_count_node_code || "").trim();
if (status.value.thread_count_source === "node_override" && status.value.thread_count_override) {
return `${effective}(节点 ${nodeCode || "-"} 单独覆盖,默认 ${defaultValue}`;
}
return `${effective}(默认)`;
});
const queueSummary = ref({
window_minutes: 15,
has_active_job: false,
queue: {
items_total: 0,
terminal: 0,
terminal_percent: 0,
overdue_leases: 0,
expiring_soon_leases: 0,
oldest_pending_at: "",
oldest_pending_age_minutes: 0
},
throughput: {
processed_recent: 0,
processed_per_minute: 0
},
nodes: [] as any[]
});
const autoRefresh = ref(true);
const logAutoFollow = ref(true);
const loading = ref(false);
const actionLoading = ref<"" | "start" | "stop">("");
const lastUpdatedAt = ref("");
const selectedRunId = ref("");
const lastAction = ref({
label: "",
message: "",
at: "",
type: "success" as "success" | "warning" | "info" | "error"
});
let timer: number | null = null;
const logConsoleRef = ref<HTMLElement | null>(null);
const persistLastAction = () => {
if (!lastAction.value.message) {
window.localStorage.removeItem(DETECT_LAST_ACTION_KEY);
return;
}
window.localStorage.setItem(DETECT_LAST_ACTION_KEY, JSON.stringify(lastAction.value));
};
const restoreLastAction = () => {
try {
const raw = window.localStorage.getItem(DETECT_LAST_ACTION_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
lastAction.value = {
label: parsed.label || "",
message: parsed.message || "",
at: parsed.at || "",
type: parsed.type || "info"
};
} catch {
window.localStorage.removeItem(DETECT_LAST_ACTION_KEY);
}
};
const runs = computed(() => status.value.runs || []);
const activeJob = computed(() => status.value.active_job || null);
const isAggregateDetectView = computed(() => Boolean(status.value.aggregate_detect_view));
const activeJobDisplayClaimed = computed(() => Number(activeJob.value?.display_items_claimed ?? activeJob.value?.items_claimed ?? 0));
const activeJobDisplayRunning = computed(() => Math.max(
Number(activeJob.value?.items_running ?? 0),
Number(activeJob.value?.display_active_threads ?? 0),
));
const threadSnapshotActive = computed(() => {
const jobValue = Number(activeJob.value?.display_active_threads ?? 0);
if (jobValue > 0) {
return jobValue;
}
return Number(status.value.active_thread_count || 0);
});
const threadSnapshotMax = computed(() => {
const jobValue = Number(activeJob.value?.display_max_threads ?? 0);
if (jobValue > 0) {
return jobValue;
}
return Number(status.value.max_thread_count || status.value.thread_count || 0);
});
const displayRunningStat = computed(() => Math.max(Number(status.value.progress.running || 0), activeJobDisplayRunning.value));
const registerableStat = computed(() => Number(status.value.progress?.registerable || 0));
const activeJobDisplayNodeStats = computed(() => {
const distributed = activeJob.value?.distributed_node_stats;
if (Array.isArray(distributed) && distributed.length) {
return distributed;
}
return Array.isArray(activeJob.value?.node_stats) ? activeJob.value.node_stats : [];
});
const participatingNodeCount = computed(() => {
const rows = activeJobDisplayNodeStats.value.filter((item) => String(item?.node_code || "").trim() && String(item?.node_code || "").trim() !== "unassigned");
return rows.length || 0;
});
const queueProcessedPerMinute = computed(() => Number(queueSummary.value.throughput?.processed_per_minute || 0));
const queueProcessedPerHour = computed(() => Number((queueProcessedPerMinute.value * 60).toFixed(2)));
const remainingItemsEstimate = computed(() => {
const queue = queueSummary.value.queue || {};
return Number(queue.pending || 0) + Number(queue.claimed || 0) + Number(queue.running || 0);
});
const estimatedRemainingMinutes = computed(() => {
const perMinute = queueProcessedPerMinute.value;
if (perMinute <= 0) {
return 0;
}
return Number((remainingItemsEstimate.value / perMinute).toFixed(1));
});
const estimatedRemainingText = computed(() => {
if (!queueSummary.value.has_active_job) {
return "-";
}
if (estimatedRemainingMinutes.value <= 0) {
return "即将完成";
}
if (estimatedRemainingMinutes.value >= 60) {
return `${(estimatedRemainingMinutes.value / 60).toFixed(2)}h`;
}
return `${estimatedRemainingMinutes.value}m`;
});
const runtimeThreadSummary = computed(() => {
if (isAggregateDetectView.value) {
return `${threadSnapshotActive.value} / ${threadSnapshotMax.value}(参与节点汇总)`;
}
return threadCountSummary.value;
});
const activeJobSummaryText = computed(() => {
if (!activeJob.value) {
return "当前暂无活跃检测任务";
}
const job = activeJob.value;
const displayClaimed = Number(job.display_items_claimed ?? job.items_claimed ?? 0);
const displayRunning = activeJobDisplayRunning.value;
const displayActiveThreads = Number(job.display_active_threads ?? 0);
const displayMaxThreads = Number(job.display_max_threads ?? 0);
return `状态:${jobStatusText(job.status)} / 总数 ${job.items_total || 0} / 待领 ${job.items_pending || 0} / 已领 ${displayClaimed} / 实时执行 ${displayRunning} / 活跃线程 ${displayActiveThreads}/${displayMaxThreads} / 速度 ${queueProcessedPerMinute.value} 项/分钟 / 预计剩余 ${estimatedRemainingText.value}`;
});
const selectedRun = computed(() => runs.value.find((item) => item.run_id === selectedRunId.value) || runs.value[0] || null);
const currentPhaseLabel = computed(() => selectedRun.value?.phase_label || (status.value.worker_online ? "运行中" : "未启动"));
const currentPhaseDetail = computed(() => {
if (selectedRun.value?.phase_detail) {
return selectedRun.value.phase_detail;
}
if (status.value.recent_event) {
return status.value.recent_event;
}
return status.value.worker_runtime_message || "当前暂无阶段明细";
});
const phaseHistory = computed(() => {
const items = selectedRun.value?.phase_history;
if (!Array.isArray(items)) return [];
return [...items].reverse();
});
const selectedRunIsLatest = computed(() => {
if (!selectedRun.value || !runs.value.length) {
return false;
}
return selectedRun.value.run_id === runs.value[0]?.run_id;
});
const dedupeConsecutiveLines = (lines: string[]) => {
const result: string[] = [];
for (const rawLine of lines) {
const line = String(rawLine || "");
if (!line) {
continue;
}
if (result[result.length - 1] === line) {
continue;
}
result.push(line);
}
return result;
};
const selectedLogText = computed(() => {
const runLogs = selectedRun.value?.logs;
const mergedLines: string[] = [];
if (Array.isArray(runLogs) && runLogs.length) {
mergedLines.push(...runLogs);
}
const remoteLogLines = status.value.remote_log_lines || [];
const canAppendRemoteLogs = !selectedRun.value || selectedRunIsLatest.value;
if (canAppendRemoteLogs && Array.isArray(remoteLogLines) && remoteLogLines.length) {
mergedLines.push(...remoteLogLines);
}
if (mergedLines.length) {
return dedupeConsecutiveLines(mergedLines).join("\n");
}
const liveLogs = status.value.log_lines || [];
return liveLogs.length ? dedupeConsecutiveLines(liveLogs).join("\n") : "暂无检测日志";
});
const proxyAlertType = computed<"success" | "warning" | "info" | "error">(() => {
if (status.value.proxy_runtime_state === "healthy") return "success";
if (status.value.proxy_runtime_state === "degraded_direct") return "warning";
if (status.value.proxy_runtime_state === "blocked_no_proxy") return "error";
return "info";
});
const proxyDiagnosisText = computed(() => {
if (status.value.proxy_supplier_empty) {
return "供应池为空,系统已按配置继续运行";
}
return status.value.proxy_runtime_label || "当前可用代理 / 配置代理池链接数";
});
const remoteLogMirrorText = computed(() => {
if (!status.value.worker_log_sync_enabled) {
return "当前已关闭远端日志回传;日志控制台只展示本机 Worker 输出,不会拼接其他节点的执行过程。";
}
const modeText = status.value.worker_log_sync_mode === "full" ? "全量" : "关键";
const nodeCount = Number(status.value.remote_log_node_count || 0);
const lineCount = Number(status.value.remote_log_line_count || 0);
const nodes = Array.isArray(status.value.remote_log_nodes) ? status.value.remote_log_nodes : [];
const lastAt = String(status.value.remote_log_last_at || "").trim();
const parts = [
`当前已开启${modeText}回传,日志控制台会自动拼接远端节点回传日志。`,
`来源节点 ${nodeCount}`,
`样本 ${lineCount}`,
];
if (nodes.length) {
parts.push(`节点:${nodes.join("、")}`);
}
if (lastAt) {
parts.push(`最近回传:${lastAt}`);
}
return parts.join("");
});
const detectScopeSummaryText = computed(() => {
if (isAggregateDetectView.value) {
return `当前检测状态:${currentPhaseLabel};当前页展示的是多节点汇总运行态,参与节点 ${participatingNodeCount.value} 台,实时活跃线程 ${threadSnapshotActive.value}/${threadSnapshotMax.value}`;
}
return `当前检测状态:${currentPhaseLabel};本机 Worker ${status.value.worker_online ? "在线" : "离线"}`;
});
const queueAlertType = computed<"success" | "warning" | "info" | "error">(() => {
if (!queueSummary.value.has_active_job) return "info";
if ((queueSummary.value.queue?.overdue_leases || 0) > 0) return "error";
if ((queueSummary.value.queue?.expiring_soon_leases || 0) > 0 || (queueSummary.value.queue?.oldest_pending_age_minutes || 0) >= 30) return "warning";
return "success";
});
const queueAlertText = computed(() => {
if (!queueSummary.value.has_active_job) {
return "当前没有活跃检测任务,队列健康度会在下一轮任务创建后开始统计。";
}
const queue = queueSummary.value.queue || {};
const throughput = queueSummary.value.throughput || {};
const displayClaimed = activeJobDisplayClaimed.value || Number(queue.display_claimed ?? queue.claimed ?? 0);
const displayRunning = displayRunningStat.value || Number(queue.display_running ?? queue.running ?? 0);
const parts = [
`当前队列待领 ${queue.pending || 0} / 已领 ${displayClaimed} / 任务执行中 ${displayRunning}`,
`${queueSummary.value.window_minutes || 15} 分钟处理 ${throughput.processed_recent || 0} 项,约 ${throughput.processed_per_minute || 0} 项/分钟`
];
if ((queue.overdue_leases || 0) > 0) {
parts.push(`存在 ${queue.overdue_leases} 个过期租约,建议立即检查 Worker 心跳和数据库续租`);
} else if ((queue.expiring_soon_leases || 0) > 0) {
parts.push(`${queue.expiring_soon_leases} 个任务租约将在 5 分钟内到期`);
}
if ((queue.oldest_pending_age_minutes || 0) > 0) {
parts.push(`最老待领任务已等待 ${queue.oldest_pending_age_minutes} 分钟`);
}
return parts.join("");
});
const scrollLogToBottom = async (force = false) => {
if (!force && !logAutoFollow.value) {
return;
}
await nextTick();
const element = logConsoleRef.value;
if (!element) {
return;
}
element.scrollTop = element.scrollHeight;
if (force) {
logAutoFollow.value = true;
}
};
const handleLogScroll = () => {
const element = logConsoleRef.value;
if (!element) {
return;
}
const distanceToBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
logAutoFollow.value = distanceToBottom < 24;
};
const statusText = (value: string) => {
if (value === "starting") return "启动中";
if (value === "running") return "运行中";
if (value === "stopping") return "停止中";
if (value === "completed") return "已完成";
if (value === "stopped") return "已停止";
if (value === "failed") return "失败";
if (value === "partial_failed") return "部分失败";
return value || "-";
};
const jobStatusText = (value: string) => {
if (value === "pending") return "待执行";
if (value === "running") return "执行中";
if (value === "completed") return "已完成";
if (value === "partial_failed") return "部分失败";
if (value === "failed") return "失败";
return value || "-";
};
const statusTagType = (value: string) => {
if (value === "running") return "success";
if (value === "starting" || value === "stopping") return "warning";
if (value === "completed") return "success";
if (value === "partial_failed") return "warning";
if (value === "failed") return "danger";
return "info";
};
const phaseTagType = (phaseLabel: string, statusValue: string) => {
if (statusValue === "failed") return "danger";
if (statusValue === "partial_failed") return "warning";
if (statusValue === "starting" || statusValue === "stopping") return "warning";
if (["检测中", "完成归档", "批次完成", "已完成"].includes(phaseLabel)) return "success";
if (["刷新代理池", "取任务中", "建线程中", "准备检测", "外部站点异常", "网络波动", "部分失败"].includes(phaseLabel)) return "warning";
return "info";
};
const clearRefreshTimer = () => {
if (timer) {
window.clearInterval(timer);
timer = null;
}
};
const ensureRefreshTimer = () => {
clearRefreshTimer();
if (autoRefresh.value) {
timer = window.setInterval(() => {
loadStatus(false);
}, 5000);
}
};
const loadStatus = async (showError = true) => {
loading.value = true;
try {
const [statusResponse, queueResponse] = await Promise.all([
detectApi.status(),
detectApi.queueSummary({ window_minutes: 15 })
]);
status.value = statusResponse.data;
queueSummary.value = queueResponse.data;
if (!selectedRunId.value && runs.value.length) {
selectedRunId.value = runs.value[0].run_id;
} else if (selectedRunId.value && !runs.value.some((item) => item.run_id === selectedRunId.value) && runs.value.length) {
selectedRunId.value = runs.value[0].run_id;
}
lastUpdatedAt.value = new Date().toLocaleString("zh-CN", { hour12: false });
} catch {
if (showError) {
ElMessage.error("读取检测状态失败");
}
} finally {
loading.value = false;
}
};
const invoke = async (action: "start" | "stop") => {
actionLoading.value = action;
try {
const response = action === "start" ? await detectApi.start() : await detectApi.stop();
const responseMessage = String(response.message || "");
const uiLevel = String(response.data?.ui_level || "").trim().toLowerCase();
const actionType: "success" | "warning" | "error" =
uiLevel === "error" ? "error" : uiLevel === "warning" ? "warning" : "success";
if (actionType === "error") {
ElMessage.error(responseMessage);
} else if (actionType === "warning") {
ElMessage.warning(responseMessage);
} else {
ElMessage.success(responseMessage);
}
lastAction.value = {
label: action === "start" ? "检测启动" : "检测停止",
message: responseMessage,
at: new Date().toLocaleString("zh-CN", { hour12: false }),
type: actionType
};
persistLastAction();
await loadStatus(false);
const pollSchedule = Array.isArray(response.data?.poll_schedule_seconds)
? response.data.poll_schedule_seconds
: [Number(response.data?.poll_after_seconds || 2)];
[...new Set(pollSchedule.map((item: unknown) => Number(item || 0)).filter((item: number) => item > 0))]
.sort((a, b) => a - b)
.forEach((seconds) => {
window.setTimeout(() => {
loadStatus(false);
}, seconds * 1000);
});
} catch (error: any) {
const message = error?.message || `${action === "start" ? "启动" : "停止"}操作失败`;
lastAction.value = {
label: action === "start" ? "检测启动" : "检测停止",
message,
at: new Date().toLocaleString("zh-CN", { hour12: false }),
type: "error"
};
persistLastAction();
ElMessage.error(message);
} finally {
actionLoading.value = "";
}
};
const handleCurrentRunChange = (row: any | null) => {
selectedRunId.value = row?.run_id || "";
};
const goRuntime = () => {
router.push("/runtime");
};
const updateWorkerLogSync = async (enabled: boolean, mode: "key" | "full") => {
try {
const current = await settingsApi.getSettings();
const payload = {
...current.data,
runtime_settings: {
...(current.data?.runtime_settings || {}),
worker_log_sync_enabled: enabled,
worker_log_sync_mode: mode
}
};
await settingsApi.updateSettings(payload);
ElMessage.success(`远端日志回传已${enabled ? `切到${mode === "full" ? "全量" : "关键"}模式` : "关闭"}`);
await loadStatus();
} catch {
ElMessage.error("更新远端日志回传开关失败");
}
};
watch(autoRefresh, ensureRefreshTimer);
watch(selectedLogText, async (current, previous) => {
if (current === previous) {
return;
}
await scrollLogToBottom();
});
watch(selectedRunId, async () => {
await scrollLogToBottom(true);
});
watch(logAutoFollow, async (enabled) => {
if (enabled) {
await scrollLogToBottom(true);
}
});
onMounted(async () => {
restoreLastAction();
await loadStatus();
ensureRefreshTimer();
await scrollLogToBottom(true);
});
onBeforeUnmount(clearRefreshTimer);
</script>
<style scoped>
.actions {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.action-alert {
margin-bottom: 16px;
}
.updated-at {
color: #64748b;
font-size: 13px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
border: 1px solid #dbe4ee;
border-radius: 14px;
padding: 14px 16px;
background: #f8fbff;
}
.summary-title {
color: #64748b;
font-size: 13px;
}
.summary-value {
margin: 8px 0 10px;
color: #0f172a;
font-size: 24px;
font-weight: 700;
}
.phase-value {
font-size: 20px;
}
.summary-note {
margin-top: 8px;
color: #64748b;
font-size: 12px;
}
.summary-grid-tight {
margin-top: -4px;
}
.history-panel {
margin: 16px 0;
}
.history-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.history-header h3 {
margin: 0;
}
.history-note {
color: #64748b;
font-size: 12px;
}
.task-code,
.event-value {
font-size: 18px;
word-break: break-all;
}
.task-detail {
margin: 16px 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid #1f2937;
background: #020617;
}
.task-detail-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: #111827;
color: #e5e7eb;
}
.task-detail-title {
font-size: 15px;
font-weight: 600;
}
.task-detail-subtitle,
.task-detail-meta {
color: #94a3b8;
font-size: 12px;
}
.task-detail-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.log-console {
margin: 0;
min-height: 160px;
max-height: 260px;
overflow: auto;
padding: 16px;
color: #e0e7ff;
background: #020617;
font-size: 12px;
line-height: 1.7;
font-family: Consolas, Monaco, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.phase-detail {
padding: 10px 16px 0;
color: #93c5fd;
font-size: 12px;
background: #020617;
}
.event-stream-panel {
margin: 16px 0;
}
.event-stream-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.event-stream-title {
color: #0f172a;
font-size: 15px;
font-weight: 600;
}
.event-stream-subtitle {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.event-stream-count {
color: #64748b;
font-size: 12px;
white-space: nowrap;
}
</style>