增加异步日志和库存展示

This commit is contained in:
yml2213
2026-07-07 08:04:54 +08:00
parent 327b996079
commit 72d59670d4
7 changed files with 313 additions and 20 deletions
+94 -11
View File
@@ -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<PrizeInfo> = 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() {
<div className="inventory-line">
<span>商品缓存 {filteredPrizes.length} 个</span>
<span>{inventorySummary}</span>
<span>{productListUpdatedAt ? `更新 ${productListUpdatedAt}` : '未缓存'}</span>
</div>
@@ -1091,9 +1161,9 @@ export default function Dashboard() {
</div>
<div className="product-meta">
<strong>{item.name}</strong>
<div>
<div className="product-tags">
<Tag color="gold">{priceOf(item)} 积分</Tag>
<Tag color="green">库存 {leftOf(item) || '-'}</Tag>
<InventoryTags prize={item} compact />
</div>
</div>
</button>
@@ -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 (
<span className={`inventory-tags${compact ? ' compact' : ''}`}>
<Tag color={info.left === 0 ? 'red' : 'green'}>剩余 {left}</Tag>
<Tag>总量 {total}</Tag>
{info.percent ? <Tag color="blue">{info.percent}</Tag> : null}
</span>
)
}
function CurrentProduct({ prize }: { prize?: PrizeInfo }) {
const name = prize?.name || '未从 CK 列表选择'
return (
<div className="current-product">
<span>当前商品</span>
<strong>{name}</strong>
<div>
<div className="current-product-tags">
{prize ? (
<>
<Tag color="gold">{priceOf(prize)} 积分</Tag>
<Tag color="green">库存 {leftOf(prize) || '-'}</Tag>
<Tag>PID {prize.id}</Tag>
<InventoryTags prize={prize} />
</>
) : (
<Tag>CK 商品源</Tag>
+24
View File
@@ -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;
+101 -2
View File
@@ -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
}
+21
View File
@@ -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)
}
}
+44 -4
View File
@@ -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 ""
}
+14 -1
View File
@@ -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
}
+15 -2
View File
@@ -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
}