update: og image

This commit is contained in:
Kerwin
2025-11-18 15:28:08 +08:00
parent 5dc431ab24
commit f9a1043431
8 changed files with 688 additions and 86 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -2,14 +2,21 @@ package handlers
import (
"bytes"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/ctwj/urldb/utils"
"github.com/ctwj/urldb/db"
"github.com/ctwj/urldb/db/entity"
"github.com/gin-gonic/gin"
"github.com/fogleman/gg"
"image/color"
"image"
_ "image/jpeg"
_ "image/png"
"io"
)
// OGImageHandler 处理OG图片生成请求
@@ -20,17 +27,69 @@ func NewOGImageHandler() *OGImageHandler {
return &OGImageHandler{}
}
// Resource 简化的资源结构体
type Resource struct {
Title string
Description string
Cover string
Key string
}
// getResourceByKey 通过key获取资源信息
func (h *OGImageHandler) getResourceByKey(key string) (*Resource, error) {
// 这里简化处理,实际应该从数据库查询
// 为了演示,我们先返回一个模拟的资源
// 在实际应用中,您需要连接数据库并查询
// 模拟数据库查询 - 实际应用中请替换为真实的数据库查询
dbInstance := db.DB
if dbInstance == nil {
return nil, fmt.Errorf("数据库连接失败")
}
var resource entity.Resource
result := dbInstance.Where("key = ?", key).First(&resource)
if result.Error != nil {
return nil, result.Error
}
return &Resource{
Title: resource.Title,
Description: resource.Description,
Cover: resource.Cover,
Key: resource.Key,
}, nil
}
// GenerateOGImage 生成OG图片
func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
// 获取请求参数
key := strings.TrimSpace(c.Query("key"))
title := strings.TrimSpace(c.Query("title"))
description := strings.TrimSpace(c.Query("description"))
siteName := strings.TrimSpace(c.Query("site_name"))
theme := strings.TrimSpace(c.Query("theme"))
coverUrl := strings.TrimSpace(c.Query("cover"))
width, _ := strconv.Atoi(c.Query("width"))
height, _ := strconv.Atoi(c.Query("height"))
// 如果提供了key从数据库获取资源信息
if key != "" {
resource, err := h.getResourceByKey(key)
if err == nil && resource != nil {
if title == "" {
title = resource.Title
}
if description == "" {
description = resource.Description
}
if coverUrl == "" && resource.Cover != "" {
coverUrl = resource.Cover
}
}
}
// 设置默认值
if title == "" {
title = "老九网盘资源数据库"
@@ -45,8 +104,16 @@ func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
height = 630
}
// 获取当前请求的域名
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
host := c.Request.Host
domain := scheme + "://" + host
// 生成图片
imageBuffer, err := createOGImage(title, description, siteName, theme, width, height)
imageBuffer, err := createOGImage(title, description, siteName, theme, width, height, coverUrl, key, domain)
if err != nil {
utils.Error("生成OG图片失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
@@ -62,13 +129,16 @@ func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
}
// createOGImage 创建OG图片
func createOGImage(title, description, siteName, theme string, width, height int) (*bytes.Buffer, error) {
func createOGImage(title, description, siteName, theme string, width, height int, coverUrl, key, domain string) (*bytes.Buffer, error) {
dc := gg.NewContext(width, height)
// 设置圆角裁剪区域
cornerRadius := 20.0
dc.DrawRoundedRectangle(0, 0, float64(width), float64(height), cornerRadius)
// 设置背景色
bgColor := getBackgroundColor(theme)
dc.SetColor(bgColor)
dc.DrawRectangle(0, 0, float64(width), float64(height))
dc.Fill()
// 绘制渐变效果
@@ -76,62 +146,151 @@ func createOGImage(title, description, siteName, theme string, width, height int
gradient.AddColorStop(0, getGradientStartColor(theme))
gradient.AddColorStop(1, getGradientEndColor(theme))
dc.SetFillStyle(gradient)
dc.DrawRectangle(0, 0, float64(width), float64(height))
dc.Fill()
// 设置站点标识
dc.SetHexColor("#ffffff")
// 尝试加载字体,如果失败则使用默认字体
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Regular.ttc", 24); err != nil {
// 使用默认字体设置
// 定义布局区域
imageAreaWidth := width / 3 // 左侧1/3用于图片
textAreaWidth := width * 2 / 3 // 右侧2/3用于文案
textAreaX := imageAreaWidth // 文案区域起始X坐标
// 统一的字体加载函数,确保中文显示正常
loadChineseFont := func(fontSize float64) bool {
// 优先使用项目字体
if err := dc.LoadFontFace("font/SourceHanSansSC-Regular.otf", fontSize); err == nil {
return true
}
// Windows系统常见字体按优先级顺序尝试
commonFonts := []string{
"C:/Windows/Fonts/msyh.ttc", // 微软雅黑
"C:/Windows/Fonts/simhei.ttf", // 黑体
"C:/Windows/Fonts/simsun.ttc", // 宋体
}
for _, fontPath := range commonFonts {
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
return true
}
}
// 如果都失败了,尝试使用粗体版本
boldFonts := []string{
"C:/Windows/Fonts/msyhbd.ttc", // 微软雅黑粗体
"C:/Windows/Fonts/simhei.ttf", // 黑体
}
for _, fontPath := range boldFonts {
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
return true
}
}
return false
}
dc.DrawStringAnchored(siteName, 60, 50, 0, 0.5)
// 加载基础字体24px
fontLoaded := loadChineseFont(24)
dc.SetHexColor("#ffffff")
// 绘制封面图片(如果存在)
if coverUrl != "" {
if err := drawCoverImageInLeftArea(dc, coverUrl, width, height, imageAreaWidth); err != nil {
utils.Error("绘制封面图片失败: %v", err)
}
}
// 设置站点标识
dc.DrawStringAnchored(siteName, float64(textAreaX)+60, 50, 0, 0.5)
// 绘制标题
dc.SetHexColor("#ffffff")
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 48); err != nil {
// 使用默认字体设置
}
// 文字居中处理
titleWidth, _ := dc.MeasureString(title)
if titleWidth > float64(width-120) {
// 如果标题过长,尝试加载较小字体
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 42); err != nil {
// 使用默认字体设置
}
titleWidth, _ = dc.MeasureString(title)
if titleWidth > float64(width-120) {
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 36); err != nil {
// 使用默认字体设置
// 标题在右侧区域显示,考虑文案宽度限制
maxTitleWidth := float64(textAreaWidth - 120) // 右侧区域减去左右边距
// 动态调整字体大小以适应文案区域,使用统一的字体加载逻辑
fontSize := 48.0
titleFontLoaded := false
for fontSize > 24 { // 最小字体24
// 优先使用项目粗体字体
if err := dc.LoadFontFace("font/SourceHanSansSC-Bold.otf", fontSize); err == nil {
titleWidth, _ := dc.MeasureString(title)
if titleWidth <= maxTitleWidth {
titleFontLoaded = true
break
}
} else {
// 尝试系统粗体字体
boldFonts := []string{
"C:/Windows/Fonts/msyhbd.ttc", // 微软雅黑粗体
"C:/Windows/Fonts/simhei.ttf", // 黑体
}
for _, fontPath := range boldFonts {
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
titleWidth, _ := dc.MeasureString(title)
if titleWidth <= maxTitleWidth {
titleFontLoaded = true
break
}
break // 找到可用字体就跳出内层循环
}
}
if titleFontLoaded {
break
}
}
fontSize -= 4
}
dc.DrawStringAnchored(title, float64(width)/2, float64(height)/2-30, 0.5, 0.5)
// 如果粗体字体都失败了,使用常规字体
if !titleFontLoaded {
loadChineseFont(36) // 使用稍大的常规字体
}
// 标题左对齐显示在右侧区域
titleX := float64(textAreaX) + 60
titleY := float64(height)/2 - 80
dc.DrawString(title, titleX, titleY)
// 绘制描述
if description != "" {
dc.SetHexColor("#e5e7eb")
// 尝试加载较小字体
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Regular.ttc", 28); err != nil {
// 使用默认字体设置
}
// 使用统一的字体加载逻辑
loadChineseFont(28)
// 自动换行处理
wrappedDesc := wrapText(dc, description, float64(width-120))
startY := float64(height)/2 + 40
// 自动换行处理,适配右侧区域宽度
wrappedDesc := wrapText(dc, description, float64(textAreaWidth-120))
descY := titleY + 60 // 标题下方
for i, line := range wrappedDesc {
y := startY + float64(i)*35
dc.DrawStringAnchored(line, float64(width)/2, y, 0.5, 0.5)
y := descY + float64(i)*30 // 行高30像素
dc.DrawString(line, titleX, y)
}
}
// 添加装饰性元素
drawDecorativeElements(dc, width, height, theme)
// 绘制底部URL访问地址
if key != "" && domain != "" {
resourceURL := domain + "/r/" + key
dc.SetHexColor("#d1d5db") // 浅灰色
// 使用统一的字体加载逻辑
loadChineseFont(20)
// URL位置底部居中距离底部边缘40像素给更多空间
urlY := float64(height) - 40
dc.DrawStringAnchored(resourceURL, float64(width)/2, urlY, 0.5, 0.5)
}
// 添加调试信息(仅在开发环境)
if title == "DEBUG" {
dc.SetHexColor("#ff0000")
dc.DrawString("Font loaded: "+strconv.FormatBool(fontLoaded), 50, float64(height)-80)
}
// 生成图片
buf := &bytes.Buffer{}
err := dc.EncodePNG(buf)
@@ -241,3 +400,166 @@ func drawDecorativeElements(dc *gg.Context, width, height int, theme string) {
dc.DrawLine(60, float64(height-80), float64(width-60), float64(height-80))
dc.Stroke()
}
// drawCoverImageInLeftArea 在左侧1/3区域绘制封面图片
func drawCoverImageInLeftArea(dc *gg.Context, coverUrl string, width, height int, imageAreaWidth int) error {
// 下载封面图片
resp, err := http.Get(coverUrl)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取图片数据
imgData, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
// 解码图片
img, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return err
}
// 获取图片尺寸和宽高比
bounds := img.Bounds()
imgWidth := bounds.Dx()
imgHeight := bounds.Dy()
aspectRatio := float64(imgWidth) / float64(imgHeight)
// 计算图片区域的可显示尺寸,留出边距
padding := 40
maxImageWidth := imageAreaWidth - padding*2
maxImageHeight := height - padding*2
var scaledImg image.Image
var drawWidth, drawHeight, drawX, drawY int
// 判断是竖图还是横图,采用不同的缩放策略
if aspectRatio < 1.0 {
// 竖图:充满整个左侧区域(去掉边距)
drawHeight = height - padding*2 // 留上下边距
drawWidth = int(float64(drawHeight) * aspectRatio)
// 如果宽度超出左侧区域,则以宽度为准充满整个区域宽度
if drawWidth > imageAreaWidth - padding*2 {
drawWidth = imageAreaWidth - padding*2
drawHeight = int(float64(drawWidth) / aspectRatio)
}
// 缩放图片
scaledImg = scaleImage(img, drawWidth, drawHeight)
// 垂直居中,水平居左
drawX = padding
drawY = (height - drawHeight) / 2
} else {
// 横图:优先占满宽度
drawWidth = maxImageWidth
drawHeight = int(float64(drawWidth) / aspectRatio)
// 如果高度超出限制,则以高度为准
if drawHeight > maxImageHeight {
drawHeight = maxImageHeight
drawWidth = int(float64(drawHeight) * aspectRatio)
}
// 缩放图片
scaledImg = scaleImage(img, drawWidth, drawHeight)
// 水平居中,垂直居中
drawX = (imageAreaWidth - drawWidth) / 2
drawY = (height - drawHeight) / 2
}
// 绘制图片
dc.DrawImage(scaledImg, drawX, drawY)
// 添加半透明遮罩效果,让文字更清晰(仅在有图片时添加)
maskColor := color.RGBA{0, 0, 0, 80} // 半透明黑色,透明度稍低
dc.SetColor(maskColor)
dc.DrawRectangle(float64(drawX), float64(drawY), float64(drawWidth), float64(drawHeight))
dc.Fill()
return nil
}
// scaleImage 图片缩放函数
func scaleImage(img image.Image, width, height int) image.Image {
// 使用 gg 库的 Scale 变换来实现缩放
srcWidth := img.Bounds().Dx()
srcHeight := img.Bounds().Dy()
// 创建目标尺寸的画布
dc := gg.NewContext(width, height)
// 计算缩放比例
scaleX := float64(width) / float64(srcWidth)
scaleY := float64(height) / float64(srcHeight)
// 应用缩放变换并绘制图片
dc.Scale(scaleX, scaleY)
dc.DrawImage(img, 0, 0)
return dc.Image()
}
// drawCoverImage 绘制封面图片(保留原函数作为备用)
func drawCoverImage(dc *gg.Context, coverUrl string, width, height int) error {
// 下载封面图片
resp, err := http.Get(coverUrl)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取图片数据
imgData, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
// 解码图片
img, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return err
}
// 计算封面图片的位置和大小,放置在左侧
coverWidth := 200 // 封面图宽度
coverHeight := 280 // 封面图高度
coverX := 50
coverY := (height - coverHeight) / 2
// 绘制封面图片(按比例缩放)
bounds := img.Bounds()
imgWidth := bounds.Dx()
imgHeight := bounds.Dy()
// 计算缩放比例,保持宽高比
scaleX := float64(coverWidth) / float64(imgWidth)
scaleY := float64(coverHeight) / float64(imgHeight)
scale := scaleX
if scaleY < scaleX {
scale = scaleY
}
// 计算缩放后的尺寸
newWidth := int(float64(imgWidth) * scale)
newHeight := int(float64(imgHeight) * scale)
// 居中绘制
offsetX := coverX + (coverWidth-newWidth)/2
offsetY := coverY + (coverHeight-newHeight)/2
dc.DrawImage(img, offsetX, offsetY)
// 添加半透明遮罩效果,让文字更清晰
maskColor := color.RGBA{0, 0, 0, 120} // 半透明黑色
dc.SetColor(maskColor)
dc.DrawRectangle(float64(coverX), float64(coverY), float64(coverWidth), float64(coverHeight))
dc.Fill()
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
pan "github.com/ctwj/urldb/common"
commonutils "github.com/ctwj/urldb/common/utils"
@@ -901,6 +902,203 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
}
}
// GetRelatedResources 获取相关资源
func GetRelatedResources(c *gin.Context) {
// 获取查询参数
key := c.Query("key") // 当前资源的key
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
utils.Info("获取相关资源请求 - key: %s, limit: %d", key, limit)
if key == "" {
ErrorResponse(c, "缺少资源key参数", http.StatusBadRequest)
return
}
// 首先通过key获取当前资源信息
currentResources, err := repoManager.ResourceRepository.FindByKey(key)
if err != nil {
utils.Error("获取当前资源失败: %v", err)
ErrorResponse(c, "资源不存在", http.StatusNotFound)
return
}
if len(currentResources) == 0 {
ErrorResponse(c, "资源不存在", http.StatusNotFound)
return
}
currentResource := &currentResources[0] // 取第一个资源作为当前资源
var resources []entity.Resource
var total int64
// 获取当前资源的标签ID列表
var tagIDsList []string
if currentResource.Tags != nil {
for _, tag := range currentResource.Tags {
tagIDsList = append(tagIDsList, strconv.Itoa(int(tag.ID)))
}
}
utils.Info("当前资源标签: %v", tagIDsList)
// 1. 优先使用Meilisearch进行标签搜索
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(tagIDsList) > 0 {
service := meilisearchManager.GetService()
if service != nil {
// 使用标签进行搜索
filters := make(map[string]interface{})
filters["tag_ids"] = tagIDsList
// 使用当前资源的标题作为搜索关键词,提高相关性
searchQuery := currentResource.Title
if searchQuery == "" {
searchQuery = strings.Join(tagIDsList, " ") // 如果没有标题,使用标签作为搜索词
}
docs, docTotal, err := service.Search(searchQuery, filters, page, limit)
if err == nil && len(docs) > 0 {
// 转换为Resource实体
for _, doc := range docs {
// 排除当前资源
if doc.Key == key {
continue
}
resource := entity.Resource{
ID: doc.ID,
Title: doc.Title,
Description: doc.Description,
URL: doc.URL,
SaveURL: doc.SaveURL,
FileSize: doc.FileSize,
Key: doc.Key,
PanID: doc.PanID,
ViewCount: 0, // Meilisearch文档中没有ViewCount字段设为默认值
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
Cover: doc.Cover,
Author: doc.Author,
}
resources = append(resources, resource)
}
total = docTotal
utils.Info("Meilisearch搜索到 %d 个相关资源", len(resources))
} else {
utils.Error("Meilisearch搜索失败回退到标签搜索: %v", err)
}
}
}
// 2. 如果Meilisearch未启用、搜索失败或没有结果使用数据库标签搜索
if len(resources) == 0 {
params := map[string]interface{}{
"page": page,
"page_size": limit,
"is_public": true,
"order_by": "updated_at",
"order_dir": "desc",
}
// 使用当前资源的标签进行搜索
if len(tagIDsList) > 0 {
params["tag_ids"] = strings.Join(tagIDsList, ",")
} else {
// 如果没有标签,使用当前资源的分类作为搜索条件
if currentResource.CategoryID != nil && *currentResource.CategoryID > 0 {
params["category_id"] = *currentResource.CategoryID
}
}
var err error
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
if err != nil {
utils.Error("搜索相关资源失败: %v", err)
ErrorResponse(c, "搜索相关资源失败", http.StatusInternalServerError)
return
}
// 排除当前资源
var filteredResources []entity.Resource
for _, resource := range resources {
if resource.Key != key {
filteredResources = append(filteredResources, resource)
}
}
resources = filteredResources
total = int64(len(filteredResources))
}
utils.Info("标签搜索到 %d 个相关资源", len(resources))
// 获取违禁词配置
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)
}
// 构建响应数据
responseData := gin.H{
"data": resourceResponses,
"total": total,
"page": page,
"page_size": limit,
"source": "database",
}
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(tagIDsList) > 0 {
responseData["source"] = "meilisearch"
}
SuccessResponse(c, responseData)
}
// getQuarkPanID 获取夸克网盘ID
func getQuarkPanID() (uint, error) {
// 通过FindAll方法查找所有平台然后过滤出quark平台

View File

@@ -239,6 +239,7 @@ func main() {
api.GET("/resources/:id", handlers.GetResourceByID)
api.GET("/resources/key/:key", handlers.GetResourcesByKey)
api.GET("/resources/check-exists", handlers.CheckResourceExists)
api.GET("/resources/related", handlers.GetRelatedResources)
api.POST("/resources/:id/view", handlers.IncrementResourceViewCount)
api.GET("/resources/:id/link", handlers.GetResourceLink)
api.DELETE("/resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchDeleteResources)

View File

@@ -60,7 +60,9 @@ export const useResourceApi = () => {
const batchDeleteResources = (ids: number[]) => useApiFetch('/resources/batch', { method: 'DELETE', body: { ids } }).then(parseApiResponse)
// 新增:获取资源链接(智能转存)
const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).then(parseApiResponse)
return { getResources, getResource, getResourcesByKey, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink }
// 新增:获取相关资源
const getRelatedResources = (params?: any) => useApiFetch('/resources/related', { params }).then(parseApiResponse)
return { getResources, getResource, getResourcesByKey, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources, getResourceLink, getRelatedResources }
}
export const useAuthApi = () => {

View File

@@ -1,5 +1,6 @@
import { ref } from 'vue'
import { useRoute } from '#imports'
import { usePublicSystemConfigApi } from './useApi'
interface SystemConfig {
id: number
@@ -60,19 +61,28 @@ export const useSeo = () => {
}
// 生成动态OG图片URL
const generateOgImageUrl = (title: string, description?: string, theme: string = 'default') => {
const generateOgImageUrl = (keyOrTitle: string, descriptionOrEmpty: string = '', theme: string = 'default') => {
// 获取运行时配置
const config = useRuntimeConfig()
const ogApiUrl = config.public.ogApiUrl || '/api/og-image'
// 构建URL参数
const params = new URLSearchParams()
params.set('title', title)
if (description) {
// 限制描述长度
const trimmedDesc = description.length > 200 ? description.substring(0, 200) + '...' : description
params.set('description', trimmedDesc)
// 检测第一个参数是key还是title通过长度和格式判断
// 如果是较短的字符串且符合key格式通常是字母数字组合则当作key处理
if (keyOrTitle.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(keyOrTitle)) {
// 作为key参数使用
params.set('key', keyOrTitle)
} else {
// 作为title参数使用
params.set('title', keyOrTitle)
if (descriptionOrEmpty) {
// 限制描述长度
const trimmedDesc = descriptionOrEmpty.length > 200 ? descriptionOrEmpty.substring(0, 200) + '...' : descriptionOrEmpty
params.set('description', trimmedDesc)
}
}
params.set('site_name', (systemConfig.value && systemConfig.value.site_title) || '老九网盘资源数据库')

View File

@@ -221,53 +221,46 @@
</h3>
<!-- 相关资源列表 -->
<div v-if="relatedResourcesLoading" class="space-y-4">
<div v-if="isRelatedResourcesLoading" class="space-y-3">
<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 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="relatedResources.length > 0" class="space-y-4">
<div v-else-if="displayRelatedResources.length > 0" class="space-y-3">
<div
v-for="resource in relatedResources"
v-for="(resource, index) in displayRelatedResources"
:key="resource.id"
class="group cursor-pointer"
@click="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-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 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-5 h-5 rounded-full flex items-center justify-center text-xs font-medium"
:class="index < 3
? 'bg-blue-500 text-white'
: 'bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300'"
>
{{ index + 1 }}
</div>
</div>
<!-- 资源信息 -->
<div class="flex-1 min-w-0">
<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">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 line-clamp-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{{ resource.title }}
</h4>
<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">
{{ formatDate(resource.updated_at) }}
</span>
</div>
</div>
</div>
</div>
@@ -483,12 +476,62 @@ const { data: resourcesData, error: resourcesError } = await useAsyncData(
}
)
// 获取相关资源服务端渲染用于SEO优化
const { data: relatedResourcesData } = await useAsyncData(
`related-resources-${resourceKey.value}`,
() => {
const params = {
key: resourceKey.value,
limit: 5
}
return resourceApi.getRelatedResources(params)
},
{
server: true,
default: () => ({ data: [] })
}
)
// 主要资源信息
const mainResource = computed(() => {
const resources = resourcesData.value?.resources
return resources && resources.length > 0 ? resources[0] : null
})
// 服务端相关资源处理(去重)
const serverRelatedResources = computed(() => {
const resources = Array.isArray(relatedResourcesData.value?.data) ? relatedResourcesData.value.data : []
// 根据key去重避免显示重复资源
const uniqueResources = resources.filter((resource, index, self) =>
index === self.findIndex((r) => r.key === resource.key)
)
return uniqueResources.slice(0, 5) // 最多显示5个相关资源
})
// 合并服务端和客户端相关资源优先显示服务端数据支持SEO
const displayRelatedResources = computed(() => {
// 如果有客户端数据(可能是更新的数据),使用客户端数据
if (relatedResources.value.length > 0) {
return relatedResources.value
}
// 否则使用服务端数据确保SEO友好
return serverRelatedResources.value
})
// 相关资源加载状态
const isRelatedResourcesLoading = computed(() => {
// 如果有服务端数据,不显示加载状态
if (serverRelatedResources.value.length > 0) {
return false
}
// 否则显示客户端加载状态
return relatedResourcesLoading.value
})
// 检测状态
const detectionStatus = computed(() => {
if (isDetecting.value) {
@@ -681,32 +724,42 @@ const handleCopyrightSubmitted = () => {
})
}
// 获取相关资源
// 获取相关资源(客户端更新,用于交互优化)
const fetchRelatedResources = async () => {
if (!mainResource.value) return
// 如果已经有服务端数据跳过客户端获取SEO优化
if (serverRelatedResources.value.length > 0) {
console.log('使用服务端相关资源数据,跳过客户端获取')
return
}
try {
relatedResourcesLoading.value = true
// 基于标签获取相关资源
const tagIds = mainResource.value.tags?.map((tag: any) => tag.id).slice(0, 3) // 取前3个标签
let params: any = { limit: 8, is_public: true }
if (tagIds && tagIds.length > 0) {
params.tag_ids = tagIds.join(',')
} else {
// 如果没有标签,获取最新资源作为推荐
params.order_by = 'updated_at'
params.order_dir = 'desc'
// 使用新的相关资源API基于资源key查找相关资源
const params: any = {
key: resourceKey.value, // 使用当前资源的key
limit: 5,
}
const response = await resourceApi.getResources(params) as any
const response = await resourceApi.getRelatedResources(params) as any
// 过滤掉当前显示的资源
const resources = Array.isArray(response) ? response : (response?.items || [])
relatedResources.value = resources
.filter((resource: any) => resource.key !== resourceKey.value)
.slice(0, 8) // 最多显示8个相关资源
// 处理响应数据
const resources = Array.isArray(response?.data) ? response.data : []
// 根据key去重避免显示重复资源
const uniqueResources = resources.filter((resource, index, self) =>
index === self.findIndex((r) => r.key === resource.key)
)
relatedResources.value = uniqueResources.slice(0, 5) // 最多显示5个相关资源
console.log('获取相关资源成功:', {
source: response?.source,
count: relatedResources.value.length,
params: params
})
} catch (error) {
console.error('获取相关资源失败:', error)
@@ -775,6 +828,7 @@ onMounted(() => {
// 设置页面SEO
const { initSystemConfig, setPageSeo } = useGlobalSeo()
const { generateOgImageUrl } = useSeo()
// 动态生成页面SEO信息
const pageSeo = computed(() => {
@@ -813,6 +867,9 @@ const updatePageSeo = () => {
const baseUrl = config.public.siteUrl || 'https://yourdomain.com'
const canonicalUrl = `${baseUrl}/r/${resourceKey.value}`
// 生成动态OG图片URL使用新的key参数格式
const ogImageUrl = generateOgImageUrl(resourceKey.value, '', 'blue')
useHead({
htmlAttrs: {
lang: 'zh-CN'
@@ -843,7 +900,7 @@ const updatePageSeo = () => {
},
{
property: 'og:image',
content: getResourceImageUrl(mainResource.value)
content: ogImageUrl
},
{
property: 'og:site_name',
@@ -864,7 +921,7 @@ const updatePageSeo = () => {
},
{
name: 'twitter:image',
content: getResourceImageUrl(mainResource.value)
content: ogImageUrl
},
// 其他元数据
{
@@ -889,6 +946,7 @@ const updatePageSeo = () => {
"name": title,
"description": description,
"url": canonicalUrl,
"image": ogImageUrl,
"mainEntity": {
"@type": "SoftwareApplication" || "DigitalDocument",
"name": title,
@@ -899,6 +957,7 @@ const updatePageSeo = () => {
},
"dateModified": mainResource.value?.updated_at,
"keywords": keywords,
"image": ogImageUrl,
"offers": {
"@type": "Offer",
"price": "0",
@@ -908,7 +967,17 @@ const updatePageSeo = () => {
"publisher": {
"@type": "Organization",
"name": systemConfig.value?.site_title || '老九网盘资源数据库'
}
},
"relatedContent": displayRelatedResources.value.map((resource, index) => ({
"@type": "SoftwareApplication" || "DigitalDocument",
"position": index + 1,
"name": resource.title,
"description": resource.description?.substring(0, 160) || '',
"url": `${baseUrl}/r/${resource.key}`,
"dateModified": resource.updated_at,
"keywords": resource.tags?.map((tag: any) => tag.name).join(', ') || '',
"image": generateOgImageUrl(resource.key, '', 'green')
}))
})
}
]