Files
code-utils/avatar.go

169 lines
5.4 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
package main
// avatar.go 实现用户头像的几种来源:
// base64选本地图缩放后存 dataURL可随账号同步、urlOSS/图床直链、path本地路径仅本机显示
// 当管理员把全局文件存储方式配成 server 时,选图走 server 模式:缩放后上传 nl-pms-api
// 以 url 模式保存返回的 http URL显示与同步复用现有 url 逻辑)。
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
"image/png"
"os"
"strings"
_ "golang.org/x/image/webp"
"github.com/wailsapp/wails/v3/pkg/application"
"golang.org/x/image/draw"
)
const (
avatarMaxFileBytes = 10 << 20 // 原始文件上限 10MB
avatarMaxEdge = 256 // 存储/显示的最长边
)
// PickAvatarImage 打开图片选择框。mode=auto推荐入口由管理员的全局文件存储
// 配置决定走向server 时缩放后上传 nl-pms-api 以 url 模式保存,否则存 base64。
// 兼容旧 mode 取值base64 存 dataURL、path 存本地路径、server 强制上传。
// 返回的 Mode 是实际采用的存储模式Preview 一律为可立即显示的 dataURL。
// 用户取消选择时返回零值。
func (a *App) PickAvatarImage(mode string) (AvatarPick, error) {
if e := a.ready(); e != nil {
return AvatarPick{}, e
}
p, e := application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择头像图片", "Choose avatar image")).
AddFilter(a.localized("图片文件", "Image files"), "*.png;*.jpg;*.jpeg;*.gif;*.webp").
PromptForSingleSelection()
if e != nil || strings.TrimSpace(p) == "" {
return AvatarPick{}, e
}
data, mime, e := encodeAvatarFile(p)
if e != nil {
return AvatarPick{}, e
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data))
if mode == "auto" {
// 上传时实时获取全局配置,管理员切换存储方式后立即生效。
if a.currentFileStorage().Mode == "server" {
mode = "server"
} else {
mode = "base64"
}
}
switch mode {
case "path":
return AvatarPick{Mode: "path", Value: p, Preview: dataURL}, nil
case "server":
url, e := a.uploadImageToServer(data, mime, "avatar")
if e != nil {
return AvatarPick{}, e
}
// 落库为 url 模式:显示与同步复用现有 url 逻辑。
return AvatarPick{Mode: "url", Value: url, Preview: dataURL}, nil
}
return AvatarPick{Mode: "base64", Value: dataURL, Preview: dataURL}, nil
}
// ReadImageAsDataURL 把本地图片读成缩放后的 dataURLpath 模式启动时用来渲染头像)。
func (a *App) ReadImageAsDataURL(path string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
return imageFileToDataURL(path)
}
// ensureDefaultAvatar 给尚未设置头像的本机用户设默认头像:内嵌应用 logo 缩放后的
// base64 dataURL。注册成功后调用随后的自动登录同步会把它推成账号头像
// sync_settings 的 avatar + user_profiles 的 64px 缩略图),队友即可看到默认 logo 头像。
// 本机已设置过头像则不覆盖;失败静默(默认头像不值得打断注册流程)。
func (a *App) ensureDefaultAvatar() {
st, e := a.store.Settings()
if e != nil || strings.TrimSpace(st.AvatarValue) != "" {
return
}
data, mime, e := encodeImageEdge(appIcon, avatarMaxEdge)
if e != nil {
return
}
st.AvatarMode = "base64"
st.AvatarValue = fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data))
// SaveSettings 检测到头像变更会自动记录 avatar_updated_at触发后续同步推送。
if e := a.store.SaveSettings(st); e == nil {
a.store.Log("info", "同步", "已设置默认头像", "应用 logo")
}
}
// encodeAvatarFile 读取图片、把最长边缩放到 avatarMaxEdge 以内,返回编码后的字节与 mime。
// 源为 JPEG 时输出 JPEG体积小其余格式输出 PNG保留透明度
func encodeAvatarFile(path string) ([]byte, string, error) {
info, e := os.Stat(path)
if e != nil {
return nil, "", errors.New("FILE_NOT_FOUND")
}
if info.Size() > avatarMaxFileBytes {
return nil, "", errors.New("AVATAR_FILE_TOO_LARGE")
}
raw, e := os.ReadFile(path)
if e != nil {
return nil, "", errors.New("FILE_READ_FAILED")
}
src, format, e := image.Decode(bytes.NewReader(raw))
if e != nil {
return nil, "", errors.New("AVATAR_DECODE_FAILED")
}
img := downscaleImage(src, avatarMaxEdge)
var buf bytes.Buffer
mime := "image/png"
if format == "jpeg" {
mime = "image/jpeg"
e = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
} else {
e = png.Encode(&buf, img)
}
if e != nil {
return nil, "", errors.New("AVATAR_DECODE_FAILED")
}
return buf.Bytes(), mime, nil
}
// imageFileToDataURL 读取并缩放图片,编码为可直接显示的 dataURL。
func imageFileToDataURL(path string) (string, error) {
data, mime, e := encodeAvatarFile(path)
if e != nil {
return "", e
}
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data)), nil
}
// downscaleImage 等比缩放到最长边 ≤ maxEdge本身足够小则原样返回。
func downscaleImage(src image.Image, maxEdge int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxEdge && h <= maxEdge {
return src
}
if w >= h {
h = h * maxEdge / w
w = maxEdge
} else {
w = w * maxEdge / h
h = maxEdge
}
if w < 1 {
w = 1
}
if h < 1 {
h = 1
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst
}