feat: 初始化 Wails v2 + Vue3 骨架与 SQLite 存储 (#4)

建立可运行、可构建的 Go 骨架,供后续 R1~R5 在其上叠加。

后端:
- main.go / app.go:Wails v2 窗口与 14 个前端可调方法。Wails 专有 API
  只出现在这两个文件,internal/ 对 Wails 零依赖,便于将来迁移 v3
- internal/config:config.yaml 的读写、校验、脱敏。保存时按固定模板
  渲染以保留注释,不用 yaml.Marshal
- internal/store:SQLite(modernc.org/sqlite,无需 CGO)。建表迁移、
  商品仓储、KV。重复同步不会覆盖本地下载与上传状态
- internal/logx:9 条脱敏规则,覆盖淘宝 token、Cookie、Authorization、
  密码和图片 base64;并发安全的内存日志缓冲

前端(Vue 3 + Naive UI,按已确认原型 v4):
- Sidebar:176px 可折叠,仅商品列表与参数设置两项,底部是登录状态的
  唯一显示位置
- ProductListView:两行工具栏(四项筛选 + 重置搜索 / 四个操作按钮)、
  商品表格、分页
- SettingsView:四张卡片,路径用文件与目录选择框
- LogWindow:运行日志子窗口,按级别过滤、自动滚动、导出

配置改用 YAML:分 huohanhan / taobao / download 三段。taobao 段刻意
没有账号密码字段,并有单元测试挡住后续加入。

构建相关:
- .gitignore 只忽略 build/bin/,不忽略 build/。Wails 把 build/ 当源码
  目录(图标、manifest),忽略掉会让别人克隆后构建失败
- .gitignore 忽略 config.yaml、payloads/ 和 *.har(含真实手机号与凭据)

未实现的下载数据、下载视频、上传数据三个方法返回明确的「还没实现」,
不静默失败。

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-02 15:59:54 +08:00
co-authored by Claude Opus 5
parent c5c47f98f9
commit 2cefa67001
38 changed files with 6382 additions and 1 deletions
+30 -1
View File
@@ -17,5 +17,34 @@ __pycache__/
.coverage
coverage/
dist/
build/
node_modules/
# 注意:这里故意没有 build/。
# Wails 把 build/ 当源码目录用(图标、Windows manifest、打包信息),
# 必须提交进 Git,否则别人克隆后 wails build 会失败。
# 真正的构建产物是 build/bin/,在下面单独忽略。
# ---- cmsp 产品代码 ----
# 本机配置:含货憨憨账号密码,绝不能提交。
# 模板见 config.example.yaml(那份不含真实凭据,是进 Git 的)。
config.yaml
# 本地数据库与运行产物
*.db
*.db-shm
*.db-wal
运行数据/
# Go 与 Wails 构建产物
/build/bin/
/frontend/dist/
/frontend/node_modules/
# Wails 自动生成的前端绑定,由 wails dev/build 重新生成
/frontend/wailsjs/go/
# 抓包与接口样本:含真实账号、手机号、Cookie 和 token,绝不能提交。
# 需要给别人看时先脱敏,另存到 docs/ 下并说明来源。
payloads/
*.har
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"context"
"fmt"
"time"
"cmsp/internal/config"
"cmsp/internal/logx"
"cmsp/internal/store"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App 是暴露给前端的对象。
//
// 重要约定(会决定将来能不能顺利升级到 Wails v3):
//
// - 只有 main.go 和 app.go 这两个文件允许 import wails 包。
// internal/ 下面的业务代码一律不许依赖 wails,这样将来换框架
// 只用改这两个文件。
//
// - 本文件里的方法只做三件事:校验参数、调用 internal 里的业务代码、
// 把结果转成前端能用的形式。不要在这里写业务逻辑。
//
// - 每个公开方法都会被 Wails 生成成 JavaScript 函数给前端调用,
// 所以方法名和参数类型要稳定,改名等于改接口。
type App struct {
ctx context.Context
cfgPath string
cfg config.Config
db *store.Store
log *logx.Logger
}
// NewApp 创建应用对象。真正的初始化在 startup 里做。
func NewApp() *App {
return &App{
cfgPath: config.DefaultPath(),
cfg: config.Default(),
log: logx.New(2000),
}
}
// startup 在窗口创建后被 Wails 调用。
//
// 这里做的事要尽量少、尽量不出错:这时候界面已经显示了,
// 抛错的话使用者只会看到一个空白窗口,不知道发生了什么。
// 所以出错时记录到日志,让界面能打开,由使用者去「参数设置」里修。
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 日志写入后推给前端,「运行日志」窗口就能实时刷新。
a.log.SetHandler(func(e logx.Entry) {
runtime.EventsEmit(ctx, "log:entry", e)
})
a.log.Info("cmsp 启动")
cfg, err := config.Load(a.cfgPath)
if err != nil {
// 配置读不出来不算致命:用默认配置继续,让使用者能进设置页修。
a.log.Error("读取配置失败,已使用默认配置:%v", err)
cfg = config.Default()
}
a.cfg = cfg
a.log.Info("配置文件:%s", a.cfgPath)
dbPath := store.DefaultPath()
db, err := store.Open(dbPath)
if err != nil {
// 数据库打不开就没法干活了,但仍然让窗口显示,
// 并把原因写在日志里,比直接闪退好排查。
a.log.Error("打开数据库失败:%v", err)
return
}
a.db = db
a.log.Success("数据库就绪:%s", dbPath)
}
// shutdown 在窗口关闭时被 Wails 调用,用来收尾。
func (a *App) shutdown(ctx context.Context) {
if a.db != nil {
if err := a.db.Close(); err != nil {
a.log.Warn("关闭数据库出错:%v", err)
}
}
}
// ---------------------------------------------------------------- 配置
// GetConfig 返回当前配置给「参数设置」页显示。
//
// 注意这里返回的是含密码的完整配置:设置页本来就要显示密码框,
// 前端不会把它写进日志。真正需要脱敏的是日志和工单。
func (a *App) GetConfig() config.Config {
return a.cfg
}
// SaveConfig 校验并保存配置。
//
// 返回的 error 会变成前端的 Promise reject,界面直接把消息弹出来,
// 所以 Validate 里的错误信息必须是能看懂的中文。
func (a *App) SaveConfig(cfg config.Config) error {
if err := config.Save(a.cfgPath, cfg); err != nil {
a.log.Error("保存配置失败:%v", err)
return err
}
a.cfg = cfg
a.log.Success("配置已保存")
return nil
}
// ResetConfig 把配置还原成默认值,但不写文件。
// 使用者还要点「保存设置」才会真正生效,避免手滑丢掉已填的账号。
func (a *App) ResetConfig() config.Config {
return config.Default()
}
// ---------------------------------------------------------------- 路径选择
// PickChromeExe 弹出文件选择框让使用者挑 chrome.exe。
//
// 用文件选择框而不是目录选择框,因为这个配置项指向的是文件本身。
// 使用者点了取消时返回空字符串,前端要判断一下再覆盖原值。
func (a *App) PickChromeExe() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择 chrome.exe",
DefaultDirectory: `C:\Program Files\Google\Chrome\Application`,
Filters: []runtime.FileFilter{
{DisplayName: "Chrome 可执行文件 (chrome.exe)", Pattern: "chrome.exe"},
{DisplayName: "可执行文件 (*.exe)", Pattern: "*.exe"},
},
})
}
// PickDirectory 弹出目录选择框,用于用户数据目录和视频保存目录。
func (a *App) PickDirectory(title string) (string, error) {
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: title,
})
}
// ---------------------------------------------------------------- 商品
// ListProducts 按条件分页查询本地商品。
//
// 注意这里查的是本地 SQLite,不会去请求货憨憨。
// 只有点「下载数据」时才会真正联网。
func (a *App) ListProducts(query store.ProductQuery) (store.ProductPage, error) {
if a.db == nil {
return store.ProductPage{}, fmt.Errorf("数据库未就绪,请查看运行日志")
}
return a.db.ListProducts(query)
}
// CountProducts 返回本地商品总数。
func (a *App) CountProducts() (int, error) {
if a.db == nil {
return 0, fmt.Errorf("数据库未就绪,请查看运行日志")
}
return a.db.CountProducts()
}
// ---------------------------------------------------------------- 运行日志
// GetLogs 返回当前保留的全部日志,「运行日志」窗口打开时调用一次。
// 之后的新日志靠 log:entry 事件推送,不用轮询。
func (a *App) GetLogs() []logx.Entry {
return a.log.Entries()
}
// ClearLogs 清空日志。
func (a *App) ClearLogs() {
a.log.Clear()
}
// ExportLogs 让使用者选个位置,把日志存成 txt。
func (a *App) ExportLogs() (string, error) {
name := fmt.Sprintf("cmsp-日志-%s.txt", time.Now().Format("20060102-150405"))
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "导出运行日志",
DefaultFilename: name,
Filters: []runtime.FileFilter{
{DisplayName: "文本文件 (*.txt)", Pattern: "*.txt"},
},
})
if err != nil {
return "", err
}
if path == "" {
// 使用者点了取消,不算错误。
return "", nil
}
if err := writeTextFile(path, a.log.Text()); err != nil {
a.log.Error("导出日志失败:%v", err)
return "", err
}
a.log.Success("日志已导出:%s", path)
return path, nil
}
// ---------------------------------------------------------------- 占位方法
//
// 下面这些是界面上已有按钮、但功能还没实现的方法。
// 先返回明确的「还没实现」,比让按钮点了没反应好——
// 同事至少知道是没做,而不是坏了。
//
// 实现顺序见 Wiki 需求总览:R1 下载数据 → R2/R3 淘宝登录与图搜
// → R4 下载视频 → R5 上传数据。
// DownloadProductData 从货憨憨拉取商品列表到本地(需求 R1)。
func (a *App) DownloadProductData() error {
return fmt.Errorf("「下载数据」还没实现,见工单 R1")
}
// DownloadVideos 对选中的商品搜同款并下载视频(需求 R3、R4)。
func (a *App) DownloadVideos(productIDs []string) error {
return fmt.Errorf("「下载视频」还没实现,见工单 R3 和 R4")
}
// UploadVideos 把本地视频上传回货憨憨(需求 R5)。
func (a *App) UploadVideos(productIDs []string) error {
return fmt.Errorf("「上传数据」还没实现,见工单 R5")
}
// ListShops 返回当前账号可用的店铺(需求 R1)。
//
// 真正实现时要调用货憨憨的 erp/shop/all,不要在界面里写死店铺列表。
func (a *App) ListShops() ([]Shop, error) {
return nil, fmt.Errorf("店铺列表还没实现,见工单 R1")
}
// Shop 是一个店铺,对应货憨憨 erp/shop/all 的返回。
type Shop struct {
PlatformShopID string `json:"platformShopId"` // 平台店铺 ID
ShopName string `json:"shopName"` // 店铺名
Region string `json:"region"` // 地区,例如 TW
}
+35
View File
@@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{safeBundleID .Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{safeBundleID .Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}
+122
View File
@@ -0,0 +1,122 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!ifdef WAILS_INSTALL_SCOPE
!if "${WAILS_INSTALL_SCOPE}" == "user"
InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!else
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}"
!endif
!else
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}"
!endif # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd
+284
View File
@@ -0,0 +1,284 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
!ifdef WAILS_INSTALL_SCOPE
!if "${WAILS_INSTALL_SCOPE}" == "user"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
!else
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
!endif
!else
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
!endif
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
!ifdef WAILS_INSTALL_SCOPE
!if "${WAILS_INSTALL_SCOPE}" == "user"
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!else
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!endif
!else
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!endif
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
!ifdef WAILS_INSTALL_SCOPE
!if "${WAILS_INSTALL_SCOPE}" == "user"
DeleteRegKey HKCU "${UNINST_KEY}"
!else
DeleteRegKey HKLM "${UNINST_KEY}"
!endif
!else
DeleteRegKey HKLM "${UNINST_KEY}"
!endif
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
{{range .Info.FileAssociations}}
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
File "..\{{.IconName}}.ico"
{{end}}
!macroend
!macro wails.unassociateFiles
; Delete app associations
{{range .Info.FileAssociations}}
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
Delete "$INSTDIR\{{.IconName}}.ico"
{{end}}
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
{{end}}
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
{{end}}
!macroend
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
+69
View File
@@ -0,0 +1,69 @@
# cmsp 本机配置模板
#
# 用法:把本文件复制成 config.yaml,填入真实账号密码。
#
# Windows: copy config.example.yaml config.yaml
#
# 也可以不复制:直接启动程序,在「参数设置」页填写并保存,
# 程序会自动生成 config.yaml。
#
# [必须] config.yaml 已在 .gitignore 里,绝不能提交进 Git——
# 密码一旦进了 git 历史就很难清干净。
# [必须] 打包发给同事时也不要带上 config.yaml。
# [必须] 密码要用双引号包起来。纯数字密码不加引号会被 YAML 当成
# 整数,读取时直接报错,前导 0 也会丢。
#
# 各项含义见 Wiki「本地开发与验证」和「参数设置」页上的说明。
huohanhan:
# 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。
base_url: https://www.huohanhan.com
# 登录账号。
account: 你的货憨憨账号
# [必须] 登录密码,加双引号。
password: "你的密码"
# 登录验证码的自动识别服务。
# [注意] 验证码图片会被发送到这个地址,换成别人的服务前先评估一下。
ocr_url: https://ocr.ilapage.cn/ocr
taobao:
# [必须] 这里没有淘宝账号和密码,将来也不要加。
# 淘宝登录必须由你在程序打开的专属 Chrome 里手动扫码完成,
# 登录态保存在下面的 user_data_dir 里,程序不读取也不保存。
# 程序不会代填密码,也不会绕过验证码或滑块。
# chrome.exe 的完整路径。注意是文件不是目录。
# 在「参数设置」页可以用「选择文件」按钮挑,不用手打。
chrome_path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
# 专属 Chrome 的用户数据目录,淘宝登录态就存在这里。
# 默认在 %LOCALAPPDATA%\电商视频自动下载工具\淘宝浏览器\默认账号
# [注意] 改了这个路径就要重新扫码登录一次,不要随手改。
# 这个目录不要提交、打包或共享。
user_data_dir: "C:\\Users\\你的用户名\\AppData\\Local\\电商视频自动下载工具\\淘宝浏览器\\默认账号"
# 给专属 Chrome 分配调试端口的范围。端口被占用时会往后找。
debug_port_start: 19666
debug_port_end: 19765
download:
# 下载的视频保存到哪个目录。
video_dir: "D:\\chengma\\cmsp\\运行数据\\视频"
# 每个商品最多下载几个视频,允许 1—10。
max_videos_per_product: 3
# 图搜结果里取前几个同款去找视频,允许 1—60。取太多会明显变慢。
search_top_n: 20
# 同时下载几个视频,允许 1—8。
# [注意] 调大会提高淘宝风控概率,没有实测结论前不要超过 3。
concurrency: 2
# 处理完一个商品后随机等待的秒数区间,用来降低被风控的概率。
# 改小了跑得快但更容易被拦,不建议低于 2 秒。
wait_seconds_min: 2
wait_seconds_max: 4
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cmsp</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1592
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "cmsp-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"naive-ui": "^2.40.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
8fffac55453e796accbe757622758e58
+61
View File
@@ -0,0 +1,61 @@
<script setup>
/**
* 应用外壳:左边导航,右边内容。
*
* 页面切换用 v-show 而不是 v-if,这样从设置页切回商品列表时
* 表格不用重新查一遍数据库。
*/
import { ref } from 'vue'
import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN, dateZhCN } from 'naive-ui'
import Sidebar from './components/Sidebar.vue'
import LogWindow from './components/LogWindow.vue'
import ProductListView from './views/ProductListView.vue'
import SettingsView from './views/SettingsView.vue'
// 'list' 或 'set'
const page = ref('list')
const collapsed = ref(false)
const showLogs = ref(false)
// 登录状态。R1 和 R2 实现后由后端事件更新,现在先都当未登录。
const huohanhanOk = ref(false)
const taobaoOk = ref(false)
</script>
<template>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<n-message-provider>
<n-dialog-provider>
<div class="shell">
<Sidebar
:active="page"
:collapsed="collapsed"
:huohanhan-ok="huohanhanOk"
:taobao-ok="taobaoOk"
@navigate="page = $event"
@toggle="collapsed = !collapsed"
/>
<main class="content">
<ProductListView v-show="page === 'list'" @open-logs="showLogs = true" />
<SettingsView v-show="page === 'set'" />
</main>
<LogWindow v-model:show="showLogs" />
</div>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<style scoped>
.shell {
display: flex;
height: 100vh;
}
.content {
flex: 1;
min-width: 0;
height: 100%;
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<script setup>
/**
* 运行日志子窗口。
*
* 为什么做成弹窗而不是常驻面板:表格要占满高度。
* 但任务运行时使用者需要同时看进度,所以主界面顶部会保留一条
* 精简任务条(那部分等任务引擎 R7 做)。
*
* 日志已经在 Go 侧脱敏(internal/logx),前端直接显示即可,
* 不要在这里再拼接原始数据。
*/
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { EventsOff, EventsOn } from '../../wailsjs/runtime/runtime'
import { ClearLogs, ExportLogs, GetLogs } from '../../wailsjs/go/main/App'
const props = defineProps({
show: { type: Boolean, default: false },
})
const emit = defineEmits(['update:show'])
const entries = ref([])
const filter = ref('all')
const autoScroll = ref(true)
const bodyRef = ref(null)
const filters = [
{ key: 'all', label: '全部' },
{ key: 'info', label: '信息' },
{ key: 'success', label: '成功' },
{ key: 'error', label: '失败' },
]
function visible() {
if (filter.value === 'all') return entries.value
if (filter.value === 'error') {
// 「失败」把警告也算进来,排查问题时不想漏。
return entries.value.filter((e) => e.level === 'error' || e.level === 'warn')
}
return entries.value.filter((e) => e.level === filter.value)
}
async function scrollToBottom() {
if (!autoScroll.value) return
await nextTick()
const el = bodyRef.value
if (el) el.scrollTop = el.scrollHeight
}
function onEntry(entry) {
entries.value.push(entry)
scrollToBottom()
}
async function load() {
entries.value = (await GetLogs()) || []
scrollToBottom()
}
async function clear() {
await ClearLogs()
entries.value = []
}
async function exportLogs() {
await ExportLogs()
}
// 窗口打开时拉一次全量,之后靠事件增量追加,不轮询。
watch(
() => props.show,
(open) => {
if (open) load()
}
)
onMounted(() => {
EventsOn('log:entry', onEntry)
})
onUnmounted(() => {
EventsOff('log:entry')
})
</script>
<template>
<n-modal
:show="show"
preset="card"
title="运行日志"
style="width: 760px"
@update:show="emit('update:show', $event)"
>
<div class="bar">
<n-radio-group v-model:value="filter" size="small">
<n-radio-button v-for="f in filters" :key="f.key" :value="f.key" :label="f.label" />
</n-radio-group>
<span class="spacer" />
<n-checkbox v-model:checked="autoScroll">自动滚动</n-checkbox>
</div>
<div ref="bodyRef" class="log-body">
<div v-for="(e, i) in visible()" :key="i" class="line">
<span class="time">{{ e.time }}</span>
<span :class="['msg', e.level]">{{ e.message }}</span>
</div>
<div v-if="visible().length === 0" class="empty">暂无日志</div>
</div>
<template #footer>
<div class="foot">
<span class="faint">日志保存在内存里,程序关闭后不保留。需要留存请点「导出日志」。</span>
<div>
<n-button size="small" @click="exportLogs">导出日志</n-button>
<n-button size="small" style="margin-left: 8px" @click="clear">清空</n-button>
</div>
</div>
</template>
</n-modal>
</template>
<style scoped>
.bar {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.spacer {
flex: 1;
}
.log-body {
height: 352px;
overflow: auto;
background: #0b1220;
border-radius: 6px;
padding: 12px 16px;
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
}
.line {
display: flex;
gap: 12px;
line-height: 22px;
}
.time {
color: #64748b;
flex-shrink: 0;
}
.msg {
color: #cbd5e1;
word-break: break-all;
}
.msg.success {
color: #4ade80;
}
.msg.error {
color: #f87171;
}
.msg.warn {
color: #fbbf24;
}
.empty {
color: #64748b;
text-align: center;
padding-top: 140px;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.faint {
color: var(--faint);
font-size: 11px;
}
</style>
+207
View File
@@ -0,0 +1,207 @@
<script setup>
/**
* 左侧导航栏。
*
* 只有两项:商品列表、参数设置。不要在这里加筛选器——
* 商品状态筛选属于工具栏,放两处会让人搞不清哪个说了算。
*
* 底部的两个状态灯是货憨憨和淘宝登录状态的唯一显示位置。
* 界面别处不要重复显示,否则两边不一致时没人知道该信哪个。
*/
defineProps({
// 当前选中哪一项:'list' 或 'set'
active: { type: String, required: true },
// 是否收起。收起后只剩图标,表格能多出 112px。
collapsed: { type: Boolean, default: false },
// 货憨憨是否已登录
huohanhanOk: { type: Boolean, default: false },
// 淘宝登录是否有效
taobaoOk: { type: Boolean, default: false },
})
const emit = defineEmits(['navigate', 'toggle'])
const items = [
{ key: 'list', icon: '▤', label: '商品列表' },
{ key: 'set', icon: '⚙', label: '参数设置' },
]
</script>
<template>
<aside class="sidebar" :class="{ mini: collapsed }">
<div class="brand">
<template v-if="collapsed">
<div class="brand-mini">cm</div>
</template>
<template v-else>
<div class="brand-name">cmsp</div>
<div class="brand-sub">Shopee 商品视频</div>
</template>
</div>
<nav>
<button
v-for="item in items"
:key="item.key"
class="nav-item"
:class="{ on: active === item.key }"
:title="collapsed ? item.label : ''"
@click="emit('navigate', item.key)"
>
<span class="nav-icon">{{ item.icon }}</span>
<span v-if="!collapsed" class="nav-label">{{ item.label }}</span>
</button>
</nav>
<div class="foot">
<div class="status" :title="huohanhanOk ? '货憨憨 已登录' : '货憨憨 未登录'">
<i class="dot" :class="huohanhanOk ? 'ok' : 'bad'"></i>
<span v-if="!collapsed">货憨憨 {{ huohanhanOk ? '已登录' : '未登录' }}</span>
</div>
<div class="status" :title="taobaoOk ? '淘宝 登录有效' : '淘宝 登录失效'">
<i class="dot" :class="taobaoOk ? 'ok' : 'bad'"></i>
<span v-if="!collapsed" :class="{ warnText: !taobaoOk }">
淘宝 {{ taobaoOk ? '登录有效' : '登录失效' }}
</span>
</div>
<button class="toggle" :title="collapsed ? '展开导航' : '收起导航'" @click="emit('toggle')">
<span class="nav-icon">{{ collapsed ? '»' : '«' }}</span>
<span v-if="!collapsed" class="nav-label">收起导航</span>
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: var(--sb-open);
height: 100%;
background: var(--sb-bg);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: width 0.15s ease;
}
.sidebar.mini {
width: var(--sb-mini);
}
.brand {
height: 60px;
background: var(--sb-brand);
padding: 0 20px;
display: flex;
flex-direction: column;
justify-content: center;
}
.sidebar.mini .brand {
padding: 0;
align-items: center;
}
.brand-name {
color: #fff;
font-size: 16px;
font-weight: bold;
}
.brand-sub {
color: var(--sb-dim);
font-size: 10px;
margin-top: 2px;
}
.brand-mini {
color: #fff;
font-size: 15px;
font-weight: bold;
}
nav {
margin-top: 20px;
flex: 1;
}
.nav-item,
.toggle {
width: 100%;
height: 40px;
border: 0;
background: transparent;
color: var(--sb-text);
font-size: 13px;
font-family: inherit;
display: flex;
align-items: center;
cursor: pointer;
padding: 0;
}
.nav-item:hover,
.toggle:hover {
background: var(--sb-active);
}
/* 选中项:左侧一条蓝色竖条 + 深一点的底色 */
.nav-item.on {
background: var(--sb-active);
box-shadow: inset 3px 0 0 var(--primary);
color: #fff;
font-weight: bold;
}
.nav-icon {
width: 44px;
text-align: center;
flex-shrink: 0;
color: var(--sb-dim);
}
.nav-item.on .nav-icon {
color: #fff;
}
.sidebar.mini .nav-icon {
width: var(--sb-mini);
}
.nav-label {
white-space: nowrap;
}
.foot {
border-top: 1px solid #1f2937;
padding-top: 12px;
}
.status {
display: flex;
align-items: center;
height: 22px;
color: var(--sb-text);
font-size: 11px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin: 0 6px 0 20px;
flex-shrink: 0;
}
.sidebar.mini .dot {
margin-left: 28px;
}
.dot.ok {
background: var(--ok);
}
.dot.bad {
background: var(--bad);
}
.warnText {
color: #fca5a5;
}
.toggle {
margin-top: 10px;
height: 44px;
background: var(--sb-brand);
color: var(--sb-dim);
font-size: 11px;
}
.toggle .nav-icon {
font-size: 15px;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
// 前端入口。
//
// 界面用 Vue 3 + Naive UI,结构照 2026-09-02 确认的原型 v4:
// 左侧可折叠导航(商品列表 / 参数设置)+ 两行工具栏 + 商品表格 + 运行日志子窗口。
//
// 后端方法在 wailsjs/go/main/App.js 里,由 wails dev / wails build 自动生成。
// 不要手改那个目录,改了下次构建会被覆盖。
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+64
View File
@@ -0,0 +1,64 @@
/* 全局样式。
*
* 颜色跟原型 v4 保持一致,改之前先看看原型:
* https://qux.ilapage.cn/#/apps/6a97c618191a826306a7f5f7
*/
:root {
/* 侧边栏 */
--sb-bg: #111827;
--sb-brand: #0b1220;
--sb-active: #1e293b;
--sb-text: #cbd5e1;
--sb-dim: #64748b;
/* 内容区 */
--bg: #f3f4f6;
--card: #ffffff;
--line: #e5e7eb;
--row: #fcfcfd;
--head: #fafafb;
/* 文字 */
--ink: #111827;
--muted: #6b7280;
--faint: #9ca3af;
/* 状态色 */
--primary: #2563eb;
--ok: #059669;
--warn: #d97706;
--bad: #dc2626;
/* 侧边栏宽度。收起时由 JS 换成 --sb-mini。 */
--sb-open: 176px;
--sb-mini: 64px;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
color: var(--ink);
background: var(--bg);
/* 桌面程序里选中文字通常是误操作,只在表格和日志里允许。 */
user-select: none;
}
/* 表格内容和日志要能复制,排查问题时需要。 */
.selectable,
.log-body {
user-select: text;
}
+350
View File
@@ -0,0 +1,350 @@
<script setup>
/**
* 商品列表页。
*
* 结构照原型 v4:
* 上行筛选:店铺 / 蝦皮ID / 创建时间范围 / 状态 / 重置 / 搜索
* 下行操作:下载数据 / 下载视频 / 上传数据 / 运行日志
* 下面是表格和分页
*
* 四个筛选条件对应货憨憨 getPage 的原生参数,不要自己造新参数:
* 店铺 -> platformShopId
* 蝦皮ID -> itemIds
* 创建时间 -> createTimeStart / createTimeEnd
* 状态 -> itemStatus
*
* 注意:本页查的是本地 SQLite,不联网。点「下载数据」才会请求货憨憨。
*/
import { computed, onMounted, ref } from 'vue'
import { useMessage } from 'naive-ui'
import {
CountProducts,
DownloadProductData,
DownloadVideos,
ListProducts,
ListShops,
UploadVideos,
} from '../../wailsjs/go/main/App'
const message = useMessage()
const emit = defineEmits(['open-logs'])
// ---- 筛选条件 ----
// 字段名和 Go 侧 store.ProductQuery 的 json tag 一一对应,改名要两边一起改。
const emptyQuery = () => ({
platformShopId: '',
itemIds: '',
createdFrom: '',
createdTo: '',
itemStatus: 'NORMAL',
page: 1,
pageSize: 20,
})
const query = ref(emptyQuery())
// 商品状态取值来自货憨憨接口文档,不要随便加。
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '在售中', value: 'NORMAL' },
{ label: '已售罄', value: 'SOLD_OUT' },
{ label: '审查中', value: 'REVIEWING' },
{ label: '未上架', value: 'UNLIST' },
{ label: '被禁售', value: 'BANNED' },
]
// ---- 店铺下拉 ----
// 店铺列表必须来自货憨憨 erp/shop/all,不能在前端写死。
const shopOptions = ref([])
const shopLoading = ref(false)
async function loadShops() {
shopLoading.value = true
try {
const shops = await ListShops()
shopOptions.value = (shops || []).map((s) => ({
label: `${s.shopName} (${s.region})`,
value: s.platformShopId,
}))
} catch (err) {
// R1 还没实现时这里必然失败,属于预期情况,不弹错误打扰使用者。
shopOptions.value = []
} finally {
shopLoading.value = false
}
}
// ---- 表格 ----
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const checkedIds = ref([])
const columns = [
{ type: 'selection' },
{
title: '主图',
key: 'mainImage',
width: 64,
render: (row) =>
row.mainImage
? h('img', { src: row.mainImage, class: 'thumb' })
: h('span', { class: 'thumb empty' }, 'IMG'),
},
{ title: '蝦皮ID', key: 'itemId', width: 118 },
{ title: '标题', key: 'itemName', ellipsis: { tooltip: true } },
{ title: '店铺', key: 'shopName', width: 120 },
{
title: '价格',
key: 'minSkuPrice',
width: 100,
render: (row) => `${row.currency} ${row.minSkuPrice}`,
},
{
title: '视频',
key: 'videoStatus',
width: 72,
render: (row) => renderStatus(row.videoStatus, videoText),
},
{
title: '下载状态',
key: 'downloadStatus',
width: 110,
render: (row) => renderStatus(row.downloadStatus, downloadText),
},
{
title: '上传状态',
key: 'uploadStatus',
width: 100,
render: (row) => renderStatus(row.uploadStatus, uploadText),
},
{ title: '创建时间', key: 'createdAt', width: 150 },
]
// 状态值到中文和颜色的对照。
// Go 侧的常量在 internal/store/product.go,改一边要改两边。
const videoText = { pending: ['待搜', 'muted'], found: ['有', 'ok'], none: ['无', 'faint'] }
const downloadText = {
pending: ['待下载', 'muted'],
running: ['下载中', 'primary'],
done: ['已下载', 'ok'],
failed: ['失败', 'bad'],
}
const uploadText = {
pending: ['待上传', 'warn'],
running: ['上传中', 'primary'],
done: ['已上传', 'ok'],
failed: ['失败', 'bad'],
}
import { h } from 'vue'
function renderStatus(value, dict) {
const [text, tone] = dict[value] || [value || '—', 'faint']
return h('span', { class: `st ${tone}` }, text)
}
async function search(resetPage = true) {
if (resetPage) query.value.page = 1
loading.value = true
try {
const page = await ListProducts(query.value)
rows.value = page.items || []
total.value = page.total || 0
} catch (err) {
message.error(String(err))
} finally {
loading.value = false
}
}
function reset() {
query.value = emptyQuery()
search()
}
// ---- 操作按钮 ----
// R1~R5 还没实现,后端返回明确的「还没实现」,这里如实显示,
// 不要假装成功,否则同事会以为点了有用。
async function run(action, fn) {
try {
await fn()
await search(false)
} catch (err) {
message.warning(`${action}:${err}`)
}
}
const selectedCount = computed(() => checkedIds.value.length)
onMounted(() => {
loadShops()
search()
})
</script>
<template>
<div class="page">
<!-- 工具栏:上行筛选,下行操作。一行放不下,别改回一行。 -->
<div class="toolbar">
<div class="row">
<span class="lb">店铺</span>
<n-select
v-model:value="query.platformShopId"
:options="shopOptions"
:loading="shopLoading"
placeholder="全部店铺"
clearable
style="width: 200px"
/>
<span class="lb">蝦皮ID</span>
<n-input
v-model:value="query.itemIds"
placeholder="多个用逗号分隔"
style="width: 170px"
/>
<span class="lb">创建时间</span>
<n-input v-model:value="query.createdFrom" placeholder="2026-08-01" style="width: 130px" />
<span class="dash">—</span>
<n-input v-model:value="query.createdTo" placeholder="2026-09-02" style="width: 130px" />
<span class="lb">状态</span>
<n-select
v-model:value="query.itemStatus"
:options="statusOptions"
style="width: 130px"
/>
<n-button style="margin-left: 12px" @click="reset">重置</n-button>
<n-button type="primary" @click="search()">搜索</n-button>
</div>
<div class="row">
<n-button type="primary" @click="run('下载数据', DownloadProductData)">下载数据</n-button>
<n-button @click="run('下载视频', () => DownloadVideos(checkedIds))">下载视频</n-button>
<n-button @click="run('上传数据', () => UploadVideos(checkedIds))">上传数据</n-button>
<n-button @click="emit('open-logs')">运行日志</n-button>
<span class="spacer" />
<span class="count">已选 {{ selectedCount }} / 共 {{ total }} 条</span>
</div>
</div>
<!-- 表格。选中的行 ID 存到 checkedIds,操作按钮拿它做批量。 -->
<div class="table selectable">
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:row-key="(row) => row.id"
v-model:checked-row-keys="checkedIds"
flex-height
style="height: 100%"
/>
</div>
<div class="pager">
<span class="muted">
第 {{ query.page }} / {{ Math.max(1, Math.ceil(total / query.pageSize)) }} 页
&nbsp;&nbsp; 每页 {{ query.pageSize }} 条 &nbsp;&nbsp; 共 {{ total }} 条
</span>
<n-pagination
v-model:page="query.page"
:item-count="total"
:page-size="query.pageSize"
@update:page="search(false)"
/>
</div>
</div>
</template>
<style scoped>
.page {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.toolbar {
background: var(--card);
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
height: 48px;
padding: 0 24px;
}
.row:first-child {
border-bottom: 1px solid #f1f2f4;
}
.lb {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.dash {
color: var(--muted);
}
.spacer {
flex: 1;
}
.count {
color: var(--muted);
font-size: 12px;
}
.table {
flex: 1;
min-height: 0;
background: var(--card);
}
.pager {
height: 44px;
background: var(--card);
border-top: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
}
.muted {
color: var(--muted);
font-size: 12px;
}
/* 表格里的状态文字配色,和原型一致 */
:deep(.st.ok) {
color: var(--ok);
}
:deep(.st.warn) {
color: var(--warn);
}
:deep(.st.bad) {
color: var(--bad);
}
:deep(.st.primary) {
color: var(--primary);
}
:deep(.st.muted) {
color: var(--muted);
}
:deep(.st.faint) {
color: var(--faint);
}
:deep(.thumb) {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 4px;
background: #e9eaee;
}
:deep(.thumb.empty) {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--faint);
}
</style>
+250
View File
@@ -0,0 +1,250 @@
<script setup>
/**
* 参数设置页。
*
* 四张卡片:货憨憨账号、淘宝专属浏览器、下载与任务、货憨憨图片空间。
*
* 两条不能违反的规则:
* 1. 淘宝那张卡片里没有账号密码输入框,将来也不要加。
* 淘宝登录必须由使用者在专属 Chrome 里手动完成。
* 2. 路径不要求手打:chrome.exe 用文件选择框,两个目录用目录选择框。
* Windows 路径手打太容易错。
*/
import { onMounted, ref } from 'vue'
import { useMessage } from 'naive-ui'
import {
GetConfig,
PickChromeExe,
PickDirectory,
ResetConfig,
SaveConfig,
} from '../../wailsjs/go/main/App'
const message = useMessage()
const cfg = ref(null)
const saving = ref(false)
async function load() {
try {
cfg.value = await GetConfig()
} catch (err) {
message.error(`读取配置失败:${err}`)
}
}
async function save() {
saving.value = true
try {
await SaveConfig(cfg.value)
message.success('配置已保存')
} catch (err) {
// 后端 Validate 返回的就是能直接给人看的中文,原样显示。
message.error(String(err))
} finally {
saving.value = false
}
}
async function reset() {
cfg.value = await ResetConfig()
message.info('已还原默认值,点「保存设置」才会生效')
}
// 使用者点了取消时后端返回空字符串,这时保持原值不动。
async function pickChrome() {
const picked = await PickChromeExe()
if (picked) cfg.value.taobao.chromePath = picked
}
async function pickDir(field, title) {
const picked = await PickDirectory(title)
if (!picked) return
if (field === 'userDataDir') cfg.value.taobao.userDataDir = picked
if (field === 'videoDir') cfg.value.download.videoDir = picked
}
onMounted(load)
</script>
<template>
<div v-if="cfg" class="page">
<div class="head">
<div class="title">参数设置</div>
<div class="sub">账号、浏览器与任务参数。密码只保存在本机 config.yaml,不进仓库和日志</div>
</div>
<div class="body">
<div class="grid">
<!-- 货憨憨 -->
<n-card title="货憨憨 ERP 账号" size="small">
<template #header-extra>
<span class="faint">用于下载商品数据和上传视频</span>
</template>
<n-form label-placement="top" size="small">
<div class="two">
<n-form-item label="网址">
<n-input v-model:value="cfg.huohanhan.baseUrl" />
</n-form-item>
<n-form-item label="OCR 识别服务">
<n-input v-model:value="cfg.huohanhan.ocrUrl" />
</n-form-item>
</div>
<div class="two">
<n-form-item label="账号">
<n-input v-model:value="cfg.huohanhan.account" />
</n-form-item>
<n-form-item label="密码">
<n-input v-model:value="cfg.huohanhan.password" type="password" show-password-on="click" />
</n-form-item>
</div>
</n-form>
</n-card>
<!-- 淘宝:注意这里没有账号密码 -->
<n-card title="淘宝专属浏览器" size="small">
<template #header-extra>
<span class="faint">登录由你手动完成,程序不代填密码</span>
</template>
<n-form label-placement="top" size="small">
<n-form-item label="Chrome 可执行文件(选择 chrome.exe)">
<n-input-group>
<n-input v-model:value="cfg.taobao.chromePath" />
<n-button @click="pickChrome">选择文件</n-button>
</n-input-group>
</n-form-item>
<n-form-item label="用户数据目录(保存淘宝登录态)">
<n-input-group>
<n-input v-model:value="cfg.taobao.userDataDir" />
<n-button @click="pickDir('userDataDir', '选择 Chrome 用户数据目录')">
选择目录
</n-button>
</n-input-group>
</n-form-item>
<n-form-item label="调试端口范围">
<n-input-number v-model:value="cfg.taobao.debugPortStart" :show-button="false" style="width: 96px" />
<span class="dash">—</span>
<n-input-number v-model:value="cfg.taobao.debugPortEnd" :show-button="false" style="width: 96px" />
</n-form-item>
</n-form>
<n-alert type="info" :bordered="false" style="margin-top: 4px">
登录必须在程序打开的专属 Chrome 里手动扫码完成。程序不会代填账号密码,
也不会绕过验证码或滑块。
</n-alert>
</n-card>
<!-- 下载与任务 -->
<n-card title="下载与任务" size="small">
<n-form label-placement="top" size="small">
<n-form-item label="视频保存目录">
<n-input-group>
<n-input v-model:value="cfg.download.videoDir" />
<n-button @click="pickDir('videoDir', '选择视频保存目录')">选择目录</n-button>
</n-input-group>
</n-form-item>
<div class="two">
<n-form-item label="每商品最多视频数(1—10)">
<n-input-number v-model:value="cfg.download.maxVideosPerProduct" :min="1" :max="10" />
</n-form-item>
<n-form-item label="图搜取前 N 个同款(1—60)">
<n-input-number v-model:value="cfg.download.searchTopN" :min="1" :max="60" />
</n-form-item>
</div>
<div class="two">
<n-form-item label="下载并发数(1—8,建议不超过 3)">
<n-input-number v-model:value="cfg.download.concurrency" :min="1" :max="8" />
</n-form-item>
<n-form-item label="商品间等待秒数(防风控)">
<n-input-number v-model:value="cfg.download.waitSecondsMin" :min="0" style="width: 90px" />
<span class="dash">—</span>
<n-input-number v-model:value="cfg.download.waitSecondsMax" :min="0" style="width: 90px" />
</n-form-item>
</div>
</n-form>
</n-card>
<!-- 图片空间:容量快满是真实风险,先占位,接口接上后填真实数据 -->
<n-card title="货憨憨图片空间" size="small">
<template #header-extra>
<span class="faint">视频体积远大于图片,批量上传前先看容量</span>
</template>
<n-alert type="warning" :bordered="false">
容量查询还没实现(需求 R5)。上线前必须接上
<code>product/material/getMaterialSize</code>:
上传返回「容量不足」时任务要立即停止,不能反复重试。
</n-alert>
</n-card>
</div>
</div>
<div class="savebar">
<span class="faint">
配置保存在本机 config.yaml,密码与 token 不写入仓库、日志和工单
</span>
<div>
<n-button @click="reset">还原默认</n-button>
<n-button type="primary" :loading="saving" style="margin-left: 12px" @click="save">
保存设置
</n-button>
</div>
</div>
</div>
</template>
<style scoped>
.page {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.head {
height: 60px;
background: var(--card);
border-bottom: 1px solid var(--line);
padding: 12px 24px;
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: bold;
}
.sub {
font-size: 11px;
color: var(--faint);
margin-top: 4px;
}
.body {
flex: 1;
overflow: auto;
padding: 20px 24px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.dash {
margin: 0 8px;
color: var(--muted);
}
.faint {
color: var(--faint);
font-size: 11px;
}
.savebar {
height: 60px;
background: var(--card);
border-top: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
+330
View File
@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;
+298
View File
@@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}
+46
View File
@@ -0,0 +1,46 @@
module cmsp
go 1.25.0
require (
github.com/wailsapp/wails/v2 v2.15.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.57.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.39.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+133
View File
@@ -0,0 +1,133 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.15.0 h1:u7cHK+UesZOlYxyJxfYLteaCPhws6UsZoDdqUejuX6Q=
github.com/wailsapp/wails/v2 v2.15.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+386
View File
@@ -0,0 +1,386 @@
// Package config 负责读写和校验程序的参数设置。
//
// 配置文件是程序目录下的 config.yaml,用 YAML 而不是 JSON,
// 是因为 YAML 能写注释——很多坑(比如密码必须加引号)只有写在
// 字段旁边,同事才不会踩。
//
// 设计约定(改代码前请先读):
//
// - config.yaml 含密码,已在 .gitignore 里,绝不能提交进 Git。
// 进 Git 的是 config.example.yaml,那份不含真实凭据。
//
// - 淘宝没有账号密码字段,将来也不要加。淘宝登录必须由使用者
// 在专属 Chrome 里手动完成,程序只保存浏览器路径,不碰凭据。
// 这是 AGENTS.md 里的红线。
//
// - 保存配置时用 Render() 按固定模板重新渲染,不要用 yaml.Marshal,
// 否则注释会被全部冲掉。
//
// - 新增一个字段要改四处:结构体、Default()、Validate()、
// Render() 里的模板。漏掉任何一处,单元测试会失败。
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Config 是程序的全部参数设置,对应界面上的「参数设置」页。
//
// yaml tag 就是配置文件里的键名。改名会导致老配置读不出来,
// 所以除非有充分理由,不要改已有字段的 tag。
type Config struct {
Huohanhan HuohanhanConfig `yaml:"huohanhan" json:"huohanhan"`
Taobao TaobaoConfig `yaml:"taobao" json:"taobao"`
Download DownloadConfig `yaml:"download" json:"download"`
}
// HuohanhanConfig 是货憨憨 ERP 的连接信息。
type HuohanhanConfig struct {
// BaseURL 是货憨憨网站地址,不带结尾的斜杠。
BaseURL string `yaml:"base_url" json:"baseUrl"`
// Account 是登录账号。
Account string `yaml:"account" json:"account"`
// Password 是登录密码。属于凭据,禁止写入日志、工单和 Wiki。
Password string `yaml:"password" json:"password"`
// OCRURL 是识别登录验证码的外部服务地址。
OCRURL string `yaml:"ocr_url" json:"ocrUrl"`
}
// TaobaoConfig 是淘宝专属浏览器的设置。
//
// 这里故意没有账号和密码字段:淘宝登录由使用者手动完成,
// 登录态保存在 Chrome 用户数据目录里,程序不读取也不保存。
type TaobaoConfig struct {
// ChromePath 是 chrome.exe 的完整路径,注意是文件不是目录。
ChromePath string `yaml:"chrome_path" json:"chromePath"`
// UserDataDir 是专属 Chrome 的用户数据目录,淘宝登录态存在这里。
UserDataDir string `yaml:"user_data_dir" json:"userDataDir"`
// DebugPortStart / DebugPortEnd 是分配调试端口的范围。
DebugPortStart int `yaml:"debug_port_start" json:"debugPortStart"`
DebugPortEnd int `yaml:"debug_port_end" json:"debugPortEnd"`
}
// DownloadConfig 是下载与任务参数。
type DownloadConfig struct {
// VideoDir 是下载的视频保存目录。
VideoDir string `yaml:"video_dir" json:"videoDir"`
// MaxVideosPerProduct 是每个商品最多下载几个视频。
MaxVideosPerProduct int `yaml:"max_videos_per_product" json:"maxVideosPerProduct"`
// SearchTopN 是图搜结果里取前几个同款去找视频。
SearchTopN int `yaml:"search_top_n" json:"searchTopN"`
// Concurrency 是同时下载几个视频。
// 调大会提高淘宝风控概率,没有实测结论前不要超过 3。
Concurrency int `yaml:"concurrency" json:"concurrency"`
// WaitSecondsMin / WaitSecondsMax 是处理完一个商品后随机等待的秒数区间。
WaitSecondsMin float64 `yaml:"wait_seconds_min" json:"waitSecondsMin"`
WaitSecondsMax float64 `yaml:"wait_seconds_max" json:"waitSecondsMax"`
}
// Default 返回一份可以直接使用的默认配置。
//
// 账号和密码故意留空:程序不内置任何凭据,必须由使用者自己填。
func Default() Config {
return Config{
Huohanhan: HuohanhanConfig{
BaseURL: "https://www.huohanhan.com",
Account: "",
Password: "",
OCRURL: "https://ocr.ilapage.cn/ocr",
},
Taobao: TaobaoConfig{
ChromePath: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
UserDataDir: DefaultChromeUserDataDir(),
DebugPortStart: 19666,
DebugPortEnd: 19765,
},
Download: DownloadConfig{
VideoDir: DefaultVideoDir(),
MaxVideosPerProduct: 3,
SearchTopN: 20,
Concurrency: 2,
WaitSecondsMin: 2,
WaitSecondsMax: 4,
},
}
}
// DefaultChromeUserDataDir 返回专属 Chrome 用户数据目录的默认位置。
//
// 这个路径沿用迁移前 Python 版本的目录,改动它会导致同事需要重新
// 扫码登录淘宝,所以不要随手改。
func DefaultChromeUserDataDir() string {
base := os.Getenv("LOCALAPPDATA")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(".", "淘宝浏览器", "默认账号")
}
base = home
}
return filepath.Join(base, "电商视频自动下载工具", "淘宝浏览器", "默认账号")
}
// DefaultVideoDir 返回视频默认保存目录:程序所在目录下的「运行数据/视频」。
func DefaultVideoDir() string {
exe, err := os.Executable()
if err != nil {
return filepath.Join("运行数据", "视频")
}
return filepath.Join(filepath.Dir(exe), "运行数据", "视频")
}
// Validate 检查配置是否可用。返回的错误信息会直接显示给使用者,
// 所以要写成一句能看懂的中文,并说明允许范围。
//
// 这里不校验账号密码对不对,那要等真正登录时才知道。
// 这里只保证「格式上能用」。
func (c Config) Validate() error {
h := c.Huohanhan
if strings.TrimSpace(h.BaseURL) == "" {
return fmt.Errorf("货憨憨网址不能为空")
}
if !strings.HasPrefix(h.BaseURL, "http://") && !strings.HasPrefix(h.BaseURL, "https://") {
return fmt.Errorf("货憨憨网址必须以 http:// 或 https:// 开头")
}
if strings.TrimSpace(h.OCRURL) == "" {
return fmt.Errorf("OCR 识别服务地址不能为空")
}
t := c.Taobao
if strings.TrimSpace(t.ChromePath) == "" {
return fmt.Errorf("Chrome 可执行文件路径不能为空")
}
if strings.TrimSpace(t.UserDataDir) == "" {
return fmt.Errorf("Chrome 用户数据目录不能为空")
}
if err := checkPort(t.DebugPortStart, "调试端口起始"); err != nil {
return err
}
if err := checkPort(t.DebugPortEnd, "调试端口结束"); err != nil {
return err
}
if t.DebugPortEnd < t.DebugPortStart {
return fmt.Errorf("调试端口结束不能小于起始端口")
}
d := c.Download
if strings.TrimSpace(d.VideoDir) == "" {
return fmt.Errorf("视频保存目录不能为空")
}
if err := checkIntRange(d.MaxVideosPerProduct, 1, 10, "每个商品最多下载视频数"); err != nil {
return err
}
if err := checkIntRange(d.SearchTopN, 1, 60, "图搜取前 N 个同款"); err != nil {
return err
}
if err := checkIntRange(d.Concurrency, 1, 8, "下载并发数"); err != nil {
return err
}
if d.WaitSecondsMin < 0 || d.WaitSecondsMin > 60 {
return fmt.Errorf("商品间最短等待秒数不合法,允许范围 0—60")
}
if d.WaitSecondsMax < 0 || d.WaitSecondsMax > 120 {
return fmt.Errorf("商品间最长等待秒数不合法,允许范围 0—120")
}
if d.WaitSecondsMax < d.WaitSecondsMin {
return fmt.Errorf("商品间最长等待秒数不能小于最短等待秒数")
}
return nil
}
func checkPort(port int, name string) error {
if port < 1024 || port > 65535 {
return fmt.Errorf("%s不合法,允许范围 1024—65535", name)
}
return nil
}
func checkIntRange(value, min, max int, name string) error {
if value < min || value > max {
return fmt.Errorf("%s不合法,允许范围 %d—%d", name, min, max)
}
return nil
}
// Desensitized 返回一份把密码换成固定占位符的副本。
//
// 任何要写日志、写工单,或者传给不需要密码的地方的场景,都用这个方法,
// 不要直接传 Config。
func (c Config) Desensitized() Config {
copied := c
if copied.Huohanhan.Password != "" {
copied.Huohanhan.Password = "******"
}
return copied
}
// Load 从 path 读取配置。
//
// 文件不存在时返回默认配置而不是错误——第一次启动本来就没有配置文件,
// 这时应该让程序正常打开、显示默认值,由使用者去填。
func Load(path string) (Config, error) {
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return Default(), nil
}
if err != nil {
return Config{}, fmt.Errorf("读取配置文件失败:%w", err)
}
// 先铺上默认值再解析,这样老配置文件缺少新字段时,
// 新字段会保留默认值而不是变成零值。
cfg := Default()
if err := yaml.Unmarshal(raw, &cfg); err != nil {
return Config{}, fmt.Errorf("配置文件不是有效的 YAML:%w", err)
}
return cfg, nil
}
// Save 校验并写入配置。
//
// 先校验再写,避免把一份用不了的配置存进去。
// 写入的是 Render() 渲染的带注释版本,不是 yaml.Marshal 的裸数据。
func Save(path string, cfg Config) error {
if err := cfg.Validate(); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("创建配置目录失败:%w", err)
}
// 权限 0600:只有当前用户能读。文件里有密码。
if err := os.WriteFile(path, []byte(cfg.Render()), 0o600); err != nil {
return fmt.Errorf("写入配置文件失败:%w", err)
}
return nil
}
// Render 按固定模板把配置渲染成带注释的 YAML。
//
// 为什么不用 yaml.Marshal:那样会把注释全部丢掉,同事下次打开
// config.yaml 就只剩一堆键值对,不知道每项什么意思、有什么坑。
//
// 新增字段时记得在这里的模板中也加上,并补一句说明。
func (c Config) Render() string {
return fmt.Sprintf(`# cmsp 本机配置
#
# 本文件由「参数设置」页保存时自动重写,注释会保留。
# 也可以直接用记事本改,改完重启程序生效。
#
# [必须] 本文件含密码,已在 .gitignore 里,绝不能提交进 Git,
# 也不要打包发给别人、截图或粘贴到工单和日志里。
# [必须] 密码要用双引号包起来。纯数字密码不加引号会被 YAML 当成
# 整数,读取时直接报错,前导 0 也会丢。
huohanhan:
# 货憨憨网站地址,一般不用改,域名变了才改。结尾不要带斜杠。
base_url: %s
# 登录账号。
account: %s
# [必须] 登录密码,加双引号。
password: %s
# 登录验证码的自动识别服务。
# [注意] 验证码图片会被发送到这个地址,换成别人的服务前先评估一下。
ocr_url: %s
taobao:
# [必须] 这里没有淘宝账号和密码,将来也不要加。
# 淘宝登录必须由你在程序打开的专属 Chrome 里手动扫码完成,
# 登录态保存在下面的 user_data_dir 里,程序不读取也不保存。
# 程序不会代填密码,也不会绕过验证码或滑块。
# chrome.exe 的完整路径。注意是文件不是目录。
chrome_path: %s
# 专属 Chrome 的用户数据目录,淘宝登录态就存在这里。
# [注意] 改了这个路径就要重新扫码登录一次,不要随手改。
# 这个目录不要提交、打包或共享。
user_data_dir: %s
# 给专属 Chrome 分配调试端口的范围。端口被占用时会往后找。
debug_port_start: %d
debug_port_end: %d
download:
# 下载的视频保存到哪个目录。
video_dir: %s
# 每个商品最多下载几个视频,允许 1—10。
max_videos_per_product: %d
# 图搜结果里取前几个同款去找视频,允许 1—60。取太多会明显变慢。
search_top_n: %d
# 同时下载几个视频,允许 1—8。
# [注意] 调大会提高淘宝风控概率,没有实测结论前不要超过 3。
concurrency: %d
# 处理完一个商品后随机等待的秒数区间,用来降低被风控的概率。
# 改小了跑得快但更容易被拦,不建议低于 2 秒。
wait_seconds_min: %s
wait_seconds_max: %s
`,
yamlString(c.Huohanhan.BaseURL),
yamlString(c.Huohanhan.Account),
quoted(c.Huohanhan.Password),
yamlString(c.Huohanhan.OCRURL),
quoted(c.Taobao.ChromePath),
quoted(c.Taobao.UserDataDir),
c.Taobao.DebugPortStart,
c.Taobao.DebugPortEnd,
quoted(c.Download.VideoDir),
c.Download.MaxVideosPerProduct,
c.Download.SearchTopN,
c.Download.Concurrency,
trimFloat(c.Download.WaitSecondsMin),
trimFloat(c.Download.WaitSecondsMax),
)
}
// quoted 把值渲染成带双引号的 YAML 字符串。
//
// Windows 路径里有反斜杠和空格,密码可能是纯数字或含特殊字符,
// 这些都必须加引号,否则 YAML 解析会出错或类型不对。
func quoted(v string) string {
// YAML 双引号字符串里,反斜杠和双引号要转义。
escaped := strings.ReplaceAll(v, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"`
}
// yamlString 渲染普通字符串。空值写成一对空引号,避免出现裸的冒号后什么都没有。
func yamlString(v string) string {
if strings.TrimSpace(v) == "" {
return `""`
}
// 含特殊字符时一律加引号,省得判断哪些安全。
if strings.ContainsAny(v, `:#{}[],&*?|<>=!%@\"' `) {
return quoted(v)
}
return v
}
// trimFloat 把 2.0 渲染成 2,把 2.5 保留成 2.5,让配置文件更好看。
func trimFloat(v float64) string {
s := fmt.Sprintf("%.2f", v)
s = strings.TrimRight(s, "0")
return strings.TrimSuffix(s, ".")
}
// DefaultPath 返回配置文件的默认位置:程序所在目录下的 config.yaml。
//
// 放在程序目录而不是系统目录,是为了让同事能直接看到和备份它。
func DefaultPath() string {
exe, err := os.Executable()
if err != nil {
return "config.yaml"
}
return filepath.Join(filepath.Dir(exe), "config.yaml")
}
+235
View File
@@ -0,0 +1,235 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 默认配置必须是合法的,否则第一次启动程序就会报错。
// 新增字段忘了写默认值或校验规则时,这个测试会失败。
func TestDefaultConfigIsValid(t *testing.T) {
if err := Default().Validate(); err != nil {
t.Fatalf("默认配置应当合法,却报错:%v", err)
}
}
func TestValidateRejectsBadValues(t *testing.T) {
// 每个用例只改一个字段,这样失败时能一眼看出是哪条规则出问题。
cases := []struct {
name string
modify func(*Config)
expect string
}{
{"网址为空", func(c *Config) { c.Huohanhan.BaseURL = "" }, "网址不能为空"},
{"网址缺协议", func(c *Config) { c.Huohanhan.BaseURL = "www.huohanhan.com" }, "http://"},
{"OCR 地址为空", func(c *Config) { c.Huohanhan.OCRURL = "" }, "OCR"},
{"Chrome 路径为空", func(c *Config) { c.Taobao.ChromePath = "" }, "Chrome 可执行文件"},
{"用户数据目录为空", func(c *Config) { c.Taobao.UserDataDir = "" }, "用户数据目录"},
{"端口过小", func(c *Config) { c.Taobao.DebugPortStart = 80 }, "1024"},
{"端口区间颠倒", func(c *Config) { c.Taobao.DebugPortEnd = c.Taobao.DebugPortStart - 1 }, "不能小于起始端口"},
{"视频目录为空", func(c *Config) { c.Download.VideoDir = "" }, "视频保存目录"},
{"每商品视频数为 0", func(c *Config) { c.Download.MaxVideosPerProduct = 0 }, "1—10"},
{"图搜取数过大", func(c *Config) { c.Download.SearchTopN = 61 }, "1—60"},
{"并发数过大", func(c *Config) { c.Download.Concurrency = 9 }, "1—8"},
{"等待区间颠倒", func(c *Config) { c.Download.WaitSecondsMax = 1; c.Download.WaitSecondsMin = 5 }, "不能小于最短"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := Default()
tc.modify(&cfg)
err := cfg.Validate()
if err == nil {
t.Fatalf("期望校验失败,却通过了")
}
if !strings.Contains(err.Error(), tc.expect) {
t.Fatalf("错误信息应包含 %q,实际是 %q", tc.expect, err.Error())
}
})
}
}
// 配置文件不存在时必须返回默认配置,而不是报错。
// 第一次启动程序就是这个场景。
func TestLoadMissingFileReturnsDefault(t *testing.T) {
path := filepath.Join(t.TempDir(), "not-exist.yaml")
cfg, err := Load(path)
if err != nil {
t.Fatalf("文件不存在时不应报错,却报了:%v", err)
}
if cfg.Taobao.DebugPortStart != Default().Taobao.DebugPortStart {
t.Fatalf("应当返回默认配置")
}
}
func TestSaveThenLoadKeepsValues(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
saved := Default()
saved.Huohanhan.Account = "13500000000" // 虚构测试数据,非真实账号
saved.Huohanhan.Password = `测试"密码\含转义` // 故意含引号和反斜杠
saved.Download.MaxVideosPerProduct = 5
saved.Download.WaitSecondsMin = 1.5
if err := Save(path, saved); err != nil {
t.Fatalf("保存失败:%v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("读取失败:%v", err)
}
if loaded.Huohanhan.Account != saved.Huohanhan.Account {
t.Fatalf("账号应当读回 %q,实际 %q", saved.Huohanhan.Account, loaded.Huohanhan.Account)
}
if loaded.Huohanhan.Password != saved.Huohanhan.Password {
t.Fatalf("含引号和反斜杠的密码应当原样读回,期望 %q,实际 %q",
saved.Huohanhan.Password, loaded.Huohanhan.Password)
}
if loaded.Download.MaxVideosPerProduct != 5 {
t.Fatalf("每商品视频数应当读回 5,实际 %d", loaded.Download.MaxVideosPerProduct)
}
if loaded.Download.WaitSecondsMin != 1.5 {
t.Fatalf("小数应当读回 1.5,实际 %v", loaded.Download.WaitSecondsMin)
}
}
// Windows 路径全是反斜杠,必须能原样存取。
func TestSaveThenLoadKeepsWindowsPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
saved := Default()
saved.Taobao.ChromePath = `C:\Program Files\Google\Chrome\Application\chrome.exe`
saved.Taobao.UserDataDir = `C:\Users\某人\AppData\Local\电商视频自动下载工具\淘宝浏览器\默认账号`
if err := Save(path, saved); err != nil {
t.Fatalf("保存失败:%v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("读取失败:%v", err)
}
if loaded.Taobao.ChromePath != saved.Taobao.ChromePath {
t.Fatalf("Chrome 路径应当原样读回\n期望 %q\n实际 %q",
saved.Taobao.ChromePath, loaded.Taobao.ChromePath)
}
if loaded.Taobao.UserDataDir != saved.Taobao.UserDataDir {
t.Fatalf("用户数据目录应当原样读回\n期望 %q\n实际 %q",
saved.Taobao.UserDataDir, loaded.Taobao.UserDataDir)
}
}
// 纯数字密码不加引号会被 YAML 当成整数导致读取失败,这是踩过的坑。
func TestSaveThenLoadKeepsNumericPassword(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
saved := Default()
saved.Huohanhan.Password = "0123456789" // 虚构测试数据,注意前导 0
if err := Save(path, saved); err != nil {
t.Fatalf("保存失败:%v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("纯数字密码应当能正常读取,却报错:%v", err)
}
if loaded.Huohanhan.Password != "0123456789" {
t.Fatalf("前导 0 应当保留,期望 0123456789,实际 %q", loaded.Huohanhan.Password)
}
}
// 保存后注释必须还在,否则同事下次打开就只剩键值对。
func TestSaveKeepsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := Save(path, Default()); err != nil {
t.Fatalf("保存失败:%v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取文件失败:%v", err)
}
text := string(raw)
for _, must := range []string{
"# cmsp 本机配置",
"绝不能提交进 Git",
"密码要用双引号包起来",
"这里没有淘宝账号和密码",
"不会代填密码",
"提高淘宝风控概率",
} {
if !strings.Contains(text, must) {
t.Fatalf("保存后应当保留注释 %q,实际内容:\n%s", must, text)
}
}
}
// 老配置文件缺少新字段时,新字段要保留默认值,不能变成 0 或空字符串。
func TestLoadFillsMissingFieldsWithDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.yaml")
old := "huohanhan:\n account: \"13500000000\"\ndownload:\n max_videos_per_product: 7\n"
if err := os.WriteFile(path, []byte(old), 0o600); err != nil {
t.Fatalf("准备测试文件失败:%v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("读取失败:%v", err)
}
if cfg.Download.MaxVideosPerProduct != 7 {
t.Fatalf("已有字段应当读回 7,实际 %d", cfg.Download.MaxVideosPerProduct)
}
if cfg.Taobao.DebugPortStart != Default().Taobao.DebugPortStart {
t.Fatalf("缺失字段应当保留默认值 %d,实际 %d",
Default().Taobao.DebugPortStart, cfg.Taobao.DebugPortStart)
}
if cfg.Huohanhan.BaseURL != Default().Huohanhan.BaseURL {
t.Fatalf("缺失的网址应当保留默认值")
}
}
// Save 必须拒绝不合法的配置,避免把用不了的配置写进文件。
func TestSaveRejectsInvalidConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
bad := Default()
bad.Download.Concurrency = 99
if err := Save(path, bad); err == nil {
t.Fatalf("应当拒绝不合法的配置")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("校验失败时不应写出文件")
}
}
// 密码绝不能原样出现在脱敏后的配置里。
func TestDesensitizedHidesPassword(t *testing.T) {
cfg := Default()
cfg.Huohanhan.Password = "这是一个不该出现在日志里的密码"
safe := cfg.Desensitized()
if strings.Contains(safe.Huohanhan.Password, "不该出现") {
t.Fatalf("脱敏后仍能看到密码:%q", safe.Huohanhan.Password)
}
if cfg.Huohanhan.Password == safe.Huohanhan.Password {
t.Fatalf("Desensitized 不应修改原配置")
}
}
// 淘宝配置里绝不能出现账号密码字段。加了就是违反 AGENTS.md 的红线,
// 这个测试就是为了挡住那种改动。
func TestTaobaoConfigHasNoCredentialFields(t *testing.T) {
rendered := Default().Render()
for _, forbidden := range []string{
"taobao_password", "taobao_account",
"\n password:", // taobao 段里不该有 password
"\n username:",
} {
// huohanhan 段里的 password 缩进也是两格,所以要精确定位 taobao 段。
taobaoSection := rendered[strings.Index(rendered, "taobao:"):strings.Index(rendered, "download:")]
if strings.Contains(taobaoSection, forbidden) {
t.Fatalf("taobao 配置段不允许出现 %q,淘宝登录必须由使用者手动完成", forbidden)
}
}
}
+164
View File
@@ -0,0 +1,164 @@
// Package logx 提供带脱敏的日志。
//
// 为什么要专门写一个包,而不是直接用标准库 log:
// 本项目会接触淘宝 Cookie、货憨憨 token 和账号密码。这些东西
// 一旦进了日志文件,就等于泄漏了。所以所有日志都必须经过这里,
// 由 Mask() 统一把敏感内容替换掉。
//
// 规则很简单:往日志里写东西时,永远调用本包的方法,不要直接
// 用 fmt.Println 或 log.Printf。
package logx
import (
"fmt"
"regexp"
"strings"
"sync"
"time"
)
// Level 是日志级别。界面上的运行日志按它上色和过滤。
type Level string
const (
LevelInfo Level = "info" // 普通进度
LevelSuccess Level = "success" // 成功完成一步
LevelWarn Level = "warn" // 有问题但还能继续
LevelError Level = "error" // 失败
)
// Entry 是一条日志。前端「运行日志」窗口直接显示它。
type Entry struct {
Time string `json:"time"` // 形如 15:04:05,只给人看
Level Level `json:"level"` // 前端按它上色和过滤
Message string `json:"message"` // 已经脱敏过的正文
}
// 下面这些正则用来找出不该出现在日志里的内容。
//
// 新增敏感字段时,在这里加一条,并在 logx_test.go 里补一个用例。
var maskPatterns = []struct {
name string
pattern *regexp.Regexp
replace string
}{
// 淘宝 MTOP 令牌,形如 _m_h5_tk=abc123_1699...
{"淘宝 token", regexp.MustCompile(`(?i)(_m_h5_tk(_enc)?=)[^;&\s]+`), "${1}***"},
// 淘宝其它登录相关 Cookie
{"淘宝 cookie", regexp.MustCompile(`(?i)(_tb_token_=|tracknick=)[^;&\s]+`), "${1}***"},
// HTTP 认证头
{"Authorization", regexp.MustCompile(`(?i)(authorization:\s*bearer\s+)\S+`), "${1}***"},
// 整个 Cookie 请求头
{"Cookie 头", regexp.MustCompile(`(?i)(cookie:\s*)\S.*`), "${1}***"},
// 常见的密码字段,覆盖 password=xxx、"password":"xxx"、密码:xxx
{"密码", regexp.MustCompile(`(?i)((password|passwd|pwd)\s*[":=]+\s*"?)[^"\s,}]+`), "${1}***"},
{"中文密码", regexp.MustCompile(`(密码[::]\s*)\S+`), "${1}***"},
// Base64 图片数据,打印出来既没用又刷屏
{"图片 base64", regexp.MustCompile(`data:image/[a-z]+;base64,[A-Za-z0-9+/=]+`), "data:image/*;base64,***"},
}
// Mask 把文本里的敏感内容替换成 ***。
//
// 它只做替换,不判断“这条日志该不该记”。要不要记由调用方决定。
func Mask(text string) string {
for _, p := range maskPatterns {
text = p.pattern.ReplaceAllString(text, p.replace)
}
return text
}
// Logger 是一个内存日志缓冲区。
//
// 为什么放内存:界面上的「运行日志」窗口需要随时能拿到最近的日志。
// 为什么有上限:任务可能跑几个小时,不限制会把内存吃光。
//
// 它是并发安全的,多个下载协程可以同时往里写。
type Logger struct {
mu sync.Mutex
entries []Entry
limit int
// onEntry 在每条日志写入后被调用,用来推送给前端。
// 为 nil 时不推送,方便在单元测试里使用。
onEntry func(Entry)
}
// New 创建一个日志缓冲区。limit 是最多保留多少条,超出后丢弃最旧的。
func New(limit int) *Logger {
if limit <= 0 {
limit = 1000
}
return &Logger{limit: limit}
}
// SetHandler 设置日志推送回调。Wails 启动后用它把日志发给前端。
func (l *Logger) SetHandler(handler func(Entry)) {
l.mu.Lock()
defer l.mu.Unlock()
l.onEntry = handler
}
func (l *Logger) write(level Level, format string, args ...any) {
entry := Entry{
Time: time.Now().Format("15:04:05"),
Level: level,
Message: Mask(fmt.Sprintf(format, args...)),
}
l.mu.Lock()
l.entries = append(l.entries, entry)
if len(l.entries) > l.limit {
// 丢掉最旧的那些。用 copy 而不是切片头部截取,
// 是为了让底层数组能被回收,避免内存一直涨。
drop := len(l.entries) - l.limit
l.entries = append(l.entries[:0], l.entries[drop:]...)
}
handler := l.onEntry
l.mu.Unlock()
if handler != nil {
handler(entry)
}
}
// Info 记录一条普通进度。
func (l *Logger) Info(format string, args ...any) { l.write(LevelInfo, format, args...) }
// Success 记录一条成功。
func (l *Logger) Success(format string, args ...any) { l.write(LevelSuccess, format, args...) }
// Warn 记录一条警告:有问题但任务还能继续。
func (l *Logger) Warn(format string, args ...any) { l.write(LevelWarn, format, args...) }
// Error 记录一条失败。
func (l *Logger) Error(format string, args ...any) { l.write(LevelError, format, args...) }
// Entries 返回当前保留的全部日志副本。
//
// 返回副本而不是内部切片,是为了防止调用方在外面改动它,
// 导致并发读写崩溃。
func (l *Logger) Entries() []Entry {
l.mu.Lock()
defer l.mu.Unlock()
out := make([]Entry, len(l.entries))
copy(out, l.entries)
return out
}
// Clear 清空日志。对应界面上「运行日志」窗口的清空按钮。
func (l *Logger) Clear() {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = nil
}
// Text 把全部日志拼成纯文本,用于「导出日志」。
func (l *Logger) Text() string {
var sb strings.Builder
for _, e := range l.Entries() {
sb.WriteString(e.Time)
sb.WriteString(" ")
sb.WriteString(e.Message)
sb.WriteString("\n")
}
return sb.String()
}
+182
View File
@@ -0,0 +1,182 @@
package logx
import (
"strings"
"sync"
"testing"
)
// 这是本包最重要的测试:敏感内容绝不能出现在日志里。
// 每新增一条脱敏规则,都要在这里补一个用例。
func TestMaskHidesSecrets(t *testing.T) {
cases := []struct {
name string
in string
// leak 是绝对不能出现在结果里的片段
leak string
}{
{
name: "淘宝 MTOP token",
in: "cookie 里 _m_h5_tk=abc123def456_1699999999999 已读取",
leak: "abc123def456",
},
{
name: "淘宝 token enc",
in: "_m_h5_tk_enc=zzz999yyy888",
leak: "zzz999yyy888",
},
{
name: "淘宝 tb_token",
in: "_tb_token_=e3b0c44298fc; path=/",
leak: "e3b0c44298fc",
},
{
name: "Authorization 头",
in: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.fake.token",
leak: "eyJhbGciOiJIUzI1NiJ9",
},
{
name: "Cookie 请求头",
in: "Cookie: a=1; b=2; secret=3",
leak: "secret=3",
},
{
name: "JSON 里的密码",
in: `{"account":"13500000000","password":"不该出现的密码"}`,
leak: "不该出现的密码",
},
{
name: "等号形式的密码",
in: "password=hunter2&next=1",
leak: "hunter2",
},
{
name: "中文冒号密码",
in: "登录失败,密码:明文密码值",
leak: "明文密码值",
},
{
name: "图片 base64",
in: "上传 data:image/jpeg;base64,AAAABBBBCCCCDDDD 完成",
leak: "AAAABBBBCCCCDDDD",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Mask(tc.in)
if strings.Contains(got, tc.leak) {
t.Fatalf("脱敏后仍然泄漏了 %q\n原文:%s\n结果:%s", tc.leak, tc.in, got)
}
if !strings.Contains(got, "***") {
t.Fatalf("应当出现 *** 占位符,结果是:%s", got)
}
})
}
}
// 普通文本不应该被误伤。
func TestMaskKeepsNormalText(t *testing.T) {
in := "[3/6] 商品 40571188442 图搜返回 48 个同款,取前 20 个"
if got := Mask(in); got != in {
t.Fatalf("普通文本不应被改动\n原文:%s\n结果:%s", in, got)
}
}
// 写日志时也必须脱敏,不能只有直接调用 Mask 才生效。
func TestLoggerMasksOnWrite(t *testing.T) {
l := New(10)
l.Info("登录请求 password=真实密码")
entries := l.Entries()
if len(entries) != 1 {
t.Fatalf("应当有 1 条日志,实际 %d 条", len(entries))
}
if strings.Contains(entries[0].Message, "真实密码") {
t.Fatalf("日志里泄漏了密码:%s", entries[0].Message)
}
}
// 超过上限时要丢掉最旧的,防止长时间任务把内存吃光。
func TestLoggerRespectsLimit(t *testing.T) {
l := New(3)
for i := 0; i < 10; i++ {
l.Info("第 %d 条", i)
}
entries := l.Entries()
if len(entries) != 3 {
t.Fatalf("应当只保留 3 条,实际 %d 条", len(entries))
}
// 保留的应当是最后 3 条。
if !strings.Contains(entries[0].Message, "第 7 条") {
t.Fatalf("应当保留最新的日志,第一条却是:%s", entries[0].Message)
}
}
// 下载任务会有多个协程同时写日志,不能崩。
func TestLoggerIsConcurrentSafe(t *testing.T) {
l := New(1000)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; j < 50; j++ {
l.Info("协程 %d 第 %d 条", n, j)
}
}(i)
}
wg.Wait()
if len(l.Entries()) != 1000 {
t.Fatalf("应当有 1000 条日志,实际 %d 条", len(l.Entries()))
}
}
func TestLoggerLevels(t *testing.T) {
l := New(10)
l.Info("信息")
l.Success("成功")
l.Warn("警告")
l.Error("失败")
want := []Level{LevelInfo, LevelSuccess, LevelWarn, LevelError}
entries := l.Entries()
for i, level := range want {
if entries[i].Level != level {
t.Fatalf("第 %d 条级别应当是 %s,实际 %s", i, level, entries[i].Level)
}
}
}
func TestLoggerClearAndText(t *testing.T) {
l := New(10)
l.Info("第一条")
l.Info("第二条")
text := l.Text()
if !strings.Contains(text, "第一条") || !strings.Contains(text, "第二条") {
t.Fatalf("导出文本应当包含全部日志,实际:%s", text)
}
l.Clear()
if len(l.Entries()) != 0 {
t.Fatalf("清空后不应还有日志")
}
}
// 日志写入后要能推给前端。
func TestLoggerHandler(t *testing.T) {
l := New(10)
var got []Entry
l.SetHandler(func(e Entry) { got = append(got, e) })
l.Info("一条日志")
if len(got) != 1 {
t.Fatalf("回调应当被调用 1 次,实际 %d 次", len(got))
}
if got[0].Message != "一条日志" {
t.Fatalf("回调收到的内容不对:%s", got[0].Message)
}
}
+299
View File
@@ -0,0 +1,299 @@
package store
import (
"database/sql"
"fmt"
"strings"
)
// 商品的三个状态字段各有一组取值。
//
// 为什么用字符串常量而不是数字:出问题时直接 SQL 查库就能看懂,
// 不用回来翻代码对照数字含义。
const (
// video_status:有没有找到同款视频
VideoPending = "pending" // 还没搜过
VideoFound = "found" // 找到了
VideoNone = "none" // 搜过了,但没有同款视频
// download_status:视频下载进度
DownloadPending = "pending" // 待下载
DownloadRunning = "running" // 下载中
DownloadDone = "done" // 已下载
DownloadFailed = "failed" // 失败
// upload_status:上传回货憨憨的进度
UploadPending = "pending" // 待上传
UploadRunning = "running" // 上传中
UploadDone = "done" // 已上传
UploadFailed = "failed" // 失败
)
// Product 是一个商品。字段和 products 表一一对应。
//
// json tag 决定了前端拿到的字段名,改名会让界面显示空白。
type Product struct {
ID string `json:"id"` // 货憨憨内部记录 ID
ItemID string `json:"itemId"` // Shopee 商品 ID,界面显示为「蝦皮ID」
ItemName string `json:"itemName"` // 标题
MainImage string `json:"mainImage"` // 主图地址,用作淘宝以图搜的输入
ShopName string `json:"shopName"` // 店铺名
PlatformShopID string `json:"platformShopId"` // 平台店铺 ID
Currency string `json:"currency"` // 币种,例如 TWD
MinSkuPrice float64 `json:"minSkuPrice"` // 最低价
ItemStatus string `json:"itemStatus"` // 货憨憨的商品状态,例如 NORMAL
CreatedAt string `json:"createdAt"` // 货憨憨侧创建时间
VideoStatus string `json:"videoStatus"` // 本工具维护
DownloadStatus string `json:"downloadStatus"` // 本工具维护
UploadStatus string `json:"uploadStatus"` // 本工具维护
LastError string `json:"lastError"` // 最近一次失败原因
SyncedAt string `json:"syncedAt"` // 本地同步时间
}
// ProductQuery 是查询条件,对应界面工具栏上行的四个筛选框。
//
// 所有字段都可以留空,留空表示不按这个条件过滤。
type ProductQuery struct {
PlatformShopID string `json:"platformShopId"` // 店铺
ItemIDs string `json:"itemIds"` // 蝦皮ID,多个用逗号分隔
CreatedFrom string `json:"createdFrom"` // 创建时间起,形如 2026-08-01
CreatedTo string `json:"createdTo"` // 创建时间止
ItemStatus string `json:"itemStatus"` // 商品状态
Page int `json:"page"` // 页码,从 1 开始
PageSize int `json:"pageSize"` // 每页条数
}
// ProductPage 是一页查询结果。
type ProductPage struct {
Items []Product `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// UpsertProducts 批量写入商品:已存在就更新,不存在就插入。
//
// 关键点:更新时只覆盖来自货憨憨的字段,绝不动 video_status、
// download_status、upload_status 这三列。否则每次「下载数据」
// 都会把已经下载好的进度清零,同事会白干。
func (s *Store) UpsertProducts(items []Product, now string) error {
if len(items) == 0 {
return nil
}
// 用事务包起来:要么全写成功,要么一条都不写。
// 中途断网时不会留下写了一半的数据。
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("开启事务失败:%w", err)
}
// Rollback 在已经 Commit 后调用会返回错误,这里忽略即可。
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO products (
id, item_id, item_name, main_image, shop_name, platform_shop_id,
currency, min_sku_price, item_status, created_at, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
item_id = excluded.item_id,
item_name = excluded.item_name,
main_image = excluded.main_image,
shop_name = excluded.shop_name,
platform_shop_id = excluded.platform_shop_id,
currency = excluded.currency,
min_sku_price = excluded.min_sku_price,
item_status = excluded.item_status,
created_at = excluded.created_at,
synced_at = excluded.synced_at`)
if err != nil {
return fmt.Errorf("准备写入语句失败:%w", err)
}
defer stmt.Close()
for _, p := range items {
if p.ID == "" {
// 没有货憨憨记录 ID 的数据没法关联,直接跳过,
// 不要用 item_id 顶替,两者不是一回事。
continue
}
_, err := stmt.Exec(p.ID, p.ItemID, p.ItemName, p.MainImage, p.ShopName,
p.PlatformShopID, p.Currency, p.MinSkuPrice, p.ItemStatus,
p.CreatedAt, now)
if err != nil {
return fmt.Errorf("写入商品 %s 失败:%w", p.ID, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交事务失败:%w", err)
}
return nil
}
// ListProducts 按条件分页查询商品。
func (s *Store) ListProducts(q ProductQuery) (ProductPage, error) {
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 || q.PageSize > 200 {
q.PageSize = 20
}
where, args := buildWhere(q)
var total int
countSQL := "SELECT COUNT(*) FROM products" + where
if err := s.db.QueryRow(countSQL, args...).Scan(&total); err != nil {
return ProductPage{}, fmt.Errorf("统计商品数量失败:%w", err)
}
listSQL := `SELECT id, item_id, item_name, main_image, shop_name,
platform_shop_id, currency, min_sku_price, item_status, created_at,
video_status, download_status, upload_status, last_error, synced_at
FROM products` + where +
` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`
listArgs := append(append([]any{}, args...), q.PageSize, (q.Page-1)*q.PageSize)
rows, err := s.db.Query(listSQL, listArgs...)
if err != nil {
return ProductPage{}, fmt.Errorf("查询商品失败:%w", err)
}
defer rows.Close()
items := make([]Product, 0, q.PageSize)
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.ItemID, &p.ItemName, &p.MainImage,
&p.ShopName, &p.PlatformShopID, &p.Currency, &p.MinSkuPrice,
&p.ItemStatus, &p.CreatedAt, &p.VideoStatus, &p.DownloadStatus,
&p.UploadStatus, &p.LastError, &p.SyncedAt); err != nil {
return ProductPage{}, fmt.Errorf("读取商品行失败:%w", err)
}
items = append(items, p)
}
if err := rows.Err(); err != nil {
return ProductPage{}, fmt.Errorf("遍历商品结果失败:%w", err)
}
return ProductPage{Items: items, Total: total, Page: q.Page, Size: q.PageSize}, nil
}
// buildWhere 根据查询条件拼出 WHERE 子句和参数。
//
// 全部用 ? 占位符传参,不要用字符串拼接把用户输入拼进 SQL,
// 那是 SQL 注入。
func buildWhere(q ProductQuery) (string, []any) {
var conds []string
var args []any
if v := strings.TrimSpace(q.PlatformShopID); v != "" {
conds = append(conds, "platform_shop_id = ?")
args = append(args, v)
}
if v := strings.TrimSpace(q.ItemStatus); v != "" {
conds = append(conds, "item_status = ?")
args = append(args, v)
}
if v := strings.TrimSpace(q.CreatedFrom); v != "" {
conds = append(conds, "created_at >= ?")
args = append(args, v)
}
if v := strings.TrimSpace(q.CreatedTo); v != "" {
// 用户填的是日期,加上时间上界,否则当天的数据会被漏掉。
conds = append(conds, "created_at <= ?")
args = append(args, v+" 23:59:59")
}
if ids := splitIDs(q.ItemIDs); len(ids) > 0 {
// IN (?, ?, ?) 的占位符个数要和参数个数一致。
holders := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
conds = append(conds, "item_id IN ("+holders+")")
for _, id := range ids {
args = append(args, id)
}
}
if len(conds) == 0 {
return "", nil
}
return " WHERE " + strings.Join(conds, " AND "), args
}
// splitIDs 把「多个用逗号分隔」的输入拆成一个个 ID。
//
// 同事可能用中文逗号、空格或换行分隔,都要能认。
func splitIDs(raw string) []string {
replacer := strings.NewReplacer(",", ",", " ", ",", "\n", ",", "\t", ",", "、", ",")
parts := strings.Split(replacer.Replace(raw), ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// CountProducts 返回商品总数,界面底部显示「共 N 条」用。
func (s *Store) CountProducts() (int, error) {
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM products`).Scan(&n); err != nil {
return 0, fmt.Errorf("统计商品总数失败:%w", err)
}
return n, nil
}
// GetProduct 按货憨憨记录 ID 取一个商品。找不到时返回 false,不算错误。
func (s *Store) GetProduct(id string) (Product, bool, error) {
var p Product
row := s.db.QueryRow(`SELECT id, item_id, item_name, main_image, shop_name,
platform_shop_id, currency, min_sku_price, item_status, created_at,
video_status, download_status, upload_status, last_error, synced_at
FROM products WHERE id = ?`, id)
err := row.Scan(&p.ID, &p.ItemID, &p.ItemName, &p.MainImage, &p.ShopName,
&p.PlatformShopID, &p.Currency, &p.MinSkuPrice, &p.ItemStatus,
&p.CreatedAt, &p.VideoStatus, &p.DownloadStatus, &p.UploadStatus,
&p.LastError, &p.SyncedAt)
switch {
case err == sql.ErrNoRows:
return Product{}, false, nil
case err != nil:
return Product{}, false, fmt.Errorf("读取商品失败:%w", err)
default:
return p, true, nil
}
}
// UpdateProductStatus 更新一个商品的处理状态。
//
// 传空字符串表示这一项不改,这样调用方只想改下载状态时不用先查一次。
func (s *Store) UpdateProductStatus(id, videoStatus, downloadStatus, uploadStatus, lastError string) error {
var sets []string
var args []any
if videoStatus != "" {
sets = append(sets, "video_status = ?")
args = append(args, videoStatus)
}
if downloadStatus != "" {
sets = append(sets, "download_status = ?")
args = append(args, downloadStatus)
}
if uploadStatus != "" {
sets = append(sets, "upload_status = ?")
args = append(args, uploadStatus)
}
// last_error 允许写空字符串,表示清除上一次的错误,
// 所以它不跟着上面的“空表示不改”规则。
sets = append(sets, "last_error = ?")
args = append(args, lastError)
args = append(args, id)
query := "UPDATE products SET " + strings.Join(sets, ", ") + " WHERE id = ?"
if _, err := s.db.Exec(query, args...); err != nil {
return fmt.Errorf("更新商品状态失败:%w", err)
}
return nil
}
+211
View File
@@ -0,0 +1,211 @@
// Package store 负责本地 SQLite 数据库。
//
// 本项目的所有状态(商品、视频、下载和上传进度、货憨憨登录态)
// 都保存在这里,它是唯一的事实来源。不要再用 JSON 文件另存一份,
// 那样两边一定会不一致。
//
// 用的是 modernc.org/sqlite —— 纯 Go 实现的 SQLite,不需要 CGO,
// 也就不需要在机器上装 gcc。换成 mattn/go-sqlite3 会让新同事
// 第一次编译就卡在编译器上,所以不要换。
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite" // 注册名为 "sqlite" 的驱动
)
// Store 是数据库连接。用完要 Close。
type Store struct {
db *sql.DB
}
// Open 打开(不存在就创建)数据库文件,并把表结构升级到最新。
//
// path 传 ":memory:" 可以开一个只存在于内存里的库,单元测试就用这个。
func Open(path string) (*Store, error) {
if path != ":memory:" {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败:%w", err)
}
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("打开数据库失败:%w", err)
}
// SQLite 同一时间只允许一个写入者。把连接数限制为 1,
// 可以避免多个下载协程同时写时报 "database is locked"。
// 本项目数据量很小,这点性能损失可以忽略。
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("连接数据库失败:%w", err)
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close 关闭数据库。
func (s *Store) Close() error {
return s.db.Close()
}
// DB 返回底层连接,只给同一个包里的文件用。
func (s *Store) DB() *sql.DB { return s.db }
// migrations 是按顺序执行的建表和改表语句。
//
// 怎么加一次改动(很重要,改错会丢数据):
// 1. 在切片末尾追加一条,绝不要修改或删除已有的任何一条;
// 2. 已经发布过的语句改了,老用户的库就升不上来了;
// 3. 加完记得在 store_test.go 里补一个测试。
//
// 每条语句都必须能重复执行而不报错(用 IF NOT EXISTS),
// 因为程序每次启动都会把它们全跑一遍。
var migrations = []string{
// 1. 商品表。一行对应货憨憨里的一个 Shopee 在线商品。
`CREATE TABLE IF NOT EXISTS products (
-- 货憨憨内部记录 ID,是后续所有写操作的关联键。
-- 注意不是 Shopee 商品 ID,两者不同,别搞混。
id TEXT PRIMARY KEY,
-- Shopee 商品 ID,界面上显示为「蝦皮ID」。
item_id TEXT NOT NULL DEFAULT '',
item_name TEXT NOT NULL DEFAULT '',
main_image TEXT NOT NULL DEFAULT '',
shop_name TEXT NOT NULL DEFAULT '',
platform_shop_id TEXT NOT NULL DEFAULT '',
currency TEXT NOT NULL DEFAULT '',
min_sku_price REAL NOT NULL DEFAULT 0,
item_status TEXT NOT NULL DEFAULT '',
-- 货憨憨侧的创建时间,原样保存字符串,不做时区换算。
created_at TEXT NOT NULL DEFAULT '',
-- 下面几个是本工具自己维护的状态,货憨憨不知道这些。
-- 取值见 product.go 里的常量。
video_status TEXT NOT NULL DEFAULT 'pending',
download_status TEXT NOT NULL DEFAULT 'pending',
upload_status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT NOT NULL DEFAULT '',
-- 本地记录的同步时间,方便排查“这条什么时候拉下来的”。
synced_at TEXT NOT NULL DEFAULT ''
)`,
// 2. 按店铺查是最常用的操作,加个索引。
`CREATE INDEX IF NOT EXISTS idx_products_shop
ON products (platform_shop_id)`,
// 3. 视频表。一个商品可能有多个视频,所以单独一张表。
`CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- 对应 products.id
product_id TEXT NOT NULL,
-- 淘宝同款商品 ID 和视频原始地址,用于排查和去重
source_item TEXT NOT NULL DEFAULT '',
source_url TEXT NOT NULL DEFAULT '',
-- 下载到本地后的文件路径
local_path TEXT NOT NULL DEFAULT '',
file_size INTEGER NOT NULL DEFAULT 0,
-- 上传到货憨憨素材空间后拿到的地址
remote_url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_videos_product
ON videos (product_id)`,
// 4. 键值表。存货憨憨登录态这类零散数据。
// 存进来的值可能含 token,读写时不要往日志里打。
`CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)`,
}
// migrate 把表结构升级到最新。
//
// 用 schema_version 记录已经执行到第几条,这样已经跑过的语句不会重复跑。
func (s *Store) migrate() error {
if _, err := s.db.Exec(
`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`,
); err != nil {
return fmt.Errorf("创建版本表失败:%w", err)
}
var current int
row := s.db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`)
if err := row.Scan(&current); err != nil {
return fmt.Errorf("读取数据库版本失败:%w", err)
}
for i := current; i < len(migrations); i++ {
if _, err := s.db.Exec(migrations[i]); err != nil {
// 带上第几条,出问题时能直接定位到 migrations 切片。
return fmt.Errorf("执行第 %d 条建表语句失败:%w", i+1, err)
}
if _, err := s.db.Exec(
`INSERT INTO schema_version (version) VALUES (?)`, i+1,
); err != nil {
return fmt.Errorf("记录数据库版本失败:%w", err)
}
}
return nil
}
// Version 返回当前数据库结构版本,等于已执行的建表语句条数。
func (s *Store) Version() (int, error) {
var v int
row := s.db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`)
if err := row.Scan(&v); err != nil {
return 0, fmt.Errorf("读取数据库版本失败:%w", err)
}
return v, nil
}
// SetKV 写入一个键值对。已存在就覆盖。
func (s *Store) SetKV(key, value, now string) error {
_, err := s.db.Exec(`
INSERT INTO kv (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
updated_at = excluded.updated_at`,
key, value, now)
if err != nil {
return fmt.Errorf("写入 kv 失败:%w", err)
}
return nil
}
// GetKV 读取一个键值对。键不存在时返回空字符串和 false,不当成错误。
func (s *Store) GetKV(key string) (string, bool, error) {
var value string
row := s.db.QueryRow(`SELECT value FROM kv WHERE key = ?`, key)
switch err := row.Scan(&value); {
case err == sql.ErrNoRows:
return "", false, nil
case err != nil:
return "", false, fmt.Errorf("读取 kv 失败:%w", err)
default:
return value, true, nil
}
}
// DefaultPath 返回数据库文件的默认位置:程序所在目录下的 cmsp.db。
func DefaultPath() string {
exe, err := os.Executable()
if err != nil {
return "cmsp.db"
}
return filepath.Join(filepath.Dir(exe), "cmsp.db")
}
+276
View File
@@ -0,0 +1,276 @@
package store
import (
"path/filepath"
"testing"
)
// newTestStore 开一个只存在于内存里的数据库。
// 测试之间互不影响,也不会在磁盘上留垃圾文件。
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(":memory:")
if err != nil {
t.Fatalf("打开测试数据库失败:%v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestOpenCreatesAllTables(t *testing.T) {
s := newTestStore(t)
version, err := s.Version()
if err != nil {
t.Fatalf("读取版本失败:%v", err)
}
if version != len(migrations) {
t.Fatalf("版本应当等于建表语句条数 %d,实际 %d", len(migrations), version)
}
// 每张表都要真的存在。
for _, table := range []string{"products", "videos", "kv", "schema_version"} {
var name string
err := s.DB().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
table).Scan(&name)
if err != nil {
t.Fatalf("表 %s 不存在:%v", table, err)
}
}
}
// 程序每次启动都会跑一遍建表语句,重复打开不能出错,也不能重复升版本。
func TestMigrateIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "cmsp.db")
first, err := Open(path)
if err != nil {
t.Fatalf("第一次打开失败:%v", err)
}
v1, _ := first.Version()
first.Close()
second, err := Open(path)
if err != nil {
t.Fatalf("第二次打开失败:%v", err)
}
defer second.Close()
v2, _ := second.Version()
if v1 != v2 {
t.Fatalf("重复打开不应改变版本,第一次 %d,第二次 %d", v1, v2)
}
}
func sampleProducts() []Product {
return []Product{
{
ID: "1118154275420983296", ItemID: "40583431295",
ItemName: "短袖連衣裙", MainImage: "https://example.invalid/a.jpg",
ShopName: "集物生活life", PlatformShopID: "406655548",
Currency: "TWD", MinSkuPrice: 859, ItemStatus: "NORMAL",
CreatedAt: "2026-08-10 10:38:51",
},
{
ID: "1118154275420983297", ItemID: "40583429107",
ItemName: "純棉短袖T恤", MainImage: "https://example.invalid/b.jpg",
ShopName: "集物生活life", PlatformShopID: "406655548",
Currency: "TWD", MinSkuPrice: 299, ItemStatus: "NORMAL",
CreatedAt: "2026-08-11 09:12:00",
},
{
ID: "1118154275420983298", ItemID: "40571188442",
ItemName: "高腰闊腿牛仔褲", MainImage: "https://example.invalid/c.jpg",
ShopName: "YUNQISHI", PlatformShopID: "411902773",
Currency: "TWD", MinSkuPrice: 645, ItemStatus: "UNLIST",
CreatedAt: "2026-08-12 14:05:00",
},
}
}
func TestUpsertAndList(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil {
t.Fatalf("写入商品失败:%v", err)
}
page, err := s.ListProducts(ProductQuery{})
if err != nil {
t.Fatalf("查询失败:%v", err)
}
if page.Total != 3 {
t.Fatalf("应当有 3 条,实际 %d 条", page.Total)
}
// 默认按创建时间倒序,最新的应当排第一。
if page.Items[0].ItemID != "40571188442" {
t.Fatalf("应当按创建时间倒序,第一条却是 %s", page.Items[0].ItemID)
}
// 新写入的商品,三个处理状态都应当是 pending。
if page.Items[0].DownloadStatus != DownloadPending {
t.Fatalf("新商品下载状态应当是 pending,实际 %s", page.Items[0].DownloadStatus)
}
}
// 这是本包最关键的一条规则:重复同步不能把已有的处理进度清零。
func TestUpsertKeepsLocalStatus(t *testing.T) {
s := newTestStore(t)
items := sampleProducts()
if err := s.UpsertProducts(items, "2026-09-02 10:00:00"); err != nil {
t.Fatalf("首次写入失败:%v", err)
}
// 假装已经下载并上传完成了。
id := items[0].ID
if err := s.UpdateProductStatus(id, VideoFound, DownloadDone, UploadDone, ""); err != nil {
t.Fatalf("更新状态失败:%v", err)
}
// 再同步一次,标题在货憨憨那边被改了。
items[0].ItemName = "改过的标题"
if err := s.UpsertProducts(items, "2026-09-02 11:00:00"); err != nil {
t.Fatalf("再次写入失败:%v", err)
}
got, found, err := s.GetProduct(id)
if err != nil || !found {
t.Fatalf("读取商品失败:err=%v found=%v", err, found)
}
if got.ItemName != "改过的标题" {
t.Fatalf("来自货憨憨的字段应当被更新,实际 %q", got.ItemName)
}
if got.DownloadStatus != DownloadDone {
t.Fatalf("下载状态不能被同步覆盖,期望 %s,实际 %s", DownloadDone, got.DownloadStatus)
}
if got.UploadStatus != UploadDone {
t.Fatalf("上传状态不能被同步覆盖,期望 %s,实际 %s", UploadDone, got.UploadStatus)
}
}
func TestListProductsFilters(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil {
t.Fatalf("写入失败:%v", err)
}
cases := []struct {
name string
query ProductQuery
want int
}{
{"按店铺", ProductQuery{PlatformShopID: "406655548"}, 2},
{"按状态", ProductQuery{ItemStatus: "UNLIST"}, 1},
{"按单个蝦皮ID", ProductQuery{ItemIDs: "40583431295"}, 1},
{"按多个蝦皮ID", ProductQuery{ItemIDs: "40583431295,40583429107"}, 2},
{"中文逗号分隔", ProductQuery{ItemIDs: "40583431295,40583429107"}, 2},
{"空格分隔", ProductQuery{ItemIDs: "40583431295 40583429107"}, 2},
{"创建时间起", ProductQuery{CreatedFrom: "2026-08-11"}, 2},
{"创建时间止", ProductQuery{CreatedTo: "2026-08-11"}, 2},
{"时间范围", ProductQuery{CreatedFrom: "2026-08-11", CreatedTo: "2026-08-11"}, 1},
{"条件叠加", ProductQuery{PlatformShopID: "406655548", ItemStatus: "NORMAL"}, 2},
{"查不到", ProductQuery{PlatformShopID: "不存在的店铺"}, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
page, err := s.ListProducts(tc.query)
if err != nil {
t.Fatalf("查询失败:%v", err)
}
if page.Total != tc.want {
t.Fatalf("期望 %d 条,实际 %d 条", tc.want, page.Total)
}
})
}
}
func TestListProductsPaging(t *testing.T) {
s := newTestStore(t)
if err := s.UpsertProducts(sampleProducts(), "2026-09-02 10:00:00"); err != nil {
t.Fatalf("写入失败:%v", err)
}
page, err := s.ListProducts(ProductQuery{Page: 2, PageSize: 2})
if err != nil {
t.Fatalf("查询失败:%v", err)
}
if page.Total != 3 {
t.Fatalf("总数应当是 3,实际 %d", page.Total)
}
if len(page.Items) != 1 {
t.Fatalf("第 2 页应当只有 1 条,实际 %d 条", len(page.Items))
}
}
// 没有货憨憨记录 ID 的数据要跳过,不能拿 item_id 顶替。
func TestUpsertSkipsRowsWithoutID(t *testing.T) {
s := newTestStore(t)
err := s.UpsertProducts([]Product{{ItemID: "40583431295"}}, "2026-09-02 10:00:00")
if err != nil {
t.Fatalf("不应报错:%v", err)
}
n, err := s.CountProducts()
if err != nil {
t.Fatalf("统计失败:%v", err)
}
if n != 0 {
t.Fatalf("缺少 ID 的数据不应写入,实际写入 %d 条", n)
}
}
func TestGetProductNotFound(t *testing.T) {
s := newTestStore(t)
_, found, err := s.GetProduct("不存在")
if err != nil {
t.Fatalf("查不到不应报错:%v", err)
}
if found {
t.Fatalf("不应当找到")
}
}
func TestKV(t *testing.T) {
s := newTestStore(t)
if _, found, err := s.GetKV("token"); err != nil || found {
t.Fatalf("键不存在时应当返回 false,err=%v found=%v", err, found)
}
if err := s.SetKV("token", "第一次的值", "2026-09-02 10:00:00"); err != nil {
t.Fatalf("写入失败:%v", err)
}
if err := s.SetKV("token", "第二次的值", "2026-09-02 11:00:00"); err != nil {
t.Fatalf("覆盖失败:%v", err)
}
value, found, err := s.GetKV("token")
if err != nil || !found {
t.Fatalf("读取失败:err=%v found=%v", err, found)
}
if value != "第二次的值" {
t.Fatalf("应当读到覆盖后的值,实际 %q", value)
}
}
func TestSplitIDs(t *testing.T) {
cases := []struct {
in string
want int
}{
{"", 0},
{" ", 0},
{"111", 1},
{"111,222", 2},
{"111,222", 2},
{"111 222 333", 3},
{"111,,222", 2},
{"111、222", 2},
{" 111 , 222 ", 2},
}
for _, tc := range cases {
if got := splitIDs(tc.in); len(got) != tc.want {
t.Fatalf("输入 %q 期望 %d 个 ID,实际 %d 个:%v", tc.in, tc.want, len(got), got)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
// cmsp 是一个 Windows 桌面工具:把货憨憨 ERP 里指定 Shopee 店铺的商品,
// 按商品主图到淘宝搜同款,抓取同款商品详情页的介绍视频,下载到本地,
// 再批量上传回货憨憨对应商品,使 Shopee 商品页刷新后能看到视频。
//
// 从哪里开始读:
//
// main.go 这里,负责启动窗口
// app.go 暴露给前端的方法清单
// internal/config/ 参数设置的读写与校验
// internal/store/ SQLite,所有状态的唯一事实来源
// internal/logx/ 带脱敏的日志
// frontend/src/ 界面
//
// 完整说明见 Wiki:https://git.ilapage.cn/chengma/cmsp/wiki
package main
import (
"embed"
"log"
"os"
"path/filepath"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/windows"
)
// 把编译好的前端打包进可执行文件,这样发给同事就是单个 exe,
// 不用再带一堆网页文件。
//
//go:embed all:frontend/dist
var assets embed.FS
func main() {
app := NewApp()
// 窗口尺寸对应已确认的原型:1440x900。
// MinWidth 设成 1200 是因为工具栏上行有六个筛选控件,
// 再窄就会挤在一起看不清。
err := wails.Run(&options.App{
Title: "cmsp — Shopee 商品视频工具",
Width: 1440,
Height: 900,
MinWidth: 1200,
MinHeight: 700,
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: app.startup,
OnShutdown: app.shutdown,
// Bind 里的对象会被生成成 JavaScript 给前端调用。
// 新增可供前端调用的方法,就写成 App 的公开方法,不用改这里。
Bind: []any{
app,
},
Windows: &windows.Options{
// 关掉右键菜单里的「检查」等开发者项,避免同事误点。
// 需要调试时用 wails dev,那时会自动打开。
DisableWindowIcon: false,
},
})
if err != nil {
// 走到这里说明窗口没能创建,界面上什么都看不到,
// 所以要同时写到控制台和文件,否则同事只会看到程序一闪而过。
log.Println("启动失败:", err)
writeStartupError(err)
}
}
// writeStartupError 把启动失败的原因写到程序目录下的文件里。
//
// 双击 exe 启动时没有控制台,报错会一闪而过。留个文件,
// 同事能直接把它发给维护者。
func writeStartupError(runErr error) {
exe, err := os.Executable()
if err != nil {
return
}
path := filepath.Join(filepath.Dir(exe), "启动失败.txt")
_ = writeTextFile(path, "cmsp 启动失败:\n"+runErr.Error()+"\n")
}
// writeTextFile 用 UTF-8 写一个文本文件。
//
// 单独抽出来是因为 app.go 导出日志时也要用。
func writeTextFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0o600)
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "cmsp",
"outputfilename": "cmsp",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"info": {
"companyName": "内部工具",
"productName": "cmsp",
"productVersion": "0.1.0",
"comments": "货憨憨 Shopee 商品视频工具"
}
}