diff --git a/.gitignore b/.gitignore
index e563dcb..6ce0039 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/app.go b/app.go
new file mode 100644
index 0000000..ea3de1c
--- /dev/null
+++ b/app.go
@@ -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
+}
diff --git a/build/README.md b/build/README.md
new file mode 100644
index 0000000..1ae2f67
--- /dev/null
+++ b/build/README.md
@@ -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.
\ No newline at end of file
diff --git a/build/appicon.png b/build/appicon.png
new file mode 100644
index 0000000..63617fe
Binary files /dev/null and b/build/appicon.png differ
diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist
new file mode 100644
index 0000000..c20334b
--- /dev/null
+++ b/build/darwin/Info.dev.plist
@@ -0,0 +1,68 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ {{.Info.ProductName}}
+ CFBundleExecutable
+ {{.OutputFilename}}
+ CFBundleIdentifier
+ com.wails.{{safeBundleID .Name}}
+ CFBundleVersion
+ {{.Info.ProductVersion}}
+ CFBundleGetInfoString
+ {{.Info.Comments}}
+ CFBundleShortVersionString
+ {{.Info.ProductVersion}}
+ CFBundleIconFile
+ iconfile
+ LSMinimumSystemVersion
+ 10.13.0
+ NSHighResolutionCapable
+ true
+ NSHumanReadableCopyright
+ {{.Info.Copyright}}
+ {{if .Info.FileAssociations}}
+ CFBundleDocumentTypes
+
+ {{range .Info.FileAssociations}}
+
+ CFBundleTypeExtensions
+
+ {{.Ext}}
+
+ CFBundleTypeName
+ {{.Name}}
+ CFBundleTypeRole
+ {{.Role}}
+ CFBundleTypeIconFile
+ {{.IconName}}
+
+ {{end}}
+
+ {{end}}
+ {{if .Info.Protocols}}
+ CFBundleURLTypes
+
+ {{range .Info.Protocols}}
+
+ CFBundleURLName
+ com.wails.{{.Scheme}}
+ CFBundleURLSchemes
+
+ {{.Scheme}}
+
+ CFBundleTypeRole
+ {{.Role}}
+
+ {{end}}
+
+ {{end}}
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+
+
diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist
new file mode 100644
index 0000000..4de2887
--- /dev/null
+++ b/build/darwin/Info.plist
@@ -0,0 +1,63 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ {{.Info.ProductName}}
+ CFBundleExecutable
+ {{.OutputFilename}}
+ CFBundleIdentifier
+ com.wails.{{safeBundleID .Name}}
+ CFBundleVersion
+ {{.Info.ProductVersion}}
+ CFBundleGetInfoString
+ {{.Info.Comments}}
+ CFBundleShortVersionString
+ {{.Info.ProductVersion}}
+ CFBundleIconFile
+ iconfile
+ LSMinimumSystemVersion
+ 10.13.0
+ NSHighResolutionCapable
+ true
+ NSHumanReadableCopyright
+ {{.Info.Copyright}}
+ {{if .Info.FileAssociations}}
+ CFBundleDocumentTypes
+
+ {{range .Info.FileAssociations}}
+
+ CFBundleTypeExtensions
+
+ {{.Ext}}
+
+ CFBundleTypeName
+ {{.Name}}
+ CFBundleTypeRole
+ {{.Role}}
+ CFBundleTypeIconFile
+ {{.IconName}}
+
+ {{end}}
+
+ {{end}}
+ {{if .Info.Protocols}}
+ CFBundleURLTypes
+
+ {{range .Info.Protocols}}
+
+ CFBundleURLName
+ com.wails.{{.Scheme}}
+ CFBundleURLSchemes
+
+ {{.Scheme}}
+
+ CFBundleTypeRole
+ {{.Role}}
+
+ {{end}}
+
+ {{end}}
+
+
diff --git a/build/windows/icon.ico b/build/windows/icon.ico
new file mode 100644
index 0000000..f334798
Binary files /dev/null and b/build/windows/icon.ico differ
diff --git a/build/windows/info.json b/build/windows/info.json
new file mode 100644
index 0000000..9727946
--- /dev/null
+++ b/build/windows/info.json
@@ -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}}"
+ }
+ }
+}
\ No newline at end of file
diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi
new file mode 100644
index 0000000..90fabb3
--- /dev/null
+++ b/build/windows/installer/project.nsi
@@ -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
diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh
new file mode 100644
index 0000000..6dd9057
--- /dev/null
+++ b/build/windows/installer/wails_tools.nsh
@@ -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
diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest
new file mode 100644
index 0000000..17e1a23
--- /dev/null
+++ b/build/windows/wails.exe.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ permonitorv2,permonitor
+
+
+
\ No newline at end of file
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644
index 0000000..60bf3c0
--- /dev/null
+++ b/config.example.yaml
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..e0d72e0
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ cmsp
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..a42ede2
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1592 @@
+{
+ "name": "cmsp-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "cmsp-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "naive-ui": "^2.40.0",
+ "vue": "^3.5.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.0",
+ "vite": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@css-render/plugin-bem": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
+ "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "css-render": "~0.15.14"
+ }
+ },
+ "node_modules/@css-render/vue3-ssr": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
+ "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+ "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+ "license": "MIT"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "license": "MIT"
+ },
+ "node_modules/@juggle/resize-observer": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+ "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+ "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+ "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+ "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+ "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+ "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+ "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+ "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+ "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+ "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+ "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+ "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+ "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+ "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+ "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+ "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+ "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+ "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+ "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+ "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+ "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+ "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+ "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.25",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
+ "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "6.0.8",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
+ "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
+ "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/shared": "3.5.42",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz",
+ "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.42",
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz",
+ "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/compiler-core": "3.5.42",
+ "@vue/compiler-dom": "3.5.42",
+ "@vue/compiler-ssr": "3.5.42",
+ "@vue/shared": "3.5.42",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.19",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz",
+ "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.42",
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz",
+ "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz",
+ "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.42",
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz",
+ "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.42",
+ "@vue/runtime-core": "3.5.42",
+ "@vue/shared": "3.5.42",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz",
+ "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.42",
+ "@vue/runtime-dom": "3.5.42",
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz",
+ "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
+ "license": "MIT"
+ },
+ "node_modules/async-validator": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+ "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+ "license": "MIT"
+ },
+ "node_modules/css-render": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
+ "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/hash": "~0.8.0",
+ "csstype": "~3.0.5"
+ }
+ },
+ "node_modules/css-render/node_modules/csstype": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
+ "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/date-fns": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/date-fns-tz": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
+ "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "date-fns": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/evtd": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz",
+ "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
+ "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/naive-ui": {
+ "version": "2.45.3",
+ "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.45.3.tgz",
+ "integrity": "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@css-render/plugin-bem": "^0.15.14",
+ "@css-render/vue3-ssr": "^0.15.14",
+ "@types/lodash": "^4.17.20",
+ "@types/lodash-es": "^4.17.12",
+ "async-validator": "^4.2.5",
+ "css-render": "^0.15.14",
+ "csstype": "^3.1.3",
+ "date-fns": "^4.1.0",
+ "date-fns-tz": "^3.2.0",
+ "evtd": "^0.2.4",
+ "highlight.js": "^11.8.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "seemly": "^0.3.10",
+ "treemate": "^0.3.11",
+ "vdirs": "^0.1.8",
+ "vooks": "^0.2.12",
+ "vueuc": "^0.4.66"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
+ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.1",
+ "@rollup/rollup-android-arm64": "4.63.1",
+ "@rollup/rollup-darwin-arm64": "4.63.1",
+ "@rollup/rollup-darwin-x64": "4.63.1",
+ "@rollup/rollup-freebsd-arm64": "4.63.1",
+ "@rollup/rollup-freebsd-x64": "4.63.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+ "@rollup/rollup-linux-arm64-musl": "4.63.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+ "@rollup/rollup-linux-loong64-musl": "4.63.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-musl": "4.63.1",
+ "@rollup/rollup-openbsd-x64": "4.63.1",
+ "@rollup/rollup-openharmony-arm64": "4.63.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+ "@rollup/rollup-win32-x64-gnu": "4.63.1",
+ "@rollup/rollup-win32-x64-msvc": "4.63.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/seemly": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz",
+ "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/treemate": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz",
+ "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
+ "license": "MIT"
+ },
+ "node_modules/vdirs": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
+ "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
+ "license": "MIT",
+ "dependencies": {
+ "evtd": "^0.2.2"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vooks": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
+ "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "evtd": "^0.2.2"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz",
+ "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.42",
+ "@vue/compiler-sfc": "3.5.42",
+ "@vue/runtime-dom": "3.5.42",
+ "@vue/server-renderer": "3.5.42",
+ "@vue/shared": "3.5.42"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vueuc": {
+ "version": "0.4.66",
+ "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.66.tgz",
+ "integrity": "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==",
+ "license": "MIT",
+ "dependencies": {
+ "@css-render/vue3-ssr": "^0.15.10",
+ "@juggle/resize-observer": "^3.3.1",
+ "css-render": "^0.15.10",
+ "evtd": "^0.2.4",
+ "seemly": "^0.3.6",
+ "vdirs": "^0.1.4",
+ "vooks": "^0.2.4"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..2e66e56
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
new file mode 100644
index 0000000..5ca9a6b
--- /dev/null
+++ b/frontend/package.json.md5
@@ -0,0 +1 @@
+8fffac55453e796accbe757622758e58
\ No newline at end of file
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..4408440
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/LogWindow.vue b/frontend/src/components/LogWindow.vue
new file mode 100644
index 0000000..3b96c99
--- /dev/null
+++ b/frontend/src/components/LogWindow.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+ 自动滚动
+
+
+
+
+ {{ e.time }}
+ {{ e.message }}
+
+
暂无日志
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue
new file mode 100644
index 0000000..12c4fb4
--- /dev/null
+++ b/frontend/src/components/Sidebar.vue
@@ -0,0 +1,207 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644
index 0000000..25f368f
--- /dev/null
+++ b/frontend/src/main.js
@@ -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')
diff --git a/frontend/src/style.css b/frontend/src/style.css
new file mode 100644
index 0000000..6f59d95
--- /dev/null
+++ b/frontend/src/style.css
@@ -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;
+}
diff --git a/frontend/src/views/ProductListView.vue b/frontend/src/views/ProductListView.vue
new file mode 100644
index 0000000..4c2421d
--- /dev/null
+++ b/frontend/src/views/ProductListView.vue
@@ -0,0 +1,350 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
new file mode 100644
index 0000000..135f2ba
--- /dev/null
+++ b/frontend/src/views/SettingsView.vue
@@ -0,0 +1,250 @@
+
+
+
+
+
+
参数设置
+
账号、浏览器与任务参数。密码只保存在本机 config.yaml,不进仓库和日志
+
+
+
+
+
+
+
+ 用于下载商品数据和上传视频
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 登录由你手动完成,程序不代填密码
+
+
+
+
+
+ 选择文件
+
+
+
+
+
+
+ 选择目录
+
+
+
+
+
+ —
+
+
+
+
+ 登录必须在程序打开的专属 Chrome 里手动扫码完成。程序不会代填账号密码,
+ 也不会绕过验证码或滑块。
+
+
+
+
+
+
+
+
+
+ 选择目录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ —
+
+
+
+
+
+
+
+
+
+ 视频体积远大于图片,批量上传前先看容量
+
+
+ 容量查询还没实现(需求 R5)。上线前必须接上
+ product/material/getMaterialSize:
+ 上传返回「容量不足」时任务要立即停止,不能反复重试。
+
+
+
+
+
+
+
+ 配置保存在本机 config.yaml,密码与 token 不写入仓库、日志和工单
+
+
+ 还原默认
+
+ 保存设置
+
+
+
+
+
+
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..a30c338
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,7 @@
+import {defineConfig} from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [vue()]
+})
diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json
new file mode 100644
index 0000000..1e7c8a5
--- /dev/null
+++ b/frontend/wailsjs/runtime/package.json
@@ -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 ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/wailsapp/wails/issues"
+ },
+ "homepage": "https://github.com/wailsapp/wails#readme"
+}
diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts
new file mode 100644
index 0000000..3bbea84
--- /dev/null
+++ b/frontend/wailsjs/runtime/runtime.d.ts
@@ -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;
+
+// [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;
+
+// [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;
+
+// [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;
+
+// [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;
+
+// [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;
+
+// [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;
+
+// [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;
+
+// [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;
+
+// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
+// Sets a text on the clipboard
+export function ClipboardSetText(text: string): Promise;
+
+// [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;
+
+// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
+// Cleans up notification resources and releases any held connections.
+export function CleanupNotifications(): Promise;
+
+// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
+// Checks if notifications are available on the current platform.
+export function IsNotificationAvailable(): Promise;
+
+// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
+// Requests notification authorization from the user (macOS only).
+export function RequestNotificationAuthorization(): Promise;
+
+// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
+// Checks the current notification authorization status (macOS only).
+export function CheckNotificationAuthorization(): Promise;
+
+// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
+// Sends a basic notification with the given options.
+export function SendNotification(options: NotificationOptions): Promise;
+
+// [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;
+
+// [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;
+
+// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
+// Removes a previously registered notification category.
+export function RemoveNotificationCategory(categoryId: string): Promise;
+
+// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
+// Removes all pending notifications from the notification center.
+export function RemoveAllPendingNotifications(): Promise;
+
+// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
+// Removes a specific pending notification by its identifier.
+export function RemovePendingNotification(identifier: string): Promise;
+
+// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
+// Removes all delivered notifications from the notification center.
+export function RemoveAllDeliveredNotifications(): Promise;
+
+// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
+// Removes a specific delivered notification by its identifier.
+export function RemoveDeliveredNotification(identifier: string): Promise;
+
+// [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;
\ No newline at end of file
diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js
new file mode 100644
index 0000000..556621e
--- /dev/null
+++ b/frontend/wailsjs/runtime/runtime.js
@@ -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);
+}
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..dcadea0
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..eb75995
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..e3ff08b
--- /dev/null
+++ b/internal/config/config.go
@@ -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")
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..28821b9
--- /dev/null
+++ b/internal/config/config_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/logx/logx.go b/internal/logx/logx.go
new file mode 100644
index 0000000..5d905e3
--- /dev/null
+++ b/internal/logx/logx.go
@@ -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()
+}
diff --git a/internal/logx/logx_test.go b/internal/logx/logx_test.go
new file mode 100644
index 0000000..4acee2c
--- /dev/null
+++ b/internal/logx/logx_test.go
@@ -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)
+ }
+}
diff --git a/internal/store/product.go b/internal/store/product.go
new file mode 100644
index 0000000..6276513
--- /dev/null
+++ b/internal/store/product.go
@@ -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
+}
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..8b600dd
--- /dev/null
+++ b/internal/store/store.go
@@ -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")
+}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 0000000..2250a4c
--- /dev/null
+++ b/internal/store/store_test.go
@@ -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)
+ }
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..fae6094
--- /dev/null
+++ b/main.go
@@ -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)
+}
diff --git a/wails.json b/wails.json
new file mode 100644
index 0000000..90db801
--- /dev/null
+++ b/wails.json
@@ -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 商品视频工具"
+ }
+}