dev
This commit is contained in:
2
domain-web/.env.example
Normal file
2
domain-web/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_TITLE=domainCheck 管理后台
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8100/api/v1
|
||||
2
domain-web/.env.production.example
Normal file
2
domain-web/.env.production.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_APP_TITLE=domainCheck 管理后台
|
||||
VITE_API_BASE_URL=https://your-domain.example.com/api/v1
|
||||
60
domain-web/README.md
Normal file
60
domain-web/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# domain-web
|
||||
|
||||
`domainCheck` 轻量 Web 管理后台前端。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd domain-web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认开发地址:
|
||||
|
||||
- 前端:`http://127.0.0.1:3200`
|
||||
- 后端:`http://127.0.0.1:8100`
|
||||
|
||||
也可以直接在工作区根目录执行:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\start_domain_web.ps1
|
||||
```
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 登录
|
||||
- 运行状态头部
|
||||
- 概览
|
||||
- 运行中心
|
||||
- 系统设置
|
||||
- 域名导入
|
||||
- 检测控制
|
||||
- 域名筛选
|
||||
- 批量更新
|
||||
- 导出中心
|
||||
- 日志诊断
|
||||
- 诊断包下载
|
||||
|
||||
## 生产环境构建
|
||||
|
||||
参考:
|
||||
|
||||
- `.env.production.example`
|
||||
- `deploy/nginx/domain-web.conf`
|
||||
- `deploy/linux/publish.sh`
|
||||
|
||||
推荐流程:
|
||||
|
||||
```bash
|
||||
cd /opt/domaincheck/domain-web
|
||||
cp .env.production.example .env.production
|
||||
# 修改 VITE_API_BASE_URL
|
||||
bash deploy/linux/publish.sh
|
||||
```
|
||||
|
||||
构建完成后:
|
||||
|
||||
- 前端静态文件位于 `dist`
|
||||
- 可通过 Nginx 对外提供访问
|
||||
- `/api/` 路径建议反代到 `domain-api`
|
||||
24
domain-web/deploy/linux/publish.sh
Normal file
24
domain-web/deploy/linux/publish.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="/opt/domaincheck/domain-web"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
if [ ! -f ".env.production" ]; then
|
||||
echo "缺少 .env.production,请先参考 .env.production.example 创建。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "未找到 npm,请先安装 Node.js。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "安装依赖..."
|
||||
npm install
|
||||
|
||||
echo "构建生产包..."
|
||||
npm run build
|
||||
|
||||
echo "构建完成,dist 目录位于: $PROJECT_ROOT/dist"
|
||||
20
domain-web/deploy/nginx/domain-web.conf
Normal file
20
domain-web/deploy/nginx/domain-web.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.example.com;
|
||||
|
||||
root /opt/domaincheck/domain-web/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8100/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
12
domain-web/index.html
Normal file
12
domain-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>domainCheck 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2194
domain-web/package-lock.json
generated
Normal file
2194
domain-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
domain-web/package.json
Normal file
24
domain-web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "domain-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"element-plus": "^2.8.4",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"sass": "^1.79.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
3
domain-web/src/App.vue
Normal file
3
domain-web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
25
domain-web/src/api/http.ts
Normal file
25
domain-web/src/api/http.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import axios from "axios";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || "http://127.0.0.1:8100/api/v1",
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use((response) => {
|
||||
const payload = response.data;
|
||||
if (payload && typeof payload.code !== "undefined" && payload.code !== 0) {
|
||||
return Promise.reject(payload);
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
export default http;
|
||||
61
domain-web/src/api/modules.ts
Normal file
61
domain-web/src/api/modules.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import http from "./http";
|
||||
|
||||
export const authApi = {
|
||||
login: (payload: { username: string; password: string }) => http.post("/auth/login", payload)
|
||||
};
|
||||
|
||||
export const dashboardApi = {
|
||||
overview: () => http.get("/dashboard/overview")
|
||||
};
|
||||
|
||||
export const runtimeApi = {
|
||||
status: () => http.get("/runtime/status"),
|
||||
preflight: () => http.get("/runtime/preflight"),
|
||||
action: (action: string) => http.post(`/runtime/actions/${action}`)
|
||||
};
|
||||
|
||||
export const settingsApi = {
|
||||
getSettings: () => http.get("/settings"),
|
||||
updateSettings: (payload: Record<string, unknown>) => http.put("/settings", 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"),
|
||||
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)}`
|
||||
};
|
||||
|
||||
export const importsApi = {
|
||||
summary: () => http.get("/imports/summary"),
|
||||
tasks: () => http.get("/imports/tasks"),
|
||||
retry: (taskId: string) => http.post(`/imports/tasks/${taskId}/retry`),
|
||||
upload: (formData: FormData) =>
|
||||
http.post("/imports/upload", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
export const detectApi = {
|
||||
status: () => http.get("/detect/status"),
|
||||
start: () => http.post("/detect/start"),
|
||||
stop: () => http.post("/detect/stop")
|
||||
};
|
||||
|
||||
export const domainsApi = {
|
||||
filters: () => http.get("/domains/filters"),
|
||||
list: (params?: Record<string, unknown>) => http.get("/domains", { params }),
|
||||
batchUpdate: (payload: Record<string, unknown>) => http.post("/domains/batch-update", payload)
|
||||
};
|
||||
|
||||
export const exportsApi = {
|
||||
list: () => http.get("/exports"),
|
||||
run: (payload: Record<string, unknown>) => http.post("/exports/run", payload)
|
||||
};
|
||||
|
||||
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`
|
||||
};
|
||||
49
domain-web/src/components/PageCard.vue
Normal file
49
domain-web/src/components/PageCard.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<header v-if="title || description" class="page-card__header">
|
||||
<div>
|
||||
<h3 v-if="title">{{ title }}</h3>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
</div>
|
||||
<slot name="header-extra" />
|
||||
</header>
|
||||
<div class="page-card__content">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-card {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.page-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-card__header h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.page-card__header p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
259
domain-web/src/layouts/MainLayout.vue
Normal file
259
domain-web/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="layout-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<strong>domainCheck</strong>
|
||||
<span>轻量管理后台</span>
|
||||
</div>
|
||||
<nav class="menu">
|
||||
<RouterLink v-for="item in menuItems" :key="item.path" :to="item.path" class="menu-item">
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="main-area">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>当前保持桌面版不变,Web 管理后台进入可持续运维阶段。</p>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<div class="status-pills">
|
||||
<span class="status-pill" :class="runtime.apiOnline ? 'online' : 'offline'">
|
||||
API {{ runtime.apiOnline ? "在线" : "离线" }}
|
||||
</span>
|
||||
<span class="status-pill" :class="runtime.workerOnline ? 'online' : 'offline'">
|
||||
Worker {{ runtime.workerOnline ? "在线" : "离线" }}
|
||||
</span>
|
||||
<span class="status-pill neutral">{{ runtime.workerMode }}</span>
|
||||
</div>
|
||||
<span class="user">{{ authStore.user.display_name }}</span>
|
||||
<el-button type="primary" plain @click="logout">退出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { runtimeApi } from "@/api/modules";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const runtime = ref({
|
||||
apiOnline: false,
|
||||
workerOnline: false,
|
||||
workerMode: "windows-local"
|
||||
});
|
||||
let timer: number | null = null;
|
||||
|
||||
const menuItems = [
|
||||
{ path: "/dashboard", label: "概览" },
|
||||
{ path: "/runtime", label: "运行中心" },
|
||||
{ path: "/settings", label: "系统设置" },
|
||||
{ path: "/imports", label: "域名导入" },
|
||||
{ path: "/detect", label: "检测控制" },
|
||||
{ path: "/domains", label: "域名筛选" },
|
||||
{ path: "/exports", label: "导出中心" },
|
||||
{ path: "/logs", label: "日志诊断" }
|
||||
];
|
||||
|
||||
const title = computed(() => String(route.meta?.title || "概览"));
|
||||
|
||||
const loadRuntimeSummary = async () => {
|
||||
try {
|
||||
const response = await runtimeApi.status();
|
||||
runtime.value = {
|
||||
apiOnline: Boolean(response.data?.api?.pid),
|
||||
workerOnline: Boolean(response.data?.worker?.running),
|
||||
workerMode: response.data?.worker?.mode || "windows-local"
|
||||
};
|
||||
} catch {
|
||||
runtime.value = {
|
||||
apiOnline: false,
|
||||
workerOnline: false,
|
||||
workerMode: "unknown"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
timer = window.setInterval(() => {
|
||||
loadRuntimeSummary();
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authStore.clearToken();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRuntimeSummary();
|
||||
startPolling();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: linear-gradient(180deg, #0f172a, #172554);
|
||||
color: #e5eefc;
|
||||
padding: 24px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: rgba(229, 238, 252, 0.78);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
color: rgba(229, 238, 252, 0.86);
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover,
|
||||
.menu-item.router-link-active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
padding: 24px 28px 12px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 8px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.status-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-pill.online {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-pill.offline {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.status-pill.neutral {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.user {
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 28px 28px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.layout-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
domain-web/src/main.ts
Normal file
13
domain-web/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ElementPlus from "element-plus";
|
||||
import "element-plus/dist/index.css";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./styles/main.scss";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(ElementPlus);
|
||||
app.mount("#app");
|
||||
47
domain-web/src/router/index.ts
Normal file
47
domain-web/src/router/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("@/views/auth/LoginView.vue"),
|
||||
meta: { public: true, title: "登录" }
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/layouts/MainLayout.vue"),
|
||||
children: [
|
||||
{ path: "", redirect: "/dashboard" },
|
||||
{ path: "dashboard", name: "dashboard", component: () => import("@/views/dashboard/DashboardView.vue"), meta: { title: "概览" } },
|
||||
{ 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: "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: "导出中心" } },
|
||||
{ path: "logs", name: "logs", component: () => import("@/views/logs/LogsView.vue"), meta: { title: "日志诊断" } }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
scrollBehavior: () => ({ top: 0, left: 0 })
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore();
|
||||
const title = import.meta.env.VITE_APP_TITLE || "domainCheck 管理后台";
|
||||
document.title = to.meta?.title ? `${String(to.meta.title)} - ${title}` : title;
|
||||
if (to.meta?.public) {
|
||||
return true;
|
||||
}
|
||||
if (!authStore.token) {
|
||||
return "/login";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
44
domain-web/src/stores/auth.ts
Normal file
44
domain-web/src/stores/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
interface UserInfo {
|
||||
username: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
const defaultUser = (): UserInfo => ({
|
||||
username: "admin",
|
||||
display_name: "管理员"
|
||||
});
|
||||
|
||||
const loadStoredUser = (): UserInfo => {
|
||||
const raw = localStorage.getItem("domain_web_user");
|
||||
if (!raw) return defaultUser();
|
||||
try {
|
||||
return JSON.parse(raw) as UserInfo;
|
||||
} catch {
|
||||
return defaultUser();
|
||||
}
|
||||
};
|
||||
|
||||
export const useAuthStore = defineStore("domain-auth", {
|
||||
state: () => ({
|
||||
token: localStorage.getItem("domain_web_token") || "",
|
||||
user: loadStoredUser() as UserInfo
|
||||
}),
|
||||
actions: {
|
||||
setToken(token: string) {
|
||||
this.token = token;
|
||||
localStorage.setItem("domain_web_token", token);
|
||||
},
|
||||
clearToken() {
|
||||
this.token = "";
|
||||
localStorage.removeItem("domain_web_token");
|
||||
this.user = defaultUser();
|
||||
localStorage.removeItem("domain_web_user");
|
||||
},
|
||||
setUser(user: UserInfo) {
|
||||
this.user = user;
|
||||
localStorage.setItem("domain_web_user", JSON.stringify(user));
|
||||
}
|
||||
}
|
||||
});
|
||||
29
domain-web/src/styles/main.scss
Normal file
29
domain-web/src/styles/main.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #f4f6fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
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;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
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>
|
||||
1
domain-web/src/vite-env.d.ts
vendored
Normal file
1
domain-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
domain-web/tsconfig.json
Normal file
21
domain-web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": false,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
41
domain-web/vite.config.ts
Normal file
41
domain-web/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 800,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes("node_modules")) {
|
||||
return;
|
||||
}
|
||||
if (id.includes("element-plus")) {
|
||||
return "element-plus";
|
||||
}
|
||||
if (id.includes("@element-plus/icons-vue")) {
|
||||
return "element-icons";
|
||||
}
|
||||
if (id.includes("vue") || id.includes("pinia") || id.includes("vue-router")) {
|
||||
return "vue-vendor";
|
||||
}
|
||||
if (id.includes("axios")) {
|
||||
return "http-vendor";
|
||||
}
|
||||
return "vendor";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3200,
|
||||
host: "0.0.0.0"
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user