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:
@@ -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 返回当前保留的全部日志,「运行日志」窗口打开时调用一次。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user