前端界面玩抽

This commit is contained in:
xhy 2025-04-04 17:29:50 +08:00
parent e7fa38cf33
commit e4287c6605
18 changed files with 863 additions and 287 deletions

View File

@ -188,7 +188,7 @@ class CrawlEngine:
session.add(domain_model) session.add(domain_model)
session.commit() session.commit()
self.ev.wait(60) self.ev.wait(10)
logger.info("crawl worker stop!") logger.info("crawl worker stop!")

View File

@ -90,7 +90,20 @@ class EvidenceEngine:
# Part1 获取证据截图 # Part1 获取证据截图
logger.debug(f"开始获取 {surl} 在百度搜索中的截图") logger.debug(f"开始获取 {surl} 在百度搜索中的截图")
img_path, tab = self.get_screenshot(target) img_path, tab, has_result = self.get_screenshot(target)
if not has_result:
# 如果没有搜到结果,直接把 has_evidence 标记为 true 就行了
with Session(self.database) as session:
stmt = select(ReportUrlModel).where(ReportUrlModel.id == target["id"])
model: ReportUrlModel = session.exec(stmt).first()
if not model:
logger.error(f"{target['id']} 记录不存在,跳过...")
return None
# 更新数据
model.has_evidence = True
session.add(model)
session.commit()
return None
if not img_path: if not img_path:
return None return None
@ -137,7 +150,7 @@ class EvidenceEngine:
except Exception as e: except Exception as e:
logger.error(f"获取证据截图和举报链接失败: {e}") logger.error(f"获取证据截图和举报链接失败: {e}")
def get_screenshot(self, target: dict) -> tuple[str | None, MixTab]: def get_screenshot(self, target: dict) -> tuple[str | None, MixTab, bool]:
"""获取搜索页面的截图,返回 img_path """ """获取搜索页面的截图,返回 img_path """
search_keyword = target["surl"].lstrip("https://").lstrip("http://") search_keyword = target["surl"].lstrip("https://").lstrip("http://")
tab = self.dp_engine.browser.new_tab() tab = self.dp_engine.browser.new_tab()
@ -147,12 +160,13 @@ class EvidenceEngine:
if "未找到相关结果" in tab.html: if "未找到相关结果" in tab.html:
logger.info(f"没有关于 {search_keyword} 的数据") logger.info(f"没有关于 {search_keyword} 的数据")
return None, tab return None, tab, False
# 图片的存储路径 # 图片的存储路径
# 截完图先不要关闭 tab别的地方还要用 # 截完图先不要关闭 tab别的地方还要用
img_path = f"./imgs/{target['domain']}/{md5(target['surl'])}.png" img_path = f"./imgs/{target['domain']}/{md5(target['surl'])}.png"
return self.do_screenshot(tab, img_path) img_path, tab = self.do_screenshot(tab, img_path)
return img_path, tab, True
def get_wap_screenshot(self, target: dict) -> tuple[str | None, MixTab]: def get_wap_screenshot(self, target: dict) -> tuple[str | None, MixTab]:
"""用 wap dp 再截一张 surl 本身的图""" """用 wap dp 再截一张 surl 本身的图"""

View File

@ -47,6 +47,18 @@ class Reporter:
continue continue
def stop(self): def stop(self):
for mode in self.mode:
if mode == "pc":
self.reporters["pc"].stop()
elif mode == "wap":
self.reporters["wap"].stop()
elif mode == "site":
self.reporters["site"].stop()
else:
logger.error(f"参数错误: {mode}")
continue
self.status = 0 self.status = 0
self.ev.set() self.ev.set()

View File

@ -51,6 +51,7 @@ class PcReporter(BaseReporter):
def stop(self): def stop(self):
self.status = 0 self.status = 0
self.ev.set() self.ev.set()
logger.warning(f"{self.engine_name} 收到退出消息,等待当前任务完成后退出")
def run(self): def run(self):
with Session(self.database) as session: with Session(self.database) as session:
@ -214,20 +215,26 @@ class PcReporter(BaseReporter):
# 获取 as、tk 值 # 获取 as、tk 值
try: try:
get_as_tk = self.post_init(surl, token, title, q, timestamp_s) get_as_tk = self.post_init(surl, token, title, q, timestamp_s)
# logger.debug(f"{get_as_tk=}")
get_as = get_as_tk['as'] get_as = get_as_tk['as']
get_tk = get_as_tk['tk'] get_tk = get_as_tk['tk']
# 获取验证码图片下载链接、backstr # 获取验证码图片下载链接、backstr
get_style_result = self.get_style(get_tk, surl, token, title, q, timestamp_s) get_style_result = self.get_style(get_tk, surl, token, title, q, timestamp_s)
# logger.debug(f"{get_style_result=}")
get_backstr = get_style_result['backstr'] get_backstr = get_style_result['backstr']
pic_download_link = get_style_result['captcha'] pic_download_link = get_style_result['captcha']
# 下载验证码图片 # 下载验证码图片
self.download_captcha(pic_download_link) self.download_captcha(pic_download_link)
rotate_angle_rate = self.get_rotate_angle_rate() rotate_angle_rate = self.get_rotate_angle_rate()
logger.debug(f"{rotate_angle_rate=}")
# key = self.get_key(get_as) # key = self.get_key(get_as)
if not rotate_angle_rate:
return {'op': 3}
get_ds_tk = self.post_log(get_as, get_tk, get_backstr, rotate_angle_rate) get_ds_tk = self.post_log(get_as, get_tk, get_backstr, rotate_angle_rate)
logger.debug(f"{get_ds_tk=}")
log_ds = get_ds_tk['ds'] log_ds = get_ds_tk['ds']
log_tk = get_ds_tk['tk'] log_tk = get_ds_tk['tk']
log_op = get_ds_tk['op'] log_op = get_ds_tk['op']
@ -238,7 +245,7 @@ class PcReporter(BaseReporter):
} }
return result return result
except Exception as e: except Exception as e:
logger.error(f'{e}') logger.exception(f'{e}')
return {'op': 3} return {'op': 3}
def post_init(self, surl, token, title, q, timestamp_s): def post_init(self, surl, token, title, q, timestamp_s):
@ -316,9 +323,13 @@ class PcReporter(BaseReporter):
with open('./captcha/captcha.png', 'rb') as p: with open('./captcha/captcha.png', 'rb') as p:
picture = p.read() picture = p.read()
slide_distance = identify_distance.rotate(image=picture) slide_distance = identify_distance.rotate(image=picture)
logger.debug(f"{slide_distance=}")
if not slide_distance:
return None
# 旋转角度为 # 旋转角度为
# logger.info('rotate angle: ' + str(slide_distance)) # logger.info('rotate angle: ' + str(slide_distance))
rotate_angle_rate = round(slide_distance / 360, 2) rotate_angle_rate = round(slide_distance / 360, 2)
logger.debug(f"{rotate_angle_rate=}")
# logger.info('rotate angle rate: ' + str(rotate_angle_rate)) # logger.info('rotate angle rate: ' + str(rotate_angle_rate))
return rotate_angle_rate return rotate_angle_rate

