commit ccd51b91fb8e8b16e2e2e1138436cb0dbc9c00d3 Author: yml2213 Date: Sun Oct 25 15:02:41 2026 +0800 1.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..815b9a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +frontend/node_modules/ +frontend/dist/ +build/bin/ +.DS_Store +*.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e2278a0 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: dev build win test + +VERSION := $(shell node -p "require('./wails.json').info.productVersion") + +dev: + wails dev + +build: + wails build + +win: + wails build -platform windows/amd64 -o hfb_price_tool-v$(VERSION).exe + +test: + go test ./... + cd frontend && npm run build diff --git a/README.md b/README.md new file mode 100644 index 0000000..44a337d --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# 哈夫币发布计算器 + +一个独立的 Go + Wails 桌面工具,只计算**号主到手价**。输入保险格数、账号状态、哈弗币数量和物品数量后立即出结果。 + +界面按「保险格数 + 特殊状态 + 哈弗币 + 物品清单 + 底部分结果」布局,规则支持本地配置和从线上同步。 + +## 计算口径 + +```text +参考比例 = 保险基础比例 + + 缺配惩罚(红皮/刀皮/砖皮/满体力/满负重,每缺一项 +1) + + 大额币修正(>130M +1;>230M +2.5;>380M +4;>530M +5) + + 每日损耗修正(10/20/30/40/50M → +0/+1/+2/+3/+4) + +最终比例 = clamp(加速比例, 参考比例, 参考比例 + 加速上限) +纯币到手 = 哈弗币万数 ÷ 最终比例 +物品到手 = Σ(数量 × 单价) // 免费项不计 +合计到手 = 纯币到手 + 物品到手 +``` + +注意:比例数字越大,号主到手越低。加速出售是主动把比例调高、降低价格。 + +默认规则与 `hfb_sys` 主站发布口径一致: + +- 保险基础比例:`3*3=40`、`2*3=42`、`2*2=47`、`2*1=48` +- 缺配项:刀皮、角色红皮、满体力、满负重、武器皮肤 +- 大额修正:130M / 230M / 380M / 530M 四档 +- 每日损耗:10M 到 50M +- 物品单价:AWM 子弹、6 级子弹、6 级头盔、6 级护甲、巴雷特子弹、顶级保险卡、咖啡豆、子弹零件、45 格大红包 + +`3*3` 保险下「顶级保险卡 / 9 格体验卡」按主站规则禁用。 + +## 本地运行 + +```bash +wails dev +``` + +Wails 程序不能用 `go run .` 启动;直接运行会缺少 Wails 需要的构建标签。 + +## 构建 + +macOS: + +```bash +wails build +``` + +Windows exe(在 macOS 上可直接交叉编译,文件名自动带版本号): + +```bash +wails build -platform windows/amd64 -o hfb_price_tool-v1.0.1.exe +``` + +> 不要加 `-nopackage`。加上它会跳过 Windows 资源打包,导致 exe 没有自定义图标、版本属性和应用清单。 + +Windows 安装包: + +```bash +wails build -platform windows/amd64 -nsis +``` + +生成物在 `build/bin/`。最终 Windows 文件名示例:`hfb_price_tool-v1.0.1.exe`。 + +图标文件: + +- `build/appicon.png`:macOS / 通用图标源图 +- `build/windows/icon.ico`:Windows exe 图标(包含 16/24/32/48/64/128/256 多尺寸) +- `build/windows/info.json`:Windows 版本属性 +- `build/windows/wails.exe.manifest`:Windows 应用清单 + +## 规则配置 + +右上角「配置」可以编辑: + +- 基础比例 +- 缺配惩罚 +- 大额修正 +- 物品单价 / 单位 / 停用 +- 每日损耗选项、加速上限 + +本地配置保存在: + +- Windows:`%AppData%\hfb-price-tool\config.json` +- macOS:`~/Library/Application Support/hfb-price-tool/config.json` + +配置弹窗支持导入、导出和打开配置目录。首次启动会自动写入一份默认规则。 + +## 线上同步 + +「线上同步」调用主站公开接口: + +```text +GET {地址}/api/listing-publish-options +``` + +默认地址:`https://hao858.com` + +同步只覆盖: + +- `ratio_config.insurance_base_ratios` +- `ratio_config.config_items` +- `ratio_config.coin_corrections` +- `quantity_items` + +每日损耗选项、加速上限、本地物品停用状态会保留。同步成功后自动保存到本地配置。 + +接口响应结构: + +```json +{ + "code": "ok", + "message": "ok", + "data": { + "ratio_config": {}, + "quantity_items": [] + } +} +``` + +## 验证 + +```bash +go test ./... +cd frontend && npm run build +``` + +关键用例:`226M + 9格 + 5 项特殊状态全未勾选 + 每日损耗 10M` + +```text +比例 = 40 + 5 + 1 = 46 +226M = 22600 万 +22600 ÷ 46 = 491.304... → ¥491.30 +``` + +该用例固定在 `internal/pricing/calc_test.go`。 + +## 版本号 + +版本号的唯一来源是 `wails.json` 的 `info.productVersion`: + +- Windows exe 的版本资源由它生成 +- 界面右上角的版本号由 `frontend/vite.config.js` 在构建时读取同一字段注入 + +升级版本时只改这一处,然后重新构建。 + +## 目录 + +```text +app.go Wails 绑定、本地配置、线上同步、导入导出 +main.go 窗口入口 +internal/pricing/ 定价核心与单测 +frontend/src/main.js 界面与交互 +frontend/src/style.css 样式 +``` diff --git a/app.go b/app.go new file mode 100644 index 0000000..da8c6f2 --- /dev/null +++ b/app.go @@ -0,0 +1,447 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + "sync" + "time" + + "hfb_price_tool/internal/pricing" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +const ( + appName = "hfb-price-tool" + appVersion = "1.0.1" + publishEndpoint = "/api/listing-publish-options" +) + +type App struct { + ctx context.Context + mu sync.RWMutex + config pricing.Config + configPath string + loadError string +} + +type Bootstrap struct { + Config pricing.Config `json:"config"` + ConfigPath string `json:"config_path"` + ServerURL string `json:"server_url"` + LoadError string `json:"load_error,omitempty"` +} + +type SyncResult struct { + Config pricing.Config `json:"config"` + ServerURL string `json:"server_url"` + SyncedAt string `json:"synced_at"` + InsuranceCount int `json:"insurance_count"` + RatioItemCount int `json:"ratio_item_count"` + CoinCorrectionCount int `json:"coin_correction_count"` + QuantityItemCount int `json:"quantity_item_count"` +} + +type ExportResult struct { + Path string `json:"path"` + Cancelled bool `json:"cancelled"` +} + +type ImportResult struct { + Config pricing.Config `json:"config"` + Path string `json:"path"` + Cancelled bool `json:"cancelled"` +} + +type remotePublishOptions struct { + RatioConfig struct { + InsuranceBaseRatios []pricing.InsuranceBaseRatio `json:"insurance_base_ratios"` + ConfigItems []pricing.RatioConfigItem `json:"config_items"` + CoinCorrections []pricing.CoinCorrection `json:"coin_corrections"` + } `json:"ratio_config"` + QuantityItems []pricing.QuantityItem `json:"quantity_items"` +} + +func NewApp() *App { + return &App{ + config: pricing.DefaultConfig(), + } +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + path, err := defaultConfigPath() + if err != nil { + a.mu.Lock() + a.loadError = "无法读取本地配置目录:" + err.Error() + a.mu.Unlock() + return + } + a.mu.Lock() + a.configPath = path + a.mu.Unlock() + + config, loadErr := loadConfigFile(path) + a.mu.Lock() + a.config = config + a.loadError = loadErr + a.mu.Unlock() +} + +// Bootstrap 返回启动所需的本地规则、配置路径和默认线上地址。 +func (a *App) Bootstrap() Bootstrap { + a.mu.RLock() + defer a.mu.RUnlock() + return Bootstrap{ + Config: a.config, + ConfigPath: a.configPath, + ServerURL: a.config.SyncURL, + LoadError: a.loadError, + } +} + +// Calculate 按当前规则计算号主到手价。 +func (a *App) Calculate(input pricing.Input) pricing.Result { + a.mu.RLock() + config := a.config + a.mu.RUnlock() + return config.Calculate(input) +} + +// SaveConfig 校验并保存本地规则。 +func (a *App) SaveConfig(config pricing.Config) (pricing.Config, error) { + normalized := pricing.Normalize(config) + if err := a.persist(normalized); err != nil { + return pricing.Config{}, err + } + return normalized, nil +} + +// ResetConfig 恢复内置默认规则并落盘。 +func (a *App) ResetConfig() (pricing.Config, error) { + config := pricing.DefaultConfig() + if err := a.persist(config); err != nil { + return pricing.Config{}, err + } + return config, nil +} + +// SyncFromServer 从主站公开接口拉取发布规则,覆盖保险、缺配、大额修正和物品。 +func (a *App) SyncFromServer(rawURL string) (SyncResult, error) { + baseURL, err := normalizeServerURL(rawURL) + if err != nil { + return SyncResult{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + remote, err := fetchRemotePublishOptions(ctx, baseURL) + if err != nil { + return SyncResult{}, err + } + if len(remote.RatioConfig.InsuranceBaseRatios) == 0 || len(remote.RatioConfig.ConfigItems) == 0 { + return SyncResult{}, errors.New("线上未返回可用的比例规则,请确认地址是否为哈夫币站点") + } + + a.mu.RLock() + current := a.config + a.mu.RUnlock() + + next := current + next.InsuranceBaseRatios = remote.RatioConfig.InsuranceBaseRatios + next.RatioConfigItems = remote.RatioConfig.ConfigItems + next.CoinCorrections = remote.RatioConfig.CoinCorrections + next.QuantityItems = mergeRemoteQuantityItems(current.QuantityItems, remote.QuantityItems) + next.SyncURL = baseURL + next = pricing.Normalize(next) + + if err := a.persist(next); err != nil { + return SyncResult{}, err + } + return SyncResult{ + Config: next, + ServerURL: baseURL, + SyncedAt: time.Now().Format("2006-01-02 15:04:05"), + InsuranceCount: len(next.InsuranceBaseRatios), + RatioItemCount: len(next.RatioConfigItems), + CoinCorrectionCount: len(next.CoinCorrections), + QuantityItemCount: len(next.QuantityItems), + }, nil +} + +// ExportConfig 将当前规则导出为 JSON 文件。 +func (a *App) ExportConfig() (ExportResult, error) { + a.mu.RLock() + config := a.config + a.mu.RUnlock() + + path, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{ + Title: "导出规则配置", + DefaultFilename: "hfb-price-config.json", + Filters: []wailsruntime.FileFilter{ + {DisplayName: "JSON 文件 (*.json)", Pattern: "*.json"}, + }, + }) + if err != nil { + return ExportResult{}, err + } + if strings.TrimSpace(path) == "" { + return ExportResult{Cancelled: true}, nil + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return ExportResult{}, fmt.Errorf("生成配置失败:%w", err) + } + data = append(data, '\n') + if err := os.WriteFile(path, data, 0o644); err != nil { + return ExportResult{}, fmt.Errorf("写入配置失败:%w", err) + } + return ExportResult{Path: path}, nil +} + +// ImportConfig 从 JSON 文件导入规则。 +func (a *App) ImportConfig() (ImportResult, error) { + path, err := wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: "导入规则配置", + Filters: []wailsruntime.FileFilter{ + {DisplayName: "JSON 文件 (*.json)", Pattern: "*.json"}, + }, + }) + if err != nil { + return ImportResult{}, err + } + if strings.TrimSpace(path) == "" { + return ImportResult{Cancelled: true}, nil + } + data, err := os.ReadFile(path) + if err != nil { + return ImportResult{}, fmt.Errorf("读取配置失败:%w", err) + } + var config pricing.Config + if err := json.Unmarshal(data, &config); err != nil { + return ImportResult{}, fmt.Errorf("配置格式不正确:%w", err) + } + config = pricing.Normalize(config) + if err := a.persist(config); err != nil { + return ImportResult{}, err + } + return ImportResult{Config: config, Path: path}, nil +} + +// OpenConfigFolder 打开本地配置文件所在目录。 +func (a *App) OpenConfigFolder() error { + a.mu.RLock() + path := a.configPath + a.mu.RUnlock() + if path == "" { + return errors.New("配置路径尚未初始化") + } + dir := filepath.Dir(path) + var command *exec.Cmd + switch goruntime.GOOS { + case "windows": + command = exec.Command("explorer", dir) + case "darwin": + command = exec.Command("open", dir) + default: + command = exec.Command("xdg-open", dir) + } + if err := command.Start(); err != nil { + return fmt.Errorf("打开目录失败:%w", err) + } + return nil +} + +func (a *App) persist(config pricing.Config) error { + config = pricing.Normalize(config) + + a.mu.RLock() + path := a.configPath + a.mu.RUnlock() + if path == "" { + defaultPath, err := defaultConfigPath() + if err != nil { + return fmt.Errorf("无法确定配置目录:%w", err) + } + path = defaultPath + a.mu.Lock() + a.configPath = path + a.mu.Unlock() + } + if err := writeConfigFile(path, config); err != nil { + return fmt.Errorf("保存规则失败:%w", err) + } + + a.mu.Lock() + a.config = config + a.loadError = "" + a.mu.Unlock() + return nil +} + +func defaultConfigPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, appName, "config.json"), nil +} + +func loadConfigFile(path string) (pricing.Config, string) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + config := pricing.DefaultConfig() + if writeErr := writeConfigFile(path, config); writeErr != nil { + return config, "默认规则已加载,但写入本地配置失败:" + writeErr.Error() + } + return config, "" + } + if err != nil { + return pricing.DefaultConfig(), "读取本地配置失败,已使用默认规则:" + err.Error() + } + var config pricing.Config + if err := json.Unmarshal(data, &config); err != nil { + return pricing.DefaultConfig(), "本地配置格式不正确,已使用默认规则:" + err.Error() + } + return pricing.Normalize(config), "" +} + +func writeConfigFile(path string, config pricing.Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + temp, err := os.CreateTemp(filepath.Dir(path), ".config-*.json") + if err != nil { + return err + } + tempName := temp.Name() + defer os.Remove(tempName) + + if _, err := temp.Write(data); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Chmod(tempName, 0o644); err != nil { + return err + } + return os.Rename(tempName, path) +} + +func normalizeServerURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New("请填写线上地址") + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "", errors.New("线上地址格式不正确") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("线上地址只支持 http 或 https") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawQuery = "" + parsed.Fragment = "" + return strings.TrimRight(parsed.String(), "/"), nil +} + +func fetchRemotePublishOptions(ctx context.Context, baseURL string) (remotePublishOptions, error) { + endpoint := baseURL + publishEndpoint + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return remotePublishOptions{}, fmt.Errorf("创建请求失败:%w", err) + } + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", appName+"/"+appVersion) + + client := &http.Client{Timeout: 15 * time.Second} + response, err := client.Do(request) + if err != nil { + return remotePublishOptions{}, fmt.Errorf("连接线上失败:%w", err) + } + defer response.Body.Close() + + body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) + if err != nil { + return remotePublishOptions{}, fmt.Errorf("读取线上响应失败:%w", err) + } + if response.StatusCode != http.StatusOK { + return remotePublishOptions{}, fmt.Errorf("线上返回 HTTP %d:%s", response.StatusCode, truncateText(string(body), 160)) + } + + var envelope struct { + Code string `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return remotePublishOptions{}, fmt.Errorf("线上响应不是有效 JSON:%w", err) + } + if envelope.Code != "ok" { + message := strings.TrimSpace(envelope.Message) + if message == "" { + message = envelope.Code + } + return remotePublishOptions{}, fmt.Errorf("线上返回错误:%s", message) + } + + var remote remotePublishOptions + if err := json.Unmarshal(envelope.Data, &remote); err != nil { + return remotePublishOptions{}, fmt.Errorf("解析线上规则失败:%w", err) + } + return remote, nil +} + +func mergeRemoteQuantityItems(current, remote []pricing.QuantityItem) []pricing.QuantityItem { + currentByKey := make(map[string]pricing.QuantityItem, len(current)) + for _, item := range current { + currentByKey[item.Key] = item + } + merged := make([]pricing.QuantityItem, 0, len(remote)) + for _, item := range remote { + if old, ok := currentByKey[item.Key]; ok { + if strings.TrimSpace(item.Unit) == "" { + item.Unit = old.Unit + } + if strings.TrimSpace(item.Placeholder) == "" { + item.Placeholder = old.Placeholder + } + item.Disabled = old.Disabled + } + merged = append(merged, item) + } + if len(merged) == 0 { + return current + } + return merged +} + +func truncateText(value string, limit int) string { + value = strings.TrimSpace(value) + if len(value) <= limit { + return value + } + return value[:limit] + "..." +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..e19f140 --- /dev/null +++ b/app_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "hfb_price_tool/internal/pricing" +) + +func TestNormalizeServerURL(t *testing.T) { + cases := []struct { + raw string + want string + }{ + {"https://hao858.com/", "https://hao858.com"}, + {"hao858.com", "https://hao858.com"}, + {"http://127.0.0.1:8080/", "http://127.0.0.1:8080"}, + } + for _, item := range cases { + got, err := normalizeServerURL(item.raw) + if err != nil { + t.Fatalf("normalizeServerURL(%q) error: %v", item.raw, err) + } + if got != item.want { + t.Fatalf("normalizeServerURL(%q)=%q, want %q", item.raw, got, item.want) + } + } + if _, err := normalizeServerURL("ftp://hao858.com"); err == nil { + t.Fatal("ftp url should be rejected") + } +} + +func TestFetchRemotePublishOptions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != publishEndpoint { + t.Fatalf("unexpected path: %s", request.URL.Path) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{ + "code":"ok", + "message":"ok", + "data":{ + "ratio_config":{ + "insurance_base_ratios":[{"insurance":"3*3","ratio":40}], + "config_items":[{"key":"melee","label":"刀皮","kind":"skin_group","missing_penalty":1}], + "coin_corrections":[{"threshold_m":130,"correction":1}] + }, + "quantity_items":[{"key":"awmAmmo","label":"AWM子弹","price":"0.6元/发"}] + } + }`)) + })) + defer server.Close() + + remote, err := fetchRemotePublishOptions(context.Background(), server.URL) + if err != nil { + t.Fatalf("fetchRemotePublishOptions error: %v", err) + } + if len(remote.RatioConfig.InsuranceBaseRatios) != 1 { + t.Fatalf("insurance count = %d, want 1", len(remote.RatioConfig.InsuranceBaseRatios)) + } + if len(remote.QuantityItems) != 1 || remote.QuantityItems[0].Key != "awmAmmo" { + t.Fatalf("unexpected quantity items: %+v", remote.QuantityItems) + } +} + +func TestFetchRemotePublishOptionsRejectsErrorEnvelope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"code":"internal_error","message":"数据库连接失败"}`)) + })) + defer server.Close() + + if _, err := fetchRemotePublishOptions(context.Background(), server.URL); err == nil { + t.Fatal("expected error envelope to fail") + } +} + +func TestMergeRemoteQuantityItemsPreservesLocalFlags(t *testing.T) { + current := []pricing.QuantityItem{ + {Key: "awmAmmo", Unit: "发", Disabled: true, Placeholder: "本地提示"}, + } + remote := []pricing.QuantityItem{ + {Key: "awmAmmo", Label: "AWM子弹", Price: "0.8元/发"}, + } + merged := mergeRemoteQuantityItems(current, remote) + if len(merged) != 1 { + t.Fatalf("merged count = %d, want 1", len(merged)) + } + if merged[0].Unit != "发" || !merged[0].Disabled || merged[0].Placeholder != "本地提示" { + t.Fatalf("local flags were not preserved: %+v", merged[0]) + } +} + +func TestConfigFileRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + config := pricing.DefaultConfig() + config.SyncURL = "https://example.com" + if err := writeConfigFile(path, config); err != nil { + t.Fatalf("writeConfigFile error: %v", err) + } + loaded, loadErr := loadConfigFile(path) + if loadErr != "" { + t.Fatalf("loadConfigFile error: %s", loadErr) + } + if loaded.SyncURL != "https://example.com" { + t.Fatalf("sync url = %q, want https://example.com", loaded.SyncURL) + } + if len(loaded.InsuranceBaseRatios) != 4 { + t.Fatalf("insurance count = %d, want 4", len(loaded.InsuranceBaseRatios)) + } + + raw, err := json.Marshal(loaded) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if len(raw) == 0 { + t.Fatal("marshaled config is empty") + } +} diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..90e62f2 Binary files /dev/null and b/build/appicon.png differ 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..bd3b4b1 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..8045680 --- /dev/null +++ b/build/windows/info.json @@ -0,0 +1,16 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}", + "product_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +} diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 0000000..4ee36dd --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fb4413e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 哈夫币发布计算器 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c200f0e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,664 @@ +{ + "name": "hfb-price-tool-frontend", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hfb-price-tool-frontend", + "version": "1.0.1", + "devDependencies": { + "vite": "^3.0.7" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "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/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/vite": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c167413 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,14 @@ +{ + "name": "hfb-price-tool-frontend", + "private": true, + "version": "1.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^3.0.7" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100755 index 0000000..0db9035 --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +93f3bcafbb7b2e5f01e2db048bd59072 \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..9f603be --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,1111 @@ +import './style.css' + +// 版本号由 vite.config.js 从 wails.json 的 info.productVersion 注入。 +const appVersion = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : 'dev' +const app = document.querySelector('#app') + +const state = { + backend: null, + config: null, + configPath: '', + serverURL: '', + form: { + insurance: '', + states: {}, + coinM: 0, + dailyLossM: 10, + saleRatio: 0, + quantities: {}, + freeItems: {}, + }, + calc: null, + configDraft: null, + configOpen: false, + activeTab: 'base', + syncMessage: '', +} + +app.innerHTML = ` +
+
+
+
哈
+
+

