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 }"