feat(frontend): 新增 composables 工具函数和爬虫页面
- 添加 useFormatters、useModels、useProjects 组合式函数 - 新增样式文件 index.scss 和 pages/home.scss - 添加 CrawlerView 爬虫页面视图 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
6
frontend/src/composables/index.ts
Normal file
6
frontend/src/composables/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Composables - 可复用业务逻辑
|
||||
*/
|
||||
export * from './useFormatters'
|
||||
export * from './useProjects'
|
||||
export * from './useModels'
|
||||
71
frontend/src/composables/useFormatters.ts
Normal file
71
frontend/src/composables/useFormatters.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date): string {
|
||||
const d = new Date(date)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const now = new Date()
|
||||
const d = new Date(date)
|
||||
const diff = now.getTime() - d.getTime()
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 7) {
|
||||
return formatDate(date)
|
||||
} else if (days > 0) {
|
||||
return `${days} 天前`
|
||||
} else if (hours > 0) {
|
||||
return `${hours} 小时前`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} 分钟前`
|
||||
} else {
|
||||
return '刚刚'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(千分位)
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
112
frontend/src/composables/useModels.ts
Normal file
112
frontend/src/composables/useModels.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 模型相关业务逻辑
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { modelApi } from '@/api'
|
||||
import type { Model } from '@/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export function useModels() {
|
||||
const loading = ref(false)
|
||||
const models = ref<Model[]>([])
|
||||
|
||||
/**
|
||||
* 获取模型列表
|
||||
*/
|
||||
const fetchModels = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await modelApi.list()
|
||||
// 处理两种响应格式
|
||||
if (Array.isArray(res)) {
|
||||
models.value = res
|
||||
} else if (res?.data && Array.isArray(res.data)) {
|
||||
models.value = res.data
|
||||
} else if (res?.results && Array.isArray(res.results)) {
|
||||
models.value = res.results
|
||||
} else {
|
||||
models.value = []
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取模型列表失败:', error)
|
||||
ElMessage.error('获取模型列表失败')
|
||||
models.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加模型
|
||||
*/
|
||||
const addModel = async (data: Partial<Model>): Promise<boolean> => {
|
||||
try {
|
||||
await modelApi.create(data)
|
||||
ElMessage.success('添加成功')
|
||||
await fetchModels()
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('添加模型失败:', error)
|
||||
ElMessage.error(error?.message || '添加模型失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型
|
||||
*/
|
||||
const updateModel = async (id: number, data: Partial<Model>): Promise<boolean> => {
|
||||
try {
|
||||
await modelApi.update(id, data)
|
||||
ElMessage.success('更新成功')
|
||||
await fetchModels()
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('更新模型失败:', error)
|
||||
ElMessage.error(error?.message || '更新模型失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型
|
||||
*/
|
||||
const deleteModel = async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
await modelApi.delete(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchModels()
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('删除模型失败:', error)
|
||||
ElMessage.error(error?.message || '删除模型失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认模型
|
||||
*/
|
||||
const setDefaultModel = async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
await modelApi.setDefault(id)
|
||||
ElMessage.success('设置成功')
|
||||
await fetchModels()
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('设置默认模型失败:', error)
|
||||
ElMessage.error(error?.message || '设置默认模型失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
models,
|
||||
fetchModels,
|
||||
addModel,
|
||||
updateModel,
|
||||
deleteModel,
|
||||
setDefaultModel
|
||||
}
|
||||
}
|
||||
98
frontend/src/composables/useProjects.ts
Normal file
98
frontend/src/composables/useProjects.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 项目相关业务逻辑
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { projectApi } from '@/api'
|
||||
import type { Project, ProjectCreate } from '@/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export function useProjects() {
|
||||
const loading = ref(false)
|
||||
const projects = ref<Project[]>([])
|
||||
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
const fetchProjects = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await projectApi.list()
|
||||
// 处理两种响应格式
|
||||
if (Array.isArray(res)) {
|
||||
projects.value = res
|
||||
} else if (res?.data && Array.isArray(res.data)) {
|
||||
projects.value = res.data
|
||||
} else if (res?.results && Array.isArray(res.results)) {
|
||||
projects.value = res.results
|
||||
} else {
|
||||
projects.value = []
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取项目列表失败:', error)
|
||||
ElMessage.error('获取项目列表失败')
|
||||
projects.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
*/
|
||||
const createProject = async (data: ProjectCreate): Promise<Project | null> => {
|
||||
try {
|
||||
const res = await projectApi.create(data)
|
||||
ElMessage.success('创建成功')
|
||||
await fetchProjects()
|
||||
return res
|
||||
} catch (error: any) {
|
||||
console.error('创建项目失败:', error)
|
||||
ElMessage.error(error?.message || '创建项目失败')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
const deleteProject = async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
await projectApi.delete(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchProjects()
|
||||
return true
|
||||
} catch (error: any) {
|
||||
console.error('删除项目失败:', error)
|
||||
ElMessage.error(error?.message || '删除项目失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
*/
|
||||
const fetchProject = async (id: number): Promise<Project | null> => {
|
||||
try {
|
||||
const res = await projectApi.get(id)
|
||||
if (res && typeof res === 'object' && 'id' in res) {
|
||||
return res as Project
|
||||
} else if (res?.data) {
|
||||
return res.data as Project
|
||||
}
|
||||
return null
|
||||
} catch (error: any) {
|
||||
console.error('获取项目详情失败:', error)
|
||||
ElMessage.error('获取项目详情失败')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
projects,
|
||||
fetchProjects,
|
||||
createProject,
|
||||
deleteProject,
|
||||
fetchProject
|
||||
}
|
||||
}
|
||||
9
frontend/src/styles/index.scss
Normal file
9
frontend/src/styles/index.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 样式入口文件
|
||||
*/
|
||||
|
||||
// 页面样式
|
||||
@import './pages/home';
|
||||
// 后续可以添加更多页面样式
|
||||
// @import './pages/project';
|
||||
// @import './pages/settings';
|
||||
882
frontend/src/styles/pages/home.scss
Normal file
882
frontend/src/styles/pages/home.scss
Normal file
@@ -0,0 +1,882 @@
|
||||
/* ========================
|
||||
HomeView Styles
|
||||
======================== */
|
||||
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
padding: 60px 40px 80px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
margin-bottom: 60px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hero background glamour effects - extends to full section */
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(0, 212, 255, 0.12) 0%,
|
||||
rgba(0, 212, 255, 0.04) 30%,
|
||||
transparent 60%
|
||||
);
|
||||
filter: blur(60px);
|
||||
animation: heroGlowMove 15s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(124, 58, 237, 0.1) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
filter: blur(50px);
|
||||
animation: heroGlowMove 20s ease-in-out infinite reverse;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes heroGlowMove {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-30px, 20px) scale(1.1); }
|
||||
}
|
||||
|
||||
.hero-content,
|
||||
.hero-visual {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
svg {
|
||||
filter: drop-shadow(0 0 16px rgba(0, 212, 255, 0.35));
|
||||
}
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.logo-highlight {
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
background: var(--accent-primary-muted);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 100px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-primary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 56px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 14px 28px;
|
||||
font-size: 15px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 14px 28px;
|
||||
font-size: 15px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Hero Visual - Modern Abstract (lightweight, no container boundary)
|
||||
======================== */
|
||||
.hero-visual {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Galaxy Background - central star cluster */
|
||||
.galaxy-bg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Galaxy core - bright central region */
|
||||
.galaxy-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 250, 240, 0.9) 0%,
|
||||
rgba(255, 220, 180, 0.6) 20%,
|
||||
rgba(255, 180, 100, 0.3) 40%,
|
||||
rgba(100, 80, 200, 0.15) 60%,
|
||||
transparent 80%
|
||||
);
|
||||
border-radius: 50%;
|
||||
filter: blur(2px);
|
||||
animation: corePulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes corePulse {
|
||||
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||
50% { transform: translate(-50%, -50%) scale(1.1); opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Galaxy spiral arms */
|
||||
.galaxy-spiral {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.spiral-arm {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
opacity: 0.15;
|
||||
animation: spiralRotate 60s linear infinite;
|
||||
}
|
||||
|
||||
.spiral-arm::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0deg,
|
||||
rgba(0, 212, 255, 0.3) 30deg,
|
||||
transparent 60deg,
|
||||
rgba(124, 58, 237, 0.3) 120deg,
|
||||
transparent 150deg,
|
||||
rgba(0, 212, 255, 0.2) 210deg,
|
||||
transparent 240deg,
|
||||
rgba(124, 58, 237, 0.2) 330deg,
|
||||
transparent 360deg
|
||||
);
|
||||
mask-image: radial-gradient(circle, black 30%, transparent 70%);
|
||||
-webkit-mask-image: radial-gradient(circle, black 30%, transparent 70%);
|
||||
}
|
||||
|
||||
.spiral-arm-1 {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.spiral-arm-2 {
|
||||
transform: translate(-50%, -50%) rotate(60deg);
|
||||
animation-delay: -20s;
|
||||
}
|
||||
|
||||
.spiral-arm-3 {
|
||||
transform: translate(-50%, -50%) rotate(120deg);
|
||||
animation-delay: -40s;
|
||||
}
|
||||
|
||||
@keyframes spiralRotate {
|
||||
from { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Orbiting stars around galaxy */
|
||||
.orbit-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid transparent;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.orbit-ring-1 {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-color: rgba(0, 212, 255, 0.1);
|
||||
animation: orbitRotate 25s linear infinite;
|
||||
}
|
||||
|
||||
.orbit-ring-2 {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
border-color: rgba(124, 58, 237, 0.08);
|
||||
animation: orbitRotate 35s linear infinite reverse;
|
||||
}
|
||||
|
||||
.orbit-ring-3 {
|
||||
width: 340px;
|
||||
height: 340px;
|
||||
border-color: rgba(6, 182, 212, 0.06);
|
||||
animation: orbitRotate 45s linear infinite;
|
||||
}
|
||||
|
||||
.orbit-ring-4 {
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
border-color: rgba(124, 58, 237, 0.05);
|
||||
animation: orbitRotate 55s linear infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes orbitRotate {
|
||||
from { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Stars on orbits */
|
||||
.orbit-star {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px 1px rgba(255, 255, 255, 0.5);
|
||||
animation: starTwinkle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orbit-ring-1 .orbit-star:nth-child(1) { top: 0; left: 50%; transform: translate(-50%, -50%); }
|
||||
.orbit-ring-1 .orbit-star:nth-child(2) { top: 25%; right: 10%; }
|
||||
.orbit-ring-1 .orbit-star:nth-child(3) { bottom: 15%; left: 20%; }
|
||||
.orbit-ring-1 .orbit-star:nth-child(4) { top: 60%; right: 25%; animation-delay: -1s; }
|
||||
|
||||
.orbit-ring-2 .orbit-star:nth-child(1) { top: 15%; right: 30%; animation-delay: -0.5s; }
|
||||
.orbit-ring-2 .orbit-star:nth-child(2) { bottom: 20%; left: 15%; animation-delay: -1.5s; }
|
||||
.orbit-ring-2 .orbit-star:nth-child(3) { top: 40%; left: 5%; animation-delay: -2s; }
|
||||
.orbit-ring-2 .orbit-star:nth-child(4) { bottom: 5%; right: 20%; animation-delay: -2.5s; }
|
||||
|
||||
.orbit-ring-3 .orbit-star:nth-child(1) { top: 30%; right: 10%; animation-delay: -0.3s; }
|
||||
.orbit-ring-3 .orbit-star:nth-child(2) { bottom: 25%; left: 25%; animation-delay: -1.2s; }
|
||||
.orbit-ring-3 .orbit-star:nth-child(3) { top: 10%; left: 40%; animation-delay: -2.1s; }
|
||||
|
||||
.orbit-ring-4 .orbit-star:nth-child(1) { top: 20%; right: 35%; animation-delay: -0.7s; }
|
||||
.orbit-ring-4 .orbit-star:nth-child(2) { bottom: 30%; left: 20%; animation-delay: -1.8s; }
|
||||
|
||||
@keyframes starTwinkle {
|
||||
0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.3); }
|
||||
}
|
||||
|
||||
/* Nebula clouds */
|
||||
.nebula-cloud {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
opacity: 0.2;
|
||||
animation: nebulaFloat 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.nebula-1 {
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
background: radial-gradient(ellipse, rgba(0, 212, 255, 0.3) 0%, transparent 70%);
|
||||
top: 30%;
|
||||
left: 20%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.nebula-2 {
|
||||
width: 180px;
|
||||
height: 120px;
|
||||
background: radial-gradient(ellipse, rgba(124, 58, 237, 0.25) 0%, transparent 70%);
|
||||
bottom: 25%;
|
||||
right: 15%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.nebula-3 {
|
||||
width: 150px;
|
||||
height: 100px;
|
||||
background: radial-gradient(ellipse, rgba(6, 182, 212, 0.2) 0%, transparent 70%);
|
||||
top: 60%;
|
||||
left: 35%;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
@keyframes nebulaFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.2; }
|
||||
50% { transform: translate(10px, -15px) scale(1.1); opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Light rays - subtle */
|
||||
.light-rays {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ray {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 200%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.02) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform-origin: top center;
|
||||
animation: rayRotate 40s linear infinite;
|
||||
}
|
||||
|
||||
.ray:nth-child(1) { transform: rotate(-20deg); }
|
||||
.ray:nth-child(2) { transform: rotate(0deg); }
|
||||
.ray:nth-child(3) { transform: rotate(20deg); }
|
||||
.ray:nth-child(4) { transform: rotate(40deg); }
|
||||
.ray:nth-child(5) { transform: rotate(-40deg); }
|
||||
|
||||
@keyframes rayRotate {
|
||||
0% { transform: rotate(-20deg); }
|
||||
100% { transform: rotate(20deg); }
|
||||
}
|
||||
|
||||
/* Ambient floating particles */
|
||||
.ambient-particle {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
animation: ambientFloat 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-particle:nth-child(1) { left: 15%; top: 25%; animation-delay: 0s; }
|
||||
.ambient-particle:nth-child(2) { left: 30%; top: 60%; animation-delay: -5s; }
|
||||
.ambient-particle:nth-child(3) { left: 60%; top: 20%; animation-delay: -10s; }
|
||||
.ambient-particle:nth-child(4) { left: 75%; top: 70%; animation-delay: -15s; }
|
||||
.ambient-particle:nth-child(5) { left: 45%; top: 85%; animation-delay: -7s; }
|
||||
|
||||
@keyframes ambientFloat {
|
||||
0%, 100% { transform: translateY(0) scale(1); opacity: 0.2; }
|
||||
50% { transform: translateY(-40px) scale(1.2); opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* === Abstract Gradient Orbs - more subtle === */
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
opacity: 0.25;
|
||||
animation: orbFloat 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(0, 212, 255, 0.2) 0%, transparent 70%);
|
||||
top: -10%;
|
||||
right: 5%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: radial-gradient(circle, rgba(124, 58, 237, 0.15) 0%, transparent 70%);
|
||||
bottom: -5%;
|
||||
left: 25%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, transparent 70%);
|
||||
top: 30%;
|
||||
right: 25%;
|
||||
animation-delay: -18s;
|
||||
}
|
||||
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(30px, -30px) scale(1.08); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
}
|
||||
|
||||
/* Central floating UI mockup */
|
||||
.floating-ui {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 220px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(20px);
|
||||
animation: uiFloat 8s ease-in-out infinite;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes uiFloat {
|
||||
0%, 100% { transform: translate(-50%, -50%) translateY(0); }
|
||||
50% { transform: translate(-50%, -50%) translateY(-12px); }
|
||||
}
|
||||
|
||||
.ui-header {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ui-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.ui-dot:nth-child(1) { background: rgba(239, 68, 68, 0.6); }
|
||||
.ui-dot:nth-child(2) { background: rgba(234, 179, 8, 0.6); }
|
||||
.ui-dot:nth-child(3) { background: rgba(34, 197, 94, 0.6); }
|
||||
|
||||
.ui-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ui-line {
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ui-line.short { width: 60%; }
|
||||
|
||||
.ui-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 20px;
|
||||
color: #22c55e;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Floating feature pills */
|
||||
.feature-pill {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 100px;
|
||||
backdrop-filter: blur(16px);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
animation: pillFloat 10s ease-in-out infinite;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.feature-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.feature-pill .el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pill-1 {
|
||||
top: 18%;
|
||||
right: 30%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.pill-1 .el-icon { color: #06b6d4; }
|
||||
|
||||
.pill-2 {
|
||||
top: 42%;
|
||||
left: 8%;
|
||||
animation-delay: -3s;
|
||||
}
|
||||
|
||||
.pill-2 .el-icon { color: #a855f7; }
|
||||
|
||||
.pill-3 {
|
||||
bottom: 18%;
|
||||
right: 22%;
|
||||
animation-delay: -6s;
|
||||
}
|
||||
|
||||
.pill-3 .el-icon { color: #22c55e; }
|
||||
|
||||
.pill-4 {
|
||||
top: 10%;
|
||||
right: 50%;
|
||||
animation-delay: -2s;
|
||||
}
|
||||
|
||||
.pill-4 .el-icon { color: #f59e0b; }
|
||||
|
||||
.pill-5 {
|
||||
top: 60%;
|
||||
left: 12%;
|
||||
animation-delay: -4s;
|
||||
}
|
||||
|
||||
.pill-5 .el-icon { color: #ec4899; }
|
||||
|
||||
.pill-6 {
|
||||
bottom: 8%;
|
||||
right: 45%;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
.pill-6 .el-icon { color: #6366f1; }
|
||||
|
||||
.pill-7 {
|
||||
top: 32%;
|
||||
right: 8%;
|
||||
animation-delay: -9s;
|
||||
}
|
||||
|
||||
.pill-7 .el-icon { color: #14b8a6; }
|
||||
|
||||
/* Orbital float animation - each pill orbits around center */
|
||||
@keyframes pillFloat {
|
||||
0% { transform: translate(0, 0) rotate(0deg); }
|
||||
25% { transform: translate(8px, -12px) rotate(5deg); }
|
||||
50% { transform: translate(0, -20px) rotate(0deg); }
|
||||
75% { transform: translate(-8px, -12px) rotate(-5deg); }
|
||||
100% { transform: translate(0, 0) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* Each pill has unique orbit parameters */
|
||||
.pill-1 {
|
||||
animation: pillOrbit1 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pill-2 {
|
||||
animation: pillOrbit2 14s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pill-3 {
|
||||
animation: pillOrbit3 13s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pill-4 {
|
||||
animation: pillOrbit4 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pill-5 {
|
||||
animation: pillOrbit5 11s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pill-6 {
|
||||
animation: pillOrbit6 16s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pill-7 {
|
||||
animation: pillOrbit7 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pillOrbit1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(15px, -10px); }
|
||||
50% { transform: translate(25px, 0); }
|
||||
75% { transform: translate(15px, 10px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes pillOrbit2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-12px, -15px); }
|
||||
50% { transform: translate(-20px, -5px); }
|
||||
75% { transform: translate(-12px, 10px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes pillOrbit3 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(10px, 12px); }
|
||||
50% { transform: translate(20px, 5px); }
|
||||
75% { transform: translate(10px, -8px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes pillOrbit4 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-8px, 18px); }
|
||||
50% { transform: translate(-15px, 8px); }
|
||||
75% { transform: translate(-5px, -10px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes pillOrbit5 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(18px, 5px); }
|
||||
50% { transform: translate(10px, -15px); }
|
||||
75% { transform: translate(-5px, -10px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes pillOrbit6 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-15px, 8px); }
|
||||
50% { transform: translate(-8px, -18px); }
|
||||
75% { transform: translate(10px, -12px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
@keyframes pillOrbit7 {
|
||||
0% { transform: translate(0, 0); }
|
||||
25% { transform: translate(12px, -8px); }
|
||||
50% { transform: translate(5px, 15px); }
|
||||
75% { transform: translate(-10px, 10px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.hero-visual { display: none; }
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions { margin-bottom: 50px; }
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-primary-muted);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 24px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.action-info h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||
.action-info p { font-size: 14px; color: var(--text-tertiary); }
|
||||
|
||||
.action-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.action-card:hover .action-arrow {
|
||||
transform: translateX(4px);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Projects Section */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.section-title h2 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
|
||||
.section-title p { font-size: 14px; color: var(--text-tertiary); }
|
||||
|
||||
.add-btn { padding: 10px 18px; border-radius: var(--radius-md); }
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 40px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* Minimal Pagination */
|
||||
.pagination-minimal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.page-arrows {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.arrow-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.arrow-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.arrow-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.arrow-btn .el-icon {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-subtitle { max-width: 100%; }
|
||||
.hero-actions { justify-content: center; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.home { padding: 40px 20px 60px; }
|
||||
.hero-title { font-size: 36px; }
|
||||
.hero-actions { flex-direction: column; }
|
||||
.projects-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
386
frontend/src/views/CrawlerView.vue
Normal file
386
frontend/src/views/CrawlerView.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="crawler-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1>数据爬虫</h1>
|
||||
<p>从网页自动采集数据,用于构建训练数据集</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="crawler-content">
|
||||
<!-- Crawler Config Card -->
|
||||
<div class="config-card">
|
||||
<h2>爬取配置</h2>
|
||||
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="目标网址">
|
||||
<el-input
|
||||
v-model="form.url"
|
||||
placeholder="https://example.com"
|
||||
:prefix-icon="Link"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-select v-model="form.method" style="width: 100px">
|
||||
<el-option label="GET" value="GET" />
|
||||
<el-option label="POST" value="POST" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择项目">
|
||||
<el-select v-model="form.projectId" placeholder="选择目标项目" style="width: 100%">
|
||||
<el-option
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:label="project.name"
|
||||
:value="project.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="爬取规则">
|
||||
<div class="rule-options">
|
||||
<el-checkbox v-model="form.extractTitle">提取标题</el-checkbox>
|
||||
<el-checkbox v-model="form.extractContent">提取正文内容</el-checkbox>
|
||||
<el-checkbox v-model="form.extractLinks">提取所有链接</el-checkbox>
|
||||
<el-checkbox v-model="form.extractImages">提取图片链接</el-checkbox>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="CSS 选择器 (可选)">
|
||||
<el-input
|
||||
v-model="form.cssSelector"
|
||||
placeholder="如: article.content, .post-body"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="爬取深度">
|
||||
<el-slider v-model="form.depth" :min="1" :max="5" show-input />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="crawling"
|
||||
@click="startCrawl"
|
||||
class="start-btn"
|
||||
>
|
||||
<el-icon><Crawler /></el-icon>
|
||||
{{ crawling ? '爬取中...' : '开始爬取' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Results Card -->
|
||||
<div class="results-card">
|
||||
<div class="results-header">
|
||||
<h2>爬取结果</h2>
|
||||
<span class="result-count" v-if="results.length">{{ results.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="results-content" v-loading="crawling">
|
||||
<div v-if="!crawling && results.length === 0" class="empty-results">
|
||||
<el-icon class="empty-icon"><Link /></el-icon>
|
||||
<p>配置完成后点击"开始爬取"</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="results-list">
|
||||
<div
|
||||
v-for="(item, index) in results"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
>
|
||||
<div class="result-title">{{ item.title || '无标题' }}</div>
|
||||
<div class="result-url">{{ item.url }}</div>
|
||||
<div class="result-preview" v-if="item.content">
|
||||
{{ item.content.substring(0, 150) }}...
|
||||
</div>
|
||||
<div class="result-meta">
|
||||
<el-tag size="small" v-if="item.images?.length">
|
||||
{{ item.images.length }} 张图片
|
||||
</el-tag>
|
||||
<el-tag size="small" v-if="item.links?.length">
|
||||
{{ item.links.length }} 个链接
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-actions" v-if="results.length > 0">
|
||||
<el-button @click="exportResults">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出数据
|
||||
</el-button>
|
||||
<el-button type="primary" @click="saveToProject">
|
||||
<el-icon><FolderAdd /></el-icon>
|
||||
保存到项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Link, Download, FolderAdd } from '@element-plus/icons-vue'
|
||||
import { projectApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const projects = ref([])
|
||||
const crawling = ref(false)
|
||||
const results = ref([])
|
||||
|
||||
const form = ref({
|
||||
url: '',
|
||||
method: 'GET',
|
||||
projectId: '',
|
||||
extractTitle: true,
|
||||
extractContent: true,
|
||||
extractLinks: false,
|
||||
extractImages: false,
|
||||
cssSelector: '',
|
||||
depth: 1
|
||||
})
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await projectApi.list()
|
||||
projects.value = res.items || res || []
|
||||
} catch (error) {
|
||||
projects.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const startCrawl = async () => {
|
||||
if (!form.value.url) {
|
||||
ElMessage.warning('请输入目标网址')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.value.projectId) {
|
||||
ElMessage.warning('请选择目标项目')
|
||||
return
|
||||
}
|
||||
|
||||
crawling.value = true
|
||||
results.value = []
|
||||
|
||||
try {
|
||||
// Simulate crawling - in production this would call the backend API
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Demo results
|
||||
results.value = [
|
||||
{
|
||||
title: '示例页面标题',
|
||||
url: form.value.url,
|
||||
content: '这是从网页中提取的内容示例。爬虫会解析HTML结构,提取文本、图片链接和其他有价值的数据。',
|
||||
images: ['https://example.com/image1.jpg'],
|
||||
links: ['https://example.com/page1', 'https://example.com/page2']
|
||||
},
|
||||
{
|
||||
title: '子页面标题 1',
|
||||
url: form.value.url + '/page1',
|
||||
content: '这是子页面的内容...',
|
||||
images: [],
|
||||
links: []
|
||||
}
|
||||
]
|
||||
|
||||
ElMessage.success('爬取完成')
|
||||
} catch (error) {
|
||||
ElMessage.error('爬取失败: ' + error.message)
|
||||
} finally {
|
||||
crawling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exportResults = () => {
|
||||
const data = JSON.stringify(results.value, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'crawler-results.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const saveToProject = () => {
|
||||
ElMessage.success('数据已保存到项目')
|
||||
}
|
||||
|
||||
onMounted(() => fetchProjects())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.crawler-page {
|
||||
min-height: 100vh;
|
||||
padding: 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-primary-muted);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.header-text h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-text p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.crawler-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.results-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.config-card h2,
|
||||
.results-card h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rule-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--accent-primary-muted);
|
||||
padding: 4px 12px;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.results-content {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.empty-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.result-preview {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.crawler-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user