update: cache

This commit is contained in:
Kerwin
2025-11-18 18:14:25 +08:00
parent f9a1043431
commit 242e12c29c
11 changed files with 527 additions and 62 deletions

View File

@@ -49,6 +49,7 @@ type ResourceRepository interface {
DeleteRelatedResources(ckID uint) (int64, error) DeleteRelatedResources(ckID uint) (int64, error)
CountResourcesByCkID(ckID uint) (int64, error) CountResourcesByCkID(ckID uint) (int64, error)
FindByKey(key string) ([]entity.Resource, error) FindByKey(key string) ([]entity.Resource, error)
GetHotResources(limit int) ([]entity.Resource, error)
} }
// ResourceRepositoryImpl Resource的Repository实现 // ResourceRepositoryImpl Resource的Repository实现
@@ -355,12 +356,37 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
// 计算偏移量 // 计算偏移量
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
// 获取分页数据,按更新时间倒序 // 处理排序参数
orderBy := "updated_at"
orderDir := "DESC"
if orderByVal, ok := params["order_by"].(string); ok && orderByVal != "" {
// 验证排序字段防止SQL注入
validOrderByFields := map[string]bool{
"created_at": true,
"updated_at": true,
"view_count": true,
"title": true,
"id": true,
}
if validOrderByFields[orderByVal] {
orderBy = orderByVal
}
}
if orderDirVal, ok := params["order_dir"].(string); ok && orderDirVal != "" {
// 验证排序方向
if orderDirVal == "ASC" || orderDirVal == "DESC" {
orderDir = orderDirVal
}
}
// 获取分页数据,应用排序
queryStart := utils.GetCurrentTime() queryStart := utils.GetCurrentTime()
err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error err := db.Order(fmt.Sprintf("%s %s", orderBy, orderDir)).Offset(offset).Limit(pageSize).Find(&resources).Error
queryDuration := time.Since(queryStart) queryDuration := time.Since(queryStart)
totalDuration := time.Since(startTime) totalDuration := time.Since(startTime)
utils.Debug("SearchWithFilters完成: 总数=%d, 当前页数据量=%d, 查询耗时=%v, 总耗时=%v", total, len(resources), queryDuration, totalDuration) utils.Debug("SearchWithFilters完成: 总数=%d, 当前页数据量=%d, 排序=%s %s, 查询耗时=%v, 总耗时=%v", total, len(resources), orderBy, orderDir, queryDuration, totalDuration)
return resources, total, err return resources, total, err
} }
@@ -724,3 +750,41 @@ func (r *ResourceRepositoryImpl) FindByKey(key string) ([]entity.Resource, error
Find(&resources).Error Find(&resources).Error
return resources, err return resources, err
} }
// GetHotResources 获取热门资源(按查看次数排序,去重,限制数量)
func (r *ResourceRepositoryImpl) GetHotResources(limit int) ([]entity.Resource, error) {
var resources []entity.Resource
// 按key分组获取每个key中查看次数最高的资源然后按查看次数排序
err := r.db.Table("resources").
Select(`
resources.*,
ROW_NUMBER() OVER (PARTITION BY key ORDER BY view_count DESC) as rn
`).
Where("is_public = ? AND view_count > 0", true).
Preload("Category").
Preload("Pan").
Preload("Tags").
Order("view_count DESC").
Limit(limit * 2). // 获取更多数据以确保去重后有足够的结果
Find(&resources).Error
if err != nil {
return nil, err
}
// 按key去重保留每个key的第一个即查看次数最高的
seenKeys := make(map[string]bool)
var hotResources []entity.Resource
for _, resource := range resources {
if !seenKeys[resource.Key] {
seenKeys[resource.Key] = true
hotResources = append(hotResources, resource)
if len(hotResources) >= limit {
break
}
}
}
return hotResources, nil
}

View File