哈夫币发布计算器

+

按账号配置自动计算号主到手价

+
+
+
+ v${appVersion} + + +
+
+ +
+
+
+ +
+
+ +
+ +
+
+ +
+ +
+ + M +
+
+ +
+ +
+
+ +
+ +
+ 1 : + +
+

填写资料后自动计算参考比例

+
+ +
+ 计算口径 +

参考比例 = 保险基数 + 缺配惩罚 + 大额修正 + 每日损耗。比例越大,号主到手越低。

+
+
+ +
+
+
+ +

数量按单发/单个填写;免费物品不计入到手价

+
+ 0 项 +
+
+
+
+ +
+
+ 哈弗币到手 + ¥0.00 +
+
+ 比例 + -- +
+
+ 物品到手 + ¥0.00 +
+
+ 合计到手 + ¥0.00 +
+
+
+ + + +
+ +` + +const els = { + insuranceGrid: document.querySelector('#insuranceGrid'), + stateGrid: document.querySelector('#stateGrid'), + lossRow: document.querySelector('#lossRow'), + itemList: document.querySelector('#itemList'), + itemsCount: document.querySelector('#itemsCount'), + coinInput: document.querySelector('#coinInput'), + ratioInput: document.querySelector('#ratioInput'), + ratioHint: document.querySelector('#ratioHint'), + coinResult: document.querySelector('#coinResult'), + ratioResult: document.querySelector('#ratioResult'), + itemResult: document.querySelector('#itemResult'), + totalResult: document.querySelector('#totalResult'), + configModalRoot: document.querySelector('#configModalRoot'), + toast: document.querySelector('#toast'), + bootError: document.querySelector('#bootError'), + bootErrorText: document.querySelector('#bootErrorText'), +} + +let toastTimer = 0 +let recalcTimer = 0 +let calcSequence = 0 +let lastRatioValue = null + +boot() + +async function boot() { + try { + state.backend = await waitForBackend() + const bootstrap = await call('Bootstrap') + state.config = bootstrap.config + state.configPath = bootstrap.config_path || '' + state.serverURL = bootstrap.server_url || state.config?.sync_url || '' + initFormState(true) + renderMain() + await recalc() + window.requestAnimationFrame(() => els.coinInput.focus()) + if (bootstrap.load_error) { + toast(bootstrap.load_error, 'error', 6000) + } + } catch (error) { + els.bootError.hidden = false + els.bootErrorText.textContent = errorText(error) + } +} + +async function waitForBackend() { + for (let index = 0; index < 120; index += 1) { + const backend = window.go?.main?.App + if (backend) return backend + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error('桌面后端未就绪,请通过 Wails 客户端启动本程序。') +} + +async function call(method, ...args) { + const backend = state.backend || window.go?.main?.App + if (!backend || typeof backend[method] !== 'function') { + throw new Error(`后端方法 ${method} 不可用`) + } + return backend[method](...args) +} + +function initFormState(reset) { + const config = state.config + if (!config) return + + if (reset) { + state.form = { + insurance: config.insurance_base_ratios?.[0]?.insurance || '', + states: {}, + coinM: 0, + dailyLossM: config.daily_loss_options?.[0] ?? 10, + saleRatio: 0, + quantities: {}, + freeItems: {}, + } + } + + const insuranceKeys = new Set((config.insurance_base_ratios || []).map(item => item.insurance)) + if (!insuranceKeys.has(state.form.insurance)) { + state.form.insurance = config.insurance_base_ratios?.[0]?.insurance || '' + } + + const stateKeys = new Set((config.ratio_config_items || []).map(item => item.key)) + for (const key of Object.keys(state.form.states)) { + if (!stateKeys.has(key)) delete state.form.states[key] + } + for (const key of stateKeys) { + if (state.form.states[key] === undefined) state.form.states[key] = false + } + + const quantityKeys = new Set((config.quantity_items || []).map(item => item.key)) + for (const key of Object.keys(state.form.quantities)) { + if (!quantityKeys.has(key)) delete state.form.quantities[key] + } + for (const key of Object.keys(state.form.freeItems)) { + if (!quantityKeys.has(key)) delete state.form.freeItems[key] + } + for (const key of quantityKeys) { + if (state.form.quantities[key] === undefined) state.form.quantities[key] = 0 + if (state.form.freeItems[key] === undefined) state.form.freeItems[key] = false + } + + const lossOptions = config.daily_loss_options || [] + if (!lossOptions.includes(Number(state.form.dailyLossM))) { + state.form.dailyLossM = lossOptions[0] ?? 10 + } +} + +function renderMain() { + if (!state.config) return + renderInsurance() + renderStates() + renderLoss() + renderItems() + els.coinInput.value = state.form.coinM > 0 ? String(state.form.coinM) : '' + els.ratioInput.value = state.form.saleRatio > 0 ? String(state.form.saleRatio) : '' + renderResults(state.calc) +} + +function renderInsurance() { + const items = state.config?.insurance_base_ratios || [] + els.insuranceGrid.innerHTML = items + .map( + item => ` + + ` + ) + .join('') +} + +function renderStates() { + const order = ['melee', 'operatorRed', 'staminaMax', 'loadMax', 'weapon'] + const items = [...(state.config?.ratio_config_items || [])].sort((left, right) => { + const leftIndex = order.indexOf(left.key) + const rightIndex = order.indexOf(right.key) + if (leftIndex >= 0 && rightIndex >= 0) return leftIndex - rightIndex + if (leftIndex >= 0) return -1 + if (rightIndex >= 0) return 1 + return 0 + }) + const selectedCount = items.filter(item => state.form.states[item.key]).length + const countEl = document.querySelector('#stateCount') + if (countEl) countEl.textContent = `已选 ${selectedCount} 项` + els.stateGrid.innerHTML = items + .map((item, index) => { + const wide = items.length % 2 === 1 && index === items.length - 1 ? ' wide' : '' + return ` + + ` + }) + .join('') +} + +function renderLoss() { + const options = state.config?.daily_loss_options || [] + els.lossRow.innerHTML = options + .map( + value => ` + + ` + ) + .join('') +} + +function renderItems() { + const items = state.config?.quantity_items || [] + let filled = 0 + + els.itemList.innerHTML = items + .map(item => { + const disabled = isGridCardDisabled(item) + if (disabled && Number(state.form.quantities[item.key] || 0) !== 0) { + state.form.quantities[item.key] = 0 + } + const quantity = disabled ? 0 : Number(state.form.quantities[item.key] || 0) + if (quantity > 0 && !state.form.freeItems[item.key]) filled += 1 + const free = Boolean(state.form.freeItems[item.key]) + return ` +
+
+ ${escapeHTML(item.label)} + ${escapeHTML(item.price)}${disabled ? ' · 9格保险下不可用' : ''} +
+ +
+ +
+ + ${escapeHTML(item.unit || '个')} +
+ +
+
+ ` + }) + .join('') + + els.itemsCount.textContent = `${filled} 项` +} + +function renderResults(result) { + if (!result) { + els.coinResult.textContent = '¥0.00' + els.ratioResult.textContent = '--' + els.itemResult.textContent = '¥0.00' + els.totalResult.textContent = '¥0.00' + return + } + + els.coinResult.textContent = money(result.coin_base_price) + const normalizedRatio = Number(result.final_ratio || 0) + const ratioChanged = lastRatioValue !== null && normalizedRatio !== lastRatioValue + lastRatioValue = normalizedRatio + els.ratioResult.textContent = result.final_ratio > 0 ? formatNumber(result.final_ratio) : '--' + els.itemResult.textContent = money(result.item_price) + els.totalResult.textContent = money(result.total_price) + if (ratioChanged) { + flashElement(els.ratioResult) + flashElement(els.ratioHint) + } + + const reference = Number(result.reference_ratio || 0) + const maxRatio = Number(result.max_ratio || 0) + els.ratioInput.disabled = reference <= 0 + if (reference > 0) { + els.ratioInput.placeholder = `留空使用 ${formatNumber(reference)}` + } else { + els.ratioInput.placeholder = '填写资料后自动生成' + } + + const parts = [] + if (result.insurance_base_ratio > 0) parts.push(`基础 ${formatNumber(result.insurance_base_ratio)}`) + if (result.config_penalty > 0) parts.push(`缺配 +${formatNumber(result.config_penalty)}`) + if (result.coin_correction > 0) parts.push(`大额 +${formatNumber(result.coin_correction)}`) + if (result.daily_loss_adjustment > 0) parts.push(`损耗 +${formatNumber(result.daily_loss_adjustment)}`) + if (result.final_ratio > reference) { + parts.push(`加速 +${formatNumber(result.final_ratio - reference)}`) + } + + if (reference > 0) { + const summary = [`参考 1:${formatNumber(reference)}`, `可调至 1:${formatNumber(maxRatio)}`] + if (result.final_ratio > reference) { + summary.push(`当前 1:${formatNumber(result.final_ratio)}`) + } + els.ratioHint.textContent = [...summary, ...parts].join(' · ') + els.ratioHint.dataset.tone = 'ok' + } else { + els.ratioHint.textContent = result.message || '填写资料后自动计算参考比例' + els.ratioHint.dataset.tone = result.valid ? 'neutral' : 'error' + } +} + +function scheduleRecalc() { + window.clearTimeout(recalcTimer) + recalcTimer = window.setTimeout(recalc, 80) +} + +async function recalc() { + if (!state.config) return + const sequence = ++calcSequence + const input = { + coin_m: numberValue(state.form.coinM), + insurance: state.form.insurance, + states: state.form.states, + daily_loss_m: numberValue(state.form.dailyLossM), + sale_ratio: numberValue(state.form.saleRatio), + quantities: state.form.quantities, + free_items: state.form.freeItems, + } + try { + const result = await call('Calculate', input) + if (sequence !== calcSequence) return + state.calc = result + renderResults(result) + } catch (error) { + if (sequence !== calcSequence) return + toast(errorText(error), 'error') + } +} + +function handleClick(event) { + const backdrop = event.target.closest('#configBackdrop') + if (backdrop && event.target === backdrop) { + closeConfig() + return + } + + const trigger = event.target.closest('[data-action]') + if (!trigger) return + const action = trigger.dataset.action + + switch (action) { + case 'select-insurance': { + state.form.insurance = trigger.dataset.value || '' + if (state.form.insurance === '3*3') { + for (const item of state.config.quantity_items || []) { + if (isGridCardDisabled(item)) state.form.quantities[item.key] = 0 + } + } + renderInsurance() + renderItems() + scheduleRecalc() + break + } + case 'toggle-state': { + const key = trigger.dataset.key || '' + state.form.states[key] = !state.form.states[key] + renderStates() + scheduleRecalc() + break + } + case 'select-loss': { + state.form.dailyLossM = numberValue(trigger.dataset.value) + renderLoss() + scheduleRecalc() + break + } + case 'clear-ratio': { + state.form.saleRatio = 0 + els.ratioInput.value = '' + els.ratioInput.focus() + scheduleRecalc() + break + } + case 'focus-coin': { + els.coinInput.focus() + break + } + case 'focus-ratio': { + if (!els.ratioInput.disabled) els.ratioInput.focus() + break + } + case 'item-minus': + case 'item-plus': { + const key = trigger.dataset.key || '' + const direction = action === 'item-plus' ? 1 : -1 + const step = event.shiftKey ? 10 : 1 + const next = Math.max(0, numberValue(state.form.quantities[key]) + direction * step) + state.form.quantities[key] = next + const input = findQuantityInput(key) + if (input) input.value = String(next) + scheduleRecalc() + break + } + case 'toggle-free': { + const key = trigger.dataset.key || '' + state.form.freeItems[key] = !state.form.freeItems[key] + renderItems() + scheduleRecalc() + break + } + case 'open-config': + openConfig('base') + break + case 'sync-top': + openConfig('sync') + break + case 'close-config': + closeConfig() + break + case 'config-tab': + state.activeTab = trigger.dataset.tab || 'base' + renderConfigModal() + break + case 'add-row': + addConfigRow(trigger.dataset.section || '') + break + case 'remove-row': + removeConfigRow(trigger.dataset.section || '', Number(trigger.dataset.index || 0)) + break + case 'save-config': + saveConfig() + break + case 'reset-config': + resetConfig() + break + case 'import-config': + importConfig() + break + case 'export-config': + exportConfig() + break + case 'sync-online': + syncOnline() + break + case 'open-folder': + openConfigFolder() + break + default: + break + } +} + +function handleInput(event) { + const input = event.target.closest('[data-input]') + if (input) { + const kind = input.dataset.input + if (kind === 'coin') { + state.form.coinM = numberValue(input.value) + scheduleRecalc() + } else if (kind === 'ratio') { + state.form.saleRatio = numberValue(input.value) + scheduleRecalc() + } else if (kind === 'qty') { + const key = input.dataset.key || '' + state.form.quantities[key] = Math.max(0, numberValue(input.value)) + scheduleRecalc() + } + return + } + + const configInput = event.target.closest('[data-config]') + if (configInput) updateConfigDraft(configInput) +} + +function handleChange(event) { + const configInput = event.target.closest('[data-config]') + if (configInput) updateConfigDraft(configInput) +} + +function updateConfigDraft(input) { + const draft = state.configDraft + if (!draft) return + const section = input.dataset.config + const index = Number(input.dataset.index || 0) + const field = input.dataset.field || '' + let value = input.type === 'checkbox' ? input.checked : input.value + if (input.type === 'number') value = numberValue(value) + + if (section === 'insurance' && draft.insurance_base_ratios[index]) { + draft.insurance_base_ratios[index][field] = value + } else if (section === 'ratio-item' && draft.ratio_config_items[index]) { + draft.ratio_config_items[index][field] = value + } else if (section === 'correction' && draft.coin_corrections[index]) { + draft.coin_corrections[index][field] = value + } else if (section === 'quantity' && draft.quantity_items[index]) { + draft.quantity_items[index][field] = value + } else if (section === 'scalar') { + if (field === 'accelerated_max_ratio') { + draft.accelerated_max_ratio = numberValue(value) + } else if (field === 'daily_loss_options') { + draft.daily_loss_options = String(value) + .split(/[,,\s]+/) + .map(item => Number(item)) + .filter(item => Number.isFinite(item) && item > 0) + } else if (field === 'sync_url') { + draft.sync_url = String(value) + } + } +} + +function openConfig(tab) { + if (!state.config) return + state.configDraft = deepClone(state.config) + state.configOpen = true + state.activeTab = tab || 'base' + state.syncMessage = '' + renderConfigModal() +} + +function closeConfig() { + state.configOpen = false + state.configDraft = null + state.syncMessage = '' + renderConfigModal() +} + +function renderConfigModal() { + if (!state.configOpen || !state.configDraft) { + els.configModalRoot.innerHTML = '' + return + } + + const tabs = [ + { key: 'base', label: '基础比例' }, + { key: 'states', label: '缺配惩罚' }, + { key: 'corrections', label: '大额修正' }, + { key: 'items', label: '物品单价' }, + { key: 'sync', label: '线上同步' }, + ] + + els.configModalRoot.innerHTML = ` + + ` + renderConfigBody() +} + +function renderConfigBody() { + const body = document.querySelector('#configBody') + const draft = state.configDraft + if (!body || !draft) return + + if (state.activeTab === 'base') { + body.innerHTML = ` +
+
+

