This commit is contained in:
Your Name
2026-04-16 13:05:07 +08:00
commit 32efff1670
99 changed files with 9974 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
<template>
<div class="login-shell">
<div class="login-panel">
<div class="hero">
<span class="eyebrow">domainCheck</span>
<h1>轻量 Web 管理后台</h1>
<p>这一版先承接系统设置导入检测控制筛选导出和日志诊断</p>
</div>
<PageCard title="登录" description="当前为第一期可用版本,默认账号密码均为 admin。">
<el-form :model="form" @submit.prevent="submit">
<el-form-item label="账号">
<el-input v-model="form.username" placeholder="请输入账号" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
<el-button type="primary" class="submit" @click="submit">进入后台</el-button>
</el-form>
</PageCard>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { authApi } from "@/api/modules";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const authStore = useAuthStore();
const form = reactive({
username: "admin",
password: "admin"
});
const submit = async () => {
try {
const { data } = await authApi.login(form);
authStore.setToken(data.access_token);
authStore.setUser(data.user);
ElMessage.success("登录成功");
router.push("/dashboard");
} catch (error: any) {
ElMessage.error(error?.message || "登录失败,请确认账号密码和 API 服务状态。");
}
};
</script>
<style scoped lang="scss">
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
.login-panel {
width: min(920px, 100%);
display: grid;
grid-template-columns: 1.15fr 0.95fr;
gap: 24px;
align-items: stretch;
}
.hero {
background: linear-gradient(145deg, #1d4ed8, #0f172a);
color: white;
border-radius: 24px;
padding: 36px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.eyebrow {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
width: fit-content;
margin-bottom: 16px;
}
.hero h1 {
margin: 0;
font-size: 34px;
line-height: 1.15;
}
.hero p {
margin: 16px 0 0;
color: rgba(255, 255, 255, 0.86);
line-height: 1.7;
}
.submit {
width: 100%;
}
@media (max-width: 960px) {
.login-panel {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div class="stack">
<div class="dashboard-toolbar">
<el-button plain :loading="loading" @click="loadOverview">刷新概览</el-button>
<el-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
<span class="updated-at">最近刷新{{ lastUpdatedAt || "暂无" }}</span>
</div>
<el-alert
v-if="loadError"
title="读取概览失败,已保留上一次成功结果。"
type="warning"
:closable="false"
show-icon
/>
<div class="stats-grid">
<PageCard v-for="item in stats" :key="item.label">
<div class="stat-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.note }}</small>
</div>
</PageCard>
</div>
<PageCard title="第一期目标" description="先把运营最需要的控制面搬到 Web再逐步把检测执行和运维能力完全迁到 Linux。">
<el-timeline>
<el-timeline-item timestamp="当前阶段" placement="top">已创建 Web 后台API运行中心和导入/导出/筛选主链路</el-timeline-item>
<el-timeline-item timestamp="下一步" placement="top">继续补 Linux Worker 控制闭环和更完整的运行诊断</el-timeline-item>
<el-timeline-item timestamp="后续阶段" placement="top">把检测端逐步迁为 Linux Worker前后端彻底分离</el-timeline-item>
</el-timeline>
</PageCard>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import PageCard from "@/components/PageCard.vue";
import { dashboardApi } from "@/api/modules";
const loading = ref(false);
const loadError = ref(false);
const autoRefresh = ref(true);
const lastUpdatedAt = ref("");
let timer: number | null = null;
const stats = ref([
{ label: "总域名", value: "--", note: "数据库域名总量" },
{ label: "待检测", value: "--", note: "当前待处理域名" },
{ label: "黑名单", value: "--", note: "命中风险域名" },
{ label: "API", value: "离线", note: "当前 API 运行状态" },
{ label: "Worker", value: "离线", note: "当前 Worker 运行状态" },
{ label: "模式", value: "--", note: "当前 Worker 运行模式" }
]);
const clearRefreshTimer = () => {
if (timer) {
window.clearInterval(timer);
timer = null;
}
};
const ensureRefreshTimer = () => {
clearRefreshTimer();
if (autoRefresh.value) {
timer = window.setInterval(() => {
loadOverview(false);
}, 10000);
}
};
const formatNow = () =>
new Date().toLocaleString("zh-CN", {
hour12: false
});
const loadOverview = async (showError = true) => {
loading.value = true;
try {
const { data } = await dashboardApi.overview();
stats.value = [
{ label: "总域名", value: String(data.domains_total), note: "数据库域名总量" },
{ label: "待检测", value: String(data.pending_total), note: "当前待处理域名" },
{ label: "黑名单", value: String(data.blacklist_total), note: "命中风险域名" },
{ label: "API", value: data.api_status, note: "当前 API 运行状态" },
{ label: "Worker", value: data.worker_status, note: "当前 Worker 运行状态" },
{ label: "模式", value: data.worker_mode, note: "当前 Worker 运行模式" }
];
loadError.value = false;
lastUpdatedAt.value = formatNow();
} catch {
loadError.value = true;
if (showError) {
lastUpdatedAt.value = lastUpdatedAt.value || "读取失败";
}
} finally {
loading.value = false;
}
};
watch(autoRefresh, ensureRefreshTimer);
onMounted(async () => {
await loadOverview();
ensureRefreshTimer();
});
onBeforeUnmount(clearRefreshTimer);
</script>
<style scoped lang="scss">
.stack {
display: grid;
gap: 20px;
}
.dashboard-toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.updated-at {
color: #64748b;
font-size: 13px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-item span {
color: #64748b;
}
.stat-item strong {
font-size: 32px;
color: #0f172a;
}
.stat-item small {
color: #94a3b8;
}
@media (max-width: 960px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<PageCard title="检测控制" description="当前已接入真实状态读取和本地 Worker 启停,后续 Linux 版会沿用同一页接 systemd。">
<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-switch v-model="autoRefresh" inline-prompt active-text="自动刷新" inactive-text="手动" />
</div>
<el-alert
v-if="lastAction.message"
:title="`${lastAction.label}${lastAction.message}`"
:type="lastAction.type"
:closable="false"
show-icon
class="action-alert"
/>
<el-descriptions :column="2" border>
<el-descriptions-item label="Worker 在线">{{ status.worker_online ? "是" : "否" }}</el-descriptions-item>
<el-descriptions-item label="运行模式">{{ status.worker_mode || "windows-local" }}</el-descriptions-item>
<el-descriptions-item label="Worker 服务">{{ status.worker_service_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="API 服务">{{ status.api_service_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="检测进程数">{{ status.worker_process_count }}</el-descriptions-item>
<el-descriptions-item label="线程数">{{ status.thread_count }}</el-descriptions-item>
<el-descriptions-item label="代理启用">{{ 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="最近 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.recent_warning"
type="warning"
:closable="false"
show-icon
:title="status.recent_warning"
style="margin-top: 16px"
/>
<el-alert
v-if="status.worker_runtime_message"
type="info"
:closable="false"
show-icon
:title="status.worker_runtime_message"
style="margin-top: 12px"
/>
<el-row :gutter="12" style="margin-top: 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.blacklisted || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测失败" :value="status.progress.failed || 0" /></el-col>
</el-row>
</PageCard>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { detectApi } from "@/api/modules";
const status = ref({
worker_online: false,
worker_mode: "windows-local",
worker_service_name: "",
api_service_name: "",
worker_process_count: 0,
worker_latest_start_time: "",
worker_runtime_message: "",
thread_count: 0,
proxy_enable: false,
allow_direct: false,
proxy_pool_count: 0,
available_proxy_count: 0,
last_worker_log_time: "",
recent_warning: "",
progress: {
pending: 0,
running: 0,
completed: 0,
failed: 0,
blacklisted: 0
}
});
const autoRefresh = ref(true);
const loading = ref(false);
const actionLoading = ref<"" | "start" | "stop">("");
const lastAction = ref({
label: "",
message: "",
type: "success" as "success" | "warning" | "info" | "error"
});
let timer: number | null = null;
const clearRefreshTimer = () => {
if (timer) {
window.clearInterval(timer);
timer = null;
}
};
const ensureRefreshTimer = () => {
clearRefreshTimer();
if (autoRefresh.value) {
timer = window.setInterval(() => {
loadStatus(false);
}, 10000);
}
};
const loadStatus = async (showError = true) => {
loading.value = true;
try {
const response = await detectApi.status();
status.value = response.data;
} catch {
if (showError) {
ElMessage.error("读取检测状态失败");
}
} finally {
loading.value = false;
}
};
const invoke = async (action: "start" | "stop") => {
actionLoading.value = action;
try {
const response = action === "start" ? await detectApi.start() : await detectApi.stop();
ElMessage.success(response.message);
lastAction.value = {
label: action === "start" ? "检测启动" : "检测停止",
message: response.message,
type: "success"
};
const pollDelaySeconds = Number(response.data?.poll_after_seconds || 2);
if (response.data?.refresh_status) {
window.setTimeout(() => {
loadStatus(false);
}, pollDelaySeconds * 1000);
return;
}
await loadStatus(false);
} catch (error: any) {
const message = error?.message || `${action === "start" ? "启动" : "停止"}操作失败`;
lastAction.value = {
label: action === "start" ? "检测启动" : "检测停止",
message,
type: "error"
};
ElMessage.error(message);
} finally {
actionLoading.value = "";
}
};
watch(autoRefresh, ensureRefreshTimer);
onMounted(async () => {
await loadStatus();
ensureRefreshTimer();
});
onBeforeUnmount(clearRefreshTimer);
</script>
<style scoped>
.actions {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.action-alert {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,309 @@
<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">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button type="warning" :disabled="selectedIds.length === 0" @click="batchDialogVisible = true">
批量更新选中域名
</el-button>
</el-form-item>
</el-form>
<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="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>
<el-dialog v-model="batchDialogVisible" title="批量更新选中域名" width="720px">
<el-form label-width="100px" class="batch-grid">
<el-form-item label="复核状态">
<el-select v-model="batchForm.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="batchForm.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="batchForm.beian_year" clearable />
</el-form-item>
<el-form-item label="快照年份">
<el-input v-model="batchForm.snapshot_years" clearable />
</el-form-item>
<el-form-item label="单位性质">
<el-select v-model="batchForm.company_type" clearable>
<el-option label="企业" value="企业" />
<el-option label="个人" value="个人" />
</el-select>
</el-form-item>
<el-form-item label="首页网址">
<el-input v-model="batchForm.website_url" clearable />
</el-form-item>
<el-form-item label="检测时间">
<el-input v-model="batchForm.detect_time" placeholder="如 2026-04-16 12:30:00" clearable />
</el-form-item>
<el-form-item label="友链数">
<el-input-number v-model="batchForm.backlink_count" :min="0" :max="1000000" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="closeBatchDialog">取消</el-button>
<el-button type="primary" @click="submitBatchUpdate">确认更新</el-button>
</template>
</el-dialog>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { domainsApi } from "@/api/modules";
type Option = { label: string; value: number };
const rows = ref<any[]>([]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const selectedIds = ref<number[]>([]);
const batchDialogVisible = ref(false);
const registerOptions = ref<Option[]>([]);
const detectOptions = ref<Option[]>([]);
const useOptions = ref<Option[]>([]);
const reviewOptions = 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,
beian_year: "",
snapshot_year: "",
website_url: "",
backlink_gt_10: false
});
const defaultBatchForm = () => ({
review_status: undefined as number | undefined,
has_beian: undefined as number | undefined,
beian_year: "",
snapshot_years: "",
company_type: "",
website_url: "",
detect_time: "",
backlink_count: undefined as number | undefined
});
const filters = ref(defaultFilters());
const batchForm = ref(defaultBatchForm());
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,
beian_year: filters.value.beian_year || undefined,
snapshot_year: filters.value.snapshot_year || undefined,
website_url: filters.value.website_url || undefined
};
if (filters.value.backlink_gt_10) {
params.backlink_gt_10 = true;
}
return params;
};
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 || [];
} catch {
ElMessage.error("读取筛选选项失败");
}
};
const loadDomains = async () => {
try {
const response = await domainsApi.list(buildParams());
rows.value = response.data.list;
total.value = response.data.total;
selectedIds.value = [];
} catch {
ElMessage.error("读取域名列表失败");
}
};
const handleSelectionChange = (selection: any[]) => {
selectedIds.value = selection.map((item) => item.id);
};
const changePage = (value: number) => {
page.value = value;
loadDomains();
};
const changePageSize = (value: number) => {
pageSize.value = value;
page.value = 1;
loadDomains();
};
const search = () => {
page.value = 1;
loadDomains();
};
const reset = () => {
filters.value = defaultFilters();
page.value = 1;
pageSize.value = 20;
loadDomains();
};
const closeBatchDialog = () => {
batchDialogVisible.value = false;
batchForm.value = defaultBatchForm();
};
const submitBatchUpdate = async () => {
const updates: Record<string, unknown> = {};
Object.entries(batchForm.value).forEach(([key, value]) => {
if (value !== "" && value !== undefined && value !== null) {
updates[key] = value;
}
});
if (Object.keys(updates).length === 0) {
ElMessage.warning("请至少填写一项更新内容");
return;
}
try {
const response = await domainsApi.batchUpdate({
domain_ids: selectedIds.value,
updates
});
ElMessage.success(response.message);
closeBatchDialog();
await loadDomains();
} catch {
ElMessage.error("批量更新失败");
}
};
onMounted(async () => {
await loadFilterOptions();
await loadDomains();
});
</script>
<style scoped>
.toolbar-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 8px 16px;
margin-bottom: 16px;
}
.toolbar-grid :deep(.el-form-item) {
margin-bottom: 8px;
}
.actions {
align-self: end;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.batch-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 8px 16px;
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<PageCard title="域名导入" description="已改成导入任务模式,适合后续大文件上传和 Linux 后端持续处理。">
<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>
<span class="filename">{{ selectedName || "尚未选择文件" }}</span>
<el-button :loading="uploading" :disabled="!selectedFile" @click="submitUpload">创建导入任务</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>
</div>
<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>
<el-descriptions-item label="导入任务总数">{{ summary.import_task_total }}</el-descriptions-item>
<el-descriptions-item label="运行中导入任务">{{ summary.running_import_tasks }}</el-descriptions-item>
<el-descriptions-item :span="2" label="最近导入时间">{{ summary.last_import_time || "暂无" }}</el-descriptions-item>
</el-descriptions>
<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-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="110" fixed="right">
<template #default="{ row }">
<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-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>
</PageCard>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { importsApi } from "@/api/modules";
const summary = ref({
domains_total: 0,
detect_tasks_total: 0,
last_import_time: "",
import_task_total: 0,
running_import_tasks: 0
});
const tasks = ref<any[]>([]);
const selectedFile = ref<File | null>(null);
const selectedName = ref("");
const lastResult = ref("");
const loading = ref(false);
const uploading = ref(false);
const loadError = ref(false);
const lastUpdatedAt = ref("");
const autoRefresh = ref(true);
let timer: number | null = null;
const clearRefreshTimer = () => {
if (timer) {
window.clearInterval(timer);
timer = null;
}
};
const ensureRefreshTimer = () => {
clearRefreshTimer();
if (autoRefresh.value) {
timer = window.setInterval(() => {
loadAll(false);
}, 8000);
}
};
const loadSummary = async () => {
const response = await importsApi.summary();
summary.value = response.data;
};
const loadTasks = async () => {
const response = await importsApi.tasks();
tasks.value = response.data;
};
const loadAll = async (showError = true) => {
loading.value = true;
try {
await Promise.all([loadSummary(), loadTasks()]);
loadError.value = false;
lastUpdatedAt.value = new Date().toLocaleString("zh-CN", { hour12: false });
} catch {
loadError.value = true;
if (showError) {
ElMessage.error("读取导入状态失败");
}
} finally {
loading.value = false;
}
};
const handleChange = (uploadFile: any) => {
selectedFile.value = uploadFile.raw || null;
selectedName.value = uploadFile.name || "";
};
const submitUpload = async () => {
if (!selectedFile.value) {
ElMessage.warning("请先选择 TXT 文件");
return;
}
const formData = new FormData();
formData.append("file", selectedFile.value);
uploading.value = true;
try {
const response = await importsApi.upload(formData);
lastResult.value = `导入任务已创建:${response.data.filename},状态 ${response.data.status}`;
ElMessage.success("导入任务已创建");
selectedFile.value = null;
selectedName.value = "";
await loadAll(false);
} catch {
ElMessage.error("创建导入任务失败");
} finally {
uploading.value = false;
}
};
const retryTask = async (taskId: string) => {
try {
await importsApi.retry(taskId);
ElMessage.success("导入任务已重新加入队列");
await loadAll(false);
} catch {
ElMessage.error("重试导入任务失败");
}
};
watch(autoRefresh, ensureRefreshTimer);
onMounted(async () => {
await loadAll();
ensureRefreshTimer();
});
onBeforeUnmount(clearRefreshTimer);
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filename {
color: #64748b;
}
.updated-at {
color: #64748b;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,399 @@
<template>
<PageCard title="系统设置" description="保存后会同步写入本地 JSON、Redis以及 Web 后台自己的运行配置。">
<el-skeleton :loading="loading" animated>
<template #template>
<el-skeleton-item variant="rect" style="width: 100%; height: 480px" />
</template>
<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-form-item>
<el-form-item label="Worker 运行模式">
<el-select v-model="runtimeSettings.worker_mode">
<el-option label="Windows 本地" value="windows-local" />
<el-option label="Linux systemd" value="linux-systemd" />
</el-select>
</el-form-item>
<el-form-item label="Worker 服务名">
<el-input v-model="runtimeSettings.worker_service_name" />
</el-form-item>
<el-form-item label="API 服务名">
<el-input v-model="runtimeSettings.api_service_name" />
</el-form-item>
</el-form>
</PageCard>
<PageCard title="代理配置">
<el-form label-position="top">
<el-form-item label="启用代理">
<el-switch v-model="proxyConfig.proxy_enable" />
</el-form-item>
<el-form-item label="允许直连兜底">
<el-switch v-model="proxyConfig.allow_direct" />
</el-form-item>
<el-form-item label="首选代理池链接">
<el-input v-model="proxyConfig.proxy_url" placeholder="优先使用的代理池链接" />
</el-form-item>
<el-form-item label="代理池列表">
<el-input
v-model="proxyUrlsText"
type="textarea"
:rows="8"
placeholder="每行一个代理池链接"
/>
</el-form-item>
</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">
<div class="detect-main">
<span class="order">{{ index + 1 }}</span>
<el-switch v-model="item.enabled" />
<span class="label">{{ item.label }}</span>
</div>
<div class="detect-actions">
<el-button size="small" :disabled="index === 0" @click="moveItem(index, -1)">上移</el-button>
<el-button size="small" :disabled="index === detectItems.length - 1" @click="moveItem(index, 1)">
下移
</el-button>
</div>
</div>
</div>
</PageCard>
<div class="actions span-2">
<el-button type="primary" @click="saveSettings">保存当前配置</el-button>
<el-button @click="loadSettings">重新读取</el-button>
<el-button @click="createBackup">创建配置备份</el-button>
<el-button @click="exportSettingsSnapshot">导出配置快照</el-button>
<el-button @click="triggerImport">导入配置快照</el-button>
<input
ref="importInput"
type="file"
accept="application/json,.json"
class="hidden-input"
@change="handleImport"
/>
</div>
<PageCard title="配置备份记录" class="span-2">
<div class="backup-toolbar">
<span class="backup-tip">导入配置前系统会自动生成一份导入前备份</span>
<el-button size="small" @click="loadBackups">刷新备份列表</el-button>
</div>
<el-table :data="backups" border>
<el-table-column prop="filename" label="文件名" min-width="280" show-overflow-tooltip />
<el-table-column prop="backup_reason" label="备份原因" min-width="120" />
<el-table-column prop="modified_at" label="生成时间" min-width="180" />
<el-table-column prop="size" label="大小" min-width="100" />
<el-table-column label="操作" min-width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="downloadBackup(row.filename)">下载</el-button>
</template>
</el-table-column>
<el-table-column prop="path" label="文件路径" min-width="360" show-overflow-tooltip />
</el-table>
</PageCard>
</div>
</el-skeleton>
</PageCard>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { settingsApi } from "@/api/modules";
type DetectItem = {
key: string;
label: string;
enabled: boolean;
};
type BackupRecord = {
filename: string;
path: string;
size: number;
modified_at: string;
backup_reason?: string;
};
const DETECT_LABELS: Record<string, string> = {
detect_register: "检查注册",
detect_baidu_site: "百度 site 查询",
detect_360_site: "360 site 查询",
detect_chinaz: "站长之家查询",
detect_aizhan: "爱站网查询",
detect_wayback: "时光机检测",
detect_jucha: "聚查查询",
detect_juziseo: "桔子查询"
};
const loading = ref(true);
const threadCount = ref(2);
const detectItems = ref<DetectItem[]>([]);
const backups = ref<BackupRecord[]>([]);
const proxyConfig = ref<Record<string, any>>({
proxy_enable: false,
allow_direct: false,
proxy_url: "",
proxy_urls: []
});
const proxyUrlsText = ref("");
const runtimeSettings = ref({
worker_mode: "windows-local",
worker_service_name: "domaincheck-worker",
api_service_name: "domaincheck-api"
});
const importInput = ref<HTMLInputElement | null>(null);
const normalizeDetectItems = (detectOptions: Record<string, boolean | string[]>) => {
const order = Array.isArray(detectOptions.detect_order) ? [...(detectOptions.detect_order as string[])] : [];
const knownKeys = Object.keys(DETECT_LABELS);
knownKeys.forEach((key) => {
if (!order.includes(key)) {
order.push(key);
}
});
detectItems.value = order
.filter((key) => DETECT_LABELS[key])
.map((key) => ({
key,
label: DETECT_LABELS[key],
enabled: Boolean(detectOptions[key])
}));
};
const moveItem = (index: number, offset: -1 | 1) => {
const targetIndex = index + offset;
if (targetIndex < 0 || targetIndex >= detectItems.value.length) return;
const items = [...detectItems.value];
const [current] = items.splice(index, 1);
items.splice(targetIndex, 0, current);
detectItems.value = items;
};
const loadBackups = async () => {
try {
const response = await settingsApi.getSettingsBackups();
backups.value = response.data || [];
} catch {
ElMessage.error("读取配置备份列表失败");
}
};
const loadSettings = async () => {
loading.value = true;
try {
const response = await settingsApi.getSettings();
threadCount.value = response.data.thread_count;
normalizeDetectItems(response.data.detect_options || {});
proxyConfig.value = {
proxy_enable: Boolean(response.data.proxy_config?.proxy_enable),
allow_direct: Boolean(response.data.proxy_config?.allow_direct),
proxy_url: response.data.proxy_config?.proxy_url || "",
proxy_urls: response.data.proxy_config?.proxy_urls || []
};
proxyUrlsText.value = (proxyConfig.value.proxy_urls || []).join("\n");
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"
};
} catch {
ElMessage.error("读取系统设置失败");
} finally {
loading.value = false;
}
};
const buildSettingsPayload = () => {
const proxyUrls = proxyUrlsText.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
const detectOptions: Record<string, boolean | string[]> = {
detect_order: detectItems.value.map((item) => item.key)
};
detectItems.value.forEach((item) => {
detectOptions[item.key] = item.enabled;
});
return {
thread_count: threadCount.value,
detect_options: detectOptions,
proxy_config: {
proxy_enable: Boolean(proxyConfig.value.proxy_enable),
allow_direct: Boolean(proxyConfig.value.allow_direct),
proxy_url: proxyConfig.value.proxy_url || proxyUrls[0] || "",
proxy_urls: proxyUrls
},
runtime_settings: runtimeSettings.value
};
};
const saveSettings = async () => {
try {
await settingsApi.updateSettings(buildSettingsPayload());
ElMessage.success("系统设置已保存");
await loadSettings();
} catch {
ElMessage.error("保存系统设置失败");
}
};
const createBackup = async () => {
try {
const response = await settingsApi.backupSettings();
ElMessage.success(`配置备份已生成:${response.data.filename}`);
await loadBackups();
} catch {
ElMessage.error("创建配置备份失败");
}
};
const exportSettingsSnapshot = async () => {
try {
const response = await settingsApi.exportSettings();
const blob = new Blob([JSON.stringify(response.data, null, 2)], {
type: "application/json;charset=utf-8"
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `domaincheck_settings_${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
link.click();
window.URL.revokeObjectURL(url);
ElMessage.success("配置快照已导出");
} catch {
ElMessage.error("导出配置快照失败");
}
};
const triggerImport = () => {
importInput.value?.click();
};
const handleImport = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
const payload = JSON.parse(text);
await settingsApi.validateImportSettings(payload);
await settingsApi.importSettings(payload);
ElMessage.success("配置快照已导入,系统已自动创建导入前备份");
await Promise.all([loadSettings(), loadBackups()]);
} catch {
ElMessage.error("导入配置快照失败,请确认文件内容有效且结构正确");
} finally {
input.value = "";
}
};
const downloadBackup = (filename: string) => {
window.open(settingsApi.backupDownloadUrl(filename), "_blank");
};
onMounted(async () => {
await Promise.all([loadSettings(), loadBackups()]);
});
</script>
<style scoped>
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.span-2 {
grid-column: 1 / -1;
}
.detect-items {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.detect-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--el-border-color);
border-radius: 12px;
background: var(--el-fill-color-blank);
}
.detect-main {
display: flex;
align-items: center;
gap: 12px;
}
.order {
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.label {
font-weight: 500;
}
.detect-actions {
display: flex;
gap: 8px;
}
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.backup-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.backup-tip {
color: #64748b;
font-size: 13px;
}
.hidden-input {
display: none;
}
</style>