feat: improve xiaodian seckill flow

This commit is contained in:
yml2213
2026-08-08 23:04:40 +08:00
parent 16972b41cd
commit dde2b43d82
19 changed files with 871 additions and 64 deletions
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useAccounts } from '@/composables/useAccounts'
import { useProducts } from '@/composables/useProducts'
import type { Product } from '@/composables/useProducts'
import { useActivity } from '@/composables/useActivity'
const {
@@ -11,17 +12,51 @@ const {
toggleAccount,
toggleAll,
getScore,
getXiaodianAssets
getXiaodianAssets,
getAccountProduct,
setAccountProduct
} = useAccounts()
const { selectedProductName } = useProducts()
const { products, selectedProductName } = useProducts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const productFilterMap = ref<Record<string, string>>({})
const allSelected = computed(() =>
accounts.value.length > 0 && selectedIndices.value.size === accounts.value.length
)
const handleProductFilter = (accountName: string, event: Event) => {
productFilterMap.value[accountName] = (event.target as HTMLInputElement).value
}
const handleAccountProductChange = (accountName: string, event: Event) => {
setAccountProduct(accountName, (event.target as HTMLSelectElement).value)
}
const productMatches = (product: Product, keyword: string) => {
if (!keyword) return true
const text = [
product.commodityName,
product.commodityId,
product.score,
product.payLabel || '',
product.payType || ''
].join(' ').toLowerCase()
return text.includes(keyword)
}
const filteredAccountProducts = (accountName: string) => {
const keyword = (productFilterMap.value[accountName] || '').trim().toLowerCase()
const selectedId = getAccountProduct(accountName)
const matched = products.value.filter(product => productMatches(product, keyword)).slice(0, 80)
if (selectedId && !matched.some(product => product.commodityId === selectedId)) {
const selected = products.value.find(product => product.commodityId === selectedId)
if (selected) return [selected, ...matched]
}
return matched
}
// 状态信息计算
const getStatusInfo = (name: string, selected: boolean) => {
const st = statusMap.value[name]
@@ -121,7 +156,37 @@ const getResultMessage = (name: string) => {
<span v-else class="status-badge status-fail">缺失</span>
</td>
<td>
<span v-if="selectedProductName" class="prod-tag">
<div
v-if="isXiaodian"
class="account-product-picker"
>
<input
class="account-product-filter"
type="text"
placeholder="筛选商品"
:value="productFilterMap[account.name] || ''"
@click.stop
@input="handleProductFilter(account.name, $event)"
>
<select
class="account-product-select"
:value="getAccountProduct(account.name)"
@click.stop
@change="handleAccountProductChange(account.name, $event)"
>
<option value="">
跟随全局:{{ selectedProductName || '未选择' }}
</option>
<option
v-for="product in filteredAccountProducts(account.name)"
:key="product.commodityId"
:value="product.commodityId"
>
{{ product.commodityName }} · {{ product.score }}{{ product.payLabel || '点券' }}
</option>
</select>
</div>
<span v-else-if="selectedProductName" class="prod-tag">
{{ selectedProductName }}
</span>
<span v-else>-</span>
@@ -307,6 +372,42 @@ tbody tr:hover {
font-size: 11.5px;
}
.account-product-picker {
width: 240px;
max-width: 100%;
display: grid;
grid-template-columns: minmax(70px, .8fr) minmax(120px, 1.2fr);
gap: 5px;
}
.account-product-filter,
.account-product-select {
width: 220px;
max-width: 100%;
height: 30px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text);
font-size: 11.5px;
}
.account-product-filter,
.account-product-select {
width: 100%;
}
.account-product-filter::placeholder {
color: var(--muted);
}
.account-product-filter:focus,
.account-product-select:focus {
outline: none;
border-color: var(--accent);
}
@media (max-width: 1180px) {
.table-wrap {
max-height: 520px;
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useScheduler } from '@/composables/useScheduler'
import { useAccounts } from '@/composables/useAccounts'
import { useActivity } from '@/composables/useActivity'
import { useProducts } from '@/composables/useProducts'
import { useToast } from '@/composables/useToast'
const { scheduleActive, startNow, startFixed, startSchedule, cancelSchedule, restoreSchedule } = useScheduler()
const { scheduleActive, seckillRunning, startNow, stopSeckill, startFixed, startSchedule, cancelSchedule, restoreSchedule, subscribeSeckillState, unsubscribeSchedulerEvents } = useScheduler()
const { reloadAccounts, queryAllScore, queryAllXiaodianAssets } = useAccounts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
@@ -26,8 +26,20 @@ let keywordRefreshing = false
const { savedTime } = restoreSchedule()
const targetTime = ref(savedTime || '18:00:00')
onMounted(() => {
subscribeSeckillState()
})
const handleMainAction = async () => {
if (seckillRunning.value) {
await stopSeckill()
return
}
await startNow(fixedAttempts.value, fixedDelayMs.value)
}
const handleSchedule = () => {
startSchedule(targetTime.value)
startSchedule(targetTime.value, fixedAttempts.value, fixedDelayMs.value)
}
const queryAssets = () => {
@@ -50,7 +62,7 @@ const saveFixedConfig = () => {
const startFixedBuy = () => {
saveFixedConfig()
if (keywordWatching.value) stopKeywordWatch()
startFixed(fixedAttempts.value, fixedDelayMs.value)
void startFixed(fixedAttempts.value, fixedDelayMs.value)
}
const stopKeywordWatch = () => {
@@ -133,6 +145,7 @@ watch(isXiaodian, (enabled) => {
onUnmounted(() => {
stopKeywordWatch()
unsubscribeSchedulerEvents()
})
</script>
@@ -141,7 +154,13 @@ onUnmounted(() => {
<div class="control-strip">
<div class="action-row">
<div class="action-group">
<button class="btn btn-danger" @click="startNow">立即抢购</button>
<button
class="btn btn-danger"
:class="{ running: seckillRunning }"
@click="handleMainAction"
>
{{ seckillRunning ? '停止抢购' : '立即抢购' }}
</button>
<button
v-if="!scheduleActive"
class="btn btn-success"
@@ -6,7 +6,10 @@ import { useActivity } from '@/composables/useActivity'
const { selectedProductId, selectedProductName, selectedProduct } = useProducts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const priceLabel = computed(() => isXiaodian.value ? '点券' : '积分')
const priceLabel = computed(() => {
if (!isXiaodian.value) return '积分'
return selectedProduct.value?.payLabel || '点券'
})
const stockText = computed(() => {
if (!selectedProduct.value) return ''
if (isXiaodian.value && selectedProduct.value.count < 0) return '库存未公开'
@@ -7,7 +7,7 @@ import { useActivity } from '@/composables/useActivity'
const { filteredProducts, selectedProductId, selectProduct, searchQuery, xiaodianTab } = useProducts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const priceLabel = computed(() => isXiaodian.value ? '点券' : '积分')
const priceLabel = (product: { payLabel?: string }) => isXiaodian.value ? (product.payLabel || '点券') : '积分'
const stockText = (count: number) => isXiaodian.value && count < 0 ? '库存未公开' : `库存 ${count}`
const parentRef = ref<HTMLElement | null>(null)
@@ -76,8 +76,8 @@ watch(xiaodianTab, () => {
<div class="pmeta">
<span
class="chip chip-score"
:title="`${filteredProducts[item.index].score} ${priceLabel}`"
>{{ filteredProducts[item.index].score }} {{ priceLabel }}</span>
:title="`${filteredProducts[item.index].score} ${priceLabel(filteredProducts[item.index])}`"
>{{ filteredProducts[item.index].score }} {{ priceLabel(filteredProducts[item.index]) }}</span>
<span
class="chip chip-stock"
:class="{ low: filteredProducts[item.index].count >= 0 && filteredProducts[item.index].count < 500 }"
+34 -7
View File
@@ -25,6 +25,7 @@ const accounts = ref<Account[]>([])
const selectedIndices = ref<Set<number>>(new Set())
const statusMap = ref<Record<string, AccountStatus | boolean>>({})
const scoreMap = ref<Record<string, string>>({})
const accountProductMap = ref<Record<string, string>>({})
export interface XiaodianAssets {
success: boolean
coupons: number
@@ -93,6 +94,12 @@ export function useAccounts() {
statusMap.value = JSON.parse(savedStatus)
} catch {}
}
const savedProducts = localStorage.getItem('accountProducts')
if (savedProducts) {
try {
accountProductMap.value = JSON.parse(savedProducts)
} catch {}
}
}
const queryAllScore = async () => {
@@ -170,26 +177,43 @@ export function useAccounts() {
markSelectedStatus({ pending: true, message })
}
const buildAccountProducts = (productId: string): Record<string, string> | null => {
const setAccountProduct = (accountName: string, productId: string) => {
if (productId) {
accountProductMap.value[accountName] = productId
} else {
delete accountProductMap.value[accountName]
}
localStorage.setItem('accountProducts', JSON.stringify(accountProductMap.value))
}
const getAccountProduct = (accountName: string): string => {
return accountProductMap.value[accountName] || ''
}
const buildAccountProducts = (productId: string, respectOverrides = true): Record<string, string> | null => {
if (selectedIndices.value.size === 0) {
showToast('提示', '请勾选要抢购的账号', 'warn')
return null
}
if (!productId) {
showToast('提示', '请先选择商品', 'warn')
return null
}
const ap: Record<string, string> = {}
selectedIndices.value.forEach(i => {
const account = accounts.value[i]
if (account) {
ap[account.name] = productId
const selectedProduct = respectOverrides ? getAccountProduct(account.name) : ''
const assignedProduct = selectedProduct || productId
if (assignedProduct) {
ap[account.name] = assignedProduct
}
}
})
if (Object.keys(ap).length === 0) {
showToast('提示', '没有有效账号可提交', 'warn')
showToast('提示', '请先选择商品,或为每个账号分配商品', 'warn')
return null
}
if (Object.keys(ap).length !== selectedIndices.value.size) {
showToast('提示', '还有已选账号没有商品', 'warn')
return null
}
return ap
@@ -201,6 +225,7 @@ export function useAccounts() {
statusMap,
scoreMap,
xiaodianAssetsMap,
accountProductMap,
loadAccounts,
reloadAccounts,
toggleAccount,
@@ -217,6 +242,8 @@ export function useAccounts() {
setStatus,
markSelectedStatus,
markSelectedPending,
setAccountProduct,
getAccountProduct,
selectedAccountNames,
buildAccountProducts
}
+2
View File
@@ -8,6 +8,8 @@ export interface Product {
commodityName: string
category: string
score: number
payType?: number
payLabel?: string
count: number
pic: string
status: number
+71 -6
View File
@@ -3,24 +3,63 @@ import { useToast } from './useToast'
import { useAccounts } from './useAccounts'
import { useProducts } from './useProducts'
import { useLogs } from './useLogs'
import { CancelSchedule, SetSchedule, StartFixedSeckill, StartSeckill } from '@wails/go/app/App'
import { useActivity } from './useActivity'
import { CancelSchedule, GetSeckillStatus, SetSchedule, StartFixedSeckill, StartSeckill, StartXiaodianSeckill, StopSeckill } from '@wails/go/app/App'
import { EventsOn } from '@wails/runtime/runtime'
const scheduleActive = ref(false)
const seckillRunning = ref(false)
const countdownSeconds = ref(0)
const countdownTimer = ref<number | null>(null)
const runState = ref('等待操作')
let unsubscribeSeckillState: (() => void) | null = null
export function useScheduler() {
const { showToast } = useToast()
const { resetStatus, buildAccountProducts, markSelectedPending, markSelectedStatus } = useAccounts()
const { selectedProductId, selectedProductName } = useProducts()
const { currentActivity } = useActivity()
const { addLocalLog, clearLocalResults } = useLogs()
const setRunState = (text: string) => {
runState.value = text || '等待操作'
}
const startNow = async () => {
const refreshRunningState = async () => {
try {
const d = await GetSeckillStatus()
seckillRunning.value = !!d?.success
} catch {
seckillRunning.value = false
}
}
const applySeckillState = (state: string) => {
seckillRunning.value = state === 'running' || state === 'stopping'
if (state === 'stopping') {
setRunState('正在停止抢购')
}
}
const subscribeSeckillState = () => {
if (unsubscribeSeckillState) {
return unsubscribeSeckillState
}
unsubscribeSeckillState = EventsOn('seckill:state', (state: string) => {
applySeckillState(state)
})
void refreshRunningState()
return unsubscribeSeckillState
}
const unsubscribeSchedulerEvents = () => {
if (unsubscribeSeckillState) {
unsubscribeSeckillState()
unsubscribeSeckillState = null
}
}
const startNow = async (attempts = 0, delayMs = 0) => {
const ap = buildAccountProducts(selectedProductId.value || '')
if (!ap) return
@@ -32,10 +71,13 @@ export function useScheduler() {
showToast('抢购中', `${Object.keys(ap).length} 个账号正在抢购`, 'info', 3000)
try {
const d = await StartSeckill(ap)
const d = currentActivity.value?.id === 'xiaodian'
? await StartXiaodianSeckill({ accountProducts: ap, attempts, delayMs })
: await StartSeckill(ap)
if (d?.success === false) {
throw new Error(d.error || '提交失败')
}
seckillRunning.value = true
setRunState(d.msg || '已提交')
addLocalLog(d.msg || '抢购已提交', 'i')
} catch (e: any) {
@@ -46,7 +88,7 @@ export function useScheduler() {
}
}
const startSchedule = async (targetTime: string) => {
const startSchedule = async (targetTime: string, attempts = 0, delayMs = 0) => {
const ap = buildAccountProducts(selectedProductId.value || '')
if (!ap) return
@@ -76,7 +118,9 @@ export function useScheduler() {
try {
const d = await SetSchedule({
accountProducts: ap,
targetTime: fullTime
targetTime: fullTime,
attempts,
delayMs
})
if (d?.success === false) {
throw new Error('定时设置失败')
@@ -104,7 +148,7 @@ export function useScheduler() {
}
const startFixed = async (attempts: number, delayMs: number) => {
const ap = buildAccountProducts(selectedProductId.value || '')
const ap = buildAccountProducts(selectedProductId.value || '', false)
if (!ap) return false
resetStatus()
clearLocalResults()
@@ -113,6 +157,7 @@ export function useScheduler() {
try {
const d = await StartFixedSeckill({ accountProducts: ap, attempts, delayMs })
if (d?.success === false) throw new Error(d.error || '提交失败')
seckillRunning.value = true
setRunState(d.msg || '固定抢购已启动')
addLocalLog(`固定抢购: ${Object.keys(ap).length} 个账号,${attempts} 次,间隔 ${delayMs}ms`, 'i')
showToast('固定抢购已启动', `${Object.keys(ap).length} 个账号`, 'info', 3000)
@@ -138,6 +183,22 @@ export function useScheduler() {
showToast('定时已停止', '抢购任务已取消', 'warn')
}
const stopSeckill = async () => {
try {
const d = await StopSeckill()
if (d?.success === false) {
throw new Error(d.error || '停止失败')
}
setRunState(d.msg || '已停止抢购')
showToast('已停止抢购', d.msg || '', 'warn')
} catch (e: any) {
setRunState('停止失败')
showToast('停止失败', e?.message || '', 'error')
} finally {
seckillRunning.value = false
}
}
const startCountdown = (sec: number) => {
stopCountdown()
const endTime = Date.now() + sec * 1000
@@ -177,10 +238,14 @@ export function useScheduler() {
return {
scheduleActive,
seckillRunning,
countdownSeconds,
runState,
setRunState,
subscribeSeckillState,
unsubscribeSchedulerEvents,
startNow,
stopSeckill,
startFixed,
startSchedule,
cancelSchedule,