mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 03:15:04 +08:00
update: 完善处理失败的资源管理
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 统计信息
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
3
main.go
3
main.go
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '管理页面' }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '请求失败')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user