mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 03:15:04 +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 (
|
||||
"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
|
||||
}
|
||||
@@ -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 := ¤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
|
||||
func getQuarkPanID() (uint, error) {
|
||||
// 通过FindAll方法查找所有平台,然后过滤出quark平台
|
||||
|
||||
1
main.go
1
main.go
@@ -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)
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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) || '老九网盘资源数据库')
|
||||
|
||||
@@ -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')
|
||||
}))
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user