chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
88
packages/frontend/src/App.vue
Executable file
88
packages/frontend/src/App.vue
Executable file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<button class="theme-toggle" @click="toggleTheme" :title="isDark ? '切换亮色' : '切换暗色'">
|
||||
{{ isDark ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : '')
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #f5f7fa;
|
||||
--text: #303133;
|
||||
--text2: #909399;
|
||||
--text3: #c0c4cc;
|
||||
--border: #e4e7ed;
|
||||
--primary: #409eff;
|
||||
--primary-light: rgba(64, 158, 255, 0.08);
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
--hover: #f5f7fa;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #141414;
|
||||
--bg-card: #1f1f1f;
|
||||
--bg-input: #2a2a2a;
|
||||
--text: #e5e5e5;
|
||||
--text2: #999999;
|
||||
--text3: #666666;
|
||||
--border: #333333;
|
||||
--primary: #409eff;
|
||||
--primary-light: rgba(64, 158, 255, 0.15);
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
--hover: #2a2a2a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] body { background: var(--bg); color: var(--text); }
|
||||
[data-theme="dark"] .el-card, [data-theme="dark"] .el-dialog, [data-theme="dark"] .el-menu { background-color: var(--bg-card) !important; border-color: var(--border) !important; color: var(--text) !important; }
|
||||
[data-theme="dark"] .el-input__wrapper, [data-theme="dark"] .el-select .el-input__wrapper { background-color: var(--bg-input) !important; border-color: var(--border) !important; }
|
||||
[data-theme="dark"] .el-input__inner, [data-theme="dark"] .el-textarea__inner { background-color: var(--bg-input) !important; color: var(--text) !important; }
|
||||
[data-theme="dark"] .el-button--default { background: var(--bg-card); border-color: var(--border); color: var(--text); }
|
||||
[data-theme="dark"] .rank-panel, [data-theme="dark"] .rank-item { background: var(--bg-card); border-color: var(--border); }
|
||||
[data-theme="dark"] .panel-title, [data-theme="dark"] .rank-name, [data-theme="dark"] .card-title { color: var(--text); }
|
||||
[data-theme="dark"] .card-meta, [data-theme="dark"] .rank-cnt, [data-theme="dark"] .panel-footer span:first-child { color: var(--text2); }
|
||||
[data-theme="dark"] .site-footer { background: var(--bg-card); border-color: var(--border); color: var(--text2); }
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 99;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.theme-toggle:hover { transform: scale(1.1); border-color: var(--primary); }
|
||||
|
||||
#app { min-height: 100vh; background: var(--bg); color: var(--text); }
|
||||
</style>
|
||||
433
packages/frontend/src/api/index.ts
Executable file
433
packages/frontend/src/api/index.ts
Executable file
@@ -0,0 +1,433 @@
|
||||
import axios from 'axios'
|
||||
import type {
|
||||
SearchResponse,
|
||||
VideoParseResult,
|
||||
SaveResult,
|
||||
QueryResponse,
|
||||
RankingItem,
|
||||
Promotion,
|
||||
CloudConfig,
|
||||
StatsData,
|
||||
} from '../types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 请求拦截器 — 添加管理员 Token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器 — 统一错误处理
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('admin_token')
|
||||
// Don't redirect if already on the login page or if this was a login attempt itself
|
||||
if (!window.location.pathname.startsWith('/admin/login') && !err.config?.url?.includes('/admin/login')) {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
// ===== 搜索与解析 =====
|
||||
export async function query(q: string, page = 1): Promise<QueryResponse> {
|
||||
const { data } = await api.post<QueryResponse>('/query', { q, page })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式搜索 — 使用 NDJSON stream,逐条返回验证结果
|
||||
* callback 接收五种事件:
|
||||
* onSearching() - 搜索开始(立即返回)
|
||||
* onSaved({results, total}) - 本地已保存资源(DB缓存,即时返回)
|
||||
* onStats({total, channels, content_info, content_tags}) - 统计信息
|
||||
* onResult(id, valid, message) - 单条链接验证结果
|
||||
* onComplete({results, channels, total, filtered}) - 全部完成
|
||||
*/
|
||||
export async function streamSearch(
|
||||
q: string,
|
||||
callbacks: {
|
||||
onSearching?: () => void
|
||||
onSaved?: (data: { results: any[]; total: number }) => void
|
||||
onStats: (stats: any) => void
|
||||
onResult: (id: string, valid: boolean, message?: string) => void
|
||||
onComplete: (data: any) => void
|
||||
onError?: (err: any) => void
|
||||
}
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/query', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ q }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const msg = JSON.parse(line)
|
||||
switch (msg.type) {
|
||||
case 'searching':
|
||||
callbacks.onSearching?.()
|
||||
break
|
||||
case 'saved':
|
||||
callbacks.onSaved?.(msg)
|
||||
break
|
||||
case 'stats':
|
||||
callbacks.onStats(msg)
|
||||
break
|
||||
case 'result':
|
||||
callbacks.onResult(msg.id, msg.valid, msg.message)
|
||||
break
|
||||
case 'complete':
|
||||
callbacks.onComplete(msg)
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
callbacks.onError?.(err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchPanSou(
|
||||
kw: string,
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
): Promise<SearchResponse> {
|
||||
const { data } = await api.get<SearchResponse>('/search', {
|
||||
params: { kw, page, page_size: pageSize },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function parseVideo(url: string): Promise<VideoParseResult> {
|
||||
const { data } = await api.post<VideoParseResult>('/video/parse', { url })
|
||||
return data
|
||||
}
|
||||
|
||||
// ===== 保存与分享 =====
|
||||
export async function saveToCloud(params: {
|
||||
type: 'search' | 'video'
|
||||
source: any
|
||||
target_cloud: string
|
||||
}): Promise<SaveResult> {
|
||||
const { data } = await api.post<SaveResult>('/save', params)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveVideoToCloud(params: {
|
||||
video_url: string
|
||||
title: string
|
||||
target_cloud: string
|
||||
}): Promise<SaveResult> {
|
||||
const { data } = await api.post<SaveResult>('/video/save-to-cloud', params)
|
||||
return data
|
||||
}
|
||||
|
||||
// ===== 排行榜 =====
|
||||
export async function getRankings(): Promise<RankingItem[]> {
|
||||
const { data } = await api.get<RankingItem[]>('/rankings')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getHotKeywords(): Promise<string[]> {
|
||||
const { data } = await api.get<string[]>('/rankings/hot')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getCategorizedRankings(): Promise<any> {
|
||||
const { data } = await api.get<any>('/rankings/categorized')
|
||||
return data
|
||||
}
|
||||
|
||||
// ===== 管理员 =====
|
||||
export async function adminLogin(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ token: string }> {
|
||||
const { data } = await api.post('/admin/login', { username, password })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<{ loggedIn: boolean; id?: number; username?: string }> {
|
||||
const { data } = await api.get('/me')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getCloudConfigs(): Promise<CloudConfig[]> {
|
||||
const { data } = await api.get('/admin/cloud-configs')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveCloudConfig(
|
||||
config: CloudConfig & { cookie?: string }
|
||||
): Promise<CloudConfig> {
|
||||
const { data } = await api.post('/admin/cloud-configs', config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateCloudConfig(
|
||||
config: CloudConfig & { cookie?: string }
|
||||
): Promise<CloudConfig> {
|
||||
const { data } = await api.put(`/admin/cloud-configs/${config.id}`, config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testCloudConnection(
|
||||
cloudType: string,
|
||||
cookie?: string,
|
||||
id?: number
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
nickname?: string
|
||||
storage_used?: string
|
||||
storage_total?: string
|
||||
}> {
|
||||
const { data } = await api.post(`/admin/cloud-configs/${cloudType}/test`, { cookie, id })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function dailyCheckIn(
|
||||
id: number
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
signedDays?: number
|
||||
}> {
|
||||
const { data } = await api.post(`/admin/cloud-configs/${id}/checkin`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function skipCheckin(id: number): Promise<boolean> {
|
||||
const { data } = await api.post(`/admin/cloud-configs/${id}/skip-checkin`)
|
||||
return data.success
|
||||
}
|
||||
|
||||
export async function checkinAll(): Promise<{
|
||||
total: number
|
||||
results: { id: number; nickname: string; success: boolean; message: string }[]
|
||||
}> {
|
||||
const { data } = await api.post('/admin/cloud-configs/checkin-all')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function checkinSummary(): Promise<{
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
pending: number
|
||||
skipped: number
|
||||
}> {
|
||||
const { data } = await api.get('/admin/cloud-configs/checkin-summary')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteCloudConfig(
|
||||
id: number
|
||||
): Promise<void> {
|
||||
await api.delete(`/admin/cloud-configs/${id}`)
|
||||
}
|
||||
|
||||
export async function getStats(days?: number): Promise<StatsData> {
|
||||
const params: Record<string, number> = {}
|
||||
if (days) params.days = days
|
||||
const { data } = await api.get('/admin/stats', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
// ===== 转存日志 =====
|
||||
export async function getSaveRecords(page = 1, pageSize = 20, startDate?: string, endDate?: string, status?: string, cloud?: string, keyword?: string): Promise<{
|
||||
total: number
|
||||
records: SaveRecord[]
|
||||
summary?: { total: number; success: number; failed: number; reused: number }
|
||||
}> {
|
||||
const params: Record<string, number | string> = { page, pageSize }
|
||||
if (startDate) params.startDate = startDate
|
||||
if (endDate) params.endDate = endDate
|
||||
if (status) params.status = status
|
||||
if (cloud) params.sourceType = cloud
|
||||
if (keyword) params.keyword = keyword
|
||||
const { data } = await api.get('/admin/save-records', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface SaveRecord {
|
||||
id: number
|
||||
source_type: string
|
||||
source_title: string | null
|
||||
source_url: string
|
||||
target_cloud: string
|
||||
share_url: string | null
|
||||
share_pwd: string | null
|
||||
file_size: string | null
|
||||
file_count: number
|
||||
duration_ms: number
|
||||
status: string
|
||||
error_message: string | null
|
||||
folder_name: string | null
|
||||
folder_count: number
|
||||
original_folder_name: string | null
|
||||
ip_address: string | null
|
||||
ip_location: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ===== 系统配置 =====
|
||||
export async function getSystemConfigs(): Promise<{ key: string; value: string; description: string }[]> {
|
||||
const { data } = await api.get('/admin/system-configs')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateSystemConfigs(
|
||||
entries: { key: string; value: string }[]
|
||||
): Promise<void> {
|
||||
await api.put('/admin/system-configs', { entries })
|
||||
}
|
||||
|
||||
// ===== 网盘类型开关 =====
|
||||
export async function getCloudTypes(): Promise<{ types: { type: string; label: string; icon: string; enabled: boolean }[] }> {
|
||||
const { data } = await api.get('/admin/cloud-types')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function toggleCloudType(type: string, enabled: boolean): Promise<void> {
|
||||
await api.put('/admin/cloud-types', { type, enabled })
|
||||
}
|
||||
|
||||
// ===== 修改密码 =====
|
||||
export async function changePassword(
|
||||
oldPassword: string,
|
||||
newPassword: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const { data } = await api.post('/admin/change-password', { oldPassword, newPassword })
|
||||
return data
|
||||
}
|
||||
|
||||
export default api
|
||||
export { query as searchQuery }
|
||||
|
||||
// ===== 系统设置(SettingsManage.vue) =====
|
||||
export async function getSettings(): Promise<any[]> {
|
||||
const { data } = await api.get('/admin/system-configs')
|
||||
return data
|
||||
}
|
||||
export async function updateSetting(key: string, value: string): Promise<void> {
|
||||
await api.put('/admin/system-configs', { entries: [{ key, value }] })
|
||||
}
|
||||
|
||||
export async function uploadFallbackImage(file: File): Promise<{ success: boolean; url: string; message: string }> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const { data } = await api.post('/admin/upload-fallback-image', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function uploadLogo(file: File): Promise<{ success: boolean; url: string; message: string }> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const { data } = await api.post('/admin/upload-logo', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSiteConfig(): Promise<{ site_logo: string; site_name: string; search_fallback_image: string; site_disclaimer: string }> {
|
||||
const { data } = await api.get('/site-config')
|
||||
return data
|
||||
}
|
||||
|
||||
// ===== Redis 连接测试 =====
|
||||
export async function testRedisConnection(url: string): Promise<{ ok: boolean; latency: number; info: string }> {
|
||||
const { data } = await api.post('/admin/test-redis', { url })
|
||||
return data
|
||||
}
|
||||
|
||||
// ===== 外部服务连接测试 =====
|
||||
export async function testExternalService(params: {
|
||||
type: 'pansou' | 'video_parser' | 'tmdb' | 'proxy' | 'ip_geo'
|
||||
url?: string
|
||||
token?: string
|
||||
}): Promise<{ ok: boolean; latency: number; info: string }> {
|
||||
const { data } = await api.post('/admin/test-external-service', params)
|
||||
return data
|
||||
}
|
||||
|
||||
// ===== 数据库状态 =====
|
||||
export async function getDbStatus(): Promise<{
|
||||
db_size: string
|
||||
db_path: string
|
||||
save_records: number
|
||||
search_stats: number
|
||||
system_configs: number
|
||||
cloud_configs: number
|
||||
content_cache: number
|
||||
redis_status: string
|
||||
redis_url: string
|
||||
}> {
|
||||
const { data } = await api.get('/admin/db-status')
|
||||
return data
|
||||
}
|
||||
|
||||
// ===== 存储清理 =====
|
||||
export async function runCleanup(): Promise<{
|
||||
success: boolean
|
||||
files_trashed: number
|
||||
logs_deleted: number
|
||||
trash_emptied: boolean
|
||||
errors: string[]
|
||||
message: string
|
||||
}> {
|
||||
const { data } = await api.post('/admin/cleanup/run')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function emptyAllTrash(): Promise<{
|
||||
success: boolean
|
||||
emptied: boolean
|
||||
errors: string[]
|
||||
message: string
|
||||
}> {
|
||||
const { data } = await api.post('/admin/cleanup/empty-trash')
|
||||
return data
|
||||
}
|
||||
1
packages/frontend/src/api/index.ts.new
Normal file
1
packages/frontend/src/api/index.ts.new
Normal file
@@ -0,0 +1 @@
|
||||
// This will be done in chunks via multiple commands to avoid escaping issues
|
||||
35
packages/frontend/src/components/CloudBadge.vue
Executable file
35
packages/frontend/src/components/CloudBadge.vue
Executable file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<span class="cloud-badge" :style="{ background: CLOUD_COLORS[cloud_type] }">
|
||||
<img v-if="showIcon && CLOUD_ICONS[cloud_type]" :src="CLOUD_ICONS[cloud_type]" class="badge-icon" />
|
||||
{{ CLOUD_LABELS[cloud_type] }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CLOUD_LABELS, CLOUD_COLORS, CLOUD_ICONS } from '../types'
|
||||
import type { CloudType } from '../types'
|
||||
|
||||
defineProps<{
|
||||
cloud_type: CloudType
|
||||
showIcon?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cloud-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
50
packages/frontend/src/components/CloudSelect.vue
Executable file
50
packages/frontend/src/components/CloudSelect.vue
Executable file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="cloud-select">
|
||||
<el-select v-model="selectedCloud" placeholder="选择目标网盘" @change="handleSelect">
|
||||
<el-option
|
||||
v-for="item in cloudList"
|
||||
:key="item.cloud_type"
|
||||
:label="item.nickname || CLOUD_LABELS[item.cloud_type]"
|
||||
:value="item.cloud_type"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { CLOUD_LABELS } from '../types'
|
||||
import type { CloudType, CloudConfig } from '../types'
|
||||
import { getCloudConfigs } from '../api'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: CloudType
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [cloudType: CloudType]
|
||||
'update:modelValue': [cloudType: CloudType]
|
||||
}>()
|
||||
|
||||
const selectedCloud = ref<CloudType | undefined>(props.modelValue)
|
||||
const cloudList = ref<CloudConfig[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
cloudList.value = await getCloudConfigs()
|
||||
} catch (e) {
|
||||
console.error('获取网盘配置失败', e)
|
||||
}
|
||||
})
|
||||
|
||||
function handleSelect(value: CloudType) {
|
||||
emit('select', value)
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cloud-select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
87
packages/frontend/src/components/RankingCard.vue
Executable file
87
packages/frontend/src/components/RankingCard.vue
Executable file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="ranking-card" @click="handleClick">
|
||||
<span class="rank-num" :class="{ 'top-three': index < 3 }">{{ index + 1 }}</span>
|
||||
<img v-if="data.cover" :src="data.cover" class="rank-cover" />
|
||||
<div class="rank-info">
|
||||
<span class="rank-title">{{ data.title }}</span>
|
||||
<span class="rank-count">🔍 {{ data.search_count }} 次搜索</span>
|
||||
</div>
|
||||
<CloudBadge :cloud_type="data.cloud_type" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import CloudBadge from './CloudBadge.vue'
|
||||
import type { RankingItem } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
data: RankingItem
|
||||
index: number
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function handleClick() {
|
||||
router.push('/search?q=' + encodeURIComponent(props.data.title))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ranking-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-white);
|
||||
border-radius: 8px;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ranking-card:hover {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
.rank-num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rank-num.top-three {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
.rank-cover {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rank-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.rank-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rank-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
530
packages/frontend/src/components/ResultCard.vue
Executable file
530
packages/frontend/src/components/ResultCard.vue
Executable file
@@ -0,0 +1,530 @@
|
||||
<template>
|
||||
<div class="result-card" :class="{ 'clickable': loggedIn }" @click="loggedIn && openLink()">
|
||||
<!-- 封面图区域(左侧) -->
|
||||
<div class="card-cover">
|
||||
<!-- 后台静默加载资源图,成功后切换 -->
|
||||
<img v-if="showCover" :src="data.cover" :alt="data.title" @error="onCoverLoadError" loading="lazy" fetchpriority="low" />
|
||||
<!-- 默认先显示兜底图(自家服务器,秒加载) -->
|
||||
<img v-else-if="fallbackImage && !fallbackImgError" :src="fallbackImage" alt="cover" class="fallback-img" @error="onFallbackImgError" />
|
||||
<!-- 兜底图也没有就用渐变色占位 -->
|
||||
<div v-else class="cover-placeholder" :style="{ background: coverGradient }">
|
||||
<span class="placeholder-icon">
|
||||
<img v-if="cloudIcon.startsWith('data:') || cloudIcon.startsWith('http') || cloudIcon.startsWith('/')" :src="cloudIcon" style="width:36px;height:36px" />
|
||||
<span v-else>{{ cloudIcon }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="cover-tag" :style="{ background: CLOUD_COLORS[data.cloud_type] }">
|
||||
{{ CLOUD_LABELS[data.cloud_type] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<div class="card-body">
|
||||
<!-- 资源名称(已清洗前缀和【】)2行显示 -->
|
||||
<div class="card-title" :title="data.title">{{ cleanTitle }}</div>
|
||||
|
||||
<!-- 更新时间 + 大小 -->
|
||||
<div class="card-time">
|
||||
<span>🕐 {{ relativeTime }}</span>
|
||||
<span v-if="data.file_size" class="meta-size">📦 {{ data.file_size }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签行(仅质量/格式/字幕类标签) -->
|
||||
<div v-if="displayTags.length > 0" class="card-tags">
|
||||
<span v-for="(tag, ti) in displayTags" :key="ti" class="tag" :class="'tag-' + tagClass(tag)">{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 底部行:来源 + 操作按钮 -->
|
||||
<div class="card-bottom-row">
|
||||
<div class="bottom-left">
|
||||
<span v-if="sourceName" class="meta-source" :title="data.source">
|
||||
{{ sourceIcon }} {{ sourceName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bottom-right">
|
||||
<button v-if="data.share_url && !loggedIn" class="action-btn get-link-btn" @click.stop="handleSave">
|
||||
🔗 获取分享链接
|
||||
</button>
|
||||
<button v-if="data.share_url && loggedIn" class="action-btn open-link-btn" @click.stop="openLink">
|
||||
🔗 打开链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CLOUD_LABELS, CLOUD_ICONS, CLOUD_COLORS } from '../types'
|
||||
import type { SearchResult } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
data: SearchResult
|
||||
fallbackTags?: string[]
|
||||
fallbackImage?: string
|
||||
loggedIn?: boolean
|
||||
cloudTypeMap?: Record<string, { label: string; icon: string }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [data: SearchResult]
|
||||
}>()
|
||||
|
||||
const PRELOAD_TIMEOUT_MS = 10000 // 资源图静默加载超时(10秒后放弃)
|
||||
|
||||
const showCover = ref(false) // 是否切换显示资源图(默认先显示兜底)
|
||||
const fallbackImgError = ref(false) // 兜底图加载失败
|
||||
const coverLoading = ref(false) // 资源图是否正在后台尝试
|
||||
let preloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// 后台静默预加载资源图
|
||||
if (props.data.cover && !showCover.value) {
|
||||
coverLoading.value = true
|
||||
const img = new Image()
|
||||
let resolved = false
|
||||
|
||||
preloadTimer = setTimeout(() => {
|
||||
// 超时未完成 -> 放弃,继续显示兜底图
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
coverLoading.value = false
|
||||
}
|
||||
}, PRELOAD_TIMEOUT_MS)
|
||||
|
||||
img.onload = () => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
showCover.value = true
|
||||
coverLoading.value = false
|
||||
if (preloadTimer) clearTimeout(preloadTimer)
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
coverLoading.value = false
|
||||
if (preloadTimer) clearTimeout(preloadTimer)
|
||||
}
|
||||
}
|
||||
|
||||
img.src = props.data.cover
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (preloadTimer) clearTimeout(preloadTimer)
|
||||
})
|
||||
|
||||
// 极意外情况:showCover 后图片加载失败 -> 回退到兜底图
|
||||
function onCoverLoadError() {
|
||||
showCover.value = false
|
||||
// 注意:此时 fallbackImage 可能已经加载过,浏览器有缓存会直接显示
|
||||
}
|
||||
|
||||
function onFallbackImgError() { fallbackImgError.value = true }
|
||||
|
||||
// 网盘图标 — 优先使用 prop 中的 API 数据,fallback emoji
|
||||
const cloudIcon = computed(() => {
|
||||
const icon = props.cloudTypeMap?.[props.data.cloud_type]?.icon
|
||||
return icon || CLOUD_ICONS[props.data.cloud_type] || '📁'
|
||||
})
|
||||
|
||||
// 封面渐变色(无图时)
|
||||
const coverGradient = computed(() => {
|
||||
const gradients: Record<string, string> = {
|
||||
quark: 'linear-gradient(135deg, #e8f5e9, #c8e6c9)',
|
||||
baidu: 'linear-gradient(135deg, #e3f2fd, #bbdefb)',
|
||||
aliyun: 'linear-gradient(135deg, #fff3e0, #ffe0b2)',
|
||||
'115': 'linear-gradient(135deg, #f3e5f5, #e1bee7)',
|
||||
xunlei: 'linear-gradient(135deg, #e8f5e9, #a5d6a7)',
|
||||
magnet: 'linear-gradient(135deg, #e8eaf6, #c5cae9)',
|
||||
}
|
||||
return gradients[props.data.cloud_type] || 'linear-gradient(135deg, #f5f5f5, #e0e0e0)'
|
||||
})
|
||||
|
||||
// ===== 时间格式化 =====
|
||||
function formatRelativeTime(dateStr?: string): string {
|
||||
if (!dateStr) return ''
|
||||
const now = Date.now()
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return dateStr.slice(0, 10)
|
||||
const diffMs = now - date.getTime()
|
||||
if (diffMs < 0) return dateStr.slice(0, 10)
|
||||
const secs = Math.floor(diffMs / 1000)
|
||||
if (secs < 60) return '刚刚'
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins} 分钟前`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days} 天前`
|
||||
if (days < 365) return `${Math.floor(days / 30)} 个月前`
|
||||
return `${Math.floor(days / 365)} 年前`
|
||||
}
|
||||
|
||||
const relativeTime = computed(() => {
|
||||
return formatRelativeTime(props.data.update_time || props.data.datetime)
|
||||
})
|
||||
|
||||
// ===== 来源解析 =====
|
||||
const sourceName = computed(() => {
|
||||
const src = props.data.source || ''
|
||||
if (!src) return ''
|
||||
if (src.startsWith('tg:')) return '@' + src.slice(3)
|
||||
if (src.startsWith('plugin:')) return src.slice(7)
|
||||
return src
|
||||
})
|
||||
|
||||
const sourceIcon = computed(() => {
|
||||
const src = props.data.source || ''
|
||||
if (src.startsWith('tg:')) return '📢'
|
||||
if (src.startsWith('plugin:')) return '🔌'
|
||||
return '📎'
|
||||
})
|
||||
|
||||
// ===== 标题清洗 =====
|
||||
// 移除各类前缀: [夸克网盘]、【#电影名称:】等
|
||||
const CLEAN_PREFIXES = [
|
||||
/^\[夸克网盘\][::]?\s*/,
|
||||
/^【#电影名称:】\s*/,
|
||||
/^【#电影名称[::]】\s*/,
|
||||
/^【[^】]*[网盘|分享|电影|下载|资源]】[::]?\s*/,
|
||||
/^\[[^\]]*[网盘|分享|电影|下载|资源]\]\s*/,
|
||||
/^[##]电影名称[::]?\s*/,
|
||||
/^[##]资源名称[::]?\s*/,
|
||||
/^[##]标题[::]?\s*/,
|
||||
/^【[^】]*资源名称[^】]*】\s*/,
|
||||
/^【影片名称】\s*/,
|
||||
/^【资源名称】\s*/,
|
||||
/^【标题】\s*/,
|
||||
]
|
||||
|
||||
const cleanTitle = computed(() => {
|
||||
let title = props.data.title || ''
|
||||
// 1. 去除【】内的通用前缀类标识
|
||||
for (const pat of CLEAN_PREFIXES) {
|
||||
title = title.replace(pat, '')
|
||||
}
|
||||
// 2. 去除剩余的【xxx】内容(保留内容作为标签,标题只留干净部分)
|
||||
title = title.replace(/【[^】]+】/g, '').trim()
|
||||
return title || props.data.title
|
||||
})
|
||||
|
||||
// ===== 标签 =====
|
||||
const QUALITY_TAG_SET = new Set([
|
||||
'4K', '1080P', '2160P', '720P', '480P',
|
||||
'HDR', 'HDR10', 'HDR10+', 'DV', '杜比视界', '杜比全景声',
|
||||
'高码率', 'BluRay', 'REMUX', 'HEVC', 'x264', 'x265', 'AVC',
|
||||
'内封简繁英字幕', '内嵌中英字幕', '内封简繁', '内嵌字幕',
|
||||
'字幕', '中文字幕', '简繁字幕', '中英字幕', '内封字幕',
|
||||
'臻彩', '高清', 'WEB-DL', 'WEBRip', '蓝光',
|
||||
])
|
||||
|
||||
const QUALITY_PATTERNS = [
|
||||
/\b(4K)\b/, /\b(1080[Pp])\b/, /\b(2160[Pp])\b/, /\b(720[Pp])\b/,
|
||||
/\b(HDR10?\+?)\b/i, /\b(DV)\b/i,
|
||||
/\b(BluRay|蓝光)\b/i, /\b(REMUX)\b/i, /\b(HEVC)\b/i,
|
||||
/\b(x264)\b/i, /\b(x265)\b/i, /\b(WEB-DL)\b/i, /\b(WEBRip)\b/i,
|
||||
]
|
||||
|
||||
const displayTags = computed(() => {
|
||||
const title = props.data.title || ''
|
||||
const tags: string[] = []
|
||||
|
||||
// 1. 从【xxx】提取内容,只保留质量/格式/字幕类标签
|
||||
const bracketMatches = title.matchAll(/【([^】]+)】/g)
|
||||
for (const m of bracketMatches) {
|
||||
const inner = m[1]
|
||||
const parts = inner.split(/[.·、,,\/\\|]/)
|
||||
for (const p of parts) {
|
||||
const trimmed = p.trim()
|
||||
if (trimmed && QUALITY_TAG_SET.has(trimmed) && !tags.includes(trimmed)) {
|
||||
tags.push(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 额外从标题提取分辨率/编码标签
|
||||
for (const pat of QUALITY_PATTERNS) {
|
||||
const m = title.match(pat)
|
||||
if (m) {
|
||||
const found = m[1]
|
||||
if (!tags.includes(found)) tags.push(found)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从标题全文找包含的关键词
|
||||
const fullTextKeywords = ['杜比视界', '杜比全景声', '高码率', '内封简繁英字幕', '内嵌中英字幕', '内封简繁', '内嵌字幕', '中文字幕', '简繁字幕', '中英字幕', '内封字幕', '臻彩']
|
||||
for (const kw of fullTextKeywords) {
|
||||
if (title.includes(kw) && !tags.includes(kw)) {
|
||||
tags.push(kw)
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.length === 0 && props.fallbackTags && props.fallbackTags.length > 0) {
|
||||
return props.fallbackTags.slice(0, 6)
|
||||
}
|
||||
|
||||
return tags.slice(0, 10)
|
||||
})
|
||||
|
||||
function tagClass(tag: string): string {
|
||||
const quality = ['4K', '1080P', '2160P', '720P', '480P', 'HDR', 'HDR10', 'HDR10+', 'DV', '杜比视界', 'BluRay', 'REMUX', 'HEVC', 'x264', 'x265', '臻彩', '高清', 'WEB-DL', 'WEBRip']
|
||||
if (quality.includes(tag)) return 'quality'
|
||||
if (tag.includes('字幕') || tag === '杜比全景声' || tag === '高码率') return 'subtitle'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
// ===== 交互行为 =====
|
||||
function handleSave() {
|
||||
emit('save', props.data)
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
if (props.data.share_url) {
|
||||
window.open(props.data.share_url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-card {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
gap: 14px;
|
||||
border: 1px solid #ebeef5;
|
||||
transition: all 0.2s ease;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 130px 120px;
|
||||
min-width: 0;
|
||||
}
|
||||
.result-card:hover {
|
||||
border-color: #c0c4cc;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.result-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.result-card.clickable:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ---- 封面(左侧,与右侧等高) ---- */
|
||||
.card-cover {
|
||||
position: relative;
|
||||
flex: 0 0 100px;
|
||||
max-width: 130px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f0f2f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
min-height: 100%;
|
||||
}
|
||||
.card-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.card-cover img.fallback-img {
|
||||
object-fit: contain;
|
||||
background: #f0f2f5;
|
||||
loading: eager;
|
||||
}
|
||||
.result-card:hover .card-cover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.placeholder-icon {
|
||||
font-size: 30px;
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.2);
|
||||
}
|
||||
.cover-tag {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.3px;
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ---- 右侧内容 ---- */
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* 资源名称 */
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 更新时间 */
|
||||
.card-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.meta-size {
|
||||
color: #67c23a;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- 标签 ---- */
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.8;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.tag-quality {
|
||||
background: #fef0f0;
|
||||
color: #e74c3c;
|
||||
}
|
||||
.tag-subtitle {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
.tag-default {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* ---- 底部行:来源 + 操作 ---- */
|
||||
.card-bottom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
.bottom-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.bottom-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.meta-source {
|
||||
color: #909399;
|
||||
background: #f4f4f5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.get-link-btn {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
.get-link-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.open-link-btn {
|
||||
background: #67c23a;
|
||||
color: #fff;
|
||||
}
|
||||
.open-link-btn:hover {
|
||||
background: #5daf34;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 640px) {
|
||||
.result-card {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.card-cover {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
align-self: auto;
|
||||
}
|
||||
.card-cover img {
|
||||
position: static;
|
||||
height: 160px;
|
||||
}
|
||||
.card-cover img.fallback-img {
|
||||
position: static;
|
||||
}
|
||||
.cover-placeholder {
|
||||
position: static;
|
||||
}
|
||||
.card-bottom-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
packages/frontend/src/components/VideoResultCard.vue
Executable file
130
packages/frontend/src/components/VideoResultCard.vue
Executable file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="video-card">
|
||||
<div class="video-cover">
|
||||
<img :src="data.cover" :alt="data.title" />
|
||||
<div class="play-icon">▶</div>
|
||||
<span class="platform-tag">{{ data.platform }}</span>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<h4>{{ data.title }}</h4>
|
||||
<p v-if="data.author" class="video-author">👤 {{ data.author }}</p>
|
||||
<p v-if="data.description" class="video-desc">{{ data.description }}</p>
|
||||
</div>
|
||||
<div class="video-actions">
|
||||
<button class="save-btn" @click="handleSave">📥 保存到云盘并获取下载链接</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VideoParseResult } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
data: VideoParseResult
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [data: VideoParseResult]
|
||||
}>()
|
||||
|
||||
function handleSave() {
|
||||
emit('save', props.data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-card {
|
||||
display: flex;
|
||||
background: var(--bg-white);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.video-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.video-cover {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.video-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.play-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
}
|
||||
.platform-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.video-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.video-info h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.video-author {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.video-desc {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.video-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.save-btn {
|
||||
padding: 10px 20px;
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.save-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
</style>
|
||||
382
packages/frontend/src/composables/useTrendChart.ts
Normal file
382
packages/frontend/src/composables/useTrendChart.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* useTrendChart
|
||||
*
|
||||
* Composable that manages a combined bar+line ECharts trend chart.
|
||||
* Features:
|
||||
* - Tree-shakeable ECharts import (core + only used chart types/components)
|
||||
* - Auto resize via ResizeObserver
|
||||
* - DataZoom slider for 30+ day ranges
|
||||
* - Day-over-day delta in tooltips
|
||||
* - Average markLine
|
||||
* - Period summary callback
|
||||
* - Proper cleanup on unmount
|
||||
*/
|
||||
import { ref, watch, nextTick, onUnmounted, type Ref } from 'vue'
|
||||
import { init, use, graphic } from 'echarts/core'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import {
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
DataZoomSliderComponent,
|
||||
MarkLineComponent,
|
||||
} from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||
|
||||
// Register only the features we actually use
|
||||
use([
|
||||
BarChart, LineChart,
|
||||
TooltipComponent, GridComponent, LegendComponent,
|
||||
DataZoomSliderComponent, MarkLineComponent,
|
||||
CanvasRenderer,
|
||||
])
|
||||
|
||||
const { LinearGradient } = graphic
|
||||
|
||||
export interface TrendDataPoint {
|
||||
date: string
|
||||
searches: number
|
||||
saves: number
|
||||
searchDelta: number
|
||||
saveDelta: number
|
||||
}
|
||||
|
||||
export interface TrendSummary {
|
||||
totalSearches: number
|
||||
totalSaves: number
|
||||
avgSearches: number
|
||||
avgSaves: number
|
||||
peakDay: string
|
||||
peakSearches: number
|
||||
peakSaves: number
|
||||
dayCount: number
|
||||
}
|
||||
|
||||
export function useTrendChart(
|
||||
trendData: Ref<TrendDataPoint[]>,
|
||||
onSummary?: Ref<TrendSummary | null> | ((s: TrendSummary) => void),
|
||||
) {
|
||||
const chartRef = ref<HTMLDivElement | null>(null)
|
||||
let chartInstance: ECharts | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const BAR_WIDTH_PCT = '35%'
|
||||
const BAR_GAP_PCT = '30%'
|
||||
|
||||
function computeSummary(data: TrendDataPoint[]): TrendSummary {
|
||||
const totalSearches = data.reduce((s, d) => s + d.searches, 0)
|
||||
const totalSaves = data.reduce((s, d) => s + d.saves, 0)
|
||||
const n = data.length || 1
|
||||
let peakIdx = 0
|
||||
let peakVal = 0
|
||||
data.forEach((d, i) => {
|
||||
const v = d.searches + d.saves
|
||||
if (v > peakVal) { peakVal = v; peakIdx = i }
|
||||
})
|
||||
return {
|
||||
totalSearches,
|
||||
totalSaves,
|
||||
avgSearches: Math.round(totalSearches / n),
|
||||
avgSaves: Math.round(totalSaves / n),
|
||||
peakDay: data[peakIdx]?.date?.slice(5) || '—',
|
||||
peakSearches: data[peakIdx]?.searches || 0,
|
||||
peakSaves: data[peakIdx]?.saves || 0,
|
||||
dayCount: n,
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const el = chartRef.value
|
||||
const data = trendData.value
|
||||
if (!el) return
|
||||
|
||||
// Compute summary and emit
|
||||
const summary = computeSummary(data)
|
||||
if (typeof onSummary === 'function') {
|
||||
onSummary(summary)
|
||||
} else if (onSummary) {
|
||||
onSummary.value = summary
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!data.length) {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#909399;font-size:13px;">暂无使用数据</div>'
|
||||
return
|
||||
}
|
||||
|
||||
// Handle DOM element recreation (v-if remounts chart div when navigating away & back)
|
||||
if (!chartInstance || chartInstance.getDom() !== el) {
|
||||
if (chartInstance) chartInstance.dispose()
|
||||
chartInstance = init(el)
|
||||
initResize()
|
||||
}
|
||||
|
||||
const dates = data.map(d => d.date.slice(5)) // MM-DD
|
||||
const n = data.length
|
||||
const maxCount = Math.max(...data.map(d => Math.max(d.searches, d.saves)), 1)
|
||||
const yMax = Math.ceil(maxCount * 1.35) || 1
|
||||
const avgSearches = Math.round(summary.totalSearches / n)
|
||||
const showDataZoom = n >= 30
|
||||
|
||||
const option: EChartsCoreOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: 'rgba(255,255,255,0.97)',
|
||||
borderColor: '#e8e8e8',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: [10, 14],
|
||||
textStyle: { fontSize: 12, color: '#303133' },
|
||||
formatter: (params: any) => {
|
||||
const idx = params[0]?.dataIndex ?? 0
|
||||
const dateLabel = data[idx]?.date || ''
|
||||
const d = data[idx]
|
||||
const seen = new Set<string>()
|
||||
const items = params.filter((p: any) => {
|
||||
const key = p.seriesName
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
let html = `<div style="font-weight:700;font-size:13px;margin-bottom:8px;color:#1a1a2e">${dateLabel}</div>`
|
||||
items.forEach((p: any) => {
|
||||
const rawVal = p.value
|
||||
const val = Array.isArray(rawVal) ? rawVal[1] : rawVal
|
||||
const isBar = p.seriesType === 'bar'
|
||||
const isSearch = p.seriesName === '搜索'
|
||||
const delta = isSearch ? d?.searchDelta : d?.saveDelta
|
||||
const deltaStr = delta !== undefined && delta !== 0
|
||||
? `<span style="margin-left:6px;font-size:11px;color:${delta > 0 ? '#f56c6c' : '#67c23a'}">${delta > 0 ? '↑' : '↓'}${Math.abs(delta)}</span>`
|
||||
: (delta === 0 ? '<span style="margin-left:6px;font-size:11px;color:#909399">→0</span>' : '')
|
||||
const icon = isBar
|
||||
? `<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${p.color};vertical-align:middle"></span>`
|
||||
: `<span style="display:inline-block;width:14px;height:2px;background:${p.color};vertical-align:middle"></span>`
|
||||
html += `<div style="display:flex;align-items:center;gap:6px;margin-top:4px">${icon}<span>${p.seriesName}:<b>${val}</b> 次${deltaStr}</span></div>`
|
||||
})
|
||||
return html
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['搜索', '保存'],
|
||||
bottom: showDataZoom ? 30 : 0,
|
||||
left: 'center',
|
||||
itemWidth: 14,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 11, color: '#666' },
|
||||
},
|
||||
grid: {
|
||||
left: 8,
|
||||
right: 12,
|
||||
top: 28,
|
||||
bottom: showDataZoom ? 70 : 42,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
fontSize: 11,
|
||||
color: '#909399',
|
||||
rotate: n > 15 ? 45 : 0,
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
splitLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '次',
|
||||
nameTextStyle: { fontSize: 10, color: '#909399' },
|
||||
min: 0,
|
||||
max: yMax,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
fontSize: 10, color: '#909399' },
|
||||
splitLine: { lineStyle: { color: '#f5f5f5', type: 'dashed' } },
|
||||
},
|
||||
series: [
|
||||
// Bar — 搜索
|
||||
{
|
||||
name: '搜索',
|
||||
type: 'bar',
|
||||
data: data.map(d => d.searches),
|
||||
barWidth: BAR_WIDTH_PCT,
|
||||
barGap: BAR_GAP_PCT,
|
||||
itemStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#6366f1' },
|
||||
{ offset: 1, color: '#a5b4fc' },
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#4f46e5' },
|
||||
{ offset: 1, color: '#818cf8' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
},
|
||||
// Bar — 保存
|
||||
{
|
||||
name: '保存',
|
||||
type: 'bar',
|
||||
data: data.map(d => d.saves),
|
||||
barWidth: BAR_WIDTH_PCT,
|
||||
barGap: BAR_GAP_PCT,
|
||||
itemStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#10b981' },
|
||||
{ offset: 1, color: '#6ee7b7' },
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#059669' },
|
||||
{ offset: 1, color: '#34d399' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
},
|
||||
// Line — 搜索
|
||||
{
|
||||
name: '搜索',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: data.map(d => d.searches),
|
||||
lineStyle: { width: 2.5, color: '#4f46e5' },
|
||||
itemStyle: { color: '#4f46e5', borderColor: '#fff', borderWidth: 2 },
|
||||
areaStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(99,102,241,0.12)' },
|
||||
{ offset: 1, color: 'rgba(99,102,241,0.01)' },
|
||||
]),
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
connectNulls: true,
|
||||
animationDuration: 700,
|
||||
animationEasing: 'cubicOut',
|
||||
z: 3,
|
||||
markLine: avgSearches > 0 ? {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#6366f1', type: 'dashed', width: 1, opacity: 0.5 },
|
||||
label: {
|
||||
formatter: `均 ${avgSearches}`,
|
||||
position: 'insideEndTop',
|
||||
fontSize: 10,
|
||||
color: '#6366f1',
|
||||
},
|
||||
data: [{ yAxis: avgSearches, name: '日均搜索' }],
|
||||
} : undefined,
|
||||
},
|
||||
// Line — 保存
|
||||
{
|
||||
name: '保存',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: data.map(d => d.saves),
|
||||
lineStyle: { width: 2.5, color: '#059669' },
|
||||
itemStyle: { color: '#059669', borderColor: '#fff', borderWidth: 2 },
|
||||
areaStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(16,185,129,0.12)' },
|
||||
{ offset: 1, color: 'rgba(16,185,129,0.01)' },
|
||||
]),
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
connectNulls: true,
|
||||
animationDuration: 700,
|
||||
animationEasing: 'cubicOut',
|
||||
z: 3,
|
||||
markLine: avgSearches > 0 ? {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#10b981', type: 'dashed', width: 1, opacity: 0.5 },
|
||||
label: {
|
||||
formatter: `均 ${Math.round(summary.totalSaves / n)}`,
|
||||
position: 'insideEndTop',
|
||||
fontSize: 10,
|
||||
color: '#10b981',
|
||||
},
|
||||
data: [{ yAxis: Math.round(summary.totalSaves / n), name: '日均保存' }],
|
||||
} : undefined,
|
||||
},
|
||||
],
|
||||
// DataZoom slider for 30+ day views
|
||||
...(showDataZoom ? {
|
||||
dataZoom: [{
|
||||
type: 'slider',
|
||||
bottom: 6,
|
||||
height: 20,
|
||||
start: 0,
|
||||
end: 100,
|
||||
borderColor: '#e8e8e8',
|
||||
fillerColor: 'rgba(99,102,241,0.08)',
|
||||
handleStyle: { color: '#6366f1', borderColor: '#6366f1' },
|
||||
textStyle: { fontSize: 10, color: '#909399' },
|
||||
}],
|
||||
} : {}),
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
// Auto-render when data changes
|
||||
watch(
|
||||
trendData,
|
||||
() => {
|
||||
nextTick(() => render())
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Setup ResizeObserver for responsive chart
|
||||
function initResize() {
|
||||
if (!chartRef.value) return
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
chartInstance?.resize()
|
||||
})
|
||||
resizeObserver.observe(chartRef.value)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
chartInstance?.dispose()
|
||||
chartInstance = null
|
||||
})
|
||||
|
||||
return {
|
||||
chartRef,
|
||||
render,
|
||||
initResize,
|
||||
}
|
||||
}
|
||||
7
packages/frontend/src/env.d.ts
vendored
Executable file
7
packages/frontend/src/env.d.ts
vendored
Executable file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
13
packages/frontend/src/main.ts
Executable file
13
packages/frontend/src/main.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/global.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
363
packages/frontend/src/pages/HomePage.vue
Executable file
363
packages/frontend/src/pages/HomePage.vue
Executable file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div class="hero-section">
|
||||
<template v-if="configLoaded">
|
||||
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
|
||||
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
|
||||
</template>
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="query"
|
||||
placeholder="搜索网盘资源,或粘贴视频/网盘链接..."
|
||||
size="large"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">
|
||||
搜 索
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="quote-section" v-if="currentQuote">
|
||||
<span class="quote-text">「 {{ currentQuote }} 」</span>
|
||||
<span class="quote-author">---{{ quoteAuthor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<div v-if="categories.length > 0" class="rankings-grid">
|
||||
<div
|
||||
v-for="cat in categories"
|
||||
:key="cat.category"
|
||||
class="rank-panel"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">{{ getCategoryIcon(cat.category) }} {{ cat.label }}</span>
|
||||
<div class="panel-tabs">
|
||||
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'hot' }" @click="switchTab(cat.category, 'hot')">热榜</span>
|
||||
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'newest' }" @click="switchTab(cat.category, 'newest')">最新</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
v-for="(item, idx) in visibleItems(cat)"
|
||||
:key="cat.category + '-' + idx"
|
||||
class="rank-item"
|
||||
@click="searchTag(item.keyword)"
|
||||
>
|
||||
<span class="rank-idx" :class="{ 'top-three': idx < 3 }">{{ idx + 1 }}</span>
|
||||
<span class="rank-name">{{ item.keyword }}</span>
|
||||
<span class="rank-cnt">{{ formatCount(item) }}</span>
|
||||
</div>
|
||||
<!-- 展开按钮 -->
|
||||
<div v-if="hasMoreItems(cat)" class="rank-expand" @click="expandCategory(cat.category)">
|
||||
展开全部 ▼
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<span v-if="cat.category === 'hotsite'">基于本站搜索数据</span>
|
||||
<span v-else-if="cat.category === 'donghua' || cat.category === 'global_anime'">数据来源:Bilibili</span>
|
||||
<span v-else-if="cat.category === 'movie' || cat.category === 'tv'">数据来源:百度</span>
|
||||
<span v-else>数据来源:TMDB</span>
|
||||
<span class="footer-time">{{ fetchedAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="siteDisclaimer" class="site-footer">
|
||||
<div class="footer-inner">{{ siteDisclaimer }}</div>
|
||||
<div class="footer-actions">
|
||||
<el-button class="footer-disclaimer-btn" size="small" @click="openDisclaimer">📜 免责声明</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { getCategorizedRankings, getSiteConfig } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const query = ref('')
|
||||
const categories = ref<any[]>([])
|
||||
const expanded = reactive<Record<string, boolean>>({})
|
||||
const activeTab = reactive<Record<string, string>>({})
|
||||
const siteLogo = ref('')
|
||||
const siteName = ref('')
|
||||
const siteDisclaimer = ref('')
|
||||
const configLoaded = ref(false)
|
||||
const currentQuote = ref('')
|
||||
const quoteAuthor = ref('')
|
||||
const fetchedAt = ref('')
|
||||
|
||||
const INITIAL_SHOW = 8
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
movie: '🎬', western_movie: '🎥', western_tv: '🌍',
|
||||
donghua: '🐉', global_anime: '🌐',
|
||||
tv: '📺',
|
||||
niche: '💎', hotsite: '🏆',
|
||||
}
|
||||
|
||||
function getCategoryIcon(cat: string): string {
|
||||
return CATEGORY_ICONS[cat] || '📋'
|
||||
}
|
||||
|
||||
function formatCount(item: any): string {
|
||||
const parts: string[] = []
|
||||
if (item.rating) {
|
||||
parts.push(`⭐${item.rating}`)
|
||||
}
|
||||
if (item.searchCount > 0) {
|
||||
const n = item.searchCount
|
||||
if (n >= 100000000) {
|
||||
parts.push(`${(n / 100000000).toFixed(1)}亿`)
|
||||
} else if (n >= 10000) {
|
||||
parts.push(`${(n / 10000).toFixed(0)}万`)
|
||||
} else {
|
||||
parts.push(String(n))
|
||||
}
|
||||
}
|
||||
return parts.join(' ') || ''
|
||||
}
|
||||
|
||||
function getItems(cat: any): any[] {
|
||||
const tab = activeTab[cat.category] || 'hot'
|
||||
return tab === 'hot' ? (cat.hot || []) : (cat.newest || [])
|
||||
}
|
||||
|
||||
function visibleItems(cat: any): any[] {
|
||||
const items = getItems(cat)
|
||||
return expanded[cat.category] ? items : items.slice(0, INITIAL_SHOW)
|
||||
}
|
||||
|
||||
function hasMoreItems(cat: any): boolean {
|
||||
const items = getItems(cat)
|
||||
return items.length > INITIAL_SHOW && !expanded[cat.category]
|
||||
}
|
||||
|
||||
function expandCategory(category: string) {
|
||||
expanded[category] = true
|
||||
}
|
||||
|
||||
function switchTab(category: string, tab: string) {
|
||||
activeTab[category] = tab
|
||||
// 切换标签时收起展开
|
||||
expanded[category] = false
|
||||
}
|
||||
|
||||
function openDisclaimer() {
|
||||
window.open('/disclaimer/', '_blank')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 一言 API
|
||||
try {
|
||||
const res = await fetch('https://v1.hitokoto.cn/')
|
||||
const data = await res.json()
|
||||
currentQuote.value = data.hitokoto || ''
|
||||
quoteAuthor.value = data.from_who || data.from || ''
|
||||
} catch {
|
||||
currentQuote.value = '学而时习之,不亦说乎。'
|
||||
quoteAuthor.value = '孔子'
|
||||
}
|
||||
|
||||
try {
|
||||
const [catsData, siteCfg] = await Promise.all([
|
||||
getCategorizedRankings(),
|
||||
getSiteConfig(),
|
||||
])
|
||||
// 新格式: { fetchedAt, categories }
|
||||
if (catsData.fetchedAt) {
|
||||
fetchedAt.value = catsData.fetchedAt
|
||||
categories.value = catsData.categories || []
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
categories.value = Array.isArray(catsData) ? catsData : []
|
||||
}
|
||||
for (const cat of categories.value) {
|
||||
activeTab[cat.category] = 'hot'
|
||||
expanded[cat.category] = false
|
||||
}
|
||||
if (siteCfg.site_logo) {
|
||||
siteLogo.value = siteCfg.site_logo
|
||||
}
|
||||
if (siteCfg.site_name) {
|
||||
siteName.value = siteCfg.site_name
|
||||
}
|
||||
if (siteCfg.site_disclaimer) {
|
||||
siteDisclaimer.value = siteCfg.site_disclaimer
|
||||
}
|
||||
configLoaded.value = true
|
||||
} catch (e) {
|
||||
console.error('加载首页数据失败', e)
|
||||
}
|
||||
})
|
||||
|
||||
function handleSearch() {
|
||||
const q = query.value.trim()
|
||||
if (q) router.push('/search?q=' + encodeURIComponent(q))
|
||||
}
|
||||
|
||||
function searchTag(tag: string) {
|
||||
router.push('/search?q=' + encodeURIComponent(tag))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-page { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
.hero-section {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 60px 24px 40px;
|
||||
}
|
||||
.logo-text { font-size: 64px; font-weight: 700; color: var(--primary-color); margin-bottom: 32px; letter-spacing: -2px; }
|
||||
.logo-img { max-width: 500px; max-height: 120px; width: auto; height: auto; object-fit: contain; margin-bottom: 32px; }
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%; max-width: 640px;
|
||||
border: 1px solid #dfe1e5;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
transition: box-shadow .2s, border-color .2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.search-box:focus-within {
|
||||
box-shadow: 0 1px 6px rgba(32,33,36,.28);
|
||||
border-color: rgba(223,225,229,0);
|
||||
}
|
||||
.search-box :deep(.el-input__wrapper) {
|
||||
border: none; box-shadow: none; background: transparent;
|
||||
padding: 4px 20px; border-radius: 0;
|
||||
}
|
||||
.search-box :deep(.el-input__inner) {
|
||||
font-size: 15px;
|
||||
}
|
||||
.search-btn {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0 24px;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
margin: 4px;
|
||||
font-size: 14px; font-weight: 600;
|
||||
background: var(--primary-color); color: #fff;
|
||||
cursor: pointer; transition: all .2s;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.search-btn:hover {
|
||||
background: #3a7be0;
|
||||
}
|
||||
.search-btn:active {
|
||||
background: #2d6ccf;
|
||||
}
|
||||
|
||||
.quote-section { margin-top: 18px; max-width: 640px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.quote-text { font-size: 14px; color: #aab0b8; font-style: italic; letter-spacing: 0.5px; }
|
||||
.quote-author { font-size: 12px; color: #c0c4cc; display: inline-block; margin-left: 4px; }
|
||||
|
||||
.content-section { max-width: 1500px; width: 100%; margin: 0 auto; padding: 0 16px 60px; }
|
||||
|
||||
.rankings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.rank-panel {
|
||||
background: var(--bg-white,#fff);
|
||||
border-radius: 12px; padding: 14px; border: 1px solid #ebeef5;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04); display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding-bottom: 10px; border-bottom: 2px solid #f0f0f0; margin-bottom: 4px;
|
||||
}
|
||||
.panel-title { font-size: 15px; font-weight: 700; color: #303133; white-space: nowrap; }
|
||||
.panel-tabs { display: flex; gap: 2px; background: #f0f2f5; border-radius: 6px; padding: 2px; }
|
||||
.panel-tab {
|
||||
font-size: 11px; padding: 3px 10px; border-radius: 5px; cursor: pointer;
|
||||
color: #909399; font-weight: 500; transition: all .2s; user-select: none;
|
||||
}
|
||||
.panel-tab.active { background: #fff; color: var(--primary-color); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
|
||||
.panel-body { flex: 1; display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.rank-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 5px 6px;
|
||||
border-radius: 6px; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.rank-item:hover { background: #f0f5ff; }
|
||||
.rank-item:active { background: #e6f0ff; }
|
||||
|
||||
.rank-idx {
|
||||
width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center;
|
||||
justify-content: center; font-size: 12px; font-weight: 700;
|
||||
color: #909399; background: #f0f0f0; flex-shrink: 0;
|
||||
}
|
||||
.rank-idx.top-three { background: var(--primary-color); color: #fff; }
|
||||
.rank-name { flex: 1; min-width: 0; font-size: 13px; font-weight: 500; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rank-cnt { font-size: 11px; color: #c0c4cc; white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
.rank-expand {
|
||||
text-align: center; padding: 6px; margin-top: 2px; font-size: 12px;
|
||||
color: var(--primary-color); cursor: pointer; border-radius: 6px;
|
||||
transition: background .15s; user-select: none;
|
||||
}
|
||||
.rank-expand:hover { background: #ecf5ff; }
|
||||
|
||||
.panel-footer {
|
||||
margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 11px; color: #c0c4cc;
|
||||
}
|
||||
.footer-time { font-family: monospace; font-size: 10px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-section { padding: 36px 16px 24px; }
|
||||
.logo-text { font-size: 36px; margin-bottom: 20px; }
|
||||
.logo-img { max-width: 360px; max-height: 100px; margin-bottom: 20px; }
|
||||
.rankings-scroll { gap: 12px; }
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: auto;
|
||||
padding: 20px 16px 32px;
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.footer-disclaimer-btn {
|
||||
font-size: 12px !important;
|
||||
color: #909399 !important;
|
||||
}
|
||||
.footer-disclaimer-btn:hover {
|
||||
color: #409eff !important;
|
||||
}
|
||||
</style>
|
||||
350
packages/frontend/src/pages/ResultDetail.vue
Executable file
350
packages/frontend/src/pages/ResultDetail.vue
Executable file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="result-detail-page">
|
||||
<div class="detail-container">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<!-- 网盘资源详情 -->
|
||||
<div v-else-if="result" class="detail-card">
|
||||
<div class="detail-header">
|
||||
<div class="detail-cover">
|
||||
<img :src="result.cover" :alt="result.title" />
|
||||
<CloudBadge :cloud_type="result.cloud_type" />
|
||||
</div>
|
||||
<div class="detail-info">
|
||||
<h1>{{ result.title }}</h1>
|
||||
<div class="detail-meta">
|
||||
<el-tag v-if="result.file_size">📦 {{ result.file_size }}</el-tag>
|
||||
<el-tag v-if="result.update_time">🕐 {{ result.update_time }}</el-tag>
|
||||
<el-tag v-if="result.source">📂 {{ result.source }}</el-tag>
|
||||
</div>
|
||||
<p v-if="result.description" class="detail-desc">{{ result.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<el-button type="primary" size="large" @click="showSaveDialog">
|
||||
📥 保存到网盘
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频解析详情 -->
|
||||
<div v-else-if="videoResult" class="detail-card">
|
||||
<div class="detail-video">
|
||||
<div class="video-preview">
|
||||
<img :src="videoResult.cover" :alt="videoResult.title" />
|
||||
<div class="play-overlay" @click="playVideo">
|
||||
<div class="play-btn">▶ 播放</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<h1>{{ videoResult.title }}</h1>
|
||||
<p v-if="videoResult.author" class="video-author">👤 {{ videoResult.author }}</p>
|
||||
<p class="video-platform">📺 {{ videoResult.platform }}</p>
|
||||
<p v-if="videoResult.description" class="detail-desc">{{ videoResult.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<el-button type="primary" size="large" @click="showSaveDialog">
|
||||
📥 保存到云盘
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 视频播放器 -->
|
||||
<div v-if="showPlayer" class="video-player-wrapper">
|
||||
<video :src="videoResult.video_url" controls autoplay class="video-player"></video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未找到 -->
|
||||
<el-empty v-else description="未找到该资源" />
|
||||
</div>
|
||||
|
||||
<!-- 保存弹窗 -->
|
||||
<el-dialog v-model="saveDialogVisible" title="保存到网盘" width="420px">
|
||||
<div class="save-dialog-content">
|
||||
<p class="save-file-name">📄 {{ result?.title || videoResult?.title }}</p>
|
||||
<CloudSelect @select="onCloudSelected" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="saveDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="confirmSave">确认保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 保存结果弹窗 -->
|
||||
<el-dialog v-model="resultDialogVisible" title="保存成功" width="420px">
|
||||
<div class="result-dialog-content">
|
||||
<el-alert type="success" :title="saveResult?.message || '保存成功'" show-icon :closable="false" />
|
||||
<div class="share-link-box">
|
||||
<p class="share-label">分享链接:</p>
|
||||
<div class="share-link-row">
|
||||
<el-input v-model="shareLink" readonly />
|
||||
<el-button @click="copyShareLink">复制</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="resultDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import CloudBadge from '../components/CloudBadge.vue'
|
||||
import CloudSelect from '../components/CloudSelect.vue'
|
||||
import { query, saveToCloud, saveVideoToCloud } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { SearchResult, VideoParseResult, SaveResult } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const result = ref<SearchResult | null>(null)
|
||||
const videoResult = ref<VideoParseResult | null>(null)
|
||||
const showPlayer = ref(false)
|
||||
|
||||
const saveDialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const selectedCloud = ref('')
|
||||
const isVideo = ref(false)
|
||||
|
||||
const resultDialogVisible = ref(false)
|
||||
const saveResult = ref<SaveResult | null>(null)
|
||||
const shareLink = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string
|
||||
if (!id) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 用 id 作为搜索词重新查询具体结果
|
||||
const res = await query(id)
|
||||
if (res.intent === 'SEARCH' && res.results.length > 0) {
|
||||
result.value = (res.results as SearchResult[])[0]
|
||||
} else if (res.intent === 'VIDEO_PARSE' && res.results.length > 0) {
|
||||
videoResult.value = (res.results as VideoParseResult[])[0]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取详情失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function showSaveDialog() {
|
||||
isVideo.value = !!videoResult.value
|
||||
saveDialogVisible.value = true
|
||||
}
|
||||
|
||||
function playVideo() {
|
||||
showPlayer.value = true
|
||||
}
|
||||
|
||||
function onCloudSelected(cloudType: string) {
|
||||
selectedCloud.value = cloudType
|
||||
}
|
||||
|
||||
async function confirmSave() {
|
||||
if (!selectedCloud.value) {
|
||||
ElMessage.warning('请选择目标网盘')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
let res: SaveResult
|
||||
if (isVideo.value && videoResult.value) {
|
||||
res = await saveVideoToCloud({
|
||||
video_url: videoResult.value.video_url,
|
||||
title: videoResult.value.title,
|
||||
target_cloud: selectedCloud.value,
|
||||
})
|
||||
} else if (result.value) {
|
||||
res = await saveToCloud({
|
||||
type: 'search',
|
||||
source: result.value,
|
||||
target_cloud: selectedCloud.value,
|
||||
})
|
||||
} else {
|
||||
return
|
||||
}
|
||||
saveResult.value = res
|
||||
shareLink.value = res.share_url
|
||||
saveDialogVisible.value = false
|
||||
resultDialogVisible.value = true
|
||||
} catch (e) {
|
||||
console.error('保存失败', e)
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShareLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink.value)
|
||||
ElMessage.success('链接已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.warning('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
.detail-container {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.detail-card {
|
||||
background: var(--bg-white);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 32px;
|
||||
}
|
||||
.detail-header {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.detail-cover {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 240px;
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.detail-info {
|
||||
flex: 1;
|
||||
}
|
||||
.detail-info h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-desc {
|
||||
font-size: 15px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-actions {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 20px;
|
||||
}
|
||||
.detail-video {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.video-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.video-preview img {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.play-overlay:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.play-btn {
|
||||
padding: 12px 32px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.video-info h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.video-author,
|
||||
.video-platform {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.video-player-wrapper {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 24px;
|
||||
}
|
||||
.video-player {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.loading-state {
|
||||
padding: 40px 0;
|
||||
}
|
||||
.save-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.save-file-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
.result-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.share-link-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.share-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.share-link-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
2199
packages/frontend/src/pages/SearchResult.vue
Executable file
2199
packages/frontend/src/pages/SearchResult.vue
Executable file
File diff suppressed because it is too large
Load Diff
1071
packages/frontend/src/pages/admin/AdminDashboard.vue
Normal file
1071
packages/frontend/src/pages/admin/AdminDashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
142
packages/frontend/src/pages/admin/AdminLayout.vue
Normal file
142
packages/frontend/src/pages/admin/AdminLayout.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="admin-menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<div class="menu-header">
|
||||
<h2>{{ siteName || 'CloudSearch' }}</h2>
|
||||
<p>管理后台</p>
|
||||
</div>
|
||||
<el-menu-item index="dashboard">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="cloud-configs">
|
||||
<template #title>
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>网盘配置</span>
|
||||
</template>
|
||||
<el-menu-item index="cloud-configs-toggle">网盘设置及授权</el-menu-item>
|
||||
<el-menu-item index="cloud-configs-cleanup">存储清理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="system">
|
||||
<template #title>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统配置</span>
|
||||
</template>
|
||||
<el-menu-item index="sys-site">网站设置</el-menu-item>
|
||||
<el-menu-item index="sys-services">外部服务和缓存</el-menu-item>
|
||||
<el-menu-item index="sys-strategy">性能配置</el-menu-item>
|
||||
<el-menu-item index="sys-password">修改管理员密码</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="save-records">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span>转存日志</span>
|
||||
</el-menu-item>
|
||||
<div class="version-footer">T {{ appVersion }}</div>
|
||||
<el-menu-item index="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
|
||||
<div class="admin-content">
|
||||
<div class="content-header">
|
||||
<h2>{{ pageTitle }}</h2>
|
||||
<el-button text @click="goBackHome">返回前台</el-button>
|
||||
</div>
|
||||
<div class="content-body">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { DataBoard, Connection, Setting, SwitchButton, DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { getSiteConfig } from '../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const siteName = ref('')
|
||||
const appVersion = ref('')
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
dashboard: '仪表盘',
|
||||
'cloud-configs-toggle': '网盘设置及授权',
|
||||
'cloud-configs-cleanup': '存储清理',
|
||||
'sys-site': '网站设置',
|
||||
'sys-services': '外部服务 & 缓存',
|
||||
'sys-strategy': '性能配置',
|
||||
'sys-password': '修改管理员密码',
|
||||
'save-records': '转存日志',
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const name = route.name as string
|
||||
if (name === 'admin-cloud-configs') return 'cloud-configs-toggle'
|
||||
if (name === 'admin-cleanup') return 'cloud-configs-cleanup'
|
||||
if (name === 'admin-system') {
|
||||
const sec = route.query.section as string
|
||||
return sec || 'sys-site'
|
||||
}
|
||||
if (name === 'admin-save-records') return 'save-records'
|
||||
return 'dashboard'
|
||||
})
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
return pageTitles[activeMenu.value] || '仪表盘'
|
||||
})
|
||||
|
||||
function handleMenuSelect(index: string) {
|
||||
if (index === 'dashboard') {
|
||||
router.push('/admin/dashboard')
|
||||
} else if (index === 'cloud-configs-toggle') {
|
||||
router.push('/admin/cloud-configs')
|
||||
} else if (index === 'cloud-configs-cleanup') {
|
||||
router.push('/admin/cleanup')
|
||||
} else if (index.startsWith('sys-')) {
|
||||
router.push({ path: '/admin/system', query: { section: index } })
|
||||
} else if (index === 'save-records') {
|
||||
router.push('/admin/save-records')
|
||||
} else if (index === 'logout') {
|
||||
localStorage.removeItem('admin_token')
|
||||
router.push('/admin/login')
|
||||
}
|
||||
}
|
||||
|
||||
function goBackHome() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const cfg = await getSiteConfig()
|
||||
siteName.value = cfg.site_name || ''
|
||||
} catch {}
|
||||
try {
|
||||
const h = await fetch('/health')
|
||||
const hv = await h.json()
|
||||
appVersion.value = hv.version
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-menu .menu-header { padding: 16px 20px 8px; text-align: center; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.admin-menu .menu-header h2 { margin: 0; font-size: 16px; color: var(--el-color-primary); }
|
||||
.admin-menu .menu-header p { margin: 4px 0 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.version-footer { padding: 8px; text-align: center; font-size: 11px; color: var(--el-text-color-placeholder); border-top: 1px solid var(--el-border-color-light); margin-top: auto; }
|
||||
.admin-layout { display: flex; height: 100vh; }
|
||||
.admin-menu { width: 220px; flex-shrink: 0; display: flex; flex-direction: column; }
|
||||
.admin-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.content-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; border-bottom: 1px solid var(--el-border-color-light); background: var(--el-bg-color); }
|
||||
.content-header h2 { margin: 0; font-size: 18px; }
|
||||
.content-body { flex: 1; overflow-y: auto; padding: 20px 24px; background: var(--el-bg-color-page); }
|
||||
</style>
|
||||
100
packages/frontend/src/pages/admin/AdminLogin.vue
Executable file
100
packages/frontend/src/pages/admin/AdminLogin.vue
Executable file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="admin-login-page">
|
||||
<div class="login-card">
|
||||
<h1 class="login-title">{{ siteName || 'CloudSearch' }} 管理后台</h1>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="0" size="large" @keyup.enter="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" class="login-btn" @click="handleLogin">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSiteConfig, adminLogin } from '../../api'
|
||||
import type { ElForm } from 'element-plus'
|
||||
|
||||
const formRef = ref<InstanceType<typeof ElForm>>()
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const siteName = ref('')
|
||||
|
||||
// 获取网站名称
|
||||
getSiteConfig().then(cfg => {
|
||||
if (cfg.site_name) siteName.value = cfg.site_name
|
||||
}).catch(() => {})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const res = await adminLogin(form.username, form.password)
|
||||
localStorage.setItem('admin_token', res.token)
|
||||
ElMessage.success('登录成功')
|
||||
window.location.href = '/admin'
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e?.response?.data?.message || e?.message || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: var(--bg-white);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.error-msg {
|
||||
text-align: center;
|
||||
color: #f56c6c;
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
194
packages/frontend/src/pages/admin/Cleanup.vue
Normal file
194
packages/frontend/src/pages/admin/Cleanup.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="cleanup-section">
|
||||
<el-card class="config-card">
|
||||
<template #header><span>🧹 存储清理</span></template>
|
||||
<el-form label-width="160px" label-position="left" size="small">
|
||||
<el-form-item label="启用自动清理">
|
||||
<el-switch v-model="cleanupEnabled" active-text="启用" inactive-text="关闭" />
|
||||
<div class="form-tip" style="margin-left: 8px;">
|
||||
每天自动检查一次,将过期文件移入回收站、删除旧日志、清空回收站释放空间
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="云盘文件保留天数">
|
||||
<el-input-number v-model="cleanupFileRetentionDays" :min="1" :max="365" style="width: 140px" />
|
||||
<div class="form-tip" style="margin-left: 8px;">超过此天数的日期文件夹将被移入回收站</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="转存日志保留天数">
|
||||
<el-input-number v-model="cleanupLogRetentionDays" :min="1" :max="365" style="width: 140px" />
|
||||
<div class="form-tip" style="margin-left: 8px;">超过此天数的转存记录将被删除</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="清空回收站">
|
||||
<el-switch v-model="cleanupEmptyTrash" active-text="启用" inactive-text="关闭" />
|
||||
<div class="form-tip" style="margin-left: 8px;">移入回收站后自动清空,永久删除文件以释放存储空间</div>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">空间阈值自动清理</el-divider>
|
||||
<el-form-item label="启用空间阈值清理">
|
||||
<el-switch v-model="cleanupSpaceThresholdEnabled" active-text="启用" inactive-text="关闭" />
|
||||
<div class="form-tip" style="margin-left: 8px;">已用空间超过阈值时,按比例删除最旧的转存文件(优先级高于保留天数)</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="cleanupSpaceThresholdEnabled" label="使用阈值">
|
||||
<el-slider v-model="cleanupSpaceThresholdPercent" :min="50" :max="99" style="width: 200px" show-input />
|
||||
<div class="form-tip" style="margin-left: 8px;">已用空间超过此百分比时触发强制清理</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="cleanupSpaceThresholdEnabled" label="删除比例">
|
||||
<el-slider v-model="cleanupSpaceThresholdDeletePercent" :min="5" :max="50" :step="5" style="width: 200px" show-input />
|
||||
<div class="form-tip" style="margin-left: 8px;">触发清理时释放总空间的百分比(如 10% 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)</div>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">分享链接复用</el-divider>
|
||||
<el-form-item label="复用已有分享链接">
|
||||
<el-switch v-model="saveReuseEnabled" active-text="启用" inactive-text="关闭" />
|
||||
<div class="form-tip" style="margin-left: 8px;">相同原始链接不再重复转存,复用已有分享链接(会验证原链接有效性;60秒内重复请求直接返回已有链接)</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-divider content-position="left">手动操作</el-divider>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<el-button type="primary" size="small" :loading="cleanupSaving" @click="handleSaveCleanupConfigs">💾 保存清理配置</el-button>
|
||||
<el-button type="danger" size="small" :loading="cleanupRunning" @click="handleRunCleanup">{{ cleanupRunning ? '清理中...' : '🗑️ 立即清理' }}</el-button>
|
||||
<el-button type="warning" size="small" :loading="emptyTrashRunning" @click="handleEmptyTrash">{{ emptyTrashRunning ? '清空中...' : '🧹 立即清空回收站' }}</el-button>
|
||||
</div>
|
||||
<div v-if="lastCleanupTime" class="cleanup-info" style="margin-top: 10px;">
|
||||
<span>⏰ 上次清理:{{ lastCleanupTime }}</span>
|
||||
<span v-if="lastCleanupStats" style="margin-left: 16px;">📊 {{ lastCleanupStats }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSystemConfigs, updateSystemConfigs, runCleanup, emptyAllTrash } from '../../api'
|
||||
|
||||
interface ConfigMap {
|
||||
[key: string]: string | number
|
||||
}
|
||||
const sysConfigs = reactive<ConfigMap>({})
|
||||
|
||||
const cleanupRunning = ref(false)
|
||||
const emptyTrashRunning = ref(false)
|
||||
const cleanupSaving = ref(false)
|
||||
|
||||
const lastCleanupTime = computed(() => String(sysConfigs.cleanup_last_run || ''))
|
||||
const lastCleanupStats = computed(() => {
|
||||
const raw = String(sysConfigs.cleanup_last_stats || '')
|
||||
if (!raw) return ''
|
||||
try {
|
||||
const s = JSON.parse(raw)
|
||||
const parts = []
|
||||
if (s.filesTrashed > 0) parts.push(`移入回收站 ${s.filesTrashed} 个文件夹`)
|
||||
if (s.logsDeleted > 0) parts.push(`删除 ${s.logsDeleted} 条日志`)
|
||||
if (s.trashEmptied) parts.push(`已清空回收站`)
|
||||
if (s.errors > 0) parts.push(`⚠️ ${s.errors} 个错误`)
|
||||
return parts.join(' / ') || '无操作'
|
||||
} catch { return '' }
|
||||
})
|
||||
|
||||
const cleanupEnabled = computed({
|
||||
get: () => String(sysConfigs.cleanup_enabled) === 'true',
|
||||
set: (val: boolean) => { sysConfigs.cleanup_enabled = val ? 'true' : 'false' },
|
||||
})
|
||||
const cleanupEmptyTrash = computed({
|
||||
get: () => String(sysConfigs.cleanup_empty_trash) !== 'false',
|
||||
set: (val: boolean) => { sysConfigs.cleanup_empty_trash = val ? 'true' : 'false' },
|
||||
})
|
||||
const cleanupFileRetentionDays = computed({
|
||||
get: () => Number(sysConfigs.cleanup_file_retention_days ?? 7),
|
||||
set: (val: number) => { sysConfigs.cleanup_file_retention_days = val },
|
||||
})
|
||||
const cleanupLogRetentionDays = computed({
|
||||
get: () => Number(sysConfigs.cleanup_log_retention_days ?? 30),
|
||||
set: (val: number) => { sysConfigs.cleanup_log_retention_days = val },
|
||||
})
|
||||
const cleanupSpaceThresholdEnabled = computed({
|
||||
get: () => String(sysConfigs.cleanup_space_threshold_enabled) === 'true',
|
||||
set: (val: boolean) => { sysConfigs.cleanup_space_threshold_enabled = val ? 'true' : 'false' },
|
||||
})
|
||||
const cleanupSpaceThresholdPercent = computed({
|
||||
get: () => Number(sysConfigs.cleanup_space_threshold_percent ?? 90),
|
||||
set: (val: number) => { sysConfigs.cleanup_space_threshold_percent = val },
|
||||
})
|
||||
const cleanupSpaceThresholdDeletePercent = computed({
|
||||
get: () => Number(sysConfigs.cleanup_space_threshold_delete_percent ?? 10),
|
||||
set: (val: number) => { sysConfigs.cleanup_space_threshold_delete_percent = val },
|
||||
})
|
||||
const saveReuseEnabled = computed({
|
||||
get: () => String(sysConfigs.save_reuse_enabled) !== 'false',
|
||||
set: (val: boolean) => { sysConfigs.save_reuse_enabled = val ? 'true' : 'false' },
|
||||
})
|
||||
|
||||
async function loadCleanupConfigs() {
|
||||
try {
|
||||
const raw = await getSystemConfigs()
|
||||
for (const cfg of raw) {
|
||||
sysConfigs[cfg.key] = cfg.value
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载清理配置失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCleanupConfigs() {
|
||||
cleanupSaving.value = true
|
||||
try {
|
||||
const keys = ['cleanup_enabled', 'cleanup_file_retention_days', 'cleanup_log_retention_days', 'cleanup_empty_trash', 'cleanup_space_threshold_enabled', 'cleanup_space_threshold_percent', 'cleanup_space_threshold_delete_percent', 'save_reuse_enabled']
|
||||
const entries = keys.map(key => ({ key, value: String(sysConfigs[key] ?? '') }))
|
||||
await updateSystemConfigs(entries)
|
||||
ElMessage.success('清理配置已保存')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
} finally {
|
||||
cleanupSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunCleanup() {
|
||||
cleanupRunning.value = true
|
||||
try {
|
||||
const result = await runCleanup()
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
} else {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
await loadCleanupConfigs()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '清理失败')
|
||||
} finally {
|
||||
cleanupRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmptyTrash() {
|
||||
emptyTrashRunning.value = true
|
||||
try {
|
||||
const result = await emptyAllTrash()
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
} else {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '清空回收站失败')
|
||||
} finally {
|
||||
emptyTrashRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCleanupConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cleanup-section .config-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.cleanup-info {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
701
packages/frontend/src/pages/admin/CloudConfig.vue
Executable file
701
packages/frontend/src/pages/admin/CloudConfig.vue
Executable file
@@ -0,0 +1,701 @@
|
||||
<template>
|
||||
<div class="cloud-config">
|
||||
<!-- 网盘类型开关 -->
|
||||
<el-card class="toggle-card" style="margin-bottom: 20px;">
|
||||
<template #header><span>📂 网盘设置及授权</span></template>
|
||||
<div class="cloud-toggle-grid">
|
||||
<div
|
||||
v-for="ct in cloudTypes"
|
||||
:key="ct.type"
|
||||
class="cloud-toggle-chip"
|
||||
>
|
||||
<img :src="ct.icon" class="cloud-icon-img" />
|
||||
<span class="cloud-label">{{ ct.label }}</span>
|
||||
<el-tag v-if="ct.type === 'others'" size="small" type="info">关</el-tag>
|
||||
<el-switch
|
||||
:model-value="ct.enabled"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleCloudToggle(ct.type, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-tip" style="margin-top: 12px;">
|
||||
关闭的网盘类型在搜索结果中不会展示。修改后立即生效,无需点击保存。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openDialog(null)">新增配置</el-button>
|
||||
<el-button @click="verifyAll">全部重新验证</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="configs" stripe style="width: 100%">
|
||||
<el-table-column label="网盘类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<CloudBadge :cloud_type="row.cloud_type" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.nickname" class="nickname-text">{{ row.nickname }}</span>
|
||||
<el-text v-else type="info" size="small">未设置</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="promotion_account" label="推广账号" width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.promotion_account" class="promotion-text">{{ row.promotion_account }}</span>
|
||||
<el-text v-else type="info" size="small">-</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cloud_type_uid" label="标识(__uid)" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.cloud_type_uid" class="uid-cell">{{ row.cloud_type_uid }}</span>
|
||||
<el-text v-else type="info" size="small">-</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="验证" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row._verifying" class="verifying">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
</span>
|
||||
<el-tag v-else-if="row.verification_status === 'valid'" type="success" size="small">有效</el-tag>
|
||||
<el-tag v-else-if="row.verification_status === 'invalid'" type="danger" size="small">无效</el-tag>
|
||||
<el-tag v-else type="info" size="small">未验证</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="空间" width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.storage_total && row.storage_total !== '-'" class="storage-cell">
|
||||
<div class="storage-bar-wrap">
|
||||
<div
|
||||
class="storage-bar-fill"
|
||||
:style="{ width: storagePercent(row) + '%' }"
|
||||
:class="storageBarClass(row)"
|
||||
></div>
|
||||
</div>
|
||||
<div class="storage-text">
|
||||
<span class="storage-used">{{ row.storage_used || '计算中...' }}</span>
|
||||
<span class="storage-sep">/</span>
|
||||
<span class="storage-total">{{ row.storage_total }}</span>
|
||||
<span class="storage-free">(可用 {{ storageFree(row) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-text v-else type="info" size="small">—</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 转存统计 -->
|
||||
<el-table-column label="转存数" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.total_saves > 0" class="save-count">{{ row.total_saves }}次</span>
|
||||
<el-text v-else type="info" size="small">-</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="转存启用" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.is_transfer_enabled !== 0"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleToggleTransfer(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="390" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button text type="primary" @click="verifyOne(row)">验证</el-button>
|
||||
<el-popconfirm title="确定删除该配置?" @confirm="handleDelete(row)">
|
||||
<template #reference>
|
||||
<el-button text type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑配置' : '新增配置'" width="560px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="网盘类型" prop="cloud_type">
|
||||
<el-select v-model="form.cloud_type" style="width: 100%" :disabled="!!editingId" @change="onCloudTypeChange">
|
||||
<el-option
|
||||
v-for="[key, label] in cloudTypeOptions"
|
||||
:key="key"
|
||||
:label="label"
|
||||
:value="key"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="推广平台及账号" prop="promotion_account" style="margin-bottom: 18px;">
|
||||
<el-input
|
||||
v-model="form.promotion_account"
|
||||
placeholder="请填写您的推广平台及账号,例:蜂小推-13288889999"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Cookie" prop="cookie">
|
||||
<el-input
|
||||
v-model="form.cookie"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||
:placeholder="cookiePlaceholder"
|
||||
input-style="font-family: monospace; font-size: 12px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<el-button type="primary" :loading="form._verifying" @click="verifyAndFillNickname" style="width: 100%">
|
||||
{{ form._verifying ? '验证中...' : '🔍 自动获取(验证 Cookie 并回填信息)' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<!-- Cookie 获取教程(根据网盘类型切换) -->
|
||||
<el-form-item label=" " v-if="form.cloud_type && form.cloud_type !== ''" class="cookie-tips-item">
|
||||
<div class="cookie-tips" :class="`cookie-tips-${form.cloud_type}`">
|
||||
<div class="cookie-tips-header">
|
||||
<span class="cookie-tips-title">📖 {{ cloudTypeLabel }} Cookie 获取教程</span>
|
||||
</div>
|
||||
<ol class="cookie-tips-steps" v-html="cookieTutorialHtml"></ol>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { CLOUD_LABELS } from '../../types'
|
||||
import type { CloudType, CloudConfig } from '../../types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType } from '../../api'
|
||||
import CloudBadge from '../../components/CloudBadge.vue'
|
||||
import type { ElForm } from 'element-plus'
|
||||
|
||||
interface CloudTypeInfo { type: string; label: string; icon: string; enabled: boolean }
|
||||
const cloudTypes = ref<CloudTypeInfo[]>([])
|
||||
|
||||
const formRef = ref<InstanceType<typeof ElForm>>()
|
||||
const configs = ref<(CloudConfig & { _verifying?: boolean })[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
const defaultForm = () => ({
|
||||
cloud_type: '' as CloudType | '',
|
||||
nickname: '',
|
||||
promotion_account: '',
|
||||
is_transfer_enabled: false,
|
||||
cookie: '',
|
||||
_verifying: false,
|
||||
_storageUsed: '',
|
||||
_storageTotal: '',
|
||||
})
|
||||
|
||||
const form = reactive<{
|
||||
cloud_type: CloudType | ''
|
||||
nickname: string
|
||||
promotion_account: string
|
||||
is_transfer_enabled: boolean
|
||||
cookie: string
|
||||
_verifying: boolean
|
||||
_storageUsed: string
|
||||
_storageTotal: string
|
||||
}>(defaultForm())
|
||||
|
||||
const rules = computed(() => ({
|
||||
cloud_type: [{ required: true, message: '请选择网盘类型', trigger: 'change' }],
|
||||
nickname: [{ required: false, message: '请填写昵称(区分多个同类型网盘)', trigger: 'blur' }],
|
||||
promotion_account: [{ required: true, message: '请填写推广平台及账号', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
const cloudTypeOptions = computed(() => {
|
||||
return Object.entries(CLOUD_LABELS) as [CloudType, string][]
|
||||
})
|
||||
|
||||
const cookiePlaceholder = computed(() => {
|
||||
if (!form.cloud_type) return '请先选择网盘类型'
|
||||
const t = form.cloud_type
|
||||
if (t === 'quark' || t === 'baidu') return `请输入 ${CLOUD_LABELS[t] || t} 的完整 Cookie`
|
||||
return editingId.value ? '留空则保持原有' : '输入完整 Cookie'
|
||||
})
|
||||
|
||||
const cloudTypeLabel = computed(() => {
|
||||
return CLOUD_LABELS[form.cloud_type as CloudType] || form.cloud_type || ''
|
||||
})
|
||||
|
||||
/** Cookie 获取教程 HTML(根据不同网盘类型) */
|
||||
const cookieTutorialHtml = computed(() => {
|
||||
const t = form.cloud_type
|
||||
if (!t) return ''
|
||||
const tutorials: Record<string, string> = {
|
||||
quark: `<li>在电脑上打开 <a href="https://pan.quark.cn" target="_blank">pan.quark.cn</a> 并登录你的夸克账号</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
|
||||
<li>刷新页面,在请求列表中点击任意一个请求(如 <code>account/info</code>)</li>
|
||||
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
|
||||
<li>复制整个 Cookie 值(<b>从开头到结束的完整内容</b>),粘贴到上方输入框</li>
|
||||
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
|
||||
<div class="cookie-tips-note">⚠️ 必须包含 <code>__st=s%...</code> 字段!请复制浏览器请求头的 <b>整个 Cookie</b>(F12 → Network → 请求头 → Cookie 项),不要只复制部分。</div>`,
|
||||
|
||||
baidu: `<li>在电脑上打开 <a href="https://pan.baidu.com" target="_blank">pan.baidu.com</a> 并登录你的百度账号</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
|
||||
<li>刷新页面,在请求列表中点击任意一个请求</li>
|
||||
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
|
||||
<li>复制整个 Cookie 值,粘贴到上方输入框</li>
|
||||
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
|
||||
<div class="cookie-tips-note">💡 需要包含 <code>BDUSS</code> 和 <code>STOKEN</code></div>`,
|
||||
|
||||
aliyun: `<li>在电脑上打开 <a href="https://www.aliyundrive.com" target="_blank">aliyundrive.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||
<div class="cookie-tips-note">💡 需包含 <code>token</code> 等有效字段</div>`,
|
||||
|
||||
'115': `<li>在电脑上打开 <a href="https://115.com" target="_blank">115.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||
<div class="cookie-tips-note">💡 需包含 <code>UID</code>、<code>CID</code>、<code>SEID</code> 等字段</div>`,
|
||||
|
||||
tianyi: `<li>在电脑上打开 <a href="https://cloud.189.cn" target="_blank">cloud.189.cn</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||
<div class="cookie-tips-note">💡 需包含 <code>COOKIE_LOGIN_USER</code>、<code>SESSION</code> 等字段</div>`,
|
||||
|
||||
'123pan': `<li>在电脑上打开 <a href="https://www.123pan.com" target="_blank">123pan.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||
|
||||
uc: `<li>在电脑上打开 <a href="https://drive.uc.cn" target="_blank">drive.uc.cn</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||
|
||||
xunlei: `<li>在电脑上打开 <a href="https://pan.xunlei.com" target="_blank">pan.xunlei.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||
|
||||
pikpak: `<li>在电脑上打开 <a href="https://www.mypikpak.com" target="_blank">mypikpak.com</a> 并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||
}
|
||||
return tutorials[t] || `<li>在电脑上打开该网盘网站并登录</li>
|
||||
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||
<li>刷新页面,复制任意请求的 <code>Cookie</code></li>
|
||||
<li>粘贴到上方输入框,点击「自动获取」验证</li>`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfigs()
|
||||
await loadCloudTypes()
|
||||
})
|
||||
|
||||
// 每30分钟自动验证一次
|
||||
let verifyTimer: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
verifyTimer = setInterval(() => {
|
||||
autoVerifyAll()
|
||||
}, 30 * 60 * 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (verifyTimer) clearInterval(verifyTimer)
|
||||
})
|
||||
|
||||
async function loadCloudTypes() {
|
||||
try {
|
||||
const result = await getCloudTypes()
|
||||
cloudTypes.value = result.types
|
||||
} catch (e) { console.error('加载网盘类型失败', e) }
|
||||
}
|
||||
|
||||
async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
const ct = cloudTypes.value.find(c => c.type === type)
|
||||
if (!ct) return
|
||||
try {
|
||||
await toggleCloudType(type, enabled)
|
||||
ct.enabled = enabled
|
||||
} catch (e: any) { ElMessage.error(e.message || '切换失败'); ct.enabled = !enabled }
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
configs.value = await getCloudConfigs()
|
||||
} catch (e) {
|
||||
console.error('加载网盘配置失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
|
||||
const newVal = enabled ? 1 : 0
|
||||
try {
|
||||
await updateCloudConfig({
|
||||
id: row.id!,
|
||||
cloud_type: row.cloud_type,
|
||||
nickname: row.nickname || '',
|
||||
promotion_account: row.promotion_account || '',
|
||||
is_transfer_enabled: newVal,
|
||||
is_active: row.is_active !== 0,
|
||||
cookie: undefined, // don't send cookie on toggle-only
|
||||
})
|
||||
row.is_transfer_enabled = newVal
|
||||
ElMessage.success(enabled ? '转存已开启' : '转存已关闭')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function autoVerifyAll() {
|
||||
for (const cfg of configs.value) {
|
||||
if (cfg.cookie_preview || cfg.nickname) {
|
||||
await verifyOne(cfg, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAll() {
|
||||
for (const cfg of configs.value) {
|
||||
if ((cfg.cookie_preview || cfg.nickname) && !cfg._verifying) {
|
||||
await verifyOne(cfg, false)
|
||||
}
|
||||
}
|
||||
ElMessage.success('全部验证完成')
|
||||
}
|
||||
|
||||
async function verifyOne(row: CloudConfig & { _verifying?: boolean }, silent = false) {
|
||||
if (!row.cookie_preview && !row.nickname) {
|
||||
if (!silent) ElMessage.warning('该配置没有 Cookie,请先编辑保存后再验证')
|
||||
return
|
||||
}
|
||||
row._verifying = true
|
||||
try {
|
||||
const result = await testCloudConnection(row.cloud_type, undefined, row.id)
|
||||
row.verification_status = result.success ? 'valid' : 'invalid'
|
||||
if (result.success) {
|
||||
if (result.nickname && !row.nickname) row.nickname = result.nickname
|
||||
if (result.storage_used) row.storage_used = result.storage_used
|
||||
if (result.storage_total) row.storage_total = result.storage_total
|
||||
if (!silent) ElMessage.success(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
|
||||
} else {
|
||||
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
row.verification_status = 'invalid'
|
||||
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:验证失败`)
|
||||
} finally {
|
||||
row._verifying = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAndFillNickname() {
|
||||
if (!form.cookie) {
|
||||
ElMessage.warning('请先输入 Cookie')
|
||||
return
|
||||
}
|
||||
if (!form.cloud_type) {
|
||||
ElMessage.warning('请先选择网盘类型')
|
||||
return
|
||||
}
|
||||
form._verifying = true
|
||||
try {
|
||||
const result = await testCloudConnection(form.cloud_type as CloudType, form.cookie)
|
||||
if (result.success) {
|
||||
if (result.nickname) form.nickname = result.nickname
|
||||
if (result.storage_used) form._storageUsed = result.storage_used
|
||||
if (result.storage_total) form._storageTotal = result.storage_total
|
||||
ElMessage.success(`昵称:${result.nickname || '获取成功'}`)
|
||||
} else {
|
||||
ElMessage.warning(result.message || '验证失败,请检查 Cookie')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '验证失败,请检查 Cookie')
|
||||
} finally {
|
||||
form._verifying = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row: CloudConfig | null) {
|
||||
if (row) {
|
||||
editingId.value = row.id ?? null
|
||||
form.cloud_type = row.cloud_type
|
||||
form.nickname = row.nickname || ''
|
||||
form.promotion_account = row.promotion_account || ''
|
||||
form.is_transfer_enabled = row.is_transfer_enabled !== 0
|
||||
form.cookie = row.cookie || ''
|
||||
form._verifying = false
|
||||
form._storageUsed = ''
|
||||
form._storageTotal = ''
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.cloud_type = '' as CloudType | ''
|
||||
form.nickname = ''
|
||||
form.promotion_account = ''
|
||||
form.is_transfer_enabled = false
|
||||
form.cookie = ''
|
||||
form._verifying = false
|
||||
form._storageUsed = ''
|
||||
form._storageTotal = ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onCloudTypeChange() {
|
||||
// Cookie 输入框提示会自动更新(computed)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// 1. 表单校验(含推广账号必填)
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 2. 如果有 Cookie,先验证 Cookie
|
||||
if (form.cookie) {
|
||||
try {
|
||||
const verifyResult = await testCloudConnection(form.cloud_type as CloudType, form.cookie)
|
||||
if (!verifyResult.success) {
|
||||
ElMessage.error(`Cookie验证失败:${verifyResult.message}`)
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
// 保存验证结果
|
||||
if (verifyResult.nickname && !form.nickname) form.nickname = verifyResult.nickname
|
||||
if (verifyResult.storage_used) form._storageUsed = verifyResult.storage_used
|
||||
if (verifyResult.storage_total) form._storageTotal = verifyResult.storage_total
|
||||
} catch (e: any) {
|
||||
ElMessage.error(`Cookie验证失败:${e.response?.data?.error || '网络错误'}`)
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 保存配置
|
||||
if (editingId.value) {
|
||||
await updateCloudConfig({
|
||||
id: editingId.value,
|
||||
cloud_type: form.cloud_type as CloudType,
|
||||
nickname: form.nickname,
|
||||
promotion_account: form.promotion_account,
|
||||
is_transfer_enabled: form.is_transfer_enabled,
|
||||
cookie: form.cookie || undefined,
|
||||
is_active: true,
|
||||
storage_used: form._storageUsed || undefined,
|
||||
storage_total: form._storageTotal || undefined,
|
||||
})
|
||||
ElMessage.success('配置更新成功')
|
||||
} else {
|
||||
const saved = await saveCloudConfig({
|
||||
cloud_type: form.cloud_type as CloudType,
|
||||
nickname: form.nickname,
|
||||
promotion_account: form.promotion_account,
|
||||
is_transfer_enabled: form.is_transfer_enabled,
|
||||
cookie: form.cookie,
|
||||
is_active: true,
|
||||
storage_used: form._storageUsed || undefined,
|
||||
storage_total: form._storageTotal || undefined,
|
||||
})
|
||||
ElMessage.success('配置保存成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
editingId.value = null
|
||||
await loadConfigs()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: CloudConfig) {
|
||||
try {
|
||||
await deleteCloudConfig(row.id!)
|
||||
ElMessage.success('删除成功')
|
||||
await loadConfigs()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析字节数 → 数值 */
|
||||
function parseBytes(s: string): number {
|
||||
const m = s.match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i)
|
||||
if (!m) return 0
|
||||
const n = parseFloat(m[1])
|
||||
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 }
|
||||
return n * (units[m[2].toUpperCase()] || 1)
|
||||
}
|
||||
|
||||
function storagePercent(row: CloudConfig): number {
|
||||
if (!row.storage_total || row.storage_total === '-' || !row.storage_used) return 0
|
||||
const total = parseBytes(row.storage_total)
|
||||
const used = parseBytes(row.storage_used)
|
||||
if (total === 0) return 0
|
||||
return Math.min(100, Math.round((used / total) * 100))
|
||||
}
|
||||
|
||||
function storageBarClass(row: CloudConfig): string {
|
||||
const pct = storagePercent(row)
|
||||
if (pct >= 90) return 'bar-danger'
|
||||
if (pct >= 70) return 'bar-warning'
|
||||
return 'bar-normal'
|
||||
}
|
||||
|
||||
function storageFree(row: CloudConfig): string {
|
||||
if (!row.storage_total || row.storage_total === '-') return '?'
|
||||
if (!row.storage_used) return '计算中...'
|
||||
const total = parseBytes(row.storage_total)
|
||||
const used = parseBytes(row.storage_used)
|
||||
if (total === 0) return '?'
|
||||
const free = total - used
|
||||
if (free < 1024) return '小于 1 KB'
|
||||
if (free < 1024 * 1024) return (free / 1024).toFixed(1) + ' KB'
|
||||
if (free < 1024 * 1024 * 1024) return (free / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
if (free < 1024 * 1024 * 1024 * 1024) return (free / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||
return (free / (1024 * 1024 * 1024 * 1024)).toFixed(1) + ' TB'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cloud-config {
|
||||
background: var(--bg-white);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 24px;
|
||||
}
|
||||
.cloud-toggle-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.cloud-toggle-chip { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--el-border-color-light); border-radius: 8px; background: var(--el-bg-color); }
|
||||
.cloud-toggle-chip:hover { border-color: var(--el-color-primary-light-5); }
|
||||
.cloud-icon-img { width: 20px; height: 20px; object-fit: contain; }
|
||||
.cloud-label { font-size: 13px; font-weight: 500; }
|
||||
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sign-summary-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.nickname-text {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.promotion-text {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
.uid-cell {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
/* 空间进度条 */
|
||||
.storage-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.storage-bar-wrap {
|
||||
height: 4px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.storage-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.storage-bar-fill.bar-normal { background: #67c23a; }
|
||||
.storage-bar-fill.bar-warning { background: #e6a23c; }
|
||||
.storage-bar-fill.bar-danger { background: #f56c6c; }
|
||||
.storage-text {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.storage-used { color: #606266; font-weight: 600; }
|
||||
.storage-total { color: #303133; font-weight: 600; }
|
||||
.storage-free { color: #909399; }
|
||||
.save-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.verifying {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
:deep(.el-input-group__append) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.el-input-group__append .el-button) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Cookie 教程卡片 */
|
||||
.cookie-tips-item :deep(.el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.cookie-tips {
|
||||
background: #f8faff;
|
||||
border: 1px solid #e8f0fe;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cookie-tips-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cookie-tips-title {
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cookie-tips-steps {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.cookie-tips-steps li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cookie-tips-steps code {
|
||||
background: #ecf5ff;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
}
|
||||
.cookie-tips-note {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #fff3c4;
|
||||
border-radius: 4px;
|
||||
color: #8a6d3b;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cookie-tips-note code {
|
||||
background: #f5f0e0;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
774
packages/frontend/src/pages/admin/SaveRecords.vue
Executable file
774
packages/frontend/src/pages/admin/SaveRecords.vue
Executable file
@@ -0,0 +1,774 @@
|
||||
<template>
|
||||
<div class="save-records">
|
||||
<!-- ── Toolbar ── -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-row">
|
||||
<div class="filter-group">
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 100px" @change="loadRecords(1)">
|
||||
<el-option label="全部状态" value="" />
|
||||
<el-option label="✓ 成功" value="success" />
|
||||
<el-option label="♻️ 复用" value="reused" />
|
||||
<el-option label="✗ 失败" value="failed" />
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="cloudFilter" placeholder="网盘" clearable style="width: 100px" @change="loadRecords(1)">
|
||||
<el-option label="全部网盘" value="" />
|
||||
<el-option v-for="ct in cloudTypes" :key="ct" :label="cloudLabel(ct)" :value="ct">
|
||||
<span :style="{ display: 'inline-flex', alignItems: 'center', gap: '6px' }">
|
||||
<img :src="cloudIcon(ct)" style="width:16px;height:16px" />
|
||||
{{ cloudLabel(ct) }}
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<div class="time-btns">
|
||||
<button
|
||||
v-for="btn in timeButtons" :key="btn.key"
|
||||
:class="['time-btn', { active: activeTimeBtn === btn.key }]"
|
||||
@click="setTimeFilter(btn.key)"
|
||||
>{{ btn.label }}</button>
|
||||
</div>
|
||||
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 220px"
|
||||
@change="onDateRangeChange"
|
||||
/>
|
||||
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索资源名称…"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@clear="loadRecords(1)"
|
||||
@keyup.enter="loadRecords(1)"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<el-button size="small" @click="resetFilters">重置筛选</el-button>
|
||||
<span class="record-count">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 转存统计汇总 ── -->
|
||||
<div v-if="summary" class="save-summary">
|
||||
<span class="summary-item summary-all">📊 共 <strong>{{ summary.total }}</strong> 条</span>
|
||||
<span class="summary-divider">|</span>
|
||||
<span class="summary-item summary-success">✅ 成功 <strong>{{ summary.success }}</strong></span>
|
||||
<span class="summary-item summary-reused">♻️ 复用 <strong>{{ summary.reused }}</strong></span>
|
||||
<span class="summary-item summary-failed">❌ 失败 <strong>{{ summary.failed }}</strong></span>
|
||||
<span class="summary-item summary-rate" v-if="summary.total > 0">
|
||||
成功率 <strong>{{ ((summary.success + summary.reused) / summary.total * 100).toFixed(1) }}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Table ── -->
|
||||
<div class="el-table-wrap">
|
||||
<el-table
|
||||
:data="records" stripe style="width: 100%"
|
||||
v-loading="loading"
|
||||
empty-text="暂无转存记录"
|
||||
@expand-change="onExpandChange"
|
||||
:row-class-name="rowClassName"
|
||||
>
|
||||
<el-table-column type="expand" width="36">
|
||||
<template #default="{ row }">
|
||||
<div class="expand-detail">
|
||||
<!-- Row 1: 原始链接 + 文件夹数量 + 文件数量 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-cell">
|
||||
<span class="detail-label">原始链接</span>
|
||||
<a :href="row.source_url" target="_blank" class="detail-link">{{ row.source_url }}</a>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.original_folder_name">
|
||||
<span class="detail-label">原始文件夹名</span>
|
||||
<code class="detail-code">{{ row.original_folder_name }}</code>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
|
||||
<span class="detail-label">文件夹</span>
|
||||
<span><strong>{{ row.folder_count || 0 }}</strong> 个</span>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
|
||||
<span class="detail-label">文件</span>
|
||||
<span><strong>{{ row.file_count || 0 }}</strong> 个</span>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.status === 'reused'">
|
||||
<span class="detail-label">复用方式</span>
|
||||
<span class="reuse-msg">♻️ 直接使用已有分享链接,无需实际转存</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: 分享链接 + 分享密码 + 转存文件夹 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-cell" v-if="row.share_url">
|
||||
<span class="detail-label">分享链接</span>
|
||||
<a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.share_pwd">
|
||||
<span class="detail-label">分享密码</span>
|
||||
<el-tag size="small" type="warning">{{ row.share_pwd }}</el-tag>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.folder_name">
|
||||
<span class="detail-label">转存文件夹</span>
|
||||
<code class="detail-code">{{ row.folder_name }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: IP地址 + 归属地 -->
|
||||
<div class="detail-row" v-if="row.ip_address">
|
||||
<div class="detail-cell">
|
||||
<span class="detail-label">IP 地址</span>
|
||||
<code class="detail-code">{{ row.ip_address }}</code>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.ip_location">
|
||||
<span class="detail-label">归属地</span>
|
||||
<code class="detail-code">{{ formatLocation(row.ip_location) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 4: 错误信息(整行) -->
|
||||
<div class="detail-row" v-if="row.status === 'failed' && row.error_message">
|
||||
<div class="detail-cell detail-full">
|
||||
<span class="detail-label">错误信息</span>
|
||||
<pre class="detail-error">{{ row.error_message }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="序号" width="68" align="center">
|
||||
<template #default="{ $index }">
|
||||
{{ (currentPage - 1) * pageSize + $index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="时间" width="140">
|
||||
<template #default="{ row }">
|
||||
<span :title="row.created_at">{{ formatTime(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="网盘" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="cloudLabel(row.source_type)" placement="top">
|
||||
<img :src="cloudIcon(row.source_type)" style="width:22px;height:22px;cursor:default" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="statusTip(row.status)" placement="top">
|
||||
<span :class="['status-badge', statusClass(row.status)]">
|
||||
{{ statusIcon(row.status) }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="资源名称" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span :title="row.source_title || ''">{{ row.source_title || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="耗时" width="85" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['duration', durationClass(row.duration_ms)]">
|
||||
{{ formatDuration(row.duration_ms) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="归属地" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.ip_location" class="loc-badge">{{ formatLocation(row.ip_location) }}</span>
|
||||
<span v-else class="no-data">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="备注" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.status === 'failed' && row.error_message" class="err-msg" :title="row.error_message">
|
||||
{{ truncateErr(row.error_message) }}
|
||||
</span>
|
||||
<span v-else-if="row.status === 'failed'" class="err-msg">失败</span>
|
||||
<span v-else-if="row.status === 'reused'" class="reuse-msg">♻️ 复用已有链接</span>
|
||||
<span v-else class="no-data">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="80" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="action-cell">
|
||||
<el-tooltip content="复制分享链接" placement="top">
|
||||
<el-button size="small" circle text :disabled="!row.share_url" @click="copyText(row.share_url!)">
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="打开分享链接" placement="top">
|
||||
<el-button size="small" circle text :disabled="!row.share_url" @click="windowOpen(row.share_url!)">
|
||||
<el-icon><TopRight /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- ── Pagination ── -->
|
||||
<div class="pagination-wrap" v-if="total > 0">
|
||||
<div class="pagination-info">
|
||||
第 {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, total) }} 条,共 {{ total }} 条
|
||||
</div>
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[15, 20, 30, 50, 100]"
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
@current-change="loadRecords"
|
||||
@size-change="loadRecords(1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getSaveRecords, getCloudTypes } from '../../api'
|
||||
import type { SaveRecord } from '../../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, CopyDocument, Link, TopRight } from '@element-plus/icons-vue'
|
||||
|
||||
const records = ref<SaveRecord[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref('')
|
||||
const cloudFilter = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const activeTimeBtn = ref('today')
|
||||
const timeStart = ref('')
|
||||
const timeEnd = ref('')
|
||||
const dateRange = ref<string[] | null>(null)
|
||||
const cloudTypes = ref<string[]>([])
|
||||
const summary = ref<{ total: number; success: number; failed: number; reused: number } | null>(null)
|
||||
|
||||
const timeButtons = [
|
||||
{ key: 'today', label: '今日' },
|
||||
{ key: 'week', label: '本周' },
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'lastMonth', label: '上月' },
|
||||
]
|
||||
|
||||
// ── Cloud type helpers — loaded from backend API ──
|
||||
const cloudTypeMap = ref<Record<string, { label: string; icon: string }>>({})
|
||||
const FALLBACK_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><rect rx="4" width="24" height="24" fill="#909399"/><text x="12" y="16" text-anchor="middle" fill="white" font-size="14" font-weight="bold" font-family="Arial">☁</text></svg>'
|
||||
|
||||
async function loadCloudTypes() {
|
||||
try {
|
||||
const res = await getCloudTypes()
|
||||
const map: Record<string, { label: string; icon: string }> = {}
|
||||
for (const ct of res.types) {
|
||||
map[ct.type] = { label: ct.label, icon: ct.icon }
|
||||
}
|
||||
cloudTypeMap.value = map
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
function cloudLabel(t: string): string { return cloudTypeMap.value[t]?.label || t }
|
||||
function cloudIcon(t: string): string { return cloudTypeMap.value[t]?.icon || FALLBACK_ICON_SVG }
|
||||
|
||||
function extractCloudTypes(data: SaveRecord[]) {
|
||||
const set = new Set<string>()
|
||||
data.forEach(r => { if (r.source_type) set.add(r.source_type) })
|
||||
const existing = new Set(cloudTypes.value)
|
||||
set.forEach(t => { if (!existing.has(t)) cloudTypes.value.push(t) })
|
||||
}
|
||||
|
||||
// ── Formatting helpers ──
|
||||
function fmt(d: Date): string {
|
||||
const y = d.getFullYear()
|
||||
const M = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const D = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${M}-${D}`
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
if (!t) return '-'
|
||||
let ts = t
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)) ts = ts.replace(' ', 'T') + '+08:00'
|
||||
const d = new Date(ts)
|
||||
if (isNaN(d.getTime())) return t
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!ms) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function durationClass(ms: number): string {
|
||||
if (!ms) return ''
|
||||
if (ms > 30000) return 'dur-slow'
|
||||
if (ms > 10000) return 'dur-warn'
|
||||
return 'dur-fast'
|
||||
}
|
||||
|
||||
function fileCountType(n: number): string {
|
||||
if (n >= 50) return 'danger'
|
||||
if (n >= 10) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function truncateErr(msg: string): string {
|
||||
return msg.length > 50 ? msg.slice(0, 50) + '…' : msg
|
||||
}
|
||||
|
||||
// ── Status helpers ──
|
||||
function statusTip(status: string): string {
|
||||
if (status === 'success') return '转存成功'
|
||||
if (status === 'reused') return '♻️ 复用已有分享链接'
|
||||
return '转存失败'
|
||||
}
|
||||
function statusClass(status: string): string {
|
||||
if (status === 'success') return 'status-ok'
|
||||
if (status === 'reused') return 'status-reuse'
|
||||
return 'status-fail'
|
||||
}
|
||||
function statusIcon(status: string): string {
|
||||
if (status === 'success') return '✓'
|
||||
if (status === 'reused') return '♻️'
|
||||
return '✗'
|
||||
}
|
||||
|
||||
/** 省份/城市中英文翻译(用于 api.ip.sb 返回的英语地名) */
|
||||
const CN_PLACES: Record<string, string> = {
|
||||
// 省份/直辖市
|
||||
'Anhui':'安徽','Beijing':'北京','Chongqing':'重庆','Fujian':'福建','Gansu':'甘肃','Guangdong':'广东',
|
||||
'Guangxi':'广西','Guizhou':'贵州','Hainan':'海南','Hebei':'河北','Henan':'河南','Heilongjiang':'黑龙江',
|
||||
'Hubei':'湖北','Hunan':'湖南','Inner Mongolia':'内蒙古','Jiangsu':'江苏','Jiangxi':'江西','Jilin':'吉林',
|
||||
'Liaoning':'辽宁','Ningxia':'宁夏','Qinghai':'青海','Shaanxi':'陕西','Shandong':'山东','Shanghai':'上海',
|
||||
'Shanxi':'山西','Sichuan':'四川','Tianjin':'天津','Tibet':'西藏','Xinjiang':'新疆','Yunnan':'云南','Zhejiang':'浙江',
|
||||
'Hong Kong':'香港','Macau':'澳门','Taiwan':'台湾',
|
||||
// 主要城市
|
||||
'Changsha':'长沙','Hefei':'合肥','Fuzhou':'福州','Lanzhou':'兰州',
|
||||
'Guangzhou':'广州','Nanning':'南宁','Guiyang':'贵阳','Haikou':'海口',
|
||||
'Shijiazhuang':'石家庄','Zhengzhou':'郑州','Harbin':'哈尔滨','Wuhan':'武汉',
|
||||
'Nanjing':'南京','Nanchang':'南昌','Changchun':'长春','Shenyang':'沈阳',
|
||||
'Yinchuan':'银川','Xining':'西宁',"Xi'an":"西安",'Jinan':'济南',
|
||||
'Taiyuan':'太原','Chengdu':'成都','Shenzhen':'深圳','Hangzhou':'杭州',
|
||||
'Suzhou':'苏州','Wuxi':'无锡','Ningbo':'宁波','Dongguan':'东莞',
|
||||
'Foshan':'佛山','Zhuhai':'珠海','Qingdao':'青岛','Dalian':'大连',
|
||||
'Xiamen':'厦门','Kunming':'昆明','Lhasa':'拉萨','Urumqi':'乌鲁木齐',
|
||||
'Linyi':'临沂','Wenzhou':'温州','Quanzhou':'泉州',
|
||||
};
|
||||
|
||||
/** 常见英文ISP → 中文 */
|
||||
const CN_ISP: Record<string, string> = {
|
||||
'China Telecom':'中国电信','China Mobile':'中国移动','China Unicom':'中国联通',
|
||||
'Chinanet':'中国电信','ChinaNet':'中国电信','CMNET':'中国移动',
|
||||
'CNC Group':'中国联通','unicom':'中国联通','telecom':'中国电信','mobile':'中国移动',
|
||||
'China Education and Research Network':'教育网','CERNET':'教育网',
|
||||
'China Networks':'中国网络','China163':'中国电信','CHINANET BACKBONE':'中国电信',
|
||||
'Tencent Cloud':'腾讯云','Alibaba Cloud':'阿里云','Aliyun':'阿里云','Huawei Cloud':'华为云',
|
||||
'Baidu':'百度','Beijing Baidu':'百度',
|
||||
};
|
||||
|
||||
function formatLocation(loc: string): string {
|
||||
// Remove "China" prefix
|
||||
let s = loc.replace(/^(中国|China)\s*/i, '')
|
||||
// Split into parts
|
||||
const parts = s.split(/\s+/).filter(Boolean)
|
||||
// Translate each part
|
||||
return parts.map(p => CN_PLACES[p] || CN_ISP[p] || p).join(' ')
|
||||
}
|
||||
|
||||
function rowClassName({ row }: { row: SaveRecord }) {
|
||||
return row.status === 'failed' ? 'row-failed' : ''
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
function windowOpen(url: string) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function onExpandChange(row: SaveRecord, expanded: boolean[]) {
|
||||
// just placeholder for potential future use
|
||||
}
|
||||
|
||||
// ── Filter helpers ──
|
||||
function setTimeFilter(key: string) {
|
||||
activeTimeBtn.value = key
|
||||
dateRange.value = null
|
||||
const now = new Date()
|
||||
const y = now.getFullYear()
|
||||
const m = now.getMonth()
|
||||
|
||||
let start: Date
|
||||
let end: Date
|
||||
switch (key) {
|
||||
case 'today':
|
||||
start = new Date(y, m, now.getDate())
|
||||
end = start
|
||||
break
|
||||
case 'week': {
|
||||
const dow = now.getDay()
|
||||
start = new Date(y, m, now.getDate() + (dow === 0 ? -6 : 1 - dow))
|
||||
end = now
|
||||
break
|
||||
}
|
||||
case 'month':
|
||||
start = new Date(y, m, 1)
|
||||
end = now
|
||||
break
|
||||
case 'lastMonth':
|
||||
start = new Date(y, m - 1, 1)
|
||||
end = new Date(y, m, 0)
|
||||
break
|
||||
default:
|
||||
start = new Date(y, m, now.getDate())
|
||||
end = start
|
||||
}
|
||||
timeStart.value = fmt(start)
|
||||
const nextDay = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1)
|
||||
timeEnd.value = fmt(nextDay)
|
||||
loadRecords(1)
|
||||
}
|
||||
|
||||
function onDateRangeChange(val: string[] | null) {
|
||||
if (val && val.length === 2) {
|
||||
activeTimeBtn.value = ''
|
||||
timeStart.value = val[0]
|
||||
const next = new Date(val[1])
|
||||
next.setDate(next.getDate() + 1)
|
||||
timeEnd.value = fmt(next)
|
||||
loadRecords(1)
|
||||
} else {
|
||||
setTimeFilter('today')
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
statusFilter.value = ''
|
||||
cloudFilter.value = ''
|
||||
searchKeyword.value = ''
|
||||
dateRange.value = null
|
||||
setTimeFilter('today')
|
||||
}
|
||||
|
||||
async function loadRecords(page = 1) {
|
||||
loading.value = true
|
||||
try {
|
||||
currentPage.value = page
|
||||
const s = statusFilter.value || undefined
|
||||
const c = cloudFilter.value || undefined
|
||||
const kw = searchKeyword.value || undefined
|
||||
const res = await getSaveRecords(page, pageSize.value, timeStart.value, timeEnd.value, s, c, kw)
|
||||
records.value = res.records
|
||||
total.value = res.total
|
||||
summary.value = res.summary || null
|
||||
extractCloudTypes(res.records)
|
||||
} catch (e) {
|
||||
console.error('加载转存记录失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeFilter('today')
|
||||
loadCloudTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-records { padding: 0; }
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--el-border-color-light, #ebeef5);
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.record-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #909399);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Time buttons ── */
|
||||
.time-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--el-fill-color-light, #f0f2f5);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
}
|
||||
.time-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: all .25s ease;
|
||||
}
|
||||
.time-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
background: rgba(64,158,255,.06);
|
||||
}
|
||||
.time-btn.active {
|
||||
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-light-3, #79bbff));
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 6px rgba(64,158,255,.3);
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
.save-records :deep(.el-table) {
|
||||
border: 1px solid var(--el-border-color-light, #ebeef5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.save-records .el-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.save-records :deep(.el-table th.el-table__cell) {
|
||||
background-color: var(--el-fill-color-light, #f5f7fa);
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary, #303133);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.save-records :deep(.el-table .el-table__cell) {
|
||||
padding: 8px 8px;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.save-records :deep(.el-table .el-table__cell .cell) {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
/* ── Cell content: one-line with ellipsis ── */
|
||||
.cell-nowrap {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.save-records :deep(.el-table__row:hover > .el-table__cell) {
|
||||
background-color: var(--el-color-primary-light-9, #ecf5ff);
|
||||
}
|
||||
|
||||
/* Row highlight for failed */
|
||||
.save-records :deep(.row-failed > .el-table__cell) {
|
||||
background-color: rgba(245,108,108,.04);
|
||||
}
|
||||
|
||||
/* ── Status badge ── */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-ok {
|
||||
background: rgba(103,194,58,.15);
|
||||
color: #67c23a;
|
||||
}
|
||||
.status-reuse {
|
||||
background: rgba(64,158,255,.15);
|
||||
color: #409eff;
|
||||
}
|
||||
.status-fail {
|
||||
background: rgba(245,108,108,.15);
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
/* ── Cell helpers ── */
|
||||
.ip-text {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.loc-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #e8f4ff, #f0f8ff);
|
||||
color: #1a6ea0;
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b8d9f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.err-msg {
|
||||
color: var(--el-color-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
.reuse-msg {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.no-data {
|
||||
color: var(--text-secondary, #c0c4cc);
|
||||
}
|
||||
|
||||
/* ── Duration color ── */
|
||||
.duration { font-size: 12px; font-family: monospace; }
|
||||
.dur-fast { color: #67c23a; }
|
||||
.dur-warn { color: #e6a23c; }
|
||||
.dur-slow { color: #f56c6c; font-weight: 600; }
|
||||
|
||||
/* ── Expand detail (flex rows) ── */
|
||||
.expand-detail {
|
||||
padding: 16px 24px;
|
||||
background: var(--el-fill-color-lighter, #fafafa);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.detail-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
}
|
||||
.detail-cell.detail-full {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-link {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
}
|
||||
.detail-link:hover { text-decoration: underline; }
|
||||
.detail-code {
|
||||
font-size: 12px;
|
||||
background: #f0f0f0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.detail-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #f56c6c;
|
||||
background: rgba(245,108,108,.08);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Action cell ── */
|
||||
.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ── Pagination ── */
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-light, #f5f7fa);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.pagination-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #909399);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 转存统计汇总 ── */
|
||||
.save-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--el-fill-color-light, #f5f7fa);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.summary-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.summary-success strong { color: #67c23a; }
|
||||
.summary-failed strong { color: #f56c6c; }
|
||||
.summary-reused strong { color: #e6a23c; }
|
||||
.summary-rate { color: #909399; }
|
||||
.summary-rate strong { color: #409eff; }
|
||||
.summary-divider { color: #dcdfe6; font-size: 12px; user-select: none; }
|
||||
</style>
|
||||
318
packages/frontend/src/pages/admin/StatsView.vue
Executable file
318
packages/frontend/src/pages/admin/StatsView.vue
Executable file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="stats-view">
|
||||
<!-- 概览卡片 -->
|
||||
<div class="overview-cards">
|
||||
<el-card class="overview-card" shadow="never">
|
||||
<div class="overview-value primary">{{ stats.todaySearches }}</div>
|
||||
<div class="overview-label">今日搜索</div>
|
||||
</el-card>
|
||||
<el-card class="overview-card" shadow="never">
|
||||
<div class="overview-value success">{{ stats.todaySaves }}</div>
|
||||
<div class="overview-label">今日保存</div>
|
||||
</el-card>
|
||||
<el-card class="overview-card" shadow="never">
|
||||
<div class="overview-value primary">{{ stats.monthSearches }}</div>
|
||||
<div class="overview-label">本月搜索</div>
|
||||
</el-card>
|
||||
<el-card class="overview-card" shadow="never">
|
||||
<div class="overview-value success">{{ stats.monthSaves }}</div>
|
||||
<div class="overview-label">本月保存</div>
|
||||
</el-card>
|
||||
<el-card class="overview-card" shadow="never">
|
||||
<div class="overview-value info">{{ stats.totalSearches }}</div>
|
||||
<div class="overview-label">总搜索量</div>
|
||||
</el-card>
|
||||
<el-card class="overview-card" shadow="never">
|
||||
<div class="overview-value warning">{{ stats.totalSaves }}</div>
|
||||
<div class="overview-label">总保存量</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<!-- 近7天趋势 -->
|
||||
<el-card class="stats-card" shadow="never">
|
||||
<template #header>
|
||||
<span>📈 近 7 天趋势</span>
|
||||
</template>
|
||||
<el-table :data="stats.trendTrend" stripe size="small" style="width: 100%">
|
||||
<el-table-column label="日期" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="搜索" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="primary" effect="plain">{{ row.searches }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="保存" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="success" effect="plain">{{ row.saves }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="趋势" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="trend-bar-wrap">
|
||||
<div class="trend-bar" :style="{ width: trendBarWidth(row) + '%', background: trendBarColor(row) }"></div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 热门搜索关键词 -->
|
||||
<el-card class="stats-card" shadow="never">
|
||||
<template #header>
|
||||
<span>🔥 热门搜索关键词 Top 20</span>
|
||||
</template>
|
||||
<el-table :data="stats.hotKeywords" stripe size="small" style="width: 100%" :max-height="480">
|
||||
<el-table-column type="index" label="排名" width="60" />
|
||||
<el-table-column prop="keyword" label="关键词" min-width="200" />
|
||||
<el-table-column prop="count" label="搜索次数" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.count }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="stats.hotKeywords.length === 0" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<!-- 网盘使用空间 -->
|
||||
<el-card class="stats-card" shadow="never">
|
||||
<template #header>
|
||||
<span>💾 网盘使用空间</span>
|
||||
</template>
|
||||
<div v-for="item in stats.cloudUsage" :key="item.cloudType" class="storage-item">
|
||||
<div class="storage-header">
|
||||
<span class="storage-label">{{ item.nickname || item.cloudType }}</span>
|
||||
<span class="storage-badge" :class="item.isActive ? 'active' : 'inactive'">{{ item.isActive ? '正常' : '停用' }}</span>
|
||||
</div>
|
||||
<div class="storage-detail">
|
||||
<span class="storage-usage">{{ item.storageUsed || '未知' }} / {{ item.storageTotal || '未知' }}</span>
|
||||
<span class="storage-pct">{{ storagePercent(item) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="storagePercent(item)" :stroke-width="14" :color="storagePercent(item) > 80 ? '#f56c6c' : '#67c23a'" />
|
||||
</div>
|
||||
<el-empty v-if="stats.cloudUsage.length === 0" description="暂无网盘数据" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<!-- 保存操作来源 IP Top 10 -->
|
||||
<el-card class="stats-card" shadow="never">
|
||||
<template #header>
|
||||
<span>🌐 保存操作来源 IP Top 10</span>
|
||||
</template>
|
||||
<el-table :data="stats.topIps" stripe size="small" style="width: 100%">
|
||||
<el-table-column type="index" label="排名" width="60" />
|
||||
<el-table-column prop="ip" label="IP" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span style="font-family: monospace; font-size: 12px">{{ row.ip }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="访问地址" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.ip_location" style="font-size: 12px">{{ formatLocation(row.ip_location) }}</span>
|
||||
<span v-else class="no-data">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="count" label="操作次数" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="warning" effect="plain">{{ row.count }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="stats.topIps.length === 0" description="暂无数据" :image-size="60" />
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getStats } from '../../api'
|
||||
import type { StatsData } from '../../types'
|
||||
|
||||
const stats = ref<StatsData>({
|
||||
todaySearches: 0,
|
||||
todaySaves: 0,
|
||||
monthSearches: 0,
|
||||
monthSaves: 0,
|
||||
totalSearches: 0,
|
||||
totalSaves: 0,
|
||||
hotKeywords: [],
|
||||
trendTrend: [],
|
||||
cloudUsage: [],
|
||||
topIps: [],
|
||||
provinceRankings: [],
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
stats.value = await getStats()
|
||||
} catch (e) {
|
||||
console.error('加载统计数据失败', e)
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return '-'
|
||||
return d.split(' ')[0] || d
|
||||
}
|
||||
|
||||
/** Strip "中国/China" prefix from location string for consistent display */
|
||||
function formatLocation(loc: string): string {
|
||||
if (!loc) return ''
|
||||
return loc.replace(/^(中国|China)\s*/i, '').trim()
|
||||
}
|
||||
|
||||
function storagePercent(item: { storageUsed: string; storageTotal: string }): number {
|
||||
if (!item.storageUsed || !item.storageTotal) return 0
|
||||
const used = parseFloat(item.storageUsed)
|
||||
const total = parseFloat(item.storageTotal)
|
||||
if (total <= 0) return 0
|
||||
return Math.round((used / total) * 100)
|
||||
}
|
||||
|
||||
/// 趋势条宽度(基于当日搜索+保存之和)
|
||||
function trendBarWidth(row: { searches: number; saves: number }): number {
|
||||
const max = Math.max(...stats.value.trendTrend.map(r => r.searches + r.saves), 1)
|
||||
return ((row.searches + row.saves) / max) * 100
|
||||
}
|
||||
function trendBarColor(row: { searches: number; saves: number }): string {
|
||||
const total = row.searches + row.saves
|
||||
if (total === 0) return '#ebeef5'
|
||||
// Color gradient: more saves → more green
|
||||
const ratio = row.saves / Math.max(total, 1)
|
||||
if (ratio > 0.5) return '#67c23a'
|
||||
if (ratio > 0.2) return '#409eff'
|
||||
return '#909399'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overview-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* --- 概览卡片:阴影 + 悬浮 + 渐变底色 --- */
|
||||
.overview-card {
|
||||
border: none !important;
|
||||
border-radius: 12px !important;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.overview-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.overview-card :deep(.el-card__body) {
|
||||
text-align: center;
|
||||
padding: 22px 16px;
|
||||
background: transparent !important;
|
||||
}
|
||||
.overview-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.overview-value.primary { color: var(--el-color-primary); }
|
||||
.overview-value.success { color: var(--el-color-success); }
|
||||
.overview-value.info { color: var(--el-color-info); }
|
||||
.overview-value.warning { color: var(--el-color-warning); }
|
||||
.overview-label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* --- stats-card 美化阴影和圆角 --- */
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.stats-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
transition: box-shadow 0.3s ease;
|
||||
border: 1px solid var(--el-border-color-light, #ebeef5);
|
||||
}
|
||||
.stats-card:hover {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
.stats-card :deep(.el-card__header) {
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background: linear-gradient(90deg, #fafbfc 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
/* --- 表格行 hover 高亮 --- */
|
||||
.stats-view :deep(.el-table__body tr:hover > td.el-table__cell) {
|
||||
background-color: #ecf5ff !important;
|
||||
cursor: default;
|
||||
}
|
||||
.stats-view :deep(.el-table__body tr) {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* --- 趋势条 --- */
|
||||
.trend-bar-wrap {
|
||||
height: 16px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.trend-bar {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width .3s;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
/* --- 存储进度条更圆润 --- */
|
||||
.stats-view :deep(.el-progress-bar__outer) {
|
||||
border-radius: 99px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stats-view :deep(.el-progress-bar__inner) {
|
||||
border-radius: 99px !important;
|
||||
}
|
||||
.storage-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.storage-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.storage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.storage-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.storage-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.storage-badge.active { background: #f0f9eb; color: #67c23a; }
|
||||
.storage-badge.inactive { background: #fef0f0; color: #f56c6c; }
|
||||
.storage-detail {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.storage-pct {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
1344
packages/frontend/src/pages/admin/SystemConfig.vue
Executable file
1344
packages/frontend/src/pages/admin/SystemConfig.vue
Executable file
File diff suppressed because it is too large
Load Diff
76
packages/frontend/src/router.ts
Executable file
76
packages/frontend/src/router.ts
Executable file
@@ -0,0 +1,76 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('./pages/HomePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
component: () => import('./pages/SearchResult.vue'),
|
||||
},
|
||||
{
|
||||
path: '/result/:id',
|
||||
name: 'result-detail',
|
||||
component: () => import('./pages/ResultDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
component: () => import('./pages/admin/AdminLogin.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('./pages/admin/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/admin/dashboard',
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('./pages/admin/AdminDashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cloud-configs',
|
||||
name: 'admin-cloud-configs',
|
||||
component: () => import('./pages/admin/CloudConfig.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cleanup',
|
||||
name: 'admin-cleanup',
|
||||
component: () => import('./pages/admin/Cleanup.vue'),
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
name: 'admin-system',
|
||||
component: () => import('./pages/admin/SystemConfig.vue'),
|
||||
},
|
||||
{
|
||||
path: 'save-records',
|
||||
name: 'admin-save-records',
|
||||
component: () => import('./pages/admin/SaveRecords.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/admin/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
27
packages/frontend/src/styles/global.css
Executable file
27
packages/frontend/src/styles/global.css
Executable file
@@ -0,0 +1,27 @@
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #333;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #409eff;
|
||||
--primary-dark: #337ecc;
|
||||
--text-secondary: #909399;
|
||||
--border-color: #e4e7ed;
|
||||
--bg-white: #ffffff;
|
||||
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
--radius-card: 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
199
packages/frontend/src/types/index.ts
Executable file
199
packages/frontend/src/types/index.ts
Executable file
@@ -0,0 +1,199 @@
|
||||
/* ===== 搜索结果类型 ===== */
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
cover?: string
|
||||
file_size?: string
|
||||
update_time?: string
|
||||
datetime?: string
|
||||
cloud_type: CloudType
|
||||
share_url?: string
|
||||
source?: string
|
||||
password?: string
|
||||
file_id?: string
|
||||
valid?: boolean
|
||||
}
|
||||
|
||||
export type CloudType =
|
||||
| 'quark' | 'baidu' | 'aliyun' | '115'
|
||||
| 'tianyi' | '123pan' | 'uc' | 'xunlei'
|
||||
| 'pikpak' | 'magnet' | 'ed2k' | 'others'
|
||||
|
||||
export const CLOUD_LABELS: Record<CloudType, string> = {
|
||||
quark: '夸克网盘',
|
||||
baidu: '百度网盘',
|
||||
aliyun: '阿里云盘',
|
||||
'115': '115网盘',
|
||||
tianyi: '天翼云盘',
|
||||
'123pan': '123云盘',
|
||||
uc: 'UC网盘',
|
||||
xunlei: '迅雷云盘',
|
||||
pikpak: 'PikPak',
|
||||
magnet: '磁力链接',
|
||||
ed2k: '电驴链接',
|
||||
others: '其他',
|
||||
}
|
||||
|
||||
export const CLOUD_COLORS: Record<CloudType, string> = {
|
||||
quark: '#07c160',
|
||||
baidu: '#4e6ef2',
|
||||
aliyun: '#ff6a00',
|
||||
'115': '#9b59b6',
|
||||
tianyi: '#00a1d6',
|
||||
'123pan': '#e74c3c',
|
||||
uc: '#f39c12',
|
||||
xunlei: '#2ecc71',
|
||||
pikpak: '#8e44ad',
|
||||
magnet: '#95a5a6',
|
||||
ed2k: '#7f8c8d',
|
||||
others: '#95a5a6',
|
||||
}
|
||||
|
||||
/**
|
||||
* 网盘图标映射 — 全部使用内联 SVG data URI,无需外部文件
|
||||
* 每个网盘类型使用其品牌色圆角底 + 首字母/中文标识
|
||||
*/
|
||||
function makeSvgIcon(bg: string, letter: string): string {
|
||||
const c = encodeURIComponent(bg)
|
||||
const l = encodeURIComponent(letter)
|
||||
return `data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22${c}%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2213%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3E${l}%3C%2Ftext%3E%3C%2Fsvg%3E`
|
||||
}
|
||||
const ICON_SVGS: Record<string, string> = {
|
||||
magnet: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%236366F1%22%2F%3E%3Cpath%20d%3D%22M7%2016l5-5m-5%200l5%205m5-5l-5-5m5%200l-5%205%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20fill%3D%22none%22%2F%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2211%22%20r%3D%221%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E',
|
||||
ed2k: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%238B4513%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2211%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3EeD%3C%2Ftext%3E%3C%2Fsvg%3E',
|
||||
others: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%239CA3AF%22%2F%3E%3Cpath%20d%3D%22M6%2013c0-2.8%202.2-5%205-5a5%205%200%200%201%204.5%202.7A4%204%200%200%201%2020%2014a4%204%200%200%201-3%203.9h-8A4%204%200%200%201%206%2013z%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E',
|
||||
}
|
||||
export const CLOUD_ICONS: Record<string, string> = {
|
||||
baidu: makeSvgIcon('#4e6ef2', '百'),
|
||||
aliyun: makeSvgIcon('#ff6a00', '阿'),
|
||||
quark: makeSvgIcon('#07c160', '夸'),
|
||||
'115': makeSvgIcon('#9b59b6', '1'),
|
||||
tianyi: makeSvgIcon('#00a1d6', '天'),
|
||||
'123pan': makeSvgIcon('#e74c3c', '1'),
|
||||
uc: makeSvgIcon('#f39c12', 'U'),
|
||||
xunlei: makeSvgIcon('#2ecc71', '迅'),
|
||||
pikpak: makeSvgIcon('#8e44ad', 'P'),
|
||||
magnet: ICON_SVGS.magnet,
|
||||
ed2k: ICON_SVGS.ed2k,
|
||||
others: ICON_SVGS.others,
|
||||
}
|
||||
|
||||
/* ===== 视频解析类型 ===== */
|
||||
export interface VideoParseResult {
|
||||
title: string
|
||||
cover: string
|
||||
video_url: string
|
||||
author?: string
|
||||
platform: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/* ===== 保存结果类型 ===== */
|
||||
export interface SaveResult {
|
||||
success: boolean
|
||||
share_url: string
|
||||
share_pwd?: string
|
||||
expire_at?: string
|
||||
file_name: string
|
||||
file_size: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/* ===== 排行榜类型 ===== */
|
||||
export interface RankingItem {
|
||||
id: number
|
||||
title: string
|
||||
search_count: number
|
||||
cloud_type: CloudType
|
||||
cover?: string
|
||||
}
|
||||
|
||||
/* ===== 推广类型 ===== */
|
||||
export interface Promotion {
|
||||
id?: number
|
||||
title: string
|
||||
description: string
|
||||
image_url: string
|
||||
link_url: string
|
||||
position: 'home_banner' | 'search_top' | 'sidebar'
|
||||
sort_order: number
|
||||
active: boolean
|
||||
click_count?: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
}
|
||||
|
||||
/* ===== 网盘配置类型 ===== */
|
||||
export interface CloudConfig {
|
||||
id?: number
|
||||
cloud_type: CloudType
|
||||
nickname?: string
|
||||
is_active: boolean
|
||||
cookie?: string
|
||||
cookie_preview?: string
|
||||
storage_used?: string
|
||||
storage_total?: string
|
||||
checkin_status?: 'none' | 'success' | 'failed' | 'pending' | 'skipped'
|
||||
last_checkin_at?: string
|
||||
checkin_message?: string
|
||||
consecutive_failures?: number
|
||||
last_used_at?: string
|
||||
total_saves?: number
|
||||
verification_status?: 'untested' | 'valid' | 'invalid'
|
||||
last_verified_at?: string
|
||||
promotion_account?: string
|
||||
is_transfer_enabled?: number
|
||||
}
|
||||
|
||||
/* ===== API 响应类型 ===== */
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[]
|
||||
total: number
|
||||
filtered: number
|
||||
channels?: ChannelGroup[]
|
||||
link_validation?: boolean
|
||||
}
|
||||
|
||||
export interface ChannelGroup {
|
||||
cloud_type: string
|
||||
label: string
|
||||
color: string
|
||||
count: number
|
||||
items: any[]
|
||||
newestTime?: string
|
||||
}
|
||||
|
||||
export interface StatsData {
|
||||
todaySearches: number
|
||||
todaySaves: number
|
||||
monthSearches: number
|
||||
monthSaves: number
|
||||
totalSearches: number
|
||||
totalSaves: number
|
||||
hotKeywords: { keyword: string; count: number }[]
|
||||
trendTrend: { date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }[]
|
||||
cloudUsage: { cloudType: string; nickname: string; storageUsed: string; storageTotal: string; isActive: boolean }[]
|
||||
topIps: { ip: string; ip_location: string | null; count: number }[]
|
||||
provinceRankings: { province: string; count: number }[]
|
||||
}
|
||||
|
||||
/* ===== 意图识别结果 ===== */
|
||||
export type IntentType = 'SEARCH' | 'VIDEO_PARSE' | 'CLOUD_SAVE'
|
||||
|
||||
export interface QueryResponse {
|
||||
intent: IntentType
|
||||
results: SearchResult[] | VideoParseResult[]
|
||||
channels?: ChannelGroup[]
|
||||
total: number
|
||||
filtered?: number
|
||||
platform?: string
|
||||
link_validation?: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user