2 Commits

Author SHA1 Message Date
Kerwin
57f7bab443 add share 2025-11-18 23:51:49 +08:00
Kerwin
242e12c29c update: cache 2025-11-18 18:14:25 +08:00
14 changed files with 849 additions and 69 deletions

View File

@@ -49,6 +49,7 @@ type ResourceRepository interface {
DeleteRelatedResources(ckID uint) (int64, error)
CountResourcesByCkID(ckID uint) (int64, error)
FindByKey(key string) ([]entity.Resource, error)
GetHotResources(limit int) ([]entity.Resource, error)
}
// ResourceRepositoryImpl Resource的Repository实现
@@ -355,12 +356,37 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
// 计算偏移量
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()
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)
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
}
@@ -724,3 +750,41 @@ func (r *ResourceRepositoryImpl) FindByKey(key string) ([]entity.Resource, error
Find(&resources).Error
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"
"strconv"
"strings"
"time"
pan "github.com/ctwj/urldb/common"
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 获取相关资源
func GetRelatedResources(c *gin.Context) {
// 获取查询参数

View File

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

@@ -0,0 +1,247 @@
<template>
<div class="share-container">
<!-- 直接显示分享按钮 -->
<div
ref="socialShareElement"
class="social-share-wrapper"
></div>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
url: {
type: String,
default: ''
},
tags: {
type: Array,
default: () => []
}
})
const route = useRoute()
// 响应式数据
const socialShareElement = ref(null)
// 计算属性 - 避免在SSR中访问客户端API
const shareTitle = computed(() => {
return props.title || '精彩资源分享'
})
const shareDescription = computed(() => {
return props.description || '发现更多优质资源尽在urlDB'
})
const shareTags = computed(() => {
return props.tags?.slice(0, 3).join(',') || '资源分享,网盘,urldb'
})
// 获取完整URL - 仅在客户端调用
const getFullUrl = () => {
if (props.url) return props.url
if (typeof window !== 'undefined') {
return `${window.location.origin}${route.fullPath}`
}
return route.fullPath
}
// 初始化 social-share - 仅在客户端调用
const initSocialShare = () => {
if (typeof window === 'undefined') return
if (socialShareElement.value) {
// 清空容器
socialShareElement.value.innerHTML = ''
// 创建 social-share 元素
const shareElement = document.createElement('div')
shareElement.className = 'social-share'
shareElement.setAttribute('data-sites', 'weibo,qq,wechat,qzone,twitter,telegram')
shareElement.setAttribute('data-title', shareTitle.value)
shareElement.setAttribute('data-description', shareDescription.value)
shareElement.setAttribute('data-url', getFullUrl())
shareElement.setAttribute('data-twitter', shareTags.value)
shareElement.setAttribute('data-wechat-qrcode-title', '微信扫一扫:分享')
shareElement.setAttribute('data-wechat-qrcode-helper', '<p>微信里点"发现",扫一下</p><p>二维码便可将本文分享至朋友圈。</p>')
socialShareElement.value.appendChild(shareElement)
// 初始化 social-share - 等待一段时间确保库已完全加载
setTimeout(() => {
console.log('检查 SocialShare 对象:', window.SocialShare)
console.log('检查 social-share 元素:', shareElement)
// 尝试多种初始化方式
if (window.SocialShare) {
if (typeof window.SocialShare.init === 'function') {
window.SocialShare.init()
console.log('SocialShare.init() 调用成功')
} else if (typeof window.SocialShare === 'function') {
window.SocialShare()
console.log('SocialShare() 函数调用成功')
} else {
console.log('SocialShare 对象存在但不是函数:', typeof window.SocialShare)
// 尝试手动初始化
try {
const socialShareElements = document.querySelectorAll('.social-share')
console.log('找到 social-share 元素:', socialShareElements.length)
if (socialShareElements.length > 0) {
// 检查是否已经生成了分享按钮
const generatedButtons = socialShareElements[0].querySelectorAll('.social-share-icon')
console.log('已生成的分享按钮:', generatedButtons.length)
}
} catch (e) {
console.error('手动检查失败:', e)
}
}
} else if (window.socialShare) {
// 尝试使用 socialShare 变量
console.log('找到 socialShare 全局变量,尝试初始化')
console.log('socialShare 对象类型:', typeof window.socialShare)
console.log('socialShare 对象内容:', window.socialShare)
if (typeof window.socialShare.init === 'function') {
try {
window.socialShare.init()
console.log('socialShare.init() 调用成功')
} catch (error) {
console.error('socialShare.init() 调用失败:', error)
}
} else if (typeof window.socialShare === 'function') {
try {
// social-share.js 需要传入选择器作为参数
window.socialShare('.social-share')
console.log('socialShare() 函数调用成功')
} catch (error) {
console.error('socialShare() 调用失败:', error)
// 尝试不带参数调用
try {
window.socialShare()
console.log('socialShare() 无参数调用成功')
} catch (error2) {
console.error('socialShare() 无参数调用也失败:', error2)
}
}
} else {
console.log('socialShare 对象存在但不是函数:', typeof window.socialShare)
console.log('socialShare 对象的属性:', Object.keys(window.socialShare || {}))
}
} else {
console.error('SocialShare 对象不存在,检查库是否正确加载')
// 检查是否有其他全局变量
console.log('可用全局变量:', Object.keys(window).filter(key => key.toLowerCase().includes('social')))
}
}, 500)
}
}
// 动态加载 social-share.js 和 CSS - 仅在客户端调用
const loadSocialShare = () => {
if (typeof window === 'undefined') return
// 加载 CSS 文件
if (!document.querySelector('link[href*="social-share.min.css"]')) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://cdn.jsdelivr.net/npm/social-share.js@1.0.16/dist/css/share.min.css'
link.onload = () => {
console.log('social-share.css 加载完成')
}
link.onerror = () => {
console.error('social-share.css 加载失败')
}
document.head.appendChild(link)
}
if (!window.SocialShare) {
console.log('开始加载 social-share.js...')
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/social-share.js@1.0.16/dist/js/social-share.min.js'
script.onload = () => {
console.log('social-share.js 加载完成,检查全局对象:', window.SocialShare)
// 加载完成后初始化
nextTick(() => {
setTimeout(() => {
initSocialShare()
}, 200) // 增加等待时间确保CSS和JS都完全加载
})
}
script.onerror = () => {
console.error('social-share.js 加载失败')
}
document.head.appendChild(script)
} else {
// 如果已经加载过,直接初始化
console.log('SocialShare 已存在,直接初始化')
initSocialShare()
}
}
// 组件挂载时直接初始化 - 仅在客户端执行
onMounted(() => {
if (typeof window !== 'undefined') {
// 页面加载完成后直接初始化 social-share
nextTick(() => {
loadSocialShare()
})
}
})
</script>
<style scoped>
.share-container {
position: relative;
display: inline-block;
}
/* social-share.js 样式适配 */
.social-share-wrapper {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
/* social-share.js 默认样式覆盖 */
.social-share-wrapper .social-share {
display: flex !important;
flex-wrap: wrap;
gap: 6px;
}
.social-share-wrapper .social-share-icon {
width: 28px !important;
height: 28px !important;
margin: 0 !important;
border-radius: 4px;
transition: all 0.2s ease;
}
.social-share-wrapper .social-share-icon:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* 暗色模式下的 social-share 图标 */
.dark .social-share-wrapper .social-share-icon {
filter: brightness(0.9);
}
/* 响应式设计 */
@media (max-width: 640px) {
.social-share-wrapper .social-share-icon {
width: 26px !important;
height: 26px !important;
}
}
</style>

View File

@@ -47,6 +47,7 @@ export const parseApiResponse = <T>(response: any): T => {
export const useResourceApi = () => {
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 getResourcesByKey = (key: string) => useApiFetch(`/resources/key/${key}`).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 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 = () => {

View File

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

View File

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

View File

@@ -29,13 +29,15 @@
"@nuxtjs/tailwindcss": "^6.8.0",
"@pinia/nuxt": "^0.5.0",
"@vicons/ionicons5": "^0.12.0",
"@vueuse/core": "^14.0.0",
"chart.js": "^4.5.0",
"naive-ui": "^2.42.0",
"pinia": "^2.1.0",
"qr-code-styling": "^1.9.2",
"vfonts": "^0.0.3",
"vue": "^3.3.0",
"vue-router": "^4.2.0"
"vue-router": "^4.2.0",
"vue-social-share": "^0.0.3"
},
"packageManager": "pnpm@9.13.0+sha512.beb9e2a803db336c10c9af682b58ad7181ca0fbd0d4119f2b33d5f2582e96d6c0d93c85b23869295b765170fbdaa92890c0da6ada457415039769edf3c959efe"
}

View File

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

View File

@@ -127,10 +127,20 @@
<!-- 网盘资源链接列表 -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<i class="fas fa-cloud-download-alt text-blue-500"></i>
网盘资源 ({{ resourcesData?.resources?.length || 0 }})
</h3>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<i class="fas fa-cloud-download-alt text-blue-500"></i>
网盘资源 ({{ resourcesData?.resources?.length || 0 }})
</h3>
<!-- 分享按钮 -->
<ShareButtons
:title="mainResource?.title"
:description="mainResource?.description"
:url="`/r/${mainResource?.key}`"
:tags="mainResource?.tags?.map(tag => tag.name)"
/>
</div>
<div class="space-y-3">
<div
@@ -234,11 +244,12 @@
</div>
<div v-else-if="displayRelatedResources.length > 0" class="space-y-3">
<div
<a
v-for="(resource, index) in displayRelatedResources"
:key="resource.id"
class="group cursor-pointer"
@click="navigateToResource(resource.key)"
:href="`/r/${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">
<!-- 序号 -->
@@ -263,7 +274,7 @@
</p>
</div>
</div>
</div>
</a>
</div>
<div v-else class="text-center py-8 text-gray-500 dark:text-gray-400">
@@ -282,73 +293,58 @@
</h3>
<!-- 热门资源列表 -->
<div v-if="hotResourcesLoading" class="space-y-4">
<div v-for="i in 5" :key="i" class="animate-pulse">
<div class="flex gap-3">
<div class="w-16 h-20 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
<div v-if="hotResourcesLoading" class="space-y-3">
<div v-for="i in 10" :key="i" class="animate-pulse">
<div class="flex items-center gap-3 p-2 rounded-lg">
<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="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-1/2"></div>
</div>
</div>
</div>
</div>
<div v-else-if="hotResources.length > 0" class="space-y-4">
<div
<div v-else-if="hotResources.length > 0" class="space-y-3">
<a
v-for="(resource, index) in hotResources"
:key="resource.id"
class="group cursor-pointer"
@click="navigateToResource(resource.key)"
:href="`/r/${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="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
? 'bg-gradient-to-br from-red-500 to-orange-500 shadow-lg'
: 'bg-gray-400'"
? 'bg-red-500 text-white'
: 'bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300'"
>
{{ index + 1 }}
</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 items-start justify-between gap-2">
<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">
<div class="flex items-center justify-between">
<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 }}
</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>
<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 }}
</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>
</a>
</div>
<div v-else class="text-center py-8 text-gray-500 dark:text-gray-400">
@@ -774,24 +770,22 @@ const fetchHotResources = async () => {
try {
hotResourcesLoading.value = true
// 获取按浏览量排序的热门资源
// 使用专门的热门资源API保持10个热门资源
const params = {
limit: 8,
is_public: true,
order_by: 'view_count',
order_dir: 'desc'
limit: 10
}
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, 8).map((resource: any) => ({
...resource,
like_count: Math.floor(Math.random() * 1000) + 100 // 模拟点赞数
}))
hotResources.value = resources.slice(0, 10)
console.log('获取热门资源成功:', {
count: hotResources.value.length,
params: params
})
} catch (error) {
console.error('获取热门资源失败:', error)

60
web/pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
'@vicons/ionicons5':
specifier: ^0.12.0
version: 0.12.0
'@vueuse/core':
specifier: ^14.0.0
version: 14.0.0(vue@3.5.18(typescript@5.8.3))
chart.js:
specifier: ^4.5.0
version: 4.5.0
@@ -47,6 +50,9 @@ importers:
vue-router:
specifier: ^4.2.0
version: 4.5.1(vue@3.5.18(typescript@5.8.3))
vue-social-share:
specifier: ^0.0.3
version: 0.0.3
devDependencies:
'@nuxt/devtools':
specifier: latest
@@ -74,7 +80,7 @@ importers:
version: 5.8.3
unplugin-auto-import:
specifier: ^19.3.0
version: 19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))
version: 19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))(@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3)))
unplugin-vue-components:
specifier: ^28.8.0
version: 28.8.0(@babel/parser@7.28.0)(@nuxt/kit@3.17.7(magicast@0.3.5))(vue@3.5.18(typescript@5.8.3))
@@ -1265,6 +1271,9 @@ packages:
'@types/uglify-js@3.17.5':
resolution: {integrity: sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/webpack-bundle-analyzer@3.9.5':
resolution: {integrity: sha512-QlyDyX7rsOIJHASzXWlih8DT9fR+XCG9cwIV/4pKrtScdHv4XFshdEf/7iiqLqG0lzWcoBdzG8ylMHQ5XLNixw==}
@@ -1401,6 +1410,19 @@ packages:
'@vue/shared@3.5.18':
resolution: {integrity: sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==}
'@vueuse/core@14.0.0':
resolution: {integrity: sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.0.0':
resolution: {integrity: sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==}
'@vueuse/shared@14.0.0':
resolution: {integrity: sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==}
peerDependencies:
vue: ^3.5.0
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -3894,6 +3916,9 @@ packages:
smob@1.5.0:
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
social-share.js@1.0.16:
resolution: {integrity: sha512-NSV6fYFft/U0fEbjXdumZGU3c2oTbnJ6Ha5eNMEEBGsJpD+nu+nbg3LiRygO5GnoNgUa/dOmJyVHb/kM4dJa6g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -4499,11 +4524,17 @@ packages:
vue-devtools-stub@0.1.0:
resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==}
vue-github-badge@1.0.1:
resolution: {integrity: sha512-8X+FUWapnnDfs6cRUg3mCfHUf2r5arUfCSRdvbIn860oj9us3Rz3VOtioUgmfzh6EhaaYTs0Oh78EzJ+Z6uqAA==}
vue-router@4.5.1:
resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
peerDependencies:
vue: ^3.2.0
vue-social-share@0.0.3:
resolution: {integrity: sha512-zzZGloWVTE/OrEFT0oVfVxzWBvak9KLWiIRWWkPWag10PlGgxTI4o1oN+kXIT+8U3MkRVA8cQLPf5CPqDGmfqw==}
vue@3.5.18:
resolution: {integrity: sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==}
peerDependencies:
@@ -5904,6 +5935,8 @@ snapshots:
dependencies:
source-map: 0.6.1
'@types/web-bluetooth@0.0.21': {}
'@types/webpack-bundle-analyzer@3.9.5':
dependencies:
'@types/webpack': 4.41.40
@@ -6133,6 +6166,19 @@ snapshots:
'@vue/shared@3.5.18': {}
'@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.0.0
'@vueuse/shared': 14.0.0(vue@3.5.18(typescript@5.8.3))
vue: 3.5.18(typescript@5.8.3)
'@vueuse/metadata@14.0.0': {}
'@vueuse/shared@14.0.0(vue@3.5.18(typescript@5.8.3))':
dependencies:
vue: 3.5.18(typescript@5.8.3)
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@@ -8901,6 +8947,8 @@ snapshots:
smob@1.5.0: {}
social-share.js@1.0.16: {}
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -9257,7 +9305,7 @@ snapshots:
dependencies:
normalize-path: 2.1.1
unplugin-auto-import@19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5)):
unplugin-auto-import@19.3.0(@nuxt/kit@3.17.7(magicast@0.3.5))(@vueuse/core@14.0.0(vue@3.5.18(typescript@5.8.3))):
dependencies:
local-pkg: 1.1.1
magic-string: 0.30.17
@@ -9267,6 +9315,7 @@ snapshots:
unplugin-utils: 0.2.4
optionalDependencies:
'@nuxt/kit': 3.17.7(magicast@0.3.5)
'@vueuse/core': 14.0.0(vue@3.5.18(typescript@5.8.3))
unplugin-utils@0.2.4:
dependencies:
@@ -9500,11 +9549,18 @@ snapshots:
vue-devtools-stub@0.1.0: {}
vue-github-badge@1.0.1: {}
vue-router@4.5.1(vue@3.5.18(typescript@5.8.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.18(typescript@5.8.3)
vue-social-share@0.0.3:
dependencies:
social-share.js: 1.0.16
vue-github-badge: 1.0.1
vue@3.5.18(typescript@5.8.3):
dependencies:
'@vue/compiler-dom': 3.5.18

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB