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:
Developer
2026-03-18 10:45:36 +08:00
parent a1342b7634
commit 9a12907f25
7 changed files with 1564 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
/**
* Composables - 可复用业务逻辑
*/
export * from './useFormatters'
export * from './useProjects'
export * from './useModels'

View 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')
}

View 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
}
}

View 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
}
}

View File

@@ -0,0 +1,9 @@
/**
* 样式入口文件
*/
// 页面样式
@import './pages/home';
// 后续可以添加更多页面样式
// @import './pages/project';
// @import './pages/settings';

View 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; }
}

View 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>