mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 11:29:37 +08:00
update: og image
This commit is contained in:
BIN
font/SourceHanSansSC-Bold.otf
Normal file
BIN
font/SourceHanSansSC-Bold.otf
Normal file
Binary file not shown.
BIN
font/SourceHanSansSC-Regular.otf
Normal file
BIN
font/SourceHanSansSC-Regular.otf
Normal file
Binary file not shown.
@@ -2,14 +2,21 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/ctwj/urldb/utils"
|
"github.com/ctwj/urldb/utils"
|
||||||
|
"github.com/ctwj/urldb/db"
|
||||||
|
"github.com/ctwj/urldb/db/entity"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/fogleman/gg"
|
"github.com/fogleman/gg"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OGImageHandler 处理OG图片生成请求
|
// OGImageHandler 处理OG图片生成请求
|
||||||
@@ -20,17 +27,69 @@ func NewOGImageHandler() *OGImageHandler {
|
|||||||
return &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图片
|
// GenerateOGImage 生成OG图片
|
||||||
func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
|
func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
|
||||||
// 获取请求参数
|
// 获取请求参数
|
||||||
|
key := strings.TrimSpace(c.Query("key"))
|
||||||
title := strings.TrimSpace(c.Query("title"))
|
title := strings.TrimSpace(c.Query("title"))
|
||||||
description := strings.TrimSpace(c.Query("description"))
|
description := strings.TrimSpace(c.Query("description"))
|
||||||
siteName := strings.TrimSpace(c.Query("site_name"))
|
siteName := strings.TrimSpace(c.Query("site_name"))
|
||||||
theme := strings.TrimSpace(c.Query("theme"))
|
theme := strings.TrimSpace(c.Query("theme"))
|
||||||
|
coverUrl := strings.TrimSpace(c.Query("cover"))
|
||||||
|
|
||||||
width, _ := strconv.Atoi(c.Query("width"))
|
width, _ := strconv.Atoi(c.Query("width"))
|
||||||
height, _ := strconv.Atoi(c.Query("height"))
|
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 == "" {
|
if title == "" {
|
||||||
title = "老九网盘资源数据库"
|
title = "老九网盘资源数据库"
|
||||||
@@ -45,8 +104,16 @@ func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
|
|||||||
height = 630
|
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 {
|
if err != nil {
|
||||||
utils.Error("生成OG图片失败: %v", err)
|
utils.Error("生成OG图片失败: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -62,13 +129,16 @@ func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createOGImage 创建OG图片
|
// 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)
|
dc := gg.NewContext(width, height)
|
||||||
|
|
||||||
|
// 设置圆角裁剪区域
|
||||||
|
cornerRadius := 20.0
|
||||||
|
dc.DrawRoundedRectangle(0, 0, float64(width), float64(height), cornerRadius)
|
||||||
|
|
||||||
// 设置背景色
|
// 设置背景色
|
||||||
bgColor := getBackgroundColor(theme)
|
bgColor := getBackgroundColor(theme)
|
||||||
dc.SetColor(bgColor)
|
dc.SetColor(bgColor)
|
||||||
dc.DrawRectangle(0, 0, float64(width), float64(height))
|
|
||||||
dc.Fill()
|
dc.Fill()
|
||||||
|
|
||||||
// 绘制渐变效果
|
// 绘制渐变效果
|
||||||
@@ -76,62 +146,151 @@ func createOGImage(title, description, siteName, theme string, width, height int
|
|||||||
gradient.AddColorStop(0, getGradientStartColor(theme))
|
gradient.AddColorStop(0, getGradientStartColor(theme))
|
||||||
gradient.AddColorStop(1, getGradientEndColor(theme))
|
gradient.AddColorStop(1, getGradientEndColor(theme))
|
||||||
dc.SetFillStyle(gradient)
|
dc.SetFillStyle(gradient)
|
||||||
dc.DrawRectangle(0, 0, float64(width), float64(height))
|
|
||||||
dc.Fill()
|
dc.Fill()
|
||||||
|
|
||||||
// 设置站点标识
|
// 定义布局区域
|
||||||
dc.SetHexColor("#ffffff")
|
imageAreaWidth := width / 3 // 左侧1/3用于图片
|
||||||
// 尝试加载字体,如果失败则使用默认字体
|
textAreaWidth := width * 2 / 3 // 右侧2/3用于文案
|
||||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Regular.ttc", 24); err != nil {
|
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")
|
dc.SetHexColor("#ffffff")
|
||||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 48); err != nil {
|
|
||||||
// 使用默认字体设置
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文字居中处理
|
// 标题在右侧区域显示,考虑文案宽度限制
|
||||||
titleWidth, _ := dc.MeasureString(title)
|
maxTitleWidth := float64(textAreaWidth - 120) // 右侧区域减去左右边距
|
||||||
if titleWidth > float64(width-120) {
|
|
||||||
// 如果标题过长,尝试加载较小字体
|
// 动态调整字体大小以适应文案区域,使用统一的字体加载逻辑
|
||||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 42); err != nil {
|
fontSize := 48.0
|
||||||
// 使用默认字体设置
|
titleFontLoaded := false
|
||||||
}
|
for fontSize > 24 { // 最小字体24
|
||||||
titleWidth, _ = dc.MeasureString(title)
|
// 优先使用项目粗体字体
|
||||||
if titleWidth > float64(width-120) {
|
if err := dc.LoadFontFace("font/SourceHanSansSC-Bold.otf", fontSize); err == nil {
|
||||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Bold.ttc", 36); 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 != "" {
|
if description != "" {
|
||||||
dc.SetHexColor("#e5e7eb")
|
dc.SetHexColor("#e5e7eb")
|
||||||
// 尝试加载较小字体
|
// 使用统一的字体加载逻辑
|
||||||
if err := dc.LoadFontFace("assets/fonts/SourceHanSansCN-Regular.ttc", 28); err != nil {
|
loadChineseFont(28)
|
||||||
// 使用默认字体设置
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动换行处理
|
// 自动换行处理,适配右侧区域宽度
|
||||||
wrappedDesc := wrapText(dc, description, float64(width-120))
|
wrappedDesc := wrapText(dc, description, float64(textAreaWidth-120))
|
||||||
startY := float64(height)/2 + 40
|
descY := titleY + 60 // 标题下方
|
||||||
|
|
||||||
for i, line := range wrappedDesc {
|
for i, line := range wrappedDesc {
|
||||||
y := startY + float64(i)*35
|
y := descY + float64(i)*30 // 行高30像素
|
||||||
dc.DrawStringAnchored(line, float64(width)/2, y, 0.5, 0.5)
|
dc.DrawString(line, titleX, y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加装饰性元素
|
// 添加装饰性元素
|
||||||
drawDecorativeElements(dc, width, height, theme)
|
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{}
|
buf := &bytes.Buffer{}
|
||||||
err := dc.EncodePNG(buf)
|
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.DrawLine(60, float64(height-80), float64(width-60), float64(height-80))
|
||||||
dc.Stroke()
|
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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
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"
|
||||||
@@ -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 := ¤tResources[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
|
// getQuarkPanID 获取夸克网盘ID
|
||||||
func getQuarkPanID() (uint, error) {
|
func getQuarkPanID() (uint, error) {
|
||||||
// 通过FindAll方法查找所有平台,然后过滤出quark平台
|
// 通过FindAll方法查找所有平台,然后过滤出quark平台
|
||||||
|
|||||||
1
main.go
1
main.go
@@ -239,6 +239,7 @@ func main() {
|
|||||||
api.GET("/resources/:id", handlers.GetResourceByID)
|
api.GET("/resources/:id", handlers.GetResourceByID)
|
||||||
api.GET("/resources/key/:key", handlers.GetResourcesByKey)
|
api.GET("/resources/key/:key", handlers.GetResourcesByKey)
|
||||||
api.GET("/resources/check-exists", handlers.CheckResourceExists)
|
api.GET("/resources/check-exists", handlers.CheckResourceExists)
|
||||||
|
api.GET("/resources/related", handlers.GetRelatedResources)
|
||||||
api.POST("/resources/:id/view", handlers.IncrementResourceViewCount)
|
api.POST("/resources/:id/view", handlers.IncrementResourceViewCount)
|
||||||
api.GET("/resources/:id/link", handlers.GetResourceLink)
|
api.GET("/resources/:id/link", handlers.GetResourceLink)
|
||||||
api.DELETE("/resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchDeleteResources)
|
api.DELETE("/resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchDeleteResources)
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ export const useResourceApi = () => {
|
|||||||
const batchDeleteResources = (ids: number[]) => useApiFetch('/resources/batch', { method: 'DELETE', body: { ids } }).then(parseApiResponse)
|
const batchDeleteResources = (ids: number[]) => useApiFetch('/resources/batch', { method: 'DELETE', body: { ids } }).then(parseApiResponse)
|
||||||
// 新增:获取资源链接(智能转存)
|
// 新增:获取资源链接(智能转存)
|
||||||
const getResourceLink = (id: number) => useApiFetch(`/resources/${id}/link`).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 = () => {
|
export const useAuthApi = () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute } from '#imports'
|
import { useRoute } from '#imports'
|
||||||
|
import { usePublicSystemConfigApi } from './useApi'
|
||||||
|
|
||||||
interface SystemConfig {
|
interface SystemConfig {
|
||||||
id: number
|
id: number
|
||||||
@@ -60,19 +61,28 @@ export const useSeo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成动态OG图片URL
|
// 生成动态OG图片URL
|
||||||
const generateOgImageUrl = (title: string, description?: string, theme: string = 'default') => {
|
const generateOgImageUrl = (keyOrTitle: string, descriptionOrEmpty: string = '', theme: string = 'default') => {
|
||||||
// 获取运行时配置
|
// 获取运行时配置
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const ogApiUrl = config.public.ogApiUrl || '/api/og-image'
|
const ogApiUrl = config.public.ogApiUrl || '/api/og-image'
|
||||||
|
|
||||||
// 构建URL参数
|
// 构建URL参数
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('title', title)
|
|
||||||
|
|
||||||
if (description) {
|
// 检测第一个参数是key还是title(通过长度和格式判断)
|
||||||
// 限制描述长度
|
// 如果是较短的字符串且符合key格式(通常是字母数字组合),则当作key处理
|
||||||
const trimmedDesc = description.length > 200 ? description.substring(0, 200) + '...' : description
|
if (keyOrTitle.length <= 50 && /^[a-zA-Z0-9_-]+$/.test(keyOrTitle)) {
|
||||||
params.set('description', trimmedDesc)
|
// 作为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) || '老九网盘资源数据库')
|
params.set('site_name', (systemConfig.value && systemConfig.value.site_title) || '老九网盘资源数据库')
|
||||||
|
|||||||
@@ -221,53 +221,46 @@
|
|||||||
</h3>
|
</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 v-for="i in 5" :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="relatedResources.length > 0" class="space-y-4">
|
<div v-else-if="displayRelatedResources.length > 0" class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="resource in relatedResources"
|
v-for="(resource, index) in displayRelatedResources"
|
||||||
:key="resource.id"
|
:key="resource.id"
|
||||||
class="group cursor-pointer"
|
class="group cursor-pointer"
|
||||||
@click="navigateToResource(resource.key)"
|
@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 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">
|
<div class="flex-shrink-0 flex items-center justify-center">
|
||||||
<img
|
<div
|
||||||
:src="getResourceImageUrl(resource)"
|
class="w-5 h-5 rounded-full flex items-center justify-center text-xs font-medium"
|
||||||
:alt="resource.title"
|
:class="index < 3
|
||||||
class="w-16 h-20 object-cover rounded-lg shadow-sm group-hover:shadow-md transition-shadow"
|
? 'bg-blue-500 text-white'
|
||||||
@error="handleResourceImageError"
|
: 'bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-300'"
|
||||||
/>
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 资源信息 -->
|
<!-- 资源信息 -->
|
||||||
<div class="flex-1 min-w-0">
|
<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 }}
|
{{ resource.title }}
|
||||||
</h4>
|
</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 }}
|
{{ 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">
|
|
||||||
{{ formatDate(resource.updated_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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 mainResource = computed(() => {
|
||||||
const resources = resourcesData.value?.resources
|
const resources = resourcesData.value?.resources
|
||||||
return resources && resources.length > 0 ? resources[0] : null
|
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(() => {
|
const detectionStatus = computed(() => {
|
||||||
if (isDetecting.value) {
|
if (isDetecting.value) {
|
||||||
@@ -681,32 +724,42 @@ const handleCopyrightSubmitted = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取相关资源
|
// 获取相关资源(客户端更新,用于交互优化)
|
||||||
const fetchRelatedResources = async () => {
|
const fetchRelatedResources = async () => {
|
||||||
if (!mainResource.value) return
|
if (!mainResource.value) return
|
||||||
|
|
||||||
|
// 如果已经有服务端数据,跳过客户端获取(SEO优化)
|
||||||
|
if (serverRelatedResources.value.length > 0) {
|
||||||
|
console.log('使用服务端相关资源数据,跳过客户端获取')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
relatedResourcesLoading.value = true
|
relatedResourcesLoading.value = true
|
||||||
|
|
||||||
// 基于标签获取相关资源
|
// 使用新的相关资源API,基于资源key查找相关资源
|
||||||
const tagIds = mainResource.value.tags?.map((tag: any) => tag.id).slice(0, 3) // 取前3个标签
|
const params: any = {
|
||||||
let params: any = { limit: 8, is_public: true }
|
key: resourceKey.value, // 使用当前资源的key
|
||||||
|
limit: 5,
|
||||||
if (tagIds && tagIds.length > 0) {
|
|
||||||
params.tag_ids = tagIds.join(',')
|
|
||||||
} else {
|
|
||||||
// 如果没有标签,获取最新资源作为推荐
|
|
||||||
params.order_by = 'updated_at'
|
|
||||||
params.order_dir = 'desc'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await resourceApi.getResources(params) as any
|
const response = await resourceApi.getRelatedResources(params) as any
|
||||||
|
|
||||||
// 过滤掉当前显示的资源
|
// 处理响应数据
|
||||||
const resources = Array.isArray(response) ? response : (response?.items || [])
|
const resources = Array.isArray(response?.data) ? response.data : []
|
||||||
relatedResources.value = resources
|
|
||||||
.filter((resource: any) => resource.key !== resourceKey.value)
|
// 根据key去重,避免显示重复资源
|
||||||
.slice(0, 8) // 最多显示8个相关资源
|
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) {
|
} catch (error) {
|
||||||
console.error('获取相关资源失败:', error)
|
console.error('获取相关资源失败:', error)
|
||||||
@@ -775,6 +828,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 设置页面SEO
|
// 设置页面SEO
|
||||||
const { initSystemConfig, setPageSeo } = useGlobalSeo()
|
const { initSystemConfig, setPageSeo } = useGlobalSeo()
|
||||||
|
const { generateOgImageUrl } = useSeo()
|
||||||
|
|
||||||
// 动态生成页面SEO信息
|
// 动态生成页面SEO信息
|
||||||
const pageSeo = computed(() => {
|
const pageSeo = computed(() => {
|
||||||
@@ -813,6 +867,9 @@ const updatePageSeo = () => {
|
|||||||
const baseUrl = config.public.siteUrl || 'https://yourdomain.com'
|
const baseUrl = config.public.siteUrl || 'https://yourdomain.com'
|
||||||
const canonicalUrl = `${baseUrl}/r/${resourceKey.value}`
|
const canonicalUrl = `${baseUrl}/r/${resourceKey.value}`
|
||||||
|
|
||||||
|
// 生成动态OG图片URL(使用新的key参数格式)
|
||||||
|
const ogImageUrl = generateOgImageUrl(resourceKey.value, '', 'blue')
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: 'zh-CN'
|
lang: 'zh-CN'
|
||||||
@@ -843,7 +900,7 @@ const updatePageSeo = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
property: 'og:image',
|
property: 'og:image',
|
||||||
content: getResourceImageUrl(mainResource.value)
|
content: ogImageUrl
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
property: 'og:site_name',
|
property: 'og:site_name',
|
||||||
@@ -864,7 +921,7 @@ const updatePageSeo = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'twitter:image',
|
name: 'twitter:image',
|
||||||
content: getResourceImageUrl(mainResource.value)
|
content: ogImageUrl
|
||||||
},
|
},
|
||||||
// 其他元数据
|
// 其他元数据
|
||||||
{
|
{
|
||||||
@@ -889,6 +946,7 @@ const updatePageSeo = () => {
|
|||||||
"name": title,
|
"name": title,
|
||||||
"description": description,
|
"description": description,
|
||||||
"url": canonicalUrl,
|
"url": canonicalUrl,
|
||||||
|
"image": ogImageUrl,
|
||||||
"mainEntity": {
|
"mainEntity": {
|
||||||
"@type": "SoftwareApplication" || "DigitalDocument",
|
"@type": "SoftwareApplication" || "DigitalDocument",
|
||||||
"name": title,
|
"name": title,
|
||||||
@@ -899,6 +957,7 @@ const updatePageSeo = () => {
|
|||||||
},
|
},
|
||||||
"dateModified": mainResource.value?.updated_at,
|
"dateModified": mainResource.value?.updated_at,
|
||||||
"keywords": keywords,
|
"keywords": keywords,
|
||||||
|
"image": ogImageUrl,
|
||||||
"offers": {
|
"offers": {
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
"price": "0",
|
"price": "0",
|
||||||
@@ -908,7 +967,17 @@ const updatePageSeo = () => {
|
|||||||
"publisher": {
|
"publisher": {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": systemConfig.value?.site_title || '老九网盘资源数据库'
|
"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')
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user