增加域名管理页面
This commit is contained in:
parent
bb2e09c885
commit
e7fa38cf33
45
app/app.py
45
app/app.py
@ -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 退出")
|
||||
|
||||
@ -5,3 +5,5 @@ class DomainStatus(enum.Enum):
|
||||
READY = 1 # 采集结束之后回到这个状态,新添加的默认也是这个状态
|
||||
QUEUEING = 2 # 排队中,已经压入任务队列了,但是还没轮到处理
|
||||
CRAWLING = 3 # 采集中
|
||||
|
||||
PAUSE = 999 # 暂停采集
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
9
fe/.editorconfig
Normal 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
1
fe/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
30
fe/.gitignore
vendored
Normal file
30
fe/.gitignore
vendored
Normal 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
6
fe/.prettierrc.json
Normal 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
8
fe/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
39
fe/README.md
Normal file
39
fe/README.md
Normal 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
75
fe/auto-imports.d.ts
vendored
Normal 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
30
fe/components.d.ts
vendored
Normal 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
1
fe/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
22
fe/eslint.config.ts
Normal file
22
fe/eslint.config.ts
Normal 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
13
fe/index.html
Normal 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
46
fe/package.json
Normal 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
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
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
53
fe/src/App.vue
Normal 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>
|
||||
119
fe/src/components/AddDomainDialog.vue
Normal file
119
fe/src/components/AddDomainDialog.vue
Normal 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) // 默认1天(1440分钟)
|
||||
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>
|
||||
102
fe/src/components/EditDomainDialog.vue
Normal file
102
fe/src/components/EditDomainDialog.vue
Normal 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>
|
||||
124
fe/src/components/ImportDomainDialog.vue
Normal file
124
fe/src/components/ImportDomainDialog.vue
Normal 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) // 默认1天(1440分钟)
|
||||
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
1
fe/src/main.css
Normal file
@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
26
fe/src/main.ts
Normal file
26
fe/src/main.ts
Normal 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
23
fe/src/router/index.ts
Normal 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
12
fe/src/stores/counter.ts
Normal 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
5
fe/src/utils/common.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const convertTimestampToDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
export { convertTimestampToDate }
|
||||
364
fe/src/views/DomainManager.vue
Normal file
364
fe/src/views/DomainManager.vue
Normal 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>
|
||||
7
fe/src/views/UrlManager.vue
Normal file
7
fe/src/views/UrlManager.vue
Normal 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
12
fe/tsconfig.app.json
Normal 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
11
fe/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
fe/tsconfig.node.json
Normal file
19
fe/tsconfig.node.json
Normal 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
50
fe/vite.config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user