Files
code-utils/avatar.go
2026-08-14 07:52:01 +08:00

169 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}