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 }