@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
pan "github.com/ctwj/urldb/common" pan "github.com/ctwj/urldb/common"
commonutils "github.com/ctwj/urldb/common/utils" commonutils "github.com/ctwj/urldb/common/utils"
@@ -902,6 +903,118 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
} }
} }
// GetHotResources 获取热门资源
func GetHotResources(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
utils.Info("获取热门资源请求 - limit: %d", limit)
// 限制最大请求数量
if limit > 20 {
limit = 20
}
if limit <= 0 {
limit = 10
}
// 使用公共缓存机制
cacheKey := fmt.Sprintf("hot_resources_%d", limit)
ttl := time.Hour // 1小时缓存
cacheManager := utils.GetHotResourcesCache()
// 尝试从缓存获取
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
utils.Info("使用热门资源缓存 - key: %s", cacheKey)
c.Header("Cache-Control", "public, max-age=3600")
c.Header("ETag", fmt.Sprintf("hot-resources-%d", len(cachedData.([]gin.H))))
// 转换为正确的类型
if data, ok := cachedData.([]gin.H); ok {
SuccessResponse(c, gin.H{
"data": data,
"total": len(data),
"limit": limit,
"cached": true,
})
}
return
}
// 缓存未命中,从数据库获取
resources, err := repoManager.ResourceRepository.GetHotResources(limit)
if err != nil {
utils.Error("获取热门资源失败: %v", err)
ErrorResponse(c, "获取热门资源失败", http.StatusInternalServerError)
return
}
// 获取违禁词配置
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
})
if err != nil {
utils.Error("获取违禁词配置失败: %v", err)
cleanWords = []string{}
}
// 处理违禁词并转换为响应格式
var resourceResponses []gin.H
for _, resource := range resources {
// 检查违禁词
forbiddenInfo := utils.CheckResourceForbiddenWords(resource.Title, resource.Description, cleanWords)
resourceResponse := gin.H{
"id": resource.ID,
"key": resource.Key,
"title": forbiddenInfo.ProcessedTitle,
"url": resource.URL,
"description": forbiddenInfo.ProcessedDesc,
"pan_id": resource.PanID,
"view_count": resource.ViewCount,
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"),
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"),
"cover": resource.Cover,
"author": resource.Author,
"file_size": resource.FileSize,
}
// 添加违禁词标记
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
// 添加标签信息
var tagResponses []gin.H
if len(resource.Tags) > 0 {
for _, tag := range resource.Tags {
tagResponse := gin.H{
"id": tag.ID,
"name": tag.Name,
"description": tag.Description,
}
tagResponses = append(tagResponses, tagResponse)
}
}
resourceResponse["tags"] = tagResponses
resourceResponses = append(resourceResponses, resourceResponse)
}
// 存储到缓存
cacheManager.Set(cacheKey, resourceResponses)
utils.Info("热门资源已缓存 - key: %s, count: %d", cacheKey, len(resourceResponses))
// 设置缓存头
c.Header("Cache-Control", "public, max-age=3600")
c.Header("ETag", fmt.Sprintf("hot-resources-%d", len(resourceResponses)))
SuccessResponse(c, gin.H{
"data": resourceResponses,
"total": len(resourceResponses),
"limit": limit,
"cached": false,
})
}
// GetRelatedResources 获取相关资源 // GetRelatedResources 获取相关资源
func GetRelatedResources(c *gin.Context) { func GetRelatedResources(c *gin.Context) {
// 获取查询参数 // 获取查询参数

View File

@@ -233,6 +233,7 @@ func main() {
// 资源管理 // 资源管理
api.GET("/resources", handlers.GetResources) api.GET("/resources", handlers.GetResources)
api.GET("/resources/hot", handlers.GetHotResources)
api.POST("/resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateResource) api.POST("/resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateResource)
api.PUT("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateResource) api.PUT("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateResource)
api.DELETE("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteResource) api.DELETE("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteResource)

View File

@@ -0,0 +1,96 @@
package scheduler
import (
"time"
"github.com/ctwj/urldb/utils"
)
// CacheCleaner 缓存清理调度器
type CacheCleaner struct {
baseScheduler *BaseScheduler
running bool
ticker *time.Ticker
stopChan chan bool
}
// NewCacheCleaner 创建缓存清理调度器
func NewCacheCleaner(baseScheduler *BaseScheduler) *CacheCleaner {
return &CacheCleaner{
baseScheduler: baseScheduler,
running: false,
ticker: time.NewTicker(time.Hour), // 每小时执行一次
stopChan: make(chan bool),
}
}
// Start 启动缓存清理任务
func (cc *CacheCleaner) Start() {
if cc.running {
utils.Warn("缓存清理任务已在运行中")
return
}
cc.running = true
utils.Info("启动缓存清理任务")
go func() {
for {
select {
case <-cc.ticker.C:
cc.cleanCache()
case <-cc.stopChan:
cc.running = false
utils.Info("缓存清理任务已停止")
return
}
}
}()
}
// Stop 停止缓存清理任务
func (cc *CacheCleaner) Stop() {
if !cc.running {
return
}
close(cc.stopChan)
cc.ticker.Stop()
}
// cleanCache 执行缓存清理
func (cc *CacheCleaner) cleanCache() {
utils.Debug("开始清理过期缓存")
// 清理过期缓存1小时TTL
utils.CleanAllExpiredCaches(time.Hour)
utils.Debug("定期清理过期缓存完成")
// 可以在这里添加其他缓存清理逻辑,比如:
// - 清理特定模式的缓存
// - 记录缓存统计信息
cc.logCacheStats()
}
// logCacheStats 记录缓存统计信息
func (cc *CacheCleaner) logCacheStats() {
hotCacheSize := utils.GetHotResourcesCache().Size()
relatedCacheSize := utils.GetRelatedResourcesCache().Size()
systemConfigSize := utils.GetSystemConfigCache().Size()
categoriesSize := utils.GetCategoriesCache().Size()
tagsSize := utils.GetTagsCache().Size()
totalSize := hotCacheSize + relatedCacheSize + systemConfigSize + categoriesSize + tagsSize
utils.Debug("缓存统计 - 热门资源: %d, 相关资源: %d, 系统配置: %d, 分类: %d, 标签: %d, 总计: %d",
hotCacheSize, relatedCacheSize, systemConfigSize, categoriesSize, tagsSize, totalSize)
// 如果缓存过多,可以记录警告
if totalSize > 1000 {
utils.Warn("缓存项数量过多: %d建议检查缓存策略", totalSize)
}
}
// IsRunning 检查是否正在运行
func (cc *CacheCleaner) IsRunning() bool {
return cc.running
}

194
utils/cache.go Normal file
View File

@@ -0,0 +1,194 @@
package utils
import (
"sync"
"time"
)
// CacheData 缓存数据结构
type CacheData struct {
Data interface{}
UpdatedAt time.Time
}
// CacheManager 通用缓存管理器
type CacheManager struct {
cache map[string]*CacheData
mutex sync.RWMutex
}
// NewCacheManager 创建缓存管理器
func NewCacheManager() *CacheManager {
return &CacheManager{
cache: make(map[string]*CacheData),
}
}
// Set 设置缓存
func (cm *CacheManager) Set(key string, data interface{}) {
cm.mutex.Lock()
defer cm.mutex.Unlock()
cm.cache[key] = &CacheData{
Data: data,
UpdatedAt: time.Now(),
}
}
// Get 获取缓存
func (cm *CacheManager) Get(key string, ttl time.Duration) (interface{}, bool) {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
if cachedData, exists := cm.cache[key]; exists {
if time.Since(cachedData.UpdatedAt) < ttl {
return cachedData.Data, true
}
// 缓存过期,删除
delete(cm.cache, key)
}
return nil, false
}
// GetWithTTL 获取缓存并返回剩余TTL
func (cm *CacheManager) GetWithTTL(key string, ttl time.Duration) (interface{}, bool, time.Duration) {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
if cachedData, exists := cm.cache[key]; exists {
elapsed := time.Since(cachedData.UpdatedAt)
if elapsed < ttl {
return cachedData.Data, true, ttl - elapsed
}
// 缓存过期,删除
delete(cm.cache, key)
}
return nil, false, 0
}
// Delete 删除缓存
func (cm *CacheManager) Delete(key string) {
cm.mutex.Lock()
defer cm.mutex.Unlock()
delete(cm.cache, key)
}
// DeletePattern 删除匹配模式的缓存
func (cm *CacheManager) DeletePattern(pattern string) {
cm.mutex.Lock()
defer cm.mutex.Unlock()
for key := range cm.cache {
// 简单的字符串匹配,可以根据需要扩展为正则表达式
if len(pattern) > 0 && (key == pattern || (len(key) >= len(pattern) && key[:len(pattern)] == pattern)) {
delete(cm.cache, key)
}
}
}
// Clear 清空所有缓存
func (cm *CacheManager) Clear() {
cm.mutex.Lock()
defer cm.mutex.Unlock()
cm.cache = make(map[string]*CacheData)
}
// Size 获取缓存项数量
func (cm *CacheManager) Size() int {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
return len(cm.cache)
}
// CleanExpired 清理过期缓存
func (cm *CacheManager) CleanExpired(ttl time.Duration) int {
cm.mutex.Lock()
defer cm.mutex.Unlock()
cleaned := 0
now := time.Now()
for key, cachedData := range cm.cache {
if now.Sub(cachedData.UpdatedAt) >= ttl {
delete(cm.cache, key)
cleaned++
}
}
return cleaned
}
// GetKeys 获取所有缓存键
func (cm *CacheManager) GetKeys() []string {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
keys := make([]string, 0, len(cm.cache))
for key := range cm.cache {
keys = append(keys, key)
}
return keys
}
// 全局缓存管理器实例
var (
// 热门资源缓存
HotResourcesCache = NewCacheManager()
// 相关资源缓存
RelatedResourcesCache = NewCacheManager()
// 系统配置缓存
SystemConfigCache = NewCacheManager()
// 分类缓存
CategoriesCache = NewCacheManager()
// 标签缓存
TagsCache = NewCacheManager()
)
// GetHotResourcesCache 获取热门资源缓存管理器
func GetHotResourcesCache() *CacheManager {
return HotResourcesCache
}
// GetRelatedResourcesCache 获取相关资源缓存管理器
func GetRelatedResourcesCache() *CacheManager {
return RelatedResourcesCache
}
// GetSystemConfigCache 获取系统配置缓存管理器
func GetSystemConfigCache() *CacheManager {
return SystemConfigCache
}
// GetCategoriesCache 获取分类缓存管理器
func GetCategoriesCache() *CacheManager {
return CategoriesCache
}
// GetTagsCache 获取标签缓存管理器
func GetTagsCache() *CacheManager {
return TagsCache
}
// ClearAllCaches 清空所有全局缓存
func ClearAllCaches() {
HotResourcesCache.Clear()
RelatedResourcesCache.Clear()
SystemConfigCache.Clear()
CategoriesCache.Clear()
TagsCache.Clear()
}
// CleanAllExpiredCaches 清理所有过期缓存
func CleanAllExpiredCaches(ttl time.Duration) {
totalCleaned := 0
totalCleaned += HotResourcesCache.CleanExpired(ttl)
totalCleaned += RelatedResourcesCache.CleanExpired(ttl)
totalCleaned += SystemConfigCache.CleanExpired(ttl)
totalCleaned += CategoriesCache.CleanExpired(ttl)
totalCleaned += TagsCache.CleanExpired(ttl)
if totalCleaned > 0 {
Info("清理过期缓存完成,共清理 %d 个缓存项", totalCleaned)
}
}

View File

@@ -47,6 +47,7 @@ export const parseApiResponse = <T>(response: any): T => {
export const useResourceApi = () => { export const useResourceApi = () => {
const getResources = (params?: any) => useApiFetch('/resources', { params }).then(parseApiResponse) const getResources = (params?: any) => useApiFetch('/resources', { params }).then(parseApiResponse)
const getHotResources = (params?: any) => useApiFetch('/resources/hot', { params }).then(parseApiResponse)
const getResource = (id: number) => useApiFetch(`/resources/${id}`).then(parseApiResponse) const getResource = (id: number) => useApiFetch(`/resources/${id}`).then(parseApiResponse)
const getResourcesByKey = (key: string) => useApiFetch(`/resources/key/${key}`).then(parseApiResponse) const getResourcesByKey = (key: string) => useApiFetch(`/resources/key/${key}`).then(parseApiResponse)
const createResource = (data: any) => useApiFetch('/resources', { method: 'POST', body: data }).then(parseApiResponse) const createResource = (data: any) => useApiFetch('/resources', { method: 'POST', body: data }).then(parseApiResponse)
@@ -62,7 +63,7 @@ export const useResourceApi = () => {
const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).then(parseApiResponse) const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).then(parseApiResponse)
// 新增:获取相关资源 // 新增:获取相关资源
const getRelatedResources = (params?: any) => useApiFetch('/resources/related', { params }).then(parseApiResponse) const getRelatedResources = (params?: any) => useApiFetch('/resources/related', { params }).then(parseApiResponse)
return { getResources, getResource, getResourcesByKey, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink, getRelatedResources } return { getResources, getHotResources, getResource, getResourcesByKey, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink, getRelatedResources }
} }
export const useAuthApi = () => { export const useAuthApi = () => {

View File

@@ -127,9 +127,12 @@ export const useSeo = () => {
dynamicKeywords = `${searchKeyword},${meta.keywords}` dynamicKeywords = `${searchKeyword},${meta.keywords}`
} }
// 生成动态OG图片URL // 生成动态OG图片URL支持自定义OG图片
const theme = searchKeyword ? 'blue' : platformId ? 'green' : 'default' let ogImageUrl = customMeta?.ogImage
const ogImageUrl = generateOgImageUrl(title, dynamicDescription, theme) if (!ogImageUrl) {
const theme = searchKeyword ? 'blue' : platformId ? 'green' : 'default'
ogImageUrl = generateOgImageUrl(title, dynamicDescription, theme)
}
return { return {
title, title,

View File

@@ -66,6 +66,7 @@ export default defineNuxtConfig({
{ name: 'theme-color', content: '#3b82f6' }, { name: 'theme-color', content: '#3b82f6' },
{ property: 'og:site_name', content: '老九网盘资源数据库' }, { property: 'og:site_name', content: '老九网盘资源数据库' },
{ property: 'og:type', content: 'website' }, { property: 'og:type', content: 'website' },
{ property: 'og:image', content: '/assets/images/og.webp' },
{ name: 'twitter:card', content: 'summary_large_image' } { name: 'twitter:card', content: 'summary_large_image' }
], ],
link: [ link: [

View File

@@ -422,7 +422,8 @@ const updatePageSeo = () => {
// 使用动态计算的标题,而不是默认的"首页" // 使用动态计算的标题,而不是默认的"首页"
setPageSeo(pageTitle.value, { setPageSeo(pageTitle.value, {
description: pageDescription.value, description: pageDescription.value,
keywords: pageKeywords.value keywords: pageKeywords.value,
ogImage: '/assets/images/og.webp' // 使用默认的OG图片
}) })
// 设置HTML属性和canonical链接 // 设置HTML属性和canonical链接
@@ -444,6 +445,12 @@ const updatePageSeo = () => {
href: canonicalUrl href: canonicalUrl
} }
], ],
meta: [
{
property: 'og:image',
content: '/assets/images/og.webp'
}
],
script: [ script: [
{ {
type: 'application/ld+json', type: 'application/ld+json',
@@ -452,7 +459,8 @@ const updatePageSeo = () => {
"@type": "WebSite", "@type": "WebSite",
"name": (seoSystemConfig.value && seoSystemConfig.value.site_title) || '老九网盘资源数据库', "name": (seoSystemConfig.value && seoSystemConfig.value.site_title) || '老九网盘资源数据库',
"description": pageDescription.value, "description": pageDescription.value,
"url": canonicalUrl "url": canonicalUrl,
"image": '/assets/images/og.webp'
}) })
} }
] ]

View File

@@ -234,11 +234,12 @@
</div> </div>
<div v-else-if="displayRelatedResources.length > 0" class="space-y-3"> <div v-else-if="displayRelatedResources.length > 0" class="space-y-3">
<div <a
v-for="(resource, index) in displayRelatedResources" v-for="(resource, index) in displayRelatedResources"
:key="resource.id" :key="resource.id"
class="group cursor-pointer" :href="`/r/${resource.key}`"
@click="navigateToResource(resource.key)" class="group block cursor-pointer"
@click.prevent="navigateToResource(resource.key)"
> >
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors"> <div class="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<!-- 序号 --> <!-- 序号 -->
@@ -263,7 +264,7 @@
</p> </p>
</div> </div>
</div> </div>
</div> </a>
</div> </div>
<div v-else class="text-center py-8 text-gray-500 dark:text-gray-400"> <div v-else class="text-center py-8 text-gray-500 dark:text-gray-400">
@@ -282,73 +283,58 @@
</h3> </h3>
<!-- 热门资源列表 --> <!-- 热门资源列表 -->
<div v-if="hotResourcesLoading" class="space-y-4"> <div v-if="hotResourcesLoading" class="space-y-3">
<div v-for="i in 5" :key="i" class="animate-pulse"> <div v-for="i in 10" :key="i" class="animate-pulse">
<div class="flex gap-3"> <div class="flex items-center gap-3 p-2 rounded-lg">
<div class="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded-lg"></div> <div class="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded-full flex-shrink-0"></div>
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div> <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div> <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-else-if="hotResources.length > 0" class="space-y-4"> <div v-else-if="hotResources.length > 0" class="space-y-3">
<div <a
v-for="(resource, index) in hotResources" v-for="(resource, index) in hotResources"
:key="resource.id" :key="resource.id"
class="group cursor-pointer" :href="`/r/${resource.key}`"
@click="navigateToResource(resource.key)" class="group block cursor-pointer"
@click.prevent="navigateToResource(resource.key)"
> >
<div class="flex gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors"> <div class="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<!-- 排名标识 --> <!-- 排名标识 -->
<div class="flex-shrink-0 flex items-center justify-center"> <div class="flex-shrink-0 flex items-center justify-center">
<div <div
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white" class="w-5 h-5 rounded-full flex items-center justify-center text-xs font-medium"
:class="index < 3 :class="index < 3
? 'bg-gradient-to-br from-red-500 to-orange-500 shadow-lg' ? 'bg-red-500 text-white'
: 'bg-gray-400'" : 'bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300'"
> >
{{ index + 1 }} {{ index + 1 }}
</div> </div>
</div> </div>
<!-- 封面图 -->
<div class="flex-shrink-0">
<img
:src="getResourceImageUrl(resource)"
:alt="resource.title"
class="w-16 h-20 object-cover rounded-lg shadow-sm group-hover:shadow-md transition-shadow"
@error="handleResourceImageError"
/>
</div>
<!-- 资源信息 --> <!-- 资源信息 -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2"> <div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 line-clamp-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors flex-1"> <h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 line-clamp-1 group-hover:text-red-600 dark:group-hover:text-red-400 transition-colors flex-1">
{{ resource.title }} {{ resource.title }}
</h4> </h4>
<div v-if="index < 3" class="flex-shrink-0"> <!-- 排名皇冠 -->
<i class="fas fa-crown text-yellow-500 text-xs"></i> <div class="flex items-center gap-1 flex-shrink-0 ml-2">
<i v-if="index === 0" class="fas fa-crown text-yellow-500 text-xs" title="第一名"></i>
<i v-else-if="index === 1" class="fas fa-crown text-gray-400 text-xs" title="第二名"></i>
<i v-else-if="index === 2" class="fas fa-crown text-orange-600 text-xs" title="第三名"></i>
</div> </div>
</div> </div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2"> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-1">
{{ resource.description }} {{ resource.description }}
</p> </p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-gray-400">
<i class="fas fa-eye"></i> {{ resource.view_count || 0 }}
</span>
<span class="text-xs text-gray-400">
<i class="fas fa-heart text-red-400"></i> {{ resource.like_count || 0 }}
</span>
</div>
</div> </div>
</div> </div>
</div> </a>
</div> </div>
<div v-else class="text-center py-8 text-gray-500 dark:text-gray-400"> <div v-else class="text-center py-8 text-gray-500 dark:text-gray-400">
@@ -774,24 +760,22 @@ const fetchHotResources = async () => {
try { try {
hotResourcesLoading.value = true hotResourcesLoading.value = true
// 获取按浏览量排序的热门资源 // 使用专门的热门资源API保持10个热门资源
const params = { const params = {
limit: 8, limit: 10
is_public: true,
order_by: 'view_count',
order_dir: 'desc'
} }
const response = await resourceApi.getResources(params) as any const response = await resourceApi.getHotResources(params) as any
// 处理响应数据 // 处理响应数据
const resources = Array.isArray(response) ? response : (response?.items || []) const resources = Array.isArray(response?.data) ? response.data : []
// 为每个资源添加模拟的点赞数如果API没有提供 hotResources.value = resources.slice(0, 10)
hotResources.value = resources.slice(0, 8).map((resource: any) => ({
...resource, console.log('获取热门资源成功:', {
like_count: Math.floor(Math.random() * 1000) + 100 // 模拟点赞数 count: hotResources.value.length,
})) params: params
})
} catch (error) { } catch (error) {
console.error('获取热门资源失败:', error) console.error('获取热门资源失败:', error)

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB