增加域名管理页面

This commit is contained in:
xhy 2025-04-04 15:20:01 +08:00
parent bb2e09c885
commit e7fa38cf33
36 changed files with 5387 additions and 24 deletions

View File

@ -68,6 +68,9 @@ class MainApp:
parser.add_argument(
"--web", action="store_true", help="启动 web 服务器,启动后将忽略其他选项"
)
parser.add_argument(
"--web-only", action="store_true", help="启动 web 服务器,但是不启动引擎"
)
parser.add_argument(
"-s",
@ -130,17 +133,18 @@ class MainApp:
signal.signal(signal.SIGINT, self.exit_handler)
# 启动所有的 engine
self.crawl_engine = CrawlEngine()
self.crawl_engine.start()
logger.info("crawl 启动")
if not self.args.web_only:
self.crawl_engine = CrawlEngine()
self.crawl_engine.start()
logger.info("crawl 启动")
self.evidence_engine = EvidenceEngine()
self.evidence_engine.start()
logger.info("evidence 启动")
self.evidence_engine = EvidenceEngine()
self.evidence_engine.start()
logger.info("evidence 启动")
self.report_engine = Reporter(["pc", "site", "wap"])
self.report_engine.start()
logger.info("report 启动")
self.report_engine = Reporter(["pc", "site", "wap"])
self.report_engine.start()
logger.info("report 启动")
# 启动 web 页面
web_app = WebApp()
@ -181,7 +185,7 @@ class MainApp:
sys.exit(1)
# 如果指定了 --web 参数,启动 web 服务器,忽略其他选项
if self.args.web:
if self.args.web or self.args.web_only:
logger.info("启动 Web 模式")
return self.start_web()
else:
@ -192,14 +196,17 @@ class MainApp:
# 在这里结束各个 engine
logger.debug("CTRL+C called.")
self.crawl_engine.stop()
self.crawl_engine.cli_wait()
logger.info("crawl 退出")
if self.crawl_engine:
self.crawl_engine.stop()
self.crawl_engine.cli_wait()
logger.info("crawl 退出")
self.evidence_engine.stop()
self.evidence_engine.wait()
logger.info("evidence 退出")
if self.evidence_engine:
self.evidence_engine.stop()
self.evidence_engine.wait()
logger.info("evidence 退出")
self.report_engine.stop()
self.report_engine.wait()
logger.info("report 退出")
if self.report_engine:
self.report_engine.stop()
self.report_engine.wait()
logger.info("report 退出")

View File

@ -5,3 +5,5 @@ class DomainStatus(enum.Enum):
READY = 1 # 采集结束之后回到这个状态,新添加的默认也是这个状态
QUEUEING = 2 # 排队中,已经压入任务队列了,但是还没轮到处理
CRAWLING = 3 # 采集中
PAUSE = 999 # 暂停采集

View File

@ -4,7 +4,7 @@ import time
from DrissionPage.errors import ElementNotFoundError
from loguru import logger
from sqlmodel import Session, select
from sqlmodel import Session, select, or_, and_
from app.config.config import AppCtx
from app.constants.domain import DomainStatus
@ -155,8 +155,18 @@ class CrawlEngine:
with Session(AppCtx.g_db_engine) as session:
stmt = select(DomainModel).where(
DomainModel.latest_crawl_time + DomainModel.crawl_interval * 60 <= current_timestamp
or_(
DomainModel.status == 2, # 条件1: status = 2
and_(
DomainModel.latest_crawl_time + DomainModel.crawl_interval * 60 <= current_timestamp, # 条件2
DomainModel.status == 1 # 条件2
)
)
)
# stmt = select(DomainModel).where(
# DomainModel.latest_crawl_time + DomainModel.crawl_interval * 60 <= current_timestamp
# )
domains = session.exec(stmt).all()
for domain_model in domains:

View File

@ -3,8 +3,9 @@ from typing import Annotated
from fastapi import APIRouter, UploadFile, Form, Query
from app.constants.api_result import ApiCode
from app.constants.domain import DomainStatus
from app.web.request.domain_request import AddDomainRequest, DeleteDomainRequest, UpdateDomainRequest, \
GetDomainListRequest
GetDomainListRequest, CrawlNowRequest, ToggleDomainRequest
from app.web.results import ApiResult
from app.web.service.domain_service import DomainService
@ -100,3 +101,16 @@ def delete_domain(request: DeleteDomainRequest):
# 删除域名
return DomainService.delete_domains(request.domain_ids, request.remove_surl)
@router.post("/v1/crawl")
def crawl_now(request: CrawlNowRequest):
"""立即爬取,实际上是把 status 置为 2"""
result = DomainService.update_domain_status(request.domain_ids, DomainStatus.QUEUEING.value)
return result
@router.post("/v1/toggle")
def toggle_domain(request: ToggleDomainRequest):
"""暂停爬取某个域名"""
return DomainService.update_domain_status(request.domain_ids, DomainStatus.PAUSE.value)

View File

@ -36,3 +36,13 @@ class UpdateDomainRequest(BaseModel):
"""更新域名的请求"""
domain_ids: list[int]
crawl_interval: int
class CrawlNowRequest(BaseModel):
"""立即爬取的请求"""
domain_ids: list[int]
class ToggleDomainRequest(BaseModel):
"""暂停某个域名的爬取"""
domain_ids: list[int]

View File

@ -20,7 +20,7 @@ class DomainService:
"""获取域名列表"""
with Session(AppCtx.g_db_engine) as session:
stmt = select(DomainModel)
stmt_total = select(func.count())
stmt_total = select(func.count(DomainModel.id))
if domain:
stmt = stmt.where(DomainModel.domain.like(f"%{domain}%"))
stmt_total = stmt_total.where(DomainModel.domain.like(f"%{domain}%"))
@ -37,6 +37,7 @@ class DomainService:
# 查询符合筛选条件的总数量
total = session.exec(stmt_total).first()
logger.debug(f"{total=}")
return ApiResult.ok({"total": total, "rows": rows})
except Exception as e:
@ -124,3 +125,17 @@ class DomainService:
logger.error(f"更新域名 interval 失败,错误:{e}")
session.rollback()
return ApiResult.error(ApiCode.DB_ERROR.value, f"更新域名 interval 失败,错误:{e}")
@classmethod
def update_domain_status(cls, domain_ids: list[int], status: int) -> ApiResult[Optional[int]]:
"""批量更新域名的 status 值"""
with Session(AppCtx.g_db_engine) as session:
stmt = update(DomainModel).where(DomainModel.id.in_(domain_ids)).values(status=status)
try:
session.exec(stmt)
session.commit()
return ApiResult.ok(len(domain_ids))
except Exception as e:
logger.error(f"更新域名 status 失败,错误:{e}")
session.rollback()
return ApiResult.error(ApiCode.DB_ERROR.value, f"更新域名 status 失败,错误:{e}")

View File

@ -20,7 +20,7 @@ class ReportURLService:
with Session(AppCtx.g_db_engine) as session:
stmt = select(ReportUrlModel)
total_stmt = select(func.count())
total_stmt = select(func.count(ReportUrlModel.id))
if domain:
stmt = stmt.where(ReportUrlModel.domain.like(f"%{domain}%"))
total_stmt = total_stmt.where(ReportUrlModel.domain.like(f"%{domain}%"))

9
fe/.editorconfig Normal file
View File

@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
fe/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

30
fe/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

6
fe/.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

8
fe/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

39
fe/README.md Normal file
View File

@ -0,0 +1,39 @@
# fe
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
### Lint with [ESLint](https://eslint.org/)
```sh
pnpm lint
```

75
fe/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,75 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

30
fe/components.d.ts vendored Normal file
View File

@ -0,0 +1,30 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AddDomainDialog: typeof import('./src/components/AddDomainDialog.vue')['default']
EditDomainDialog: typeof import('./src/components/EditDomainDialog.vue')['default']
ImportDomainDialog: typeof import('./src/components/ImportDomainDialog.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NModal: typeof import('naive-ui')['NModal']
NPagination: typeof import('naive-ui')['NPagination']
NTag: typeof import('naive-ui')['NTag']
NTooltip: typeof import('naive-ui')['NTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

1
fe/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

22
fe/eslint.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

13
fe/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

46
fe/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "fe",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.1",
"axios": "^1.8.4",
"pinia": "^3.0.1",
"tailwindcss": "^4.1.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.13.14",
"@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.22.0",
"eslint-plugin-vue": "~10.0.0",
"jiti": "^2.4.2",
"naive-ui": "^2.41.0",
"npm-run-all2": "^7.0.2",
"prettier": "3.5.3",
"typescript": "~5.8.0",
"unplugin-auto-import": "^19.1.2",
"unplugin-vue-components": "^28.4.1",
"vfonts": "^0.0.3",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
}
}

4097
fe/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
fe/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

53
fe/src/App.vue Normal file
View File

@ -0,0 +1,53 @@
<script setup lang="tsx">
import { RouterLink, RouterView } from 'vue-router'
import { NLayout, NMessageProvider, NConfigProvider, NLayoutHeader, NLayoutSider, NMenu } from 'naive-ui'
import { type MenuOption } from 'naive-ui'
import { List, ColorWand } from '@vicons/ionicons5'
// TODO
const handleUpdateValue = (v: string) => {
console.log('handleUpdateValue: v')
}
//
const menuOpts: MenuOption[] = [
{
label: () => <RouterLink to={{ name: 'domain-manager' }}>域名管理</RouterLink>,
key: 'rule-manager',
icon: () => <List />,
},
{
label: () => <RouterLink to={{ name: 'url-manager' }}>URL 管理</RouterLink>,
key: 'rule-sniff',
icon: () => <ColorWand />,
},
]
</script>
<template>
<n-config-provider>
<n-dialog-provider>
<n-message-provider>
<n-layout class="h-screen">
<!-- header -->
<n-layout-header class="h-16 p-5" style="background-color: oklch(62.3% 0.214 259.815); color: white" bordered>
<span class="font-bold text-xl">BAIDU Reporter</span>
</n-layout-header>
<n-layout position="absolute" has-sider style="top: 64px">
<!-- sidebar -->
<n-layout-sider width="8%" show-trigger show-collapsed-content :collapsed-width="64"
content-style="padding: 8px; text-align:center;" :native-scrollbar="false" bordered collapse-mode="width">
<n-menu :indent="24" :options="menuOpts" @update:value="handleUpdateValue" />
</n-layout-sider>
<!-- content -->
<n-layout content-style="padding: 16px;" :native-scrollbar="false">
<router-view />
</n-layout>
</n-layout>
</n-layout>
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
</template>

View File

@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref, inject } from 'vue'
import { NModal, NForm, NFormItem, NInputNumber, NCheckbox, NInput, NButton, NButtonGroup, useMessage } from 'naive-ui'
import type { FormRules } from 'naive-ui'
import type { AxiosInstance } from 'axios'
const model = defineModel<boolean>("show", { required: true })
const emit = defineEmits(['success'])
const axios = inject("axios") as AxiosInstance
const message = useMessage()
const interval = ref<number>(1440) // 11440
const startImmediately = ref(true)
const domains = ref('')
const formRef = ref<InstanceType<typeof NForm> | null>(null)
const rules: FormRules = {
interval: [
{
required: true,
type: "number",
message: '请输入采集间隔',
trigger: ['blur', 'change']
},
{
type: 'number',
min: 1,
message: '采集间隔必须大于0',
trigger: ['blur', 'change'],
}
],
domains: [
{
required: true,
message: '请输入域名',
trigger: ['blur', 'change']
},
{
validator: (_, value: string) => {
if (!value.trim()) return true // required
const domainList = value.split(/[\n,]/).map(d => d.trim()).filter(d => d)
if (domainList.length === 0) return false
return true
},
message: '域名格式不正确',
trigger: ['blur', 'change']
}
]
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
} catch (errors) {
return
}
try {
//
const domainList = domains.value
.split(/[\n,]/) //
.map(domain => domain.trim()) //
.filter(domain => domain) //
const response = await axios.post('/api/domain/v1/add', {
domains: domainList,
crawl_interval: interval.value,
crawl_now: startImmediately.value
})
if (response.data.code === 20000) {
message.success('添加成功')
emit('success')
handleClose()
} else {
message.error(`添加失败:${response.data.message}`)
}
} catch (error) {
console.error('添加失败', error)
message.error(`添加失败:${error}`)
}
}
const handleClose = () => {
//
interval.value = 1440
startImmediately.value = true
domains.value = ''
formRef.value?.restoreValidation()
model.value = false
}
</script>
<template>
<n-modal v-model:show="model" preset="card" title="手动添加" :mask-closable="false" style="width: 600px">
<n-form size="small" ref="formRef" :model="{ interval, domains }" :rules="rules">
<n-form-item path="interval" label="采集间隔(分钟)">
<n-input-number v-model:value="interval" :min="1" />
</n-form-item>
<n-form-item path="domains" label="域名列表">
<n-input v-model:value="domains" type="textarea" :rows="10" placeholder="请输入域名,支持换行或英文逗号分隔" />
</n-form-item>
<n-form-item label="采集选项">
<n-checkbox v-model:checked="startImmediately">
立即开始采集
</n-checkbox>
</n-form-item>
</n-form>
<template #action>
<n-button-group size="small">
<n-button type="primary" @click="handleConfirm">确认</n-button>
<n-button @click="handleClose">关闭</n-button>
</n-button-group>
</template>
</n-modal>
</template>

View File

@ -0,0 +1,102 @@
<script setup lang="ts">
import { ref, defineProps, defineEmits, inject, defineModel, computed, watch } from 'vue';
import { useMessage } from 'naive-ui';
import type { AxiosInstance } from 'axios';
import type { DataTableRowKey } from 'naive-ui';
const show = defineModel<boolean>('show');
const props = defineProps<{
// ID
domainIds: DataTableRowKey[] | null;
}>();
const emit = defineEmits<{
(e: 'success'): void;
(e: 'close'): void;
}>();
const axios = inject('axios') as AxiosInstance;
const message = useMessage();
//
const crawlInterval = ref<number | null>(null);
const loading = ref(false);
//
const dialogTitle = computed(() => {
const count = props.domainIds?.length || 0;
if (count > 1) {
return `批量修改 ${count} 个域名的采集间隔`;
} else if (count === 1) {
return '修改域名采集间隔';
}
return '修改采集间隔'; //
});
// crawlInterval
watch(show, (newShow) => {
if (newShow) {
crawlInterval.value = null; //
}
});
const handleSubmit = async () => {
if (crawlInterval.value === null || crawlInterval.value < 1) {
message.error('请输入有效的采集间隔大于等于1的整数');
return;
}
if (!props.domainIds || props.domainIds.length === 0) {
message.error('没有指定要修改的域名');
return;
}
try {
loading.value = true;
const response = (await axios.post('/api/domain/v1/update', {
domain_ids: props.domainIds, // 使 ID
crawl_interval: crawlInterval.value
})).data;
if (response.code !== 20000) {
message.error(`更新失败:${response.message}`);
return;
}
message.success('更新成功');
emit('success');
show.value = false; //
} catch (error) {
console.error('更新失败', error);
message.error(`更新失败:${error}`);
} finally {
loading.value = false;
}
};
const handleClose = () => {
show.value = false;
emit('close'); // close
};
</script>
<template>
<n-modal v-model:show="show" preset="dialog" :title="dialogTitle" :loading="loading" @close="handleClose">
<!-- 批量编辑提示 -->
<div v-if="(domainIds?.length || 0) > 1" class="mb-4 text-orange-500">
你正在批量修改 {{ domainIds?.length }} 个域名的采集间隔
</div>
<n-form>
<n-form-item label="采集间隔(分钟)" required>
<n-input-number v-model:value="crawlInterval" :min="1" :step="1" style="width: 100%"
placeholder="请输入采集间隔" />
</n-form-item>
</n-form>
<template #action>
<n-button @click="handleClose">取消</n-button>
<n-button type="primary" @click="handleSubmit" :loading="loading">确定</n-button>
</template>
</n-modal>
</template>

