From 327b99607917a428c5b0cad6cc4f3c8ef37881a4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 6 Jul 2026 22:30:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=9A=E6=97=B6=E6=8A=A2?= =?UTF-8?q?=E8=B4=AD=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 262 +++++++++++++++++++++++++++++---------- frontend/src/styles.css | 87 ++++++++++++- internal/gui/app.go | 13 ++ internal/gui/app_test.go | 35 +++++- 4 files changed, 325 insertions(+), 72 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9e09276..e4e94bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 +type ScheduleCountdown = { + targetAt: number + fireAt: number +} + function getExchangeInfo(resp?: ExchangeResp): ExchangeInfo { return resp?.exchange_info || resp?.exchangeInfo || {} } @@ -249,6 +247,10 @@ function readSavedConfig(): Partial { } } +function writeSavedConfigPatch(patch: Partial) { + localStorage.setItem(storageKey, JSON.stringify({ ...readSavedConfig(), ...patch })) +} + function readSavedCookies(): CookieEntry[] { try { const items = JSON.parse(localStorage.getItem(cookieStorageKey) || '[]') as Partial[] @@ -354,35 +356,6 @@ function pickConfig(values: Partial): 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([]) @@ -410,6 +384,7 @@ export default function Dashboard() { const [results, setResults] = useState([]) const [logs, setLogs] = useState([]) const [now, setNow] = useState(new Date()) + const [scheduleCountdown, setScheduleCountdown] = useState(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([]) 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() {
-
+ {String(watchedTransport || 'wss').toUpperCase()} - - - 目标 + + 定时目标 - + @@ -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
+ + const remaining = countdown.fireAt - now.getTime() + const ready = remaining <= 0 + + return ( +
+ {ready ? '开抢中' : '距开抢'} + {ready ? '00:00:00.000' : formatCountdownDuration(remaining)} + 目标 {formatTimeOfDay(new Date(countdown.targetAt))} +
+ ) +} + +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 ( +
+ updatePart(0, next)} + /> + : + updatePart(1, next)} + /> + : + updatePart(2, next)} + /> +
+ ) +} + +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 (
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index fb0e509..30349dd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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 { diff --git a/internal/gui/app.go b/internal/gui/app.go index e8f8851..2e80f75 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -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) diff --git a/internal/gui/app_test.go b/internal/gui/app_test.go index 8de3eea..89ca758 100644 --- a/internal/gui/app_test.go +++ b/internal/gui/app_test.go @@ -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) + } + }) +}