保险格数基础比例

+

对应主站发布规则:3*3=9格、2*3=6格、2*2=4格、2*1=2格。

+
+ +
+
+ + + + ${draft.insurance_base_ratios + .map( + (item, index) => ` + + + + + + ` + ) + .join('')} + +
保险格数基础比例
+
+ ` + return + } + + if (state.activeTab === 'states') { + body.innerHTML = ` +
+
+

缺配惩罚

+

每缺少一项,比例增加对应点数;全部满足时不加。

+
+ +
+
+ + + + ${draft.ratio_config_items + .map( + (item, index) => ` + + + + + + + + ` + ) + .join('')} + +
key显示名称类型缺少惩罚
+ +
+
+ ` + return + } + + if (state.activeTab === 'corrections') { + body.innerHTML = ` +
+
+

大额哈夫币修正

+

严格大于阈值才命中,并取命中的最高档;不会累加。

+
+ +
+
+ + + + ${draft.coin_corrections + .map( + (item, index) => ` + + + + + + ` + ) + .join('')} + +
大于(M)比例增加
+
+ ` + return + } + + if (state.activeTab === 'items') { + body.innerHTML = ` +
+
+

物品单价

+

单价支持「0.6元/发」「6元/60发」「2.5元/个」;停用后不会出现在计算器里。