View File

@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, inject } from 'vue'
import { NModal, NForm, NFormItem, NInputNumber, NCheckbox, NUpload, NButton, NButtonGroup, useMessage } from 'naive-ui'
import type { FormRules } from 'naive-ui'
import type { AxiosInstance } from 'axios'
import type { UploadFileInfo } from 'naive-ui'
const model = defineModel<boolean>("show", { required: true })
const emit = defineEmits(['success'])
const axios = inject("axios") as AxiosInstance
const message = useMessage()
const interval = ref(1440) // 11440
const startImmediately = ref(true)
const fileList = ref<UploadFileInfo[]>([])
const formRef = ref<InstanceType<typeof NForm> | null>(null)
const rules: FormRules = {
interval: [
{
type: "number",
required: true,
message: '请输入采集间隔',
trigger: ['blur', 'change']
},
{
type: 'number',
min: 1,
message: '采集间隔必须大于0',
trigger: ['blur', 'change']
}
],
fileList: [
{
type: "array",
required: true,
message: '请选择文件',
trigger: ['change']
},
{
validator: (_, value: UploadFileInfo[]) => {
if (!value || value.length === 0) return false
return !!value[0].file
},
message: '文件无效',
trigger: ['change']
}
]
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
} catch (errors) {
return
}
try {
const formData = new FormData()
const file = fileList.value[0].file
if (!file) {
message.error('文件无效')
return
}
formData.append('file', file)
formData.append('crawl_interval', interval.value.toString())
formData.append('crawl_now', startImmediately.value.toString())
const response = await axios.post('/api/domain/v1/import', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data.code === 20000) {
message.success('导入成功')
emit('success')
handleClose()
} else {
message.error(`导入失败:${response.data.message}`)
}
} catch (error) {
console.error('导入失败', error)
message.error(`导入失败:${error}`)
}
}
const handleClose = () => {
//
interval.value = 1440
startImmediately.value = true
fileList.value = []
formRef.value?.restoreValidation()
model.value = false
}
</script>
<template>
<n-modal v-model:show="model" preset="card" title="通过文件导入" :mask-closable="false" style="width: 600px">
<n-form size="small" ref="formRef" :model="{ interval, fileList }" :rules="rules" label-placement="left"
label-width="200">
<n-form-item path="interval" label="采集间隔(分钟)">
<n-input-number v-model:value="interval" :min="1" />
</n-form-item>
<n-form-item path="fileList" label="选择文件">
<n-upload v-model:file-list="fileList" :max="1" accept=".txt,.csv">
<n-button>选择文件</n-button>
</n-upload>
</n-form-item>
<n-form-item label="采集选项">
<n-checkbox v-model:checked="startImmediately">
立即开始采集
</n-checkbox>
</n-form-item>
</n-form>
<template #action>
<n-button-group size="small">
<n-button type="primary" @click="handleConfirm">确认</n-button>
<n-button @click="handleClose">关闭</n-button>
</n-button-group>
</template>
</n-modal>
</template>

