Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

优化图片展示和上传 #280

Merged
merged 1 commit into from
Feb 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 23 additions & 20 deletions backend/db/memo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,32 @@ package db

import (
"time"

"github.com/kingwrcy/moments/vo"
)

type Memo struct {
Id int32 `gorm:"column:id;primary_key;NOT NULL" json:"id,omitempty"`
Content string `gorm:"column:content" json:"content,omitempty"`
Imgs string `gorm:"column:imgs" json:"imgs,omitempty"`
FavCount int32 `gorm:"column:favCount;default:0;NOT NULL" json:"favCount,omitempty"`
CommentCount int32 `gorm:"column:commentCount;default:0;NOT NULL" json:"commentCount,omitempty"`
UserId int32 `gorm:"column:userId;NOT NULL" json:"userId,omitempty"`
CreatedAt *time.Time `gorm:"column:createdAt;default:CURRENT_TIMESTAMP;NOT NULL" json:"createdAt,omitempty"`
UpdatedAt *time.Time `gorm:"column:updatedAt;NOT NULL" json:"updatedAt,omitempty"`
Music163Url string `gorm:"column:music163Url" json:"music163Url,omitempty"`
BilibiliUrl string `gorm:"column:bilibiliUrl" json:"bilibiliUrl,omitempty"`
Location string `gorm:"column:location" json:"location,omitempty"`
ExternalUrl string `gorm:"column:externalUrl" json:"externalUrl,omitempty"`
ExternalTitle string `gorm:"column:externalTitle" json:"externalTitle,omitempty"`
ExternalFavicon string `gorm:"column:externalFavicon;default:/favicon.png;NOT NULL" json:"externalFavicon,omitempty"`
Pinned *bool `gorm:"column:pinned;default:false;NOT NULL" json:"pinned,omitempty"`
Ext string `gorm:"column:ext;default:{};NOT NULL" json:"ext,omitempty"`
ShowType *int32 `gorm:"column:showType;default:1;NOT NULL" json:"showType,omitempty"`
User *User `json:"user,omitempty"`
Comments []Comment `json:"comments,omitempty"`
Tags *string `json:"tags,omitempty"`
Id int32 `gorm:"column:id;primary_key;NOT NULL" json:"id,omitempty"`
Content string `gorm:"column:content" json:"content,omitempty"`
Imgs string `gorm:"column:imgs" json:"imgs,omitempty"`
FavCount int32 `gorm:"column:favCount;default:0;NOT NULL" json:"favCount,omitempty"`
CommentCount int32 `gorm:"column:commentCount;default:0;NOT NULL" json:"commentCount,omitempty"`
UserId int32 `gorm:"column:userId;NOT NULL" json:"userId,omitempty"`
CreatedAt *time.Time `gorm:"column:createdAt;default:CURRENT_TIMESTAMP;NOT NULL" json:"createdAt,omitempty"`
UpdatedAt *time.Time `gorm:"column:updatedAt;NOT NULL" json:"updatedAt,omitempty"`
Music163Url string `gorm:"column:music163Url" json:"music163Url,omitempty"`
BilibiliUrl string `gorm:"column:bilibiliUrl" json:"bilibiliUrl,omitempty"`
Location string `gorm:"column:location" json:"location,omitempty"`
ExternalUrl string `gorm:"column:externalUrl" json:"externalUrl,omitempty"`
ExternalTitle string `gorm:"column:externalTitle" json:"externalTitle,omitempty"`
ExternalFavicon string `gorm:"column:externalFavicon;default:/favicon.png;NOT NULL" json:"externalFavicon,omitempty"`
Pinned *bool `gorm:"column:pinned;default:false;NOT NULL" json:"pinned,omitempty"`
Ext string `gorm:"column:ext;default:{};NOT NULL" json:"ext,omitempty"`
ShowType *int32 `gorm:"column:showType;default:1;NOT NULL" json:"showType,omitempty"`
User *User `json:"user,omitempty"`
Comments []Comment `json:"comments,omitempty"`
Tags *string `json:"tags,omitempty"`
ImgConfigs *[]*vo.ImgConfig `gorm:"-" json:"imgConfigs,omitempty"`
}

