feat: 自定义重试间隔策略 + 修复按钮hover样式

- 新增重试间隔阶段配置,支持自定义时间分界点和频率
- 默认配置: 0-8秒=130ms, 8-25秒=260ms, 25-75秒=900ms
- 设置面板添加可增删的阶段配置 UI
- 修复按钮 hover 时文字变白的问题
This commit is contained in:
yml2213
2026-06-26 13:46:46 +08:00
parent a955128887
commit 9bff652c66
8 changed files with 231 additions and 43 deletions
+4 -4
View File
@@ -70,7 +70,7 @@ func (a *App) startup(ctx context.Context) {
a.client = client.New(a.cfg.TimeAPIURL, a.trace)
a.font = imgpkg.LoadFont(a.paths.FontFiles)
a.scheduler = seckill.New(a.client, a.paths, a.font, a.cfg.Concurrency, a.cfg.Attempts)
a.scheduler = seckill.New(a.client, a.paths, a.font, a.cfg.Concurrency, a.cfg.Attempts, a.cfg.RetryStages)
a.scheduler.LoadAccounts()
if err := os.MkdirAll(a.paths.SuccessDir, 0755); err != nil {
@@ -563,10 +563,10 @@ func (a *App) SaveSettings(cfg types.Config) map[string]any {
// 运行时生效
a.cfg = cfg
logger.SetMinLevel(cfg.LogLevel)
a.scheduler.UpdateSettings(cfg.Concurrency, cfg.Attempts)
a.scheduler.UpdateSettings(cfg.Concurrency, cfg.Attempts, cfg.RetryStages)
logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d trace=%v",
cfg.LogLevel, cfg.Concurrency, cfg.Attempts, cfg.TraceEnabled)
logger.LogEvent("配置", "success", "配置已保存并生效: 日志等级=%s 并发=%d 重试=%d 重试阶段=%d trace=%v",
cfg.LogLevel, cfg.Concurrency, cfg.Attempts, len(cfg.RetryStages), cfg.TraceEnabled)
return map[string]any{"success": true}
}
+4 -2
View File
@@ -352,7 +352,8 @@ const handleOrganize = async () => {
}
.btn-primary:hover {
filter: brightness(1.08);
background: #4d9aff;
border-color: #4d9aff;
}
.btn-success {
@@ -362,7 +363,8 @@ const handleOrganize = async () => {
}
.btn-success:hover {
filter: brightness(1.08);
background: #4fc95f;
border-color: #4fc95f;
}
.btn-compact {
+151 -14
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useSettings } from '@/composables/useSettings'
import { useSettings, type RetryStage } from '@/composables/useSettings'
const { settings, loadSettings, saveSettings } = useSettings()
@@ -11,12 +11,16 @@ const formData = ref({
traceMaxBody: 0,
logLevel: 'info',
concurrency: 80,
attempts: 1
attempts: 1,
retryStages: [] as RetryStage[]
})
const open = async () => {
await loadSettings()
formData.value = { ...settings.value }
formData.value = {
...settings.value,
retryStages: settings.value.retryStages.map(s => ({ ...s }))
}
visible.value = true
}
@@ -31,6 +35,19 @@ const handleSave = async () => {
}
}
const addStage = () => {
const lastStage = formData.value.retryStages[formData.value.retryStages.length - 1]
const newMaxElapsed = lastStage ? lastStage.maxElapsed + 10 : 30
formData.value.retryStages.push({
maxElapsed: newMaxElapsed,
interval: 500
})
}
const removeStage = (index: number) => {
formData.value.retryStages.splice(index, 1)
}
// 暴露给父组件
defineExpose({ open, close })
</script>
@@ -133,25 +150,65 @@ defineExpose({ open, close })
</div>
</div>
<!-- 重试间隔配置 -->
<div class="form-group">
<label class="form-label">重试间隔策略</label>
<div class="retry-stages">
<div
v-for="(stage, index) in formData.retryStages"
:key="index"
class="retry-stage-row"
>
<span class="stage-label">阶段{{ index + 1 }}:</span>
<div class="stage-inputs">
<div class="stage-input-group">
<input
v-model.number="stage.maxElapsed"
class="form-input stage-input"
type="number"
min="1"
max="75"
step="1"
>
<span class="input-unit">秒内</span>
</div>
<span class="stage-separator">→</span>
<div class="stage-input-group">
<input
v-model.number="stage.interval"
class="form-input stage-input"
type="number"
min="50"
max="5000"
step="10"
>
<span class="input-unit">ms</span>
</div>
</div>
<button
class="btn-icon btn-remove"
title="删除阶段"
@click="removeStage(index)"
>
×
</button>
</div>
<button class="btn btn-ghost btn-sm btn-add-stage" @click="addStage">
+ 添加阶段
</button>
</div>
<div class="form-hint">按经过秒数从小到大排列,每个阶段设置不同的重试频率</div>
</div>
<div class="info-card">
<div class="info-title">💡 抢购策略说明</div>
<ul class="info-list">
<li>抢购窗口:<strong>75 秒</strong>,自动循环重试</li>
<li>前 8 秒高频(180ms),之后逐渐降速</li>
<li>默认:前 8 秒高频(180ms),之后逐渐降速</li>
<li>尝试包数 &gt; 1 时,每轮使用不同的包</li>
</ul>
</div>
<div class="info-card info-card-warn">
<div class="info-title">⚠️ 自动停止条件</div>
<ul class="info-list">
<li><strong>成功</strong> - 抢到商品(含兑换上限)</li>
<li><strong>库存耗尽</strong> - 商品本次活动库存已耗完</li>
<li><strong>未绑定</strong> - 账号未绑定角色</li>
<li><strong>资格不足</strong> - 需要开通精英手册</li>
<li><strong>超时</strong> - 75秒抢购窗口结束</li>
</ul>
</div>
</div>
</div>
<div class="modal-footer">
@@ -415,6 +472,86 @@ defineExpose({ open, close })
font-weight: 600;
}
/* 重试阶段配置 */
.retry-stages {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.retry-stage-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--field);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.stage-label {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
min-width: 42px;
}
.stage-inputs {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.stage-input-group {
display: flex;
align-items: center;
gap: 4px;
}
.stage-input {
width: 70px !important;
text-align: center;
}
.stage-separator {
color: var(--muted);
font-size: 14px;
}
.btn-icon {
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: .15s;
flex-shrink: 0;
}
.btn-remove:hover {
background: var(--danger-soft, #fef2f2);
color: var(--danger, #ef4444);
}
.btn-add-stage {
align-self: flex-start;
margin-top: 4px;
}
.btn-sm {
height: 32px;
padding: 0 12px;
font-size: 12px;
}
/* 动画 */
.modal-enter-active {
transition: all .2s ease;
+18 -2
View File
@@ -1,6 +1,11 @@
import { ref } from 'vue'
import { useToast } from './useToast'
export interface RetryStage {
maxElapsed: number
interval: number
}
export interface Settings {
timeApiUrl: string
traceEnabled: boolean
@@ -8,17 +13,25 @@ export interface Settings {
logLevel: string
concurrency: number
attempts: number
retryStages: RetryStage[]
}
const App = window.go.main.App
const defaultRetryStages: RetryStage[] = [
{ maxElapsed: 8, interval: 130 },
{ maxElapsed: 25, interval: 260 },
{ maxElapsed: 75, interval: 900 }
]
const settings = ref<Settings>({
timeApiUrl: '',
traceEnabled: false,
traceMaxBody: 0,
logLevel: 'info',
concurrency: 80,
attempts: 1
attempts: 1,
retryStages: [...defaultRetryStages]
})
export function useSettings() {
@@ -33,7 +46,10 @@ export function useSettings() {
traceMaxBody: cfg.traceMaxBody || 0,
logLevel: cfg.logLevel || 'info',
concurrency: cfg.concurrency || 80,
attempts: cfg.attempts || 1
attempts: cfg.attempts || 1,
retryStages: cfg.retryStages && cfg.retryStages.length > 0
? cfg.retryStages
: [...defaultRetryStages]
}
} catch (e: any) {
showToast('读取设置失败', e.message, 'error')
+6 -3
View File
@@ -39,7 +39,8 @@
}
.btn-primary:hover {
filter: brightness(1.08);
background: #4d9aff;
border-color: #4d9aff;
}
.btn-danger {
@@ -49,7 +50,8 @@
}
.btn-danger:hover {
filter: brightness(1.08);
background: #ff6b63;
border-color: #ff6b63;
}
.btn-success {
@@ -59,7 +61,8 @@
}
.btn-success:hover {
filter: brightness(1.08);
background: #4fc95f;
border-color: #4fc95f;
}
.btn-cancel {
+13
View File
@@ -81,6 +81,16 @@ func defaultConfig() types.Config {
LogLevel: "info",
Concurrency: constants.DefaultConcurrency,
Attempts: constants.DefaultAttempts,
RetryStages: defaultRetryStages(),
}
}
// defaultRetryStages 返回默认重试间隔阶段配置。
func defaultRetryStages() []types.RetryStage {
return []types.RetryStage{
{MaxElapsed: 8, Interval: 130},
{MaxElapsed: 25, Interval: 260},
{MaxElapsed: 75, Interval: 900},
}
}
@@ -118,6 +128,9 @@ func LoadConfig(path string) types.Config {
if cfg.LogLevel == "" {
cfg.LogLevel = "info"
}
if len(cfg.RetryStages) == 0 {
cfg.RetryStages = defaultRetryStages()
}
logger.LogEvent("配置", "success", "配置加载完成: %s", path)
return cfg
}
+22 -12
View File
@@ -46,6 +46,8 @@ type Scheduler struct {
concurrency int // 抢购并发数
attempts int // 单号重试次数
retryStages []types.RetryStage // 重试间隔阶段配置
accountsMu sync.RWMutex
accounts []types.Account
@@ -65,7 +67,7 @@ type Scheduler struct {
}
// New 创建抢购调度器。
func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, concurrency, attempts int) *Scheduler {
func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, concurrency, attempts int, retryStages []types.RetryStage) *Scheduler {
if concurrency <= 0 {
concurrency = constants.DefaultConcurrency
}
@@ -78,18 +80,22 @@ func New(cli *client.DouyuClient, paths types.AppPaths, font *truetype.Font, con
font: font,
concurrency: concurrency,
attempts: attempts,
retryStages: retryStages,
results: make(map[string]string),
}
}
// UpdateSettings 运行时更新并发数和重试次数。
func (s *Scheduler) UpdateSettings(concurrency, attempts int) {
// UpdateSettings 运行时更新并发数、重试次数和重试间隔。
func (s *Scheduler) UpdateSettings(concurrency, attempts int, retryStages []types.RetryStage) {
if concurrency > 0 {
s.concurrency = concurrency
}
if attempts > 0 {
s.attempts = attempts
}
if len(retryStages) > 0 {
s.retryStages = retryStages
}
}
// LoadAccounts 从账号文件加载账号。
@@ -300,7 +306,7 @@ func (s *Scheduler) RunNow(accountProducts map[string]string) {
break
}
retryInterval := seckillRetryInterval(time.Since(startTime))
retryInterval := s.getRetryInterval(time.Since(startTime))
if retryInterval > 0 && time.Since(startTime)+retryInterval < seckillMaxDuration {
time.Sleep(jitterDuration(retryInterval))
}
@@ -421,15 +427,19 @@ func (s *Scheduler) executeRound(attempts []types.ExchangeAttempt) roundOutcome
return outcome
}
func seckillRetryInterval(elapsed time.Duration) time.Duration {
switch {
case elapsed < 8*time.Second:
return 180 * time.Millisecond
case elapsed < 25*time.Second:
return 350 * time.Millisecond
default:
return 900 * time.Millisecond
// getRetryInterval 根据配置的重试阶段计算当前应该使用的重试间隔。
func (s *Scheduler) getRetryInterval(elapsed time.Duration) time.Duration {
elapsedSec := int(elapsed.Seconds())
// 使用配置的阶段
for _, stage := range s.retryStages {
if stage.MaxElapsed > 0 && elapsedSec < stage.MaxElapsed && stage.Interval > 0 {
return time.Duration(stage.Interval) * time.Millisecond
}
}
// 默认值(如果没有配置或配置无效)
return 900 * time.Millisecond
}
func jitterDuration(base time.Duration) time.Duration {
+13 -6
View File
@@ -7,12 +7,19 @@ import (
// Config 应用配置。
type Config struct {
TimeAPIURL string `json:"timeApiUrl"`
TraceEnabled bool `json:"traceEnabled"`
TraceMaxBody int `json:"traceMaxBody"`
LogLevel string `json:"logLevel"` // debug / info / warn / error
Concurrency int `json:"concurrency"` // 抢购并发数
Attempts int `json:"attempts"` // 单号重试次数
TimeAPIURL string `json:"timeApiUrl"`
TraceEnabled bool `json:"traceEnabled"`
TraceMaxBody int `json:"traceMaxBody"`
LogLevel string `json:"logLevel"` // debug / info / warn / error
Concurrency int `json:"concurrency"` // 抢购并发数
Attempts int `json:"attempts"` // 单号重试次数
RetryStages []RetryStage `json:"retryStages"` // 重试间隔阶段配置
}
// RetryStage 重试间隔阶段配置。
type RetryStage struct {
MaxElapsed int `json:"maxElapsed"` // 经过秒数上限
Interval int `json:"interval"` // 重试间隔(ms)
}
// AppPaths 运行时路径。