dev
This commit is contained in:
109
domain-web/src/views/auth/LoginView.vue
Normal file
109
domain-web/src/views/auth/LoginView.vue
Normal 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>
|
||||
160
domain-web/src/views/dashboard/DashboardView.vue
Normal file
160
domain-web/src/views/dashboard/DashboardView.vue
Normal 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>
|
||||
183
domain-web/src/views/detect/DetectView.vue
Normal file
183
domain-web/src/views/detect/DetectView.vue
Normal 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>
|
||||
309
domain-web/src/views/domains/DomainsView.vue
Normal file
309
domain-web/src/views/domains/DomainsView.vue
Normal 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>
|
||||
189
domain-web/src/views/imports/ImportsView.vue
Normal file
189
domain-web/src/views/imports/ImportsView.vue
Normal 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>
|
||||
399
domain-web/src/views/settings/SettingsView.vue
Normal file
399
domain-web/src/views/settings/SettingsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user