feat: improve xiaodian seckill flow
This commit is contained in:
@@ -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 }"
|
||||
|
||||
Reference in New Issue
Block a user