169 lines
5.4 KiB
Go
169 lines
5.4 KiB
Go
package main
|
||
|
||
// avatar.go 实现用户头像的几种来源:
|
||
// base64(选本地图缩放后存 dataURL,可随账号同步)、url(OSS/图床直链)、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 把本地图片读成缩放后的 dataURL(path 模式启动时用来渲染头像)。
|
||
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
|
||
}
|