View File

@ -46,8 +46,10 @@ class SiteReporter(BaseReporter):
self.token_pattern = r'name="submit_token" value="(.*?)"' self.token_pattern = r'name="submit_token" value="(.*?)"'
def stop(self): def stop(self):
# logger.debug(f"{self.engine_name} stop called.")
self.status = 0 self.status = 0
self.ev.set() self.ev.set()
logger.warning(f"{self.engine_name} 收到退出消息,等待当前任务完成后退出")
def run(self): def run(self):
"""实现 PC 端的举报逻辑""" """实现 PC 端的举报逻辑"""

View File

@ -46,6 +46,7 @@ class WapReporter(BaseReporter):
def stop(self): def stop(self):
self.status = 0 self.status = 0
self.ev.set() self.ev.set()
logger.warning(f"{self.engine_name} 收到退出消息,等待当前任务完成后退出")
def run(self): def run(self):
"""实现 WAP 端的举报逻辑""" """实现 WAP 端的举报逻辑"""

View File

@ -2,6 +2,8 @@ import base64
import json import json
import requests import requests
from loguru import logger
class YdmVerify(object): class YdmVerify(object):
_custom_url = "https://www.jfbym.com/api/YmServer/customApi" _custom_url = "https://www.jfbym.com/api/YmServer/customApi"
@ -17,4 +19,11 @@ class YdmVerify(object):
"type": "90009" "type": "90009"
} }
resp = requests.post(self._custom_url, headers=self._headers, data=json.dumps(payload)) resp = requests.post(self._custom_url, headers=self._headers, data=json.dumps(payload))
logger.debug(f"{resp.json()=}")
response_data = resp.json()
if response_data.get("code") == 10002:
logger.error(f'{response_data.get("msg")}')
return None
return resp.json()['data']['data'] return resp.json()['data']['data']

View File

