feat: 自定义重试间隔策略 + 修复按钮hover样式
- 新增重试间隔阶段配置,支持自定义时间分界点和频率 - 默认配置: 0-8秒=130ms, 8-25秒=260ms, 25-75秒=900ms - 设置面板添加可增删的阶段配置 UI - 修复按钮 hover 时文字变白的问题
This commit is contained in:
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>尝试包数 > 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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 运行时路径。
|
||||
|
||||
Reference in New Issue
Block a user