func (m *Memo) TableName() string {
Expand Down
90 changes: 58 additions & 32 deletions backend/handler/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"io"
"os"
"path"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -45,45 +44,47 @@ func (f FileHandler) Upload(c echo.Context) error {
var (
result []string
)

form, err := c.MultipartForm()
if err != nil {
f.base.log.Error().Msgf("读取上传图片异常:%s", err)
return FailRespWithMsg(c, Fail, "上传图片异常")
}
files := form.File["files"]

if err := os.MkdirAll(f.base.cfg.UploadDir, 0755); err != nil {
f.base.log.Error().Msgf("创建父级目录异常:%s", err)
return FailRespWithMsg(c, Fail, "创建父级目录异常")
}

files := form.File["files"]
for _, file := range files {
// Source
// 原始图片
src, err := file.Open()
if err != nil {
f.base.log.Error().Msgf("打开上传图片异常:%s", err)
return FailRespWithMsg(c, Fail, "上传图片异常")
}
defer src.Close()
// Destination

// 创建原始图片
img_filename := strings.ReplaceAll(uuid.NewString(), "-", "")
img_filepath := path.Join(f.base.cfg.UploadDir, img_filename)

thumb_filename := img_filename + "_thumb"
thumb_filepath := path.Join(f.base.cfg.UploadDir, thumb_filename)

if err := os.MkdirAll(filepath.Dir(img_filepath), 0755); err != nil {
f.base.log.Error().Msgf("创建父级目录异常:%s", err)
return FailRespWithMsg(c, Fail, "创建父级目录异常")
}
dst, err := os.Create(img_filepath)
if err != nil {
f.base.log.Error().Msgf("打开目标图片异常:%s", err)
return FailRespWithMsg(c, Fail, "上传图片异常")
}
defer dst.Close()
// Copy

// 保存图片
if _, err = io.Copy(dst, src); err != nil {
f.base.log.Error().Msgf("复制图片异常:%s", err)
return FailRespWithMsg(c, Fail, "上传图片异常")
}

// compress image
// 生成并保存缩略图
thumb_filename := img_filename + "_thumb"
thumb_filepath := path.Join(f.base.cfg.UploadDir, thumb_filename)
if err := CompressImage(f, img_filepath, thumb_filepath, 30); err != nil {
f.base.log.Error().Msgf("压缩图片异常:%s", err)
}
Expand Down Expand Up @@ -113,7 +114,6 @@ type s3PresignedResp struct {
// @Success 200 {object} s3PresignedResp
// @Router /api/file/s3PreSigned [post]
func (f FileHandler) S3PreSigned(c echo.Context) error {

var (
req PreSignedReq
sysConfig db.SysConfig
Expand All @@ -126,15 +126,30 @@ func (f FileHandler) S3PreSigned(c echo.Context) error {
if err := f.base.db.First(&sysConfig).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return FailResp(c, Fail)
}

if err := json.Unmarshal([]byte(sysConfig.Content), &sysConfigVo); err != nil {
f.base.log.Error().Msgf("无法反序列化系统配置, %s", err)
return FailRespWithMsg(c, Fail, err.Error())
}
cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(sysConfigVo.S3.Region),
config.WithEndpointResolver(aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) {
return aws.Endpoint{URL: sysConfigVo.S3.Endpoint}, nil
})),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(sysConfigVo.S3.AccessKey, sysConfigVo.S3.SecretKey, "")))

cfg, err := config.LoadDefaultConfig(
context.TODO(),
config.WithRegion(sysConfigVo.S3.Region),
config.WithEndpointResolver(
aws.EndpointResolverFunc(
func(service, region string) (aws.Endpoint, error) {
return aws.Endpoint{URL: sysConfigVo.S3.Endpoint}, nil
},
),
),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(
sysConfigVo.S3.AccessKey,
sysConfigVo.S3.SecretKey,
"",
),
),
)
if err != nil {
f.base.log.Error().Msgf("无法加载SDK配置, %s", err)
return FailRespWithMsg(c, Fail, err.Error())
Expand All @@ -143,22 +158,33 @@ func (f FileHandler) S3PreSigned(c echo.Context) error {
client := s3.NewFromConfig(cfg)
presignedClient := s3.NewPresignClient(client)

key := fmt.Sprintf("moments/%s/%s", time.Now().Format("2006/01/02"), strings.ReplaceAll(uuid.NewString(), "-", ""))
presignedResult, err := presignedClient.PresignPutObject(context.TODO(), &s3.PutObjectInput{
Bucket: aws.String(sysConfigVo.S3.Bucket),
Key: aws.String(key),
ContentType: aws.String(req.ContentType),
}, func(opts *s3.PresignOptions) {
opts.Expires = time.Minute * 5
})
key := fmt.Sprintf(
"moments/%s/%s",
time.Now().Format("2006/01/02"),
strings.ReplaceAll(uuid.NewString(), "-", ""),
)
presignedResult, err := presignedClient.PresignPutObject(
context.TODO(),
&s3.PutObjectInput{
Bucket: aws.String(sysConfigVo.S3.Bucket),
Key: aws.String(key),
ContentType: aws.String(req.ContentType),
},
func(opts *s3.PresignOptions) {
opts.Expires = time.Minute * 5
},
)

if err != nil {
f.base.log.Error().Msgf("无法获取预签名URL, %s", err)
return FailRespWithMsg(c, Fail, fmt.Sprintf("无法获取预签名URL, %s", err))
}

return SuccessResp(c, s3PresignedResp{
PreSignedUrl: presignedResult.URL,
ImageUrl: fmt.Sprintf("%s/%s", sysConfigVo.S3.Domain, key),
})
return SuccessResp(
c,
s3PresignedResp{
PreSignedUrl: presignedResult.URL,
ImageUrl: fmt.Sprintf("%s/%s", sysConfigVo.S3.Domain, key),
},
)
}
60 changes: 57 additions & 3 deletions backend/handler/memo.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
Expand Down Expand Up @@ -93,6 +94,47 @@ type memoListResp struct {
Total int64 `json:"total,omitempty"` //总数
}

/*
这里通过 memo.Imgs 生成 ImgConfigs,ImgConfig 包含图片的原始 Url 和 ThumbUrl
正常情况下这里应该是在上传图片的时候来保存 ImgConfig,而不是在获取 memo 时计算
但是由于历史实现的 memo 模型结构不支持保存额外的图片信息,所以这里先临时实现一个版本,有精力再重构
*/
func (m MemoHandler) handleImgConfigs(sysConfigVO *vo.FullSysConfigVO, memo *db.Memo) {
var imgConfigs []*vo.ImgConfig

imgs := strings.Split(memo.Imgs, ",")
for _, img := range imgs {
if img == "" {
continue
}

imgConfig := &vo.ImgConfig{
Url: &img,
ThumbUrl: &img,
}

if strings.HasPrefix(img, "/upload/") {
thumb_filename := img + "_thumb"
thumb_filepath := path.Join(m.base.cfg.UploadDir, path.Base(thumb_filename))
if fs_util.Exists(thumb_filepath) {
imgConfig.ThumbUrl = &thumb_filename
}
} else if sysConfigVO.S3.ThumbnailSuffix != "" && strings.HasPrefix(img, sysConfigVO.S3.Domain) {
thumbnailSuffix := sysConfigVO.S3.ThumbnailSuffix
if !strings.HasPrefix(thumbnailSuffix, "?") {
thumbnailSuffix = "?" + thumbnailSuffix
}

newThumbUrl := img + thumbnailSuffix
imgConfig.ThumbUrl = &newThumbUrl
}

imgConfigs = append(imgConfigs, imgConfig)
}

memo.ImgConfigs = &imgConfigs
}

// ListMemos godoc
//
// @Tags Memo
Expand Down Expand Up @@ -196,6 +238,10 @@ func (m MemoHandler) ListMemos(c echo.Context) error {
list[i].Comments = comments
}

for i := range list {
m.handleImgConfigs(&sysConfigVO, &list[i])
}

return SuccessResp(c, memoListResp{
List: list,
Total: total,
Expand Down Expand Up @@ -237,15 +283,17 @@ func (m MemoHandler) RemoveMemo(c echo.Context) error {
if memo.Imgs != "" {
imgs := strings.Split(memo.Imgs, ",")
for _, img := range imgs {
if !strings.HasPrefix(img, "/upload/") {
return SuccessResp(c, h{})
if img == "" || !strings.HasPrefix(img, "/upload/") {
continue
}

img := strings.ReplaceAll(img, "/upload/", "")
_ = os.Remove(filepath.Join(m.base.cfg.UploadDir, img))
thumbImg := strings.ReplaceAll(img+"_thumb", "/upload/", "")
_ = os.Remove(filepath.Join(m.base.cfg.UploadDir, thumbImg))
}
}

return SuccessResp(c, h{})
}

Expand Down Expand Up @@ -411,12 +459,16 @@ func (m MemoHandler) SaveMemo(c echo.Context) error {
// @Router /api/memo/get [post]
func (m MemoHandler) GetMemo(c echo.Context) error {
var (
memo db.Memo
memo db.Memo
sysConfig db.SysConfig
sysConfigVO vo.FullSysConfigVO
)

ctx := c.(CustomContext)
currentUser := ctx.CurrentUser()

m.base.db.First(&sysConfig)

id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil {
return FailResp(c, ParamError)
Expand All @@ -443,6 +495,8 @@ func (m MemoHandler) GetMemo(c echo.Context) error {

memo.Comments = comments

m.handleImgConfigs(&sysConfigVO, &memo)

return SuccessResp(c, memo)
}

Expand Down
7 changes: 6 additions & 1 deletion backend/handler/rss.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,14 @@ func getContentWithExt(memo db.Memo, host string) string {
if memo.Imgs != "" {
imgs := strings.Split(memo.Imgs, ",")
for _, img := range imgs {
if img[:7] == "/upload" {
if img == "" {
continue
}

if strings.HasPrefix(img, "/upload/") {
img = host + img
}

content += fmt.Sprintf("\n\n![%s](%s)", img, img)
}
}
Expand Down
5 changes: 5 additions & 0 deletions backend/vo/memo_vo.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,8 @@ type DoubanBook struct {
PubDate string `json:"pubDate,omitempty"` //发布日期
Keywords string `json:"keywords,omitempty"` //关键字
}

type ImgConfig struct {
Url *string `json:"url,omitempty"`
ThumbUrl *string `json:"thumbUrl,omitempty"`
}
2 changes: 1 addition & 1 deletion front/components/Memo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
<div class="flex flex-col gap-2">
<external-url-preview :favicon="item.externalFavicon" :title="item.externalTitle" :url="item.externalUrl"
v-if="item.externalFavicon&&item.externalTitle&&item.externalUrl"/>
<upload-image-preview :imgs="item.imgs||''" :memo-id="item.id"/>
<upload-image-preview :imgs="item.imgs" :imgConfigs="item.imgConfigs" :memo-id="item.id"/>

<music-preview v-if="extJSON.music && extJSON.music.id" v-bind="extJSON.music"/>
<douban-book-preview v-if="extJSON.doubanBook && extJSON.doubanBook.title" :book="extJSON.doubanBook"/>
Expand Down
17 changes: 7 additions & 10 deletions front/components/MemoEdit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ const locationLabel = computed(() => {
})
const handleDragImage = (imgs: string[]) => {
state.imgs = imgs.join(",")
state.imgs = imgs.filter(Boolean).join(",")
}
const updateMusic = (music: MusicDTO) => {
Expand All @@ -186,13 +186,10 @@ const {y: windowY} = useWindowScroll()
const isOpen = ref(false)
const virtualElement = ref({getBoundingClientRect: () => ({})})
const handleRemoveImage = (img: string) => {
const imgs = state.imgs.split(",")
const index = imgs.findIndex(r => r === img)
if (index < 0) {
return
}
imgs.splice(index, 1)
state.imgs = imgs.join(",")
state.imgs = state.imgs
.split(",")
.filter(item => item && item != img)
.join(",")
}
function onContextMenu() {
Expand Down Expand Up @@ -278,9 +275,9 @@ const saveMemo = async () => {
externalFavicon: state.externalUrl ? state.externalFavicon : "",
externalTitle: state.externalTitle,
externalUrl: state.externalUrl,
imgs: state.imgs.split(','),
imgs: state.imgs.split(",").filter(Boolean),
location: state.location,
tags: selectedLabel.value
tags: selectedLabel.value,
})
toast.success("保存成功!")
await navigateTo('/')
Expand Down
Loading