+
+ +
+
+ + + + ${draft.quantity_items + .map( + (item, index) => ` + + + + + + + + + ` + ) + .join('')} + +
key名称单价文本单位停用
+
+ ` + return + } + + if (state.activeTab === 'sync') { + body.innerHTML = ` +
+
+
+

从线上拉取规则

+

调用公开接口 GET /api/listing-publish-options,只覆盖保险、缺配、大额修正和物品。

+
+
+ +
+ ${escapeHTML(state.syncMessage || '同步后会自动保存到本地;每日损耗、加速上限和本地停用项会保留。')} +
+
+

默认地址:https://hao858.com

+

如果线上规则还没发布,可以继续使用本地配置,或在其他页签手工维护。

+
+
+
+

其他参数

+
+ + +
+
+ ` + return + } +} + +function addConfigRow(section) { + const draft = state.configDraft + if (!draft) return + if (section === 'insurance') { + draft.insurance_base_ratios.push({ insurance: '', ratio: 0 }) + } else if (section === 'ratio') { + draft.ratio_config_items.push({ + key: '', + label: '', + kind: 'skin_group', + group_key: '', + missing_penalty: 1, + }) + } else if (section === 'corrections') { + draft.coin_corrections.push({ threshold_m: 0, correction: 0 }) + } else if (section === 'quantity') { + draft.quantity_items.push({ + key: '', + label: '', + price: '', + unit: '个', + placeholder: '', + disabled: false, + }) + } + renderConfigBody() +} + +function removeConfigRow(section, index) { + const draft = state.configDraft + if (!draft) return + if (section === 'insurance') draft.insurance_base_ratios.splice(index, 1) + if (section === 'ratio') draft.ratio_config_items.splice(index, 1) + if (section === 'corrections') draft.coin_corrections.splice(index, 1) + if (section === 'quantity') draft.quantity_items.splice(index, 1) + renderConfigBody() +} + +async function saveConfig() { + if (!state.configDraft) return + try { + const saved = await call('SaveConfig', state.configDraft) + state.config = saved + state.serverURL = saved.sync_url || state.serverURL + initFormState(false) + closeConfig() + renderMain() + await recalc() + toast('规则已保存并应用') + } catch (error) { + toast(errorText(error), 'error', 6000) + } +} + +async function resetConfig() { + if (!window.confirm('恢复内置默认规则?当前本地配置会被覆盖。')) return + try { + const config = await call('ResetConfig') + state.config = config + state.serverURL = config.sync_url || state.serverURL + initFormState(false) + closeConfig() + renderMain() + await recalc() + toast('已恢复默认规则') + } catch (error) { + toast(errorText(error), 'error', 6000) + } +} + +async function importConfig() { + try { + const result = await call('ImportConfig') + if (result.cancelled) return + state.config = result.config + state.serverURL = result.config.sync_url || state.serverURL + initFormState(false) + closeConfig() + renderMain() + await recalc() + toast(`已导入:${result.path}`, 'ok', 5000) + } catch (error) { + toast(errorText(error), 'error', 6000) + } +} + +async function exportConfig() { + try { + const result = await call('ExportConfig') + if (result.cancelled) return + toast(`已导出:${result.path}`, 'ok', 5000) + } catch (error) { + toast(errorText(error), 'error', 6000) + } +} + +async function syncOnline() { + const draft = state.configDraft + if (!draft) return + const url = String(draft.sync_url || state.serverURL || '').trim() + state.syncMessage = '正在拉取线上规则...' + renderConfigBody() + try { + const result = await call('SyncFromServer', url) + state.config = result.config + state.configDraft = deepClone(result.config) + state.serverURL = result.server_url + state.syncMessage = `已同步 ${result.insurance_count} 档保险、${result.ratio_item_count} 项缺配、${result.coin_correction_count} 档大额修正、${result.quantity_item_count} 个物品(${result.synced_at})` + initFormState(false) + renderMain() + await recalc() + renderConfigModal() + toast('线上规则已同步并保存') + } catch (error) { + state.syncMessage = `同步失败:${errorText(error)}` + renderConfigBody() + toast('线上同步失败', 'error', 5000) + } +} + +async function openConfigFolder() { + try { + await call('OpenConfigFolder') + } catch (error) { + toast(errorText(error), 'error', 5000) + } +} + +function findQuantityInput(key) { + return [...document.querySelectorAll('input[data-input="qty"]')].find( + input => input.dataset.key === key + ) +} + +function isGridCardDisabled(item) { + if (item.disabled) return true + if (state.form.insurance !== '3*3') return false + return ( + item.key === 'gridCard9' || + String(item.label || '').includes('9格') || + String(item.label || '').includes('顶级保险卡') + ) +} + +function insuranceLabel(insurance) { + const labels = { + '3*3': '9格', + '2*3': '6格', + '2*2': '4格', + '2*1': '2格', + } + return labels[insurance] || insurance +} + +function stateLabel(item) { + const labels = { + melee: '有刀皮', + operatorRed: '有角色红皮', + staminaMax: '满体力', + loadMax: '满负重', + weapon: '有武器皮肤', + } + if (labels[item.key]) return labels[item.key] + if (item.kind === 'max_stamina' || item.kind === 'max_load') return item.label + return `有${item.label}` +} + +function numberValue(value) { + const number = Number(value) + return Number.isFinite(number) && number > 0 ? number : 0 +} + +function formatNumber(value) { + const number = Math.round((Number(value) || 0) * 10) / 10 + return Number.isInteger(number) ? String(number) : number.toFixed(1) +} + +function money(value) { + return `¥${(Number(value) || 0).toFixed(2)}` +} + +function deepClone(value) { + return JSON.parse(JSON.stringify(value)) +} + +function errorText(error) { + if (!error) return '未知错误' + if (typeof error === 'string') return error + return error.message || String(error) +} + +function escapeHTML(value) { + return String(value ?? '').replace(/[&<>"']/g, char => { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + } + return map[char] + }) +} + +function toast(message, tone = 'ok', duration = 3000) { + els.toast.textContent = message + els.toast.dataset.tone = tone + els.toast.hidden = false + window.clearTimeout(toastTimer) + toastTimer = window.setTimeout(() => { + els.toast.hidden = true + }, duration) +} + +function flashElement(element) { + if (!element) return + element.classList.remove('is-flash') + void element.offsetWidth + element.classList.add('is-flash') + window.setTimeout(() => element.classList.remove('is-flash'), 520) +} + +app.addEventListener('click', handleClick) +app.addEventListener('input', handleInput) +app.addEventListener('change', handleChange) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..435a36d --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,1202 @@ +:root { + color: #1f2937; + background: #eef2f7; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 14px; + --bg: #eef2f7; + --panel: #ffffff; + --ink: #1f2937; + --muted: #7c8798; + --line: #e3e8f0; + --line-strong: #cfd8e6; + --accent: #f08a24; + --accent-strong: #d9720c; + --accent-soft: #fff3e5; + --blue: #3568d4; + --blue-soft: #edf3ff; + --danger: #c24141; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + overflow: hidden; + min-height: 100vh; + background: + radial-gradient(circle at 12% 0%, rgba(240, 138, 36, 0.08), transparent 30%), + radial-gradient(circle at 88% 0%, rgba(53, 104, 212, 0.08), transparent 32%), + var(--bg); +} + +button, +input, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +#app, +.app-shell { + height: 100vh; +} + +.app-shell { + display: flex; + flex-direction: column; +} + +.topbar { + height: 76px; + flex-shrink: 0; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand-mark { + width: 44px; + height: 44px; + flex-shrink: 0; + border-radius: 13px; + display: grid; + place-items: center; + background: linear-gradient(145deg, #ffb35c, #ec7a10); + color: #fff; + font-size: 21px; + font-weight: 800; + box-shadow: 0 8px 18px rgba(236, 122, 16, 0.24); +} + +.brand-text h1 { + margin: 0; + font-size: 21px; + line-height: 1.25; + letter-spacing: 0.2px; +} + +.brand-text p { + margin: 3px 0 0; + color: var(--muted); + font-size: 12px; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 9px; + flex-shrink: 0; +} + +.version { + padding: 7px 9px; + border: 1px solid #d8e3fb; + border-radius: 8px; + background: #f4f7ff; + color: #3f64b8; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.button { + height: 38px; + padding: 0 15px; + border: 1px solid var(--line-strong); + border-radius: 9px; + background: #fff; + color: var(--ink); + font-weight: 650; + transition: + transform 0.12s ease, + box-shadow 0.12s ease, + background 0.12s ease; +} + +.button:hover { + box-shadow: 0 5px 14px rgba(31, 41, 55, 0.09); +} + +.button:active { + transform: translateY(1px); +} + +.button.ghost { + background: #fff; + color: #4a5568; +} + +.button.dark { + border-color: #283447; + background: #283447; + color: #fff; +} + +.button.primary { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.button.primary:hover { + background: var(--accent-strong); +} + +.button:disabled, +.icon-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.layout { + flex: 1; + min-height: 0; + padding: 0 22px 14px; + display: grid; + grid-template-columns: minmax(390px, 46%) minmax(0, 1fr); + gap: 14px; +} + +.panel { + min-height: 0; + border: 1px solid var(--line); + border-radius: 15px; + background: var(--panel); + box-shadow: 0 10px 28px rgba(31, 41, 55, 0.045); +} + +.left-panel { + padding: 18px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 15px; +} + +.right-panel { + padding: 18px 20px 8px; + display: flex; + flex-direction: column; +} + +.panel-section { + min-width: 0; +} + +.section-label { + margin-bottom: 8px; + color: #667085; + font-size: 12px; + font-weight: 750; + letter-spacing: 0.3px; +} + +.section-count { + padding: 2px 7px; + border-radius: 999px; + background: #f0f4fa; + color: #8d98a8; + font-size: 10.5px; + font-weight: 650; + letter-spacing: 0; +} + +.row-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.insurance-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.choice { + min-height: 58px; + padding: 8px 6px; + border: 1px solid var(--line); + border-radius: 10px; + background: #f9fbfd; + color: #667085; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + transition: + border-color 0.12s ease, + background 0.12s ease, + color 0.12s ease, + transform 0.12s ease; +} + +.choice:hover { + border-color: #b9c8e6; + background: #f5f8ff; +} + +.choice strong { + font-size: 15px; + font-weight: 750; +} + +.choice small { + color: #9aa5b5; + font-size: 11px; + font-weight: 600; +} + +.choice.is-active { + border-color: #7da0e8; + background: var(--blue-soft); + color: var(--blue); + box-shadow: inset 0 0 0 1px rgba(53, 104, 212, 0.16); +} + +.choice.is-active small { + color: #5f7fc6; +} + +.state-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.state-chip { + min-height: 39px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: #f9fbfd; + color: #5f6b7a; + font-weight: 650; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + transition: + border-color 0.12s ease, + background 0.12s ease, + color 0.12s ease, + box-shadow 0.12s ease; +} + +.state-chip:hover { + border-color: #b9c8e6; + background: #f5f8ff; +} + +.state-chip.wide { + grid-column: 1 / -1; +} + +.state-chip.is-active { + border-color: #527fd7; + background: var(--blue-soft); + color: var(--blue); + box-shadow: + inset 0 0 0 1px rgba(53, 104, 212, 0.16), + 0 3px 10px rgba(53, 104, 212, 0.08); +} + +.state-chip.is-active::after { + content: "✓"; + font-size: 11px; + font-weight: 800; +} + +.coin-input { + height: 52px; + padding: 0 14px; + border: 1px solid var(--line-strong); + border-radius: 11px; + background: #fff; + display: flex; + align-items: center; + gap: 8px; + cursor: text; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease, + background 0.12s ease; +} + +.coin-input:hover { + border-color: #b9c8e6; +} + +.coin-input:focus-within { + border-color: #3568d4; + background: #f8fbff; + box-shadow: + 0 0 0 4px rgba(53, 104, 212, 0.16), + 0 5px 16px rgba(53, 104, 212, 0.08); +} + +.coin-input input { + width: 100%; + height: 100%; + border: none; + outline: none; + background: transparent; + color: var(--ink); + font-size: 26px; + font-weight: 760; + letter-spacing: 0.4px; + caret-color: var(--accent); +} + +.coin-input:focus-within span { + color: var(--blue); +} + +.coin-input span { + color: #8d98a8; + font-size: 14px; + font-weight: 750; +} + +.loss-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.mini-chip { + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f9fbfd; + color: #7c8798; + font-size: 12px; + font-weight: 650; +} + +.mini-chip:hover { + border-color: #b9c8e6; +} + +.mini-chip.is-active { + border-color: #7da0e8; + background: var(--blue-soft); + color: var(--blue); +} + +.ratio-section { + margin-top: 0; +} + +.ratio-input-row { + height: 43px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: #fff; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + cursor: text; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease, + background 0.12s ease; +} + +.ratio-input-row:hover { + border-color: #b9c8e6; +} + +.ratio-input-row:focus-within { + border-color: #3568d4; + background: #f8fbff; + box-shadow: 0 0 0 4px rgba(53, 104, 212, 0.14); +} + +.ratio-prefix { + flex-shrink: 0; + color: #8d98a8; + font-size: 13px; + font-weight: 750; + white-space: nowrap; +} + +.ratio-input-row input { + width: 100%; + height: 100%; + border: none; + outline: none; + background: transparent; + color: var(--ink); + font-size: 16px; + font-weight: 700; + caret-color: var(--accent); + -moz-appearance: textfield; + appearance: textfield; +} + +.ratio-input-row input::-webkit-outer-spin-button, +.ratio-input-row input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; +} + +.calc-note { + margin-top: auto; + padding: 12px 13px; + border: 1px dashed #d9e2ef; + border-radius: 10px; + background: #fafcff; +} + +.calc-note strong { + color: #667085; + font-size: 11.5px; + font-weight: 750; +} + +.calc-note p { + margin: 4px 0 0; + color: #8d98a8; + font-size: 11.5px; + line-height: 1.55; +} + +.hint { + margin: 6px 0 0; + color: #8d98a8; + font-size: 11.5px; + line-height: 1.5; +} + +.hint[data-tone="ok"] { + color: #758399; +} + +.hint[data-tone="error"] { + color: var(--danger); +} + +.text-button { + padding: 0; + border: none; + background: transparent; + color: #6d83ad; + font-size: 11.5px; + font-weight: 650; +} + +.text-button:hover { + color: var(--blue); + text-decoration: underline; +} + +.items-head { + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); +} + +.items-head .hint { + margin-top: 2px; +} + +.items-count { + margin-top: 2px; + padding: 5px 8px; + border-radius: 7px; + background: #f4f7fb; + color: #7c8798; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.item-list { + min-height: 0; + flex: 1; + overflow: auto; + padding-right: 3px; +} + +.item-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 50px 142px; + align-items: center; + gap: 10px; + padding: 10px 2px; + border-bottom: 1px dashed #e8edf4; +} + +.item-row:last-child { + border-bottom: none; +} + +.item-main { + min-width: 0; +} + +.item-main strong { + display: block; + color: #2f3b4c; + font-size: 13.5px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-main span { + display: block; + margin-top: 2px; + color: #9aa5b5; + font-size: 11.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.free-toggle { + height: 29px; + padding: 0 6px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f9fbfd; + color: #7c8798; + font-size: 11px; + font-weight: 700; +} + +.free-toggle:hover { + border-color: #f0c188; +} + +.free-toggle.is-free { + border-color: #f3c48d; + background: var(--accent-soft); + color: #b9650a; +} + +.stepper { + display: grid; + grid-template-columns: 29px minmax(0, 1fr) 29px; + align-items: center; + gap: 5px; +} + +.stepper > button { + width: 29px; + height: 29px; + padding: 0; + border: 1px solid #dce4ef; + border-radius: 7px; + background: #f6f9fd; + color: #5f6b7a; + font-size: 17px; + line-height: 1; + font-weight: 500; +} + +.stepper > button:hover { + border-color: #b9c8e6; + background: #edf3ff; + color: var(--blue); +} + +.qty-box { + height: 31px; + border: 1px solid #dce4ef; + border-radius: 7px; + background: #fff; + display: flex; + align-items: center; + gap: 3px; + padding: 0 6px; +} + +.qty-box:focus-within { + border-color: #3568d4; + background: #f8fbff; + box-shadow: 0 0 0 3px rgba(53, 104, 212, 0.13); +} + +.qty-box input { + width: 100%; + min-width: 0; + height: 100%; + border: none; + outline: none; + background: transparent; + color: #344054; + text-align: center; + font-size: 12.5px; + font-weight: 700; + -moz-appearance: textfield; + appearance: textfield; +} + +.qty-box input::-webkit-outer-spin-button, +.qty-box input::-webkit-inner-spin-button, +.coin-input input::-webkit-outer-spin-button, +.coin-input input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; +} + +.qty-box span { + flex-shrink: 0; + color: #9aa5b5; + font-size: 10.5px; + font-weight: 650; +} + +.item-row.is-disabled { + opacity: 0.46; +} + +.item-row.is-disabled .stepper > button, +.item-row.is-disabled .free-toggle { + cursor: not-allowed; +} + +.result-bar { + flex-shrink: 0; + margin: 0 22px 18px; + min-height: 92px; + border: 1px solid var(--line); + border-radius: 15px; + background: #fff; + box-shadow: 0 10px 28px rgba(31, 41, 55, 0.05); + display: grid; + grid-template-columns: 1.25fr 0.75fr 1.05fr 1fr; + align-items: center; +} + +.result-cell { + min-width: 0; + padding: 0 20px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.result-cell + .result-cell { + border-left: 1px solid var(--line); +} + +.result-cell span { + color: #8d98a8; + font-size: 11.5px; + font-weight: 700; +} + +.result-money { + color: var(--accent); + font-size: 25px; + line-height: 1.1; + font-weight: 800; + letter-spacing: 0.2px; + white-space: nowrap; +} + +.result-ratio { + color: #243043; + font-size: 25px; + line-height: 1.1; + font-weight: 800; + white-space: nowrap; +} + +.result-cell.total .result-money { + color: #e46f12; +} + +@keyframes valueFlash { + 0% { + background: #fff0dc; + box-shadow: 0 0 0 4px rgba(240, 138, 36, 0.16); + } + 100% { + background: transparent; + box-shadow: 0 0 0 0 rgba(240, 138, 36, 0); + } +} + +.is-flash { + border-radius: 7px; + animation: valueFlash 0.52s ease; +} + +.boot-error { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + background: rgba(245, 247, 251, 0.97); +} + +.boot-error-card { + width: min(460px, calc(100vw - 40px)); + padding: 24px; + border: 1px solid var(--line); + border-radius: 15px; + background: #fff; + box-shadow: 0 18px 48px rgba(31, 41, 55, 0.12); +} + +.boot-error-card h2 { + margin: 0 0 8px; + font-size: 19px; +} + +.boot-error-card p { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 40; + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.46); + backdrop-filter: blur(2px); +} + +.modal-card { + width: min(960px, 100%); + max-height: calc(100vh - 40px); + border-radius: 16px; + background: #fff; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal-head { + padding: 18px 22px 14px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + border-bottom: 1px solid var(--line); +} + +.modal-head h2 { + margin: 0; + font-size: 18px; + line-height: 1.3; +} + +.modal-head p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; +} + +.icon-button { + width: 32px; + height: 32px; + flex-shrink: 0; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #f9fbfd; + color: #7c8798; + font-size: 19px; + line-height: 1; +} + +.icon-button:hover { + border-color: #b9c8e6; + color: #4a5568; +} + +.icon-button.danger:hover { + border-color: #efb8b8; + background: #fff5f5; + color: var(--danger); +} + +.config-path { + margin: 12px 22px 0; + padding: 9px 11px; + border: 1px solid #edf1f6; + border-radius: 8px; + background: #f8fafc; + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + font-size: 11.5px; +} + +.config-path > span { + flex-shrink: 0; + color: #8d98a8; + font-weight: 700; +} + +.config-path code { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #5d6b7f; + font-family: "SF Mono", Menlo, Consolas, monospace; +} + +.config-tabs { + padding: 12px 22px 0; + border-bottom: 1px solid var(--line); + display: flex; + gap: 4px; + overflow-x: auto; +} + +.config-tab { + flex-shrink: 0; + padding: 9px 11px; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: #8d98a8; + font-size: 12.5px; + font-weight: 650; +} + +.config-tab:hover { + color: #4a5568; +} + +.config-tab.is-active { + border-bottom-color: var(--accent); + color: #2f3b4c; + font-weight: 750; +} + +.config-body { + min-height: 300px; + padding: 16px 22px; + overflow: auto; +} + +.editor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; +} + +.editor-head h3, +.extra-config h3 { + margin: 0; + font-size: 14.5px; + line-height: 1.35; +} + +.editor-head .hint { + margin-top: 3px; +} + +.table-wrap { + width: 100%; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 10px; +} + +.editor-table { + width: 100%; + border-collapse: collapse; + min-width: 430px; + background: #fff; +} + +.editor-table.wide-table { + min-width: 720px; +} + +.editor-table th { + padding: 8px 9px; + border-bottom: 1px solid var(--line); + background: #f8fafc; + color: #8d98a8; + text-align: left; + font-size: 11px; + font-weight: 750; + letter-spacing: 0.2px; + white-space: nowrap; +} + +.editor-table td { + padding: 6px 9px; + border-bottom: 1px solid #eef2f7; + vertical-align: middle; +} + +.editor-table tr:last-child td { + border-bottom: none; +} + +.editor-table input, +.editor-table select { + width: 100%; + min-width: 62px; + height: 34px; + padding: 0 9px; + border: 1px solid #dce4ef; + border-radius: 7px; + background: #fff; + color: #344054; + outline: none; + font-size: 12.5px; +} + +.editor-table input:focus, +.editor-table select:focus { + border-color: #7da0e8; + box-shadow: 0 0 0 2px rgba(53, 104, 212, 0.09); +} + +.editor-table input[type="checkbox"] { + width: 17px; + min-width: 17px; + height: 17px; + padding: 0; + accent-color: var(--blue); +} + +.check-col { + text-align: center; +} + +.action-col { + width: 44px; + text-align: center; +} + +.action-col .icon-button { + width: 27px; + height: 27px; + font-size: 16px; +} + +.sync-card { + padding: 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fbfcfe; +} + +.sync-field { + display: block; + margin-top: 6px; +} + +.sync-field > span { + display: block; + margin-bottom: 6px; + color: #667085; + font-size: 12px; + font-weight: 700; +} + +.sync-input-row { + display: flex; + gap: 9px; +} + +.sync-input-row input { + flex: 1; + min-width: 0; + height: 40px; + padding: 0 12px; + border: 1px solid var(--line-strong); + border-radius: 9px; + background: #fff; + outline: none; + color: #344054; +} + +.sync-input-row input:focus { + border-color: #7da0e8; + box-shadow: 0 0 0 3px rgba(53, 104, 212, 0.09); +} + +.sync-status { + margin-top: 12px; + padding: 10px 12px; + border: 1px solid #dce8ff; + border-radius: 8px; + background: #f3f7ff; + color: #4e6da8; + font-size: 12px; + line-height: 1.55; +} + +.sync-status.is-error { + border-color: #f3c6c6; + background: #fff5f5; + color: #a83b3b; +} + +.sync-note { + margin-top: 12px; + color: #8d98a8; + font-size: 11.5px; + line-height: 1.7; +} + +.sync-note p { + margin: 0; +} + +.sync-note code, +.hint code { + padding: 1px 4px; + border-radius: 4px; + background: #eef2f7; + font-family: "SF Mono", Menlo, Consolas, monospace; + font-size: 10.5px; +} + +.extra-config { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.extra-config h3 { + margin-bottom: 10px; +} + +.extra-grid { + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 12px; +} + +.extra-grid label > span { + display: block; + margin-bottom: 6px; + color: #667085; + font-size: 12px; + font-weight: 700; +} + +.extra-grid input { + width: 100%; + height: 38px; + padding: 0 11px; + border: 1px solid var(--line-strong); + border-radius: 8px; + outline: none; + color: #344054; +} + +.extra-grid input:focus { + border-color: #7da0e8; + box-shadow: 0 0 0 3px rgba(53, 104, 212, 0.09); +} + +.modal-foot { + flex-shrink: 0; + padding: 13px 22px; + border-top: 1px solid var(--line); + background: #fcfdff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.foot-left, +.foot-right { + display: flex; + align-items: center; + gap: 8px; +} + +.toast { + position: fixed; + left: 50%; + bottom: 26px; + z-index: 70; + max-width: min(680px, calc(100vw - 40px)); + padding: 10px 16px; + border-radius: 10px; + background: #243043; + color: #fff; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.25); + font-size: 12.5px; + line-height: 1.45; + transform: translateX(-50%); +} + +.toast[data-tone="error"] { + background: #a83b3b; +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + overflow: auto; + } + + .left-panel { + overflow: visible; + } + + .right-panel { + min-height: 380px; + } + + .result-bar { + grid-template-columns: 1fr 1fr; + padding: 10px 0; + } + + .result-cell:nth-child(3) { + border-left: none; + } + + .result-cell:nth-child(n + 3) { + margin-top: 8px; + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..b4cdb60 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' + +// 版本号唯一来源:wails.json 的 info.productVersion。 +function readAppVersion() { + try { + const path = fileURLToPath(new URL('../wails.json', import.meta.url)) + return JSON.parse(readFileSync(path, 'utf-8')).info?.productVersion || '0.0.0' + } catch { + return '0.0.0' + } +} + +export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(readAppVersion()), + }, + server: { + host: 'localhost', + port: 5199, + strictPort: true, + }, +}) diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts new file mode 100755 index 0000000..eca6f65 --- /dev/null +++ b/frontend/wailsjs/go/main/App.d.ts @@ -0,0 +1,20 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {main} from '../models'; +import {pricing} from '../models'; + +export function Bootstrap():Promise; + +export function Calculate(arg1:pricing.Input):Promise; + +export function ExportConfig():Promise; + +export function ImportConfig():Promise; + +export function OpenConfigFolder():Promise; + +export function ResetConfig():Promise; + +export function SaveConfig(arg1:pricing.Config):Promise; + +export function SyncFromServer(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js new file mode 100755 index 0000000..ac4a8eb --- /dev/null +++ b/frontend/wailsjs/go/main/App.js @@ -0,0 +1,35 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function Bootstrap() { + return window['go']['main']['App']['Bootstrap'](); +} + +export function Calculate(arg1) { + return window['go']['main']['App']['Calculate'](arg1); +} + +export function ExportConfig() { + return window['go']['main']['App']['ExportConfig'](); +} + +export function ImportConfig() { + return window['go']['main']['App']['ImportConfig'](); +} + +export function OpenConfigFolder() { + return window['go']['main']['App']['OpenConfigFolder'](); +} + +export function ResetConfig() { + return window['go']['main']['App']['ResetConfig'](); +} + +export function SaveConfig(arg1) { + return window['go']['main']['App']['SaveConfig'](arg1); +} + +export function SyncFromServer(arg1) { + return window['go']['main']['App']['SyncFromServer'](arg1); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts new file mode 100755 index 0000000..fcbfa9d --- /dev/null +++ b/frontend/wailsjs/go/models.ts @@ -0,0 +1,357 @@ +export namespace main { + + export class Bootstrap { + config: pricing.Config; + config_path: string; + server_url: string; + load_error?: string; + + static createFrom(source: any = {}) { + return new Bootstrap(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.config = this.convertValues(source["config"], pricing.Config); + this.config_path = source["config_path"]; + this.server_url = source["server_url"]; + this.load_error = source["load_error"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class ExportResult { + path: string; + cancelled: boolean; + + static createFrom(source: any = {}) { + return new ExportResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.path = source["path"]; + this.cancelled = source["cancelled"]; + } + } + export class ImportResult { + config: pricing.Config; + path: string; + cancelled: boolean; + + static createFrom(source: any = {}) { + return new ImportResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.config = this.convertValues(source["config"], pricing.Config); + this.path = source["path"]; + this.cancelled = source["cancelled"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class SyncResult { + config: pricing.Config; + server_url: string; + synced_at: string; + insurance_count: number; + ratio_item_count: number; + coin_correction_count: number; + quantity_item_count: number; + + static createFrom(source: any = {}) { + return new SyncResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.config = this.convertValues(source["config"], pricing.Config); + this.server_url = source["server_url"]; + this.synced_at = source["synced_at"]; + this.insurance_count = source["insurance_count"]; + this.ratio_item_count = source["ratio_item_count"]; + this.coin_correction_count = source["coin_correction_count"]; + this.quantity_item_count = source["quantity_item_count"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + +export namespace pricing { + + export class CoinCorrection { + threshold_m: number; + correction: number; + + static createFrom(source: any = {}) { + return new CoinCorrection(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.threshold_m = source["threshold_m"]; + this.correction = source["correction"]; + } + } + export class QuantityItem { + key: string; + label: string; + price: string; + unit: string; + placeholder?: string; + disabled: boolean; + + static createFrom(source: any = {}) { + return new QuantityItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.key = source["key"]; + this.label = source["label"]; + this.price = source["price"]; + this.unit = source["unit"]; + this.placeholder = source["placeholder"]; + this.disabled = source["disabled"]; + } + } + export class RatioConfigItem { + key: string; + label: string; + kind: string; + group_key?: string; + missing_penalty: number; + + static createFrom(source: any = {}) { + return new RatioConfigItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.key = source["key"]; + this.label = source["label"]; + this.kind = source["kind"]; + this.group_key = source["group_key"]; + this.missing_penalty = source["missing_penalty"]; + } + } + export class InsuranceBaseRatio { + insurance: string; + ratio: number; + + static createFrom(source: any = {}) { + return new InsuranceBaseRatio(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.insurance = source["insurance"]; + this.ratio = source["ratio"]; + } + } + export class Config { + schema_version: number; + insurance_base_ratios: InsuranceBaseRatio[]; + ratio_config_items: RatioConfigItem[]; + coin_corrections: CoinCorrection[]; + quantity_items: QuantityItem[]; + daily_loss_options: number[]; + accelerated_max_ratio: number; + sync_url: string; + + static createFrom(source: any = {}) { + return new Config(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.schema_version = source["schema_version"]; + this.insurance_base_ratios = this.convertValues(source["insurance_base_ratios"], InsuranceBaseRatio); + this.ratio_config_items = this.convertValues(source["ratio_config_items"], RatioConfigItem); + this.coin_corrections = this.convertValues(source["coin_corrections"], CoinCorrection); + this.quantity_items = this.convertValues(source["quantity_items"], QuantityItem); + this.daily_loss_options = source["daily_loss_options"]; + this.accelerated_max_ratio = source["accelerated_max_ratio"]; + this.sync_url = source["sync_url"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Input { + coin_m: number; + insurance: string; + states: Record; + daily_loss_m: number; + sale_ratio: number; + quantities: Record; + free_items: Record; + + static createFrom(source: any = {}) { + return new Input(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.coin_m = source["coin_m"]; + this.insurance = source["insurance"]; + this.states = source["states"]; + this.daily_loss_m = source["daily_loss_m"]; + this.sale_ratio = source["sale_ratio"]; + this.quantities = source["quantities"]; + this.free_items = source["free_items"]; + } + } + + export class ItemLine { + key: string; + label: string; + quantity: number; + unit: string; + unit_price: number; + amount: number; + free: boolean; + disabled: boolean; + + static createFrom(source: any = {}) { + return new ItemLine(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.key = source["key"]; + this.label = source["label"]; + this.quantity = source["quantity"]; + this.unit = source["unit"]; + this.unit_price = source["unit_price"]; + this.amount = source["amount"]; + this.free = source["free"]; + this.disabled = source["disabled"]; + } + } + + + export class Result { + reference_ratio: number; + final_ratio: number; + max_ratio: number; + insurance_base_ratio: number; + config_penalty: number; + coin_correction: number; + daily_loss_adjustment: number; + coin_base_price: number; + item_price: number; + total_price: number; + items: ItemLine[]; + valid: boolean; + message?: string; + + static createFrom(source: any = {}) { + return new Result(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.reference_ratio = source["reference_ratio"]; + this.final_ratio = source["final_ratio"]; + this.max_ratio = source["max_ratio"]; + this.insurance_base_ratio = source["insurance_base_ratio"]; + this.config_penalty = source["config_penalty"]; + this.coin_correction = source["coin_correction"]; + this.daily_loss_adjustment = source["daily_loss_adjustment"]; + this.coin_base_price = source["coin_base_price"]; + this.item_price = source["item_price"]; + this.total_price = source["total_price"]; + this.items = this.convertValues(source["items"], ItemLine); + this.valid = source["valid"]; + this.message = source["message"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + 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..068b678 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module hfb_price_tool + +go 1.25.0 + +require github.com/wailsapp/wails/v2 v2.15.0 + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.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.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // 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.46.0 // indirect + golang.org/x/text v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1e0a460 --- /dev/null +++ b/go.sum @@ -0,0 +1,83 @@ +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/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/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/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/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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/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/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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.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= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/pricing/calc.go b/internal/pricing/calc.go new file mode 100644 index 0000000..7b5805b --- /dev/null +++ b/internal/pricing/calc.go @@ -0,0 +1,140 @@ +package pricing + +import ( + "math" + "strings" +) + +// Calculate 只计算号主到手价:参考比例、加速比例、纯币价、物品价和合计。 +func (config Config) Calculate(input Input) Result { + cfg := Normalize(config) + coinM := math.Max(input.CoinM, 0) + coinWan := coinM * 100 + result := Result{ + Items: []ItemLine{}, + Valid: false, + } + + baseRatio, ok := insuranceBaseRatio(cfg.InsuranceBaseRatios, input.Insurance) + if !ok { + result.Message = "未找到该保险格数的基础比例,请检查规则配置" + result.ItemPrice, result.Items = calculateItemLines(cfg, input) + result.TotalPrice = result.ItemPrice + return result + } + + result.InsuranceBaseRatio = baseRatio + result.ConfigPenalty = calculateConfigPenalty(cfg.RatioConfigItems, input.States) + result.CoinCorrection = coinCorrection(cfg.CoinCorrections, coinM) + result.DailyLossAdjustment = dailyLossRatioAdjustment(input.DailyLossM) + + referenceRatio := RoundRatio( + baseRatio + + result.ConfigPenalty + + result.CoinCorrection + + result.DailyLossAdjustment, + ) + result.ReferenceRatio = referenceRatio + result.MaxRatio = RoundRatio(referenceRatio + cfg.AcceleratedMaxRatio) + result.FinalRatio = referenceRatio + if input.SaleRatio > 0 { + result.FinalRatio = RoundRatio(clampSaleRatio(input.SaleRatio, referenceRatio, result.MaxRatio)) + } + + if coinWan > 0 && result.FinalRatio > 0 { + result.CoinBasePrice = RoundMoney(coinWan / result.FinalRatio) + } + result.ItemPrice, result.Items = calculateItemLines(cfg, input) + result.TotalPrice = RoundMoney(result.CoinBasePrice + result.ItemPrice) + result.Valid = referenceRatio > 0 + if coinM <= 0 { + result.Message = "请输入哈弗币数量" + } + return result +} + +func insuranceBaseRatio(items []InsuranceBaseRatio, insurance string) (float64, bool) { + insurance = strings.TrimSpace(insurance) + for _, item := range items { + if item.Insurance == insurance { + return item.Ratio, true + } + } + return 0, false +} + +func calculateConfigPenalty(items []RatioConfigItem, states map[string]bool) float64 { + penalty := 0.0 + for _, item := range items { + if states[item.Key] { + continue + } + penalty += item.MissingPenalty + } + return RoundRatio(penalty) +} + +// coinCorrection 命中最高阈值档,且使用严格大于,与主站发布逻辑保持一致。 +func coinCorrection(items []CoinCorrection, coinM float64) float64 { + bestThreshold := -1.0 + correction := 0.0 + for _, item := range items { + if coinM > item.ThresholdM && item.ThresholdM >= bestThreshold { + bestThreshold = item.ThresholdM + correction = item.Correction + } + } + return correction +} + +func dailyLossRatioAdjustment(dailyLossM float64) float64 { + if dailyLossM <= 0 { + return 0 + } + adjustment := (dailyLossM - 10) / 10 + if adjustment < 0 { + return 0 + } + floored := math.Floor(adjustment) + if floored > 4 { + return 4 + } + return floored +} + +func clampSaleRatio(requested, reference, maxRatio float64) float64 { + if requested < reference { + return reference + } + if requested > maxRatio { + return maxRatio + } + return requested +} + +func calculateItemLines(config Config, input Input) (float64, []ItemLine) { + lines := make([]ItemLine, 0, len(config.QuantityItems)) + rawTotal := 0.0 + for _, item := range config.QuantityItems { + quantity := math.Max(input.Quantities[item.Key], 0) + unitPrice := ParseUnitPrice(item.Price) + free := input.FreeItems[item.Key] + disabled := isItemDisabledForInsurance(item, input.Insurance) + amount := 0.0 + if !disabled && !free && quantity > 0 && unitPrice > 0 { + rawTotal += quantity * unitPrice + amount = RoundMoney(quantity * unitPrice) + } + lines = append(lines, ItemLine{ + Key: item.Key, + Label: item.Label, + Quantity: quantity, + Unit: item.Unit, + UnitPrice: unitPrice, + Amount: amount, + Free: free, + Disabled: disabled, + }) + } + return RoundMoney(rawTotal), lines +} diff --git a/internal/pricing/calc_test.go b/internal/pricing/calc_test.go new file mode 100644 index 0000000..e140f55 --- /dev/null +++ b/internal/pricing/calc_test.go @@ -0,0 +1,240 @@ +package pricing + +import ( + "math" + "testing" +) + +func TestScreenshotExample(t *testing.T) { + config := DefaultConfig() + result := config.Calculate(Input{ + CoinM: 226, + Insurance: "3*3", + States: map[string]bool{}, + DailyLossM: 10, + }) + if result.ReferenceRatio != 46 { + t.Fatalf("reference ratio = %v, want 46", result.ReferenceRatio) + } + if result.FinalRatio != 46 { + t.Fatalf("final ratio = %v, want 46", result.FinalRatio) + } + if result.CoinBasePrice != 491.3 { + t.Fatalf("coin base price = %v, want 491.3", result.CoinBasePrice) + } + if result.ItemPrice != 0 { + t.Fatalf("item price = %v, want 0", result.ItemPrice) + } + if result.TotalPrice != 491.3 { + t.Fatalf("total price = %v, want 491.3", result.TotalPrice) + } +} + +func TestAllStatesMatched(t *testing.T) { + config := DefaultConfig() + result := config.Calculate(Input{ + CoinM: 100, + Insurance: "3*3", + States: map[string]bool{ + "melee": true, + "operatorRed": true, + "staminaMax": true, + "loadMax": true, + "weapon": true, + }, + DailyLossM: 10, + }) + if result.ReferenceRatio != 40 { + t.Fatalf("reference ratio = %v, want 40", result.ReferenceRatio) + } + if result.CoinBasePrice != 250 { + t.Fatalf("coin base price = %v, want 250", result.CoinBasePrice) + } +} + +func TestMissingPenalty(t *testing.T) { + config := DefaultConfig() + result := config.Calculate(Input{ + CoinM: 100, + Insurance: "3*3", + States: map[string]bool{ + "melee": true, + "operatorRed": true, + "staminaMax": true, + "loadMax": true, + }, + DailyLossM: 10, + }) + if result.ConfigPenalty != 1 { + t.Fatalf("config penalty = %v, want 1", result.ConfigPenalty) + } + if result.ReferenceRatio != 41 { + t.Fatalf("reference ratio = %v, want 41", result.ReferenceRatio) + } + if result.CoinBasePrice != 243.9 { + t.Fatalf("coin base price = %v, want 243.9", result.CoinBasePrice) + } +} + +func TestCoinCorrectionUsesHighestCrossedThreshold(t *testing.T) { + config := DefaultConfig() + cases := []struct { + coinM float64 + want float64 + }{ + {130, 0}, + {130.1, 1}, + {230, 1}, + {230.1, 2.5}, + {380.1, 4}, + {600, 5}, + } + for _, item := range cases { + result := config.Calculate(Input{CoinM: item.coinM, Insurance: "3*3", DailyLossM: 10}) + if result.CoinCorrection != item.want { + t.Fatalf("coinM=%v correction=%v, want %v", item.coinM, result.CoinCorrection, item.want) + } + } +} + +func TestDailyLossAdjustment(t *testing.T) { + config := DefaultConfig() + cases := []struct { + loss float64 + want float64 + }{ + {0, 0}, + {10, 0}, + {20, 1}, + {30, 2}, + {40, 3}, + {50, 4}, + {90, 4}, + } + for _, item := range cases { + result := config.Calculate(Input{CoinM: 100, Insurance: "3*3", DailyLossM: item.loss}) + if result.DailyLossAdjustment != item.want { + t.Fatalf("dailyLoss=%v adjustment=%v, want %v", item.loss, result.DailyLossAdjustment, item.want) + } + } +} + +func TestAcceleratedRatioClamp(t *testing.T) { + config := DefaultConfig() + base := Input{CoinM: 226, Insurance: "3*3", DailyLossM: 10} + + result := config.Calculate(withSaleRatio(base, 50)) + if result.FinalRatio != 50 { + t.Fatalf("final ratio = %v, want 50", result.FinalRatio) + } + if result.MaxRatio != 56 { + t.Fatalf("max ratio = %v, want 56", result.MaxRatio) + } + + result = config.Calculate(withSaleRatio(base, 100)) + if result.FinalRatio != 56 { + t.Fatalf("clamped ratio = %v, want 56", result.FinalRatio) + } + + result = config.Calculate(withSaleRatio(base, 30)) + if result.FinalRatio != 46 { + t.Fatalf("ratio below reference = %v, want 46", result.FinalRatio) + } +} + +func TestConsumablePriceParsing(t *testing.T) { + cases := []struct { + price string + want float64 + }{ + {"0.6元/发", 0.6}, + {"6元/60发", 0.1}, + {"2.5元/个", 2.5}, + {"3元/张", 3}, + {"2元", 2}, + {"", 0}, + } + for _, item := range cases { + if got := ParseUnitPrice(item.price); math.Abs(got-item.want) > 1e-9 { + t.Fatalf("ParseUnitPrice(%q)=%v, want %v", item.price, got, item.want) + } + } +} + +func TestConsumablesAndFreeItems(t *testing.T) { + config := DefaultConfig() + result := config.Calculate(Input{ + CoinM: 100, + Insurance: "3*3", + DailyLossM: 10, + States: map[string]bool{ + "melee": true, + "operatorRed": true, + "staminaMax": true, + "loadMax": true, + "weapon": true, + }, + Quantities: map[string]float64{ + "awmAmmo": 10, + "helmet6": 2, + }, + FreeItems: map[string]bool{ + "helmet6": true, + }, + }) + if result.ItemPrice != 6 { + t.Fatalf("item price = %v, want 6", result.ItemPrice) + } + if result.TotalPrice != 256 { + t.Fatalf("total price = %v, want 256", result.TotalPrice) + } +} + +func TestGridCardDisabledAtThreeByThree(t *testing.T) { + config := DefaultConfig() + result := config.Calculate(Input{ + CoinM: 100, + Insurance: "3*3", + DailyLossM: 10, + Quantities: map[string]float64{"gridCard9": 3}, + }) + if result.ItemPrice != 0 { + t.Fatalf("item price = %v, want 0", result.ItemPrice) + } + for _, item := range result.Items { + if item.Key == "gridCard9" && !item.Disabled { + t.Fatal("gridCard9 should be disabled at 3*3") + } + } + + result = config.Calculate(Input{ + CoinM: 100, + Insurance: "2*3", + DailyLossM: 10, + Quantities: map[string]float64{"gridCard9": 3}, + }) + if result.ItemPrice != 9 { + t.Fatalf("item price at 2*3 = %v, want 9", result.ItemPrice) + } +} + +func TestNormalizeRestoresEmptySections(t *testing.T) { + normalized := Normalize(Config{}) + if len(normalized.InsuranceBaseRatios) == 0 { + t.Fatal("insurance ratios should fall back to defaults") + } + if len(normalized.RatioConfigItems) == 0 { + t.Fatal("ratio config items should fall back to defaults") + } + if len(normalized.QuantityItems) == 0 { + t.Fatal("quantity items should fall back to defaults") + } + if normalized.AcceleratedMaxRatio != 10 { + t.Fatalf("accelerated max = %v, want 10", normalized.AcceleratedMaxRatio) + } +} + +func withSaleRatio(input Input, ratio float64) Input { + input.SaleRatio = ratio + return input +} diff --git a/internal/pricing/config.go b/internal/pricing/config.go new file mode 100644 index 0000000..baf04b9 --- /dev/null +++ b/internal/pricing/config.go @@ -0,0 +1,206 @@ +package pricing + +import ( + "math" + "regexp" + "sort" + "strconv" + "strings" +) + +var ( + fractionPricePattern = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*元\s*/\s*(\d+(?:\.\d+)?)`) + singlePricePattern = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*元`) +) + +// Normalize 保证配置始终可计算,并修补线上同步/手工编辑产生的空字段。 +func Normalize(config Config) Config { + defaults := DefaultConfig() + config.SchemaVersion = 1 + + insurances := make([]InsuranceBaseRatio, 0, len(config.InsuranceBaseRatios)) + seenInsurance := make(map[string]struct{}, len(config.InsuranceBaseRatios)) + for _, item := range config.InsuranceBaseRatios { + item.Insurance = strings.TrimSpace(item.Insurance) + if item.Insurance == "" || !isFinitePositive(item.Ratio) { + continue + } + if _, exists := seenInsurance[item.Insurance]; exists { + continue + } + seenInsurance[item.Insurance] = struct{}{} + item.Ratio = RoundRatio(item.Ratio) + insurances = append(insurances, item) + } + if len(insurances) == 0 { + insurances = defaults.InsuranceBaseRatios + } + config.InsuranceBaseRatios = insurances + + ratioItems := make([]RatioConfigItem, 0, len(config.RatioConfigItems)) + seenRatioKey := make(map[string]struct{}, len(config.RatioConfigItems)) + for _, item := range config.RatioConfigItems { + item.Key = strings.TrimSpace(item.Key) + item.Label = strings.TrimSpace(item.Label) + item.Kind = strings.TrimSpace(item.Kind) + item.GroupKey = strings.TrimSpace(item.GroupKey) + if item.Key == "" || item.Label == "" || !isFiniteNonNegative(item.MissingPenalty) { + continue + } + if _, exists := seenRatioKey[item.Key]; exists { + continue + } + seenRatioKey[item.Key] = struct{}{} + configKind := item.Kind + if configKind != "skin_group" && configKind != "max_stamina" && configKind != "max_load" { + configKind = "skin_group" + } + item.Kind = configKind + item.MissingPenalty = RoundRatio(item.MissingPenalty) + ratioItems = append(ratioItems, item) + } + if len(ratioItems) == 0 { + ratioItems = defaults.RatioConfigItems + } + config.RatioConfigItems = ratioItems + + corrections := make([]CoinCorrection, 0, len(config.CoinCorrections)) + for _, item := range config.CoinCorrections { + if !isFiniteNonNegative(item.ThresholdM) || !isFiniteNonNegative(item.Correction) { + continue + } + item.ThresholdM = RoundRatio(item.ThresholdM) + item.Correction = RoundRatio(item.Correction) + corrections = append(corrections, item) + } + if len(corrections) == 0 { + corrections = defaults.CoinCorrections + } + sort.SliceStable(corrections, func(i, j int) bool { + return corrections[i].ThresholdM < corrections[j].ThresholdM + }) + config.CoinCorrections = corrections + + quantityItems := make([]QuantityItem, 0, len(config.QuantityItems)) + seenQuantityKey := make(map[string]struct{}, len(config.QuantityItems)) + for _, item := range config.QuantityItems { + item.Key = strings.TrimSpace(item.Key) + item.Label = strings.TrimSpace(item.Label) + item.Price = strings.TrimSpace(item.Price) + item.Unit = strings.TrimSpace(item.Unit) + if item.Key == "" || item.Label == "" || item.Price == "" { + continue + } + if _, exists := seenQuantityKey[item.Key]; exists { + continue + } + seenQuantityKey[item.Key] = struct{}{} + if item.Unit == "" { + item.Unit = defaultUnit(item.Key, item.Label) + } + quantityItems = append(quantityItems, item) + } + if len(quantityItems) == 0 { + quantityItems = defaults.QuantityItems + } + config.QuantityItems = quantityItems + + lossOptions := make([]float64, 0, len(config.DailyLossOptions)) + seenLoss := make(map[float64]struct{}, len(config.DailyLossOptions)) + for _, value := range config.DailyLossOptions { + if !isFinitePositive(value) { + continue + } + value = RoundRatio(value) + if _, exists := seenLoss[value]; exists { + continue + } + seenLoss[value] = struct{}{} + lossOptions = append(lossOptions, value) + } + if len(lossOptions) == 0 { + lossOptions = defaults.DailyLossOptions + } + sort.Float64s(lossOptions) + config.DailyLossOptions = lossOptions + + if !isFinitePositive(config.AcceleratedMaxRatio) { + config.AcceleratedMaxRatio = defaults.AcceleratedMaxRatio + } + config.AcceleratedMaxRatio = math.Min(RoundRatio(config.AcceleratedMaxRatio), 50) + + config.SyncURL = strings.TrimRight(strings.TrimSpace(config.SyncURL), "/") + if config.SyncURL == "" { + config.SyncURL = defaults.SyncURL + } + return config +} + +// ParseUnitPrice 解析「0.6元/发」「6元/60发」这类单价文本,返回单个物品的价格。 +func ParseUnitPrice(price string) float64 { + normalized := strings.ReplaceAll(strings.TrimSpace(price), "/", "/") + normalized = strings.ReplaceAll(normalized, ",", ",") + if match := fractionPricePattern.FindStringSubmatch(normalized); len(match) == 3 { + amount := parseFloat(match[1]) + count := parseFloat(match[2]) + if amount > 0 && count > 0 { + return amount / count + } + } + if match := singlePricePattern.FindStringSubmatch(normalized); len(match) == 2 { + return math.Max(parseFloat(match[1]), 0) + } + return 0 +} + +func RoundMoney(value float64) float64 { + return math.Round(value*10) / 10 +} + +func RoundRatio(value float64) float64 { + return math.Round(value*10) / 10 +} + +func isItemDisabledForInsurance(item QuantityItem, insurance string) bool { + if item.Disabled { + return true + } + if insurance != "3*3" { + return false + } + return item.Key == "gridCard9" || + strings.Contains(item.Label, "9格") || + strings.Contains(item.Label, "顶级保险卡") +} + +func defaultUnit(key, label string) string { + switch key { + case "awmAmmo", "level6Ammo", "barrettAmmo": + return "发" + case "gridCard9": + return "张" + } + if strings.Contains(label, "子弹") { + return "发" + } + if strings.Contains(label, "卡") { + return "张" + } + return "个" +} + +func parseFloat(value string) float64 { + result, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || math.IsNaN(result) || math.IsInf(result, 0) { + return 0 + } + return result +} + +func isFinitePositive(value float64) bool { + return value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +} + +func isFiniteNonNegative(value float64) bool { + return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +} diff --git a/internal/pricing/defaults.go b/internal/pricing/defaults.go new file mode 100644 index 0000000..349ac9e --- /dev/null +++ b/internal/pricing/defaults.go @@ -0,0 +1,42 @@ +package pricing + +const DefaultSyncURL = "https://hao858.com" + +func DefaultConfig() Config { + return Config{ + SchemaVersion: 1, + InsuranceBaseRatios: []InsuranceBaseRatio{ + {Insurance: "3*3", Ratio: 40}, + {Insurance: "2*3", Ratio: 42}, + {Insurance: "2*2", Ratio: 47}, + {Insurance: "2*1", Ratio: 48}, + }, + RatioConfigItems: []RatioConfigItem{ + {Key: "melee", Label: "刀皮", Kind: "skin_group", GroupKey: "melee", MissingPenalty: 1}, + {Key: "operatorRed", Label: "角色红皮", Kind: "skin_group", GroupKey: "operatorRed", MissingPenalty: 1}, + {Key: "staminaMax", Label: "满体力", Kind: "max_stamina", MissingPenalty: 1}, + {Key: "loadMax", Label: "满负重", Kind: "max_load", MissingPenalty: 1}, + {Key: "weapon", Label: "武器皮肤", Kind: "skin_group", GroupKey: "weapon", MissingPenalty: 1}, + }, + CoinCorrections: []CoinCorrection{ + {ThresholdM: 130, Correction: 1}, + {ThresholdM: 230, Correction: 2.5}, + {ThresholdM: 380, Correction: 4}, + {ThresholdM: 530, Correction: 5}, + }, + QuantityItems: []QuantityItem{ + {Key: "awmAmmo", Label: "AWM子弹", Price: "0.6元/发", Unit: "发"}, + {Key: "level6Ammo", Label: "6级子弹", Price: "0.1元/发", Unit: "发", Placeholder: "按单发数量填写,60发=6元"}, + {Key: "helmet6", Label: "6级头盔", Price: "1.5元/个", Unit: "个"}, + {Key: "armor6", Label: "6级护甲", Price: "2.5元/个", Unit: "个"}, + {Key: "barrettAmmo", Label: "巴雷特子弹", Price: "0.3元/发", Unit: "发"}, + {Key: "gridCard9", Label: "顶级保险卡", Price: "3元/张", Unit: "张", Placeholder: "3*3 保险下不可用"}, + {Key: "coffeeBean", Label: "高级咖啡豆", Price: "2.5元/个", Unit: "个"}, + {Key: "bulletPart", Label: "高级子弹零件", Price: "3元/个", Unit: "个"}, + {Key: "redPacket45", Label: "45格大红包", Price: "1.5元/个", Unit: "个"}, + }, + DailyLossOptions: []float64{10, 20, 30, 40, 50}, + AcceleratedMaxRatio: 10, + SyncURL: DefaultSyncURL, + } +} diff --git a/internal/pricing/types.go b/internal/pricing/types.go new file mode 100644 index 0000000..56561ad --- /dev/null +++ b/internal/pricing/types.go @@ -0,0 +1,76 @@ +package pricing + +type InsuranceBaseRatio struct { + Insurance string `json:"insurance"` + Ratio float64 `json:"ratio"` +} + +type RatioConfigItem struct { + Key string `json:"key"` + Label string `json:"label"` + Kind string `json:"kind"` + GroupKey string `json:"group_key,omitempty"` + MissingPenalty float64 `json:"missing_penalty"` +} + +type CoinCorrection struct { + ThresholdM float64 `json:"threshold_m"` + Correction float64 `json:"correction"` +} + +type QuantityItem struct { + Key string `json:"key"` + Label string `json:"label"` + Price string `json:"price"` + Unit string `json:"unit"` + Placeholder string `json:"placeholder,omitempty"` + Disabled bool `json:"disabled"` +} + +type Config struct { + SchemaVersion int `json:"schema_version"` + InsuranceBaseRatios []InsuranceBaseRatio `json:"insurance_base_ratios"` + RatioConfigItems []RatioConfigItem `json:"ratio_config_items"` + CoinCorrections []CoinCorrection `json:"coin_corrections"` + QuantityItems []QuantityItem `json:"quantity_items"` + DailyLossOptions []float64 `json:"daily_loss_options"` + AcceleratedMaxRatio float64 `json:"accelerated_max_ratio"` + SyncURL string `json:"sync_url"` +} + +type Input struct { + CoinM float64 `json:"coin_m"` + Insurance string `json:"insurance"` + States map[string]bool `json:"states"` + DailyLossM float64 `json:"daily_loss_m"` + SaleRatio float64 `json:"sale_ratio"` + Quantities map[string]float64 `json:"quantities"` + FreeItems map[string]bool `json:"free_items"` +} + +type ItemLine struct { + Key string `json:"key"` + Label string `json:"label"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + UnitPrice float64 `json:"unit_price"` + Amount float64 `json:"amount"` + Free bool `json:"free"` + Disabled bool `json:"disabled"` +} + +type Result struct { + ReferenceRatio float64 `json:"reference_ratio"` + FinalRatio float64 `json:"final_ratio"` + MaxRatio float64 `json:"max_ratio"` + InsuranceBaseRatio float64 `json:"insurance_base_ratio"` + ConfigPenalty float64 `json:"config_penalty"` + CoinCorrection float64 `json:"coin_correction"` + DailyLossAdjustment float64 `json:"daily_loss_adjustment"` + CoinBasePrice float64 `json:"coin_base_price"` + ItemPrice float64 `json:"item_price"` + TotalPrice float64 `json:"total_price"` + Items []ItemLine `json:"items"` + Valid bool `json:"valid"` + Message string `json:"message,omitempty"` +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d335e08 --- /dev/null +++ b/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "embed" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "哈夫币发布计算器", + Width: 1120, + Height: 760, + MinWidth: 980, + MinHeight: 640, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 243, G: 246, B: 250, A: 1}, + OnStartup: app.startup, + Bind: []interface{}{ + app, + }, + }) + if err != nil { + println("Error:", err.Error()) + } +} diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..5accb1e --- /dev/null +++ b/wails.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "hfb_price_tool", + "outputfilename": "hfb_price_tool", + "info": { + "companyName": "yml", + "productName": "哈夫币发布计算器", + "productVersion": "1.0.1", + "copyright": "Copyright © 2026 yml", + "comments": "按账号配置计算号主到手价(界面与 exe 版本号均取自 info.productVersion)" + }, + "frontend:install": "npm install", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev", + "frontend:dev:serverUrl": "http://localhost:5199", + "devServer": "localhost:34199", + "author": { + "name": "yml", + "email": "" + } +}