diff --git a/app.go b/app.go index b0f6bf9..6adb547 100644 --- a/app.go +++ b/app.go @@ -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 返回当前保留的全部日志,「运行日志」窗口打开时调用一次。 diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue index 555a821..84b2720 100644 --- a/frontend/src/views/ProductListView.vue +++ b/frontend/src/views/ProductListView.vue @@ -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(() => { + + + +
+ +
+ +
@@ -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; diff --git a/main.go b/main.go index 8c18139..5288726 100644 --- a/main.go +++ b/main.go @@ -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, }, })