update: 完善处理失败的资源管理

This commit is contained in:
ctwj
2025-08-03 22:40:22 +08:00
parent 689d1e61a0
commit 5bd21e156d
13 changed files with 484 additions and 105 deletions

View File

@@ -222,23 +222,24 @@ func ExtractShareId(url string) (string, ServiceType) {
// 提取分享ID
shareID := ""
substring := strings.Index(url, "/s/")
if substring == -1 {
substring = strings.Index(url, "/t/") // 天翼云 是 t
shareID = url[substring+3:]
}
if substring == -1 {
substring = strings.Index(url, "/web/share?code=") // 天翼云 带密码
shareID = url[substring+11:]
}
if substring == -1 {
substring = strings.Index(url, "/p/") // 天翼云 是 p
shareID = url[substring+3:]
substring := -1
if index := strings.Index(url, "/s/"); index != -1 {
substring = index + 3
} else if index := strings.Index(url, "/t/"); index != -1 {
substring = index + 3
} else if index := strings.Index(url, "/web/share?code="); index != -1 {
substring = index + 16
} else if index := strings.Index(url, "/p/"); index != -1 {
substring = index + 3
}
if substring == -1 {
return "", NotFound
}
shareID = url[substring:]
// 去除可能的锚点
if hashIndex := strings.Index(shareID, "#"); hashIndex != -1 {
shareID = shareID[:hashIndex]

View File

@@ -1,6 +1,8 @@
package converter
import (
"time"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
)
@@ -169,6 +171,12 @@ func ToCksResponseList(cksList []entity.Cks) []dto.CksResponse {
// ToReadyResourceResponse 将ReadyResource实体转换为ReadyResourceResponse
func ToReadyResourceResponse(resource *entity.ReadyResource) dto.ReadyResourceResponse {
isDeleted := !resource.DeletedAt.Time.IsZero()
var deletedAt *time.Time
if isDeleted {
deletedAt = &resource.DeletedAt.Time
}
return dto.ReadyResourceResponse{
ID: resource.ID,
Title: resource.Title,
@@ -183,6 +191,8 @@ func ToReadyResourceResponse(resource *entity.ReadyResource) dto.ReadyResourceRe
ErrorMsg: resource.ErrorMsg,
CreateTime: resource.CreateTime,
IP: resource.IP,
DeletedAt: deletedAt,
IsDeleted: isDeleted,
}
}

View File

@@ -92,6 +92,8 @@ type ReadyResourceResponse struct {
ErrorMsg string `json:"error_msg"`
CreateTime time.Time `json:"create_time"`
IP *string `json:"ip"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
IsDeleted bool `json:"is_deleted"`
}
// Stats 统计信息

View File

@@ -23,8 +23,13 @@ type ReadyResourceRepository interface {
GenerateUniqueKey() (string, error)
FindWithErrors() ([]entity.ReadyResource, error)
FindWithErrorsPaginated(page, limit int) ([]entity.ReadyResource, int64, error)
FindWithErrorsIncludingDeleted() ([]entity.ReadyResource, error)
FindWithErrorsPaginatedIncludingDeleted(page, limit int, errorFilter string) ([]entity.ReadyResource, int64, error)
FindWithErrorsByQuery(errorFilter string) ([]entity.ReadyResource, error)
FindWithoutErrors() ([]entity.ReadyResource, error)
ClearErrorMsg(id uint) error
ClearErrorMsgAndRestore(id uint) error
ClearAllErrorsByQuery(errorFilter string) (int64, error) // 批量清除错误信息并真正删除资源
}
// ReadyResourceRepositoryImpl ReadyResource的Repository实现
@@ -118,20 +123,20 @@ func (r *ReadyResourceRepositoryImpl) GenerateUniqueKey() (string, error) {
return "", gorm.ErrInvalidData
}
// FindWithErrors 查找有错误信息的资源(deleted_at为空且存在error_msg
// FindWithErrors 查找有错误信息的资源(包括软删除的
func (r *ReadyResourceRepositoryImpl) FindWithErrors() ([]entity.ReadyResource, error) {
var resources []entity.ReadyResource
err := r.db.Where("deleted_at IS NULL AND error_msg != '' AND error_msg IS NOT NULL").Find(&resources).Error
err := r.db.Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL").Find(&resources).Error
return resources, err
}
// FindWithErrorsPaginated 分页查找有错误信息的资源(deleted_at为空且存在error_msg
// FindWithErrorsPaginated 分页查找有错误信息的资源(包括软删除的
func (r *ReadyResourceRepositoryImpl) FindWithErrorsPaginated(page, limit int) ([]entity.ReadyResource, int64, error) {
var resources []entity.ReadyResource
var total int64
offset := (page - 1) * limit
db := r.db.Model(&entity.ReadyResource{}).Where("deleted_at IS NULL AND error_msg != '' AND error_msg IS NOT NULL")
db := r.db.Model(&entity.ReadyResource{}).Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL")
// 获取总数
if err := db.Count(&total).Error; err != nil {
@@ -150,7 +155,75 @@ func (r *ReadyResourceRepositoryImpl) FindWithoutErrors() ([]entity.ReadyResourc
return resources, err
}
// FindWithErrorsIncludingDeleted 查找有错误信息的资源(包括软删除的,用于管理页面)
func (r *ReadyResourceRepositoryImpl) FindWithErrorsIncludingDeleted() ([]entity.ReadyResource, error) {
var resources []entity.ReadyResource
err := r.db.Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL").Find(&resources).Error
return resources, err
}
// FindWithErrorsPaginatedIncludingDeleted 分页查找有错误信息的资源(包括软删除的,用于管理页面)
func (r *ReadyResourceRepositoryImpl) FindWithErrorsPaginatedIncludingDeleted(page, limit int, errorFilter string) ([]entity.ReadyResource, int64, error) {
var resources []entity.ReadyResource
var total int64
offset := (page - 1) * limit
db := r.db.Model(&entity.ReadyResource{}).Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL")
// 如果有错误过滤条件,添加到查询中
if errorFilter != "" {
db = db.Where("error_msg ILIKE ?", "%"+errorFilter+"%")
}
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&resources).Error
return resources, total, err
}
// ClearErrorMsg 清除指定资源的错误信息
func (r *ReadyResourceRepositoryImpl) ClearErrorMsg(id uint) error {
return r.db.Model(&entity.ReadyResource{}).Where("id = ?", id).Update("error_msg", "").Error
return r.db.Model(&entity.ReadyResource{}).Where("id = ?", id).Update("error_msg", "").Update("deleted_at", nil).Error
}
// ClearErrorMsgAndRestore 清除错误信息并恢复软删除的资源
func (r *ReadyResourceRepositoryImpl) ClearErrorMsgAndRestore(id uint) error {
return r.db.Unscoped().Model(&entity.ReadyResource{}).Where("id = ?", id).Updates(map[string]interface{}{
"error_msg": "",
"deleted_at": nil,
}).Error
}
// FindWithErrorsByQuery 根据查询条件查找有错误信息的资源(不分页,用于批量操作)
func (r *ReadyResourceRepositoryImpl) FindWithErrorsByQuery(errorFilter string) ([]entity.ReadyResource, error) {
var resources []entity.ReadyResource
db := r.db.Model(&entity.ReadyResource{}).Unscoped().Where("error_msg != '' AND error_msg IS NOT NULL")
// 如果有错误过滤条件,添加到查询中
if errorFilter != "" {
db = db.Where("error_msg ILIKE ?", "%"+errorFilter+"%")
}
err := db.Order("created_at DESC").Find(&resources).Error
return resources, err
}
// ClearAllErrorsByQuery 根据查询条件批量清除错误信息并真正删除资源
func (r *ReadyResourceRepositoryImpl) ClearAllErrorsByQuery(errorFilter string) (int64, error) {
db := r.db.Unscoped().Model(&entity.ReadyResource{}).Where("error_msg != '' AND error_msg IS NOT NULL")
// 如果有错误过滤条件,添加到查询中
if errorFilter != "" {
db = db.Where("error_msg ILIKE ?", "%"+errorFilter+"%")
}
// 真正删除资源(物理删除)
result := db.Delete(&entity.ReadyResource{})
return result.RowsAffected, result.Error
}

View File

@@ -316,6 +316,7 @@ func GetReadyResourcesWithErrors(c *gin.Context) {
// 获取分页参数
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "100")
errorFilter := c.Query("error_filter")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
@@ -327,8 +328,8 @@ func GetReadyResourcesWithErrors(c *gin.Context) {
pageSize = 100
}
// 获取有错误的资源(分页)
resources, total, err := repoManager.ReadyResourceRepository.FindWithErrorsPaginated(page, pageSize)
// 获取有错误的资源(分页,包括软删除的
resources, total, err := repoManager.ReadyResourceRepository.FindWithErrorsPaginatedIncludingDeleted(page, pageSize, errorFilter)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
@@ -465,3 +466,109 @@ func RetryFailedResources(c *gin.Context) {
"retryable_count": getRetryableErrorCount(resources),
})
}
// BatchRestoreToReadyPool 批量将失败资源重新放入待处理池
func BatchRestoreToReadyPool(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "请求参数错误: "+err.Error(), http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
ErrorResponse(c, "资源ID列表不能为空", http.StatusBadRequest)
return
}
successCount := 0
failedCount := 0
for _, id := range req.IDs {
// 清除错误信息并恢复软删除的资源
err := repoManager.ReadyResourceRepository.ClearErrorMsgAndRestore(id)
if err != nil {
failedCount++
continue
}
successCount++
}
SuccessResponse(c, gin.H{
"message": "批量重新放入待处理池操作完成",
"total_count": len(req.IDs),
"success_count": successCount,
"failed_count": failedCount,
})
}
// BatchRestoreToReadyPoolByQuery 根据查询条件批量将失败资源重新放入待处理池
func BatchRestoreToReadyPoolByQuery(c *gin.Context) {
var req struct {
ErrorFilter string `json:"error_filter"`
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "请求参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 根据查询条件获取所有符合条件的资源
resources, err := repoManager.ReadyResourceRepository.FindWithErrorsByQuery(req.ErrorFilter)
if err != nil {
ErrorResponse(c, "查询资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
if len(resources) == 0 {
SuccessResponse(c, gin.H{
"message": "没有找到符合条件的资源",
"total_count": 0,
"success_count": 0,
"failed_count": 0,
})
return
}
successCount := 0
failedCount := 0
for _, resource := range resources {
err := repoManager.ReadyResourceRepository.ClearErrorMsgAndRestore(resource.ID)
if err != nil {
failedCount++
continue
}
successCount++
}
SuccessResponse(c, gin.H{
"message": "批量重新放入待处理池操作完成",
"total_count": len(resources),
"success_count": successCount,
"failed_count": failedCount,
})
}
// ClearAllErrorsByQuery 根据查询条件批量清除错误信息并删除资源
func ClearAllErrorsByQuery(c *gin.Context) {
var req struct {
ErrorFilter string `json:"error_filter"`
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "请求参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 根据查询条件批量删除失败资源
affectedRows, err := repoManager.ReadyResourceRepository.ClearAllErrorsByQuery(req.ErrorFilter)
if err != nil {
ErrorResponse(c, "批量删除失败资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"message": "批量删除失败资源操作完成",
"affected_rows": affectedRows,
})
}

View File

@@ -217,6 +217,9 @@ func main() {
api.GET("/ready-resources/errors", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetReadyResourcesWithErrors)
api.POST("/ready-resources/:id/clear-error", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearErrorMsg)
api.POST("/ready-resources/retry-failed", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.RetryFailedResources)
api.POST("/ready-resources/batch-restore", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchRestoreToReadyPool)
api.POST("/ready-resources/batch-restore-by-query", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchRestoreToReadyPoolByQuery)
api.POST("/ready-resources/clear-all-errors-by-query", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearAllErrorsByQuery)
// 用户管理(仅管理员)
api.GET("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetUsers)

View File

@@ -462,7 +462,7 @@ func (s *Scheduler) convertReadyResourceToResource(readyResource entity.ReadyRes
return NewNoAccountError(serviceType.String())
}
// 选择第一个有效的账号
// 选择第一个有效的账号 TODO 需要优化,随机选择账号
var selectedAccount *entity.Cks
for _, account := range accounts {
if account.IsValid {

View File

@@ -125,7 +125,8 @@ const pageConfig = computed(() => {
'/monitor': { title: '系统监控', icon: 'fas fa-desktop', description: '系统性能监控' },
'/admin/add-resource': { title: '添加资源', icon: 'fas fa-plus', description: '添加新资源' },
'/api-docs': { title: 'API文档', icon: 'fas fa-book', description: '接口文档说明' },
'/admin/version': { title: '版本信息', icon: 'fas fa-code-branch', description: '系统版本详情' }
'/admin/version': { title: '版本信息', icon: 'fas fa-code-branch', description: '系统版本详情' },
'/admin/failed-resources': { title: '错误资源', icon: 'fas fa-code-branch', description: '错误资源' }
}
return configs[route.path] || { title: props.title, icon: 'fas fa-cog', description: '管理页面' }
})

View File

@@ -35,6 +35,18 @@ export const parseApiResponse = <T>(response: any): T => {
page_size: response.data.limit
} as T
}
// 特殊处理失败资源接口返回完整的data结构
if (response.data && response.data.data && Array.isArray(response.data.data) && response.data.total !== undefined) {
return response.data
}
// 特殊处理登录接口直接返回data部分包含token和user
if (response.data && response.data.token && response.data.user) {
return response.data
}
// 特殊处理删除操作响应直接返回data部分
if (response.data && response.data.affected_rows !== undefined) {
return response.data
}
return response.data
} else {
throw new Error(response.message || '请求失败')
@@ -122,6 +134,9 @@ export const useReadyResourceApi = () => {
const clearReadyResources = () => useApiFetch('/ready-resources', { method: 'DELETE' }).then(parseApiResponse)
const clearErrorMsg = (id: number) => useApiFetch(`/ready-resources/${id}/clear-error`, { method: 'POST' }).then(parseApiResponse)
const retryFailedResources = () => useApiFetch('/ready-resources/retry-failed', { method: 'POST' }).then(parseApiResponse)
const batchRestoreToReadyPool = (ids: number[]) => useApiFetch('/ready-resources/batch-restore', { method: 'POST', body: { ids } }).then(parseApiResponse)
const batchRestoreToReadyPoolByQuery = (queryParams: any) => useApiFetch('/ready-resources/batch-restore-by-query', { method: 'POST', body: queryParams }).then(parseApiResponse)
const clearAllErrorsByQuery = (queryParams: any) => useApiFetch('/ready-resources/clear-all-errors-by-query', { method: 'POST', body: queryParams }).then(parseApiResponse)
return {
getReadyResources,
getFailedResources,
@@ -131,7 +146,10 @@ export const useReadyResourceApi = () => {
deleteReadyResource,
clearReadyResources,
clearErrorMsg,
retryFailedResources
retryFailedResources,
batchRestoreToReadyPool,
batchRestoreToReadyPoolByQuery,
clearAllErrorsByQuery
}
}

View File

@@ -22,6 +22,12 @@ export function useApiFetch<T = any>(
...options,
headers,
onResponse({ response }) {
console.log('API响应:', {
status: response.status,
data: response._data,
url: url
})
if (response.status === 401 ||
(response._data && (response._data.code === 401 || response._data.error === '无效的令牌'))
) {
@@ -38,6 +44,7 @@ export function useApiFetch<T = any>(
// 统一处理 code/message
if (response._data && response._data.code && response._data.code !== 200) {
console.error('API错误响应:', response._data)
throw new Error(response._data.message || '请求失败')
}
},

View File

@@ -25,18 +25,53 @@
<div class="flex gap-2">
<button
@click="retryAllFailed"
class="w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 rounded-md transition-colors text-center flex items-center justify-center gap-2"
:disabled="!errorFilter.trim() || isProcessing"
:class="[
'w-full sm:w-auto px-4 py-2 rounded-md transition-colors text-center flex items-center justify-center gap-2',
errorFilter.trim() && !isProcessing
? 'bg-green-600 hover:bg-green-700'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
]"
>
<i class="fas fa-redo"></i> 重试所有失败
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-redo"></i>
{{ isProcessing ? '处理中...' : '重新放入待处理池' }}
</button>
<button
@click="clearAllErrors"
class="w-full sm:w-auto px-4 py-2 bg-yellow-600 hover:bg-yellow-700 rounded-md transition-colors text-center flex items-center justify-center gap-2"
:disabled="!errorFilter.trim() || isProcessing"
:class="[
'w-full sm:w-auto px-4 py-2 rounded-md transition-colors text-center flex items-center justify-center gap-2',
errorFilter.trim() && !isProcessing
? 'bg-yellow-600 hover:bg-yellow-700'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
]"
>
<i class="fas fa-broom"></i> 清除所有错误
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-trash"></i>
{{ isProcessing ? '处理中...' : '删除失败资源' }}
</button>
</div>
<div class="flex gap-2 items-center">
<!-- 错误信息过滤 -->
<div class="flex items-center gap-2">
<n-input
v-model:value="errorFilter"
type="text"
placeholder="过滤错误信息..."
class="w-48"
clearable
@input="onErrorFilterChange"
/>
<button
v-if="errorFilter"
@click="clearErrorFilter"
class="px-2 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
title="清除过滤条件"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="flex gap-2">
<button
@click="refreshData"
class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 flex items-center gap-2"
@@ -46,20 +81,7 @@
</div>
</div>
<!-- 错误统计 -->
<div v-if="errorStats && Object.keys(errorStats).length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">错误类型统计</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="(count, type) in errorStats"
:key="type"
class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3 text-center"
>
<div class="text-2xl font-bold text-red-600 dark:text-red-400">{{ count }}</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">{{ getErrorTypeName(type) }}</div>
</div>
</div>
</div>
<!-- 失败资源列表 -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
@@ -68,6 +90,7 @@
<thead class="bg-red-800 dark:bg-red-900 text-white dark:text-gray-100 sticky top-0 z-10">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium">ID</th>
<th class="px-4 py-3 text-left text-sm font-medium">状态</th>
<th class="px-4 py-3 text-left text-sm font-medium">标题</th>
<th class="px-4 py-3 text-left text-sm font-medium">URL</th>
<th class="px-4 py-3 text-left text-sm font-medium">错误信息</th>
@@ -78,12 +101,12 @@
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto">
<tr v-if="loading" class="text-center py-8">
<td colspan="7" class="text-gray-500 dark:text-gray-400">
<td colspan="8" class="text-gray-500 dark:text-gray-400">
<i class="fas fa-spinner fa-spin mr-2"></i>加载中...
</td>
</tr>
<tr v-else-if="failedResources.length === 0">
<td colspan="7">
<td colspan="8">
<div class="flex flex-col items-center justify-center py-12">
<svg class="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" stroke-width="3" stroke-dasharray="6 6" />
@@ -97,11 +120,30 @@
<tr
v-for="resource in failedResources"
:key="resource.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
:class="[
'hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors',
resource.is_deleted ? 'bg-gray-100 dark:bg-gray-700' : ''
]"
>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100 font-medium">{{ resource.id }}</td>
<td class="px-4 py-3 text-sm">
<span
v-if="resource.is_deleted"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
title="已删除"
>
<i class="fas fa-trash mr-1"></i>已删除
</span>
<span
v-else
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
title="正常"
>
<i class="fas fa-check mr-1"></i>正常
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<span v-if="resource.title" :title="resource.title">{{ escapeHtml(resource.title) }}</span>
<span v-if="resource.title && resource.title !== null" :title="resource.title">{{ escapeHtml(resource.title) }}</span>
<span v-else class="text-gray-400 dark:text-gray-500 italic">未设置</span>
</td>
<td class="px-4 py-3 text-sm">
@@ -168,6 +210,9 @@
<!-- 总资源数 -->
<div class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个失败资源
<span v-if="errorFilter" class="ml-2 text-blue-600 dark:text-blue-400">
(已过滤)
</span>
</div>
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600"></div>
@@ -216,6 +261,9 @@
<div class="inline-flex items-center bg-white dark:bg-gray-800 rounded-lg shadow px-6 py-3">
<div class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-semibold text-gray-900 dark:text-gray-100">{{ totalCount }}</span> 个失败资源
<span v-if="errorFilter" class="ml-2 text-blue-600 dark:text-blue-400">
(已过滤)
</span>
</div>
</div>
</div>
@@ -232,11 +280,13 @@ definePageMeta({
interface FailedResource {
id: number
title?: string
title?: string | null
url: string
error_msg: string
create_time: string
ip?: string
ip?: string | null
deleted_at?: string | null
is_deleted: boolean
}
const failedResources = ref<FailedResource[]>([])
@@ -249,10 +299,12 @@ const pageSize = ref(100)
const totalCount = ref(0)
const totalPages = ref(0)
// 错误统计
const errorStats = ref<Record<string, number>>({})
const dialog = useDialog()
// 过滤相关状态
const errorFilter = ref('')
// 获取失败资源API
import { useReadyResourceApi } from '~/composables/useApi'
const readyResourceApi = useReadyResourceApi()
@@ -261,28 +313,49 @@ const readyResourceApi = useReadyResourceApi()
const fetchData = async () => {
loading.value = true
try {
const response = await readyResourceApi.getFailedResources({
const params: any = {
page: currentPage.value,
page_size: pageSize.value
}) as any
}
if (response && response.data) {
// 如果有过滤条件,添加到查询参数中
if (errorFilter.value.trim()) {
params.error_filter = errorFilter.value.trim()
}
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)
errorStats.value = response.error_stats || {}
} else {
console.log('fetchData - 使用空数据格式')
failedResources.value = []
totalCount.value = 0
totalPages.value = 1
errorStats.value = {}
}
console.log('fetchData - 处理后的数据:', {
failedResourcesCount: failedResources.value.length,
totalCount: totalCount.value,
totalPages: totalPages.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
errorStats.value = {}
} finally {
loading.value = false
}
@@ -332,6 +405,28 @@ const visiblePages = computed(() => {
return pages
})
// 防抖函数
const debounce = (func: Function, delay: number) => {
let timeoutId: NodeJS.Timeout
return (...args: any[]) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func.apply(null, args), delay)
}
}
// 错误过滤输入变化处理(防抖)
const onErrorFilterChange = debounce(() => {
currentPage.value = 1 // 重置到第一页
fetchData()
}, 300)
// 清除错误过滤
const clearErrorFilter = () => {
errorFilter.value = ''
currentPage.value = 1 // 重置到第一页
fetchData()
}
// 刷新数据
const refreshData = () => {
fetchData()
@@ -402,22 +497,52 @@ const deleteResource = async (id: number) => {
})
}
// 重试所有失败资源
// 处理状态
const isProcessing = ref(false)
// 重新放入待处理池
const retryAllFailed = async () => {
if (totalCount.value === 0) {
alert('没有可处理的资源')
return
}
// 检查是否有过滤条件
if (!errorFilter.value.trim()) {
alert('请先设置过滤条件,以避免处理所有失败资源')
return
}
// 构建查询条件
const queryParams: any = {}
// 如果有过滤条件,添加到查询参数中
if (errorFilter.value.trim()) {
queryParams.error_filter = errorFilter.value.trim()
}
const count = totalCount.value
dialog.warning({
title: '警告',
content: '确定要重试所有可重试的失败资源吗?',
title: '确认操作',
content: `确定要将 ${count} 个资源重新放入待处理池吗?`,
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
if (isProcessing.value) return // 防止重复点击
isProcessing.value = true
try {
const response = await readyResourceApi.retryFailedResources() as any
alert(`重试操作完成:\n总数量${response.total_count}\n已清除${response.cleared_count}\n跳过${response.skipped_count}`)
const response = await readyResourceApi.batchRestoreToReadyPoolByQuery(queryParams) as any
alert(`操作完成:\n总数量${response.total_count}\n成功处理${response.success_count}\n失败${response.failed_count}`)
fetchData()
} catch (error) {
console.error('重试所有失败资源失败:', error)
alert('重试失败')
console.error('重新放入待处理池失败:', error)
alert('操作失败')
} finally {
isProcessing.value = false
}
}
})
@@ -425,19 +550,49 @@ const retryAllFailed = async () => {
// 清除所有错误
const clearAllErrors = async () => {
// 检查是否有过滤条件
if (!errorFilter.value.trim()) {
alert('请先设置过滤条件,以避免删除所有失败资源')
return
}
// 构建查询条件
const queryParams: any = {}
// 如果有过滤条件,添加到查询参数中
if (errorFilter.value.trim()) {
queryParams.error_filter = errorFilter.value.trim()
}
const count = totalCount.value
dialog.warning({
title: '警告',
content: '确定要清除所有失败资源的错误信息吗?此操作不可恢复!',
positiveText: '确定',
content: `确定要删除 ${count} 个失败资源吗?此操作将永久删除这些资源,不可恢复!`,
positiveText: '确定删除',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
if (isProcessing.value) return // 防止重复点击
isProcessing.value = true
try {
// 这里需要实现批量清除错误的API
alert('批量清除错误功能待实现')
} catch (error) {
console.error('清除所有错误失败:', error)
alert('清除失败')
console.log('开始调用删除API参数:', queryParams)
const response = await readyResourceApi.clearAllErrorsByQuery(queryParams) as any
console.log('删除API响应:', response)
alert(`操作完成:\n删除失败资源${response.affected_rows} 个资源`)
fetchData()
} catch (error: any) {
console.error('删除失败资源失败:', error)
console.error('错误详情:', {
message: error?.message,
stack: error?.stack,
response: error?.response
})
alert('删除失败')
} finally {
isProcessing.value = false
}
}
})
@@ -477,24 +632,7 @@ const truncateError = (errorMsg: string) => {
return errorMsg.length > 50 ? errorMsg.substring(0, 50) + '...' : errorMsg
}
// 获取错误类型名称
const getErrorTypeName = (type: string) => {
const typeNames: Record<string, string> = {
'NO_ACCOUNT': '无账号',
'NO_VALID_ACCOUNT': '无有效账号',
'TRANSFER_FAILED': '转存失败',
'LINK_CHECK_FAILED': '链接检查失败',
'UNSUPPORTED_LINK': '不支持的链接',
'INVALID_LINK': '无效链接',
'SERVICE_CREATION_FAILED': '服务创建失败',
'TAG_PROCESSING_FAILED': '标签处理失败',
'CATEGORY_PROCESSING_FAILED': '分类处理失败',
'RESOURCE_SAVE_FAILED': '资源保存失败',
'PLATFORM_NOT_FOUND': '平台未找到',
'UNKNOWN': '未知错误'
}
return typeNames[type] || type
}
// 页面加载时获取数据
onMounted(async () => {

View File

@@ -83,6 +83,12 @@
<!-- 操作按钮 -->
<div class="flex justify-between items-center mb-4">
<div class="flex gap-2">
<NuxtLink
to="/admin/failed-resources"
class="w-full sm:w-auto px-4 py-2 bg-red-600 hover:bg-red-700 rounded-md transition-colors text-center flex items-center justify-center gap-2"
>
<i class="fas fa-plus"></i> 错误资源
</NuxtLink>
<NuxtLink
to="/admin/add-resource"
class="w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 rounded-md transition-colors text-center flex items-center justify-center gap-2"

View File

@@ -15,7 +15,7 @@
<n-input
type="text"
id="username"
v-model="form.username"
v-model:value="form.username"
required
:class="{ 'border-red-500': errors.username }"
/>
@@ -27,7 +27,7 @@
<n-input
type="password"
id="password"
v-model="form.password"
v-model:value="form.password"
required
:class="{ 'border-red-500': errors.password }"
/>
@@ -87,12 +87,15 @@ const validateForm = () => {
errors.username = ''
errors.password = ''
if (!form.username.trim()) {
console.log('validateForm - username:', form.username)
console.log('validateForm - password:', form.password ? '***' : 'empty')
if (!form.username || !form.username.trim()) {
errors.username = '请输入用户名'
return false
}
if (!form.password.trim()) {
if (!form.password || !form.password.trim()) {
errors.password = '请输入密码'
return false
}
@@ -101,7 +104,17 @@ const validateForm = () => {
}
const handleLogin = async () => {
if (!validateForm()) return
console.log('handleLogin - 开始登录,表单数据:', {
username: form.username,
password: form.password ? '***' : 'empty'
})
if (!validateForm()) {
console.log('handleLogin - 表单验证失败')
return
}
console.log('handleLogin - 表单验证通过开始调用登录API')
const result = await userStore.login({
username: form.username,