diff --git a/app.go b/app.go index 5941833..ac80d85 100644 --- a/app.go +++ b/app.go @@ -37,18 +37,20 @@ type App struct { } type DefaultSettings struct { - Server string `json:"server"` - Uploader string `json:"uploader"` - Secret string `json:"secret"` - Timeout string `json:"timeout"` + Server string `json:"server"` + Uploader string `json:"uploader"` + Secret string `json:"secret"` + Timeout string `json:"timeout"` + SourceChannel string `json:"sourceChannel"` } type UploadForm struct { - Server string `json:"server"` - Uploader string `json:"uploader"` - Secret string `json:"secret"` - Timeout string `json:"timeout"` - Data string `json:"data"` + Server string `json:"server"` + Uploader string `json:"uploader"` + Secret string `json:"secret"` + Timeout string `json:"timeout"` + SourceChannel string `json:"sourceChannel"` + Data string `json:"data"` } func NewApp() *App { @@ -61,10 +63,11 @@ func (a *App) startup(ctx context.Context) { func (a *App) DefaultSettings() DefaultSettings { return DefaultSettings{ - Server: envDefault("HFB_SERVER", "http://127.0.0.1:8080"), - Uploader: strings.TrimSpace(os.Getenv("HFB_UPLOADER_NAME")), - Secret: strings.TrimSpace(os.Getenv("HFB_UPLOAD_SECRET")), - Timeout: "15s", + Server: envDefault("HFB_SERVER", "http://127.0.0.1:8080"), + Uploader: strings.TrimSpace(os.Getenv("HFB_UPLOADER_NAME")), + Secret: strings.TrimSpace(os.Getenv("HFB_UPLOAD_SECRET")), + Timeout: "15s", + SourceChannel: strings.TrimSpace(os.Getenv("HFB_SOURCE_CHANNEL")), } } @@ -125,7 +128,11 @@ func buildUploadRequest(form UploadForm) (hfb.ExternalUploadRequest, error) { if uploaderName == "" { return hfb.ExternalUploadRequest{}, fmt.Errorf("缺少上传人名称:请展开“连接配置”填写上传人,或在文本中增加“上传人:姓名”") } - return hfb.NewExternalUploadRequest(uploaderName, parsed.Account, time.Now()), nil + sourceChannel := strings.TrimSpace(form.SourceChannel) + if sourceChannel == "" { + return hfb.ExternalUploadRequest{}, fmt.Errorf("请选择订单来源渠道") + } + return hfb.NewExternalUploadRequest(uploaderName, sourceChannel, parsed.Account, time.Now()), nil } func parseTimeout(raw string) (time.Duration, error) { diff --git a/frontend/src/main.js b/frontend/src/main.js index f2a8009..91dc970 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -2,6 +2,8 @@ import './style.css'; import { ClipboardGetText } from '../wailsjs/runtime/runtime.js'; const storageKey = 'hfb-upload-settings'; +const customChannelValue = '__custom__'; +const sourceChannelOptions = ['咸鱼', '淘宝', '京东', 'QQ', '微信']; const app = document.querySelector('#app'); @@ -37,6 +39,26 @@ app.innerHTML = ` + + 来源渠道 + + ${sourceChannelOptions + .map( + (item) => ` + + + ${item} + ` + ) + .join('')} + + + 自定义 + + + + + 打开文件 @@ -65,6 +87,8 @@ const elements = { status: document.querySelector('#status'), server: document.querySelector('#server'), uploader: document.querySelector('#uploader'), + channelOptions: [...document.querySelectorAll('input[name="sourceChannelOption"]')], + customChannel: document.querySelector('#customChannel'), secret: document.querySelector('#secret'), timeout: document.querySelector('#timeout'), data: document.querySelector('#data'), @@ -100,6 +124,7 @@ function currentSettings() { return { server: elements.server.value.trim(), uploader: elements.uploader.value.trim(), + sourceChannel: selectedSourceChannel(), secret: elements.secret.value.trim(), timeout: elements.timeout.value.trim() || '15s', }; @@ -116,6 +141,47 @@ function collectForm() { }; } +function selectedSourceChannel() { + const selected = elements.channelOptions.find((input) => input.checked); + if (!selected) { + return ''; + } + if (selected.value === customChannelValue) { + return elements.customChannel.value.trim(); + } + return selected.value.trim(); +} + +function applySourceChannelSetting(value) { + const sourceChannel = String(value || '').trim(); + if (sourceChannelOptions.includes(sourceChannel)) { + setSelectedChannelOption(sourceChannel); + elements.customChannel.value = ''; + } else if (sourceChannel === '') { + setSelectedChannelOption(''); + elements.customChannel.value = ''; + } else { + setSelectedChannelOption(customChannelValue); + elements.customChannel.value = sourceChannel; + } + syncCustomChannel(); +} + +function setSelectedChannelOption(value) { + elements.channelOptions.forEach((input) => { + input.checked = input.value === value; + }); +} + +function syncCustomChannel() { + const selected = elements.channelOptions.find((input) => input.checked); + const isCustom = selected?.value === customChannelValue; + elements.customChannel.hidden = !isCustom; + if (!isCustom) { + elements.customChannel.value = ''; + } +} + async function readClipboardText() { try { const text = await ClipboardGetText(); @@ -142,13 +208,23 @@ async function init() { const settings = { ...defaults, ...saved }; elements.server.value = settings.server || 'http://127.0.0.1:8080'; elements.uploader.value = settings.uploader || ''; + applySourceChannelSetting(settings.sourceChannel || ''); elements.secret.value = settings.secret || ''; elements.timeout.value = settings.timeout || '15s'; elements.data.placeholder = await api.SampleText(); - [elements.server, elements.uploader, elements.secret, elements.timeout].forEach((input) => { + [elements.server, elements.uploader, elements.secret, elements.timeout, elements.customChannel].forEach((input) => { input.addEventListener('input', saveSettings); }); + elements.channelOptions.forEach((input) => { + input.addEventListener('change', () => { + syncCustomChannel(); + saveSettings(); + if (input.value === customChannelValue) { + elements.customChannel.focus(); + } + }); + }); } elements.fileInput.addEventListener('change', async (event) => { diff --git a/frontend/src/style.css b/frontend/src/style.css index cc44bd5..be31bde 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -33,7 +33,7 @@ textarea { margin: 0 auto; padding: 22px 0; display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr); + grid-template-rows: auto auto auto auto minmax(0, 1fr); gap: 14px; overflow: hidden; } @@ -131,6 +131,78 @@ textarea:focus { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); } +.channel-panel { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.channel-title { + flex: 0 0 auto; + font-weight: 700; + color: #374151; +} + +.channel-options { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.channel-choice { + position: relative; + display: inline-flex; +} + +.channel-choice input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.channel-choice span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 76px; + height: 42px; + margin: 0; + padding: 0 18px; + border: 1px solid #d7dce5; + border-radius: 8px; + background: #ffffff; + color: #374151; + font-weight: 700; + cursor: pointer; + user-select: none; +} + +.channel-choice input:checked + span { + border-color: #2563eb; + background: #2563eb; + color: #ffffff; + box-shadow: 0 8px 16px rgba(37, 99, 235, 0.18); +} + +.channel-choice input:focus-visible + span { + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16); +} + +.custom-channel { + width: min(220px, 100%); +} + +.custom-channel[hidden] { + display: none; +} + .actions { display: flex; align-items: center; @@ -242,6 +314,30 @@ textarea:focus { margin-top: 12px; } + .channel-panel { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .channel-options { + width: 100%; + } + + .channel-choice { + flex: 1 1 calc(33.333% - 8px); + } + + .channel-choice span { + width: 100%; + min-width: 0; + padding: 0 10px; + } + + .custom-channel { + flex: 1 1 100%; + } + .workspace { grid-template-columns: 1fr; } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ce28814..3188530 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -5,6 +5,7 @@ export namespace main { uploader: string; secret: string; timeout: string; + sourceChannel: string; static createFrom(source: any = {}) { return new DefaultSettings(source); @@ -16,6 +17,7 @@ export namespace main { this.uploader = source["uploader"]; this.secret = source["secret"]; this.timeout = source["timeout"]; + this.sourceChannel = source["sourceChannel"]; } } export class UploadForm { @@ -23,6 +25,7 @@ export namespace main { uploader: string; secret: string; timeout: string; + sourceChannel: string; data: string; static createFrom(source: any = {}) { @@ -35,6 +38,7 @@ export namespace main { this.uploader = source["uploader"]; this.secret = source["secret"]; this.timeout = source["timeout"]; + this.sourceChannel = source["sourceChannel"]; this.data = source["data"]; } } diff --git a/internal/hfb/parser.go b/internal/hfb/parser.go index 38a3b65..f859b01 100644 --- a/internal/hfb/parser.go +++ b/internal/hfb/parser.go @@ -7,7 +7,10 @@ import ( "strings" ) -var numberPattern = regexp.MustCompile(`[-+]?\d+(?:\.\d+)?`) +var ( + numberPattern = regexp.MustCompile(`[-+]?\d+(?:\.\d+)?`) + onlineTimePattern = regexp.MustCompile(`^\D*(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D+(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D*$`) +) func ParseUploadText(text string) (ParsedUpload, error) { values := make(map[string]string) @@ -55,7 +58,7 @@ func ParseUploadText(text string) (ParsedUpload, error) { account.BanRecord = normalizeBanFlag(banRaw) account.CommonRegion = firstValue(values, "常用地区", "常用区域") account.ContactPhone = firstValue(values, "联系电话", "手机号", "电话") - account.OwnerOnlineTime = firstValue(values, "号主在线时间", "在线时间") + account.OwnerOnlineTime = normalizeOwnerOnlineTime(firstValue(values, "号主在线时间", "在线时间")) account.Remark = composeRemark(firstValue(values, "备注", "说明"), banRaw) account.Currency.HafuCoin, err = parseRequiredFloat(values, "哈夫币纯币", "哈夫币") @@ -107,10 +110,14 @@ func ParseUploadText(text string) (ParsedUpload, error) { } func splitKeyValue(line string) (string, string, bool) { - if idx := strings.Index(line, ":"); idx >= 0 { + fullWidthIdx := strings.Index(line, ":") + asciiIdx := strings.Index(line, ":") + if fullWidthIdx >= 0 && (asciiIdx < 0 || fullWidthIdx < asciiIdx) { + idx := fullWidthIdx return line[:idx], line[idx+len(":"):], true } - if idx := strings.Index(line, ":"); idx >= 0 { + if asciiIdx >= 0 { + idx := asciiIdx return line[:idx], line[idx+1:], true } return "", "", false @@ -246,6 +253,49 @@ func parseFloatText(value string, preferLast bool) (float64, error) { return strconv.ParseFloat(pick, 64) } +func normalizeOwnerOnlineTime(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + start, end, ok := parseOwnerOnlineTimeRange(value) + if !ok { + return value + } + return start + " 至 " + end +} + +func parseOwnerOnlineTimeRange(value string) (string, string, bool) { + matches := onlineTimePattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(matches) != 5 { + return "", "", false + } + start, ok := normalizeTimePart(matches[1], matches[2]) + if !ok { + return "", "", false + } + end, ok := normalizeTimePart(matches[3], matches[4]) + if !ok { + return "", "", false + } + return start, end, true +} + +func normalizeTimePart(hourText, minuteText string) (string, bool) { + hour, err := strconv.Atoi(strings.TrimSpace(hourText)) + if err != nil || hour < 0 || hour > 23 { + return "", false + } + minute := 0 + if strings.TrimSpace(minuteText) != "" { + minute, err = strconv.Atoi(strings.TrimSpace(minuteText)) + if err != nil || minute < 0 || minute > 59 { + return "", false + } + } + return fmt.Sprintf("%02d:%02d", hour, minute), true +} + func parseSkins(value string) []string { value = strings.TrimSpace(value) if value == "" || value == "无" || strings.EqualFold(value, "none") { diff --git a/internal/hfb/parser_test.go b/internal/hfb/parser_test.go index 7857894..cb622e9 100644 --- a/internal/hfb/parser_test.go +++ b/internal/hfb/parser_test.go @@ -1,6 +1,9 @@ package hfb -import "testing" +import ( + "strings" + "testing" +) const sampleText = `上号方式:QQ扫码 哈夫币纯币:156 @@ -60,6 +63,31 @@ func TestParseUploadTextASCIIDelimiterKeepsRatioValue(t *testing.T) { } } +func TestParseUploadTextNormalizesOwnerOnlineTimeFormats(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + {name: "至", line: "号主在线时间:08:00 至 23:00", want: "08:00 至 23:00"}, + {name: "短横线和全角冒号", line: "号主在线时间:8:00-1:00", want: "08:00 至 01:00"}, + {name: "英文字段冒号", line: "号主在线时间:8:00-1:00", want: "08:00 至 01:00"}, + {name: "点和到", line: "在线时间:8点到1点", want: "08:00 至 01:00"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + text := strings.Replace(sampleText, "号主在线时间:11:00 至 00:00", tt.line, 1) + parsed, err := ParseUploadText(text) + if err != nil { + t.Fatalf("ParseUploadText() error = %v", err) + } + if parsed.Account.OwnerOnlineTime != tt.want { + t.Fatalf("OwnerOnlineTime = %q, want %q", parsed.Account.OwnerOnlineTime, tt.want) + } + }) + } +} + func TestParseUploadTextMissingRequiredField(t *testing.T) { _, err := ParseUploadText("上号方式:QQ扫码") if err == nil { diff --git a/internal/hfb/types.go b/internal/hfb/types.go index 9e4051c..2915080 100644 --- a/internal/hfb/types.go +++ b/internal/hfb/types.go @@ -11,9 +11,10 @@ type ParsedUpload struct { } type ExternalUploadRequest struct { - UploadTime int64 `json:"uploadTime"` - UploaderName string `json:"uploaderName"` - Data ExternalAccountData `json:"data"` + UploadTime int64 `json:"uploadTime"` + UploaderName string `json:"uploaderName"` + SourceChannel string `json:"sourceChannel"` + Data ExternalAccountData `json:"data"` } type ExternalAccountData struct { @@ -52,11 +53,12 @@ type ExternalUploadInventory struct { Skins []string `json:"skins"` } -func NewExternalUploadRequest(uploaderName string, account ExternalAccountData, now time.Time) ExternalUploadRequest { +func NewExternalUploadRequest(uploaderName string, sourceChannel string, account ExternalAccountData, now time.Time) ExternalUploadRequest { return ExternalUploadRequest{ - UploadTime: now.Unix(), - UploaderName: uploaderName, - Data: account, + UploadTime: now.Unix(), + UploaderName: uploaderName, + SourceChannel: sourceChannel, + Data: account, } } diff --git a/internal/hfb/upload_test.go b/internal/hfb/upload_test.go index fe1b9ff..7c82549 100644 --- a/internal/hfb/upload_test.go +++ b/internal/hfb/upload_test.go @@ -36,7 +36,7 @@ func TestUploadSendsSignedRequest(t *testing.T) { defer server.Close() client := Client{Server: server.URL, Secret: secret, Timeout: time.Second} - req := NewExternalUploadRequest("张三", mustSampleAccount(t), time.Unix(100, 0)) + req := NewExternalUploadRequest("张三", "淘宝", mustSampleAccount(t), time.Unix(100, 0)) resp, err := client.Upload(context.Background(), req) if err != nil { t.Fatalf("Upload() error = %v", err)