fix: 修复 WebUI 源码缺失、环境检测路径及文档说明

- 调整 .gitignore,避免误忽略 webui/src/lib/ 和 webui/src/components/env/
- 补齐 WebUI 缺失的 lib 工具函数、API 封装及环境检测组件
- 修复 /api/env/check 使用相对路径导致启动目录不同时检测失败的问题
- 更新中/英/西三语 README 的 WebUI 开发调试与生产构建说明
This commit is contained in:
程序员阿江(Relakkes)
2026-07-01 23:32:10 +08:00
parent 92c98e658e
commit 076dcba978
9 changed files with 528 additions and 28 deletions

View File

@@ -0,0 +1,177 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle, XCircle, Loader2, RefreshCw, AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { envApi, EnvCheckResult } from '@/lib/api'
const ENV_CHECK_KEY = 'mediacrawler_env_checked'
interface EnvironmentCheckProps {
onCheckComplete: (success: boolean) => void
}
// 检查是否已经通过环境检测
export function isEnvChecked(): boolean {
return localStorage.getItem(ENV_CHECK_KEY) === 'true'
}
// 清除环境检测状态
export function clearEnvCheck(): void {
localStorage.removeItem(ENV_CHECK_KEY)
}
export function EnvironmentCheck({ onCheckComplete }: EnvironmentCheckProps) {
const { t } = useTranslation('env')
const [status, setStatus] = useState<'checking' | 'success' | 'error'>('checking')
const [result, setResult] = useState<EnvCheckResult | null>(null)
const [showDetails, setShowDetails] = useState(false)
const checkEnvironment = async () => {
setStatus('checking')
setResult(null)
try {
const response = await envApi.check()
setResult(response.data)
if (response.data.success) {
setStatus('success')
// 存储到 localStorage
localStorage.setItem(ENV_CHECK_KEY, 'true')
// 成功后延迟关闭
setTimeout(() => onCheckComplete(true), 1500)
} else {
setStatus('error')
}
} catch (error) {
setResult({
success: false,
message: t('defaultError'),
error: t('defaultErrorHint')
})
setStatus('error')
}
}
useEffect(() => {
checkEnvironment()
}, [])
const handleSkip = () => {
localStorage.setItem(ENV_CHECK_KEY, 'true')
onCheckComplete(false)
}
const handleRetry = () => {
checkEnvironment()
}
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-cyber-bg-panel border border-cyber-border-DEFAULT rounded-lg shadow-cyber-card p-6 max-w-md w-full mx-4 relative">
{/* Corner decorations */}
<div className="absolute top-0 left-0 w-4 h-4 border-t-2 border-l-2 border-cyber-neon-cyan" />
<div className="absolute top-0 right-0 w-4 h-4 border-t-2 border-r-2 border-cyber-neon-cyan" />
<div className="absolute bottom-0 left-0 w-4 h-4 border-b-2 border-l-2 border-cyber-neon-cyan" />
<div className="absolute bottom-0 right-0 w-4 h-4 border-b-2 border-r-2 border-cyber-neon-cyan" />
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-cyber-neon-orange" />
<h2 className="text-lg font-mono font-semibold text-cyber-neon-cyan glow-text-cyan">
{t('title')}
</h2>
</div>
{/* Status Display */}
<div className="bg-cyber-bg-tertiary border border-cyber-border-DEFAULT rounded-lg p-4 mb-4">
<div className="flex items-center gap-3">
{status === 'checking' && (
<>
<Loader2 className="w-5 h-5 text-cyber-neon-cyan animate-spin" />
<span className="text-cyber-text-primary font-mono text-sm">
{t('scanning')}
</span>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="w-5 h-5 text-cyber-neon-green" />
<span className="text-cyber-neon-green font-mono text-sm">
{t('success', { message: result?.message })}
</span>
</>
)}
{status === 'error' && (
<>
<XCircle className="w-5 h-5 text-cyber-neon-pink" />
<span className="text-cyber-neon-pink font-mono text-sm">
{t('error', { message: result?.message })}
</span>
</>
)}
</div>
{/* Error Details */}
{status === 'error' && result?.error && (
<div className="mt-3">
<button
onClick={() => setShowDetails(!showDetails)}
className="text-sm text-cyber-neon-cyan hover:underline font-mono"
>
{showDetails ? t('hideDetails') : t('showDetails')}
</button>
{showDetails && (
<pre className="mt-2 p-3 bg-black text-cyber-neon-green rounded text-xs font-mono overflow-x-auto whitespace-pre-wrap border border-cyber-border-DEFAULT">
{result.error}
</pre>
)}
</div>
)}
</div>
{/* Help Text */}
{status === 'error' && (
<div className="text-sm text-cyber-text-secondary mb-4 space-y-2 font-mono">
<p className="text-cyber-neon-orange">{t('requirements')}</p>
<ol className="list-decimal list-inside space-y-1 pl-2 text-cyber-text-muted">
<li>{t('requirementsList.1')}</li>
<li>{t('requirementsList.2')}</li>
<li>{t('requirementsList.3')}</li>
</ol>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
{status === 'error' && (
<>
<Button
variant="outline"
className="flex-1 font-mono"
onClick={handleSkip}
>
{t('skipCheck')}
</Button>
<Button
variant="glow"
className="flex-1 font-mono"
onClick={handleRetry}
>
<RefreshCw className="w-4 h-4" />
{t('retryCheck')}
</Button>
</>
)}
{status === 'checking' && (
<Button
variant="outline"
className="w-full font-mono"
onClick={handleSkip}
>
{t('skipCheck')}
</Button>
)}
</div>
</div>
</div>
)
}

104
webui/src/lib/api.ts Normal file
View File

@@ -0,0 +1,104 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// Types
export interface CrawlerConfig {
platform: string
login_type: string
crawler_type: string
keywords: string
start_page: number
enable_comments: boolean
enable_sub_comments: boolean
save_option: string
cookies: string
headless: boolean
}
export interface CrawlerStatus {
status: 'idle' | 'running' | 'stopping' | 'error'
platform: string | null
crawler_type: string | null
started_at: string | null
error_message: string | null
}
export interface LogEntry {
id: number
timestamp: string
level: 'info' | 'warning' | 'error' | 'success' | 'debug'
message: string
}
export interface DataFile {
name: string
path: string
size: number
modified_at: number
record_count: number | null
type: string
}
export interface FilePreviewResponse {
data: Record<string, unknown>[]
total: number
columns?: string[]
}
export interface Platform {
value: string
label: string
icon: string
}
export interface ConfigOption {
value: string
label: string
}
// API functions
export const crawlerApi = {
start: (config: CrawlerConfig) => api.post('/crawler/start', config),
stop: () => api.post('/crawler/stop'),
getStatus: () => api.get<CrawlerStatus>('/crawler/status'),
getLogs: (limit = 100) => api.get<{ logs: LogEntry[] }>('/crawler/logs', { params: { limit } }),
}
export const dataApi = {
getFiles: (platform?: string, fileType?: string) =>
api.get<{ files: DataFile[] }>('/data/files', { params: { platform, file_type: fileType } }),
getFileContent: (path: string, limit = 100) =>
api.get<FilePreviewResponse>('/data/files/' + path, { params: { preview: true, limit } }),
getStats: () => api.get('/data/stats'),
getDownloadUrl: (path: string) => `/api/data/download/${path}`,
}
export const configApi = {
getPlatforms: () => api.get<{ platforms: Platform[] }>('/config/platforms'),
getOptions: () =>
api.get<{
login_types: ConfigOption[]
crawler_types: ConfigOption[]
save_options: ConfigOption[]
}>('/config/options'),
}
export interface EnvCheckResult {
success: boolean
message: string
output?: string
error?: string
}
export const envApi = {
check: () => api.get<EnvCheckResult>('/env/check'),
}
export default api

141
webui/src/lib/urlParser.ts Normal file
View File

@@ -0,0 +1,141 @@
export interface ParsedId {
id: string
type: 'video' | 'creator' | 'unknown'
original: string
isValid: boolean
}
// URL patterns for different platforms
const platformPatterns: Record<string, {
video: RegExp[]
creator: RegExp[]
}> = {
xhs: {
video: [
/xiaohongshu\.com\/explore\/([a-zA-Z0-9]+)/,
/xiaohongshu\.com\/discovery\/item\/([a-zA-Z0-9]+)/,
/xhslink\.com\/([a-zA-Z0-9]+)/,
],
creator: [
/xiaohongshu\.com\/user\/profile\/([a-zA-Z0-9]+)/,
],
},
dy: {
video: [
/douyin\.com\/video\/(\d+)/,
/v\.douyin\.com\/([a-zA-Z0-9]+)/,
/iesdouyin\.com\/share\/video\/(\d+)/,
],
creator: [
/douyin\.com\/user\/([a-zA-Z0-9_-]+)/,
],
},
bili: {
video: [
/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/,
/bilibili\.com\/video\/(av\d+)/,
/b23\.tv\/([a-zA-Z0-9]+)/,
],
creator: [
/space\.bilibili\.com\/(\d+)/,
],
},
wb: {
video: [
/weibo\.com\/\d+\/([a-zA-Z0-9]+)/,
/m\.weibo\.cn\/status\/(\d+)/,
],
creator: [
/weibo\.com\/u\/(\d+)/,
/weibo\.com\/([a-zA-Z0-9]+)$/,
],
},
ks: {
video: [
/kuaishou\.com\/short-video\/([a-zA-Z0-9_-]+)/,
/v\.kuaishou\.com\/([a-zA-Z0-9]+)/,
],
creator: [
/kuaishou\.com\/profile\/([a-zA-Z0-9_-]+)/,
],
},
}
export function parseUrl(input: string, platform: string): ParsedId {
const trimmed = input.trim()
// If it's just an ID (no URL structure), return as unknown type
if (!trimmed.includes('/') && !trimmed.includes('.')) {
return {
id: trimmed,
type: 'unknown',
original: trimmed,
isValid: trimmed.length > 0,
}
}
const patterns = platformPatterns[platform]
if (!patterns) {
return {
id: trimmed,
type: 'unknown',
original: trimmed,
isValid: false,
}
}
// Try video patterns
for (const pattern of patterns.video) {
const match = trimmed.match(pattern)
if (match && match[1]) {
return {
id: match[1],
type: 'video',
original: trimmed,
isValid: true,
}
}
}
// Try creator patterns
for (const pattern of patterns.creator) {
const match = trimmed.match(pattern)
if (match && match[1]) {
return {
id: match[1],
type: 'creator',
original: trimmed,
isValid: true,
}
}
}
// Fallback: try to extract any ID-like segment
const urlMatch = trimmed.match(/([a-zA-Z0-9_-]{6,})/)
if (urlMatch) {
return {
id: urlMatch[1],
type: 'unknown',
original: trimmed,
isValid: false,
}
}
return {
id: trimmed,
type: 'unknown',
original: trimmed,
isValid: false,
}
}
export function parseMultipleUrls(input: string, platform: string): ParsedId[] {
if (!input.trim()) return []
const items = input
.split(/[,\n]+/)
.map(s => s.trim())
.filter(Boolean)
return items.map(item => parseUrl(item, platform))
}

25
webui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,25 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatFileSize(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 formatDateTime(date: string | Date | number): string {
const d = typeof date === 'string' ? new Date(date) : typeof date === 'number' ? new Date(date * 1000) : date
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}