Files
urldb/web/pages/admin/failed-resources.vue
2025-09-14 10:26:58 +08:00

633 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<AdminPageLayout :is-sub-page="true">
<!-- 页面头部 - 标题和按钮 -->
<template #page-header>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">失败资源列表</h1>
<p class="text-gray-600 dark:text-gray-400">显示处理失败的资源包含错误信息</p>
</div>
<div class="flex space-x-3">
<n-button
@click="retryAllFailed"
:disabled="selectedResources.length === 0 || isProcessing"
:type="selectedResources.length > 0 && !isProcessing ? 'success' : 'default'"
:loading="isProcessing"
>
<template #icon>
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-redo"></i>
</template>
{{ isProcessing ? '处理中...' : `重新放入待处理池 (${selectedResources.length})` }}
</n-button>
<n-button
@click="clearAllErrors"
:disabled="selectedResources.length === 0 || isProcessing"
:type="selectedResources.length > 0 && !isProcessing ? 'warning' : 'default'"
:loading="isProcessing"
>
<template #icon>
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-trash"></i>
</template>
{{ isProcessing ? '处理中...' : `删除失败资源 (${selectedResources.length})` }}
</n-button>
<n-button @click="refreshData" type="info">
<template #icon>
<i class="fas fa-refresh"></i>
</template>
刷新
</n-button>
</div>
</template>
<!-- 过滤栏 - 搜索和筛选 -->
<template #filter-bar>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex flex-col md:flex-row gap-4">
<n-select
v-model:value="errorFilter"
placeholder="选择状态"
:options="statusOptions"
clearable
/>
<n-button type="primary" @click="handleSearch" class="w-full md:w-auto md:min-w-[100px]">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
</div>
</template>
<!-- 内容区header - 失败资源列表头部 -->
<template #content-header>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-lg font-semibold">失败资源列表</span>
<n-checkbox
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@update:checked="toggleSelectAll"
>
全选
</n-checkbox>
</div>
<div class="text-sm text-gray-500">
<span> {{ totalCount }} 个资源已选择 {{ selectedResources.length }} </span>
</div>
</div>
</template>
<!-- 内容区content - 失败资源列表 -->
<template #content>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<n-spin size="large">
<template #description>
<span class="text-gray-500">加载中...</span>
</template>
</n-spin>
</div>
<!-- 虚拟列表 -->
<div v-else-if="failedResources.length > 0">
<n-virtual-list
:items="failedResources"
:item-size="120"
:item-resizable="true"
style="max-height: 600px"
>
<template #default="{ item }">
<div class="border-b border-gray-200 dark:border-gray-700 p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<div class="flex items-center justify-between">
<!-- 左侧信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-4">
<!-- 复选框 -->
<n-checkbox
:checked="selectedResources.includes(item.id)"
@update:checked="(checked) => {
if (checked) {
selectedResources.push(item.id)
} else {
const index = selectedResources.indexOf(item.id)
if (index > -1) {
selectedResources.splice(index, 1)
}
}
}"
/>
<!-- ID -->
<div class="w-16 text-sm font-medium text-gray-900 dark:text-gray-100">
#{{ item.id }}
</div>
<!-- 标题 -->
<div class="flex-1 min-w-0">
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 line-clamp-1" :title="item.title || '未设置'">
{{ item.title || '未设置' }}
</h3>
</div>
</div>
<!-- 错误信息 -->
<div class="mt-2 flex items-center space-x-2">
<p class="text-xs text-gray-500 dark:text-gray-400 line-clamp-1 mt-1" :title="item.url">
<a
:href="checkUrlSafety(item.url)"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline"
>
{{ item.url }}
</a>
</p>
<n-tag type="error" size="small" :title="item.error_msg">
{{ truncateError(item.error_msg) }}
</n-tag>
</div>
<!-- 底部信息 -->
<div class="flex items-center space-x-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span>创建时间: {{ formatTime(item.create_time) }}</span>
<span>IP: {{ item.ip || '-' }}</span>
</div>
</div>
<!-- 右侧操作按钮 -->
<div class="flex items-center space-x-2 ml-4">
<n-button
size="small"
type="success"
@click="retryResource(item.id)"
title="重试此资源"
>
<template #icon>
<i class="fas fa-redo"></i>
</template>
</n-button>
<n-button
size="small"
type="warning"
@click="clearError(item.id)"
title="清除错误信息"
>
<template #icon>
<i class="fas fa-broom"></i>
</template>
</n-button>
<n-button
size="small"
type="error"
@click="deleteResource(item.id)"
title="删除此资源"
>
<template #icon>
<i class="fas fa-trash"></i>
</template>
</n-button>
</div>
</div>
</div>
</template>
</n-virtual-list>
</div>
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-12">
<n-empty description="暂无失败资源">
<template #icon>
<i class="fas fa-check-circle text-4xl text-green-500"></i>
</template>
<template #extra>
<span class="text-sm text-gray-500">所有资源处理成功</span>
</template>
</n-empty>
</div>
</template>
<!-- 内容区footer - 分页组件 -->
<template #content-footer>
<div class="p-4">
<div class="flex justify-center">
<n-pagination
v-model:page="currentPage"
v-model:page-size="pageSize"
:item-count="totalCount"
:page-sizes="[100, 200, 500, 1000]"
show-size-picker
@update:page="fetchData"
@update:page-size="(size) => { pageSize = size; currentPage = 1; fetchData() }"
/>
</div>
</div>
</template>
</AdminPageLayout>
</template>
<script setup lang="ts">
// 设置页面布局
definePageMeta({
layout: 'admin',
ssr: false
})
interface FailedResource {
id: number
title?: string | null
url: string
error_msg: string
create_time: string
ip?: string | null
deleted_at?: string | null
is_deleted: boolean
}
const notification = useNotification()
const dialog = useDialog()
const failedResources = ref<FailedResource[]>([])
const loading = ref(false)
// 分页相关状态
const currentPage = ref(1)
const pageSize = ref(200)
const totalCount = ref(0)
const totalPages = ref(0)
// 过滤相关状态
const errorFilter = ref('')
const selectedStatus = ref<string | null>(null)
// 处理状态
const isProcessing = ref(false)
// 选择相关状态
const selectedResources = ref<number[]>([])
// 状态选项
const statusOptions = [
{ label: '好友已取消了分享', value: '好友已取消了分享' },
{ label: '用户封禁', value: '用户封禁' },
{ label: '分享地址已失效', value: '分享地址已失效' },
{ label: '链接无效: 链接状态检查失败', value: '链接无效: 链接状态检查失败' }
]
// 获取失败资源API
import { useReadyResourceApi } from '~/composables/useApi'
const readyResourceApi = useReadyResourceApi()
// 全选相关计算属性
const isAllSelected = computed(() => {
return failedResources.value.length > 0 && selectedResources.value.length === failedResources.value.length
})
const isIndeterminate = computed(() => {
return selectedResources.value.length > 0 && selectedResources.value.length < failedResources.value.length
})
// 全选切换方法
const toggleSelectAll = (checked: boolean) => {
if (checked) {
selectedResources.value = failedResources.value.map(resource => resource.id)
} else {
selectedResources.value = []
}
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: currentPage.value,
page_size: pageSize.value
}
// 如果有错误信息过滤条件,添加到查询参数中
if (errorFilter.value.trim()) {
params.error_filter = errorFilter.value.trim()
}
// 如果有状态筛选条件,添加到查询参数中
if (selectedStatus.value) {
params.status = selectedStatus.value
}
console.log('fetchData - 开始获取失败资源,参数:', params)
const response = await readyResourceApi.getFailedResources(params) as any
console.log('fetchData - 原始响应:', response)
if (response && response.data && Array.isArray(response.data)) {
console.log('fetchData - 使用response.data格式数组')
failedResources.value = response.data
totalCount.value = response.total || 0
totalPages.value = Math.ceil((response.total || 0) / pageSize.value)
} else {
console.log('fetchData - 使用空数据格式')
failedResources.value = []
totalCount.value = 0
totalPages.value = 1
}
console.log('fetchData - 处理后的数据:', {
failedResourcesCount: failedResources.value.length,
totalCount: totalCount.value,
totalPages: totalPages.value
})
// 重置选择状态
selectedResources.value = []
// 打印第一个资源的数据结构(如果存在)
if (failedResources.value.length > 0) {
console.log('fetchData - 第一个资源的数据结构:', failedResources.value[0])
}
} catch (error) {
console.error('获取失败资源失败:', error)
failedResources.value = []
totalCount.value = 0
totalPages.value = 1
} finally {
loading.value = false
}
}
// 搜索处理
const handleSearch = () => {
currentPage.value = 1 // 重置到第一页
fetchData()
}
// 清除错误过滤
const clearErrorFilter = () => {
errorFilter.value = ''
selectedStatus.value = null
currentPage.value = 1 // 重置到第一页
fetchData()
}
// 刷新数据
const refreshData = () => {
fetchData()
}
// 重试单个资源
const retryResource = async (id: number) => {
dialog.warning({
title: '警告',
content: '确定要重试这个资源吗?',
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
try {
await readyResourceApi.clearErrorMsg(id)
notification.success({
content: '错误信息已清除,资源将在下次调度时重新处理',
duration: 3000
})
fetchData()
} catch (error) {
console.error('重试失败:', error)
notification.error({
content: '重试失败',
duration: 3000
})
}
}
})
}
// 清除单个资源错误
const clearError = async (id: number) => {
dialog.warning({
title: '警告',
content: '确定要清除这个资源的错误信息吗?',
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
try {
await readyResourceApi.clearErrorMsg(id)
notification.success({
content: '错误信息已清除',
duration: 3000
})
fetchData()
} catch (error) {
console.error('清除错误失败:', error)
notification.error({
content: '清除错误失败',
duration: 3000
})
}
}
})
}
// 删除资源
const deleteResource = async (id: number) => {
dialog.warning({
title: '警告',
content: '确定要删除这个失败资源吗?',
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
try {
await readyResourceApi.deleteReadyResource(id)
if (failedResources.value.length === 1 && currentPage.value > 1) {
currentPage.value--
}
fetchData()
notification.success({
content: '删除成功',
duration: 3000
})
} catch (error) {
console.error('删除失败:', error)
notification.error({
content: '删除失败',
duration: 3000
})
}
}
})
}
// 重新放入待处理池
const retryAllFailed = async () => {
if (selectedResources.value.length === 0) {
notification.error({
content: '请先选择要处理的资源',
duration: 3000
})
return
}
const count = selectedResources.value.length
dialog.warning({
title: '确认操作',
content: `确定要将 ${count} 个资源重新放入待处理池吗?`,
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
if (isProcessing.value) return // 防止重复点击
isProcessing.value = true
try {
// 使用选中的资源ID进行批量操作
const response = await readyResourceApi.batchRestoreToReadyPool(selectedResources.value) as any
notification.success({
content: `操作完成:\n总数量${count}\n成功处理${response.success_count || count}\n失败${response.failed_count || 0}`,
duration: 3000
})
selectedResources.value = [] // 清空选择
fetchData()
} catch (error) {
console.error('重新放入待处理池失败:', error)
notification.error({
content: '操作失败',
duration: 3000
})
} finally {
isProcessing.value = false
}
}
})
}
// 清除所有错误
const clearAllErrors = async () => {
if (selectedResources.value.length === 0) {
notification.error({
content: '请先选择要删除的资源',
duration: 3000
})
return
}
const count = selectedResources.value.length
dialog.warning({
title: '警告',
content: `确定要删除 ${count} 个失败资源吗?此操作将永久删除这些资源,不可恢复!`,
positiveText: '确定删除',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
if (isProcessing.value) return // 防止重复点击
isProcessing.value = true
try {
console.log('开始调用删除API选中的资源ID:', selectedResources.value)
// 逐个删除选中的资源
let successCount = 0
for (const id of selectedResources.value) {
try {
await readyResourceApi.deleteReadyResource(id)
successCount++
} catch (error) {
console.error(`删除资源 ${id} 失败:`, error)
}
}
notification.success({
content: `操作完成:\n删除失败资源${successCount} 个资源`,
duration: 3000
})
selectedResources.value = [] // 清空选择
fetchData()
} catch (error: any) {
console.error('删除失败资源失败:', error)
console.error('错误详情:', {
message: error?.message,
stack: error?.stack,
response: error?.response
})
notification.error({
content: '删除失败',
duration: 3000
})
} finally {
isProcessing.value = false
}
}
})
}
// 格式化时间
const formatTime = (timeString: string) => {
const date = new Date(timeString)
return date.toLocaleString('zh-CN')
}
// 转义HTML防止XSS
const escapeHtml = (text: string) => {
if (!text) return text
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// 验证URL安全性
const checkUrlSafety = (url: string) => {
if (!url) return '#'
try {
const urlObj = new URL(url)
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
return '#'
}
return url
} catch {
return '#'
}
}
// 截断错误信息
const truncateError = (errorMsg: string) => {
if (!errorMsg) return ''
return errorMsg.length > 50 ? errorMsg.substring(0, 50) + '...' : errorMsg
}
// 页面加载时获取数据
onMounted(async () => {
try {
await fetchData()
} catch (error) {
console.error('页面初始化失败:', error)
}
})
</script>
<style scoped>
/* 自定义样式 */
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
</style>