1
fe/src/main.css Normal file
View File

@ -0,0 +1 @@
@import 'tailwindcss';

26
fe/src/main.ts Normal file
View File

@ -0,0 +1,26 @@
import './main.css'
import {createApp} from 'vue'
import {createPinia} from 'pinia'
import App from './App.vue'
import router from './router'
import axios from "axios"
import "vfonts/Lato.css"
import "vfonts/IBMPlexMono.css"
const app = createApp(App)
app.use(createPinia())
app.use(router)
const axiosInstance = axios.create({
withCredentials: true,
timeout: 9000,
timeoutErrorMessage: "E_NETWORK_TIMEOUT",
})
app.provide('axios', axiosInstance)
app.mount('#app')

23
fe/src/router/index.ts Normal file
View File

@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/domain',
},
{
path: '/domain',
name: 'domain-manager',
component: () => import('../views/DomainManager.vue'),
},
{
path: '/url',
name: 'url-manager',
component: () => import('../views/UrlManager.vue'),
},
],
})
export default router

12
fe/src/stores/counter.ts Normal file
View File

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

5
fe/src/utils/common.ts Normal file
View File

@ -0,0 +1,5 @@
const convertTimestampToDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString()
}
export { convertTimestampToDate }

View File

@ -0,0 +1,364 @@
<script setup lang="tsx">
import { onMounted, inject, ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { AxiosInstance } from "axios";
import { type DataTableColumn, type DataTableRowKey, useMessage, useDialog } from "naive-ui";
import ImportDomainDialog from "../components/ImportDomainDialog.vue";
import AddDomainDialog from "../components/AddDomainDialog.vue";
import EditDomainDialog from "../components/EditDomainDialog.vue";
import { convertTimestampToDate } from "@/utils/common";
const axios = inject("axios") as AxiosInstance;
const message = useMessage();
const dialog = useDialog();
const route = useRoute();
const router = useRouter();
const showImportDialog = ref(false);
const showAddDialog = ref(false);
const showEditDialog = ref(false);
// ID
const editingDomainIds = ref<DataTableRowKey[] | null>(null);
// Key
const checkedRowKeys = ref<DataTableRowKey[]>([]);
//
const pagination = ref({
page: 1,
pageSize: 50,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100, 200, 500, 1000],
onChange: (page: number) => {
pagination.value.page = page;
updateUrlParams();
getDomainList();
},
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize;
pagination.value.page = 1;
updateUrlParams();
getDomainList();
}
});
// URL
const updateUrlParams = () => {
router.push({
query: {
page: pagination.value.page,
size: pagination.value.pageSize
}
});
};
//
const initPagination = () => {
const page = Number(route.query.page) || 1;
const size = Number(route.query.size) || 50;
pagination.value.page = page;
pagination.value.pageSize = size;
};
const columns: Array<DataTableColumn> = [
{
type: 'selection',
},
{
title: '#',
key: 'id',
},
{
title: '域名',
key: 'domain',
},
{
title: '状态',
key: 'status',
render: (row) => {
let statusText = '';
let statusType = '';
switch (row.status) {
case 1:
statusText = 'READY';
statusType = 'success';
break;
case 2:
statusText = 'QUENE';
statusType = 'warning';
break;
case 3:
statusText = 'CRAWLING';
statusType = 'info';
break;
case 999:
statusText = "PAUSE";
statusType = "error";
break;
default:
statusText = 'UNKNOWN';
statusType = 'error';
}
return <n-tag type={statusType}>{statusText}</n-tag>;
},
},
{
title: '采集间隔 (分钟)',
key: 'crawl_interval',
render: (row) => (
<n-tooltip>
{{
trigger: () => <span>{row.crawl_interval}</span>,
default: () => `${row.crawl_interval as number / 60 / 24}`
}}
</n-tooltip>
),
},
{
title: "最近采集时间",
key: "latest_crawl_time",
render: (row) => convertTimestampToDate(row.latest_crawl_time as number),
},
{
title: "操作",
key: "action",
render: (row) => (
<div class="flex gap-2">
<n-button size="small" type="primary" onClick={() => handleEdit(row)}>编辑</n-button>
<n-button size="small" type="info" onClick={() => handleSingleCrawl(row)}>立即采集</n-button>
<n-button size="small" type="error" onClick={() => handleDelete(row)}>删除</n-button>
</div>
)
}
]
const domains = ref([])
//
const hasSelectedRows = computed(() => checkedRowKeys.value.length > 0);
//
const handleCheck = (rowKeys: DataTableRowKey[]) => {
checkedRowKeys.value = rowKeys;
}
/** 批量删除域名 */
const handleBatchDelete = () => {
if (!hasSelectedRows.value) {
message.warning('请至少选择一个域名');
return;
}
const removeSurl = ref(false)
dialog.warning({
title: '确认批量删除',
content: () => (
<div>
<div class="mb-2">确定要删除选中的 {checkedRowKeys.value.length} 个域名吗</div>
<n-checkbox v-model:checked={removeSurl.value}>
同时删除所有关联的 SURL
</n-checkbox>
</div>
),
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
const response = (await axios.post('/api/domain/v1/delete', {
domain_ids: checkedRowKeys.value,
remove_surl: removeSurl.value
})).data
if (response.code !== 20000) {
message.error(`批量删除域名失败,错误:${response.message}`)
return
}
message.success('批量删除成功')
checkedRowKeys.value = []; //
getDomainList()
} catch (error) {
console.error('批量删除域名失败', error)
message.error(`批量删除域名失败,错误:${error}`)
}
}
})
}
/** 批量修改采集间隔 */
const handleBatchEdit = () => {
if (!hasSelectedRows.value) {
message.warning('请至少选择一个域名');
return;
}
editingDomainIds.value = [...checkedRowKeys.value]; // ID ID
showEditDialog.value = true;
}
/** 批量立即采集 */
const handleBatchCrawl = async () => {
if (!hasSelectedRows.value) {
message.warning('请至少选择一个域名');
return;
}
try {
// TODO: /api/domain/v1/crawl
const response = (await axios.post('/api/domain/v1/crawl', {
domain_ids: checkedRowKeys.value
})).data;
if (response.code !== 20000) {
message.error(`批量触发采集失败:${response.message}`);
return;
}
message.success('批量触发采集成功,已加入队列');
checkedRowKeys.value = []; //
getDomainList(); //
} catch (error) {
console.error('批量触发采集失败', error);
message.error(`批量触发采集失败:${error}`);
}
}
/** 单个立即采集 */
const handleSingleCrawl = async (row: any) => {
try {
// ID
const response = (await axios.post('/api/domain/v1/crawl', {
domain_ids: [row.id]
})).data;
if (response.code !== 20000) {
message.error(`触发采集失败:${response.message}`);
return;
}
message.success(`域名 ${row.domain} 已加入采集队列`);
getDomainList(); //
} catch (error) {
console.error('触发采集失败', error);
message.error(`触发采集失败:${error}`);
}
}
/** 删除域名 */
const handleDelete = async (row: any) => {
const removeSurl = ref(false)
dialog.warning({
title: '确认删除',
content: () => (
<div>
<div class="mb-2">确定要删除域名 {row.domain} </div>
<n-checkbox v-model:checked={removeSurl.value}>
同时删除所有关联的 SURL
</n-checkbox>
</div>
),
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
try {
const response = (await axios.post('/api/domain/v1/delete', {
domain_ids: [row.id],
remove_surl: removeSurl.value
})).data
if (response.code !== 20000) {
message.error(`删除域名失败,错误:${response.message}`)
return
}
message.success('删除成功')
// checkedRowKeys
const index = checkedRowKeys.value.findIndex(key => key === row.id);
if (index > -1) {
checkedRowKeys.value.splice(index, 1);
}
getDomainList()
} catch (error) {
console.error('删除域名失败', error)
message.error(`删除域名失败,错误:${error}`)
}
}
})
}
/** 获取域名列表 */
const getDomainList = async () => {
try {
const response = (await axios.get('/api/domain/v1/list', {
params: {
page: pagination.value.page,
size: pagination.value.pageSize
}
})).data;
if (response.code !== 20000) {
message.error(`获取域名列表失败,错误:${response.message}`);
return;
}
domains.value = response.data.rows;
pagination.value.itemCount = response.data.total;
} catch (error) {
console.error('获取域名列表失败', error);
message.error(`获取域名列表失败,错误:${error}`);
}
}
const handleImportSuccess = () => {
getDomainList()
}
const handleAddSuccess = () => {
getDomainList()
}
/** 编辑域名 */
const handleEdit = (row: any) => {
editingDomainIds.value = [row.id]; // ID ID
showEditDialog.value = true;
}
const handleEditSuccess = () => {
getDomainList();
const editedCount = editingDomainIds.value?.length || 0;
editingDomainIds.value = null; // ID
//
if (editedCount > 1) {
checkedRowKeys.value = [];
}
}
onMounted(async () => {
initPagination();
await getDomainList()
})
</script>
<template>
<div class="text-2xl pb-4">域名管理</div>
<div class="flex gap-2 mb-4">
<n-button type="primary" @click="showImportDialog = true">通过文件导入</n-button>
<n-button type="primary" @click="showAddDialog = true">手动添加</n-button>
<n-button type="error" @click="handleBatchDelete" :disabled="!hasSelectedRows">批量删除</n-button>
<n-button type="warning" @click="handleBatchEdit" :disabled="!hasSelectedRows">修改间隔</n-button>
<n-button type="info" @click="handleBatchCrawl" :disabled="!hasSelectedRows">立即采集</n-button>
</div>
<n-data-table :columns="columns" :data="domains" :row-key="(row: any) => row.id" :checked-row-keys="checkedRowKeys"
@update:checked-row-keys="handleCheck" size="small" />
<div class="flex justify-center mt-4">
<n-pagination v-model:page="pagination.page" :page-size="pagination.pageSize" :item-count="pagination.itemCount"
:show-size-picker="pagination.showSizePicker" :page-sizes="pagination.pageSizes"
@update:page-size="pagination.onUpdatePageSize" @update:page="pagination.onChange" />
</div>
<ImportDomainDialog v-model:show="showImportDialog" @success="handleImportSuccess" />
<AddDomainDialog v-model:show="showAddDialog" @success="handleAddSuccess" />
<EditDomainDialog v-model:show="showEditDialog" :domain-ids="editingDomainIds" @success="handleEditSuccess"
@close="editingDomainIds = null" />
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<p class="text-2xl">DomainManager</p>
</template>
<style scoped></style>

12
fe/tsconfig.app.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
fe/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
fe/tsconfig.node.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

50
fe/vite.config.ts Normal file
View File

@ -0,0 +1,50 @@
import {fileURLToPath, URL} from 'node:url'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import {NaiveUiResolver} from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
vue(),
vueJsx(),
vueDevTools(),
AutoImport({
imports: [
'vue',
{
'naive-ui': [
'useDialog',
'useMessage',
'useNotification',
'useLoadingBar'
]
}
]
}),
Components({
resolvers: [NaiveUiResolver()]
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})