feat: 商品主图可点击查看原图,支持右键另存为

- 表格主图列的缩略图可点击,弹出原图弹窗,鼠标指针改为 zoom-in
- main.go 打开 EnableDefaultContextMenu,这样在弹窗图片上右键才有
  「图片另存为」。代价是整个界面右键都会出现浏览器菜单,其中包含
  「检查」,同事可能误点开发者工具;权衡后仍然打开,因为另存图片是
  实际需要,误点开发者工具关掉即可,没有破坏性。同时移除 main.go 中
  已不再成立的「关掉右键检查」注释。
- 弹窗另配一个「另存为」按钮:WebView2 的原生右键菜单在个别机器上
  不给图片选项,这条路径不依赖浏览器菜单
- app.go 新增 SaveImageAs:只接受 http/https,避免变成可读取本地
  任意文件的入口;30 秒超时、20 MB 上限、空响应判失败
- 原图限制在 70vh 内并可滚动,避免大图撑出屏幕

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbdtsD3ohhSMy3KPoCgARq
This commit is contained in:
QiuSW
2026-09-03 10:24:18 +08:00
co-authored by Claude Opus 5
parent 73f5688ab1
commit 46378db86b
3 changed files with 157 additions and 3 deletions
+67
View File
@@ -4,10 +4,13 @@ import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
@@ -631,6 +634,70 @@ func (a *App) OpenInBrowser(rawURL string) error {
return nil
}
// SaveImageAs 把一张网络图片下载并另存到使用者选择的位置。
//
// 弹窗里的「另存为」按钮用它。WebView2 的原生右键菜单在个别机器上
// 不给图片选项,这里提供一条不依赖浏览器菜单的路径。
//
// 只接受 http/https,避免变成可以读取本地任意文件的入口。
func (a *App) SaveImageAs(imageURL string) (string, error) {
if !strings.HasPrefix(imageURL, "http://") && !strings.HasPrefix(imageURL, "https://") {
return "", fmt.Errorf("只能保存 http 或 https 图片")
}
// 从地址里猜一个默认文件名,猜不出就用时间戳。
name := path.Base(strings.SplitN(imageURL, "?", 2)[0])
if name == "" || name == "." || name == "/" || !strings.Contains(name, ".") {
name = fmt.Sprintf("商品主图-%s.jpg", time.Now().Format("20060102-150405"))
}
target, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "保存商品主图",
DefaultFilename: name,
Filters: []runtime.FileFilter{
{DisplayName: "图片 (*.jpg;*.jpeg;*.png;*.webp)", Pattern: "*.jpg;*.jpeg;*.png;*.webp"},
{DisplayName: "所有文件 (*.*)", Pattern: "*.*"},
},
})
if err != nil {
return "", err
}
if target == "" {
// 使用者点了取消,不算错误。
return "", nil
}
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return "", fmt.Errorf("构造图片请求失败:%w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("下载图片失败:%w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("下载图片失败:HTTP %d", resp.StatusCode)
}
// 限制 20 MB,避免异常地址把内存吃光。
data, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
if err != nil {
return "", fmt.Errorf("读取图片失败:%w", err)
}
if len(data) == 0 {
return "", fmt.Errorf("下载到的图片是空的")
}
if err := os.WriteFile(target, data, 0o600); err != nil {
return "", fmt.Errorf("保存图片失败:%w", err)
}
a.log.Success("商品主图已保存:%s", target)
return target, nil
}
// ---------------------------------------------------------------- 运行日志
// GetLogs 返回当前保留的全部日志,「运行日志」窗口打开时调用一次。
+82 -1
View File
@@ -33,6 +33,7 @@ import {
GetShopsUpdatedAt,
RefreshShops,
OpenFolder,
SaveImageAs,
OpenInBrowser,
DownloadVideos,
ListProducts,
@@ -202,6 +203,35 @@ const total = ref(0)
const loading = ref(false)
const checkedIds = ref([])
const searchingProductID = ref('')
// ---- 主图原图弹窗 ----
// 点缩略图弹出原图。弹窗里的图片可以直接右键「图片另存为」
// (main.go 打开了 EnableDefaultContextMenu),
// 另外配一个「另存为」按钮,个别机器的 WebView2 右键菜单不给图片选项时仍可用。
const imageShow = ref(false)
const imageURL = ref('')
const imageTitle = ref('')
const imageSaving = ref(false)
function openImage(row) {
if (!row.mainImage) return
imageURL.value = row.mainImage
imageTitle.value = row.itemName || row.itemId || '商品主图'
imageShow.value = true
}
async function saveImage() {
imageSaving.value = true
try {
const saved = await SaveImageAs(imageURL.value)
// 使用者点了取消时后端返回空字符串,不算失败,也不用提示。
if (saved) message.success(`已保存到 ${saved}`)
} catch (err) {
message.error(`保存失败:${err}`)
} finally {
imageSaving.value = false
}
}
const videoSummaries = ref({})
const similarModalVisible = ref(false)
const similarItems = ref([])
@@ -216,7 +246,12 @@ const columns = [
width: 64,
render: (row) =>
row.mainImage
? h('img', { src: row.mainImage, class: 'thumb' })
? h('img', {
src: row.mainImage,
class: 'thumb clickable',
title: '点击查看原图',
onClick: () => openImage(row),
})
: h('span', { class: 'thumb empty' }, 'IMG'),
},
{ title: '蝦皮ID', key: 'itemId', width: 118 },
@@ -603,6 +638,25 @@ onUnmounted(() => {
</div>
</n-spin>
</n-modal>
<!-- 主图原图弹窗。图片可直接右键「图片另存为」,
另配一个按钮兜底。 -->
<n-modal
v-model:show="imageShow"
preset="card"
:title="imageTitle"
style="width: auto; max-width: 90vw"
>
<div class="image-box">
<img :src="imageURL" class="full-image" :alt="imageTitle" />
</div>
<template #footer>
<div class="image-foot">
<span class="faint">在图片上点右键可以「图片另存为」</span>
<n-button size="small" :loading="imageSaving" @click="saveImage">另存为</n-button>
</div>
</template>
</n-modal>
</div>
</template>
@@ -694,6 +748,33 @@ onUnmounted(() => {
color: var(--faint);
}
.image-box {
display: flex;
justify-content: center;
/* 原图可能很大,限制在视口内,避免弹窗撑出屏幕 */
max-height: 70vh;
overflow: auto;
}
.full-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.image-foot {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.faint {
color: var(--faint);
font-size: 11px;
}
:deep(.thumb.clickable) {
cursor: zoom-in;
}
:deep(.ops) {
display: flex;
gap: 12px;
+8 -2
View File
@@ -62,14 +62,20 @@ func main() {
},
OnStartup: app.startup,
OnShutdown: app.shutdown,
// 打开浏览器默认右键菜单,这样在商品主图弹窗上右键才有「图片另存为」。
//
// 代价:整个界面右键都会出现浏览器菜单,其中包含「检查」,
// 同事可能误点开发者工具。权衡后仍然打开,因为另存图片是实际需要,
// 误点开发者工具关掉即可,没有破坏性。
// 弹窗里另外放了一个「另存为」按钮,WebView2 的原生菜单在个别机器上
// 不给图片选项时仍然可用。
EnableDefaultContextMenu: true,
// Bind 里的对象会被生成成 JavaScript 给前端调用。
// 新增可供前端调用的方法,就写成 App 的公开方法,不用改这里。
Bind: []any{
app,
},
Windows: &windows.Options{
// 关掉右键菜单里的「检查」等开发者项,避免同事误点。
// 需要调试时用 wails dev,那时会自动打开。
DisableWindowIcon: false,
},
})