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 @@ + + + + + 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 @@ + + + + + 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 商品视频工具" + } +}