优化定时抢购界面

This commit is contained in:
yml2213
2026-07-06 22:30:48 +08:00
parent ce6fdcc371
commit 327b996079
4 changed files with 325 additions and 72 deletions
+195 -67
View File
@@ -34,7 +34,6 @@ import {
} from 'lucide-react'
import {
DefaultConfig,
Exchange,
GetLogs,
GetRecords,
IsRunning,
@@ -167,12 +166,6 @@ type ExchangeResp = {
actPreCondition?: ActPreCondition
}
type ExchangeResult = {
uid: number
resp?: ExchangeResp
error?: string
}
type MonitorResult = {
key?: string
loop?: number
@@ -197,6 +190,11 @@ type RecordRow = UserPrizeInfo & {
type SavedConfig = Omit<UiConfig, 'cookieText' | 'selectedAccountLines'>
type ScheduleCountdown = {
targetAt: number
fireAt: number
}
function getExchangeInfo(resp?: ExchangeResp): ExchangeInfo {
return resp?.exchange_info || resp?.exchangeInfo || {}
}
@@ -249,6 +247,10 @@ function readSavedConfig(): Partial<SavedConfig> {
}
}
function writeSavedConfigPatch(patch: Partial<SavedConfig>) {
localStorage.setItem(storageKey, JSON.stringify({ ...readSavedConfig(), ...patch }))
}
function readSavedCookies(): CookieEntry[] {
try {
const items = JSON.parse(localStorage.getItem(cookieStorageKey) || '[]') as Partial<CookieEntry>[]
@@ -354,35 +356,6 @@ function pickConfig(values: Partial<UiConfig>): UiConfig {
}
}
function normalizeExchangeResult(row: ExchangeResult, index: number): MonitorResult {
const message =
row.error ||
row.resp?.msg ||
getPreCondition(row.resp).tip ||
row.resp?.order_id ||
row.resp?.orderId ||
''
const category =
row.error
? 'error'
: row.resp?.status === 200
? 'success'
: message.includes('积分') && message.includes('不足')
? 'not_enough'
: 'failed'
return {
key: `exchange-${row.uid}-${index}-${Date.now()}`,
loop: 0,
uid: row.uid,
action: 'exchange',
category,
message,
exchange: row.resp,
error: row.error,
}
}
function normalizeMonitorResult(row: MonitorResult): MonitorResult {
return {
...row,
@@ -398,6 +371,7 @@ export default function Dashboard() {
const watchedPID = Form.useWatch('pid', form)
const watchedName = Form.useWatch('name', form)
const watchedCookieText = Form.useWatch('cookieText', form)
const watchedStartAt = Form.useWatch('startAt', form)
const [loading, setLoading] = useState('')
const [running, setRunning] = useState(false)
const [accounts, setAccounts] = useState<AccountPreview[]>([])
@@ -410,6 +384,7 @@ export default function Dashboard() {
const [results, setResults] = useState<MonitorResult[]>([])
const [logs, setLogs] = useState<LogEntry[]>([])
const [now, setNow] = useState(new Date())
const [scheduleCountdown, setScheduleCountdown] = useState<ScheduleCountdown | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [cookieManagerOpen, setCookieManagerOpen] = useState(false)
const [cookieEditOpen, setCookieEditOpen] = useState(false)
@@ -417,6 +392,7 @@ export default function Dashboard() {
const [cookieDraft, setCookieDraft] = useState('')
const [cookieEntries, setCookieEntries] = useState<CookieEntry[]>([])
const [prizeCacheUpdatedAt, setPrizeCacheUpdatedAt] = useState(0)
const [configLoaded, setConfigLoaded] = useState(false)
const applyCookieEntries = async (entries: CookieEntry[], persist = true, refreshLatest = false) => {
const normalizedEntries = entries
@@ -476,6 +452,7 @@ export default function Dashboard() {
setPrizeCacheUpdatedAt(savedPrizeCache.updatedAt)
setLogs((logItems || []) as LogEntry[])
setRunning(Boolean(isRunning))
setConfigLoaded(true)
if (savedCookies.length > 0) {
applyCookieEntries(savedCookies, true, true).catch((err: unknown) => message.error(String(err)))
}
@@ -491,7 +468,9 @@ export default function Dashboard() {
const offState = EventsOn(
'gui:monitor-state',
(state: { running?: boolean; message?: string }) => {
setRunning(Boolean(state?.running))
const nextRunning = Boolean(state?.running)
setRunning(nextRunning)
if (!nextRunning) setScheduleCountdown(null)
if (state?.message) message.info(state.message)
},
)
@@ -513,6 +492,11 @@ export default function Dashboard() {
setSelectedPrizeKeys([])
}, [watchedCookieText])
useEffect(() => {
if (!configLoaded) return
writeSavedConfigPatch({ startAt: String(form.getFieldValue('startAt') || '') })
}, [configLoaded, form, watchedStartAt])
const validAccounts = useMemo(() => accounts.filter((item) => item.valid), [accounts])
const importedCookieCount = useMemo(
() => normalizeCookieText(watchedCookieText || '').split('\n').filter(Boolean).length,
@@ -811,21 +795,19 @@ export default function Dashboard() {
const startMonitor = (scheduled: boolean) => {
const values = config()
if (!scheduled) values.startAt = ''
setResults([])
return runAction('monitor', () => StartMonitor(values), () => setRunning(true))
}
const exchangeNow = () => {
const values = config()
if (values.pid <= 0) {
message.warning('请选择商品或填写 PID')
return
const countdown = scheduled ? buildScheduleCountdown(values.startAt, values.leadMs, now) : null
if (scheduled && !countdown) {
message.warning('请先填写定时目标时间(HH:mm:ss)')
return undefined
}
if (!scheduled) {
values.startAt = ''
setScheduleCountdown(null)
}
setResults([])
return runAction('exchange', () => Exchange(values), (data) => {
const items = (data || []) as ExchangeResult[]
setResults(items.map(normalizeExchangeResult))
return runAction('monitor', () => StartMonitor(values), () => {
setRunning(true)
setScheduleCountdown(countdown)
})
}
@@ -1125,8 +1107,8 @@ export default function Dashboard() {
<div className="tab-strip">
<Button type="primary">抢购控制台</Button>
<Button onClick={() => setCookieManagerOpen(true)}>CK 管理</Button>
<Button>等待操作</Button>
</div>
<CountdownBadge countdown={scheduleCountdown} now={now} running={running} />
<Space size={12}>
<Tag>{String(watchedTransport || 'wss').toUpperCase()}</Tag>
<Button icon={<Settings size={16} />} onClick={() => setSettingsOpen(true)}>
@@ -1138,17 +1120,17 @@ export default function Dashboard() {
<section className="statusbar">
<span>服务器</span>
<strong>{formatClock(now)}</strong>
<strong className="status-time">{formatClock(now)}</strong>
<span>账号</span>
<strong>
<strong className="status-count status-account-count">
{selectedAccountCount}/{validAccounts.length}
</strong>
<span>成功</span>
<strong className="green">{successCount}</strong>
<strong className="status-count green">{successCount}</strong>
<span>异常</span>
<strong className="red">{errorCount}</strong>
<strong className="status-count red">{errorCount}</strong>
<span>待处理</span>
<strong>{pendingCount}</strong>
<strong className="status-count">{pendingCount}</strong>
</section>
<section className="actionbar">
@@ -1173,14 +1155,6 @@ export default function Dashboard() {
>
开启定时
</Button>
<Button
icon={<ShoppingCart size={16} />}
loading={loading === 'exchange'}
disabled={running}
onClick={exchangeNow}
>
立即兑换
</Button>
<Button
icon={<ClipboardPaste size={16} />}
loading={loading === 'clipboard'}
@@ -1216,15 +1190,16 @@ export default function Dashboard() {
onClick={async () => {
await StopMonitor()
setRunning(false)
setScheduleCountdown(null)
}}
>
停止
</Button>
</Space>
<Space>
<span className="muted">目标</span>
<Space className="schedule-target">
<span className="muted">定时目标</span>
<Form.Item name="startAt" noStyle>
<Input className="target-time" />
<ScheduleTimeInput />
</Form.Item>
</Space>
</section>
@@ -1471,6 +1446,159 @@ function CurrentProduct({ prize }: { prize?: PrizeInfo }) {
)
}
function CountdownBadge({
countdown,
now,
running,
}: {
countdown: ScheduleCountdown | null
now: Date
running: boolean
}) {
if (!running || !countdown) return <div className="countdown-slot" />
const remaining = countdown.fireAt - now.getTime()
const ready = remaining <= 0
return (
<div className={`countdown-badge${ready ? ' ready' : ''}`}>
<span>{ready ? '开抢中' : '距开抢'}</span>
<strong>{ready ? '00:00:00.000' : formatCountdownDuration(remaining)}</strong>
<small>目标 {formatTimeOfDay(new Date(countdown.targetAt))}</small>
</div>
)
}
function buildScheduleCountdown(
startAt: string,
leadMs: number,
base: Date,
): ScheduleCountdown | null {
const parts = parseScheduleTime(startAt)
if (parts.every((part) => part === null)) return null
const [hour, minute, second] = parts.map((part) => Math.max(0, Number(part || 0)))
const target = new Date(base)
target.setHours(Math.min(hour, 23), Math.min(minute, 59), Math.min(second, 59), 0)
if (target.getTime() <= base.getTime()) {
target.setDate(target.getDate() + 1)
}
const lead = Math.max(0, Number(leadMs || 0))
return {
targetAt: target.getTime(),
fireAt: target.getTime() - lead,
}
}
function formatCountdownDuration(ms: number): string {
const safeMs = Math.max(0, Math.floor(ms))
const hours = Math.floor(safeMs / 3600000)
const minutes = Math.floor((safeMs % 3600000) / 60000)
const seconds = Math.floor((safeMs % 60000) / 1000)
const milliseconds = safeMs % 1000
return `${padNumber(hours)}:${padNumber(minutes)}:${padNumber(seconds)}.${padNumber(
milliseconds,
3,
)}`
}
function formatTimeOfDay(value: Date): string {
return `${padNumber(value.getHours())}:${padNumber(value.getMinutes())}:${padNumber(
value.getSeconds(),
)}`
}
type ScheduleTimePart = number | null
function ScheduleTimeInput({
value,
onChange,
}: {
value?: string
onChange?: (value: string) => void
}) {
const [parts, setParts] = useState<[ScheduleTimePart, ScheduleTimePart, ScheduleTimePart]>([
null,
null,
null,
])
useEffect(() => {
const parsed = parseScheduleTime(value || '')
setParts(parsed)
const normalized = formatScheduleTime(parsed)
if (value && normalized && value !== normalized) {
onChange?.(normalized)
}
}, [value])
const updatePart = (index: number, nextValue: number | string | null) => {
const next = [...parts] as [ScheduleTimePart, ScheduleTimePart, ScheduleTimePart]
next[index] =
nextValue === null || nextValue === ''
? null
: Math.max(0, Math.floor(Number(nextValue) || 0))
setParts(next)
onChange?.(formatScheduleTime(next))
}
return (
<div className="schedule-time-input">
<InputNumber
controls={false}
min={0}
max={23}
precision={0}
placeholder="时"
value={parts[0]}
onChange={(next) => updatePart(0, next)}
/>
<span className="time-separator">:</span>
<InputNumber
controls={false}
min={0}
max={59}
precision={0}
placeholder="分"
value={parts[1]}
onChange={(next) => updatePart(1, next)}
/>
<span className="time-separator">:</span>
<InputNumber
controls={false}
min={0}
max={59}
precision={0}
placeholder="秒"
value={parts[2]}
onChange={(next) => updatePart(2, next)}
/>
</div>
)
}
function parseScheduleTime(value: string): [ScheduleTimePart, ScheduleTimePart, ScheduleTimePart] {
const match = value.trim().match(/(?:^|\s)(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/)
if (!match) return [null, null, null]
return [
Math.min(Number(match[1]), 23),
Math.min(Number(match[2]), 59),
Math.min(Number(match[3] || 0), 59),
]
}
function formatScheduleTime(parts: [ScheduleTimePart, ScheduleTimePart, ScheduleTimePart]): string {
if (parts.every((part) => part === null)) return ''
const [hour, minute, second] = parts.map((part) => Math.max(0, Number(part || 0)))
return `${padNumber(Math.min(hour, 23))}:${padNumber(Math.min(minute, 59))}:${padNumber(
Math.min(second, 59),
)}`
}
function padNumber(value: number, size = 2): string {
return String(value).padStart(size, '0')
}
function LogView({ logs }: { logs: LogEntry[] }) {
return (
<div className="log-view">
+83 -4
View File
@@ -170,9 +170,9 @@ body {
}
.topbar {
display: flex;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) auto;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #d8e0e8;
background: #fff;
@@ -184,6 +184,47 @@ body {
gap: 12px;
}
.countdown-slot {
min-width: 220px;
}
.countdown-badge {
display: flex;
width: fit-content;
max-width: 100%;
align-items: center;
justify-self: center;
gap: 10px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #eff6ff;
padding: 7px 12px;
color: #1d4ed8;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.countdown-badge.ready {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.countdown-badge span,
.countdown-badge small {
color: inherit;
font-size: 13px;
opacity: 0.82;
}
.countdown-badge strong {
min-width: 112px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 16px;
line-height: 1;
text-align: center;
}
.statusbar {
display: flex;
align-items: center;
@@ -191,6 +232,7 @@ body {
border-bottom: 1px solid #d8e0e8;
background: #fff;
padding: 0 20px;
font-variant-numeric: tabular-nums;
}
.statusbar span {
@@ -201,6 +243,23 @@ body {
font-size: 16px;
}
.status-time {
display: inline-block;
width: 23ch;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: nowrap;
}
.status-count {
display: inline-block;
min-width: 18px;
text-align: right;
}
.status-account-count {
min-width: 44px;
}
.green {
color: #16803d;
}
@@ -229,8 +288,28 @@ body {
border-color: #117233 !important;
}
.target-time {
width: 190px;
.schedule-target {
flex-shrink: 0;
}
.schedule-target .muted {
white-space: nowrap;
}
.schedule-time-input {
display: flex;
align-items: center;
gap: 6px;
}
.schedule-time-input .ant-input-number {
width: 54px;
}
.time-separator {
color: #697586;
font-weight: 700;
line-height: 1;
}
.main-grid {
+13
View File
@@ -593,6 +593,11 @@ func parseStartTime(text string) (time.Time, error) {
if ts, err := strconv.ParseInt(text, 10, 64); err == nil {
return time.Unix(ts, 0), nil
}
for _, layout := range []string{"15:04:05", "15:04"} {
if t, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return nextClockTime(time.Now(), t.Hour(), t.Minute(), t.Second()), nil
}
}
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02 15:04"} {
if t, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return t, nil
@@ -601,6 +606,14 @@ func parseStartTime(text string) (time.Time, error) {
return time.Time{}, fmt.Errorf("无法解析开始时间: %s", text)
}
func nextClockTime(now time.Time, hour, minute, second int) time.Time {
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, second, 0, now.Location())
if !target.After(now) {
target = target.Add(24 * time.Hour)
}
return target
}
func waitUntil(ctx context.Context, when time.Time) error {
for {
remaining := time.Until(when)
+34 -1
View File
@@ -1,6 +1,9 @@
package gui
import "testing"
import (
"testing"
"time"
)
func TestSplitCookieLinesNormalizesLineBreaks(t *testing.T) {
tests := []struct {
@@ -44,3 +47,33 @@ func TestSplitCookieLinesNormalizesLineBreaks(t *testing.T) {
})
}
}
func TestParseStartTimeAcceptsClockOnly(t *testing.T) {
t.Run("future_today", func(t *testing.T) {
now := time.Date(2026, 7, 6, 10, 20, 30, 0, time.Local)
got := nextClockTime(now, 12, 5, 6)
want := time.Date(2026, 7, 6, 12, 5, 6, 0, time.Local)
if !got.Equal(want) {
t.Fatalf("nextClockTime() = %s, want %s", got, want)
}
})
t.Run("past_tomorrow", func(t *testing.T) {
now := time.Date(2026, 7, 6, 10, 20, 30, 0, time.Local)
got := nextClockTime(now, 9, 5, 6)
want := time.Date(2026, 7, 7, 9, 5, 6, 0, time.Local)
if !got.Equal(want) {
t.Fatalf("nextClockTime() = %s, want %s", got, want)
}
})
t.Run("parse_hms", func(t *testing.T) {
got, err := parseStartTime("23:59:58")
if err != nil {
t.Fatalf("parseStartTime() error = %v", err)
}
if got.Hour() != 23 || got.Minute() != 59 || got.Second() != 58 {
t.Fatalf("parseStartTime() = %s, want 23:59:58", got)
}
})
}