@ -1,6 +1,7 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from loguru import logger
from app.web.request.report_request import AddUrlsRequest, CollectEvidenceRequest, ReportRequest, GetUrlListRequest from app.web.request.report_request import AddUrlsRequest, CollectEvidenceRequest, ReportRequest, GetUrlListRequest
from app.web.service.domain_service import DomainService from app.web.service.domain_service import DomainService
@ -10,8 +11,11 @@ router = APIRouter(prefix="/api/urls", tags=["URL管理"])
@router.get("/v1/list") @router.get("/v1/list")
async def get_all_urls(request: Annotated[GetUrlListRequest, Query()]): def get_all_urls(request: Annotated[GetUrlListRequest, Query()]):
"""获取所有的URL支持根据域名、状态进行过滤不传则返回全部数据支持分页""" """获取所有的URL支持根据域名、状态进行过滤不传则返回全部数据支持分页"""
logger.debug(f"{request=}")
return ReportURLService.get_list( return ReportURLService.get_list(
request.domain, request.domain,
request.surl, request.surl,
@ -25,7 +29,7 @@ async def get_all_urls(request: Annotated[GetUrlListRequest, Query()]):
@router.post("/v1/add") @router.post("/v1/add")
async def add_urls(request: AddUrlsRequest): def add_urls(request: AddUrlsRequest):
""" """
手动添加 URL 到域名中支持批量添加 手动添加 URL 到域名中支持批量添加
格式 [ 格式 [
@ -58,7 +62,7 @@ async def add_urls(request: AddUrlsRequest):
@router.post("/v1/evidence") @router.post("/v1/evidence")
async def collect_evidence(request: CollectEvidenceRequest): def collect_evidence(request: CollectEvidenceRequest):
""" """
强制手动触发证据收集任务支持批量传入已经收集过的 URL 也要强制收集 强制手动触发证据收集任务支持批量传入已经收集过的 URL 也要强制收集
TODO:本来应该需要使用任务队列的为了简单先把数据库的相关标记改为 0 也能达到一样的效果 TODO:本来应该需要使用任务队列的为了简单先把数据库的相关标记改为 0 也能达到一样的效果
@ -68,10 +72,11 @@ async def collect_evidence(request: CollectEvidenceRequest):
@router.post("/v1/report") @router.post("/v1/report")
async def report(request: ReportRequest): def report(request: ReportRequest):
"""举报指定的URL支持批量传入 id 批量举报 """举报指定的URL支持批量传入 id 批量举报
先通过改数据库然后等引擎自己调度实现 先通过改数据库然后等引擎自己调度实现
""" """
logger.debug(f"{request=}")
return ReportURLService.batch_update_report_flag( return ReportURLService.batch_update_report_flag(
request.ids, request.ids,
request.report_by_one, request.report_by_one,

View File

@ -6,10 +6,10 @@ from pydantic import BaseModel, Field
class GetUrlListRequest(BaseModel): class GetUrlListRequest(BaseModel):
domain: str = "" domain: str = ""
surl: str = "" surl: str = ""
is_report_by_one: Optional[bool] = False is_report_by_one: Optional[int] = 2
is_report_by_site: Optional[bool] = False is_report_by_site: Optional[int] = 2
is_report_by_wap: Optional[bool] = False is_report_by_wap: Optional[int] = 2
has_evidence: Optional[bool] = False has_evidence: Optional[int] = 2
page: int = Field(default=1, gt=0) page: int = Field(default=1, gt=0)
size: int = Field(default=50, gt=0) size: int = Field(default=50, gt=0)

View File

@ -15,8 +15,9 @@ class ReportURLService:
@classmethod @classmethod
def get_list( def get_list(
cls, domain: str, surl: str, is_report_by_one: Optional[bool], is_report_by_site: Optional[bool], cls, domain: str, surl: str, is_report_by_one: Optional[int], is_report_by_site: Optional[int],
is_report_by_wap: Optional[bool], has_evidence: Optional[bool], page: int, size: int): is_report_by_wap: Optional[int], has_evidence: Optional[int], page: int, size: int
):
with Session(AppCtx.g_db_engine) as session: with Session(AppCtx.g_db_engine) as session:
stmt = select(ReportUrlModel) stmt = select(ReportUrlModel)
@ -27,22 +28,22 @@ class ReportURLService:
if surl: if surl:
stmt = stmt.where(ReportUrlModel.surl.like(f"%{surl}%")) stmt = stmt.where(ReportUrlModel.surl.like(f"%{surl}%"))
total_stmt = total_stmt.where(ReportUrlModel.surl.like(f"%{surl}%")) total_stmt = total_stmt.where(ReportUrlModel.surl.like(f"%{surl}%"))
if is_report_by_one is not None: if is_report_by_one and is_report_by_one != 2:
stmt = stmt.where(ReportUrlModel.is_report_by_one == is_report_by_one) stmt = stmt.where(ReportUrlModel.is_report_by_one == is_report_by_one)
total_stmt = total_stmt.where(ReportUrlModel.is_report_by_one == is_report_by_one) total_stmt = total_stmt.where(ReportUrlModel.is_report_by_one == is_report_by_one)
if is_report_by_site is not None: if is_report_by_site and is_report_by_site != 2:
stmt = stmt.where(ReportUrlModel.is_report_by_site == is_report_by_site) stmt = stmt.where(ReportUrlModel.is_report_by_site == is_report_by_site)
total_stmt = total_stmt.where(ReportUrlModel.is_report_by_site == is_report_by_site) total_stmt = total_stmt.where(ReportUrlModel.is_report_by_site == is_report_by_site)
if is_report_by_wap is not None: if is_report_by_wap and is_report_by_wap != 2:
stmt = stmt.where(ReportUrlModel.is_report_by_wap == is_report_by_wap) stmt = stmt.where(ReportUrlModel.is_report_by_wap == is_report_by_wap)
total_stmt = total_stmt.where(ReportUrlModel.is_report_by_wap == is_report_by_wap) total_stmt = total_stmt.where(ReportUrlModel.is_report_by_wap == is_report_by_wap)
if has_evidence is not None: if has_evidence and has_evidence != 2:
stmt = stmt.where(ReportUrlModel.has_evidence == has_evidence) stmt = stmt.where(ReportUrlModel.has_evidence == has_evidence)
total_stmt = total_stmt.where(ReportUrlModel.has_evidence == has_evidence) total_stmt = total_stmt.where(ReportUrlModel.has_evidence == has_evidence)
# 设置分页 # 设置分页
stmt = stmt.offset((page - 1) * size).limit(size) stmt = stmt.offset((page - 1) * size).limit(size)
# logger.debug(f"{str(stmt)=}")
try: try:
total = session.exec(total_stmt).first() total = session.exec(total_stmt).first()
urls = session.exec(stmt).all() urls = session.exec(stmt).all()
@ -103,10 +104,12 @@ class ReportURLService:
stmt = update(ReportUrlModel).where(ReportUrlModel.id.in_(ids)) stmt = update(ReportUrlModel).where(ReportUrlModel.id.in_(ids))
if report_by_wap: if report_by_wap:
stmt = stmt.values(is_report_by_wap=False) stmt = stmt.values(is_report_by_wap=False)
elif report_by_site: if report_by_site:
stmt = stmt.values(is_report_by_site=False) stmt = stmt.values(is_report_by_site=False)
elif report_by_one: if report_by_one:
stmt = stmt.values(is_report_by_one=False) stmt = stmt.values(is_report_by_one=False)
logger.debug(f"{str(stmt)=}")
session.exec(stmt) session.exec(stmt)
session.commit() session.commit()
return ApiResult.ok(len(ids)) return ApiResult.ok(len(ids))

7
fe/components.d.ts vendored
View File

@ -12,16 +12,23 @@ declare module 'vue' {
EditDomainDialog: typeof import('./src/components/EditDomainDialog.vue')['default'] EditDomainDialog: typeof import('./src/components/EditDomainDialog.vue')['default']
ImportDomainDialog: typeof import('./src/components/ImportDomainDialog.vue')['default'] ImportDomainDialog: typeof import('./src/components/ImportDomainDialog.vue')['default']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox'] NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable'] NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider'] NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDropdown: typeof import('naive-ui')['NDropdown']
NForm: typeof import('naive-ui')['NForm'] NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem'] NFormItem: typeof import('naive-ui')['NFormItem']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGrid: typeof import('naive-ui')['NGrid']
NInput: typeof import('naive-ui')['NInput'] NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber'] NInputNumber: typeof import('naive-ui')['NInputNumber']
NModal: typeof import('naive-ui')['NModal'] NModal: typeof import('naive-ui')['NModal']
NPagination: typeof import('naive-ui')['NPagination'] NPagination: typeof import('naive-ui')['NPagination']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
NTooltip: typeof import('naive-ui')['NTooltip'] NTooltip: typeof import('naive-ui')['NTooltip']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -1,6 +1,13 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { RouterLink, RouterView } from 'vue-router' import { RouterLink, RouterView } from 'vue-router'
import { NLayout, NMessageProvider, NConfigProvider, NLayoutHeader, NLayoutSider, NMenu } from 'naive-ui' import {
NLayout,
NMessageProvider,
NConfigProvider,
NLayoutHeader,
NLayoutSider,
NMenu,
} from 'naive-ui'
import { type MenuOption } from 'naive-ui' import { type MenuOption } from 'naive-ui'
import { List, ColorWand } from '@vicons/ionicons5' import { List, ColorWand } from '@vicons/ionicons5'
@ -30,14 +37,26 @@ const menuOpts: MenuOption[] = [
<n-message-provider> <n-message-provider>
<n-layout class="h-screen"> <n-layout class="h-screen">
<!-- header --> <!-- header -->
<n-layout-header class="h-16 p-5" style="background-color: oklch(62.3% 0.214 259.815); color: white" bordered> <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> <span class="font-bold text-xl">BAIDU Reporter</span>
</n-layout-header> </n-layout-header>
<n-layout position="absolute" has-sider style="top: 64px"> <n-layout position="absolute" has-sider style="top: 64px">
<!-- sidebar --> <!-- sidebar -->
<n-layout-sider width="8%" show-trigger show-collapsed-content :collapsed-width="64" <n-layout-sider
content-style="padding: 8px; text-align:center;" :native-scrollbar="false" bordered collapse-mode="width"> 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-menu :indent="24" :options="menuOpts" @update:value="handleUpdateValue" />
</n-layout-sider> </n-layout-sider>

View File

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

View File

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

View File

@ -1,14 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, inject } from 'vue' import { ref, inject } from 'vue'
import { NModal, NForm, NFormItem, NInputNumber, NCheckbox, NUpload, NButton, NButtonGroup, useMessage } from 'naive-ui' import {
NModal,
NForm,
NFormItem,
NInputNumber,
NCheckbox,
NUpload,
NButton,
NButtonGroup,
useMessage,
} from 'naive-ui'
import type { FormRules } from 'naive-ui' import type { FormRules } from 'naive-ui'
import type { AxiosInstance } from 'axios' import type { AxiosInstance } from 'axios'
import type { UploadFileInfo } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui'
const model = defineModel<boolean>("show", { required: true }) const model = defineModel<boolean>('show', { required: true })
const emit = defineEmits(['success']) const emit = defineEmits(['success'])
const axios = inject("axios") as AxiosInstance const axios = inject('axios') as AxiosInstance
const message = useMessage() const message = useMessage()
const interval = ref(1440) // 11440 const interval = ref(1440) // 11440
@ -19,24 +29,24 @@ const formRef = ref<InstanceType<typeof NForm> | null>(null)
const rules: FormRules = { const rules: FormRules = {
interval: [ interval: [
{ {
type: "number", type: 'number',
required: true, required: true,
message: '请输入采集间隔', message: '请输入采集间隔',
trigger: ['blur', 'change'] trigger: ['blur', 'change'],
}, },
{ {
type: 'number', type: 'number',
min: 1, min: 1,
message: '采集间隔必须大于0', message: '采集间隔必须大于0',
trigger: ['blur', 'change'] trigger: ['blur', 'change'],
} },
], ],
fileList: [ fileList: [
{ {
type: "array", type: 'array',
required: true, required: true,
message: '请选择文件', message: '请选择文件',
trigger: ['change'] trigger: ['change'],
}, },
{ {
validator: (_, value: UploadFileInfo[]) => { validator: (_, value: UploadFileInfo[]) => {
@ -44,9 +54,9 @@ const rules: FormRules = {
return !!value[0].file return !!value[0].file
}, },
message: '文件无效', message: '文件无效',
trigger: ['change'] trigger: ['change'],
} },
] ],
} }
const handleConfirm = async () => { const handleConfirm = async () => {
@ -69,8 +79,8 @@ const handleConfirm = async () => {
const response = await axios.post('/api/domain/v1/import', formData, { const response = await axios.post('/api/domain/v1/import', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data',
} },
}) })
if (response.data.code === 20000) { if (response.data.code === 20000) {
@ -97,9 +107,21 @@ const handleClose = () => {
</script> </script>
<template> <template>
<n-modal v-model:show="model" preset="card" title="通过文件导入" :mask-closable="false" style="width: 600px"> <n-modal
<n-form size="small" ref="formRef" :model="{ interval, fileList }" :rules="rules" label-placement="left" v-model:show="model"
label-width="200"> 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-form-item path="interval" label="采集间隔(分钟)">
<n-input-number v-model:value="interval" :min="1" /> <n-input-number v-model:value="interval" :min="1" />
</n-form-item> </n-form-item>
@ -109,9 +131,7 @@ const handleClose = () => {
</n-upload> </n-upload>
</n-form-item> </n-form-item>
<n-form-item label="采集选项"> <n-form-item label="采集选项">
<n-checkbox v-model:checked="startImmediately"> <n-checkbox v-model:checked="startImmediately"> 立即开始采集 </n-checkbox>
立即开始采集
</n-checkbox>
</n-form-item> </n-form-item>
</n-form> </n-form>
<template #action> <template #action>

View File

@ -6,10 +6,10 @@ import {createPinia} from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import axios from "axios" import axios from 'axios'
import "vfonts/Lato.css" import 'vfonts/Lato.css'
import "vfonts/IBMPlexMono.css" import 'vfonts/IBMPlexMono.css'
const app = createApp(App) const app = createApp(App)
@ -19,7 +19,7 @@ app.use(router)
const axiosInstance = axios.create({ const axiosInstance = axios.create({
withCredentials: true, withCredentials: true,
timeout: 9000, timeout: 9000,
timeoutErrorMessage: "E_NETWORK_TIMEOUT", timeoutErrorMessage: 'E_NETWORK_TIMEOUT',
}) })
app.provide('axios', axiosInstance) app.provide('axios', axiosInstance)

View File

@ -1,28 +1,43 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { onMounted, inject, ref, computed } from "vue"; import { onMounted, inject, ref, computed } from 'vue'
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from 'vue-router'
import type { AxiosInstance } from "axios"; import type { AxiosInstance } from 'axios'
import { type DataTableColumn, type DataTableRowKey, useMessage, useDialog } from "naive-ui"; import { type DataTableColumn, type DataTableRowKey, useMessage, useDialog } from 'naive-ui'
import ImportDomainDialog from "../components/ImportDomainDialog.vue"; import ImportDomainDialog from '../components/ImportDomainDialog.vue'
import AddDomainDialog from "../components/AddDomainDialog.vue"; import AddDomainDialog from '../components/AddDomainDialog.vue'
import EditDomainDialog from "../components/EditDomainDialog.vue"; import EditDomainDialog from '../components/EditDomainDialog.vue'
import { convertTimestampToDate } from "@/utils/common"; import { convertTimestampToDate } from '@/utils/common'
const axios = inject("axios") as AxiosInstance; const axios = inject('axios') as AxiosInstance
const message = useMessage(); const message = useMessage()
const dialog = useDialog(); const dialog = useDialog()
const route = useRoute(); const route = useRoute()
const router = useRouter(); const router = useRouter()
const showImportDialog = ref(false); //
const showAddDialog = ref(false); const filterForm = ref({
const showEditDialog = ref(false); domain: '',
status: null as number | null,
})
//
const statusOptions = [
{ label: '全部', value: null },
{ label: 'READY', value: 1 },
{ label: 'QUEUE', value: 2 },
{ label: 'CRAWLING', value: 3 },
{ label: 'PAUSE', value: 999 },
]
const showImportDialog = ref(false)
const showAddDialog = ref(false)
const showEditDialog = ref(false)
// ID // ID
const editingDomainIds = ref<DataTableRowKey[] | null>(null); const editingDomainIds = ref<DataTableRowKey[] | null>(null)
// Key // Key
const checkedRowKeys = ref<DataTableRowKey[]>([]); const checkedRowKeys = ref<DataTableRowKey[]>([])
// //
const pagination = ref({ const pagination = ref({
@ -32,35 +47,42 @@ const pagination = ref({
showSizePicker: true, showSizePicker: true,
pageSizes: [10, 20, 50, 100, 200, 500, 1000], pageSizes: [10, 20, 50, 100, 200, 500, 1000],
onChange: (page: number) => { onChange: (page: number) => {
pagination.value.page = page; pagination.value.page = page
updateUrlParams(); updateUrlParams()
getDomainList(); getDomainList()
}, },
onUpdatePageSize: (pageSize: number) => { onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize; pagination.value.pageSize = pageSize
pagination.value.page = 1; pagination.value.page = 1
updateUrlParams(); updateUrlParams()
getDomainList(); getDomainList()
} },
}); })
// URL // URL
const updateUrlParams = () => { const updateUrlParams = () => {
router.push({ router.push({
query: { query: {
page: pagination.value.page, page: pagination.value.page,
size: pagination.value.pageSize size: pagination.value.pageSize,
domain: filterForm.value.domain || undefined,
status: filterForm.value.status || undefined,
},
})
} }
});
};
// //
const initPagination = () => { const initPagination = () => {
const page = Number(route.query.page) || 1; const page = Number(route.query.page) || 1
const size = Number(route.query.size) || 50; const size = Number(route.query.size) || 50
pagination.value.page = page; const domain = route.query.domain as string || ''
pagination.value.pageSize = size; const status = route.query.status ? Number(route.query.status) : null
};
pagination.value.page = page
pagination.value.pageSize = size
filterForm.value.domain = domain
filterForm.value.status = status
}
const columns: Array<DataTableColumn> = [ const columns: Array<DataTableColumn> = [
{ {
@ -78,32 +100,32 @@ const columns: Array<DataTableColumn> = [
title: '状态', title: '状态',
key: 'status', key: 'status',
render: (row) => { render: (row) => {
let statusText = ''; let statusText = ''
let statusType = ''; let statusType = ''
switch (row.status) { switch (row.status) {
case 1: case 1:
statusText = 'READY'; statusText = 'READY'
statusType = 'success'; statusType = 'success'
break; break
case 2: case 2:
statusText = 'QUENE'; statusText = 'QUENE'
statusType = 'warning'; statusType = 'warning'
break; break
case 3: case 3:
statusText = 'CRAWLING'; statusText = 'CRAWLING'
statusType = 'info'; statusType = 'info'
break; break
case 999: case 999:
statusText = "PAUSE"; statusText = 'PAUSE'
statusType = "error"; statusType = 'error'
break; break
default: default:
statusText = 'UNKNOWN'; statusText = 'UNKNOWN'
statusType = 'error'; statusType = 'error'
} }
return <n-tag type={statusType}>{statusText}</n-tag>; return <n-tag type={statusType}>{statusText}</n-tag>
}, },
}, },
{ {
@ -113,44 +135,50 @@ const columns: Array<DataTableColumn> = [
<n-tooltip> <n-tooltip>
{{ {{
trigger: () => <span>{row.crawl_interval}</span>, trigger: () => <span>{row.crawl_interval}</span>,
default: () => `${row.crawl_interval as number / 60 / 24}` default: () => `${(row.crawl_interval as number) / 60 / 24}`,
}} }}
</n-tooltip> </n-tooltip>
), ),
}, },
{ {
title: "最近采集时间", title: '最近采集时间',
key: "latest_crawl_time", key: 'latest_crawl_time',
render: (row) => convertTimestampToDate(row.latest_crawl_time as number), render: (row) => convertTimestampToDate(row.latest_crawl_time as number),
}, },
{ {
title: "操作", title: '操作',
key: "action", key: 'action',
render: (row) => ( render: (row) => (
<div class="flex gap-2"> <div class="flex gap-2">
<n-button size="small" type="primary" onClick={() => handleEdit(row)}>编辑</n-button> <n-button size="small" type="primary" onClick={() => handleEdit(row)}>
<n-button size="small" type="info" onClick={() => handleSingleCrawl(row)}>立即采集</n-button> 编辑
<n-button size="small" type="error" onClick={() => handleDelete(row)}>删除</n-button> </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> </div>
) ),
} },
] ]
const domains = ref([]) const domains = ref([])
// //
const hasSelectedRows = computed(() => checkedRowKeys.value.length > 0); const hasSelectedRows = computed(() => checkedRowKeys.value.length > 0)
// //
const handleCheck = (rowKeys: DataTableRowKey[]) => { const handleCheck = (rowKeys: DataTableRowKey[]) => {
checkedRowKeys.value = rowKeys; checkedRowKeys.value = rowKeys
} }
/** 批量删除域名 */ /** 批量删除域名 */
const handleBatchDelete = () => { const handleBatchDelete = () => {
if (!hasSelectedRows.value) { if (!hasSelectedRows.value) {
message.warning('请至少选择一个域名'); message.warning('请至少选择一个域名')
return; return
} }
const removeSurl = ref(false) const removeSurl = ref(false)
dialog.warning({ dialog.warning({
@ -158,67 +186,69 @@ const handleBatchDelete = () => {
content: () => ( content: () => (
<div> <div>
<div class="mb-2">确定要删除选中的 {checkedRowKeys.value.length} 个域名吗</div> <div class="mb-2">确定要删除选中的 {checkedRowKeys.value.length} 个域名吗</div>
<n-checkbox v-model:checked={removeSurl.value}> <n-checkbox v-model:checked={removeSurl.value}>同时删除所有关联的 SURL</n-checkbox>
同时删除所有关联的 SURL
</n-checkbox>
</div> </div>
), ),
positiveText: '确定', positiveText: '确定',
negativeText: '取消', negativeText: '取消',
onPositiveClick: async () => { onPositiveClick: async () => {
try { try {
const response = (await axios.post('/api/domain/v1/delete', { const response = (
await axios.post('/api/domain/v1/delete', {
domain_ids: checkedRowKeys.value, domain_ids: checkedRowKeys.value,
remove_surl: removeSurl.value remove_surl: removeSurl.value,
})).data })
).data
if (response.code !== 20000) { if (response.code !== 20000) {
message.error(`批量删除域名失败,错误:${response.message}`) message.error(`批量删除域名失败,错误:${response.message}`)
return return
} }
message.success('批量删除成功') message.success('批量删除成功')
checkedRowKeys.value = []; // checkedRowKeys.value = [] //
getDomainList() getDomainList()
} catch (error) { } catch (error) {
console.error('批量删除域名失败', error) console.error('批量删除域名失败', error)
message.error(`批量删除域名失败,错误:${error}`) message.error(`批量删除域名失败,错误:${error}`)
} }
} },
}) })
} }
/** 批量修改采集间隔 */ /** 批量修改采集间隔 */
const handleBatchEdit = () => { const handleBatchEdit = () => {
if (!hasSelectedRows.value) { if (!hasSelectedRows.value) {
message.warning('请至少选择一个域名'); message.warning('请至少选择一个域名')
return; return
} }
editingDomainIds.value = [...checkedRowKeys.value]; // ID ID editingDomainIds.value = [...checkedRowKeys.value] // ID ID
showEditDialog.value = true; showEditDialog.value = true
} }
/** 批量立即采集 */ /** 批量立即采集 */
const handleBatchCrawl = async () => { const handleBatchCrawl = async () => {
if (!hasSelectedRows.value) { if (!hasSelectedRows.value) {
message.warning('请至少选择一个域名'); message.warning('请至少选择一个域名')
return; return
} }
try { try {
// TODO: /api/domain/v1/crawl // TODO: /api/domain/v1/crawl
const response = (await axios.post('/api/domain/v1/crawl', { const response = (
domain_ids: checkedRowKeys.value await axios.post('/api/domain/v1/crawl', {
})).data; domain_ids: checkedRowKeys.value,
})
).data
if (response.code !== 20000) { if (response.code !== 20000) {
message.error(`批量触发采集失败:${response.message}`); message.error(`批量触发采集失败:${response.message}`)
return; return
} }
message.success('批量触发采集成功,已加入队列'); message.success('批量触发采集成功,已加入队列')
checkedRowKeys.value = []; // checkedRowKeys.value = [] //
getDomainList(); // getDomainList() //
} catch (error) { } catch (error) {
console.error('批量触发采集失败', error); console.error('批量触发采集失败', error)
message.error(`批量触发采集失败:${error}`); message.error(`批量触发采集失败:${error}`)
} }
} }
@ -226,20 +256,22 @@ const handleBatchCrawl = async () => {
const handleSingleCrawl = async (row: any) => { const handleSingleCrawl = async (row: any) => {
try { try {
// ID // ID
const response = (await axios.post('/api/domain/v1/crawl', { const response = (
domain_ids: [row.id] await axios.post('/api/domain/v1/crawl', {
})).data; domain_ids: [row.id],
})
).data
if (response.code !== 20000) { if (response.code !== 20000) {
message.error(`触发采集失败:${response.message}`); message.error(`触发采集失败:${response.message}`)
return; return
} }
message.success(`域名 ${row.domain} 已加入采集队列`); message.success(`域名 ${row.domain} 已加入采集队列`)
getDomainList(); // getDomainList() //
} catch (error) { } catch (error) {
console.error('触发采集失败', error); console.error('触发采集失败', error)
message.error(`触发采集失败:${error}`); message.error(`触发采集失败:${error}`)
} }
} }
@ -252,58 +284,61 @@ const handleDelete = async (row: any) => {
content: () => ( content: () => (
<div> <div>
<div class="mb-2">确定要删除域名 {row.domain} </div> <div class="mb-2">确定要删除域名 {row.domain} </div>
<n-checkbox v-model:checked={removeSurl.value}> <n-checkbox v-model:checked={removeSurl.value}>同时删除所有关联的 SURL</n-checkbox>
同时删除所有关联的 SURL
</n-checkbox>
</div> </div>
), ),
positiveText: '确定', positiveText: '确定',
negativeText: '取消', negativeText: '取消',
onPositiveClick: async () => { onPositiveClick: async () => {
try { try {
const response = (await axios.post('/api/domain/v1/delete', { const response = (
await axios.post('/api/domain/v1/delete', {
domain_ids: [row.id], domain_ids: [row.id],
remove_surl: removeSurl.value remove_surl: removeSurl.value,
})).data })
).data
if (response.code !== 20000) { if (response.code !== 20000) {
message.error(`删除域名失败,错误:${response.message}`) message.error(`删除域名失败,错误:${response.message}`)
return return
} }
message.success('删除成功') message.success('删除成功')
// checkedRowKeys // checkedRowKeys
const index = checkedRowKeys.value.findIndex(key => key === row.id); const index = checkedRowKeys.value.findIndex((key) => key === row.id)
if (index > -1) { if (index > -1) {
checkedRowKeys.value.splice(index, 1); checkedRowKeys.value.splice(index, 1)
} }
getDomainList() getDomainList()
} catch (error) { } catch (error) {
console.error('删除域名失败', error) console.error('删除域名失败', error)
message.error(`删除域名失败,错误:${error}`) message.error(`删除域名失败,错误:${error}`)
} }
} },
}) })
} }
/** 获取域名列表 */ /** 获取域名列表 */
const getDomainList = async () => { const getDomainList = async () => {
try { try {
const response = (await axios.get('/api/domain/v1/list', { const response = (
await axios.get('/api/domain/v1/list', {
params: { params: {
page: pagination.value.page, page: pagination.value.page,
size: pagination.value.pageSize size: pagination.value.pageSize,
} domain: filterForm.value.domain || undefined,
})).data; status: filterForm.value.status || undefined,
},
})
).data
if (response.code !== 20000) { if (response.code !== 20000) {
message.error(`获取域名列表失败,错误:${response.message}`); message.error(`获取域名列表失败,错误:${response.message}`)
return; return
} }
domains.value = response.data.rows; domains.value = response.data.rows
pagination.value.itemCount = response.data.total; pagination.value.itemCount = response.data.total
} catch (error) { } catch (error) {
console.error('获取域名列表失败', error); console.error('获取域名列表失败', error)
message.error(`获取域名列表失败,错误:${error}`); message.error(`获取域名列表失败,错误:${error}`)
} }
} }
@ -317,22 +352,40 @@ const handleAddSuccess = () => {
/** 编辑域名 */ /** 编辑域名 */
const handleEdit = (row: any) => { const handleEdit = (row: any) => {
editingDomainIds.value = [row.id]; // ID ID editingDomainIds.value = [row.id] // ID ID
showEditDialog.value = true; showEditDialog.value = true
} }
const handleEditSuccess = () => { const handleEditSuccess = () => {
getDomainList(); getDomainList()
const editedCount = editingDomainIds.value?.length || 0; const editedCount = editingDomainIds.value?.length || 0
editingDomainIds.value = null; // ID editingDomainIds.value = null // ID
// //
if (editedCount > 1) { if (editedCount > 1) {
checkedRowKeys.value = []; checkedRowKeys.value = []
} }
} }
//
const resetFilter = () => {
filterForm.value = {
domain: '',
status: null,
}
pagination.value.page = 1
updateUrlParams()
getDomainList()
}
//
const applyFilter = () => {
pagination.value.page = 1
updateUrlParams()
getDomainList()
}
onMounted(async () => { onMounted(async () => {
initPagination(); initPagination()
await getDomainList() await getDomainList()
}) })
</script> </script>
@ -347,6 +400,20 @@ onMounted(async () => {
<n-button type="info" @click="handleBatchCrawl" :disabled="!hasSelectedRows">立即采集</n-button> <n-button type="info" @click="handleBatchCrawl" :disabled="!hasSelectedRows">立即采集</n-button>
</div> </div>
<!-- 筛选表单 -->
<n-form inline :model="filterForm" class="mb-4 p-4 bg-gray-50 rounded-lg">
<n-form-item label="域名" path="domain">
<n-input v-model:value="filterForm.domain" placeholder="请输入域名" clearable @keydown.enter="applyFilter" />
</n-form-item>
<n-form-item label="状态" path="status">
<n-select v-model:value="filterForm.status" :options="statusOptions" placeholder="请选择状态" style="width: 200px" />
</n-form-item>
<n-form-item>
<n-button type="primary" @click="applyFilter">筛选</n-button>
<n-button class="ml-2" @click="resetFilter">重置</n-button>
</n-form-item>
</n-form>
<n-data-table :columns="columns" :data="domains" :row-key="(row: any) => row.id" :checked-row-keys="checkedRowKeys" <n-data-table :columns="columns" :data="domains" :row-key="(row: any) => row.id" :checked-row-keys="checkedRowKeys"
@update:checked-row-keys="handleCheck" size="small" /> @update:checked-row-keys="handleCheck" size="small" />
@ -360,5 +427,4 @@ onMounted(async () => {
<AddDomainDialog v-model:show="showAddDialog" @success="handleAddSuccess" /> <AddDomainDialog v-model:show="showAddDialog" @success="handleAddSuccess" />
<EditDomainDialog v-model:show="showEditDialog" :domain-ids="editingDomainIds" @success="handleEditSuccess" <EditDomainDialog v-model:show="showEditDialog" :domain-ids="editingDomainIds" @success="handleEditSuccess"
@close="editingDomainIds = null" /> @close="editingDomainIds = null" />
</template> </template>

View File

@ -1,7 +1,379 @@
<script setup lang="ts"></script> <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, NDropdown } from 'naive-ui'
import { convertTimestampToDate } from '@/utils/common'
const axios = inject('axios') as AxiosInstance
const message = useMessage()
const dialog = useDialog()
const route = useRoute()
const router = useRouter()
//
const searchForm = ref({
domain: '',
surl: '',
is_report_by_one: 2,
is_report_by_site: 2,
is_report_by_wap: 2,
has_evidence: 2,
})
//
const options = [
{ label: '全部', value: 2 },
{ label: '是', value: 1 },
{ label: '否', value: 0 },
]
//
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()
getUrlList()
},
onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
updateUrlParams()
getUrlList()
},
})
// 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
}
// Key
const checkedRowKeys = ref<DataTableRowKey[]>([])
//
const reportOptions = [
{
label: '全部渠道',
key: 'all',
},
{
label: 'PC渠道',
key: 'pc',
},
{
label: 'SITE渠道',
key: 'site',
},
{
label: 'WAP渠道',
key: 'wap',
},
]
const columns: Array<DataTableColumn> = [
{
type: 'selection',
},
{
title: '#',
key: 'id',
minWidth: 60,
},
{
title: '域名',
key: 'domain',
minWidth: 200,
},
{
title: 'SURL',
key: 'surl',
minWidth: 100,
},
{
title: 'Q',
key: 'q',
minWidth: 100,
},
{
title: 'Token',
key: 'token',
minWidth: 100,
},
{
title: '已通过PC举报',
key: 'is_report_by_one',
render: (row) => (
<n-tag type={row.is_report_by_one ? 'success' : 'default'}>
{row.is_report_by_one ? '是' : '否'}
</n-tag>
),
},
{
title: '已通过site举报',
key: 'is_report_by_site',
render: (row) => (
<n-tag type={row.is_report_by_site ? 'success' : 'default'}>
{row.is_report_by_site ? '是' : '否'}
</n-tag>
),
},
{
title: '已通过WAP举报',
key: 'is_report_by_wap',
render: (row) => (
<n-tag type={row.is_report_by_wap ? 'success' : 'default'}>
{row.is_report_by_wap ? '是' : '否'}
</n-tag>
),
},
{
title: '已收集证据',
key: 'has_evidence',
render: (row) => (
<n-tag type={row.has_evidence ? 'success' : 'default'}>
{row.has_evidence ? '是' : '否'}
</n-tag>
),
},
{
title: '操作',
key: 'action',
render: (row) => {
return (
<div class="flex gap-2">
<n-dropdown
trigger="click"
options={reportOptions}
onSelect={(key: string) => handleSingleReport(row, key)}
>
<n-button size="small" type="primary">举报</n-button>
</n-dropdown>
<n-button
size="small"
type="info"
onClick={() => handleSingleCollectEvidence(row)}
>
收集证据
</n-button>
</div>
)
},
},
]
const urls = ref([])
// URL
const getUrlList = async () => {
try {
const response = (
await axios.get('/api/urls/v1/list', {
params: {
...searchForm.value,
page: pagination.value.page,
size: pagination.value.pageSize,
},
})
).data
if (response.code !== 20000) {
message.error(`获取URL列表失败${response.message}`)
return
}
urls.value = response.data.data
console.log('response.data.total:', response.data.total)
pagination.value.itemCount = response.data.total
} catch (error) {
console.error('获取URL列表失败', error)
message.error(`获取URL列表失败${error}`)
}
}
//
const handleSearch = () => {
pagination.value.page = 1
getUrlList()
}
//
const handleReset = () => {
searchForm.value = {
domain: '',
surl: '',
is_report_by_one: 2,
is_report_by_site: 2,
is_report_by_wap: 2,
has_evidence: 2,
}
handleSearch()
}
//
const handleReport = async (ids: number[], option: string) => {
//
const selectedUrls = urls.value.filter((url: any) => ids.includes(url.id))
const hasNoEvidence = selectedUrls.some((url: any) => !url.has_evidence)
if (hasNoEvidence) {
message.warning('请先收集证据后再进行举报')
return
}
try {
const response = (await axios.post('/api/urls/v1/report', {
ids,
report_by_one: option === 'all' || option === 'pc',
report_by_site: option === 'all' || option === 'site',
report_by_wap: option === 'all' || option === 'wap',
})).data
if (response.code !== 20000) {
message.error(`举报失败:${response.message}`)
return
}
message.success('操作成功已修改SURL状态等待引擎调度')
getUrlList()
} catch (error) {
console.error('举报失败', error)
message.error(`举报失败:${error}`)
}
}
//
const handleCollectEvidence = async (ids: number[]) => {
try {
const response = (await axios.post('/api/urls/v1/evidence', {
ids,
})).data
if (response.code !== 20000) {
message.error(`收集证据失败:${response.message}`)
return
}
message.success('操作成功已修改SURL状态等待引擎调度')
getUrlList()
} catch (error) {
console.error('收集证据失败', error)
message.error(`收集证据失败:${error}`)
}
}
// URL
const handleSingleReport = (row: any, option: string) => {
handleReport([row.id], option)
}
// URL
const handleSingleCollectEvidence = (row: any) => {
handleCollectEvidence([row.id])
}
//
const handleBatchReport = (option: string) => {
if (checkedRowKeys.value.length === 0) {
message.warning('请至少选择一个URL')
return
}
handleReport(checkedRowKeys.value as number[], option)
}
//
const handleBatchCollectEvidence = () => {
if (checkedRowKeys.value.length === 0) {
message.warning('请至少选择一个URL')
return
}
handleCollectEvidence(checkedRowKeys.value as number[])
}
//
const hasSelectedRows = computed(() => checkedRowKeys.value.length > 0)
onMounted(() => {
initPagination()
getUrlList()
})
</script>
<template> <template>
<p class="text-2xl">DomainManager</p> <div class="p-4">
<h1 class="text-2xl mb-4">URL管理</h1>
<!-- 搜索表单 -->
<n-card class="mb-4">
<n-form :model="searchForm" label-placement="left" label-width="auto" require-mark-placement="right-hanging">
<n-grid :cols="24" :x-gap="24">
<n-form-item-gi :span="8" label="域名">
<n-input v-model:value="searchForm.domain" placeholder="请输入域名" @keyup.enter="handleSearch" />
</n-form-item-gi>
<n-form-item-gi :span="8" label="SURL">
<n-input v-model:value="searchForm.surl" placeholder="请输入SURL" @keyup.enter="handleSearch" />
</n-form-item-gi>
<n-form-item-gi :span="8" label="是否已通过PC举报">
<n-select v-model:value="searchForm.is_report_by_one" :options="options" placeholder="请选择" />
</n-form-item-gi>
<n-form-item-gi :span="8" label="是否已通过site举报">
<n-select v-model:value="searchForm.is_report_by_site" :options="options" placeholder="请选择" />
</n-form-item-gi>
<n-form-item-gi :span="8" label="是否已通过WAP举报">
<n-select v-model:value="searchForm.is_report_by_wap" :options="options" placeholder="请选择" />
</n-form-item-gi>
<n-form-item-gi :span="8" label="是否已收集证据">
<n-select v-model:value="searchForm.has_evidence" :options="options" placeholder="请选择" />
</n-form-item-gi>
</n-grid>
<div class="flex justify-end gap-2 mt-4">
<n-button @click="handleReset">重置</n-button>
<n-button type="primary" @click="handleSearch">搜索</n-button>
</div>
</n-form>
</n-card>
<!-- 数据表格 -->
<n-card>
<div class="mb-4" v-if="hasSelectedRows">
<n-space>
<n-dropdown trigger="click" :options="reportOptions" @select="handleBatchReport">
<n-button type="primary">批量举报 ({{ checkedRowKeys.length }})</n-button>
</n-dropdown>
<n-button type="info" @click="handleBatchCollectEvidence">
批量收集证据 ({{ checkedRowKeys.length }})
</n-button>
</n-space>
</div>
<n-data-table :columns="columns" :data="urls" :bordered="false" :row-key="(row: any) => row.id"
:checked-row-keys="checkedRowKeys" @update:checked-row-keys="checkedRowKeys = $event" />
<div class="flex justify-center mt-4">
<n-pagination v-model:page="pagination.page" :item-count="pagination.itemCount" :page-size="pagination.pageSize"
:show-size-picker="pagination.showSizePicker" :page-sizes="pagination.pageSizes"
:on-update:page-size="pagination.onUpdatePageSize" :on-change="pagination.onChange" />
</div>
</n-card>
</div>
</template> </template>
<style scoped></style> <style scoped></style>