diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4e94bd..b0c3e87 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -107,6 +107,7 @@ type PrizeInfo = { left_num?: number status?: number percent?: string + is_show_num?: number img?: string } @@ -208,9 +209,77 @@ function priceOf(row?: PrizeInfo): number { return row.new_score || row.score || 0 } -function leftOf(row?: PrizeInfo): number { - if (!row) return 0 - return row.left_num || 0 +type InventoryInfo = { + left?: number + total?: number + percent?: string + estimated: boolean +} + +function inventoryOf(row?: PrizeInfo): InventoryInfo { + if (!row) return { estimated: false } + const total = Number(row.num || 0) + const percent = normalizePercent(row.percent) + const rawLeft = Number(row.left_num || 0) + if (rawLeft > 0 || percent === '0%') { + return { + left: rawLeft, + total: total > 0 ? total : undefined, + percent, + estimated: false, + } + } + if (total > 0 && percent) { + const percentValue = Number(percent.replace('%', '')) + if (Number.isFinite(percentValue)) { + return { + left: Math.floor((total * percentValue) / 100), + total, + percent, + estimated: true, + } + } + } + return { + total: total > 0 ? total : undefined, + percent, + estimated: false, + } +} + +function normalizePercent(value?: string): string | undefined { + const text = String(value || '').trim() + if (!text) return undefined + const numeric = Number(text.replace('%', '')) + if (!Number.isFinite(numeric)) return text + return `${numeric}%` +} + +function inventoryText(row?: PrizeInfo): string { + const info = inventoryOf(row) + const left = info.left === undefined ? '-' : `${info.estimated ? '约 ' : ''}${info.left}` + const total = info.total === undefined ? '-' : String(info.total) + return `剩余 ${left} / 总量 ${total}${info.percent ? ` · ${info.percent}` : ''}` +} + +function summarizeInventory(items: PrizeInfo[]): string { + let left = 0 + let total = 0 + let leftKnown = 0 + let totalKnown = 0 + items.forEach((item) => { + const info = inventoryOf(item) + if (info.left !== undefined) { + left += info.left + leftKnown += 1 + } + if (info.total !== undefined) { + total += info.total + totalKnown += 1 + } + }) + if (leftKnown === 0 && totalKnown === 0) return '库存未公开' + return `剩余 ${leftKnown ? left : '-'} / 总量 ${totalKnown ? total : '-'}` } function statusTag(status?: number, error?: string): ReactNode { @@ -550,6 +619,7 @@ export default function Dashboard() { String(priceOf(item)).includes(keyword), ) }, [prizes, productSearch]) + const inventorySummary = useMemo(() => summarizeInventory(filteredPrizes), [filteredPrizes]) const config = () => ({ ...pickConfig(form.getFieldsValue(true)), @@ -911,10 +981,9 @@ export default function Dashboard() { const prizeColumns: ColumnsType = useMemo( () => [ - { title: 'num', dataIndex: 'id', width: 92 }, - { title: 'name', dataIndex: 'name', minWidth: 220, ellipsis: true }, - { title: '剩余', width: 84, render: (_, row) => leftOf(row) || '-' }, - { title: '总量', dataIndex: 'num', width: 84 }, + { title: 'PID', dataIndex: 'id', width: 92 }, + { title: '名称', dataIndex: 'name', minWidth: 220, ellipsis: true }, + { title: '库存', minWidth: 190, render: (_, row) => inventoryText(row) }, { title: '价格', width: 84, render: (_, row) => priceOf(row) || '-' }, ], [], @@ -1065,6 +1134,7 @@ export default function Dashboard() {
商品缓存 {filteredPrizes.length} 个 + {inventorySummary} {productListUpdatedAt ? `更新 ${productListUpdatedAt}` : '未缓存'}
@@ -1091,9 +1161,9 @@ export default function Dashboard() {
{item.name} -
+
{priceOf(item)} 积分 - 库存 {leftOf(item) || '-'} +
@@ -1425,18 +1495,31 @@ export default function Dashboard() { ) } +function InventoryTags({ prize, compact = false }: { prize?: PrizeInfo; compact?: boolean }) { + const info = inventoryOf(prize) + const left = info.left === undefined ? '-' : `${info.estimated ? '约 ' : ''}${info.left}` + const total = info.total === undefined ? '-' : String(info.total) + return ( + + 剩余 {left} + 总量 {total} + {info.percent ? {info.percent} : null} + + ) +} + function CurrentProduct({ prize }: { prize?: PrizeInfo }) { const name = prize?.name || '未从 CK 列表选择' return (
当前商品 {name} -
+
{prize ? ( <> {priceOf(prize)} 积分 - 库存 {leftOf(prize) || '-'} PID {prize.id} + ) : ( CK 商品源 diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 30349dd..6bcaad2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -73,8 +73,32 @@ body { font-size: 16px; } +.current-product-tags, +.product-tags, +.inventory-tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.inventory-tags { + display: inline-flex; +} + +.inventory-tags.compact { + gap: 4px; +} + +.inventory-tags .ant-tag, +.product-tags .ant-tag, +.current-product-tags .ant-tag { + margin-inline-end: 0; +} + .inventory-line { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; diff --git a/internal/gui/app.go b/internal/gui/app.go index 2e80f75..2ccc2c9 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -20,6 +21,7 @@ const ( eventMonitorResult = "gui:monitor-result" eventMonitorState = "gui:monitor-state" maxLogEntries = 500 + logQueueSize = 2048 ) type App struct { @@ -28,6 +30,12 @@ type App struct { logMu sync.Mutex logs []LogEntry + logCh chan LogEntry + logStop chan struct{} + logWorkerOnce sync.Once + logStopOnce sync.Once + droppedLogs atomic.Int64 + runMu sync.Mutex monitorCancel context.CancelFunc monitorRunning bool @@ -111,16 +119,21 @@ type cookieLine struct { } func NewApp() *App { - return &App{} + return &App{ + logCh: make(chan LogEntry, logQueueSize), + logStop: make(chan struct{}), + } } func (a *App) Startup(ctx context.Context) { a.ctx = ctx + a.startLogWorker() a.addLog("info", "GUI 已启动") } func (a *App) Shutdown(ctx context.Context) { a.StopMonitor() + a.stopLogWorker() } func (a *App) DefaultConfig() Config { @@ -192,7 +205,8 @@ func (a *App) ListPrizes(cfg Config) (PrizeListResult, error) { a.addLog("error", "查询奖品失败: %v", err) return PrizeListResult{}, err } - a.addLog("info", "查询奖品完成: uid=%d sid=%d count=%d", accounts[0].UID, cfg.SID, len(resp.Prizes)) + left, total, leftKnown, totalKnown := prizeInventorySummary(resp.Prizes) + a.addLog("info", "查询商品完成: uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", accounts[0].UID, cfg.SID, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg) return PrizeListResult{ UID: accounts[0].UID, Status: resp.Status, @@ -423,6 +437,77 @@ func (a *App) addLog(level, format string, args ...any) { Level: level, Message: fmt.Sprintf(format, args...), } + if a.logCh == nil { + a.writeLogEntry(entry) + return + } + select { + case a.logCh <- entry: + default: + a.droppedLogs.Add(1) + } +} + +func (a *App) startLogWorker() { + if a.logCh == nil { + a.logCh = make(chan LogEntry, logQueueSize) + } + if a.logStop == nil { + a.logStop = make(chan struct{}) + } + a.logWorkerOnce.Do(func() { + go a.logWorker() + }) +} + +func (a *App) stopLogWorker() { + if a.logStop == nil { + return + } + a.logStopOnce.Do(func() { + close(a.logStop) + }) +} + +func (a *App) logWorker() { + for { + select { + case entry := <-a.logCh: + a.flushDroppedLogs() + a.writeLogEntry(entry) + case <-a.logStop: + a.drainLogQueue() + a.flushDroppedLogs() + return + } + } +} + +func (a *App) drainLogQueue() { + for { + select { + case entry := <-a.logCh: + a.flushDroppedLogs() + a.writeLogEntry(entry) + default: + return + } + } +} + +func (a *App) flushDroppedLogs() { + dropped := a.droppedLogs.Swap(0) + if dropped <= 0 { + return + } + a.writeLogEntry(LogEntry{ + Time: time.Now().Format("15:04:05.000"), + Level: "warn", + Message: fmt.Sprintf("日志队列繁忙,已异步丢弃 %d 条日志", dropped), + }) +} + +func (a *App) writeLogEntry(entry LogEntry) { a.logMu.Lock() a.logs = append(a.logs, entry) if len(a.logs) > maxLogEntries { @@ -643,3 +728,17 @@ func errorString(err error) string { } return err.Error() } + +func prizeInventorySummary(prizes []huya.PrizeInfo) (left, total int64, leftKnown, totalKnown int) { + for _, prize := range prizes { + if prize.Num > 0 { + total += prize.Num + totalKnown++ + } + if itemLeft, ok := prize.InventoryLeft(); ok { + left += itemLeft + leftKnown++ + } + } + return left, total, leftKnown, totalKnown +} diff --git a/internal/gui/app_test.go b/internal/gui/app_test.go index 89ca758..757c878 100644 --- a/internal/gui/app_test.go +++ b/internal/gui/app_test.go @@ -77,3 +77,24 @@ func TestParseStartTimeAcceptsClockOnly(t *testing.T) { } }) } + +func TestAddLogDoesNotBlockWhenQueueIsFull(t *testing.T) { + app := &App{ + logCh: make(chan LogEntry), + logStop: make(chan struct{}), + } + done := make(chan struct{}) + go func() { + app.addLog("debug", "test") + close(done) + }() + + select { + case <-done: + case <-time.After(100 * time.Millisecond): + t.Fatal("addLog blocked when log queue was full") + } + if dropped := app.droppedLogs.Load(); dropped != 1 { + t.Fatalf("droppedLogs = %d, want 1", dropped) + } +} diff --git a/internal/huya/activity.go b/internal/huya/activity.go index f031433..1e2ae3a 100644 --- a/internal/huya/activity.go +++ b/internal/huya/activity.go @@ -261,18 +261,26 @@ func (p PrizeInfo) Price() int64 { } func (p PrizeInfo) EstimatedLeft() int64 { + left, ok := p.InventoryLeft() + if ok { + return left + } + return 0 +} + +func (p PrizeInfo) InventoryLeft() (int64, bool) { if p.LeftNum > 0 { - return p.LeftNum + return p.LeftNum, true } text := strings.TrimSuffix(strings.TrimSpace(p.Percent), "%") if text == "" || p.Num <= 0 { - return 0 + return 0, false } percent, err := strconv.ParseFloat(text, 64) if err != nil { - return 0 + return 0, false } - return int64(math.Floor(float64(p.Num)*percent/100.0 + 1e-9)) + return int64(math.Floor(float64(p.Num)*percent/100.0 + 1e-9)), true } type PrizeTag struct { @@ -579,3 +587,35 @@ func (r *ScoreExchangePrizeResp) ReadFrom(rd *taf.Reader) error { func (r ScoreExchangePrizeResp) Success() bool { return r.Status == 200 && (r.Msg == "" || strings.Contains(r.Msg, "成功")) } + +func prizeListInventorySummary(prizes []PrizeInfo) (left, total int64, leftKnown, totalKnown int) { + for _, prize := range prizes { + if prize.Num > 0 { + total += prize.Num + totalKnown++ + } + if itemLeft, ok := prize.InventoryLeft(); ok { + left += itemLeft + leftKnown++ + } + } + return left, total, leftKnown, totalKnown +} + +func exchangeResponseMessage(resp *ScoreExchangePrizeResp) string { + if resp == nil { + return "" + } + for _, text := range []string{ + resp.Msg, + resp.ActPreCondition.Tip, + resp.ActPreCondition.ButtonDesc, + resp.ExchangeInfo.Desc, + resp.OrderID, + } { + if strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + return "" +} diff --git a/internal/huya/http_client.go b/internal/huya/http_client.go index e20bdfb..45dc25a 100644 --- a/internal/huya/http_client.go +++ b/internal/huya/http_client.go @@ -92,7 +92,7 @@ func (c *HTTPClient) CallRPC(ctx context.Context, service, method string, reqStr httpReq = httpReq.WithContext(ctx) } - c.logf("[HTTP] POST %s.%s body=%dB", service, method, len(body)) + c.logf("[HTTP] 请求 %s.%s", service, method) httpResp, err := client.Do(httpReq) if err != nil { return nil, err @@ -141,6 +141,9 @@ func (c *HTTPClient) QueryUserScore(ctx context.Context, uid int64, cookie strin req := &GetUserScoreReq{User: c.buildUser(uid, cookie, ActivityWebUA), SID: sid} var resp GetUserScoreResp _, err := c.CallRPC(ctx, ServantWebActUI, FuncGetUserScore, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 2}) + if err == nil { + c.logf("[HTTP] 积分已解码 uid=%d sid=%d status=%d 可用=%d msg=%s", uid, sid, resp.Status, resp.AvailableScore(), resp.Msg) + } return &resp, err } @@ -148,6 +151,10 @@ func (c *HTTPClient) GetActPrizeList(ctx context.Context, uid int64, cookie stri req := &GetActPrizeListReq{User: c.buildUser(uid, cookie, ActivityWebUA), SID: sid} var resp GetActPrizeListResp _, err := c.CallRPC(ctx, ServantWebActUI, FuncPrizeList, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 1}) + if err == nil { + left, total, leftKnown, totalKnown := prizeListInventorySummary(resp.Prizes) + c.logf("[HTTP] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg) + } return &resp, err } @@ -157,6 +164,9 @@ func (c *HTTPClient) GetUserPrizeRecords(ctx context.Context, uid int64, cookie req := &GetUserPrizeRecordsReq{User: user, SID: sid} var resp GetUserPrizeRecordsResp _, err := c.CallRPC(ctx, ServantWebActUI, FuncPrizeRecords, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 6}) + if err == nil { + c.logf("[HTTP] 兑换记录已解码 uid=%d sid=%d status=%d count=%d msg=%s", uid, sid, resp.Status, len(resp.Records), resp.Msg) + } return &resp, err } @@ -170,5 +180,8 @@ func (c *HTTPClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie s } var resp ScoreExchangePrizeResp _, err := c.CallRPC(ctx, ServantWebActUI, FuncExchangePrize, req, &resp, CallOptions{UID: uid, Cookie: cookie, RequestID: 1}) + if err == nil { + c.logf("[HTTP] 兑换结果已解码 uid=%d sid=%d pid=%d status=%d order=%s msg=%s", uid, sid, pid, resp.Status, resp.OrderID, exchangeResponseMessage(&resp)) + } return &resp, err } diff --git a/internal/huya/wss_client.go b/internal/huya/wss_client.go index 55b771d..66d4b8f 100644 --- a/internal/huya/wss_client.go +++ b/internal/huya/wss_client.go @@ -238,7 +238,7 @@ func (s *WSSSession) readLoop() { case WSSCmdHeartBeatAck, WSSCmdHeartBeatRsp: // 心跳响应无需处理。 case WSSCmdRegisterGroupRsp, WSSCmdVerifyCookieRsp, WSSCmdUpdateUserInfoRsp, WSSCmdUpdateUserExpsRsp: - s.client.logf("[WSS] 收到控制响应 cmd=%d data=%dB", cmd.CmdType, len(cmd.Data)) + // 控制类响应频率高且没有业务字段,避免刷屏。 default: s.client.logf("[WSS] 收到 cmd=%d data=%dB requestID=%d", cmd.CmdType, len(cmd.Data), cmd.RequestID) } @@ -456,7 +456,7 @@ func (s *WSSSession) callWUPWithRequestIDAndTrace(ctx context.Context, service, s.pendingMu.Lock() s.pending[requestID] = ch s.pendingMu.Unlock() - s.client.logf("[WSS] WUP %s.%s requestID=%d body=%dB", service, method, requestID, len(data)) + s.client.logf("[WSS] 请求 %s.%s requestID=%d", service, method, requestID) if err := s.writePayload(ctx, payload); err != nil { s.removePending(requestID) return nil, err @@ -509,6 +509,9 @@ func (c *WSSClient) QueryUserScore(ctx context.Context, uid int64, cookie string req := &GetUserScoreReq{User: c.buildUser(uid, cookie), SID: sid} var resp GetUserScoreResp _, err = session.callRPC(ctx, ServantWebActUI, FuncGetUserScore, req, &resp) + if err == nil { + c.logf("[WSS] 积分已解码 uid=%d sid=%d status=%d 可用=%d msg=%s", uid, sid, resp.Status, resp.AvailableScore(), resp.Msg) + } return &resp, err } @@ -520,6 +523,10 @@ func (c *WSSClient) GetActPrizeList(ctx context.Context, uid int64, cookie strin req := &GetActPrizeListReq{User: c.buildUser(uid, cookie), SID: sid} var resp GetActPrizeListResp _, err = session.callRPC(ctx, ServantWebActUI, FuncPrizeList, req, &resp) + if err == nil { + left, total, leftKnown, totalKnown := prizeListInventorySummary(resp.Prizes) + c.logf("[WSS] 商品列表已解码 uid=%d sid=%d status=%d count=%d 剩余=%d/%d项 总量=%d/%d项 msg=%s", uid, sid, resp.Status, len(resp.Prizes), left, leftKnown, total, totalKnown, resp.Msg) + } return &resp, err } @@ -531,6 +538,9 @@ func (c *WSSClient) GetUserPrizeRecords(ctx context.Context, uid int64, cookie s req := &GetUserPrizeRecordsReq{User: c.buildUser(uid, cookie), SID: sid} var resp GetUserPrizeRecordsResp _, err = session.callRPC(ctx, ServantWebActUI, FuncPrizeRecords, req, &resp) + if err == nil { + c.logf("[WSS] 兑换记录已解码 uid=%d sid=%d status=%d count=%d msg=%s", uid, sid, resp.Status, len(resp.Records), resp.Msg) + } return &resp, err } @@ -548,6 +558,9 @@ func (c *WSSClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie st } var resp ScoreExchangePrizeResp _, err = session.callRPC(ctx, ServantWebActUI, FuncExchangePrize, req, &resp) + if err == nil { + c.logf("[WSS] 兑换结果已解码 uid=%d sid=%d pid=%d status=%d order=%s msg=%s", uid, sid, pid, resp.Status, resp.OrderID, exchangeResponseMessage(&resp)) + } return &resp, err }