更新若干功能
This commit is contained in:
168
avatar.go
Normal file
168
avatar.go
Normal file
@@ -0,0 +1,168 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user