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:
+30
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -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>
|
||||
@@ -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 |
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Generated
+1592
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
8fffac55453e796accbe757622758e58
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)) }} 页
|
||||
每页 {{ query.pageSize }} 条 共 {{ 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()]
|
||||
})
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤t); 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 商品视频工具"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user