update: 完善新后台

This commit is contained in:
ctwj
2025-08-08 01:28:25 +08:00
parent 5cfd0ad3ee
commit 667338368a
16 changed files with 1544 additions and 519 deletions

View File

@@ -78,6 +78,7 @@ func InitDB() error {
&entity.SearchStat{}, &entity.SearchStat{},
&entity.SystemConfig{}, &entity.SystemConfig{},
&entity.HotDrama{}, &entity.HotDrama{},
&entity.ResourceView{},
) )
if err != nil { if err != nil {
utils.Fatal("数据库迁移失败: %v", err) utils.Fatal("数据库迁移失败: %v", err)

View File

@@ -0,0 +1,25 @@
package entity
import (
"time"
"gorm.io/gorm"
)
// ResourceView 资源访问记录
type ResourceView struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
ResourceID uint `json:"resource_id" gorm:"not null;index;comment:资源ID"`
IPAddress string `json:"ip_address" gorm:"size:45;comment:访问者IP地址"`
UserAgent string `json:"user_agent" gorm:"type:text;comment:用户代理"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;comment:访问时间"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
// 关联关系
Resource Resource `json:"resource" gorm:"foreignKey:ResourceID"`
}
// TableName 指定表名
func (ResourceView) TableName() string {
return "resource_views"
}

View File

@@ -16,6 +16,7 @@ type RepositoryManager struct {
SearchStatRepository SearchStatRepository SearchStatRepository SearchStatRepository
SystemConfigRepository SystemConfigRepository SystemConfigRepository SystemConfigRepository
HotDramaRepository HotDramaRepository HotDramaRepository HotDramaRepository
ResourceViewRepository ResourceViewRepository
} }
// NewRepositoryManager 创建Repository管理器 // NewRepositoryManager 创建Repository管理器
@@ -31,5 +32,6 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
SearchStatRepository: NewSearchStatRepository(db), SearchStatRepository: NewSearchStatRepository(db),
SystemConfigRepository: NewSystemConfigRepository(db), SystemConfigRepository: NewSystemConfigRepository(db),
HotDramaRepository: NewHotDramaRepository(db), HotDramaRepository: NewHotDramaRepository(db),
ResourceViewRepository: NewResourceViewRepository(db),
} }
} }

View File

@@ -2,7 +2,6 @@ package repo
import ( import (
"fmt" "fmt"
"time" "time"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
@@ -221,6 +220,13 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
if query, ok := value.(string); ok && query != "" { if query, ok := value.(string); ok && query != "" {
db = db.Where("title ILIKE ? OR description ILIKE ?", "%"+query+"%", "%"+query+"%") db = db.Where("title ILIKE ? OR description ILIKE ?", "%"+query+"%", "%"+query+"%")
} }
case "category_id": // 添加category_id参数支持
if categoryID, ok := value.(uint); ok {
fmt.Printf("应用分类筛选: category_id = %d\n", categoryID)
db = db.Where("category_id = ?", categoryID)
} else {
fmt.Printf("分类ID类型错误: %T, value: %v\n", value, value)
}
case "category": // 添加category参数支持字符串形式 case "category": // 添加category参数支持字符串形式
if category, ok := value.(string); ok && category != "" { if category, ok := value.(string); ok && category != "" {
// 根据分类名称查找分类ID // 根据分类名称查找分类ID
@@ -254,12 +260,17 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
} }
} }
// 如果没有明确指定is_valid和is_public则默认只显示有效的公开资源 // 管理后台显示所有资源公开API才限制为有效的公开资源
// 这里通过检查请求来源来判断是否为管理后台
// 如果没有明确指定is_valid和is_public则显示所有资源
// 注意:这个逻辑可能需要根据实际需求调整
if _, hasIsValid := params["is_valid"]; !hasIsValid { if _, hasIsValid := params["is_valid"]; !hasIsValid {
db = db.Where("is_valid = ?", true) // 管理后台不限制is_valid
// db = db.Where("is_valid = ?", true)
} }
if _, hasIsPublic := params["is_public"]; !hasIsPublic { if _, hasIsPublic := params["is_public"]; !hasIsPublic {
db = db.Where("is_public = ?", true) // 管理后台不限制is_public
// db = db.Where("is_public = ?", true)
} }
// 获取总数 // 获取总数
@@ -276,10 +287,13 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
} }
if pageSizeVal, ok := params["page_size"].(int); ok && pageSizeVal > 0 { if pageSizeVal, ok := params["page_size"].(int); ok && pageSizeVal > 0 {
pageSize = pageSizeVal pageSize = pageSizeVal
// 限制最大page_size为100 fmt.Printf("原始pageSize: %d\n", pageSize)
if pageSize > 100 { // 限制最大page_size为1000
pageSize = 100 if pageSize > 1000 {
pageSize = 1000
fmt.Printf("pageSize超过1000限制为: %d\n", pageSize)
} }
fmt.Printf("最终pageSize: %d\n", pageSize)
} }
// 计算偏移量 // 计算偏移量
@@ -287,6 +301,7 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
// 获取分页数据,按更新时间倒序 // 获取分页数据,按更新时间倒序
err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error
fmt.Printf("查询结果: 总数=%d, 当前页数据量=%d, pageSize=%d\n", total, len(resources), pageSize)
return resources, total, err return resources, total, err
} }

View File

@@ -0,0 +1,90 @@
package repo
import (
"time"
"github.com/ctwj/urldb/db/entity"
"gorm.io/gorm"
)
// ResourceViewRepository 资源访问记录仓库接口
type ResourceViewRepository interface {
BaseRepository[entity.ResourceView]
RecordView(resourceID uint, ipAddress, userAgent string) error
GetTodayViews() (int64, error)
GetViewsByDate(date string) (int64, error)
GetViewsTrend(days int) ([]map[string]interface{}, error)
GetResourceViews(resourceID uint, limit int) ([]entity.ResourceView, error)
}
// ResourceViewRepositoryImpl 资源访问记录仓库实现
type ResourceViewRepositoryImpl struct {
BaseRepositoryImpl[entity.ResourceView]
}
// NewResourceViewRepository 创建资源访问记录仓库
func NewResourceViewRepository(db *gorm.DB) ResourceViewRepository {
return &ResourceViewRepositoryImpl{
BaseRepositoryImpl: BaseRepositoryImpl[entity.ResourceView]{db: db},
}
}
// RecordView 记录资源访问
func (r *ResourceViewRepositoryImpl) RecordView(resourceID uint, ipAddress, userAgent string) error {
view := &entity.ResourceView{
ResourceID: resourceID,
IPAddress: ipAddress,
UserAgent: userAgent,
}
return r.db.Create(view).Error
}
// GetTodayViews 获取今日访问量
func (r *ResourceViewRepositoryImpl) GetTodayViews() (int64, error) {
today := time.Now().Format("2006-01-02")
var count int64
err := r.db.Model(&entity.ResourceView{}).
Where("DATE(created_at) = ?", today).
Count(&count).Error
return count, err
}
// GetViewsByDate 获取指定日期的访问量
func (r *ResourceViewRepositoryImpl) GetViewsByDate(date string) (int64, error) {
var count int64
err := r.db.Model(&entity.ResourceView{}).
Where("DATE(created_at) = ?", date).
Count(&count).Error
return count, err
}
// GetViewsTrend 获取访问量趋势数据
func (r *ResourceViewRepositoryImpl) GetViewsTrend(days int) ([]map[string]interface{}, error) {
var results []map[string]interface{}
for i := days - 1; i >= 0; i-- {
date := time.Now().AddDate(0, 0, -i)
dateStr := date.Format("2006-01-02")
count, err := r.GetViewsByDate(dateStr)
if err != nil {
return nil, err
}
results = append(results, map[string]interface{}{
"date": dateStr,
"views": count,
})
}
return results, nil
}
// GetResourceViews 获取指定资源的访问记录
func (r *ResourceViewRepositoryImpl) GetResourceViews(resourceID uint, limit int) ([]entity.ResourceView, error) {
var views []entity.ResourceView
err := r.db.Where("resource_id = ?", resourceID).
Order("created_at DESC").
Limit(limit).
Find(&views).Error
return views, err
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/ctwj/urldb/db/converter" "github.com/ctwj/urldb/db/converter"
"github.com/ctwj/urldb/db/dto" "github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -16,6 +17,8 @@ func GetResources(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
utils.Info("资源列表请求 - page: %d, pageSize: %d", page, pageSize)
params := map[string]interface{}{ params := map[string]interface{}{
"page": page, "page": page,
"page_size": pageSize, "page_size": pageSize,
@@ -30,8 +33,12 @@ func GetResources(c *gin.Context) {
} }
} }
if categoryID := c.Query("category_id"); categoryID != "" { if categoryID := c.Query("category_id"); categoryID != "" {
utils.Info("收到分类ID参数: %s", categoryID)
if id, err := strconv.ParseUint(categoryID, 10, 32); err == nil { if id, err := strconv.ParseUint(categoryID, 10, 32); err == nil {
params["category_id"] = uint(id) params["category_id"] = uint(id)
utils.Info("解析分类ID成功: %d", uint(id))
} else {
utils.Error("解析分类ID失败: %v", err)
} }
} }
@@ -285,11 +292,23 @@ func IncrementResourceViewCount(c *gin.Context) {
ErrorResponse(c, "无效的资源ID", http.StatusBadRequest) ErrorResponse(c, "无效的资源ID", http.StatusBadRequest)
return return
} }
// 增加资源访问量
err = repoManager.ResourceRepository.IncrementViewCount(uint(id)) err = repoManager.ResourceRepository.IncrementViewCount(uint(id))
if err != nil { if err != nil {
ErrorResponse(c, "增加浏览次数失败", http.StatusInternalServerError) ErrorResponse(c, "增加浏览次数失败", http.StatusInternalServerError)
return return
} }
// 记录访问记录
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
err = repoManager.ResourceViewRepository.RecordView(uint(id), ipAddress, userAgent)
if err != nil {
// 记录访问失败不影响主要功能,只记录日志
utils.Error("记录资源访问失败: %v", err)
}
SuccessResponse(c, gin.H{"message": "浏览次数+1"}) SuccessResponse(c, gin.H{"message": "浏览次数+1"})
} }

View File

@@ -22,17 +22,39 @@ func GetStats(c *gin.Context) {
db.DB.Model(&entity.Tag{}).Count(&totalTags) db.DB.Model(&entity.Tag{}).Count(&totalTags)
db.DB.Model(&entity.Resource{}).Select("COALESCE(SUM(view_count), 0)").Scan(&totalViews) db.DB.Model(&entity.Resource{}).Select("COALESCE(SUM(view_count), 0)").Scan(&totalViews)
// 获取今日更新数量 // 获取今日数据
var todayUpdates int64
today := utils.GetCurrentTime().Format("2006-01-02") today := utils.GetCurrentTime().Format("2006-01-02")
db.DB.Model(&entity.Resource{}).Where("DATE(updated_at) = ?", today).Count(&todayUpdates)
// 今日新增资源数量
var todayResources int64
db.DB.Model(&entity.Resource{}).Where("DATE(created_at) = ?", today).Count(&todayResources)
// 今日浏览量 - 使用访问记录表统计今日访问量
var todayViews int64
todayViews, err := repoManager.ResourceViewRepository.GetTodayViews()
if err != nil {
utils.Error("获取今日访问量失败: %v", err)
todayViews = 0
}
// 今日搜索量
var todaySearches int64
db.DB.Model(&entity.SearchStat{}).Where("DATE(date) = ?", today).Count(&todaySearches)
// 添加调试日志
utils.Info("统计数据 - 总资源: %d, 总分类: %d, 总标签: %d, 总浏览量: %d",
totalResources, totalCategories, totalTags, totalViews)
utils.Info("今日数据 - 新增资源: %d, 今日浏览量: %d, 今日搜索: %d",
todayResources, todayViews, todaySearches)
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"total_resources": totalResources, "total_resources": totalResources,
"total_categories": totalCategories, "total_categories": totalCategories,
"total_tags": totalTags, "total_tags": totalTags,
"total_views": totalViews, "total_views": totalViews,
"today_updates": todayUpdates, "today_resources": todayResources,
"today_views": todayViews,
"today_searches": todaySearches,
}) })
} }
@@ -99,36 +121,12 @@ func GetSystemInfo(c *gin.Context) {
// GetViewsTrend 获取访问量趋势数据 // GetViewsTrend 获取访问量趋势数据
func GetViewsTrend(c *gin.Context) { func GetViewsTrend(c *gin.Context) {
// 获取最近7天的访问量数据 // 使用访问记录表获取最近7天的访问量数据
var results []gin.H results, err := repoManager.ResourceViewRepository.GetViewsTrend(7)
if err != nil {
// 获取访问量作为基础数据 utils.Error("获取访问量趋势数据失败: %v", err)
var totalViews int64 // 如果获取失败,返回空数据
db.DB.Model(&entity.Resource{}). results = []map[string]interface{}{}
Select("COALESCE(SUM(view_count), 0)").
Scan(&totalViews)
// 生成最近7天的日期
for i := 6; i >= 0; i-- {
date := utils.GetCurrentTime().AddDate(0, 0, -i)
dateStr := date.Format("2006-01-02")
// 基于总访问量生成合理的趋势数据
// 使用日期因子和随机因子来模拟真实的访问趋势
baseViews := float64(totalViews) / 7.0 // 平均分配到7天
dayFactor := 1.0 + float64(i-3)*0.2 // 中间日期访问量较高
randomFactor := float64(80+utils.GetCurrentTime().Hour()*i) / 100.0
views := int64(baseViews * dayFactor * randomFactor)
// 确保访问量不为负数
if views < 0 {
views = 0
}
results = append(results, gin.H{
"date": dateStr,
"views": views,
})
} }
// 添加调试日志 // 添加调试日志

View File

@@ -18,8 +18,30 @@
<!-- 右侧用户菜单 --> <!-- 右侧状态信息和用户菜单 -->
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- 自动处理状态 -->
<div class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 rounded-lg px-3 py-2">
<div class="w-2 h-2 rounded-full animate-pulse" :class="{
'bg-red-400': !systemConfig?.auto_process_ready_resources,
'bg-green-400': systemConfig?.auto_process_ready_resources
}"></div>
<span class="text-xs text-gray-700 dark:text-gray-300 font-medium">
自动处理已<span>{{ systemConfig?.auto_process_ready_resources ? '开启' : '关闭' }}</span>
</span>
</div>
<!-- 自动转存状态 -->
<div class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 rounded-lg px-3 py-2">
<div class="w-2 h-2 rounded-full animate-pulse" :class="{
'bg-red-400': !systemConfig?.auto_transfer_enabled,
'bg-green-400': systemConfig?.auto_transfer_enabled
}"></div>
<span class="text-xs text-gray-700 dark:text-gray-300 font-medium">
自动转存已<span>{{ systemConfig?.auto_transfer_enabled ? '开启' : '关闭' }}</span>
</span>
</div>
<NuxtLink to="/" class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"> <NuxtLink to="/" class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<i class="fas fa-home text-lg"></i> <i class="fas fa-home text-lg"></i>
</NuxtLink> </NuxtLink>
@@ -240,11 +262,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { useUserStore } from '~/stores/user' import { useUserStore } from '~/stores/user'
import { useSystemConfigStore } from '~/stores/systemConfig'
// 用户状态管理 // 用户状态管理
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
// 系统配置store
const systemConfigStore = useSystemConfigStore()
// 初始化系统配置
await systemConfigStore.initConfig()
// 系统配置
const systemConfig = computed(() => {
const config = systemConfigStore.config || {}
console.log('顶部导航系统配置:', config)
return config
})
// 用户菜单状态 // 用户菜单状态
const showUserMenu = ref(false) const showUserMenu = ref(false)
@@ -331,7 +367,7 @@ const dataManagementItems = ref([
}, },
{ {
to: '/admin/accounts', to: '/admin/accounts',
label: '账号管理', label: '平台账号',
icon: 'fas fa-user-shield', icon: 'fas fa-user-shield',
active: (route: any) => route.path.startsWith('/admin/accounts') active: (route: any) => route.path.startsWith('/admin/accounts')
} }
@@ -339,17 +375,17 @@ const dataManagementItems = ref([
// 系统配置菜单项 // 系统配置菜单项
const systemConfigItems = ref([ const systemConfigItems = ref([
{
to: '/admin/platforms',
label: '平台管理',
icon: 'fas fa-cloud',
active: (route: any) => route.path.startsWith('/admin/platforms')
},
{ {
to: '/admin/system-config', to: '/admin/system-config',
label: '系统配置', label: '系统配置',
icon: 'fas fa-cog', icon: 'fas fa-cog',
active: (route: any) => route.path.startsWith('/admin/system-config') active: (route: any) => route.path.startsWith('/admin/system-config')
},
{
to: '/admin/users',
label: '用户管理',
icon: 'fas fa-users',
active: (route: any) => route.path.startsWith('/admin/users')
} }
]) ])
@@ -360,12 +396,6 @@ const operationItems = ref([
label: '热播剧管理', label: '热播剧管理',
icon: 'fas fa-film', icon: 'fas fa-film',
active: (route: any) => route.path.startsWith('/admin/hot-dramas') active: (route: any) => route.path.startsWith('/admin/hot-dramas')
},
{
to: '/admin/users',
label: '用户管理',
icon: 'fas fa-users',
active: (route: any) => route.path.startsWith('/admin/users')
} }
]) ])
@@ -386,9 +416,9 @@ const autoExpandCurrentGroup = () => {
// 检查当前页面属于哪个分组并展开 // 检查当前页面属于哪个分组并展开
if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts')) { if (currentPath.startsWith('/admin/resources') || currentPath.startsWith('/admin/ready-resources') || currentPath.startsWith('/admin/tags') || currentPath.startsWith('/admin/categories') || currentPath.startsWith('/admin/accounts')) {
expandedGroups.value.dataManagement = true expandedGroups.value.dataManagement = true
} else if (currentPath.startsWith('/admin/platforms') || currentPath.startsWith('/admin/system-config')) { } else if (currentPath.startsWith('/admin/system-config') || currentPath.startsWith('/admin/users')) {
expandedGroups.value.systemConfig = true expandedGroups.value.systemConfig = true
} else if (currentPath.startsWith('/admin/hot-dramas') || currentPath.startsWith('/admin/users')) { } else if (currentPath.startsWith('/admin/hot-dramas')) {
expandedGroups.value.operation = true expandedGroups.value.operation = true
} else if (currentPath.startsWith('/admin/search-stats')) { } else if (currentPath.startsWith('/admin/search-stats')) {
expandedGroups.value.statistics = true expandedGroups.value.statistics = true
@@ -408,9 +438,9 @@ watch(() => useRoute().path, (newPath) => {
// 根据新路径展开对应分组 // 根据新路径展开对应分组
if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts')) { if (newPath.startsWith('/admin/resources') || newPath.startsWith('/admin/ready-resources') || newPath.startsWith('/admin/tags') || newPath.startsWith('/admin/categories') || newPath.startsWith('/admin/accounts')) {
expandedGroups.value.dataManagement = true expandedGroups.value.dataManagement = true
} else if (newPath.startsWith('/admin/platforms') || newPath.startsWith('/admin/system-config')) { } else if (newPath.startsWith('/admin/system-config') || newPath.startsWith('/admin/users')) {
expandedGroups.value.systemConfig = true expandedGroups.value.systemConfig = true
} else if (newPath.startsWith('/admin/hot-dramas') || newPath.startsWith('/admin/users')) { } else if (newPath.startsWith('/admin/hot-dramas')) {
expandedGroups.value.operation = true expandedGroups.value.operation = true
} else if (newPath.startsWith('/admin/search-stats')) { } else if (newPath.startsWith('/admin/search-stats')) {
expandedGroups.value.statistics = true expandedGroups.value.statistics = true

View File

@@ -4,16 +4,16 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">账号管理</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">账号管理</h1>
<p class="text-gray-600 dark:text-gray-400">管理系统中的第三方平台账号</p> <p class="text-gray-600 dark:text-gray-400">管理平台账号信息</p>
</div> </div>
<div class="flex space-x-3"> <div class="flex space-x-3">
<n-button type="primary" @click="showAddModal = true"> <n-button @click="showCreateModal = true" type="primary">
<template #icon> <template #icon>
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</template> </template>
添加账号 添加账号
</n-button> </n-button>
<n-button @click="refreshData"> <n-button @click="refreshData" type="info">
<template #icon> <template #icon>
<i class="fas fa-refresh"></i> <i class="fas fa-refresh"></i>
</template> </template>
@@ -25,29 +25,21 @@
<!-- 搜索和筛选 --> <!-- 搜索和筛选 -->
<n-card> <n-card>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<n-input <n-input v-model:value="searchQuery" placeholder="搜索账号..." clearable>
v-model:value="searchQuery"
placeholder="搜索账号..."
@keyup.enter="handleSearch"
>
<template #prefix> <template #prefix>
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
</template> </template>
</n-input> </n-input>
<n-select <n-select v-model:value="platform" placeholder="选择平台" :options="platformOptions" clearable
v-model:value="selectedPlatform" @update:value="onPlatformChange" />
placeholder="选择平台"
:options="platformOptions"
clearable
/>
<n-select <n-button type="primary" @click="handleSearch">
v-model:value="selectedStatus" <template #icon>
placeholder="选择状态" <i class="fas fa-search"></i>
:options="statusOptions" </template>
clearable 搜索
/> </n-button>
</div> </div>
</n-card> </n-card>
@@ -56,418 +48,577 @@
<template #header> <template #header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-lg font-semibold">账号列表</span> <span class="text-lg font-semibold">账号列表</span>
<span class="text-sm text-gray-500"> {{ total }} 个账号</span> <div class="text-sm text-gray-500">
{{ filteredCksList.length }} 个账号
</div>
</div> </div>
</template> </template>
<div v-if="loading" class="flex items-center justify-center py-8"> <!-- 加载状态 -->
<n-spin size="large" /> <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>
<div v-else-if="accounts.length === 0" class="text-center py-8"> <!-- 空状态 -->
<i class="fas fa-user-shield text-4xl text-gray-400 mb-4"></i> <div v-else-if="filteredCksList.length === 0" class="flex flex-col items-center justify-center py-12">
<p class="text-gray-500">暂无账号数据</p> <n-empty description="暂无账号">
<template #icon>
<i class="fas fa-user-circle text-4xl text-gray-400"></i>
</template>
<template #extra>
<n-button @click="showCreateModal = true" type="primary">
<template #icon>
<i class="fas fa-plus"></i>
</template>
添加账号
</n-button>
</template>
</n-empty>
</div> </div>
<div v-else class="space-y-4"> <!-- 账号列表 -->
<div <div v-else>
v-for="account in accounts" <n-virtual-list :items="filteredCksList" :item-size="100" style="max-height: 500px">
:key="account.id" <template #default="{ item }">
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" <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 items-center justify-between">
<div class="flex-1"> <!-- 左侧信息 -->
<div class="flex items-center space-x-3 mb-2"> <div class="flex-1 min-w-0">
<div class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900"> <div class="flex items-center space-x-4">
<i class="fas fa-user text-blue-600 dark:text-blue-400"></i> <!-- ID -->
<div class="w-16 text-sm font-medium text-gray-900 dark:text-gray-100">
#{{ item.id }}
</div>
<!-- 平台 -->
<div class="flex items-center space-x-2">
<span v-html="getPlatformIcon(item.pan?.name || '')" class="text-lg"></span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ item.pan?.name || '未知平台' }}
</span>
</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.username || '未知用户'">
{{ item.username || '未知用户' }}
</h3>
</div>
</div>
<!-- 状态和容量信息 -->
<div class="mt-2 flex items-center space-x-4">
<n-tag :type="item.is_valid ? 'success' : 'error'" size="small">
{{ item.is_valid ? '有效' : '无效' }}
</n-tag>
<span class="text-xs text-gray-500">
总空间: {{ formatFileSize(item.space) }}
</span>
<span class="text-xs text-gray-500">
已使用: {{ formatFileSize(Math.max(0, item.used_space || (item.space - item.left_space))) }}
</span>
<span class="text-xs text-gray-500">
剩余: {{ formatFileSize(Math.max(0, item.left_space)) }}
</span>
</div>
<!-- 备注 -->
<div v-if="item.remark" class="mt-1">
<span class="text-xs text-gray-600 dark:text-gray-400 line-clamp-1" :title="item.remark">
备注: {{ item.remark }}
</span>
</div>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
{{ account.username }}
</h3>
<span v-if="account.is_enabled" class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
启用
</span>
<span v-else class="text-xs px-2 py-1 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded">
禁用
</span>
</div>
<div class="flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400 mb-2"> <!-- 右侧操作按钮 -->
<span>平台: {{ getPlatformName(account.pan_id) }}</span> <div class="flex items-center space-x-2 ml-4">
<span>邮箱: {{ account.email || '未设置' }}</span> <n-button size="small" :type="item.is_valid ? 'warning' : 'success'" @click="toggleStatus(item)"
<span>状态: {{ account.status || '正常' }}</span> :title="item.is_valid ? '禁用账号' : '启用账号'">
</div> <template #icon>
<i :class="item.is_valid ? 'fas fa-ban' : 'fas fa-check'"></i>
<div class="flex items-center space-x-4 text-xs text-gray-500"> </template>
<span>创建时间: {{ formatDate(account.created_at) }}</span> </n-button>
<span>更新时间: {{ formatDate(account.updated_at) }}</span> <n-button size="small" type="info" @click="refreshCapacity(item.id)" title="刷新容量">
<span>最后登录: {{ formatDate(account.last_login_at) || '从未登录' }}</span> <template #icon>
<i class="fas fa-sync-alt"></i>
</template>
</n-button>
<n-button size="small" type="primary" @click="editCks(item)" title="编辑账号">
<template #icon>
<i class="fas fa-edit"></i>
</template>
</n-button>
<n-button size="small" type="error" @click="deleteCks(item.id)" title="删除账号">
<template #icon>
<i class="fas fa-trash"></i>
</template>
</n-button>
</div>
</div> </div>
</div> </div>
</template>
<div class="flex space-x-2"> </n-virtual-list>
<n-button size="small" type="primary" @click="editAccount(account)">
<template #icon>
<i class="fas fa-edit"></i>
</template>
编辑
</n-button>
<n-button size="small" type="warning" @click="testAccount(account)">
<template #icon>
<i class="fas fa-vial"></i>
</template>
测试
</n-button>
<n-button size="small" type="error" @click="deleteAccount(account)">
<template #icon>
<i class="fas fa-trash"></i>
</template>
删除
</n-button>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="mt-6">
<n-pagination
v-model:page="currentPage"
v-model:page-size="pageSize"
:item-count="total"
:page-sizes="[10, 20, 50, 100]"
show-size-picker
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div> </div>
</n-card> </n-card>
<!-- 添加/编辑账号模态框 --> <!-- 分页 -->
<n-modal v-model:show="showAddModal" preset="card" title="添加账号" style="width: 600px"> <div class="flex justify-center">
<n-form <n-pagination v-model:page="currentPage" v-model:page-size="itemsPerPage" :item-count="filteredCksList.length"
ref="formRef" :page-sizes="[10, 20, 50, 100]" show-size-picker @update:page="goToPage"
:model="accountForm" @update:page-size="(size) => { itemsPerPage = size; currentPage = 1; }" />
:rules="rules" </div>
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<n-form-item label="用户名" path="username">
<n-input
v-model:value="accountForm.username"
placeholder="请输入用户名"
/>
</n-form-item>
<n-form-item label="平台" path="pan_id">
<n-select
v-model:value="accountForm.pan_id"
placeholder="请选择平台"
:options="platformOptions"
/>
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input
v-model:value="accountForm.email"
placeholder="请输入邮箱"
type="text"
/>
</n-form-item>
<n-form-item label="密码" path="password">
<n-input
v-model:value="accountForm.password"
placeholder="请输入密码"
type="password"
show-password-on="click"
/>
</n-form-item>
<n-form-item label="Cookie" path="cookie">
<n-input
v-model:value="accountForm.cookie"
placeholder="请输入Cookie"
type="textarea"
:rows="3"
/>
</n-form-item>
<n-form-item label="启用状态" path="is_enabled">
<n-switch v-model:value="accountForm.is_enabled" />
</n-form-item>
</div>
<n-form-item label="备注" path="remark">
<n-input
v-model:value="accountForm.remark"
placeholder="请输入备注信息"
type="textarea"
:rows="2"
/>
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end space-x-3">
<n-button @click="closeModal">取消</n-button>
<n-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</n-button>
</div>
</template>
</n-modal>
</div> </div>
<!-- 创建/编辑账号模态框 -->
<n-modal :show="showCreateModal || showEditModal" preset="card" title="账号管理" style="width: 500px"
@update:show="(show) => { if (!show) closeModal() }">
<template #header>
<div class="flex items-center space-x-2">
<i class="fas fa-user-circle text-lg"></i>
<span>{{ showEditModal ? '编辑账号' : '添加账号' }}</span>
</div>
</template>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台类型 <span class="text-red-500">*</span>
</label>
<n-select v-model:value="form.pan_id" placeholder="请选择平台"
:options="platforms.filter(pan => pan.name === 'quark').map(pan => ({ label: pan.remark, value: pan.id }))"
:disabled="showEditModal" required />
<p v-if="showEditModal" class="mt-1 text-xs text-gray-500">编辑时不允许修改平台类型</p>
</div>
<div v-if="showEditModal && editingCks?.username">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">用户名</label>
<n-input :value="editingCks.username" disabled readonly />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cookie <span class="text-red-500">*</span>
</label>
<n-input v-model:value="form.ck" type="textarea" placeholder="请输入Cookie内容系统将自动识别容量" :rows="4" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">备注</label>
<n-input v-model:value="form.remark" placeholder="可选备注信息" />
</div>
<div v-if="showEditModal">
<n-checkbox v-model:checked="form.is_valid">
账号有效
</n-checkbox>
</div>
</div>
<template #footer>
<div class="flex justify-end space-x-3">
<n-button type="tertiary" @click="closeModal">
取消
</n-button>
<n-button type="primary" :loading="submitting" @click="handleSubmit">
{{ showEditModal ? '更新' : '创建' }}
</n-button>
</div>
</template>
</n-modal>
</template> </template>
<script setup lang="ts"> <script setup>
definePageMeta({ definePageMeta({
layout: 'admin' as any layout: 'admin',
ssr: false
}) })
// 使用API const notification = useNotification()
const { useCksApi, usePanApi } = await import('~/composables/useApi') const router = useRouter()
const cksApi = useCksApi() const userStore = useUserStore()
const panApi = usePanApi()
// 响应式数据 const cksList = ref([])
const loading = ref(false) const platforms = ref([])
const accounts = ref<any[]>([]) const showCreateModal = ref(false)
const platforms = ref<any[]>([]) const showEditModal = ref(false)
const total = ref(0) const editingCks = ref(null)
const currentPage = ref(1) const form = ref({
const pageSize = ref(20)
const searchQuery = ref('')
const selectedPlatform = ref('')
const selectedStatus = ref('')
// 模态框相关
const showAddModal = ref(false)
const submitting = ref(false)
const editingAccount = ref<any>(null)
// 表单数据
const accountForm = ref({
username: '',
pan_id: '', pan_id: '',
email: '', ck: '',
password: '', is_valid: true,
cookie: '',
is_enabled: true,
remark: '' remark: ''
}) })
// 表单验证规则 // 搜索和分页逻辑
const rules = { const searchQuery = ref('')
username: { const currentPage = ref(1)
required: true, const itemsPerPage = ref(10)
message: '请输入用户名', const totalPages = ref(1)
trigger: 'blur' const loading = ref(true)
}, const pageLoading = ref(true)
pan_id: { const submitting = ref(false)
required: true, const platform = ref(null)
message: '请选择平台', const dialog = useDialog()
trigger: 'change'
import { useCksApi, usePanApi } from '~/composables/useApi'
const cksApi = useCksApi()
const panApi = usePanApi()
const { data: pansData } = await useAsyncData('pans', () => panApi.getPans())
const pans = computed(() => {
// 统一接口格式后直接为数组
return Array.isArray(pansData.value) ? pansData.value : (pansData.value?.list || [])
})
const platformOptions = computed(() => {
const options = [
{ label: '全部平台', value: null }
]
pans.value.forEach(pan => {
options.push({
label: pan.remark || pan.name || `平台${pan.id}`,
value: pan.id
})
})
return options
})
// 检查认证
const checkAuth = () => {
userStore.initAuth()
if (!userStore.isAuthenticated) {
router.push('/login')
return
} }
} }
const formRef = ref() // 获取账号列表
const fetchCks = async () => {
// 状态选项 loading.value = true
const statusOptions = [
{ label: '正常', value: 'normal' },
{ label: '异常', value: 'error' },
{ label: '禁用', value: 'disabled' }
]
// 计算属性
const platformOptions = computed(() =>
platforms.value.map(platform => ({ label: platform.name, value: platform.id }))
)
// 获取数据
const fetchData = async () => {
try { try {
loading.value = true console.log('开始获取账号列表...')
const params = { const response = await cksApi.getCks()
page: currentPage.value, cksList.value = Array.isArray(response) ? response : []
page_size: pageSize.value, console.log('获取账号列表成功,数据:', cksList.value)
search: searchQuery.value, } catch (error) {
pan_id: selectedPlatform.value, console.error('获取账号列表失败:', error)
status: selectedStatus.value
}
const response = await cksApi.getCks(params) as any
accounts.value = response.data || []
total.value = response.total || 0
} catch (error: any) {
useNotification().error({
content: error.message || '获取账号数据失败',
duration: 5000
})
} finally { } finally {
loading.value = false loading.value = false
pageLoading.value = false
} }
} }
// 获取平台数据 // 获取平台列表
const fetchPlatforms = async () => { const fetchPlatforms = async () => {
try { try {
const response = await panApi.getPans() const response = await panApi.getPans()
if (response && (response as any).items && Array.isArray((response as any).items)) { platforms.value = Array.isArray(response) ? response : []
platforms.value = (response as any).items
} else if (Array.isArray(response)) {
platforms.value = response
} else {
platforms.value = []
}
} catch (error) { } catch (error) {
console.error('获取平台数据失败:', error) console.error('获取平台列表失败:', error)
platforms.value = []
} }
} }
// 初始化数据 // 创建账号
onMounted(async () => { const createCks = async () => {
await Promise.all([ submitting.value = true
fetchData(),
fetchPlatforms()
])
})
// 搜索处理
const handleSearch = () => {
currentPage.value = 1
fetchData()
}
// 分页处理
const handlePageChange = (page: number) => {
currentPage.value = page
fetchData()
}
const handlePageSizeChange = (size: number) => {
pageSize.value = size
currentPage.value = 1
fetchData()
}
// 刷新数据
const refreshData = () => {
fetchData()
}
// 编辑账号
const editAccount = (account: any) => {
editingAccount.value = account
accountForm.value = {
username: account.username,
pan_id: account.pan_id,
email: account.email || '',
password: '',
cookie: account.cookie || '',
is_enabled: account.is_enabled,
remark: account.remark || ''
}
showAddModal.value = true
}
// 测试账号
const testAccount = async (account: any) => {
try { try {
// 这里需要实现测试账号的API调用 await cksApi.createCks(form.value)
console.log('测试账号:', account.id) await fetchCks()
// await cksApi.testCks(account.id)
useNotification().success({
content: '账号测试成功',
duration: 3000
})
} catch (error: any) {
useNotification().error({
content: error.message || '账号测试失败',
duration: 5000
})
}
}
// 删除账号
const deleteAccount = async (account: any) => {
try {
await cksApi.deleteCks(account.id)
useNotification().success({
content: '账号删除成功',
duration: 3000
})
await fetchData()
} catch (error: any) {
useNotification().error({
content: error.message || '删除账号失败',
duration: 5000
})
}
}
// 关闭模态框
const closeModal = () => {
showAddModal.value = false
editingAccount.value = null
accountForm.value = {
username: '',
pan_id: '',
email: '',
password: '',
cookie: '',
is_enabled: true,
remark: ''
}
}
// 提交表单
const handleSubmit = async () => {
try {
submitting.value = true
if (editingAccount.value) {
await cksApi.updateCks(editingAccount.value.id, accountForm.value)
useNotification().success({
content: '账号更新成功',
duration: 3000
})
} else {
await cksApi.createCks(accountForm.value)
useNotification().success({
content: '账号创建成功',
duration: 3000
})
}
closeModal() closeModal()
await fetchData() } catch (error) {
} catch (error: any) { dialog.error({
useNotification().error({ title: '错误',
content: error.message || '保存账号失败', content: '创建账号失败: ' + (error.message || '未知错误'),
duration: 5000 positiveText: '确定'
}) })
} finally { } finally {
submitting.value = false submitting.value = false
} }
} }
// 获取平台名称 // 更新账号
const getPlatformName = (panId: number) => { const updateCks = async () => {
if (!platforms.value || !Array.isArray(platforms.value)) { submitting.value = true
return '未知平台' try {
await cksApi.updateCks(editingCks.value.id, form.value)
await fetchCks()
closeModal()
} catch (error) {
console.error('更新账号失败:', error)
notification.error({
title: '失败',
content: '更新账号失败: ' + (error.message || '未知错误'),
duration: 3000
})
} finally {
submitting.value = false
} }
const platform = platforms.value.find(p => p.id === panId)
return platform?.name || '未知平台'
} }
// 格式化日期 // 删除账号
const formatDate = (dateString: string) => { const deleteCks = async (id) => {
if (!dateString) return '' dialog.warning({
return new Date(dateString).toLocaleString('zh-CN') title: '警告',
content: '确定要删除这个账号吗?',
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
try {
await cksApi.deleteCks(id)
await fetchCks()
} catch (error) {
console.error('删除账号失败:', error)
notification.error({
title: '失败',
content: '删除账号失败: ' + (error.message || '未知错误'),
duration: 3000
})
}
}
})
} }
// 刷新容量
const refreshCapacity = async (id) => {
dialog.warning({
title: '警告',
content: '确定要刷新此账号的容量信息吗?',
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
try {
await cksApi.refreshCapacity(id)
await fetchCks()
notification.success({
title: '成功',
content: '容量信息已刷新!',
duration: 3000
})
} catch (error) {
console.error('刷新容量失败:', error)
notification.error({
title: '失败',
content: '刷新容量失败: ' + (error.message || '未知错误'),
duration: 3000
})
}
}
})
}
// 切换账号状态
const toggleStatus = async (cks) => {
const newStatus = !cks.is_valid
dialog.warning({
title: '警告',
content: `确定要${cks.is_valid ? '禁用' : '启用'}此账号吗?`,
positiveText: '确定',
negativeText: '取消',
draggable: true,
onPositiveClick: async () => {
try {
console.log('切换状态 - 账号ID:', cks.id, '当前状态:', cks.is_valid, '新状态:', newStatus)
await cksApi.updateCks(cks.id, { is_valid: newStatus })
console.log('状态更新成功,正在刷新数据...')
await fetchCks()
console.log('数据刷新完成')
notification.success({
title: '成功',
content: `账号已${newStatus ? '启用' : '禁用'}`,
duration: 3000
})
} catch (error) {
console.error('切换账号状态失败:', error)
notification.error({
title: '失败',
content: `切换账号状态失败: ${error.message || '未知错误'}`,
duration: 3000
})
}
}
})
}
// 编辑账号
const editCks = (cks) => {
editingCks.value = cks
form.value = {
pan_id: cks.pan_id,
ck: cks.ck,
is_valid: cks.is_valid,
remark: cks.remark || ''
}
showEditModal.value = true
}
// 关闭模态框
const closeModal = () => {
showCreateModal.value = false
showEditModal.value = false
editingCks.value = null
form.value = {
pan_id: '',
ck: '',
is_valid: true,
remark: ''
}
}
// 提交表单
const handleSubmit = async () => {
if (showEditModal.value) {
await updateCks()
} else {
await createCks()
}
}
// 获取平台图标
const getPlatformIcon = (platformName) => {
const defaultIcons = {
'unknown': '<i class="fas fa-question-circle text-gray-400"></i>',
'other': '<i class="fas fa-cloud text-gray-500"></i>',
'magnet': '<i class="fas fa-magnet text-red-600"></i>',
'uc': '<i class="fas fa-cloud-download-alt text-purple-600"></i>',
'夸克网盘': '<i class="fas fa-cloud text-blue-600"></i>',
'阿里云盘': '<i class="fas fa-cloud text-orange-600"></i>',
'百度网盘': '<i class="fas fa-cloud text-blue-500"></i>',
'天翼云盘': '<i class="fas fa-cloud text-red-500"></i>',
'OneDrive': '<i class="fas fa-cloud text-blue-700"></i>',
'Google Drive': '<i class="fas fa-cloud text-green-600"></i>'
}
return defaultIcons[platformName] || defaultIcons['unknown']
}
// 格式化文件大小
const formatFileSize = (bytes) => {
if (!bytes || bytes <= 0) return '0 B'
const tb = bytes / (1024 * 1024 * 1024 * 1024)
if (tb >= 1) {
return tb.toFixed(2) + ' TB'
}
const gb = bytes / (1024 * 1024 * 1024)
if (gb >= 1) {
return gb.toFixed(2) + ' GB'
}
const mb = bytes / (1024 * 1024)
if (mb >= 1) {
return mb.toFixed(2) + ' MB'
}
const kb = bytes / 1024
if (kb >= 1) {
return kb.toFixed(2) + ' KB'
}
return bytes + ' B'
}
// 过滤和分页计算
const filteredCksList = computed(() => {
let filtered = cksList.value
console.log('原始账号数量:', filtered.length)
// 平台过滤
if (platform.value !== null && platform.value !== undefined) {
filtered = filtered.filter(cks => cks.pan_id === platform.value)
console.log('平台过滤后数量:', filtered.length, '平台ID:', platform.value)
}
// 搜索过滤
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(cks =>
cks.pan?.name?.toLowerCase().includes(query) ||
cks.remark?.toLowerCase().includes(query)
)
console.log('搜索过滤后数量:', filtered.length, '搜索词:', searchQuery.value)
}
totalPages.value = Math.ceil(filtered.length / itemsPerPage.value)
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filtered.slice(start, end)
})
// 防抖搜索
let searchTimeout = null
const debounceSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => {
currentPage.value = 1
}, 500)
}
// 搜索处理
const handleSearch = () => {
currentPage.value = 1
console.log('执行搜索搜索词:', searchQuery.value)
console.log('当前过滤后的账号数量:', filteredCksList.value.length)
}
// 平台变化处理
const onPlatformChange = () => {
currentPage.value = 1
console.log('平台过滤条件变化:', platform.value)
console.log('当前过滤后的账号数量:', filteredCksList.value.length)
}
// 刷新数据
const refreshData = () => {
currentPage.value = 1
// 保持当前的过滤条件,只刷新数据
fetchCks()
fetchPlatforms()
}
// 分页跳转
const goToPage = (page) => {
currentPage.value = page
}
// 页面加载
onMounted(async () => {
try {
checkAuth()
await Promise.all([
fetchCks(),
fetchPlatforms()
])
} catch (error) {
console.error('页面初始化失败:', error)
}
})
</script> </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>

View File

@@ -205,7 +205,7 @@ const { data: tagsData } = await useAsyncData('categoryTags', () => tagApi.getTa
// 标签选项 // 标签选项
const tagOptions = computed(() => { const tagOptions = computed(() => {
const data = tagsData.value as any const data = tagsData.value as any
const tags = data?.data || data || [] const tags = data?.items || data || []
return tags.map((tag: any) => ({ return tags.map((tag: any) => ({
label: tag.name, label: tag.name,
value: tag.id value: tag.id
@@ -314,8 +314,8 @@ const fetchData = async () => {
search: searchQuery.value search: searchQuery.value
}) as any }) as any
if (response && response.data) { if (response && response.items) {
categories.value = response.data categories.value = response.items
total.value = response.total || 0 total.value = response.total || 0
} else if (Array.isArray(response)) { } else if (Array.isArray(response)) {
categories.value = response categories.value = response

View File

@@ -0,0 +1,631 @@
<template>
<div class="space-y-6">
<!-- 页面标题 -->
<div class="flex items-center justify-between">
<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>
</div>
<!-- 搜索和筛选 -->
<n-card>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<n-select
v-model:value="errorFilter"
placeholder="选择状态"
:options="statusOptions"
clearable
/>
<n-button type="primary" @click="handleSearch">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
</n-card>
<!-- 失败资源列表 -->
<n-card>
<template #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 class="text-sm text-gray-500"> {{ totalCount }} 个资源已选择 {{ selectedResources.length }} </span>
</div>
</div>
</template>
<!-- 加载状态 -->
<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>
<!-- 虚拟列表 -->
<n-virtual-list
v-if="!loading"
:items="failedResources"
:item-size="80"
:item-resizable="true"
style="max-height: 400px"
container-style="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 v-if="!loading && failedResources.length === 0" 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>
<!-- 分页 -->
<div class="mt-6 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>
</n-card>
</div>
</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)
}
})
// 设置页面标题
useHead({
title: '失败资源列表 - 老九网盘资源数据库'
})
</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>

View File

@@ -18,27 +18,7 @@
</div> </div>
</div> </div>
<!-- 自动处理状态显示 -->
<div class="absolute bottom-4 right-4 flex items-center gap-4">
<div class="flex items-center gap-2 bg-white/10 rounded-lg px-3 py-2">
<div class="w-2 h-2 rounded-full animate-pulse" :class="{
'bg-red-400': !systemConfig?.auto_process_ready_resources,
'bg-green-400': systemConfig?.auto_process_ready_resources
}"></div>
<span class="text-xs text-white font-medium">
自动处理已<span>{{ systemConfig?.auto_process_ready_resources ? '开启' : '关闭' }}</span>
</span>
</div>
<div class="flex items-center gap-2 bg-white/10 rounded-lg px-3 py-2">
<div class="w-2 h-2 rounded-full animate-pulse" :class="{
'bg-red-400': !systemConfig?.auto_transfer_enabled,
'bg-green-400': systemConfig?.auto_transfer_enabled
}"></div>
<span class="text-xs text-white font-medium">
自动转存已<span>{{ systemConfig?.auto_transfer_enabled ? '开启' : '关闭' }}</span>
</span>
</div>
</div>
</n-card> </n-card>
<!-- 统计卡片 --> <!-- 统计卡片 -->
@@ -114,7 +94,7 @@
<i class="fas fa-server text-green-600 text-xl"></i> <i class="fas fa-server text-green-600 text-xl"></i>
</div> </div>
<div class="ml-4"> <div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">平台管理</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">平台列表</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">系统支持的网盘平台</p> <p class="text-sm text-gray-600 dark:text-gray-400">系统支持的网盘平台</p>
</div> </div>
</div> </div>
@@ -164,7 +144,7 @@ definePageMeta({
layout: 'admin' layout: 'admin'
}) })
import { useStatsApi, usePanApi, useSystemConfigApi } from '~/composables/useApi' import { useStatsApi, usePanApi } from '~/composables/useApi'
import { useApiFetch } from '~/composables/useApiFetch' import { useApiFetch } from '~/composables/useApiFetch'
import { parseApiResponse } from '~/composables/useApi' import { parseApiResponse } from '~/composables/useApi'
import Chart from 'chart.js/auto' import Chart from 'chart.js/auto'
@@ -172,13 +152,6 @@ import Chart from 'chart.js/auto'
// API // API
const statsApi = useStatsApi() const statsApi = useStatsApi()
const panApi = usePanApi() const panApi = usePanApi()
const systemConfigApi = useSystemConfigApi()
// 获取系统配置
const { data: systemConfigData } = await useAsyncData('systemConfig', () => systemConfigApi.getSystemConfig())
// 系统配置
const systemConfig = computed(() => (systemConfigData.value as any) || {})
// 获取统计数据 // 获取统计数据
const { data: statsData } = await useAsyncData('adminStats', () => statsApi.getStats()) const { data: statsData } = await useAsyncData('adminStats', () => statsApi.getStats())
@@ -187,11 +160,16 @@ const { data: statsData } = await useAsyncData('adminStats', () => statsApi.getS
const { data: pansData } = await useAsyncData('adminPans', () => panApi.getPans()) const { data: pansData } = await useAsyncData('adminPans', () => panApi.getPans())
// 统计数据 // 统计数据
const stats = computed(() => (statsData.value as any) || { const stats = computed(() => {
total_resources: 0, console.log('原始统计数据:', statsData.value)
today_resources: 0, const result = (statsData.value as any) || {
today_views: 0, total_resources: 0,
today_searches: 0 today_resources: 0,
today_views: 0,
today_searches: 0
}
console.log('处理后的统计数据:', result)
return result
}) })
// 平台数据 // 平台数据

View File

@@ -7,12 +7,6 @@
<p class="text-gray-600 dark:text-gray-400">管理待处理的资源</p> <p class="text-gray-600 dark:text-gray-400">管理待处理的资源</p>
</div> </div>
<div class="flex space-x-3"> <div class="flex space-x-3">
<n-button type="primary" @click="navigateTo('/admin/add-resource')">
<template #icon>
<i class="fas fa-plus"></i>
</template>
添加资源
</n-button>
<n-button @click="navigateTo('/admin/failed-resources')" type="error"> <n-button @click="navigateTo('/admin/failed-resources')" type="error">
<template #icon> <template #icon>
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
@@ -274,6 +268,7 @@ const fetchSystemConfig = async () => {
const response = await systemConfigApi.getSystemConfig() const response = await systemConfigApi.getSystemConfig()
systemConfig.value = response systemConfig.value = response
systemConfigStore.setConfig(response) systemConfigStore.setConfig(response)
console.log('ready-resources页面系统配置:', response)
} catch (error) { } catch (error) {
console.error('获取系统配置失败:', error) console.error('获取系统配置失败:', error)
} }

View File

@@ -13,7 +13,7 @@
</template> </template>
添加资源 添加资源
</n-button> </n-button>
<n-button @click="showBatchModal = true" type="info"> <n-button @click="openBatchModal" type="info">
<template #icon> <template #icon>
<i class="fas fa-list"></i> <i class="fas fa-list"></i>
</template> </template>
@@ -68,8 +68,18 @@
<n-card> <n-card>
<template #header> <template #header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-lg font-semibold">资源列表</span> <div class="flex items-center space-x-4">
<span class="text-sm text-gray-500"> {{ total }} 个资源</span> <span class="text-lg font-semibold">资源列表</span>
<div class="flex items-center space-x-2">
<n-checkbox
:checked="isAllSelected"
@update:checked="toggleSelectAll"
:indeterminate="isIndeterminate"
/>
<span class="text-sm text-gray-500">全选</span>
</div>
</div>
<span class="text-sm text-gray-500"> {{ total }} 个资源已选择 {{ selectedResources.length }} </span>
</div> </div>
</template> </template>
@@ -86,7 +96,8 @@
<!-- 虚拟列表 --> <!-- 虚拟列表 -->
<n-virtual-list <n-virtual-list
:items="resources" :items="resources"
:item-size="120" :item-size="100"
style="max-height: 400px"
container-style="height: 600px;" container-style="height: 600px;"
> >
<template #default="{ item: resource }"> <template #default="{ item: resource }">
@@ -100,19 +111,20 @@
@update:checked="(checked) => toggleResourceSelection(resource.id, checked)" @update:checked="(checked) => toggleResourceSelection(resource.id, checked)"
/> />
<span class="text-sm text-gray-500">{{ resource.id }}</span> <span class="text-sm text-gray-500">{{ resource.id }}</span>
<span v-if="resource.pan_id" class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">
<span v-if="resource.pan_id" class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded flex-shrink-0">
{{ getPlatformName(resource.pan_id) }} {{ getPlatformName(resource.pan_id) }}
</span> </span>
<span v-if="resource.category_id" class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded"> <h3 class="text-lg font-medium text-gray-900 dark:text-white flex-1 line-clamp-1">
{{ resource.title }}
</h3>
<span v-if="resource.category_id" class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded flex-shrink-0">
{{ getCategoryName(resource.category_id) }} {{ getCategoryName(resource.category_id) }}
</span> </span>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2"> <p v-if="resource.description" class="text-gray-600 dark:text-gray-400 mb-2 line-clamp-2">
{{ resource.title }}
</h3>
<p v-if="resource.description" class="text-gray-600 dark:text-gray-400 mb-2">
{{ resource.description }} {{ resource.description }}
</p> </p>
@@ -149,12 +161,12 @@
</div> </div>
<div class="flex items-center space-x-2 ml-4"> <div class="flex items-center space-x-2 ml-4">
<n-button size="small" type="primary" @click="editResource(resource)"> <!-- <n-button size="small" type="primary" @click="editResource(resource)">
<template #icon> <template #icon>
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</template> </template>
编辑 编辑
</n-button> </n-button> -->
<n-button size="small" type="error" @click="deleteResource(resource)"> <n-button size="small" type="error" @click="deleteResource(resource)">
<template #icon> <template #icon>
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
@@ -173,7 +185,7 @@
v-model:page="currentPage" v-model:page="currentPage"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:item-count="total" :item-count="total"
:page-sizes="[10, 20, 50, 100]" :page-sizes="[100, 200, 500, 1000]"
show-size-picker show-size-picker
@update:page="handlePageChange" @update:page="handlePageChange"
@update:page-size="handlePageSizeChange" @update:page-size="handlePageSizeChange"
@@ -186,7 +198,12 @@
<n-modal v-model:show="showBatchModal" preset="card" title="批量操作" style="width: 600px"> <n-modal v-model:show="showBatchModal" preset="card" title="批量操作" style="width: 600px">
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span>已选择 {{ selectedResources.length }} 个资源</span> <div>
<span class="font-medium">已选择 {{ selectedResources.length }} 个资源</span>
<p class="text-sm text-gray-500 mt-1">
{{ isAllSelected ? '已全选当前页面' : isIndeterminate ? '部分选中' : '未选择' }}
</p>
</div>
<n-button size="small" @click="clearSelection">清空选择</n-button> <n-button size="small" @click="clearSelection">清空选择</n-button>
</div> </div>
@@ -305,7 +322,7 @@ const resources = ref<Resource[]>([])
const loading = ref(false) const loading = ref(false)
const total = ref(0) const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(200)
const searchQuery = ref('') const searchQuery = ref('')
const selectedCategory = ref(null) const selectedCategory = ref(null)
const selectedPlatform = ref(null) const selectedPlatform = ref(null)
@@ -359,11 +376,15 @@ const { data: platformsData } = await useAsyncData('resourcePlatforms', () => pa
// 分类选项 // 分类选项
const categoryOptions = computed(() => { const categoryOptions = computed(() => {
const data = categoriesData.value as any const data = categoriesData.value as any
const categories = data?.data || data || [] console.log('分类数据:', data)
return categories.map((cat: any) => ({ const categories = data?.items || data || []
console.log('处理后的分类:', categories)
const options = categories.map((cat: any) => ({
label: cat.name, label: cat.name,
value: cat.id value: cat.id
})) }))
console.log('分类选项:', options)
return options
}) })
// 标签选项 // 标签选项
@@ -381,7 +402,7 @@ const platformOptions = computed(() => {
const data = platformsData.value as any const data = platformsData.value as any
const platforms = data?.data || data || [] const platforms = data?.data || data || []
return platforms.map((platform: any) => ({ return platforms.map((platform: any) => ({
label: platform.name, label: platform.remark || platform.name,
value: platform.id value: platform.id
})) }))
}) })
@@ -394,28 +415,50 @@ const getCategoryName = (categoryId: number) => {
// 获取平台名称 // 获取平台名称
const getPlatformName = (platformId: number) => { const getPlatformName = (platformId: number) => {
const platform = (platformsData.value as any)?.data?.find((plat: any) => plat.id === platformId) console.log('platformId', platformId, platformsData.value)
return platform?.name || '未知平台' const platform = (platformsData.value as any)?.find((plat: any) => plat.id === platformId)
return platform?.remark || platform?.name || '未知平台'
} }
// 获取数据 // 获取数据
const fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
const response = await resourceApi.getResources({ const params: any = {
page: currentPage.value, page: currentPage.value,
page_size: pageSize.value, page_size: pageSize.value,
search: searchQuery.value, search: searchQuery.value
category_id: selectedCategory.value, }
pan_id: selectedPlatform.value
}) as any // 添加分类筛选
if (selectedCategory.value) {
params.category_id = selectedCategory.value
console.log('添加分类筛选:', selectedCategory.value)
}
// 添加平台筛选
if (selectedPlatform.value) {
params.pan_id = selectedPlatform.value
console.log('添加平台筛选:', selectedPlatform.value)
}
console.log('请求参数:', params)
console.log('pageSize:', pageSize.value)
console.log('selectedCategory:', selectedCategory.value)
console.log('selectedPlatform:', selectedPlatform.value)
const response = await resourceApi.getResources(params) as any
console.log('API响应:', response)
console.log('返回的资源数量:', response?.data?.length || 0)
if (response && response.data) { if (response && response.data) {
resources.value = response.data resources.value = response.data
total.value = response.total || 0 total.value = response.total || 0
// 清空选择(因为数据已更新)
selectedResources.value = []
} else { } else {
resources.value = [] resources.value = []
total.value = 0 total.value = 0
selectedResources.value = []
} }
} catch (error) { } catch (error) {
console.error('获取资源失败:', error) console.error('获取资源失败:', error)
@@ -461,11 +504,45 @@ const toggleResourceSelection = (resourceId: number, checked: boolean) => {
} }
} }
// 全选状态计算
const isAllSelected = computed(() => {
return resources.value.length > 0 && selectedResources.value.length === resources.value.length
})
// 部分选中状态计算
const isIndeterminate = computed(() => {
return selectedResources.value.length > 0 && selectedResources.value.length < resources.value.length
})
// 切换全选
const toggleSelectAll = (checked: boolean) => {
if (checked) {
// 全选添加所有当前页面的资源ID
selectedResources.value = resources.value.map(resource => resource.id)
} else {
// 取消全选:清空选择
selectedResources.value = []
}
}
// 清空选择 // 清空选择
const clearSelection = () => { const clearSelection = () => {
selectedResources.value = [] selectedResources.value = []
} }
// 打开批量操作模态框
const openBatchModal = () => {
// 如果没有选择任何资源,自动全选当前页面
if (selectedResources.value.length === 0 && resources.value.length > 0) {
selectedResources.value = resources.value.map(resource => resource.id)
notification.info({
content: '已自动全选当前页面资源',
duration: 2000
})
}
showBatchModal.value = true
}
// 编辑资源 // 编辑资源
const editResource = (resource: Resource) => { const editResource = (resource: Resource) => {
editingResource.value = resource editingResource.value = resource
@@ -623,4 +700,17 @@ useHead({
<style scoped> <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> </style>

View File

@@ -206,7 +206,7 @@ const { data: categoriesData } = await useAsyncData('tagCategories', () => categ
const categoryOptions = computed(() => { const categoryOptions = computed(() => {
const data = categoriesData.value as any const data = categoriesData.value as any
const categories = data?.data || data || [] const categories = data?.data || data || []
return categories.map((cat: any) => ({ return categories.items.map((cat: any) => ({
label: cat.name, label: cat.name,
value: cat.id value: cat.id
})) }))
@@ -325,8 +325,8 @@ const fetchData = async () => {
search: searchQuery.value search: searchQuery.value
}) as any }) as any
if (response && response.data) { if (response && response.items) {
tags.value = response.data tags.value = response.items
total.value = response.total || 0 total.value = response.total || 0
} else if (Array.isArray(response)) { } else if (Array.isArray(response)) {
tags.value = response tags.value = response

View File

@@ -10,7 +10,7 @@ export const useSystemConfigStore = defineStore('systemConfig', {
async initConfig(force = false) { async initConfig(force = false) {
if (this.initialized && !force) return if (this.initialized && !force) return
try { try {
const data = await useApiFetch('/public/system-config').then((res: any) => res.data || res) const data = await useApiFetch('/system/config').then((res: any) => res.data || res)
this.config = data this.config = data
this.initialized = true this.initialized = true
} catch (e) { } catch (e) {