Commit 0b1c698e by 周田

init

parents
VITE_APP_BASE_API = '/api'
# 开发环境配置
VITE_APP_BASE_API = '/api'
VITE_APP_TITLE = 'Spider Admin Pro'
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
{
"recommendations": ["Vue.volar"]
}
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "fk-spider-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.15.17",
"@types/nprogress": "^0.2.3",
"@types/uuid": "^10.0.0",
"axios": "^1.9.0",
"echarts": "^5.6.0",
"element-plus": "^2.9.10",
"js-cookie": "^3.0.5",
"normalize.css": "^8.0.1",
"nprogress": "~0.2.0",
"path-to-regexp": "^3.3.0",
"pinia": "^3.0.2",
"qs": "~6.11.2",
"uuid": "^11.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"sass-embedded": "^1.88.0",
"typescript": "~5.8.3",
"unocss": "^66.1.2",
"vite": "^6.3.5",
"vue-tsc": "^2.2.8"
}
}
This diff is collapsed. Click to expand it.
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
\ No newline at end of file
VITE_APP_BASE_API = '/api'
<script setup lang="ts">
// App.vue
</script>
<template>
<router-view />
</template>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden; /* 防止出现滚动条 */
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
}
#app {
height: 100%;
width: 100%;
overflow: hidden; /* 修改为hidden,防止横向滚动条 */
}
.app-container {
padding: 20px;
box-sizing: border-box; /* 确保padding不会增加元素宽度 */
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>
// API路径配置文件
// 这个文件确保所有API路径在一处统一管理,方便修改
export const authApi = {
login: '/auth/login',
logout: '/auth/logout'
} as const
export const scrapydApi = {
// scrapyd相关接口
listProjects: '/scrapyd/listProjects',
listVersions: '/scrapyd/listVersions',
listSpiders: '/scrapyd/listSpiders',
listJobsMerge: '/scrapyd/listJobsMerge',
cancel: '/scrapyd/cancel',
cancelAllJob: '/scrapyd/cancelAllJob',
deleteVersion: '/scrapyd/deleteVersion',
deleteProject: '/scrapyd/deleteProject',
schedule: '/scrapyd/schedule',
daemonStatus: '/scrapyd/daemonStatus',
addVersion: '/scrapyd/addVersion',
// 日志相关
logs: '/scrapyd/logs',
projectLogs: '/scrapyd/projectLogs',
spiderLogs: '/scrapyd/spiderLogs',
jobLog: '/scrapyd/jobLog'
} as const
export const scheduleApi = {
// 调度任务相关
addJob: '/schedule/addJob',
getJobs: '/schedule/getJobs',
pauseJob: '/schedule/pauseJob',
resumeJob: '/schedule/resumeJob',
removeJob: '/schedule/removeJob',
jobDetail: '/schedule/jobDetail',
state: '/schedule/state',
start: '/schedule/start',
shutdown: '/schedule/shutdown',
pause: '/schedule/pause',
resume: '/schedule/resume',
removeAllJobs: '/schedule/removeAllJobs',
scheduleLogs: '/schedule/scheduleLogs',
removeScheduleLogs: '/schedule/removeScheduleLogs',
} as const
export const scrapydServerApi = {
getScrapydServerPage: '/scrapydServer/getScrapydServerPage',
addScrapydServer: '/scrapydServer/addScrapydServer',
updateScrapydServer: '/scrapydServer/updateScrapydServer',
updateScrapydServerStatus: '/scrapydServer/updateScrapydServerStatus',
deleteScrapydServer: '/scrapydServer/deleteScrapydServer'
} as const
export const systemApi = {
// 系统相关
systemInfo: '/system/systemInfo',
systemData: '/system/systemData',
systemConfig: '/system/systemConfig',
loginHistoryList: '/actionHistory/loginHistoryList',
scrapydServerList: '/system/scrapydServerList' // 没用
} as const
export const statsApi = {
// 统计相关
statsList: '/statsCollection/listItem',
statsDetail: '/statsCollection/detail',
removeStats: '/statsCollection/delete',
clearAllStats: '/statsCollection/clearAll' // TODO 未实现
} as const
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { scrapydApi } from './apiPaths'
// 获取日志列表
export function getLogs(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.logs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取项目日志
export function getProjectLogs(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.projectLogs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取爬虫日志
export function getSpiderLogs(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.spiderLogs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取任务日志
export function getJobLog(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.jobLog,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { scrapydApi } from './apiPaths'
// 获取项目列表
export function getProjectList(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listProjects,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取项目版本列表
export function getProjectVersions(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listVersions,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取爬虫列表
export function getSpiderList(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listSpiders,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取任务列表
export function getJobList(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listJobsMerge,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 取消任务
export function cancelJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scrapydApi.cancel,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 取消所有任务
export function cancelAllJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scrapydApi.cancelAllJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除项目版本
export function deleteProjectVersion(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scrapydApi.deleteVersion,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除项目
export function deleteProject(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scrapydApi.deleteProject,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 调度任务
export function scheduleJob(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.schedule,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 运行爬虫
export function schedule(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.schedule,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取服务状态
export function getDaemonStatus(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.daemonStatus,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 添加项目版本
export function addProjectVersion(data: FormData): Promise<ApiResponse> {
return request({
url: scrapydApi.addVersion,
method: POST,
data,
headers: {
'Content-Type': 'multipart/form-data'
}
}) as unknown as Promise<ApiResponse>
}
// 获取项目下的爬虫列表
export function listSpiders(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listSpiders,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取爬虫列表简化版(用于表单选择等)
export function getSpiders(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listSpiders,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取项目列表简化版(用于表单选择等)
export function listProjects(data: QueryParams): Promise<ApiResponse> {
return request({
url: scrapydApi.listProjects,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { scheduleApi, scrapydServerApi } from './apiPaths'
// 添加调度任务
export function addJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.addJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取调度任务列表
export function getJobs(data: QueryParams): Promise<ApiResponse> {
return request({
url: scheduleApi.getJobs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 暂停调度任务
export function pauseJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.pauseJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 恢复调度任务
export function resumeJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.resumeJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除调度任务
export function removeJob(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.removeJob,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取调度任务详情
export function getJobDetail(data: QueryParams): Promise<ApiResponse> {
return request({
url: scheduleApi.jobDetail,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取调度器状态
export function getScheduleState(): Promise<ApiResponse> {
return request({
url: scheduleApi.state,
method: POST,
}) as unknown as Promise<ApiResponse>
}
// 启动调度器
export function startSchedule(): Promise<ApiResponse> {
return request({
url: scheduleApi.start,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 关闭调度器
export function shutdownSchedule(): Promise<ApiResponse> {
return request({
url: scheduleApi.shutdown,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 暂停调度器
export function pauseSchedule(): Promise<ApiResponse> {
return request({
url: scheduleApi.pause,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 恢复调度器
export function resumeSchedule(): Promise<ApiResponse> {
return request({
url: scheduleApi.resume,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 删除所有调度任务
export function removeAllJobs(): Promise<ApiResponse> {
return request({
url: scheduleApi.removeAllJobs,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 获取调度日志
export function getScheduleLogs(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: scheduleApi.scheduleLogs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除调度日志
export function removeScheduleLogs(data: Record<string, any> = {}): Promise<ApiResponse> {
return request({
url: scheduleApi.removeScheduleLogs,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取Scrapyd服务列表
export function getScrapydServerPage(): Promise<ApiResponse> {
return request({
url: scrapydServerApi.getScrapydServerPage,
method: POST,
}) as unknown as Promise<ApiResponse>
}
import { type ApiResponse, request, POST } from "@/utils/request"
import { scrapydServerApi } from "./apiPaths"
// 添加Scrapyd服务
export const addScrapydServer = (data: Record<string, any>): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.addScrapydServer,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 更新Scrapyd服务
export const updateScrapydServer = (data: Record<string, any>): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.updateScrapydServer,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 更新Scrapyd服务状态
export const updateScrapydServerStatus = (data: Record<string, any>): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.updateScrapydServerStatus,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 删除Scrapyd服务
export const deleteScrapydServer = (data: Record<string, any>): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.deleteScrapydServer,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { statsApi } from './apiPaths'
// 获取爬虫统计数据列表
export function getStatsList(params: QueryParams): Promise<ApiResponse> {
return request({
url: statsApi.statsList,
method: POST,
params
}) as unknown as Promise<ApiResponse>
}
// 获取爬虫统计数据明细
export function getStatsDetail(params: QueryParams): Promise<ApiResponse> {
return request({
url: statsApi.statsDetail,
method: POST,
params
}) as unknown as Promise<ApiResponse>
}
// 删除爬虫统计数据
export function removeStats(data: QueryParams): Promise<ApiResponse> {
return request({
url: statsApi.removeStats,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 清空爬虫统计数据
export function clearAllStats(data: QueryParams): Promise<ApiResponse> {
return request({
url: statsApi.clearAllStats, // TODO 这个接口在后端还没有实现
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { systemApi, scrapydServerApi } from './apiPaths'
// 获取系统信息
export function getSystemInfo(): Promise<ApiResponse> {
return request({
url: systemApi.systemInfo,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 获取系统数据
export function getSystemData(data: QueryParams): Promise<ApiResponse> {
return request({
url: systemApi.systemData,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取系统配置
export function getSystemConfig(): Promise<ApiResponse> {
return request({
url: systemApi.systemConfig,
method: POST
}) as unknown as Promise<ApiResponse>
}
// 更新系统配置
export function updateSystemConfig(data: Record<string, any>): Promise<ApiResponse> {
return request({
url: systemApi.systemConfig,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取登录历史
export function getLoginHistory(params: QueryParams): Promise<ApiResponse> {
return request({
url: systemApi.loginHistoryList,
method: POST,
params
}) as unknown as Promise<ApiResponse>
}
export const getScrapydServerList = (): Promise<ApiResponse> => {
return request({
url: scrapydServerApi.getScrapydServerPage,
method: POST,
}) as unknown as Promise<ApiResponse>
}
import { request, POST } from '@/utils/request'
import type { ApiResponse, QueryParams } from '@/utils/request'
import { authApi } from './apiPaths'
// 用户登录
export function login(data: { username: string, password: string }): Promise<ApiResponse> {
return request({
url: authApi.login,
method: POST,
data
}) as unknown as Promise<ApiResponse>
}
// 获取用户信息
export function getInfo() {
return new Promise(resolve => {
resolve({
data: {
roles: ['admin'],
introduction: 'Admin',
avatar: 'user-avatar.gif',
name: 'Admin'
}
})
})
}
// 用户登出
export function logout(): Promise<ApiResponse> {
return request({
url: authApi.logout,
method: POST
}) as unknown as Promise<ApiResponse>
}
<svg width="252" height="294" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M0 57.732L126.099 0l126.099 57.732-126.099 57.732z" fill="#EAEAEA"/>
<path d="M126.099 115.464v116.608L0 174.339V57.732l126.099 57.732z" fill="#D8D8D8"/>
<path d="M126.099 115.464v116.608l126.099-57.733V57.732l-126.099 57.732z" fill="#ABABAB"/>
<path d="M126.099 247.186V294l126.099-57.732v-46.816l-126.099 57.734z" fill="#ABABAB"/>
<path d="M126.099 247.186V294L0 236.268v-46.816l126.099 57.734z" fill="#D8D8D8"/>
<path d="M0 189.453L126.099 247.186l126.099-57.733-126.099-57.732z" fill="#EAEAEA"/>
</g>
</svg>
<svg width="100" height="75" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M0 0h100v75H0z"/>
<path d="M16.4 28.6c0-7.8 6.3-14.2 14.2-14.2 7.8 0 14.2 6.3 14.2 14.2 0 5-2.5 9.3-6.4 11.7 0 0-1.3.7-1.5 2.2-.1.6-.2 1.9-.2 1.9h-12.1s-.1-1.4-.2-2c-.3-1.5-1.6-2.1-1.6-2.1-3.9-2.4-6.4-6.7-6.4-11.7" fill="#FFF"/>
<path d="M73 28.6c0-7.8 6.3-14.2 14.2-14.2 7.8 0 14.2 6.3 14.2 14.2 0 5-2.5 9.3-6.4 11.7 0 0-1.3.7-1.5 2.2-.1.6-.2 1.9-.2 1.9H81.2s-.1-1.4-.2-2c-.3-1.5-1.6-2.1-1.6-2.1-3.9-2.4-6.4-6.7-6.4-11.7" fill="#FFF"/>
<path d="M30.6 44.4h42.8s1.9 1.3 1.9 4v2.1H28.8v-2c0-3 1.8-4.1 1.8-4.1" fill="#FFF"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
\ No newline at end of file
<template>
<div class="active-plate">
<el-row :gutter="20">
<el-col :xs="12" :sm="8" :md="8" :lg="6" v-for="(item, index) in infoList" :key="index" @click="handleClick(item)">
<div class="info-card" :style="{ borderColor: item.color }">
<div class="icon-box" :style="{ background: item.color }">
<el-icon :size="24" :color="item.color === '#ffffff' ? '#333' : '#fff'">
<component :is="item.icon" />
</el-icon>
</div>
<div class="content">
<div class="count">{{ item.count }}</div>
<div class="title">{{ item.title }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
<script lang="ts" setup>
interface InfoItem {
icon: any
title: string
count: number | string
color: string
}
defineProps({
infoList: {
type: Array as () => InfoItem[],
default: () => []
}
})
const handleClick = (item: InfoItem) => {
// 处理点击事件
console.log('Clicked item:', item)
}
</script>
<style lang="scss" scoped>
.active-plate {
.info-card {
display: flex;
padding: 15px;
background: #fff;
border-radius: 4px;
margin-bottom: 20px;
border-left: 4px solid #ddd;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
transition: all 0.3s;
&:hover {
transform: translateY(-5px);
}
.icon-box {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.count {
font-size: 24px;
font-weight: 500;
line-height: 1.2;
color: #333;
}
.title {
font-size: 14px;
color: #909399;
margin-top: 5px;
}
}
}
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { isExternal } from '@/utils/validate'
const props = defineProps({
to: {
type: String,
required: true
}
})
const isExtLink = computed(() => isExternal(props.to))
</script>
<template>
<a v-if="isExtLink" :href="to" target="_blank" rel="noopener">
<slot />
</a>
<router-link v-else :to="to">
<slot />
</router-link>
</template>
<template>
<span>
<el-button
@click="changeAutoRefresh"
:size="size"
>{{ autoRefreshText }}</el-button> <span class="tips">{{ countDownText }}</span>
</span>
</template>
<script setup lang="ts">
/**
* 自动刷新组件
* emit: refresh - 刷新事件
*/
import { ref, computed, onBeforeUnmount } from 'vue';
const props = defineProps({
// 刷新频率,默认:3秒
frequency: { type: Number, default: 3000 },
// 按钮大小
size: { type: String, default: 'default' }
});
const emit = defineEmits(['refresh']);
// 定时器
const timer = ref<NodeJS.Timeout | null>(null);
const countDownTimer = ref<NodeJS.Timeout | null>(null);
// 倒计时
const countDown = ref(0);
const autoRefreshText = computed(() => {
if (timer.value == null) {
return '开启自动刷新';
} else {
return '关闭自动刷新';
}
});
const countDownText = computed(() => {
if (timer.value == null) {
return '';
} else {
return countDown.value + ' 秒后自动刷新';
}
});
function autoRefresh() {
emit('refresh');
resetCountDown();
}
function countDownReduce() {
countDown.value--;
}
function resetCountDown() {
countDown.value = Math.ceil(props.frequency * 0.001);
}
function openAutoRefresh() {
resetCountDown();
// 启动读秒计时器
countDownTimer.value = setInterval(() => {
countDownReduce();
}, 1000);
// 启动自动刷新计时器
timer.value = setInterval(() => {
autoRefresh();
}, props.frequency);
}
function closeAutoRefresh() {
if (timer.value) clearInterval(timer.value);
if (countDownTimer.value) clearInterval(countDownTimer.value);
timer.value = null;
countDownTimer.value = null;
}
function changeAutoRefresh() {
if (timer.value) {
closeAutoRefresh();
} else {
openAutoRefresh();
}
}
// 组件销毁前清除定时器
onBeforeUnmount(() => {
closeAutoRefresh();
});
</script>
<style lang="scss" scoped>
.tips {
margin-left: 10px;
color: #666666;
font-size: 12px;
}
</style>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, type RouteLocationMatched } from 'vue-router'
// 当路由有redirect时,面包屑不显示这条路由
const isDashboard = (route: RouteLocationMatched): boolean => {
const name = route && route.name
if (!name) {
return false
}
return name.toString().trim().toLowerCase() === 'Home'.toLowerCase()
}
const getBreadcrumb = (route: RouteLocationMatched): { name: string, redirect?: string } => {
if (route.meta?.title) {
return { name: route.meta.title as string, redirect: route.redirect as string | undefined }
}
return { name: route.name as string }
}
const route = useRoute()
const levelList = ref<{ name: string, path?: string }[]>([])
const getBreadcrumbs = () => {
// 只显示有meta.title且不是隐藏的路由
const matched = route.matched.filter(item => item.meta && item.meta.title && !item.meta.hidden)
// 如果第一条不是首页则添加首页
if (matched.length > 0 && !isDashboard(matched[0])) {
matched.unshift({
path: '/',
name: 'Home',
meta: { title: '首页' }
} as unknown as RouteLocationMatched)
}
levelList.value = matched.map(item => {
const bread = getBreadcrumb(item)
return { name: bread.name, path: bread.redirect || item.path }
})
}
getBreadcrumbs()
// 监听路由变化
watch(
() => route.path,
() => {
getBreadcrumbs()
}
)
</script>
<template>
<el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
<span v-if="index === levelList.length - 1" class="no-redirect">{{ item.name }}</span>
<router-link v-else :to="item.path!">{{ item.name }}</router-link>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<style lang="scss" scoped>
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
.no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/store/app'
import { storeToRefs } from 'pinia'
defineProps({
isActive: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle-click'])
const appStore = useAppStore()
const { sidebar } = storeToRefs(appStore)
const isCollapse = computed(() => {
return !sidebar.value.opened
})
const toggleClick = () => {
emit('toggle-click')
}
</script>
<template>
<div class="hamburger-container" @click="toggleClick">
<el-icon :class="{'is-active': isCollapse}" class="hamburger-icon">
<Menu />
</el-icon>
</div>
</template>
<style lang="scss" scoped>
.hamburger-container {
padding: 0 15px;
height: 100%;
float: left;
cursor: pointer;
transition: background 0.3s;
display: flex;
align-items: center;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
.hamburger-icon {
font-size: 20px;
transform: rotate(0deg);
transition: transform 0.3s ease;
&.is-active {
transform: rotate(90deg);
}
}
}
</style>
<template>
<div class="select-project">
<el-select
v-model="currentValue"
placeholder="选择项目"
@change="handleChange"
:size="size"
style="width: 300px;"
clearable
>
<el-option
v-for="item in projectList"
:key="item.project"
:label="item.project"
:value="item.project"
/>
</el-select>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import * as projectApi from '@/api/project';
const props = defineProps({
scrapydServerId: {
type: String,
default: ''
},
value: {
type: String,
default: ''
},
size: {
type: String,
default: 'default'
}
});
const emit = defineEmits(['change', 'update:value', 'on-init']);
const projectList = ref<any[]>([]);
const currentValue = ref('');
// 获取项目列表
const getProjectList = async () => {
if (!props.scrapydServerId) {
return;
}
try {
const res = await projectApi.getProjectList({
scrapydServerId: props.scrapydServerId
});
if (res.code === 0) {
projectList.value = res.data || [];
emit('on-init', projectList.value);
}
} catch (error) {
console.error('获取项目列表失败:', error);
}
};
// 重置数据
const resetData = () => {
getProjectList();
};
// 处理选择变化
const handleChange = (value: string) => {
emit('update:value', value);
emit('change', value);
};
// 监听服务器ID变化
watch(() => props.scrapydServerId, (newVal) => {
if (newVal) {
resetData();
}
});
// 监听value变化
watch(() => props.value, (newVal) => {
currentValue.value = newVal;
});
onMounted(() => {
currentValue.value = props.value;
getProjectList();
});
// 对外暴露方法
defineExpose({
resetData
});
</script>
<style lang="scss" scoped>
.select-project {
display: inline-block;
}
</style>
<template>
<div class="text-left">
<el-form-item label="Scrapyd服务器">
<el-select
v-model="currentValue"
placeholder="请选择Scrapyd服务器"
style="width: 300px"
@change="handleChange"
clearable
size="default"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch } from 'vue'
import * as systemApi from '@/api/system'
const props = defineProps({
value: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['change', 'update:value', 'on-init'])
interface OptionItem {
label: string
value: string
}
const options = ref<OptionItem[]>([])
const currentValue = ref('')
// 获取Scrapyd服务器列表
const getScrapydServerList = async () => {
try {
const res = await systemApi.getScrapydServerList()
if (res.code === 0) {
options.value = res.data.list.map((item: any) => ({
label: item.server_name,
value: item.id
}))
// 设置初始值
if (options.value.length > 0) {
// 如果没有当前值,或者当前值在选项中不存在,则设置为第一个选项的值
const shouldSetDefault = !currentValue.value || !options.value.some(opt => opt.value === currentValue.value)
if (shouldSetDefault) {
currentValue.value = options.value[0].value
emit('update:value', currentValue.value)
emit('change', currentValue.value)
}
}
emit('on-init', options.value)
}
} catch (error) {
console.error('获取Scrapyd服务器列表失败:', error)
}
}
// 选择变更处理
const handleChange = (val: string) => {
emit('update:value', val)
emit('change', val)
}
// 监听props.value变化
watch(() => props.value, (newVal) => {
currentValue.value = newVal
})
onMounted(() => {
currentValue.value = props.value
getScrapydServerList()
})
</script>
export const ScheduleTypeEnum = {
// 指定一个服务器
ONLY_ONE_SERVER: '0',
// 随机轮询
RANDOM_SERVER: '1',
}
export const ScheduleTypeOptions = [
{
value: ScheduleTypeEnum.ONLY_ONE_SERVER,
label: '当前服务器',
},
{
value: ScheduleTypeEnum.RANDOM_SERVER,
label: '服务器轮询',
},
]
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const key = computed(() => route.path)
</script>
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<router-view :key="key" />
</transition>
</section>
</template>
<style scoped>
.app-main {
/*50 = navbar */
min-height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow-y: auto; /* 修改为auto允许垂直滚动 */
overflow-x: hidden; /* 防止水平滚动 */
}
.fixed-header+.app-main {
padding-top: 50px;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useAppStore } from '@/store/app'
import { storeToRefs } from 'pinia'
import Breadcrumb from '@/components/Breadcrumb/index.vue'
import Hamburger from '@/components/Hamburger/index.vue'
const router = useRouter()
const userStore = useUserStore()
const appStore = useAppStore()
const { sidebar } = storeToRefs(appStore)
const avatar = computed(() => userStore.avatar || 'user-avatar.gif')
const toggleSideBar = () => {
appStore.toggleSidebar()
}
const logout = async () => {
await userStore.logout()
router.push(`/login?redirect=${router.currentRoute.value.fullPath}`)
}
</script>
<template>
<div class="navbar">
<hamburger :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb class="breadcrumb-container" />
<div class="right-menu">
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<img :src="avatar" class="user-avatar">
<el-icon><CaretBottom /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item>
首页
</el-dropdown-item>
</router-link>
<el-dropdown-item divided @click="logout">
<span style="display:block;">退出</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
cursor: pointer;
transition: background .3s;
-webkit-tap-highlight-color:transparent;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
.breadcrumb-container {
float: left;
}
.right-menu {
float: right;
height: 100%;
line-height: 50px;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-block;
padding: 0 8px;
height: 100%;
font-size: 18px;
color: #5a5e66;
vertical-align: text-bottom;
&.hover-effect {
cursor: pointer;
transition: background .3s;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
}
.avatar-container {
margin-right: 30px;
.avatar-wrapper {
margin-top: 5px;
position: relative;
.user-avatar {
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 10px;
}
.el-icon {
cursor: pointer;
position: absolute;
right: -20px;
top: 15px;
font-size: 12px;
}
}
}
}
}
</style>
<script setup lang="ts">
import settings from '@/settings'
const props = defineProps({
collapse: {
type: Boolean,
required: true
}
})
const title = settings.title || 'fk spider web'
// Logo 图片可在后续使用实际的 logo 文件
// const logo = require('@/assets/logo.png')
const logo = '' // 暂时为空
</script>
<template>
<div class="sidebar-logo-container" :class="{'collapse': collapse}">
<transition name="sidebarLogoFade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="logo">
<h1 v-else class="sidebar-title">{{ title }}</h1>
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="logo">
<h1 class="sidebar-title">{{ title }}</h1>
</router-link>
</transition>
</div>
</template>
<style lang="scss" scoped>
.sidebar-logo-container {
position: relative;
width: 100%;
height: 50px;
line-height: 50px;
background: #2b2f3a;
text-align: center;
overflow: hidden;
& .sidebar-logo-link {
height: 100%;
width: 100%;
& .sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 12px;
}
& .sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
}
}
&.collapse {
.sidebar-logo {
margin-right: 0px;
}
}
}
.sidebarLogoFade-enter-active {
transition: opacity 1.5s;
}
.sidebarLogoFade-enter-from,
.sidebarLogoFade-leave-to {
opacity: 0;
}
</style>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PropType } from 'vue'
import { isExternal } from '@/utils/validate'
import AppLink from '@/components/AppLink/index.vue'
// 使用类型断言来处理路由项
type MenuItem = any
const props = defineProps({
item: {
type: Object as PropType<MenuItem>,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
})
// 定义要显示的唯一子项
const onlyOneChild = ref<MenuItem | null>(null)
// 是否总是显示根菜单
const alwaysShowRootMenu = computed(() => {
return props.item.meta?.alwaysShow || false
})
// 解析路径
const resolvePath = (routePath: string) => {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(props.basePath)) {
return props.basePath
}
return props.basePath ? props.basePath + '/' + routePath : routePath
}
// 是否只有一个要显示的子项
const hasOneShowingChild = (children: MenuItem[] = [], parent: MenuItem): boolean => {
if (!children) {
children = []
}
const showingChildren = children.filter(item => {
if (item.meta?.hidden) {
return false
} else {
// 临时设置(当只有一个子路由时使用)
onlyOneChild.value = item
return true
}
})
// 当只有一个子路由时,默认显示该子路由
if (showingChildren.length === 1) {
return true
}
// 当没有要显示的子路由时,则显示父路由
if (showingChildren.length === 0) {
onlyOneChild.value = {
...parent,
path: '',
meta: parent.meta
}
return true
}
return false
}
</script>
<template>
<div v-if="!item.meta?.hidden" class="sidebar-item-container">
<template v-if="hasOneShowingChild(item.children as MenuItem[], item) && (!onlyOneChild?.children?.length || onlyOneChild?.meta?.noShowingChildren) && !alwaysShowRootMenu">
<app-link
v-if="onlyOneChild?.meta"
:to="resolvePath(onlyOneChild.path)"
class="sidebar-link"
>
<el-menu-item :index="resolvePath(onlyOneChild.path)" >
<el-icon v-if="onlyOneChild.meta?.icon">
<component :is="onlyOneChild.meta.icon" />
</el-icon>
<span v-if="onlyOneChild.meta?.title">{{ onlyOneChild.meta.title }}</span>
</el-menu-item>
</app-link>
</template>
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)">
<template #title>
<el-icon v-if="item.meta?.icon">
<component :is="item.meta.icon" />
</el-icon>
<span v-if="item.meta?.title">{{ item.meta.title }}</span>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-sub-menu>
</div>
</template>
<style lang="scss" scoped>
.sidebar-link {
text-decoration: none;
}
.el-menu-item, .el-sub-menu {
background-color: transparent !important;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSettingsStore } from '@/store/settings'
import { useAppStore } from '@/store/app'
import { storeToRefs } from 'pinia'
import SidebarItem from './SidebarItem.vue'
import Logo from './Logo.vue'
const route = useRoute()
const router = useRouter()
const settingsStore = useSettingsStore()
const appStore = useAppStore()
const { sidebar } = storeToRefs(appStore)
const { sidebarLogo } = storeToRefs(settingsStore)
// 获取所有路由配置,而不仅仅是匹配的路由
const routes = computed(() => {
// 获取路由器中的所有路由
return router.options.routes.filter(item => {
// 过滤掉设置了hidden的路由
// @ts-ignore - hidden 属性在实际路由对象中存在,但TypeScript类型定义中没有
return !(item.hidden || (item.meta && item.meta.hidden))
})
})
const activeMenu = computed(() => {
const { meta, path } = route
// 如果设置了 path,侧边栏会高亮显示你设置的路径
if (meta.activeMenu) {
return meta.activeMenu as string
}
return path
})
const isCollapse = computed(() => !sidebar.value.opened)
</script>
<template>
<div class="sidebar-container-wrapper">
<logo v-if="sidebarLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
background-color="#304156"
text-color="#bfcbd9"
:unique-opened="false"
active-text-color="#409eff"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item
v-for="route in routes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as vars;
.sidebar-container-wrapper {
height: 100%;
display: flex;
flex-direction: column;
// 主题定制
:deep(.el-menu) {
border: none;
height: 100%;
width: 100% !important;
}
:deep(.submenu-title-noDropdown:hover), :deep(.el-submenu__title:hover) {
background-color: vars.$menuHover !important;
}
:deep(.is-active > .el-submenu__title) {
color: vars.$subMenuActiveText !important;
}
.el-menu-item, .el-sub-menu {
background-color: transparent !important;
}
}
.scrollbar-wrapper {
flex: 1;
overflow-x: hidden !important;
}
</style>
export { default as Navbar } from './Navbar.vue'
export { default as Sidebar } from './Sidebar/index.vue'
export { default as AppMain } from './AppMain.vue'
<script lang="ts">
import { defineComponent, computed } from 'vue'
import { Navbar, Sidebar, AppMain } from './components/index.ts'
// @ts-ignore: 导入 JS 混入文件,忽略类型检查
import ResizeMixin from './mixin/ResizeHandler.js'
import { useAppStore } from '@/store/app'
import { useSettingsStore } from '@/store/settings'
import { storeToRefs } from 'pinia'
export default defineComponent({
name: 'Layout',
components: {
Navbar,
Sidebar,
AppMain
},
mixins: [ResizeMixin],
setup() {
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const { sidebar, device } = storeToRefs(appStore)
const { fixedHeader } = storeToRefs(settingsStore)
const handleClickOutside = () => {
appStore.closeSideBar({ withoutAnimation: false })
}
const classObj = computed(() => {
return {
hideSidebar: !sidebar.value.opened,
openSidebar: sidebar.value.opened,
withoutAnimation: sidebar.value.withoutAnimation,
mobile: device.value === 'mobile'
}
})
return {
sidebar,
device,
fixedHeader,
classObj,
handleClickOutside
}
}
})
</script>
<template>
<div :class="classObj" class="app-wrapper">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
<sidebar class="sidebar-container" />
<div class="main-container">
<div :class="{'fixed-header':fixedHeader}">
<navbar />
</div>
<app-main />
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/styles/variables' as vars;
@use '@/styles/mixin';
.app-wrapper {
@include mixin.clearfix;
position: relative;
height: 100%;
width: 100%;
&.mobile.openSidebar{
position: fixed;
top: 0;
}
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{vars.$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px)
}
.mobile .fixed-header {
width: 100%;
}
</style>
\ No newline at end of file
import { useAppStore } from '@/store/app'
const { body } = document
const WIDTH = 992 // refer to Bootstrap's responsive design
export default {
watch: {
$route(route) {
const appStore = useAppStore()
if (appStore.device === 'mobile' && appStore.sidebar.opened) {
appStore.closeSideBar({ withoutAnimation: false })
}
}
},
beforeMount() {
window.addEventListener('resize', this.$_resizeHandler)
},
beforeUnmount() {
window.removeEventListener('resize', this.$_resizeHandler)
},
mounted() {
const isMobile = this.$_isMobile()
const appStore = useAppStore()
if (isMobile) {
appStore.toggleDevice('mobile')
appStore.closeSideBar({ withoutAnimation: true })
}
},
methods: {
// use $_ for mixins properties
// https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
$_isMobile() {
const rect = body.getBoundingClientRect()
return rect.width - 1 < WIDTH
},
$_resizeHandler() {
if (!document.hidden) {
const isMobile = this.$_isMobile()
const appStore = useAppStore()
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop')
if (isMobile) {
appStore.closeSideBar({ withoutAnimation: true })
}
}
}
}
}
import { createApp } from 'vue'
import 'virtual:uno.css'
import App from './App.vue'
import router from './router'
import pinia from './store'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import {
UserFilled,
Lock,
View,
Hide,
CaretBottom,
HomeFilled,
Menu,
Document,
Tickets,
List,
Folder,
Files,
Loading,
VideoPlay,
Refresh,
Back,
Upload,
Plus,
Monitor,
Key,
House,
Clock,
Message,
DataAnalysis,
Notebook,
Setting
} from '@element-plus/icons-vue'
import 'normalize.css/normalize.css'
import './styles/index.scss'
import './style.css'
import './permission' // 权限控制
const app = createApp(App)
// 注册 Element Plus 图标
app.component('Monitor', Monitor)
app.component('Document', Document)
app.component('List', List)
app.component('Key', Key)
app.component('House', House)
app.component('Clock', Clock)
app.component('Message', Message)
app.component('DataAnalysis', DataAnalysis)
app.component('Notebook', Notebook)
app.component('Setting', Setting)
app.component('UserFilled', UserFilled)
app.component('Lock', Lock)
app.component('View', View)
app.component('Hide', Hide)
app.component('CaretBottom', CaretBottom)
app.component('HomeFilled', HomeFilled)
app.component('Menu', Menu)
app.component('Tickets', Tickets)
app.component('Folder', Folder)
app.component('Files', Files)
app.component('Loading', Loading)
app.component('VideoPlay', VideoPlay)
app.component('Refresh', Refresh)
app.component('Back', Back)
app.component('Upload', Upload)
app.component('Plus', Plus)
app.use(router)
app.use(pinia)
app.use(ElementPlus, { size: 'default' })
app.mount('#app')
import router from './router'
import { useUserStore } from './store/user'
import { getToken } from '@/utils/auth'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// NProgress 配置
NProgress.configure({ showSpinner: false })
// 白名单路由
const whiteList = ['/login', '/404']
router.beforeEach(async (to, from, next) => {
// 开始进度条
NProgress.start()
const userStore = useUserStore()
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login') {
// 已登录,重定向到首页
next({ path: '/' })
NProgress.done()
} else {
// 确定用户是否有用户信息
const hasGetUserInfo = userStore.name !== ''
if (hasGetUserInfo) {
next()
} else {
try {
// 获取用户信息
await userStore.getInfo()
next()
} catch (error) {
// 移除令牌并重新登录
await userStore.resetToken()
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
} else {
// 没有令牌
if (whiteList.indexOf(to.path) !== -1) {
// 在白名单中直接通过
next()
} else {
// 重定向到登录页
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
router.afterEach(() => {
// 完成进度条
NProgress.done()
})
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'
/**
* 路由配置
*
* hidden: true 设为true则不会显示在侧边栏
* alwaysShow: true 设为true则总是显示根菜单
* redirect: noRedirect 设为noRedirect则不会在面包屑中重定向
* name:'router-name' 用于 <keep-alive> (必须设置!!!)
* meta : {
* roles: ['admin','editor'] 控制页面角色(可设置多个角色)
* title: 'title' 显示在侧边栏和面包屑上的名称(推荐设置)
* icon: 'svg-name' 侧边栏图标
* breadcrumb: false 设为false则不显示在面包屑中
* activeMenu: '/example/list' 如果设置路径,侧边栏将高亮你设置的路径
* }
*/
/**
* constantRoutes
* 没有权限要求的基础页面
* 所有角色都可访问
*/
export const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' },
hidden: true
} as unknown as RouteRecordRaw,
{
path: '/404',
component: () => import('@/views/404.vue'),
hidden: true
} as unknown as RouteRecordRaw,
{
path: '',
component: Layout,
redirect: '/dashboard',
children: [{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/home/home.vue'),
meta: { title: '控制台', icon: 'Monitor' }
}]
} as unknown as RouteRecordRaw,
{
path: "/scrapyd-server",
component: Layout,
children: [
{
path: "list",
name: "scrapyd-server-list",
component: () => import("@/views/scrapyd-server/index.vue"),
meta: { title: "Scrapyd服务", icon: "Key" }
},
]
} as unknown as RouteRecordRaw,
{
path: '/project',
component: Layout,
redirect: '/project/list',
name: 'project',
meta: { title: '项目', icon: 'House' },
children: [
{
path: 'list',
name: 'project-list',
component: () => import('@/views/project/index.vue'),
meta: { title: '项目列表', icon: 'House' }
},
{
path: 'version-list',
name: 'project-version',
component: () => import('@/views/project/versions.vue'),
meta: { title: '版本列表', icon: 'List' },
hidden: true
}
]
} as unknown as RouteRecordRaw,
{
path: '/spider',
component: Layout,
children: [
{
path: 'list',
name: 'spider-list',
component: () => import('@/views/spider/index.vue'),
meta: { title: 'Spider', icon: 'List' }
}
]
} as unknown as RouteRecordRaw,
{
path: '/schedule',
component: Layout,
children: [
{
path: 'list',
name: 'schedule-list',
component: () => import('@/views/schedule/index.vue'),
meta: { title: '定时任务', icon: 'Clock' }
}
]
} as unknown as RouteRecordRaw,
{
path: "/schedule-log",
component: Layout,
children: [
{
path: 'list',
name: 'schedule-log-list',
component: () => import('@/views/schedule/logs.vue'),
meta: { title: '调度日志', icon: 'Message' },
}
]
} as unknown as RouteRecordRaw,
{
path: "/job",
component: Layout,
children: [
{
path: "list",
name: "job-list",
component: () => import("@/views/job/index.vue"),
meta: { title: "任务列表", icon: "List" },
}
]
} as unknown as RouteRecordRaw,
{
path: '/stats',
component: Layout,
children: [
{
path: 'list',
name: 'stats-list',
component: () => import('@/views/stats/index.vue'),
meta: { title: '数据统计', icon: 'DataAnalysis' }
}
]
} as unknown as RouteRecordRaw,
{
path: "/logs",
component: Layout,
redirect: "/logs/list",
name: "logs",
meta: { title: "日志", icon: "Notebook" },
hidden: true,
children: [
{
path: "list",
name: "logs-list",
component: () => import("@/views/log/LogIndex.vue"),
meta: { title: "运行日志", icon: "Notebook" }
},
{
path: "project",
name: "logs-project",
component: () => import("@/views/log/LogProject.vue"),
meta: { title: "项目日志", icon: "Notebook" },
hidden: true
},
{
path: "spider",
name: "logs-project-spider",
component: () => import("@/views/log/LogSpider.vue"),
meta: { title: "Spider日志", icon: "Notebook" },
hidden: true
},
{
path: "job",
name: "logs-project-spider-job",
component: () => import("@/views/log/LogJob.vue"),
meta: { title: "任务日志", icon: "Notebook" },
hidden: true
}
]
} as unknown as RouteRecordRaw,
{
path: "/system",
component: Layout,
redirect: "/system/login-list",
name: "system",
meta: { title: "系统管理", icon: "Setting" },
hidden: true,
children: [
{
path: "login-list",
name: "system-login-list",
component: () => import('@/views/system/SystemIndex.vue'),
meta: { title: "登录日志", icon: "Setting" }
},
]
} as unknown as RouteRecordRaw,
// 404 页面必须放在最后 !!!
// { path: '/:pathMatch(.*)*', redirect: '/404', hidden: true } as unknown as RouteRecordRaw
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
scrollBehavior: () => ({ top: 0 })
})
export function resetRouter() {
const newRouter = createRouter({
history: createWebHistory(),
routes: constantRoutes,
scrollBehavior: () => ({ top: 0 })
})
// @ts-ignore
router.matcher = newRouter.matcher
}
export default router
interface ISettings {
title: string
fixedHeader: boolean
sidebarLogo: boolean
}
const settings: ISettings = {
title: 'Spider Admin Pro',
fixedHeader: true,
sidebarLogo: true
}
export default settings
import { defineStore } from 'pinia'
interface AppState {
sidebar: {
opened: boolean
withoutAnimation: boolean
},
device: 'desktop' | 'mobile'
}
export const useAppStore = defineStore('app', {
state: (): AppState => ({
sidebar: {
opened: localStorage.getItem('sidebarStatus')
? !!+localStorage.getItem('sidebarStatus')! // 将获取到的值转换为布尔值 其中 + 为先转为数字 !! 为转成 bool 值
: true,
withoutAnimation: false
},
device: 'desktop',
}),
actions: {
toggleSidebar() {
this.sidebar.opened = !this.sidebar.opened
this.sidebar.withoutAnimation = false
if (this.sidebar.opened) {
localStorage.setItem('sidebarStatus', '1')
} else {
localStorage.setItem('sidebarStatus', '0')
}
},
closeSideBar(options: { withoutAnimation: boolean }) {
this.sidebar.opened = false
this.sidebar.withoutAnimation = options.withoutAnimation
localStorage.setItem('sidebarStatus', '0')
},
toggleDevice(device: 'desktop' | 'mobile') {
this.device = device
}
}
})
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
import { defineStore } from 'pinia'
import defaultSettings from '@/settings'
const { fixedHeader, sidebarLogo } = defaultSettings
interface SettingsState {
fixedHeader: boolean
sidebarLogo: boolean
}
export const useSettingsStore = defineStore('settings', {
state: (): SettingsState => ({
fixedHeader,
sidebarLogo
}),
actions: {
changeSetting({ key, value }: { key: string, value: any }) {
// @ts-ignore
this[key] = value
}
}
})
import { defineStore } from 'pinia'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { resetRouter } from '@/router'
import { useAppStore } from './app'
import { login } from '@/api/user'
interface UserState {
token: string
name: string
avatar: string
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
token: getToken(),
name: '',
avatar: 'user-avatar.gif'
}),
actions: {
// 用户登录
async login(userInfo: {username: string, password: string}) {
const { username, password } = userInfo
try {
const res = await login({
username: username.trim(),
password: password
})
if (res.code === 0) {
const token = res.data.token
this.token = token
setToken(token)
return Promise.resolve()
} else {
return Promise.reject(res.message)
}
} catch (error) {
return Promise.reject(error)
}
},
// 获取用户信息
async getInfo() {
// 这里保留了 getInfo 方法,后续会根据实际需求修改
this.name = 'Admin'
this.avatar = 'user-avatar.gif'
return {
name: this.name,
avatar: this.avatar,
roles: ['admin']
}
},
// 用户登出
async logout() {
this.token = ''
this.name = ''
removeToken()
resetRouter()
},
// 重置令牌
resetToken() {
this.token = ''
removeToken()
},
// 侧边栏相关
toggleSidebar() {
const appStore = useAppStore()
appStore.toggleSidebar()
}
}
})
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
/* max-width: 1280px; */
margin: 0 auto;
/* padding: 2rem; */
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@use 'variables' as vars;
@use 'mixin';
// 基本布局样式
.app-wrapper {
@include mixin.clearfix;
position: relative;
height: 100%;
width: 100%;
&.mobile.openSidebar {
position: fixed;
top: 0;
}
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{vars.$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px);
}
.mobile .fixed-header {
width: 100%;
}
// 主容器
#app {
.main-container {
min-height: 100%;
transition: margin-left 0.28s;
margin-left: vars.$sideBarWidth;
position: relative;
}
// 移动端响应
.mobile {
.main-container {
margin-left: 0;
}
.sidebar-container {
transition: transform 0.28s;
width: vars.$sideBarWidth !important;
}
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-#{vars.$sideBarWidth}, 0, 0);
}
}
}
.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
}
@mixin clearfix {
&:after {
content: "";
display: table;
clear: both;
}
}
@mixin scrollBar {
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
@mixin relative {
position: relative;
width: 100%;
height: 100%;
}
@use 'variables' as vars;
// 侧边栏样式
#app {
.sidebar-container {
transition: width 0.28s;
width: vars.$sideBarWidth !important;
background-color: vars.$menuBg;
height: 100%;
position: fixed;
font-size: 0px;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out,
0s padding-right ease-in-out;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.el-scrollbar__bar.is-vertical {
right: 0px;
}
.el-scrollbar {
height: 100%;
}
&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
}
}
.el-scrollbar__view {
height: 100%;
}
.el-menu {
border: none;
height: 100%;
width: 100% !important;
}
// menu hover
.submenu-title-noDropdown,
.el-sub-menu__title {
&:hover {
background-color: vars.$menuHover !important;
}
}
.is-active > .el-sub-menu__title {
color: vars.$subMenuActiveText !important;
}
& .nest-menu .el-sub-menu > .el-sub-menu__title,
& .el-sub-menu .el-menu-item {
min-width: vars.$sideBarWidth !important;
background-color: vars.$subMenuBg !important;
&:hover {
background-color: vars.$subMenuHover !important;
}
}
}
.hideSidebar {
.sidebar-container {
width: 54px !important;
}
.main-container {
margin-left: 54px;
}
.submenu-title-noDropdown {
padding: 0 !important;
position: relative;
.el-tooltip {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-menu-item {
&.is-active {
background-color: vars.$menuHover !important;
}
}
.el-sub-menu {
overflow: hidden;
& > .el-sub-menu__title {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
.el-sub-menu__icon-arrow {
display: none;
}
}
}
.el-menu--collapse {
.el-sub-menu {
& > .el-sub-menu__title {
& > span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
}
}
}
}
.el-menu--collapse .el-menu .el-sub-menu {
min-width: vars.$sideBarWidth !important;
}
}
// 垂直菜单样式
.el-menu--vertical {
& > .el-menu {
.svg-icon {
margin-right: 16px;
}
}
.nest-menu .el-sub-menu > .el-sub-menu__title,
.el-menu-item {
&:hover {
// you can use $subMenuHover
background-color: vars.$menuHover !important;
}
}
// 下拉菜单滚动条样式
> .el-menu--popup {
max-height: 100vh;
overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
}
// global transition css
/* fade */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.28s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* fade-transform */
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 0.5s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* breadcrumb transition */
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.5s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(-20px);
}
// Base variables
$menuText: #bfcbd9;
$menuActiveText: #409eff;
$subMenuActiveText: #f4f4f5;
$menuBg: #304156;
$menuHover: #263445;
$subMenuBg: #1f2d3d;
$subMenuHover: #001528;
$sideBarWidth: 150px;
// 菜单item字体大小和高度
$sideBarMenuItemFontSize: 14px;
$sideBarMenuItemHeight: 49px;
$sideBarMenuItemLineHeight: 49px;
// the :export directive is the magic sauce for webpack
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
:export {
menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
sideBarMenuItemFontSize: $sideBarMenuItemFontSize;
sideBarMenuItemHeight: $sideBarMenuItemHeight;
sideBarMenuItemLineHeight: $sideBarMenuItemLineHeight;
}
@use 'variables' as vars;
@use 'mixin';
@forward 'layout';
@forward 'sidebar';
@forward 'transition';
/* 全局样式 */
html, body {
margin: 0;
padding: 0;
font-size: 14px;
height: 100%;
width: 100%;
overflow-x: hidden; /* 防止浏览器默认滚动条 */
}
body {
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
/* 页面容器 */
.app-container {
padding: 20px;
box-sizing: border-box;
height: 100%;
// max-width: 1200px;
margin: 0 auto;
}
/* 间距 */
.ml-sm { margin-left: 5px; }
.ml-md { margin-left: 10px; }
.ml-lg { margin-left: 20px; }
.mt-sm { margin-top: 5px; }
.mt-md { margin-top: 10px; }
.mt-lg { margin-top: 20px; }
/* 辅助样式 */
.text-center { text-align: center; }
.text-right { text-align: right; }
/* 浮动 */
.pull-left { float: left; }
.pull-right { float: right; }
.clearfix:after {
content: "";
display: table;
clear: both;
}
declare module '@element-plus/icons-vue';
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
icon?: string
hidden?: boolean
alwaysShow?: boolean
breadcrumb?: boolean
noCache?: boolean
activeMenu?: string
noShowingChildren?: boolean
roles?: string[]
permissions?: string[]
}
}
declare module '*.scss' {
const content: Record<string, string>;
export default content;
}
declare module '*.css' {
const content: Record<string, string>;
export default content;
}
declare module '*.less' {
const content: Record<string, string>;
export default content;
}
declare module '*.sass' {
const content: Record<string, string>;
export default content;
}
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
export function getToken(): string {
return Cookies.get(TokenKey) || ''
}
export function setToken(token: string) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getToken, removeToken } from '@/utils/auth'
// 创建axios实例
const instance = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API || '',
timeout: 10000, // 毫秒
headers: {
'Content-Type': 'application/json; charset=utf-8'
}
})
// 请求拦截器
instance.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
// 保持与原项目相同的Token头设置
config.headers['Token'] = token
}
return config
},
(err) => {
console.log('请求出错')
return Promise.reject(err)
}
)
// 响应拦截器
instance.interceptors.response.use(
(res) => {
let result = res.data
if (typeof res.data === 'string') {
try {
result = JSON.parse(res.data)
} catch (e) {
// 不是JSON格式,保持原样
}
} else {
// 4000 token无效或者过期
if (result.code === 4000) {
removeToken()
window.location.reload()
}
if (result.code === 0) {
result.ok = true
} else {
result.ok = false
ElMessage.error(result.msg || '请求失败')
}
}
return result
},
(err) => {
ElMessage.error('网络请求出错, 请检查网络')
return Promise.reject(err)
}
)
// HTTP 请求方法常量
export const GET = 'get'
export const POST = 'post'
export const PUT = 'put'
export const DELETE = 'delete'
// 请求函数类型
export interface RequestConfig {
url: string;
method: 'get' | 'post' | 'put' | 'delete';
params?: any;
data?: any;
headers?: Record<string, string>;
}
export interface ApiResponse<T = any> {
code: number
data: T
message: string
}
export interface QueryParams {
page?: number
size?: number
project?: string
spider?: string
scrapydServerId: string
order_prop?: string
order_type?: string
[key: string]: any
}
// 请求函数
export const request = (config: RequestConfig) => {
return instance(config);
}
export function isExternal(path: string): boolean {
return /^(https?:|mailto:|tel:)/.test(path)
}
// 校验用户名
export const validateUsername = (rule: any, value: string, callback: Function) => {
if (value.trim().length < 3) {
callback(new Error('用户名不能少于3个字符'))
} else {
callback()
}
}
// 校验密码
export const validatePassword = (rule: any, value: string, callback: Function) => {
if (value.length < 6) {
callback(new Error('密码不能少于6个字符'))
} else {
callback()
}
}
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/')
}
</script>
<template>
<div class="wscn-http404-container">
<div class="wscn-http404">
<div class="pic-404">
<img class="pic-404__parent" src="@/assets/404_images/404.svg" alt="404">
<img class="pic-404__child left" src="@/assets/404_images/404_cloud.svg" alt="404">
<img class="pic-404__child mid" src="@/assets/404_images/404_cloud.svg" alt="404">
<img class="pic-404__child right" src="@/assets/404_images/404_cloud.svg" alt="404">
</div>
<div class="bullshit">
<div class="bullshit__oops">OOPS!</div>
<div class="bullshit__info">页面不存在</div>
<div class="bullshit__headline">您访问的页面找不到了...</div>
<div class="bullshit__info">请检查您输入的URL是否正确</div>
<a class="bullshit__return-home" @click="goHome">返回首页</a>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.wscn-http404-container {
transform: translate(-50%, -50%);
position: absolute;
top: 40%;
left: 50%;
}
.wscn-http404 {
position: relative;
width: 1200px;
padding: 0 50px;
overflow: hidden;
.pic-404 {
position: relative;
float: left;
width: 600px;
overflow: hidden;
&__parent {
width: 100%;
}
&__child {
position: absolute;
&.left {
width: 80px;
top: 17px;
left: 220px;
opacity: 0;
animation-name: cloudLeft;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
&.mid {
width: 46px;
top: 10px;
left: 420px;
opacity: 0;
animation-name: cloudMid;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1.2s;
}
&.right {
width: 62px;
top: 100px;
left: 500px;
opacity: 0;
animation-name: cloudRight;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
@keyframes cloudLeft {
0% {
top: 17px;
left: 220px;
opacity: 0;
}
20% {
top: 33px;
left: 188px;
opacity: 1;
}
80% {
top: 81px;
left: 92px;
opacity: 1;
}
100% {
top: 97px;
left: 60px;
opacity: 0;
}
}
@keyframes cloudMid {
0% {
top: 10px;
left: 420px;
opacity: 0;
}
20% {
top: 40px;
left: 360px;
opacity: 1;
}
70% {
top: 130px;
left: 180px;
opacity: 1;
}
100% {
top: 160px;
left: 120px;
opacity: 0;
}
}
@keyframes cloudRight {
0% {
top: 100px;
left: 500px;
opacity: 0;
}
20% {
top: 120px;
left: 460px;
opacity: 1;
}
80% {
top: 180px;
left: 340px;
opacity: 1;
}
100% {
top: 200px;
left: 300px;
opacity: 0;
}
}
}
}
.bullshit {
position: relative;
float: left;
width: 300px;
padding: 30px 0;
overflow: hidden;
&__oops {
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
opacity: 0;
margin-bottom: 20px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-fill-mode: forwards;
}
&__headline {
font-size: 20px;
line-height: 24px;
color: #222;
font-weight: bold;
opacity: 0;
margin-bottom: 10px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.2s;
animation-fill-mode: forwards;
}
&__info {
font-size: 13px;
line-height: 21px;
color: grey;
opacity: 0;
margin-bottom: 30px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.4s;
animation-fill-mode: forwards;
}
&__return-home {
display: block;
float: left;
width: 110px;
height: 36px;
background: #1482f0;
border-radius: 100px;
text-align: center;
color: #ffffff;
opacity: 0;
font-size: 14px;
line-height: 36px;
cursor: pointer;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.6s;
animation-fill-mode: forwards;
}
@keyframes slideUp {
0% {
transform: translateY(60px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
}
}
</style>
<template>
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="title">{{ title }}</div>
<div class="desc">{{ desc }}</div>
</div>
<slot name="tools"></slot>
</div>
<div class="card-body">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
defineProps({
title: {
type: String,
default: ''
},
desc: {
type: String,
default: ''
}
})
</script>
<style lang="scss" scoped>
.card {
background: #fff;
border-radius: 5px;
margin-bottom: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
&-header {
padding: 15px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
&-title {
.title {
font-size: 16px;
font-weight: 500;
}
.desc {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
}
&-body {
padding: 20px;
}
}
</style>
<template>
<home-card desc="Config" title="系统配置">
<el-form ref="formRef" :model="form" label-width="120px" v-loading="loading">
<el-form-item label="Scrapyd服务器数量">
<span style="margin-left: 20px">{{ scrapyd_count }}</span>
</el-form-item>
<el-form-item label="默认用户名">
<el-input v-model="form.username" disabled />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="请输入新密码,不修改请留空"
/>
</el-form-item>
<el-form-item label="Token过期时间">
<el-input-number
v-model="form.token_expire_minutes"
:min="1"
:max="10080"
/>
<span class="form-tip">分钟</span>
</el-form-item>
<el-form-item label="调度历史保留">
<el-input-number
v-model="form.schedule_history_keep_days"
:min="1"
:max="365"
/>
<span class="form-tip"></span>
</el-form-item>
<el-form-item label="登录历史保留">
<el-input-number
v-model="form.login_history_keep_days"
:min="1"
:max="365"
/>
<span class="form-tip"></span>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm" :loading="submitting">保存</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</home-card>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import HomeCard from './HomeCard.vue'
import * as systemApi from '@/api/system'
const formRef = ref<FormInstance>()
const loading = ref(false)
const submitting = ref(false)
const form = reactive({
username: 'admin',
password: '',
token_expire_minutes: 1440, // 默认1天
schedule_history_keep_days: 30,
login_history_keep_days: 30
})
// 获取系统配置
const getSystemConfig = async () => {
loading.value = true
try {
const res = await systemApi.getSystemConfig()
if (res.code === 0) {
// 避免直接覆盖整个表单,只更新需要的字段
form.username = res.data.username || 'admin'
form.token_expire_minutes = res.data.token_expire_minutes || 1440
form.schedule_history_keep_days = res.data.schedule_history_keep_days || 30
form.login_history_keep_days = res.data.login_history_keep_days || 30
// 不设置密码,避免无意修改
form.password = ''
}
} catch (error) {
console.error('获取系统配置失败:', error)
} finally {
loading.value = false
}
}
// 提交表单
const submitForm = async () => {
if (!formRef.value) return
submitting.value = true
try {
// 如果密码为空,不包含密码字段
const data = form.password
? { ...form }
: {
username: form.username,
token_expire_minutes: form.token_expire_minutes,
schedule_history_keep_days: form.schedule_history_keep_days,
login_history_keep_days: form.login_history_keep_days
}
const res = await systemApi.updateSystemConfig(data)
if (res.code === 0) {
ElMessage({
type: 'success',
message: '保存成功!'
})
// 清空密码字段
form.password = ''
}
} catch (error) {
console.error('更新系统配置失败:', error)
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
getSystemConfig()
}
const scrapyd_count = ref(0)
const getScrapydServerListInfo = async () => {
try {
const res = await systemApi.getScrapydServerList()
if (res.code === 0) {
scrapyd_count.value = res.data.total
}
} catch (error) {
console.error('获取Scrapyd服务器列表失败:', error)
}
}
onMounted(() => {
getSystemConfig()
getScrapydServerListInfo()
})
</script>
<style lang="scss" scoped>
.form-tip {
margin-left: 10px;
color: #909399;
font-size: 14px;
}
</style>
<template>
<home-card desc="System" title="系统数据">
<active-plate :info-list="list" />
</home-card>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch } from 'vue'
import ActivePlate from '@/components/ActivePlate.vue'
import HomeCard from './HomeCard.vue'
import * as systemApi from '@/api/system'
const props = defineProps({
scrapydServerId: {
type: String,
default: ''
}
})
const list = ref([])
const getData = async () => {
try {
const res = await systemApi.getSystemData({
scrapydServerId: props.scrapydServerId
})
if (res.code === 0) {
// 将后端数据转换为组件需要的格式
list.value = res.data.map((item: any, index: number) => {
// 根据数据类型选择不同的图标
let icon = 'Monitor'
if (item.title.includes('项目')) {
icon = 'Files'
} else if (item.title.includes('爬虫')) {
icon = 'Lock'
} else if (item.title.includes('版本')) {
icon = 'View'
} else if (item.title.includes('任务')) {
icon = 'Folder'
} else if (item.title.includes('服务')) {
icon = 'Upload'
}
return {
title: item.title,
icon: icon,
count: item.count || 0,
color: getColorByIndex(index)
}
})
}
} catch (error) {
console.error('获取系统数据失败:', error)
}
}
// 根据索引获取颜色
const getColorByIndex = (index: number): string => {
const colors = ['#11A0F8', '#FFBB44', '#7ACE4C', '#36CBCB', '#F47F92', '#FBD437']
return colors[index % colors.length]
}
// 监听服务器ID变化,重新获取数据
watch(() => props.scrapydServerId, (newVal) => {
if (newVal) {
getData()
}
})
onMounted(() => {
// 只有当scrapydServerId存在时才获取数据
if (props.scrapydServerId) {
getData()
}
})
</script>
<style lang="scss" scoped>
</style>
<template>
<div>
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="12">
<home-system-panel
title="磁盘信息"
desc="Disk"
:list="diskUsageList"
:value="String(diskUsage.percent)"
subtext="磁盘使用率"
/>
</el-col>
<el-col :xs="24" :sm="24" :md="12">
<home-system-panel
title="内存信息"
desc="Memory"
:list="virtualMemoryList"
:value="String(virtualMemory.percent)"
subtext="内存使用率"
/>
</el-col>
</el-row>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import HomeSystemPanel from './HomeSystemPanel.vue'
import * as systemApi from '@/api/system'
const virtualMemory = ref<{
total_format: string
used_format: string
available_format: string
percent: number
}>({
total_format: '0',
used_format: '0',
available_format: '0',
percent: 0
})
interface VirtualMemoryType {
value: string
name: string
color: string
}
const virtualMemoryList = ref<VirtualMemoryType[]>([])
interface DiskUsageType {
total_format: string
used_format: string
free_format: string
percent: number
}
const diskUsage = ref<DiskUsageType>({
total_format: '0',
used_format: '0',
free_format: '0',
percent: 0
})
interface DiskUsageListType {
value: string
name: string
color: string
}
const diskUsageList = ref<DiskUsageListType[]>([])
const getData = async () => {
try {
const res = await systemApi.getSystemInfo()
if (res.code === 0) {
virtualMemory.value = res.data.virtual_memory
virtualMemoryList.value = [
{
value: virtualMemory.value.total_format,
name: '总内存',
color: '#3AA1FF'
},
{
value: virtualMemory.value.used_format,
name: '已用内存',
color: '#36CBCB'
},
{
value: virtualMemory.value.available_format,
name: '可用内存',
color: '#4ECB73'
}
]
diskUsage.value = res.data.disk_usage
diskUsageList.value = [
{
value: diskUsage.value.total_format,
name: '总大小',
color: '#FBD437'
},
{
value: diskUsage.value.used_format,
name: '已用大小',
color: '#36CBCB'
},
{
value: diskUsage.value.free_format,
name: '剩余大小',
color: '#F47F92'
}
]
}
} catch (error) {
console.error('获取系统信息失败:', error)
}
}
onMounted(() => {
getData()
})
</script>
<style lang="scss" scoped>
</style>
<template>
<home-card :title="title" :desc="desc">
<div class="system-panel">
<div class="chart-container">
<el-progress
type="dashboard"
:percentage="parseInt(value)"
:color="getProgressColor"
:stroke-width="10"
>
<template #default>
<div class="progress-content">
<span class="progress-value">{{ parseInt(value) }}%</span>
<span class="progress-text">{{ subtext }}</span>
</div>
</template>
</el-progress>
</div>
<div class="info-list">
<div class="info-item" v-for="(item, index) in list" :key="index">
<div class="info-label">
<span class="color-dot" :style="{ background: item.color }"></span>
<span>{{ item.name }}</span>
</div>
<div class="info-value">{{ item.value }}</div>
</div>
</div>
</div>
</home-card>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import HomeCard from './HomeCard.vue'
const props = defineProps({
title: {
type: String,
default: ''
},
desc: {
type: String,
default: ''
},
list: {
type: Array as () => {color: string, name: string, value: string}[],
default: () => []
},
value: {
type: String,
default: 0
},
subtext: {
type: String,
default: ''
}
})
const getProgressColor = computed(() => {
const value = parseInt(props.value as string)
if (value < 60) {
return '#67C23A'
} else if (value < 80) {
return '#E6A23C'
} else {
return '#F56C6C'
}
})
</script>
<style lang="scss" scoped>
.system-panel {
display: flex;
flex-wrap: wrap;
.chart-container {
flex: 0 0 200px;
display: flex;
justify-content: center;
align-items: center;
}
.progress-content {
display: flex;
flex-direction: column;
align-items: center;
.progress-value {
font-size: 24px;
font-weight: bold;
}
.progress-text {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
}
.info-list {
flex: 1;
margin-left: 20px;
.info-item {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
.info-label {
color: #606266;
display: flex;
align-items: center;
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
}
.info-value {
font-weight: 500;
color: #303133;
}
}
}
}
@media (max-width: 768px) {
.system-panel {
flex-direction: column;
.chart-container {
flex: none;
margin-bottom: 20px;
}
.info-list {
margin-left: 0;
}
}
}
</style>
<script setup lang="ts">
import { ref } from 'vue'
import SelectScrapydServer from '@/components/SelectScrapydServer.vue'
import HomeDataInfo from './components/HomeDataInfo.vue'
import HomeSystemInfo from './components/HomeSystemInfo.vue'
import HomeConfig from './components/HomeConfig.vue'
const scrapydServerId = ref('')
const handleSelectScrapydServerInit = (options: any[]) => {
if (options.length > 0) {
scrapydServerId.value = options[0].value
}
}
const resetData = (id: string) => {
scrapydServerId.value = id
localStorage.setItem('scrapyd_server_id', id)
}
</script>
<template>
<div class="app-container home-container">
<select-scrapyd-server
v-model:value="scrapydServerId"
@on-init="handleSelectScrapydServerInit"
@change="resetData"
/>
<div class="mt-md">
<home-data-info
:key="scrapydServerId"
:scrapyd-server-id="scrapydServerId"
/>
<home-system-info />
<!-- <home-config /> -->
</div>
</div>
</template>
<style lang="scss" scoped>
.home-container {
padding: 20px;
height: 100%;
.mt-md {
margin-top: 20px;
}
}
</style>
<template>
<div class="job-cancel">
<el-button
:size="size"
@click="handleCancel"
:disabled="disabled"
>取消</el-button>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import * as projectApi from '@/api/project';
const props = defineProps({
job: { type: String, required: true },
project: { type: String, required: true },
scrapydServerId: { type: String, required: true },
size: { type: String, default: 'default' },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['success']);
// 取消任务
const handleCancel = async () => {
try {
const res = await projectApi.cancelJob({
project: props.project,
job: props.job,
scrapydServerId: props.scrapydServerId
});
if (res.code === 0) {
ElMessage.success('取消成功');
emit('success');
} else {
ElMessage.error(res.message || '取消失败');
}
} catch (error) {
console.error('取消任务失败:', error);
ElMessage.error('取消任务失败');
}
};
</script>
<style lang="scss" scoped>
.job-cancel {
display: inline-block;
}
</style>
<template>
<el-button
:size="size"
@click="handleCancel"
:disabled="disabled"
>取消所有任务</el-button>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import * as projectApi from '@/api/project';
const props = defineProps({
project: { type: String, required: true },
scrapydServerId: { type: String, required: true },
size: { type: String, default: 'default' },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['success']);
// 取消所有任务
const handleCancel = async () => {
try {
const res = await projectApi.cancelAllJob({
project: props.project,
scrapydServerId: props.scrapydServerId
});
if (res.code === 0) {
ElMessage.success('取消成功');
emit('success');
} else {
ElMessage.error(res.message || '取消失败');
}
} catch (error) {
console.error('取消所有任务失败:', error);
ElMessage.error('取消所有任务失败');
}
};
</script>
<style lang="scss" scoped>
</style>
<template>
<div class="job-table">
<el-table
v-loading="loading"
:data="jobList"
border
fit
highlight-current-row
>
<el-table-column align="center" label="序号" width="60">
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column label="Spider" align="center">
<template #default="scope">
{{ scope.row.spider }}
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="80">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="持续时间" align="center" width="100">
<template #default="scope">
{{ scope.row.duration_str || '-' }}
</template>
</el-table-column>
<el-table-column label="开始时间" align="center" width="170">
<template #default="scope">
{{ scope.row.start_time || '-' }}
</template>
</el-table-column>
<el-table-column label="结束时间" align="center" width="170">
<template #default="scope">
{{ scope.row.end_time || '-' }}
</template>
</el-table-column>
<el-table-column label="PID" align="center" width="80">
<template #default="scope">
{{ scope.row.pid || '-' }}
</template>
</el-table-column>
<el-table-column label="查看日志" align="center" width="100">
<template #default="scope">
<router-link :to="{
path: '/project/logs',
query: {
scrapydServerId: scrapydServerId,
project: project,
spider: scope.row.spider,
job: scope.row.id
}
}" target="_blank">
<el-button text type="primary" size="small">
<el-icon><Document /></el-icon>
查看
</el-button>
</router-link>
</template>
</el-table-column>
<el-table-column label="取消运行" align="center" width="100">
<template #default="scope">
<JobCancel
:job="scope.row.id"
:project="project"
:scrapyd-server-id="scrapydServerId"
:disabled="scope.row.status === 'finished'"
@success="$emit('success')"
/>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from 'vue';
import { Document } from '@element-plus/icons-vue';
import JobCancel from '@/views/job/components/JobCancel.vue';
const props = defineProps({
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
scrapydServerId: {
type: String,
default: ''
},
project: {
type: String,
default: ''
}
});
const emit = defineEmits(['success']);
const jobList = ref(props.data);
// 监听props.data的变化
watch(() => props.data, (newVal) => {
jobList.value = newVal;
}, { deep: true });
// 根据状态返回不同的类型
const getStatusType = (status: string) => {
switch (status) {
case 'running':
return 'primary';
case 'finished':
return 'success';
case 'pending':
return 'warning';
default:
return 'info';
}
};
</script>
<style lang="scss" scoped>
.job-table {
margin-top: 20px;
}
</style>
<template>
<div class="job-tool">
<div class="job-tool-row">
<el-radio-group
v-model="status"
:size="size"
@change="handleStatusChange"
>
<el-radio-button label="">全部 {{ statusInfo.total }}</el-radio-button>
<el-radio-button label="pending">等待中 {{ statusInfo.pending }}</el-radio-button>
<el-radio-button label="running">运行中 {{ statusInfo.running }}</el-radio-button>
<el-radio-button label="finished">已结束 {{ statusInfo.finished }}</el-radio-button>
</el-radio-group>
<SelectScrapydServer
class="ml-sm"
v-model:value="scrapydServerId"
@on-init="handleServerInit"
@change="handleServerChange"
/>
<SelectProject
ref="projectSelect"
class="ml-sm"
:size="size"
:scrapyd-server-id="scrapydServerId"
v-model:value="project"
@on-init="handleProjectInit"
@change="handleProjectChange"
/>
<JobCancelAll
v-if="project"
class="ml-sm"
:size="size"
:scrapyd-server-id="scrapydServerId"
:project="project"
@success="$emit('success')"
/>
<AutoRefresh
class="ml-sm"
:size="size"
@refresh="$emit('success')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import SelectScrapydServer from '@/components/SelectScrapydServer.vue';
import SelectProject from '@/components/SelectProject.vue';
import AutoRefresh from '@/components/AutoRefresh.vue';
import JobCancelAll from '@/views/job/components/JobCancelAll.vue';
const props = defineProps({
statusInfo: {
type: Object,
default: () => ({
total: 0,
finished: 0,
pending: 0,
running: 0
})
},
size: {
type: String,
default: 'default'
}
});
const emit = defineEmits([
'status-change',
'project-change',
'server-change',
'success',
'on-init'
]);
const route = useRoute();
const projectSelect = ref<any>(null);
const status = ref('');
const project = ref('');
const scrapydServerId = ref('');
// 处理状态变化
const handleStatusChange = () => {
emit('status-change', status.value);
};
// 处理项目变化
const handleProjectChange = (val: string) => {
if (val) {
localStorage.setItem('project', val);
emit('project-change', val);
}
};
// 处理项目选择初始化
const handleProjectInit = (data: any[]) => {
emit('on-init', data);
};
// 处理服务器变化
const handleServerChange = () => {
// 切换服务器时重新获取项目列表
if (projectSelect.value) {
projectSelect.value.resetData();
}
emit('server-change', scrapydServerId.value);
};
// 处理服务器选择初始化
const handleServerInit = (servers: any[]) => {
// 如果当前没有选择服务器,而且有可选的服务器,则选择第一个
if (!scrapydServerId.value && servers && servers.length > 0) {
scrapydServerId.value = servers[0].value;
}
};
onMounted(() => {
// 从路由参数中获取初始值
if (route.query.scrapydServerId) {
scrapydServerId.value = route.query.scrapydServerId as string;
}
if (route.query.project) {
project.value = route.query.project as string;
}
if (route.query.status) {
status.value = route.query.status as string;
}
});
</script>
<style lang="scss" scoped>
.job-tool {
margin-bottom: 20px;
}
.job-tool-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.ml-sm {
margin-left: 10px;
}
</style>
<template>
<div class="app-container">
<JobTool
:status-info="statusInfo"
@status-change="handleStatusChange"
@project-change="handleProjectChange"
@server-change="handleServerChange"
@success="fetchJobList"
@on-init="handleToolInit"
/>
<JobTable
:data="jobList"
:loading="loading"
:scrapyd-server-id="scrapydServerId"
:project="project"
@success="fetchJobList"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import * as projectApi from '@/api/project';
import JobTool from '@/views/job/components/JobTool.vue';
import JobTable from '@/views/job/components/JobTable.vue';
const route = useRoute();
const loading = ref(false);
const jobList = ref<any[]>([]);
const scrapydServerId = ref('');
const project = ref('');
const status = ref('');
// 状态统计信息
const statusInfo = reactive({
total: 0,
finished: 0,
pending: 0,
running: 0
});
// 获取任务列表
const fetchJobList = async () => {
if (!scrapydServerId.value || !project.value) {
return;
}
loading.value = true;
try {
const res = await projectApi.getJobList({
project: project.value,
status: status.value,
scrapydServerId: scrapydServerId.value
});
if (res.code === 0) {
jobList.value = res.data.list || [];
// 更新状态统计
statusInfo.total = res.data.total || 0;
statusInfo.pending = res.data.pending || 0;
statusInfo.running = res.data.running || 0;
statusInfo.finished = res.data.finished || 0;
} else {
ElMessage.error(res.message || '获取任务列表失败');
}
} catch (error) {
console.error('获取任务列表失败:', error);
ElMessage.error('获取任务列表失败');
} finally {
loading.value = false;
}
};
// 处理状态变化
const handleStatusChange = (val: string) => {
status.value = val;
fetchJobList();
};
// 处理项目变化
const handleProjectChange = (val: string) => {
project.value = val;
fetchJobList();
};
// 处理服务器变化
const handleServerChange = (val: string) => {
scrapydServerId.value = val;
};
// 处理工具栏初始化
const handleToolInit = (data: any[]) => {
// 如果有项目数据且当前未选择项目,则选择第一个
if (data && data.length > 0 && !project.value) {
project.value = data[0].project;
fetchJobList();
}
};
onMounted(() => {
// 从路由参数中获取初始值
if (route.query.scrapydServerId) {
scrapydServerId.value = route.query.scrapydServerId as string;
}
if (route.query.project) {
project.value = route.query.project as string;
}
if (route.query.status) {
status.value = route.query.status as string;
}
// 如果有必要参数,则自动获取任务列表
if (scrapydServerId.value && project.value) {
fetchJobList();
}
});
</script>
<style lang="scss" scoped>
.app-container {
padding: 20px;
}
</style>
<template>
<div class="app-container">
<SelectScrapydServer
v-model:value="scrapydServerId"
@on-init="handleSelectScrapydServerInit"
@change="handleScrapydServerChange"
></SelectScrapydServer>
<LogTable
class="mt-md"
type="logs"
:scrapydServerId="scrapydServerId"
v-loading="listLoading"
:data="list"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import LogTable from './LogTable.vue';
import SelectScrapydServer from '@/components/SelectScrapydServer.vue';
import { getLogs } from '@/api/log';
const route = useRoute();
const scrapydServerId = ref('');
const list = ref<any[]>([]);
const listLoading = ref(true);
async function getData() {
listLoading.value = true;
try {
const res = await getLogs({
scrapydServerId: scrapydServerId.value,
});
if (res) {
list.value = res.data;
}
} catch (error) {
console.error('获取日志列表失败:', error);
} finally {
listLoading.value = false;
}
}
const handleScrapydServerChange = () => {
getData();
};
const handleSelectScrapydServerInit = (data: any) => {
if (!scrapydServerId.value) {
if (data && data.length > 0) {
scrapydServerId.value = data[0].value;
}
}
getData();
};
onMounted(() => {
const queryScrapydServerId = route.query.scrapydServerId;
if (queryScrapydServerId) {
scrapydServerId.value = queryScrapydServerId as string;
}
});
</script>
<style lang="scss" scoped></style>
<template>
<div class="app-container">
<AutoRefresh
@refresh="getData"
/>
<el-divider />
<div
class="log-content"
v-loading="listLoading"
>
<pre v-if="content">{{ content }}</pre>
<div v-else>暂无数据</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import AutoRefresh from '@/components/AutoRefresh.vue';
import { getJobLog } from '@/api/log';
const route = useRoute();
const scrapydServerId = ref('');
const project = ref('');
const spider = ref('');
const job = ref('');
const listLoading = ref(true);
const content = ref('');
const getData = async () => {
listLoading.value = true;
try {
const res = await getJobLog({
scrapydServerId: scrapydServerId.value,
project: project.value,
spider: spider.value,
job: job.value,
});
content.value = res as unknown as string || '';
} catch (error) {
console.error('获取任务日志失败:', error);
} finally {
listLoading.value = false;
}
}
onMounted(() => {
project.value = route.query.project as string;
spider.value = route.query.spider as string;
job.value = route.query.job as string;
scrapydServerId.value = route.query.scrapydServerId as string;
getData();
});
</script>
<style lang="scss" scoped>
.log-content {
overflow: auto;
text-align: left;
pre {
margin: 0;
}
}
</style>
<template>
<div class="app-container">
<div style="height: 20px"></div>
<LogTable
type="project"
v-loading="listLoading"
:scrapydServerId="scrapydServerId"
:project="project"
:data="list"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import LogTable from './LogTable.vue';
import { getProjectLogs } from '@/api/log';
const route = useRoute();
const scrapydServerId = ref('');
const project = ref('');
const list = ref<any[]>([]);
const listLoading = ref(true);
async function getData() {
listLoading.value = true;
try {
const res = await getProjectLogs({
project: project.value,
scrapydServerId: scrapydServerId.value,
});
list.value = res.data;
} catch (error) {
console.error('获取项目日志失败:', error);
} finally {
listLoading.value = false;
}
}
const handleStatusChange = () => {
getData();
};
onMounted(() => {
project.value = route.query.project as string;
scrapydServerId.value = route.query.scrapydServerId as string;
getData();
});
</script>
<style lang="scss" scoped></style>
<template>
<div class="app-container">
<div style="height:20px"></div>
<LogTable
type="spider"
v-loading="listLoading"
:data="list"
:scrapydServerId="scrapydServerId"
:project="project"
:spider="spider"
stripe
border
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import LogTable from './LogTable.vue';
import { getSpiderLogs } from '@/api/log';
const route = useRoute();
const scrapydServerId = ref('');
const project = ref('');
const spider = ref('');
const list = ref<any[]>([]);
const listLoading = ref(true);
async function getData() {
listLoading.value = true;
try {
const res = await getSpiderLogs({
scrapydServerId: scrapydServerId.value,
project: project.value,
spider: spider.value,
});
list.value = res.data;
} catch (error) {
console.error('获取爬虫日志失败:', error);
} finally {
listLoading.value = false;
}
}
const handleStatusChange = () => {
getData();
};
onMounted(() => {
scrapydServerId.value = route.query.scrapydServerId as string;
project.value = route.query.project as string;
spider.value = route.query.spider as string;
getData();
});
</script>
<style lang="scss" scoped>
</style>
<template>
<el-table v-bind="$attrs">
<el-table-column
align="center"
label="序号"
width="60"
>
<template #default="scope">
{{ scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
label="文件名"
align="center"
>
<template #default="scope">
<router-link
:to="getRoute(scope.row.filename)"
:target="type === 'spider' ? '_blank' : '_self'"
>
{{ scope.row.filename }}
</router-link>
</template>
</el-table-column>
<el-table-column
label="文件大小"
align="center"
>
<template #default="scope">
{{ scope.row.size }}
</template>
</el-table-column>
<el-table-column
label="文件类型"
align="center"
>
<template #default="scope">
{{ scope.row.content_type }}
</template>
</el-table-column>
<el-table-column
label="文件编码"
align="center"
>
<template #default="scope">
{{ scope.row.content_encoding }}
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps({
type: { type: String, required: false },
scrapydServerId: { type: String, required: false, default: '' },
project: { type: String, required: false, default: '' },
spider: { type: String, required: false, default: '' }
});
const getRoute = (filename: string) => {
if (props.type === 'logs') {
return {
name: 'logs-project',
query: {
scrapydServerId: props.scrapydServerId,
project: filename,
},
};
} else if (props.type === 'project') {
return {
name: 'logs-project-spider',
query: {
scrapydServerId: props.scrapydServerId,
project: props.project,
spider: filename,
},
};
} else if (props.type === 'spider') {
return {
name: 'logs-project-spider-job',
query: {
scrapydServerId: props.scrapydServerId,
project: props.project,
spider: props.spider,
job: filename,
},
};
}
return {};
};
</script>
<style lang="scss" scoped>
</style>
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment