优化定时抢购界面
This commit is contained in:
+195
-67
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user