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
|
|||
|
|
}
|