This commit is contained in:
Your Name
2026-04-16 21:35:47 +08:00
parent ff32aa50bf
commit ebf632e651
86 changed files with 14097 additions and 585 deletions

View File

@@ -10,18 +10,24 @@ export const dashboardApi = {
export const runtimeApi = {
status: () => http.get("/runtime/status"),
readiness: () => http.get("/runtime/readiness"),
preflight: () => http.get("/runtime/preflight"),
cluster: () => http.get("/runtime/cluster"),
action: (action: string) => http.post(`/runtime/actions/${action}`)
};
export const settingsApi = {
getSettings: () => http.get("/settings"),
updateSettings: (payload: Record<string, unknown>) => http.put("/settings", payload),
getCredentials: () => http.get("/settings/credentials"),
updateCredentials: (payload: Record<string, unknown>) => http.put("/settings/credentials", payload),
exportSettings: () => http.get("/settings/export"),
importSettings: (payload: Record<string, unknown>) => http.post("/settings/import", payload),
validateImportSettings: (payload: Record<string, unknown>) => http.post("/settings/validate-import", payload),
backupSettings: () => http.post("/settings/backup"),
getSettingsBackups: () => http.get("/settings/backups"),
getJuziseoStatus: () => http.get("/settings/juziseo/status"),
loginJuziseo: (payload: { email: string; password: string }) => http.post("/settings/juziseo/login", payload),
backupDownloadUrl: (filename: string) =>
`${(import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8100/api/v1").replace(/\/api\/v1\/?$/, "")}/api/v1/settings/backups/download/${encodeURIComponent(filename)}`
};
@@ -30,6 +36,7 @@ export const importsApi = {
summary: () => http.get("/imports/summary"),
tasks: () => http.get("/imports/tasks"),
retry: (taskId: string) => http.post(`/imports/tasks/${taskId}/retry`),
createTextTask: (payload: { text: string; filename?: string }) => http.post("/imports/text", payload),
upload: (formData: FormData) =>
http.post("/imports/upload", formData, {
headers: {
@@ -40,6 +47,10 @@ export const importsApi = {
export const detectApi = {
status: () => http.get("/detect/status"),
activeJob: () => http.get("/detect/job/active"),
queueSummary: (params?: Record<string, unknown>) => http.get("/detect/queue-summary", { params }),
jobs: (params?: Record<string, unknown>) => http.get("/detect/jobs", { params }),
jobDetail: (jobId: number) => http.get(`/detect/jobs/${jobId}`),
start: () => http.post("/detect/start"),
stop: () => http.post("/detect/stop")
};
@@ -47,6 +58,7 @@ export const detectApi = {
export const domainsApi = {
filters: () => http.get("/domains/filters"),
list: (params?: Record<string, unknown>) => http.get("/domains", { params }),
detail: (domainId: number) => http.get(`/domains/${domainId}`),
batchUpdate: (payload: Record<string, unknown>) => http.post("/domains/batch-update", payload)
};
@@ -59,3 +71,26 @@ export const logsApi = {
latest: () => http.get("/logs/latest"),
bundleUrl: () => `${(import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8100/api/v1").replace(/\/api\/v1\/?$/, "")}/api/v1/logs/bundle`
};
export const sensitiveWordsApi = {
get: () => http.get("/sensitive-words"),
save: (payload: { text: string }) => http.put("/sensitive-words", payload)
};
export const jumingApi = {
status: () => http.get("/juming/status"),
preferences: () => http.get("/juming/preferences"),
updatePreferences: (payload: Record<string, unknown>) => http.put("/juming/preferences", payload),
login: (payload: { email: string; password: string }) => http.post("/juming/login", payload),
loginJucha: () => http.post("/juming/jucha-login"),
crawl: (payload: Record<string, unknown>) => http.post("/juming/crawl", payload),
tasks: () => http.get("/juming/tasks"),
retryTask: (taskId: string) => http.post(`/juming/tasks/${taskId}/retry`),
stopTask: (taskId: string) => http.post(`/juming/tasks/${taskId}/stop`),
uploadCookie: (formData: FormData) =>
http.post("/juming/cookie/upload", formData, {
headers: {
"Content-Type": "multipart/form-data"
}
})
};

View File

@@ -27,6 +27,11 @@ defineProps<{
border-radius: 20px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
padding: 22px;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.page-card__header {
@@ -35,6 +40,7 @@ defineProps<{
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
flex-shrink: 0;
}
.page-card__header h3 {
@@ -46,4 +52,10 @@ defineProps<{
margin: 8px 0 0;
color: #64748b;
}
.page-card__content {
flex: 1;
min-height: 0;
overflow: auto;
}
</style>

View File

@@ -63,6 +63,8 @@ const menuItems = [
{ path: "/runtime", label: "运行中心" },
{ path: "/settings", label: "系统设置" },
{ path: "/imports", label: "域名导入" },
{ path: "/sensitive-words", label: "敏感词配置" },
{ path: "/juming", label: "聚名采集" },
{ path: "/detect", label: "检测控制" },
{ path: "/domains", label: "域名筛选" },
{ path: "/exports", label: "导出中心" },
@@ -119,7 +121,9 @@ onBeforeUnmount(() => {
.layout-shell {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.sidebar {
@@ -129,6 +133,8 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
gap: 28px;
min-height: 0;
overflow: auto;
}
.brand {
@@ -169,6 +175,8 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.topbar {
@@ -235,12 +243,16 @@ onBeforeUnmount(() => {
}
.content {
flex: 1;
min-height: 0;
padding: 0 28px 28px;
overflow: hidden;
}
@media (max-width: 960px) {
.layout-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {

View File

@@ -17,6 +17,8 @@ const routes: RouteRecordRaw[] = [
{ path: "runtime", name: "runtime", component: () => import("@/views/runtime/RuntimeView.vue"), meta: { title: "运行中心" } },
{ path: "settings", name: "settings", component: () => import("@/views/settings/SettingsView.vue"), meta: { title: "系统设置" } },
{ path: "imports", name: "imports", component: () => import("@/views/imports/ImportsView.vue"), meta: { title: "域名导入" } },
{ path: "sensitive-words", name: "sensitive-words", component: () => import("@/views/sensitive-words/SensitiveWordsView.vue"), meta: { title: "敏感词配置" } },
{ path: "juming", name: "juming", component: () => import("@/views/juming/JumingView.vue"), meta: { title: "聚名采集" } },
{ path: "detect", name: "detect", component: () => import("@/views/detect/DetectView.vue"), meta: { title: "检测控制" } },
{ path: "domains", name: "domains", component: () => import("@/views/domains/DomainsView.vue"), meta: { title: "域名筛选" } },
{ path: "exports", name: "exports", component: () => import("@/views/exports/ExportsView.vue"), meta: { title: "导出中心" } },

View File

@@ -13,6 +13,7 @@ html,
body,
#app {
margin: 0;
height: 100%;
min-height: 100vh;
}
@@ -21,6 +22,7 @@ body {
radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 28%),
radial-gradient(circle at top right, rgba(14, 165, 233, 0.08), transparent 22%),
#f4f6fb;
overflow: hidden;
}
a {

View File

@@ -1,15 +1,17 @@
<template>
<PageCard title="检测控制" description="当前已接入真实状态读取和本地 Worker 启停,后续 Linux 版会沿用同一页接 systemd。">
<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-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}`"
:title="`${lastAction.label}${lastAction.message}${lastAction.at ? `${lastAction.at}` : ''}`"
:type="lastAction.type"
:closable="false"
show-icon
@@ -23,14 +25,34 @@
<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 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>
<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"
@@ -39,6 +61,15 @@
: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"
@@ -47,23 +78,216 @@
: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="`当前检测状态:${currentPhaseLabel}Worker ${status.worker_online ? '在线' : '离线'}。`"
style="margin-top: 12px"
/>
<el-row :gutter="12" style="margin-top: 16px">
<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="status.progress.running || 0" /></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.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="runs.length" /></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">{{ status.active_thread_count || 0 }} / {{ status.max_thread_count || status.thread_count || 0 }}</div>
<div class="summary-note">当前活跃线程 / 配置线程上限</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="activeJob.items_claimed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务执行中" :value="activeJob.items_running || 0" /></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="90" />
<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="activeJob?.node_stats?.length" :data="activeJob.node_stats" 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="90" />
<el-table-column prop="items_completed" label="完成" min-width="90" />
<el-table-column prop="items_failed" label="失败" min-width="90" />
</el-table>
<el-table v-if="(activeJob?.current_cycle_events || activeJob?.recent_events)?.length" :data="activeJob.current_cycle_events || activeJob.recent_events" border style="margin-bottom: 16px">
<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>
<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>Worker{{ status.worker_online ? "在线" : "离线" }}</span>
<span v-if="selectedRun?.phase_label">阶段{{ selectedRun.phase_label }}</span>
<span>运行中{{ status.progress.running || 0 }}</span>
<span>线程{{ threadCountSummary }}</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>
<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 { onBeforeUnmount, onMounted, ref, watch } from "vue";
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 } 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",
@@ -73,12 +297,32 @@ const status = ref({
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[],
log_lines: [] as string[],
runs: [] as any[],
progress_percent: 0,
active_job: null as any,
progress: {
pending: 0,
running: 0,
@@ -88,15 +332,209 @@ const status = ref({
}
});
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 activeJobSummaryText = computed(() => {
if (!activeJob.value) {
return "当前暂无活跃检测任务";
}
const job = activeJob.value;
return `状态:${jobStatusText(job.status)} / 总数 ${job.items_total || 0} / 待领 ${job.items_pending || 0} / 执行中 ${job.items_running || 0}`;
});
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 selectedLogText = computed(() => {
const runLogs = selectedRun.value?.logs;
if (Array.isArray(runLogs) && runLogs.length) {
return runLogs.join("\n");
}
const liveLogs = status.value.log_lines || [];
return liveLogs.length ? 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 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 parts = [
`当前队列待领 ${queue.pending || 0} / 已领 ${queue.claimed || 0} / 执行中 ${queue.running || 0}`,
`${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) {
@@ -110,15 +548,25 @@ const ensureRefreshTimer = () => {
if (autoRefresh.value) {
timer = window.setInterval(() => {
loadStatus(false);
}, 10000);
}, 5000);
}
};
const loadStatus = async (showError = true) => {
loading.value = true;
try {
const response = await detectApi.status();
status.value = response.data;
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("读取检测状态失败");
@@ -136,34 +584,58 @@ const invoke = async (action: "start" | "stop") => {
lastAction.value = {
label: action === "start" ? "检测启动" : "检测停止",
message: response.message,
at: new Date().toLocaleString("zh-CN", { hour12: false }),
type: "success"
};
persistLastAction();
const pollDelaySeconds = Number(response.data?.poll_after_seconds || 2);
if (response.data?.refresh_status) {
window.setTimeout(() => {
loadStatus(false);
}, pollDelaySeconds * 1000);
return;
}
await loadStatus(false);
window.setTimeout(() => {
loadStatus(false);
}, pollDelaySeconds * 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");
};
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);
@@ -175,9 +647,140 @@ onBeforeUnmount(clearRefreshTimer);
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: 320px;
max-height: 520px;
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;
}
</style>

View File

@@ -1,86 +1,214 @@
<template>
<PageCard title="域名筛选" description="已接入真实分页、联表筛选、检测时间展示和批量更新。">
<el-form label-width="88px" class="toolbar-grid">
<el-form-item label="域名关键字">
<el-input v-model="filters.domain_keyword" placeholder="支持模糊查询" clearable />
</el-form-item>
<el-form-item label="注册状态">
<el-select v-model="filters.register_status" clearable>
<el-option v-for="item in registerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="检测状态">
<el-select v-model="filters.detect_status" clearable>
<el-option v-for="item in detectOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="使用状态">
<el-select v-model="filters.use_status" clearable>
<el-option v-for="item in useOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="复核状态">
<el-select v-model="filters.review_status" clearable>
<el-option v-for="item in reviewOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="备案状态">
<el-select v-model="filters.has_beian" clearable>
<el-option label="有备案" :value="2" />
<el-option label="无备案" :value="3" />
<el-option label="未检测" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="备案年份">
<el-input v-model="filters.beian_year" placeholder="如 2023" clearable />
</el-form-item>
<el-form-item label="快照年份">
<el-input v-model="filters.snapshot_year" placeholder="如 2024" clearable />
</el-form-item>
<el-form-item label="首页网址">
<el-input v-model="filters.website_url" placeholder="模糊匹配网址" clearable />
</el-form-item>
<el-form-item label="友链 > 10">
<el-switch v-model="filters.backlink_gt_10" />
</el-form-item>
<el-form-item class="actions">
<template #header-extra>
<div class="header-actions">
<el-button text @click="showAdvancedFilters = !showAdvancedFilters">
{{ showAdvancedFilters ? "收起筛选" : "展开筛选" }}
</el-button>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button plain @click="exportDialogVisible = true">导出</el-button>
<el-button type="warning" :disabled="selectedIds.length === 0" @click="batchDialogVisible = true">
批量更新选中域名
批量更新
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<el-table :data="rows" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" fixed="left" />
<el-table-column prop="domain" label="域名" min-width="180" fixed="left" />
<el-table-column prop="register_status" label="注册状态" min-width="100" />
<el-table-column prop="use_status" label="使用状态" min-width="100" />
<el-table-column prop="detect_status" label="检测状态" min-width="100" />
<el-table-column prop="review_status" label="复核状态" min-width="100" />
<el-table-column prop="has_beian" label="备案" min-width="80" />
<el-table-column prop="website_url" label="首页网址" min-width="200" show-overflow-tooltip />
<el-table-column prop="beian_year" label="备案年份" min-width="100" />
<el-table-column prop="snapshot_years" label="快照年份" min-width="120" />
<el-table-column prop="backlink_count" label="友链数" min-width="90" />
<el-table-column prop="backlink_gt_10" label="友链>10" min-width="90">
<template #default="{ row }">{{ row.backlink_gt_10 ? "是" : "否" }}</template>
</el-table-column>
<el-table-column prop="detect_time" label="检测时间" min-width="180" />
</el-table>
<div class="domains-layout">
<el-form label-width="80px" size="small" class="toolbar-grid">
<el-form-item label="域名关键字">
<el-input v-model="filters.domain_keyword" placeholder="支持模糊查询" clearable />
</el-form-item>
<el-form-item label="注册状态">
<el-select v-model="filters.register_status">
<el-option v-for="item in registerOptions" :key="String(item.value)" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="检测状态">
<el-select v-model="filters.detect_status">
<el-option v-for="item in detectOptions" :key="String(item.value)" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="使用状态">
<el-select v-model="filters.use_status">
<el-option v-for="item in useOptions" :key="String(item.value)" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-show="showAdvancedFilters" label="复核状态">
<el-select v-model="filters.review_status">
<el-option v-for="item in reviewOptions" :key="String(item.value)" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-show="showAdvancedFilters" label="备案状态">
<el-select v-model="filters.has_beian">
<el-option label="全部" :value="null" />
<el-option label="有备案" :value="2" />
<el-option label="无备案" :value="3" />
<el-option label="未检测" :value="1" />
</el-select>
</el-form-item>
<el-form-item v-show="showAdvancedFilters" label="备案年份">
<el-input v-model="filters.beian_year" placeholder="如 2023" clearable />
</el-form-item>
<el-form-item v-show="showAdvancedFilters" label="快照年份">
<el-input v-model="filters.snapshot_year" placeholder="如 2024" clearable />
</el-form-item>
<el-form-item v-show="showAdvancedFilters" label="首页网址">
<el-input v-model="filters.website_url" placeholder="模糊匹配网址" clearable />
</el-form-item>
<el-form-item v-show="showAdvancedFilters" label="单位性质">
<el-select v-model="filters.company_type">
<el-option label="全部" value="" />
<el-option label="企业" value="企业" />
<el-option label="个人" value="个人" />
</el-select>
</el-form-item>
<el-form-item v-show="showAdvancedFilters" label="来源类型">
<el-select v-model="filters.source_type">
<el-option v-for="item in sourceOptions" :key="String(item.value)" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item v-show="showAdvancedFilters" label="友链 > 10">
<el-switch v-model="filters.backlink_gt_10" />
</el-form-item>
</el-form>
<div class="pager">
<el-pagination
background
layout="total, sizes, prev, pager, next"
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100, 200]"
:total="total"
@current-change="changePage"
@size-change="changePageSize"
/>
<div class="active-filters">
<span class="active-filters__label">当前筛选</span>
<template v-if="activeFilterChips.length">
<span v-for="chip in activeFilterChips" :key="chip.label" class="active-filter-chip">
{{ chip.label }}
</span>
</template>
<span v-else class="active-filters__empty">未设置额外筛选当前展示全量结果</span>
</div>
<div class="table-region">
<el-table :data="rows" border size="small" height="100%" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" fixed="left" />
<el-table-column label="域名" min-width="200" fixed="left" show-overflow-tooltip>
<template #default="{ row }">
<el-button text class="domain-link" @click="openDomainDetail(row)">
{{ row.domain }}
</el-button>
</template>
</el-table-column>
<el-table-column prop="register_status" min-width="100" show-overflow-tooltip>
<template #header>
<el-tooltip content="表示域名当前是否仍处于可注册/已注册等注册态。" placement="top">
<span class="th-label">注册状态</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="use_status" min-width="100" show-overflow-tooltip>
<template #header>
<el-tooltip content="表示该域名当前是否已被业务侧占用、可用或待处理。" placement="top">
<span class="th-label">使用状态</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="detect_status" min-width="100" show-overflow-tooltip>
<template #header>
<el-tooltip content="检测通过表示本轮已跑完启用流程,且未进黑名单、未落失败。" placement="top">
<span class="th-label">检测状态</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="review_status" label="复核状态" min-width="100" show-overflow-tooltip />
<el-table-column prop="expire_date" label="过期时间" min-width="170" show-overflow-tooltip />
<el-table-column prop="company_type" label="单位性质" min-width="100" show-overflow-tooltip />
<el-table-column prop="source_label" label="来源类型" min-width="110" show-overflow-tooltip />
<el-table-column prop="has_beian" label="备案" min-width="80" show-overflow-tooltip />
<el-table-column prop="website_url" label="首页网址" min-width="200" show-overflow-tooltip />
<el-table-column prop="beian_year" label="备案年份" min-width="100" show-overflow-tooltip />
<el-table-column prop="snapshot_years" label="快照年份" min-width="120" show-overflow-tooltip />
<el-table-column prop="baidu_history" min-width="110" show-overflow-tooltip>
<template #header>
<el-tooltip content="是:历史快照或历史收录结果中命中过;否:未命中或未发现。" placement="top">
<span class="th-label">百度历史收录</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="baidu_site" min-width="110" show-overflow-tooltip>
<template #header>
<el-tooltip content="是:当前百度 site 查询有收录;否:无收录或未命中。" placement="top">
<span class="th-label">百度Site收录</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="is_chinese_title" min-width="100" show-overflow-tooltip>
<template #header>
<el-tooltip content="是:首页标题含中文;否:标题不是中文或未取到标题。" placement="top">
<span class="th-label">标题为中文</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="qihu360_site" min-width="110" show-overflow-tooltip>
<template #header>
<el-tooltip content="是360 搜索 site 查询有收录;否:无收录或未命中。" placement="top">
<span class="th-label">360 Site收录</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="google_site" min-width="120" show-overflow-tooltip>
<template #header>
<el-tooltip content="是Google site 查询有收录;否:无收录或未命中。" placement="top">
<span class="th-label">Google Site收录</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="步骤诊断" min-width="150">
<template #default="{ row }">
<el-tag v-if="row.step_summary?.has_degraded" type="warning" effect="light">
{{ row.step_summary.summary_text }}
</el-tag>
<el-tag v-else-if="row.step_summary?.has_failed" type="danger" effect="light">
{{ row.step_summary.summary_text }}
</el-tag>
<span v-else class="step-summary-ok">{{ row.step_summary?.summary_text || "步骤正常" }}</span>
</template>
</el-table-column>
<el-table-column prop="backlink_count" label="友链数" min-width="90" show-overflow-tooltip />
<el-table-column prop="backlink_gt_10" label="友链>10" min-width="90">
<template #default="{ row }">{{ row.backlink_gt_10 ? "是" : "否" }}</template>
</el-table-column>
<el-table-column prop="detect_time" label="检测时间" min-width="180" show-overflow-tooltip />
</el-table>
</div>
<div class="domains-footer">
<div class="pager-meta">
<span class="footer-summary"> {{ total }} {{ page }} / 每页 {{ pageSize }} / 已选 {{ selectedIds.length }} </span>
<el-tooltip
effect="dark"
placement="top"
content="当前筛选条件、页码和每页数量会自动记住;刷新页面后会恢复上次工作面。"
>
<span class="hint-chip">筛选状态已自动记忆</span>
</el-tooltip>
<el-tooltip
effect="dark"
placement="top"
content="检测通过=本轮已跑完当前启用的检测流程,且未进黑名单、未落检测失败;百度历史/百度Site/360 Site/Google Site 的“是”表示该搜索源有收录,“否”表示未收录或未命中;标题为中文 的“是”表示首页标题含中文,“否”表示标题不是中文。"
>
<span class="hint-chip">字段说明</span>
</el-tooltip>
</div>
<div class="pager">
<el-pagination
background
small
layout="total, sizes, prev, pager, next"
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100, 200]"
:total="total"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</div>
</div>
<el-dialog v-model="batchDialogVisible" title="批量更新选中域名" width="720px">
@@ -90,6 +218,9 @@
<el-option v-for="item in reviewOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="过期时间">
<el-input v-model="batchForm.expire_date" placeholder="如 2026-12-31 00:00:00" clearable />
</el-form-item>
<el-form-item label="备案状态">
<el-select v-model="batchForm.has_beian" clearable>
<el-option label="有备案" :value="2" />
@@ -118,22 +249,122 @@
<el-form-item label="友链数">
<el-input-number v-model="batchForm.backlink_count" :min="0" :max="1000000" />
</el-form-item>
<el-form-item label="百度历史收录">
<el-select v-model="batchForm.baidu_history" clearable>
<el-option label="是" value="是" />
<el-option label="否" value="否" />
</el-select>
</el-form-item>
<el-form-item label="百度Site收录">
<el-select v-model="batchForm.baidu_site" clearable>
<el-option label="是" value="是" />
<el-option label="否" value="否" />
</el-select>
</el-form-item>
<el-form-item label="标题为中文">
<el-select v-model="batchForm.is_chinese_title" clearable>
<el-option label="是" value="是" />
<el-option label="否" value="否" />
</el-select>
</el-form-item>
<el-form-item label="360 Site收录">
<el-select v-model="batchForm.qihu360_site" clearable>
<el-option label="是" value="是" />
<el-option label="否" value="否" />
</el-select>
</el-form-item>
<el-form-item label="Google Site收录">
<el-select v-model="batchForm.google_site" clearable>
<el-option label="是" value="是" />
<el-option label="否" value="否" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="closeBatchDialog">取消</el-button>
<el-button type="primary" @click="submitBatchUpdate">确认更新</el-button>
</template>
</el-dialog>
<el-dialog v-model="exportDialogVisible" title="导出筛选结果" width="520px">
<el-form label-width="100px">
<el-form-item label="导出格式">
<el-radio-group v-model="exportForm.type">
<el-radio-button label="txt">TXT</el-radio-button>
<el-radio-button label="csv">CSV</el-radio-button>
<el-radio-button label="xlsx">Excel</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="导出范围">
<el-radio-group v-model="exportForm.scope">
<el-radio-button label="page">当前页</el-radio-button>
<el-radio-button label="pages">导出几页</el-radio-button>
<el-radio-button label="all">全部</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="exportForm.scope === 'pages'" label="导出页数">
<el-input-number v-model="exportForm.page_count" :min="1" :max="1000" />
</el-form-item>
<el-alert
:closable="false"
show-icon
type="info"
:title="`将按当前筛选条件导出,当前页码 ${page},每页 ${pageSize} 条。`"
/>
</el-form>
<template #footer>
<el-button @click="exportDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="exporting" @click="submitExport">生成导出文件</el-button>
</template>
</el-dialog>
<el-drawer v-model="detailVisible" title="域名检测详情" size="720px">
<template v-if="detailLoading">
<div class="detail-loading">正在加载域名详情...</div>
</template>
<template v-else-if="detailRow">
<el-descriptions :column="2" border>
<el-descriptions-item label="域名">{{ detailRow.domain }}</el-descriptions-item>
<el-descriptions-item label="检测状态">{{ detailRow.detect_status }}</el-descriptions-item>
<el-descriptions-item label="复核状态">{{ detailRow.review_status }}</el-descriptions-item>
<el-descriptions-item label="来源类型">{{ detailRow.source_label }}</el-descriptions-item>
<el-descriptions-item label="步骤诊断">{{ detailRow.step_summary?.summary_text || "步骤正常" }}</el-descriptions-item>
<el-descriptions-item label="检测时间">{{ detailRow.detect_time || "-" }}</el-descriptions-item>
<el-descriptions-item label="首页网址">{{ detailRow.website_url || "-" }}</el-descriptions-item>
<el-descriptions-item label="快照年份">{{ detailRow.snapshot_years || "-" }}</el-descriptions-item>
</el-descriptions>
<el-table :data="detailRow.step_details || []" border size="small" style="margin-top: 16px">
<el-table-column prop="label" label="步骤" min-width="140" />
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<el-tag :type="stepStateTagType(row.state)" effect="light">
{{ stepStateText(row.state) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="320" show-overflow-tooltip />
<el-table-column prop="checked_at" label="检查时间" min-width="170" show-overflow-tooltip />
</el-table>
</template>
<template v-else>
<div class="detail-loading">当前没有可展示的详情</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { domainsApi } from "@/api/modules";
import { domainsApi, exportsApi } from "@/api/modules";
type Option = { label: string; value: number };
type Option = { label: string; value: number | null };
const DOMAINS_VIEW_STORAGE_KEY = "domaincheck:domains:view-state";
const route = useRoute();
const rows = ref<any[]>([]);
const page = ref(1);
@@ -141,52 +372,132 @@ const pageSize = ref(20);
const total = ref(0);
const selectedIds = ref<number[]>([]);
const batchDialogVisible = ref(false);
const exportDialogVisible = ref(false);
const exporting = ref(false);
const restoredState = ref(false);
const showAdvancedFilters = ref(false);
const detailVisible = ref(false);
const detailLoading = ref(false);
const detailRow = ref<any | null>(null);
const registerOptions = ref<Option[]>([]);
const detectOptions = ref<Option[]>([]);
const useOptions = ref<Option[]>([]);
const reviewOptions = ref<Option[]>([]);
const sourceOptions = ref<Option[]>([]);
const defaultFilters = () => ({
domain_keyword: "",
register_status: undefined as number | undefined,
detect_status: undefined as number | undefined,
use_status: undefined as number | undefined,
review_status: undefined as number | undefined,
has_beian: undefined as number | undefined,
register_status: null as number | null,
detect_status: null as number | null,
use_status: null as number | null,
review_status: null as number | null,
has_beian: null as number | null,
beian_year: "",
snapshot_year: "",
website_url: "",
company_type: "",
source_type: null as number | null,
backlink_gt_10: false
});
const defaultBatchForm = () => ({
review_status: undefined as number | undefined,
expire_date: "",
has_beian: undefined as number | undefined,
beian_year: "",
snapshot_years: "",
company_type: "",
website_url: "",
detect_time: "",
backlink_count: undefined as number | undefined
backlink_count: undefined as number | undefined,
baidu_history: "",
baidu_site: "",
is_chinese_title: "",
qihu360_site: "",
google_site: ""
});
const filters = ref(defaultFilters());
const batchForm = ref(defaultBatchForm());
const exportForm = ref({
type: "txt",
scope: "page",
page_count: 1
});
const findOptionLabel = (options: Option[], value: number | null) => {
if (value === null || value === undefined) return "";
return options.find((item) => item.value === value)?.label || String(value);
};
const activeFilterChips = computed(() => {
const chips: Array<{ label: string }> = [];
if (filters.value.domain_keyword.trim()) chips.push({ label: `域名:${filters.value.domain_keyword.trim()}` });
if (filters.value.register_status !== null) chips.push({ label: `注册:${findOptionLabel(registerOptions.value, filters.value.register_status)}` });
if (filters.value.detect_status !== null) chips.push({ label: `检测:${findOptionLabel(detectOptions.value, filters.value.detect_status)}` });
if (filters.value.use_status !== null) chips.push({ label: `使用:${findOptionLabel(useOptions.value, filters.value.use_status)}` });
if (filters.value.review_status !== null) chips.push({ label: `复核:${findOptionLabel(reviewOptions.value, filters.value.review_status)}` });
if (filters.value.has_beian !== null) {
const beianText = { 1: "未检测", 2: "有备案", 3: "无备案" }[filters.value.has_beian as 1 | 2 | 3] || String(filters.value.has_beian);
chips.push({ label: `备案:${beianText}` });
}
if (filters.value.beian_year.trim()) chips.push({ label: `备案年份:${filters.value.beian_year.trim()}` });
if (filters.value.snapshot_year.trim()) chips.push({ label: `快照年份:${filters.value.snapshot_year.trim()}` });
if (filters.value.website_url.trim()) chips.push({ label: `首页网址:${filters.value.website_url.trim()}` });
if (filters.value.company_type.trim()) chips.push({ label: `单位性质:${filters.value.company_type.trim()}` });
if (filters.value.source_type !== null) chips.push({ label: `来源:${findOptionLabel(sourceOptions.value, filters.value.source_type)}` });
if (filters.value.backlink_gt_10) chips.push({ label: "友链 > 10" });
return chips;
});
const restoreViewState = () => {
try {
const raw = window.localStorage.getItem(DOMAINS_VIEW_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
filters.value = {
...defaultFilters(),
...(parsed.filters || {})
};
page.value = Math.max(Number(parsed.page || 1), 1);
pageSize.value = [20, 50, 100, 200].includes(Number(parsed.pageSize)) ? Number(parsed.pageSize) : 20;
showAdvancedFilters.value = Boolean(parsed.showAdvancedFilters);
} catch {
window.localStorage.removeItem(DOMAINS_VIEW_STORAGE_KEY);
} finally {
restoredState.value = true;
}
};
const persistViewState = () => {
if (!restoredState.value) return;
window.localStorage.setItem(
DOMAINS_VIEW_STORAGE_KEY,
JSON.stringify({
filters: filters.value,
page: page.value,
pageSize: pageSize.value,
showAdvancedFilters: showAdvancedFilters.value
})
);
};
const buildParams = () => {
const params: Record<string, unknown> = {
page: page.value,
page_size: pageSize.value,
domain_keyword: filters.value.domain_keyword || undefined,
register_status: filters.value.register_status,
detect_status: filters.value.detect_status,
use_status: filters.value.use_status,
review_status: filters.value.review_status,
has_beian: filters.value.has_beian,
register_status: filters.value.register_status ?? undefined,
detect_status: filters.value.detect_status ?? undefined,
use_status: filters.value.use_status ?? undefined,
review_status: filters.value.review_status ?? undefined,
has_beian: filters.value.has_beian ?? undefined,
beian_year: filters.value.beian_year || undefined,
snapshot_year: filters.value.snapshot_year || undefined,
website_url: filters.value.website_url || undefined
website_url: filters.value.website_url || undefined,
company_type: filters.value.company_type || undefined,
source_type: filters.value.source_type ?? undefined
};
if (filters.value.backlink_gt_10) {
params.backlink_gt_10 = true;
@@ -197,10 +508,11 @@ const buildParams = () => {
const loadFilterOptions = async () => {
try {
const response = await domainsApi.filters();
registerOptions.value = response.data.register_status || [];
detectOptions.value = response.data.detect_status || [];
useOptions.value = response.data.use_status || [];
reviewOptions.value = response.data.review_status || [];
registerOptions.value = [{ label: "全部", value: null }, ...(response.data.register_status || [])];
detectOptions.value = [{ label: "全部", value: null }, ...(response.data.detect_status || [])];
useOptions.value = [{ label: "全部", value: null }, ...(response.data.use_status || [])];
reviewOptions.value = [{ label: "全部", value: null }, ...(response.data.review_status || [])];
sourceOptions.value = [{ label: "全部", value: null }, ...(response.data.source_type || [])];
} catch {
ElMessage.error("读取筛选选项失败");
}
@@ -217,6 +529,37 @@ const loadDomains = async () => {
}
};
const stepStateText = (value: string) => {
if (value === "passed") return "正常";
if (value === "degraded") return "降级";
if (value === "failed") return "失败";
if (value === "blacklisted") return "命中";
return value || "-";
};
const stepStateTagType = (value: string) => {
if (value === "passed") return "success";
if (value === "degraded") return "warning";
if (value === "failed") return "danger";
if (value === "blacklisted") return "danger";
return "info";
};
const openDomainDetail = async (row: any) => {
detailVisible.value = true;
detailLoading.value = true;
detailRow.value = null;
try {
const response = await domainsApi.detail(row.id);
detailRow.value = response.data;
} catch {
ElMessage.error("读取域名详情失败");
detailVisible.value = false;
} finally {
detailLoading.value = false;
}
};
const handleSelectionChange = (selection: any[]) => {
selectedIds.value = selection.map((item) => item.id);
};
@@ -241,6 +584,7 @@ const reset = () => {
filters.value = defaultFilters();
page.value = 1;
pageSize.value = 20;
window.localStorage.removeItem(DOMAINS_VIEW_STORAGE_KEY);
loadDomains();
};
@@ -249,6 +593,26 @@ const closeBatchDialog = () => {
batchForm.value = defaultBatchForm();
};
const submitExport = async () => {
exporting.value = true;
try {
const response = await exportsApi.run({
...buildParams(),
type: exportForm.value.type,
scope: exportForm.value.scope,
page: page.value,
page_size: pageSize.value,
page_count: exportForm.value.page_count
});
exportDialogVisible.value = false;
ElMessage.success(response.message || "导出文件已生成");
} catch (error: any) {
ElMessage.error(error?.message || "生成导出文件失败");
} finally {
exporting.value = false;
}
};
const submitBatchUpdate = async () => {
const updates: Record<string, unknown> = {};
Object.entries(batchForm.value).forEach(([key, value]) => {
@@ -273,32 +637,161 @@ const submitBatchUpdate = async () => {
}
};
watch(filters, persistViewState, { deep: true });
watch(page, persistViewState);
watch(pageSize, persistViewState);
watch(showAdvancedFilters, persistViewState);
onMounted(async () => {
restoreViewState();
if (route.query.source_type) {
const parsed = Number(route.query.source_type);
if (!Number.isNaN(parsed)) {
filters.value.source_type = parsed;
page.value = 1;
}
}
await loadFilterOptions();
await loadDomains();
});
</script>
<style scoped>
.domains-layout {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
gap: 12px;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
}
.th-label {
display: inline-flex;
align-items: center;
cursor: help;
}
.domain-link {
padding: 0;
font-weight: 600;
}
.toolbar-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 8px 16px;
margin-bottom: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 6px 14px;
flex-shrink: 0;
}
.toolbar-grid :deep(.el-form-item) {
margin-bottom: 8px;
margin-bottom: 6px;
}
.actions {
align-self: end;
.active-filters {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
margin-top: -2px;
padding: 2px 2px 0;
color: #64748b;
font-size: 12px;
flex-wrap: wrap;
}
.active-filters__label {
color: #334155;
font-weight: 600;
}
.active-filters__empty {
color: #94a3b8;
}
.step-summary-ok {
color: #64748b;
font-size: 12px;
}
.active-filter-chip {
display: inline-flex;
align-items: center;
max-width: 240px;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
background: #f1f5f9;
color: #334155;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table-region {
flex: 1;
min-height: 0;
overflow: hidden;
}
.table-region :deep(.el-table__row td) {
height: 38px;
}
.table-region :deep(.cell) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 22px;
}
.domains-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 40px;
}
.pager-meta {
display: flex;
align-items: center;
gap: 10px;
min-height: 32px;
padding: 0 2px 0 0;
color: #64748b;
font-size: 12px;
flex-wrap: wrap;
min-width: 0;
}
.footer-summary {
color: #334155;
font-weight: 500;
}
.hint-chip {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
background: #eef4ff;
color: #31558f;
cursor: help;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
flex-shrink: 0;
}
.batch-grid {
@@ -306,4 +799,26 @@ onMounted(async () => {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 8px 16px;
}
.detail-loading {
padding: 24px 4px;
color: #64748b;
}
@media (max-width: 1100px) {
.header-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.domains-footer {
align-items: flex-start;
flex-direction: column;
}
.pager {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@@ -1,18 +1,80 @@
<template>
<PageCard title="域名导入" description="已改成导入任务模式,适合后续大文件上传和 Linux 后端持续处理。">
<PageCard title="域名导入" description="已升级为任务中心:适合大文件上传、文本粘贴导入,并支持阶段查看、日志常驻和切页回看。">
<div class="toolbar">
<el-upload :show-file-list="false" :auto-upload="false" :on-change="handleChange" accept=".txt">
<template #trigger>
<el-button type="primary">选择 TXT 文件</el-button>
</template>
</el-upload>
<el-select v-model="sourceType" style="width: 150px">
<el-option v-for="item in sourceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span class="filename">{{ selectedName || "尚未选择文件" }}</span>
<el-button :loading="uploading" :disabled="!selectedFile" @click="submitUpload">创建导入任务</el-button>
<el-button plain @click="downloadTemplate">下载示例模板</el-button>
<el-button plain :loading="loading" @click="loadAll">刷新</el-button>
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
<span class="updated-at">最近刷新{{ lastUpdatedAt || "暂无" }}</span>
<span class="updated-at">最近同步{{ lastUpdatedAt || "暂无" }}</span>
</div>
<el-alert
v-if="selectedName"
:closable="false"
show-icon
type="info"
style="margin-bottom: 12px"
:title="selectedFileSummary"
/>
<el-row v-if="selectedName && fileStats.total > 0" :gutter="12" style="margin-bottom: 12px">
<el-col :xs="12" :md="6"><el-statistic title="总行数" :value="fileStats.total" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="有效域名" :value="fileStats.valid" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="重复域名" :value="fileStats.duplicates" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="非法域名" :value="fileStats.invalid" /></el-col>
</el-row>
<el-alert
v-if="selectedName && selectedFileLineCount !== null && selectedFileLineCount > 1000"
:closable="false"
show-icon
type="warning"
style="margin-bottom: 12px"
title="当前文件行数较多,建议直接创建后台导入任务并在下方任务中心查看日志,不必等待页面停留。"
/>
<el-table
v-if="selectedFilePreview.length"
:data="selectedFilePreview"
border
size="small"
style="margin-bottom: 12px"
>
<el-table-column prop="line" label="行号" width="90" />
<el-table-column prop="domain" label="预览内容" min-width="320" show-overflow-tooltip />
</el-table>
<el-input
v-model="manualText"
type="textarea"
:rows="8"
placeholder="支持手工粘贴域名,一行一个。对齐旧桌面版的文本导入方式。"
style="margin-bottom: 12px"
/>
<div class="toolbar" style="margin-top: 0">
<el-button type="primary" plain :loading="submittingText" @click="submitTextImport">粘贴内容创建导入任务</el-button>
<el-button plain :disabled="!manualText.trim()" @click="clearManualText">清空内容</el-button>
<span class="updated-at">当前行数{{ manualCount }}</span>
<span class="updated-at">{{ draftHint }}</span>
</div>
<el-row v-if="manualStats.total > 0" :gutter="12" style="margin-bottom: 16px">
<el-col :xs="12" :md="6"><el-statistic title="文本总行数" :value="manualStats.total" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="有效域名" :value="manualStats.valid" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="重复域名" :value="manualStats.duplicates" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="非法域名" :value="manualStats.invalid" /></el-col>
</el-row>
<el-descriptions :column="2" border>
<el-descriptions-item label="已导入域名总数">{{ summary.domains_total }}</el-descriptions-item>
<el-descriptions-item label="detect_tasks 总数">{{ summary.detect_tasks_total }}</el-descriptions-item>
@@ -21,16 +83,60 @@
<el-descriptions-item :span="2" label="最近导入时间">{{ summary.last_import_time || "暂无" }}</el-descriptions-item>
</el-descriptions>
<el-row :gutter="12" style="margin-top: 16px; margin-bottom: 16px">
<el-col :xs="24" :md="12">
<div class="summary-card">
<div class="summary-title">当前导入阶段</div>
<div class="summary-value">{{ currentPhaseLabel }}</div>
<div class="summary-note">{{ currentPhaseDetail }}</div>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="summary-card">
<div class="summary-title">当前任务概览</div>
<div class="summary-value">{{ currentTaskSummary }}</div>
<div class="summary-note">支持大文件上传和文本粘贴导入离开页面后回来仍可继续回看</div>
<el-button
v-if="selectedTask?.source_type"
plain
size="small"
style="margin-top: 10px"
@click="goDomainsBySource(selectedTask.source_type)"
>
前往域名筛选查看结果
</el-button>
</div>
</el-col>
</el-row>
<el-alert v-if="lastResult" style="margin-top: 16px" type="success" :closable="false" show-icon :title="lastResult" />
<el-alert v-if="loadError" style="margin-top: 12px" type="warning" :closable="false" show-icon title="读取导入任务失败,已保留上一次结果。" />
<el-table :data="tasks" border style="margin-top: 16px">
<el-table-column prop="status" label="状态" min-width="100" />
<el-table :data="tasks" border style="margin-top: 16px" highlight-current-row @current-change="handleCurrentTaskChange">
<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.status)" plain>{{ row.phase_label || "-" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="source_label" label="来源类型" min-width="110" />
<el-table-column prop="filename" label="文件名" min-width="220" show-overflow-tooltip />
<el-table-column prop="message" label="任务信息" min-width="260" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<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="240">
<template #default="{ row }">
<span v-if="row.result?.stats">
总数 {{ row.result.stats.total }} / 新增 {{ row.result.stats.added }} / 已存在 {{ row.result.stats.exists }} / 无效 {{ row.result.stats.invalid }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="110" fixed="right">
<template #default="{ row }">
<el-button
@@ -44,24 +150,37 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="结果" min-width="220">
<template #default="{ row }">
<span v-if="row.result?.stats">
总数 {{ row.result.stats.total }} / 新增 {{ row.result.stats.added }} / 已存在 {{ row.result.stats.exists }} / 无效 {{ row.result.stats.invalid }}
</span>
<span v-else>-</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">
{{ selectedTask ? `当前查看任务:${selectedTask.task_id}` : "请选择一条任务记录查看日志" }}
</div>
</div>
<div class="task-detail-meta">
<span v-if="selectedTask">状态{{ statusText(selectedTask.status) }}</span>
<span v-if="selectedTask?.phase_label">阶段{{ selectedTask.phase_label }}</span>
<span v-if="selectedTask?.result?.stats">新增{{ selectedTask.result.stats.added || 0 }}</span>
</div>
</div>
<pre class="log-console">{{ selectedTaskLogs }}</pre>
</div>
</PageCard>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { importsApi } from "@/api/modules";
const IMPORT_DRAFT_STORAGE_KEY = "domaincheck:imports:manual-draft";
const router = useRouter();
const summary = ref({
domains_total: 0,
detect_tasks_total: 0,
@@ -70,16 +189,105 @@ const summary = ref({
running_import_tasks: 0
});
const tasks = ref<any[]>([]);
const selectedTaskId = ref("");
const selectedFile = ref<File | null>(null);
const sourceType = ref(7);
const selectedName = ref("");
const selectedFileLineCount = ref<number | null>(null);
const selectedFilePreview = ref<Array<{ line: number; domain: string }>>([]);
const selectedFileRawRows = ref<string[]>([]);
const lastResult = ref("");
const loading = ref(false);
const uploading = ref(false);
const loadError = ref(false);
const lastUpdatedAt = ref("");
const autoRefresh = ref(true);
const manualText = ref("");
const submittingText = ref(false);
const draftSavedAt = ref("");
let timer: number | null = null;
const sourceTypeOptions = [
{ label: "TXT 导入", value: 7 },
{ label: "手工录入", value: 6 },
{ label: "其它", value: 9 }
];
const manualCount = computed(() =>
manualText.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean).length
);
const DOMAIN_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(com|net)$/i;
const normalizeDomain = (value: string) => {
let candidate = String(value || "").trim().toLowerCase();
candidate = candidate.replace(/^https?:\/\//, "");
candidate = candidate.split("/")[0].replace(/^\.+|\.+$/g, "");
if (candidate.startsWith("www.")) {
candidate = candidate.slice(4);
}
return DOMAIN_PATTERN.test(candidate) ? candidate : "";
};
const summarizeRows = (rows: string[]) => {
const normalizedList = rows
.map((item) => normalizeDomain(item))
.filter(Boolean);
const unique = new Set(normalizedList);
return {
total: rows.length,
valid: normalizedList.length,
duplicates: normalizedList.length - unique.size,
invalid: rows.length - normalizedList.length
};
};
const fileStats = computed(() => summarizeRows(selectedFileRawRows.value));
const manualStats = computed(() =>
summarizeRows(
manualText.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
)
);
const selectedTask = computed(() => tasks.value.find((item) => item.task_id === selectedTaskId.value) || tasks.value[0] || null);
const currentPhaseLabel = computed(() => selectedTask.value?.phase_label || "暂无任务");
const currentPhaseDetail = computed(() => selectedTask.value?.message || "当前还没有导入任务在执行。");
const currentTaskSummary = computed(() => {
if (!selectedTask.value) {
return "等待创建";
}
const result = selectedTask.value.result?.stats;
if (result) {
return `${selectedTask.value.source_label || "未知来源"} / 新增 ${result.added || 0} / 已存在 ${result.exists || 0} / 无效 ${result.invalid || 0}`;
}
return `${selectedTask.value.source_label || "未知来源"} / ${selectedTask.value.filename || "导入任务"} / ${statusText(selectedTask.value.status)}`;
});
const selectedTaskLogs = computed(() => {
const logs = selectedTask.value?.logs || [];
return logs.length ? logs.join("\n") : "暂无导入日志";
});
const selectedFileSummary = computed(() => {
if (!selectedName.value) {
return "";
}
if (selectedFileLineCount.value === null) {
return `已选择文件:${selectedName.value},正在统计行数。`;
}
return `已选择文件:${selectedName.value},预计非空行 ${selectedFileLineCount.value} 条,当前预览前 ${selectedFilePreview.value.length} 行;提交前已完成重复和非法格式的本地预检。`;
});
const draftHint = computed(() => {
if (!manualText.value.trim()) {
return "手工输入内容会自动暂存";
}
return draftSavedAt.value ? `草稿已暂存:${draftSavedAt.value}` : "手工输入内容会自动暂存";
});
const clearRefreshTimer = () => {
if (timer) {
window.clearInterval(timer);
@@ -96,6 +304,28 @@ const ensureRefreshTimer = () => {
}
};
const statusText = (value: string) => {
if (value === "queued") return "排队中";
if (value === "running") return "运行中";
if (value === "completed") return "已完成";
if (value === "failed") return "失败";
return value || "-";
};
const statusTagType = (value: string) => {
if (value === "running") return "warning";
if (value === "completed") return "success";
if (value === "failed") return "danger";
return "info";
};
const phaseTagType = (value: string) => {
if (value === "running") return "warning";
if (value === "completed") return "success";
if (value === "failed") return "danger";
return "info";
};
const loadSummary = async () => {
const response = await importsApi.summary();
summary.value = response.data;
@@ -104,6 +334,11 @@ const loadSummary = async () => {
const loadTasks = async () => {
const response = await importsApi.tasks();
tasks.value = response.data;
if (!selectedTaskId.value && tasks.value.length) {
selectedTaskId.value = tasks.value[0].task_id;
} else if (selectedTaskId.value && !tasks.value.some((item) => item.task_id === selectedTaskId.value) && tasks.value.length) {
selectedTaskId.value = tasks.value[0].task_id;
}
};
const loadAll = async (showError = true) => {
@@ -122,9 +357,66 @@ const loadAll = async (showError = true) => {
}
};
const handleChange = (uploadFile: any) => {
const handleChange = async (uploadFile: any) => {
selectedFile.value = uploadFile.raw || null;
selectedName.value = uploadFile.name || "";
selectedFileLineCount.value = null;
selectedFilePreview.value = [];
selectedFileRawRows.value = [];
const raw = uploadFile.raw;
if (!raw) return;
try {
const text = await raw.text();
const rows = text
.split(/\r?\n/)
.map((item: string) => item.trim())
.filter(Boolean);
selectedFileRawRows.value = rows;
selectedFileLineCount.value = rows.length;
selectedFilePreview.value = rows.slice(0, 10).map((domain: string, index: number) => ({
line: index + 1,
domain
}));
} catch {
selectedFileLineCount.value = null;
selectedFilePreview.value = [];
selectedFileRawRows.value = [];
}
};
const persistManualDraft = () => {
if (!manualText.value.trim()) {
window.localStorage.removeItem(IMPORT_DRAFT_STORAGE_KEY);
draftSavedAt.value = "";
return;
}
window.localStorage.setItem(IMPORT_DRAFT_STORAGE_KEY, manualText.value);
draftSavedAt.value = new Date().toLocaleString("zh-CN", { hour12: false });
};
const restoreManualDraft = () => {
const draft = window.localStorage.getItem(IMPORT_DRAFT_STORAGE_KEY);
if (draft) {
manualText.value = draft;
}
};
const clearManualText = () => {
manualText.value = "";
window.localStorage.removeItem(IMPORT_DRAFT_STORAGE_KEY);
draftSavedAt.value = "";
};
const downloadTemplate = () => {
const content = ["example.com", "example.net", "yourdomain.com"].join("\n");
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "domain_import_sample.txt";
link.click();
window.URL.revokeObjectURL(url);
ElMessage.success("示例模板已生成");
};
const submitUpload = async () => {
@@ -134,14 +426,21 @@ const submitUpload = async () => {
}
const formData = new FormData();
formData.append("file", selectedFile.value);
formData.append("source_type", String(sourceType.value));
uploading.value = true;
try {
const response = await importsApi.upload(formData);
lastResult.value = `导入任务已创建:${response.data.filename},状态 ${response.data.status}`;
lastResult.value = `导入任务已创建:${response.data.filename},状态 ${statusText(response.data.status)}`;
ElMessage.success("导入任务已创建");
selectedFile.value = null;
selectedName.value = "";
selectedFileLineCount.value = null;
selectedFilePreview.value = [];
selectedFileRawRows.value = [];
await loadAll(false);
if (response.data?.task_id) {
selectedTaskId.value = response.data.task_id;
}
} catch {
ElMessage.error("创建导入任务失败");
} finally {
@@ -149,19 +448,66 @@ const submitUpload = async () => {
}
};
const submitTextImport = async () => {
const text = manualText.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.join("\n");
if (!text) {
ElMessage.warning("请先粘贴域名内容");
return;
}
submittingText.value = true;
try {
const response = await importsApi.createTextTask({
text,
filename: `manual_domains_${Date.now()}.txt`,
source_type: sourceType.value
});
lastResult.value = `文本导入任务已创建:${response.data.filename},状态 ${statusText(response.data.status)}`;
ElMessage.success("文本导入任务已创建");
clearManualText();
await loadAll(false);
if (response.data?.task_id) {
selectedTaskId.value = response.data.task_id;
}
} catch {
ElMessage.error("创建文本导入任务失败");
} finally {
submittingText.value = false;
}
};
const retryTask = async (taskId: string) => {
try {
await importsApi.retry(taskId);
ElMessage.success("导入任务已重新加入队列");
await loadAll(false);
selectedTaskId.value = taskId;
} catch {
ElMessage.error("重试导入任务失败");
}
};
const goDomainsBySource = (sourceTypeValue: number) => {
router.push({
path: "/domains",
query: {
source_type: String(sourceTypeValue)
}
});
};
const handleCurrentTaskChange = (row: any | null) => {
selectedTaskId.value = row?.task_id || "";
};
watch(autoRefresh, ensureRefreshTimer);
watch(manualText, persistManualDraft);
onMounted(async () => {
restoreManualDraft();
await loadAll();
ensureRefreshTimer();
});
@@ -186,4 +532,79 @@ onBeforeUnmount(clearRefreshTimer);
color: #64748b;
font-size: 13px;
}
.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: 22px;
font-weight: 700;
}
.summary-note {
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
.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;
}
.log-console {
margin: 0;
min-height: 220px;
max-height: 420px;
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;
}
</style>

View File

@@ -0,0 +1,745 @@
<template>
<PageCard title="聚名采集" description="已升级为任务中心:提交后后台持续执行,支持阶段查看、日志常驻和切页回看。">
<el-alert
:title="status.cookie_ready ? `聚名登录态已就绪:${status.cookie_storage}Cookie ${status.cookie_count || 0} 项` : '请先使用下方账号密码登录聚名;登录成功后服务器会自动保存 Cookie'"
:type="status.cookie_ready ? 'success' : 'warning'"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-alert
:title="savedJumingSummary"
:type="savedJumingAccount ? 'info' : 'warning'"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-alert
:title="juchaSummary"
:type="status.jucha?.cookie_ready ? 'success' : 'warning'"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form label-width="110px" class="login-form">
<el-form-item label="聚名账号">
<el-input v-model="loginForm.email" placeholder="请输入聚名账号" />
</el-form-item>
<el-form-item label="聚名密码">
<el-input v-model="loginForm.password" type="password" show-password placeholder="请输入聚名密码" />
<el-button type="primary" plain :loading="loggingIn" style="margin-left: 12px" @click="loginJuming">
账号密码登录
</el-button>
<span class="login-status" :class="{ success: status.cookie_ready, warning: !status.cookie_ready }">
{{ status.cookie_ready ? "已登录成功" : "未登录" }}
</span>
</el-form-item>
<el-form-item>
<span class="cookie-note">说明聚名登录成功后会自动保存 Cookie并尝试自动联名登录聚查</span>
<el-button plain :loading="linkingJucha" style="margin-left: 12px" @click="loginJucha">重新联名登录聚查</el-button>
</el-form-item>
</el-form>
<el-form label-width="110px" class="cookie-form">
<el-form-item label="Cookie 文件">
<el-upload
:auto-upload="false"
:show-file-list="true"
:limit="1"
accept=".pkl,.pickle,.json,.txt"
:on-change="onCookieFileChange"
:on-remove="onCookieFileRemove"
>
<template #trigger>
<el-button>选择 Cookie 文件</el-button>
</template>
</el-upload>
<el-button
type="primary"
plain
:loading="uploadingCookie"
:disabled="!cookieFile"
style="margin-left: 12px"
@click="uploadCookie"
>
上传 Cookie 兜底
</el-button>
</el-form-item>
<el-form-item label="Cookie 路径">
<span class="cookie-path">{{ status.cookie_file || "-" }}</span>
</el-form-item>
<el-form-item>
<span class="cookie-note">如账号密码登录受验证码或风控影响可临时上传已登录 Cookie 作为兜底方式</span>
</el-form-item>
</el-form>
<el-form label-width="110px" class="juming-form">
<el-form-item label="采集类型">
<el-radio-group v-model="form.mode">
<el-radio-button label="delete_list">过期删除</el-radio-button>
<el-radio-button label="fixed_price">一口价</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="form.mode === 'delete_list'">
<el-form-item label="起始日期">
<el-date-picker v-model="form.crawl_date" type="date" value-format="YYYY-MM-DD" />
<el-checkbox v-model="form.auto_date" style="margin-left: 12px">自动追加到今天+4天</el-checkbox>
<span class="updated-at" style="margin-left: 12px">
{{ preferencesSaving ? "偏好保存中..." : preferencesSavedAt ? `偏好已保存:${preferencesSavedAt}` : "偏好将自动保存" }}
</span>
</el-form-item>
</template>
<template v-else>
<el-form-item label="起始页码">
<el-input-number v-model="form.page_start" :min="1" />
</el-form-item>
<el-form-item label="每页数量">
<el-input-number v-model="form.page_size" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="抓取页数">
<el-input-number v-model="form.page_count" :min="1" :max="20" />
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" :loading="running" :disabled="!status.cookie_ready" @click="runCrawl">开始采集并入库</el-button>
<el-button plain :loading="loadingStatus" @click="refreshAll">刷新状态</el-button>
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
<span class="updated-at">最近同步{{ lastUpdatedAt || "暂无" }}</span>
<span v-if="!status.cookie_ready" class="crawl-tip">请先完成聚名登录或上传有效 Cookie登录成功后此按钮会自动解锁</span>
</el-form-item>
</el-form>
<el-alert
v-if="latestCompletedSummary"
:closable="false"
show-icon
type="success"
style="margin-bottom: 16px"
:title="latestCompletedSummary"
/>
<el-row :gutter="16" style="margin-bottom: 16px">
<el-col :xs="12" :md="6"><el-statistic title="总任务数" :value="tasks.length" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="运行中任务" :value="runningTaskCount" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="成功任务" :value="completedTaskCount" /></el-col>
<el-col :xs="12" :md="6"><el-statistic title="失败任务" :value="failedTaskCount" /></el-col>
</el-row>
<el-row :gutter="12" style="margin-bottom: 16px">
<el-col :xs="24" :md="12">
<div class="summary-card">
<div class="summary-title">当前采集阶段</div>
<div class="summary-value">{{ currentPhaseLabel }}</div>
<div class="summary-note">{{ currentPhaseDetail }}</div>
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="summary-card">
<div class="summary-title">当前任务概览</div>
<div class="summary-value">{{ currentTaskSummary }}</div>
<div class="summary-note">离开页面后回来仍可继续查看任务进度和运行日志</div>
</div>
</el-col>
</el-row>
<el-table :data="tasks" border highlight-current-row @current-change="handleCurrentTaskChange">
<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="mode" label="采集类型" min-width="110">
<template #default="{ row }">
{{ row.mode === "fixed_price" ? "一口价" : "过期删除" }}
</template>
</el-table-column>
<el-table-column prop="message" label="任务信息" min-width="280" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" min-width="160" />
<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="110" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'running' || row.status === 'queued'"
type="danger"
link
@click="stopTask(row.task_id)"
>
停止
</el-button>
<el-button
v-if="row.status === 'failed' || row.status === 'queued'"
type="primary"
link
@click="retryTask(row.task_id)"
>
重试
</el-button>
<span v-else>-</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">
{{ selectedTask ? `当前查看任务:${selectedTask.task_id}` : "请选择一条任务记录查看日志" }}
</div>
</div>
<div class="task-detail-meta">
<span v-if="selectedTask">状态{{ statusText(selectedTask.status) }}</span>
<span v-if="selectedTask?.phase_label">阶段{{ selectedTask.phase_label }}</span>
<span v-if="selectedTask?.result">抓取{{ selectedTask.result.domains_found || 0 }}</span>
</div>
</div>
<pre class="log-console">{{ selectedTaskLogs }}</pre>
</div>
<el-row v-if="selectedTask?.result" :gutter="16" style="margin-bottom: 16px">
<el-col :xs="12" :md="4"><el-statistic title="抓取总数" :value="selectedTask.result.domains_found || 0" /></el-col>
<el-col :xs="12" :md="4"><el-statistic title="有效域名" :value="selectedTask.result.stats?.valid || 0" /></el-col>
<el-col :xs="12" :md="4"><el-statistic title="新增入库" :value="selectedTask.result.stats?.added || 0" /></el-col>
<el-col :xs="12" :md="4"><el-statistic title="已存在" :value="selectedTask.result.stats?.exists || 0" /></el-col>
<el-col :xs="12" :md="4"><el-statistic title="无效" :value="selectedTask.result.stats?.invalid || 0" /></el-col>
<el-col :xs="12" :md="4"><el-statistic title="失败" :value="selectedTask.result.stats?.failed || 0" /></el-col>
</el-row>
<el-table v-if="selectedTimeline.length" :data="selectedTimeline" border style="margin-bottom: 16px">
<el-table-column prop="label" label="阶段" min-width="180" />
<el-table-column prop="count" label="数量" width="100" />
</el-table>
<el-table v-if="selectedSampleDomains.length" :data="selectedSampleDomains" border>
<el-table-column prop="domain" label="样本域名" min-width="260" />
</el-table>
</PageCard>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import type { UploadFile } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { jumingApi, settingsApi } from "@/api/modules";
const loadingStatus = ref(false);
const running = ref(false);
const uploadingCookie = ref(false);
const loggingIn = ref(false);
const linkingJucha = ref(false);
const cookieFile = ref<File | null>(null);
const autoRefresh = ref(true);
const lastUpdatedAt = ref("");
const preferencesSavedAt = ref("");
const preferencesSaving = ref(false);
const tasks = ref<any[]>([]);
const selectedTaskId = ref("");
let timer: number | null = null;
let savePreferencesTimer: number | null = null;
let suppressPreferenceSave = false;
const status = reactive<Record<string, any>>({
cookie_ready: false,
cookie_storage: "missing",
cookie_file: "",
cookie_count: 0,
jucha: {
cookie_ready: false,
cookie_file: ""
},
linked_jucha: {
attempted: false,
ok: false,
message: ""
},
defaults: {
mode: "delete_list",
page_start: 1,
page_size: 500,
page_count: 1,
crawl_date: "",
auto_date: true
}
});
const loginForm = reactive({
email: "",
password: ""
});
const form = reactive({
mode: "delete_list",
page_start: 1,
page_size: 500,
page_count: 1,
crawl_date: "",
auto_date: true
});
const runningTaskCount = computed(() => tasks.value.filter((item) => item.status === "running" || item.status === "queued").length);
const completedTaskCount = computed(() => tasks.value.filter((item) => item.status === "completed").length);
const failedTaskCount = computed(() => tasks.value.filter((item) => item.status === "failed").length);
const selectedTask = computed(() => tasks.value.find((item) => item.task_id === selectedTaskId.value) || tasks.value[0] || null);
const currentPhaseLabel = computed(() => selectedTask.value?.phase_label || "暂无任务");
const currentPhaseDetail = computed(() => selectedTask.value?.message || "当前还没有聚名采集任务在执行。");
const currentTaskSummary = computed(() => {
if (!selectedTask.value) {
return "等待创建";
}
return `${selectedTask.value.mode === "fixed_price" ? "一口价" : "过期删除"} / ${statusText(selectedTask.value.status)}`;
});
const selectedTaskLogs = computed(() => {
const lines = selectedTask.value?.logs || [];
return lines.length ? lines.join("\n") : "暂无日志输出";
});
const selectedTimeline = computed(() => {
const payload = selectedTask.value?.result;
if (!payload) return [];
if (Array.isArray(payload.dates)) {
return payload.dates.map((item: any) => ({ label: item.date, count: item.count }));
}
if (Array.isArray(payload.pages)) {
return payload.pages.map((item: any) => ({ label: `${item.page}`, count: item.count }));
}
return [];
});
const selectedSampleDomains = computed(() => (selectedTask.value?.result?.sample_domains || []).map((domain: string) => ({ domain })));
const latestCompletedSummary = computed(() => {
const latestCompleted = tasks.value.find((item) => item.status === "completed" && item.result?.stats);
if (!latestCompleted) return "";
const result = latestCompleted.result;
const stats = result.stats || {};
return `最近完成任务:抓取 ${result.domains_found || 0} 个域名,新增 ${stats.added || 0} 个,已存在 ${stats.exists || 0} 个,无效 ${stats.invalid || 0}`;
});
const maskAccount = (value: string) => {
const text = String(value || "").trim();
if (!text) return "";
const [name, domain] = text.split("@");
if (!domain) {
if (text.length <= 4) return `${text[0] || ""}***`;
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
const visible = name.length <= 2 ? `${name[0] || ""}*` : `${name.slice(0, 2)}***${name.slice(-1)}`;
return `${visible}@${domain}`;
};
const savedJumingAccount = computed(() => maskAccount(loginForm.email));
const savedJumingSummary = computed(() => {
if (!savedJumingAccount.value) {
return "当前未保存聚名账号,首次登录成功后会自动保存,后续进入页面会自动回填。";
}
return `已保存聚名账号:${savedJumingAccount.value},运营回来时能直接确认这台服务器已录入账号。`;
});
const juchaSummary = computed(() => {
const linked = status.linked_jucha || {};
if (status.jucha?.cookie_ready) {
return `聚查登录态已就绪:${status.jucha?.cookie_file || "-"}${linked.message ? `,最近结果:${linked.message}` : ""}`;
}
return linked.message || "尚未检测到聚查登录态,聚名登录成功后可自动联名,也可手动重试。";
});
const statusText = (value: string) => {
if (value === "queued") return "排队中";
if (value === "running") return "运行中";
if (value === "completed") return "已完成";
if (value === "failed") return "失败";
if (value === "stopped") return "已停止";
return value || "-";
};
const statusTagType = (value: string) => {
if (value === "running") return "warning";
if (value === "completed") return "success";
if (value === "failed") return "danger";
if (value === "stopped") return "info";
return "";
};
const phaseTagType = (phaseLabel: string, statusValue: string) => {
if (statusValue === "failed") return "danger";
if (statusValue === "completed") return "success";
if (statusValue === "stopped") return "info";
if (["抓取中", "入库中"].includes(phaseLabel)) return "warning";
if (["启动中", "停止中", "排队中"].includes(phaseLabel)) return "";
return "info";
};
const clearRefreshTimer = () => {
if (timer) {
window.clearInterval(timer);
timer = null;
}
};
const ensureRefreshTimer = () => {
clearRefreshTimer();
if (autoRefresh.value) {
timer = window.setInterval(() => {
refreshAll(false);
}, 5000);
}
};
const applyDefaults = () => {
const defaults = status.defaults || {};
suppressPreferenceSave = true;
form.mode = defaults.mode || "delete_list";
form.page_start = defaults.page_start || 1;
form.page_size = defaults.page_size || 500;
form.page_count = defaults.page_count || 1;
form.crawl_date = defaults.crawl_date || "";
form.auto_date = defaults.auto_date ?? true;
window.setTimeout(() => {
suppressPreferenceSave = false;
}, 0);
};
const loadCredentials = async () => {
try {
const response = await settingsApi.getCredentials();
loginForm.email = response.data?.juming?.email || "";
loginForm.password = response.data?.juming?.password || "";
} catch (error: any) {
ElMessage.error(error?.message || "读取聚名账号失败");
}
};
const loadStatus = async () => {
const response = await jumingApi.status();
Object.assign(status, response.data || {});
applyDefaults();
};
const persistPreferences = async () => {
if (suppressPreferenceSave) return;
preferencesSaving.value = true;
try {
const response = await jumingApi.updatePreferences({
mode: form.mode,
page_start: form.page_start,
page_size: form.page_size,
page_count: form.page_count,
crawl_date: form.crawl_date,
auto_date: form.auto_date
});
status.defaults = response.data || status.defaults;
preferencesSavedAt.value = new Date().toLocaleString("zh-CN", { hour12: false });
} catch (error: any) {
ElMessage.error(error?.message || "保存聚名采集偏好失败");
} finally {
preferencesSaving.value = false;
}
};
const schedulePersistPreferences = () => {
if (suppressPreferenceSave) return;
if (savePreferencesTimer) {
window.clearTimeout(savePreferencesTimer);
}
savePreferencesTimer = window.setTimeout(() => {
persistPreferences();
}, 500);
};
const loadTasks = async () => {
const response = await jumingApi.tasks();
tasks.value = response.data || [];
if (!selectedTaskId.value && tasks.value.length) {
selectedTaskId.value = tasks.value[0].task_id;
} else if (selectedTaskId.value) {
const exists = tasks.value.some((item) => item.task_id === selectedTaskId.value);
if (!exists && tasks.value.length) {
selectedTaskId.value = tasks.value[0].task_id;
}
}
};
const refreshAll = async (showError = true) => {
loadingStatus.value = true;
try {
await Promise.all([loadStatus(), loadTasks()]);
lastUpdatedAt.value = new Date().toLocaleString("zh-CN", { hour12: false });
} catch (error: any) {
if (showError) {
ElMessage.error(error?.message || "刷新聚名状态失败");
}
} finally {
loadingStatus.value = false;
}
};
const runCrawl = async () => {
running.value = true;
try {
const response = await jumingApi.crawl({
mode: form.mode,
page_start: form.page_start,
page_size: form.page_size,
page_count: form.page_count,
crawl_date: form.crawl_date,
auto_date: form.auto_date
});
ElMessage.success(response.message || "聚名采集任务已创建");
await loadTasks();
if (response.data?.task_id) {
selectedTaskId.value = response.data.task_id;
}
} catch (error: any) {
ElMessage.error(error?.message || "创建聚名采集任务失败");
} finally {
running.value = false;
}
};
const onCookieFileChange = (file: UploadFile) => {
cookieFile.value = file.raw || null;
};
const onCookieFileRemove = () => {
cookieFile.value = null;
};
const uploadCookie = async () => {
if (!cookieFile.value) {
ElMessage.warning("请先选择聚名 Cookie 文件");
return;
}
uploadingCookie.value = true;
try {
const formData = new FormData();
formData.append("file", cookieFile.value, cookieFile.value.name);
const response = await jumingApi.uploadCookie(formData);
ElMessage.success(response.message || "聚名 Cookie 已上传");
cookieFile.value = null;
await refreshAll(false);
} catch (error: any) {
ElMessage.error(error?.message || "聚名 Cookie 上传失败");
} finally {
uploadingCookie.value = false;
}
};
const loginJuming = async () => {
if (!loginForm.email.trim() || !loginForm.password.trim()) {
ElMessage.warning("请输入聚名账号和密码");
return;
}
loggingIn.value = true;
try {
const response = await jumingApi.login({
email: loginForm.email.trim(),
password: loginForm.password
});
await settingsApi.updateCredentials({
juming: {
email: loginForm.email.trim(),
password: loginForm.password
}
});
ElMessage.success(response.message || "聚名登录成功");
await refreshAll(false);
} catch (error: any) {
ElMessage.error(error?.message || "聚名登录失败");
} finally {
loggingIn.value = false;
}
};
const loginJucha = async () => {
linkingJucha.value = true;
try {
const response = await jumingApi.loginJucha();
ElMessage.success(response.message || "聚查联名登录成功");
await refreshAll(false);
} catch (error: any) {
ElMessage.error(error?.message || "聚查联名登录失败");
} finally {
linkingJucha.value = false;
}
};
const retryTask = async (taskId: string) => {
try {
await jumingApi.retryTask(taskId);
ElMessage.success("聚名采集任务已重新加入队列");
await loadTasks();
selectedTaskId.value = taskId;
} catch (error: any) {
ElMessage.error(error?.message || "重试聚名采集任务失败");
}
};
const stopTask = async (taskId: string) => {
try {
await jumingApi.stopTask(taskId);
ElMessage.success("已发送停止请求");
await loadTasks();
selectedTaskId.value = taskId;
} catch (error: any) {
ElMessage.error(error?.message || "停止聚名采集任务失败");
}
};
const handleCurrentTaskChange = (row: any | null) => {
selectedTaskId.value = row?.task_id || "";
};
watch(autoRefresh, ensureRefreshTimer);
watch(
() => [form.mode, form.page_start, form.page_size, form.page_count, form.crawl_date, form.auto_date],
schedulePersistPreferences,
{ deep: false }
);
onMounted(async () => {
await Promise.all([refreshAll(), loadCredentials()]);
ensureRefreshTimer();
});
onBeforeUnmount(() => {
clearRefreshTimer();
if (savePreferencesTimer) {
window.clearTimeout(savePreferencesTimer);
savePreferencesTimer = null;
}
});
</script>
<style scoped>
.login-form {
margin-bottom: 12px;
}
.cookie-form {
margin-bottom: 12px;
}
.juming-form {
margin-bottom: 20px;
}
.cookie-path {
color: #475569;
word-break: break-all;
}
.cookie-note {
color: #64748b;
}
.login-status {
margin-left: 12px;
font-size: 13px;
font-weight: 500;
}
.login-status.success {
color: #16a34a;
}
.login-status.warning {
color: #d97706;
}
.crawl-tip {
margin-left: 12px;
color: #64748b;
font-size: 13px;
}
.updated-at {
color: #64748b;
font-size: 13px;
}
.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: 22px;
font-weight: 700;
}
.summary-note {
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
.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;
}
.log-console {
margin: 0;
min-height: 260px;
max-height: 420px;
overflow: auto;
padding: 16px;
color: #d1fae5;
background: #020617;
font-size: 12px;
line-height: 1.7;
font-family: Consolas, Monaco, monospace;
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<PageCard title="敏感词配置" description="对齐旧桌面版主功能,支持加载、编辑、导入、导出和整库保存。">
<el-alert
:title="`当前共 ${total} 个敏感词`"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-input
v-model="text"
type="textarea"
:rows="22"
placeholder="请输入敏感词,一行一个"
style="margin-bottom: 16px"
/>
<div class="actions">
<el-button @click="triggerImport">导入</el-button>
<el-button @click="exportWords">导出</el-button>
<el-button @click="loadWords">加载</el-button>
<el-button type="primary" :loading="saving" @click="saveWords">保存</el-button>
<input ref="fileInput" type="file" accept=".txt,text/plain" class="hidden-input" @change="handleImport" />
</div>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { sensitiveWordsApi } from "@/api/modules";
const text = ref("");
const total = ref(0);
const saving = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const normalizeText = (value: string) =>
value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.join("\n");
const loadWords = async () => {
try {
const response = await sensitiveWordsApi.get();
text.value = response.data?.text || "";
total.value = Number(response.data?.total || 0);
} catch (error: any) {
ElMessage.error(error?.message || "加载敏感词失败");
}
};
const saveWords = async () => {
saving.value = true;
try {
const response = await sensitiveWordsApi.save({ text: normalizeText(text.value) });
text.value = response.data?.text || normalizeText(text.value);
total.value = Number(response.data?.total || 0);
ElMessage.success(response.message || "敏感词已保存");
} catch (error: any) {
ElMessage.error(error?.message || "保存敏感词失败");
} finally {
saving.value = false;
}
};
const triggerImport = () => {
fileInput.value?.click();
};
const handleImport = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
text.value = normalizeText(await file.text());
total.value = text.value ? text.value.split("\n").length : 0;
ElMessage.success(`已导入 ${total.value} 个敏感词`);
input.value = "";
};
const exportWords = () => {
const content = normalizeText(text.value);
if (!content) {
ElMessage.warning("没有敏感词可导出");
return;
}
const blob = new Blob([`${content}\n`], { type: "text/plain;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "sensitive_words.txt";
link.click();
window.URL.revokeObjectURL(url);
ElMessage.success("敏感词已导出");
};
onMounted(loadWords);
</script>
<style scoped>
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.hidden-input {
display: none;
}
</style>

View File

@@ -8,8 +8,32 @@
<div class="settings-grid">
<PageCard title="运行配置">
<el-form label-position="top">
<el-form-item label="检测线程数">
<el-input-number v-model="threadCount" :min="1" :max="64" />
<el-alert
:title="currentNodeCode ? `支持默认并发和节点单独覆盖;当前后台节点编码:${currentNodeCode}` : '支持默认并发和节点单独覆盖:未单独配置的机器自动走默认线程数。'"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<el-form-item label="默认检测线程数">
<el-input-number v-model="threadCount" :min="1" :max="256" />
</el-form-item>
<el-form-item label="节点独立线程覆盖">
<div class="node-thread-overrides">
<div v-if="!nodeThreadOverrides.length" class="node-thread-empty">
当前没有单独覆盖配置所有节点都会走默认线程数
</div>
<div v-for="(item, index) in nodeThreadOverrides" :key="`${item.node_code}-${index}`" class="node-thread-row">
<el-input v-model="item.node_code" placeholder="节点编码,例如 mainland-worker-01" />
<el-input-number v-model="item.thread_count" :min="1" :max="256" />
<el-button type="danger" plain @click="removeNodeThreadOverride(index)">删除</el-button>
</div>
<div class="node-thread-actions">
<el-button plain @click="addNodeThreadOverride">新增节点覆盖</el-button>
</div>
</div>
</el-form-item>
<el-form-item label="Worker 运行模式">
@@ -26,6 +50,10 @@
<el-form-item label="API 服务名">
<el-input v-model="runtimeSettings.api_service_name" />
</el-form-item>
<el-form-item label="Sync Agent 服务名">
<el-input v-model="runtimeSettings.sync_agent_service_name" />
</el-form-item>
</el-form>
</PageCard>
@@ -54,6 +82,47 @@
</el-form>
</PageCard>
<PageCard title="第三方登录">
<el-form label-position="top">
<el-alert
:title="savedJuziseoSummary"
:type="savedJuziseoAccount ? 'info' : 'warning'"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<el-alert
:title="juziseoStatus.cookie_ready ? `桔子SEO 登录态已就绪:${juziseoStatus.cookie_storage}Cookie ${juziseoStatus.cookie_count || 0} 项` : '桔子SEO 尚未登录旧桌面版已有该功能Web 端现已补齐'"
:type="juziseoStatus.cookie_ready ? 'success' : 'warning'"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<el-form-item label="桔子SEO账号">
<el-input v-model="juziseoForm.email" placeholder="请输入桔子SEO账号" />
</el-form-item>
<el-form-item label="桔子SEO密码">
<el-input v-model="juziseoForm.password" type="password" show-password placeholder="请输入桔子SEO密码" />
</el-form-item>
<el-form-item label="Cookie 路径">
<span class="credential-path">{{ juziseoStatus.cookie_file || "-" }}</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="juziseoLoggingIn" @click="loginJuziseo">登录桔子SEO</el-button>
<el-button @click="loadJuziseoStatus">刷新状态</el-button>
</el-form-item>
<div class="third-party-tip">
聚名登录入口在聚名采集页面桔子SEO 登录入口已按旧桌面版补回到系统设置
当前会同时保存账号和 Cookie方便 Linux 长驻服务直接复用
</div>
</el-form>
</PageCard>
<PageCard title="检测选项" class="span-2">
<div class="detect-items">
<div v-for="(item, index) in detectItems" :key="item.key" class="detect-item">
@@ -111,7 +180,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { settingsApi } from "@/api/modules";
@@ -130,6 +199,11 @@ type BackupRecord = {
backup_reason?: string;
};
type NodeThreadOverrideRow = {
node_code: string;
thread_count: number;
};
const DETECT_LABELS: Record<string, string> = {
detect_register: "检查注册",
detect_baidu_site: "百度 site 查询",
@@ -143,6 +217,8 @@ const DETECT_LABELS: Record<string, string> = {
const loading = ref(true);
const threadCount = ref(2);
const currentNodeCode = ref("");
const nodeThreadOverrides = ref<NodeThreadOverrideRow[]>([]);
const detectItems = ref<DetectItem[]>([]);
const backups = ref<BackupRecord[]>([]);
const proxyConfig = ref<Record<string, any>>({
@@ -155,9 +231,53 @@ const proxyUrlsText = ref("");
const runtimeSettings = ref({
worker_mode: "windows-local",
worker_service_name: "domaincheck-worker",
api_service_name: "domaincheck-api"
api_service_name: "domaincheck-api",
sync_agent_service_name: "domaincheck-sync-agent"
});
const importInput = ref<HTMLInputElement | null>(null);
const juziseoLoggingIn = ref(false);
const juziseoForm = ref({
email: "",
password: ""
});
const juziseoStatus = ref({
cookie_ready: false,
cookie_storage: "missing",
cookie_file: "",
cookie_count: 0
});
const maskAccount = (value: string) => {
const text = String(value || "").trim();
if (!text) return "";
const [name, domain] = text.split("@");
if (!domain) {
if (text.length <= 4) return `${text[0] || ""}***`;
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
const visible = name.length <= 2 ? `${name[0] || ""}*` : `${name.slice(0, 2)}***${name.slice(-1)}`;
return `${visible}@${domain}`;
};
const savedJuziseoAccount = computed(() => maskAccount(juziseoForm.value.email));
const savedJuziseoSummary = computed(() => {
if (!savedJuziseoAccount.value) {
return "当前未保存桔子SEO账号首次登录后会自动持久化到本地 JSON 与 Redis。";
}
return `已保存桔子SEO账号${savedJuziseoAccount.value},运营无需每次重新确认是否已录入。`;
});
const loadCredentials = async () => {
try {
const response = await settingsApi.getCredentials();
juziseoForm.value = {
email: response.data?.juziseo?.email || "",
password: response.data?.juziseo?.password || ""
};
} catch {
ElMessage.error("读取已保存账号失败");
}
};
const normalizeDetectItems = (detectOptions: Record<string, boolean | string[]>) => {
const order = Array.isArray(detectOptions.detect_order) ? [...(detectOptions.detect_order as string[])] : [];
@@ -186,6 +306,27 @@ const moveItem = (index: number, offset: -1 | 1) => {
detectItems.value = items;
};
const normalizeNodeThreadOverrides = (payload: Record<string, number | string>) => {
nodeThreadOverrides.value = Object.entries(payload || {})
.map(([node_code, thread_count]) => ({
node_code: String(node_code || "").trim(),
thread_count: Number(thread_count || 0)
}))
.filter((item) => item.node_code && item.thread_count > 0)
.sort((a, b) => a.node_code.localeCompare(b.node_code));
};
const addNodeThreadOverride = () => {
nodeThreadOverrides.value.push({
node_code: "",
thread_count: threadCount.value || 2
});
};
const removeNodeThreadOverride = (index: number) => {
nodeThreadOverrides.value.splice(index, 1);
};
const loadBackups = async () => {
try {
const response = await settingsApi.getSettingsBackups();
@@ -195,11 +336,27 @@ const loadBackups = async () => {
}
};
const loadJuziseoStatus = async () => {
try {
const response = await settingsApi.getJuziseoStatus();
juziseoStatus.value = {
cookie_ready: Boolean(response.data?.cookie_ready),
cookie_storage: response.data?.cookie_storage || "missing",
cookie_file: response.data?.cookie_file || "",
cookie_count: Number(response.data?.cookie_count || 0)
};
} catch {
ElMessage.error("读取桔子SEO状态失败");
}
};
const loadSettings = async () => {
loading.value = true;
try {
const response = await settingsApi.getSettings();
threadCount.value = response.data.thread_count;
currentNodeCode.value = response.data.current_node_code || "";
normalizeNodeThreadOverrides(response.data.node_thread_counts || {});
normalizeDetectItems(response.data.detect_options || {});
proxyConfig.value = {
@@ -213,7 +370,8 @@ const loadSettings = async () => {
runtimeSettings.value = {
worker_mode: response.data.runtime_settings?.worker_mode || "windows-local",
worker_service_name: response.data.runtime_settings?.worker_service_name || "domaincheck-worker",
api_service_name: response.data.runtime_settings?.api_service_name || "domaincheck-api"
api_service_name: response.data.runtime_settings?.api_service_name || "domaincheck-api",
sync_agent_service_name: response.data.runtime_settings?.sync_agent_service_name || "domaincheck-sync-agent"
};
} catch {
ElMessage.error("读取系统设置失败");
@@ -235,8 +393,19 @@ const buildSettingsPayload = () => {
detectOptions[item.key] = item.enabled;
});
const nodeThreadCounts = nodeThreadOverrides.value.reduce<Record<string, number>>((result, item) => {
const nodeCode = String(item.node_code || "").trim();
const threadCountValue = Number(item.thread_count || 0);
if (!nodeCode || threadCountValue <= 0) {
return result;
}
result[nodeCode] = threadCountValue;
return result;
}, {});
return {
thread_count: threadCount.value,
node_thread_counts: nodeThreadCounts,
detect_options: detectOptions,
proxy_config: {
proxy_enable: Boolean(proxyConfig.value.proxy_enable),
@@ -258,6 +427,32 @@ const saveSettings = async () => {
}
};
const loginJuziseo = async () => {
if (!juziseoForm.value.email.trim() || !juziseoForm.value.password.trim()) {
ElMessage.warning("请输入桔子SEO账号和密码");
return;
}
juziseoLoggingIn.value = true;
try {
const response = await settingsApi.loginJuziseo({
email: juziseoForm.value.email.trim(),
password: juziseoForm.value.password
});
await settingsApi.updateCredentials({
juziseo: {
email: juziseoForm.value.email.trim(),
password: juziseoForm.value.password
}
});
ElMessage.success(response.message || "桔子SEO登录成功");
await loadJuziseoStatus();
} catch (error: any) {
ElMessage.error(error?.message || "桔子SEO登录失败");
} finally {
juziseoLoggingIn.value = false;
}
};
const createBackup = async () => {
try {
const response = await settingsApi.backupSettings();
@@ -314,17 +509,53 @@ const downloadBackup = (filename: string) => {
};
onMounted(async () => {
await Promise.all([loadSettings(), loadBackups()]);
await Promise.all([loadSettings(), loadBackups(), loadJuziseoStatus(), loadCredentials()]);
});
</script>
<style scoped>
.credential-path {
color: #475569;
word-break: break-all;
}
.third-party-tip {
color: #64748b;
line-height: 1.6;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.node-thread-overrides {
display: flex;
flex-direction: column;
gap: 10px;
}
.node-thread-row {
display: grid;
grid-template-columns: minmax(240px, 1fr) 140px auto;
gap: 10px;
align-items: center;
}
.node-thread-actions {
display: flex;
justify-content: flex-start;
}
.node-thread-empty {
padding: 10px 12px;
border: 1px dashed var(--el-border-color);
border-radius: 10px;
color: #64748b;
background: var(--el-fill-color-lighter);
}
.span-2 {
grid-column: 1 / -1;
}
@@ -379,6 +610,12 @@ onMounted(async () => {
flex-wrap: wrap;
}
@media (max-width: 768px) {
.node-thread-row {
grid-template-columns: 1fr;
}
}
.backup-toolbar {
display: flex;
justify-content: space-between;

View File

@@ -10,6 +10,7 @@ export default defineConfig({
}
},
build: {
emptyOutDir: true,
chunkSizeWarningLimit: 800,
rollupOptions: {
output: {