增加域名管理页面
This commit is contained in:
parent
bb2e09c885
commit
e7fa38cf33
@ -68,6 +68,9 @@ class MainApp:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--web", action="store_true", help="启动 web 服务器,启动后将忽略其他选项"
|
"--web", action="store_true", help="启动 web 服务器,启动后将忽略其他选项"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--web-only", action="store_true", help="启动 web 服务器,但是不启动引擎"
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-s",
|
"-s",
|
||||||
@ -130,6 +133,7 @@ class MainApp:
|
|||||||
signal.signal(signal.SIGINT, self.exit_handler)
|
signal.signal(signal.SIGINT, self.exit_handler)
|
||||||
|
|
||||||
# 启动所有的 engine
|
# 启动所有的 engine
|
||||||
|
if not self.args.web_only:
|
||||||
self.crawl_engine = CrawlEngine()
|
self.crawl_engine = CrawlEngine()
|
||||||
self.crawl_engine.start()
|
self.crawl_engine.start()
|
||||||
logger.info("crawl 启动")
|
logger.info("crawl 启动")
|
||||||
@ -181,7 +185,7 @@ class MainApp:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# 如果指定了 --web 参数,启动 web 服务器,忽略其他选项
|
# 如果指定了 --web 参数,启动 web 服务器,忽略其他选项
|
||||||
if self.args.web:
|
if self.args.web or self.args.web_only:
|
||||||
logger.info("启动 Web 模式")
|
logger.info("启动 Web 模式")
|
||||||
return self.start_web()
|
return self.start_web()
|
||||||
else:
|
else:
|
||||||
@ -192,14 +196,17 @@ class MainApp:
|
|||||||
# 在这里结束各个 engine
|
# 在这里结束各个 engine
|
||||||
logger.debug("CTRL+C called.")
|
logger.debug("CTRL+C called.")
|
||||||
|
|
||||||
|
if self.crawl_engine:
|
||||||
self.crawl_engine.stop()
|
self.crawl_engine.stop()
|
||||||
self.crawl_engine.cli_wait()
|
self.crawl_engine.cli_wait()
|
||||||
logger.info("crawl 退出")
|
logger.info("crawl 退出")
|
||||||
|
|
||||||
|
if self.evidence_engine:
|
||||||
self.evidence_engine.stop()
|
self.evidence_engine.stop()
|
||||||
self.evidence_engine.wait()
|
self.evidence_engine.wait()
|
||||||
logger.info("evidence 退出")
|
logger.info("evidence 退出")
|
||||||
|
|
||||||
|
if self.report_engine:
|
||||||
self.report_engine.stop()
|
self.report_engine.stop()
|
||||||
self.report_engine.wait()
|
self.report_engine.wait()
|
||||||
logger.info("report 退出")
|
logger.info("report 退出")
|
||||||
|
|||||||
@ -5,3 +5,5 @@ class DomainStatus(enum.Enum):
|
|||||||
READY = 1 # 采集结束之后回到这个状态,新添加的默认也是这个状态
|
READY = 1 # 采集结束之后回到这个状态,新添加的默认也是这个状态
|
||||||
QUEUEING = 2 # 排队中,已经压入任务队列了,但是还没轮到处理
|
QUEUEING = 2 # 排队中,已经压入任务队列了,但是还没轮到处理
|
||||||
CRAWLING = 3 # 采集中
|
CRAWLING = 3 # 采集中
|
||||||
|
|
||||||
|
PAUSE = 999 # 暂停采集
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import time
|
|||||||
|
|
||||||
from DrissionPage.errors import ElementNotFoundError
|
from DrissionPage.errors import ElementNotFoundError
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select, or_, and_
|
||||||
|
|
||||||
from app.config.config import AppCtx
|
from app.config.config import AppCtx
|
||||||
from app.constants.domain import DomainStatus
|
from app.constants.domain import DomainStatus
|
||||||
@ -155,8 +155,18 @@ class CrawlEngine:
|
|||||||
with Session(AppCtx.g_db_engine) as session:
|
with Session(AppCtx.g_db_engine) as session:
|
||||||
|
|
||||||
stmt = select(DomainModel).where(
|
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()
|
domains = session.exec(stmt).all()
|
||||||
|
|
||||||
for domain_model in domains:
|
for domain_model in domains:
|
||||||
|
|||||||
@ -3,8 +3,9 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, UploadFile, Form, Query
|
from fastapi import APIRouter, UploadFile, Form, Query
|
||||||
|
|
||||||
from app.constants.api_result import ApiCode
|
from app.constants.api_result import ApiCode
|
||||||
|
from app.constants.domain import DomainStatus
|
||||||
from app.web.request.domain_request import AddDomainRequest, DeleteDomainRequest, UpdateDomainRequest, \
|
from app.web.request.domain_request import AddDomainRequest, DeleteDomainRequest, UpdateDomainRequest, \
|
||||||
GetDomainListRequest
|
GetDomainListRequest, CrawlNowRequest, ToggleDomainRequest
|
||||||
from app.web.results import ApiResult
|
from app.web.results import ApiResult
|
||||||
from app.web.service.domain_service import DomainService
|
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)
|
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]
|
domain_ids: list[int]
|
||||||
crawl_interval: 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:
|
with Session(AppCtx.g_db_engine) as session:
|
||||||
stmt = select(DomainModel)
|
stmt = select(DomainModel)
|
||||||
stmt_total = select(func.count())
|
stmt_total = select(func.count(DomainModel.id))
|
||||||
if domain:
|
if domain:
|
||||||
stmt = stmt.where(DomainModel.domain.like(f"%{domain}%"))
|
stmt = stmt.where(DomainModel.domain.like(f"%{domain}%"))
|
||||||
stmt_total = stmt_total.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()
|
total = session.exec(stmt_total).first()
|
||||||
|
logger.debug(f"{total=}")
|
||||||
|
|
||||||
return ApiResult.ok({"total": total, "rows": rows})
|
return ApiResult.ok({"total": total, "rows": rows})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -124,3 +125,17 @@ class DomainService:
|
|||||||
logger.error(f"更新域名 interval 失败,错误:{e}")
|
logger.error(f"更新域名 interval 失败,错误:{e}")
|
||||||
session.rollback()
|
session.rollback()
|
||||||
return ApiResult.error(ApiCode.DB_ERROR.value, f"更新域名 interval 失败,错误:{e}")
|
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:
|
with Session(AppCtx.g_db_engine) as session:
|
||||||
stmt = select(ReportUrlModel)
|
stmt = select(ReportUrlModel)
|
||||||
total_stmt = select(func.count())
|
total_stmt = select(func.count(ReportUrlModel.id))
|
||||||
if domain:
|
if domain:
|
||||||
stmt = stmt.where(ReportUrlModel.domain.like(f"%{domain}%"))
|
stmt = stmt.where(ReportUrlModel.domain.like(f"%{domain}%"))
|
||||||
total_stmt = total_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