From 02d38d9ede407f3c22b7c79e339b586064a238c6 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 9 Aug 2026 18:46:39 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=95=86=E5=93=81=E7=9B=91=E6=8E=A7=E5=BA=93=E5=AD=98=E6=8A=A2?= =?UTF-8?q?=E8=B4=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/console/ControlStrip.vue | 79 +++++++++++++------ frontend/src/composables/useAccounts.ts | 9 +++ frontend/src/composables/useProducts.ts | 41 +++++++--- frontend/src/composables/useScheduler.ts | 8 +- internal/app/app_products.go | 40 +++++++++- internal/app/app_products_test.go | 19 +++++ 6 files changed, 154 insertions(+), 42 deletions(-) create mode 100644 internal/app/app_products_test.go diff --git a/frontend/src/components/console/ControlStrip.vue b/frontend/src/components/console/ControlStrip.vue index 2fb82aa..7570a04 100644 --- a/frontend/src/components/console/ControlStrip.vue +++ b/frontend/src/components/console/ControlStrip.vue @@ -14,11 +14,12 @@ const { queryAllScore, queryAllXiaodianAssets, queryAllXiaodianRoles, - getXiaodianBindQRCode + getXiaodianBindQRCode, + buildAccountProducts } = useAccounts() const { currentActivity } = useActivity() const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian') -const { products, selectedProductId, selectedProductName, loadProducts, selectProduct } = useProducts() +const { products, selectedProductId, selectedProductName, loadProducts, loadProductsForAccounts, selectProduct } = useProducts() const { showToast } = useToast() const fixedKeyword = ref(localStorage.getItem('xiaodianFixedKeyword') || localStorage.getItem('xiaodianWatchKeyword') || '') const fixedAttempts = ref(Number(localStorage.getItem('xiaodianFixedAttempts') || 3)) @@ -34,8 +35,14 @@ const stockWatchIntervalSec = ref(Number(localStorage.getItem('xiaodianStockWatc const stockWatchAttempts = ref(Number(localStorage.getItem('xiaodianStockWatchAttempts') || 3)) const stockWatchDelayMs = ref(Number(localStorage.getItem('xiaodianStockWatchDelayMs') || 500)) const stockWatching = ref(false) -const stockWatchTarget = ref<{ id: string; name: string } | null>(null) -const stockWatchTargetName = computed(() => stockWatchTarget.value?.name || selectedProductName.value || '请先在左侧选择商品') +const stockWatchAccountProducts = ref>({}) +const stockWatchTargetName = computed(() => { + const assignments = stockWatchAccountProducts.value + const accountCount = Object.keys(assignments).length + if (accountCount === 0) return selectedProductName.value || '请先为账号选择商品' + const names = [...new Set(Object.values(assignments).map(id => products.value.find(item => item.commodityId === id)?.commodityName || id))] + return `${names.join('、')} · ${accountCount} 个账号` +}) let stockWatchTimer: number | null = null let stockWatchStartTimer: number | null = null let stockRefreshing = false @@ -129,6 +136,7 @@ const stopStockWatch = () => { stockWatchStartTimer = null } stockRefreshing = false + stockWatchAccountProducts.value = {} } const beginKeywordWatch = async () => { @@ -189,32 +197,51 @@ const toggleKeywordWatch = () => { } const tryStartStockBuy = async () => { - const target = stockWatchTarget.value - if (!target) return false + const assignments = stockWatchAccountProducts.value + if (Object.keys(assignments).length === 0) return false if (scheduleActive.value || seckillRunning.value) return false - const product = products.value.find(item => item.commodityId === target.id) - // count 为 -1 时代表库存未公开,不能把未知状态误判为可抢。 - if (!product || product.count <= 0) return false + const productsByID = new Map(products.value.map(product => [product.commodityId, product])) + const readyAccountProducts: Record = {} + const readyProducts = new Map() + Object.entries(assignments).forEach(([accountName, productID]) => { + const product = productsByID.get(productID) + // count 为 -1 时代表库存未公开,不能把未知状态误判为可抢。 + if (product && product.count > 0) { + readyAccountProducts[accountName] = productID + readyProducts.set(productID, product) + } + }) + if (Object.keys(readyAccountProducts).length === 0) return false - stopStockWatch() - selectProduct(product.commodityId, product.commodityName) - const stockText = product.stockLabel || `库存 ${product.count}` - showToast('检测到库存', `${product.commodityName} ${stockText},开始固定抢购`, 'info', 3000) - await startFixed(stockWatchAttempts.value, stockWatchDelayMs.value) + const productText = [...readyProducts.values()] + .map(product => `${product.commodityName} ${product.stockLabel || `库存 ${product.count}`}`) + .join(';') + const started = await startFixed(stockWatchAttempts.value, stockWatchDelayMs.value, readyAccountProducts) + if (!started) return false + + Object.keys(readyAccountProducts).forEach(accountName => { + delete stockWatchAccountProducts.value[accountName] + }) + if (Object.keys(stockWatchAccountProducts.value).length === 0) { + stopStockWatch() + } + showToast('检测到库存', `${productText},已启动 ${Object.keys(readyAccountProducts).length} 个账号抢购`, 'info', 3000) return true } const beginStockWatch = async () => { - if (!stockWatching.value || !isXiaodian.value || !stockWatchTarget.value) return - await loadProducts(true) - if (await tryStartStockBuy()) return + if (!stockWatching.value || !isXiaodian.value || Object.keys(stockWatchAccountProducts.value).length === 0) return + const accountNames = () => Object.keys(stockWatchAccountProducts.value) + await loadProductsForAccounts(accountNames(), true) + await tryStartStockBuy() + if (!stockWatching.value) return const interval = Math.max(1, Math.min(600, Number(stockWatchIntervalSec.value) || 3)) * 1000 stockWatchTimer = window.setInterval(async () => { if (!stockWatching.value || scheduleActive.value || seckillRunning.value || stockRefreshing) return stockRefreshing = true try { - await loadProducts(false) + await loadProductsForAccounts(accountNames(), false) await tryStartStockBuy() } finally { stockRefreshing = false @@ -227,22 +254,28 @@ const toggleStockWatch = () => { stopStockWatch() return } - const product = products.value.find(item => item.commodityId === selectedProductId.value) - if (!product) { - showToast('请选择商品', '请先在左侧选择要监控库存的商品', 'warn') + const accountProducts = buildAccountProducts(selectedProductId.value || '') + if (!accountProducts) { + return + } + + const missingProductIDs = [...new Set(Object.values(accountProducts))] + .filter(productID => !products.value.some(product => product.commodityId === productID)) + if (missingProductIDs.length > 0) { + showToast('商品未加载', '请刷新商品后再启动库存监控', 'warn') return } saveStockWatchConfig() stopKeywordWatch() - stockWatchTarget.value = { id: product.commodityId, name: product.commodityName } + stockWatchAccountProducts.value = { ...accountProducts } stockWatching.value = true const [hours, minutes, seconds] = stockWatchStartTime.value.split(':').map(Number) const target = new Date() target.setHours(hours || 0, minutes || 0, seconds || 0, 0) const waitMs = Math.max(0, target.getTime() - Date.now()) if (waitMs > 0) { - showToast('库存监控已设置', `${stockWatchTarget.value.name} 将在 ${stockWatchStartTime.value} 开始监控`, 'info', 3000) + showToast('库存监控已设置', `${stockWatchTargetName.value} 将在 ${stockWatchStartTime.value} 开始监控`, 'info', 3000) stockWatchStartTimer = window.setTimeout(() => { stockWatchStartTimer = null void beginStockWatch() diff --git a/frontend/src/composables/useAccounts.ts b/frontend/src/composables/useAccounts.ts index c889bdc..40dad45 100644 --- a/frontend/src/composables/useAccounts.ts +++ b/frontend/src/composables/useAccounts.ts @@ -303,6 +303,14 @@ export function useAccounts() { markSelectedStatus({ pending: true, message }) } + const markAccountNamesPending = (accountNames: string[], message = '抢购中,等待后端结果') => { + const next: Record = {} + accountNames.forEach(name => { + next[name] = { pending: true, message } + }) + mergeStatuses(next) + } + const setAccountProduct = (accountName: string, productId: string) => { if (productId) { accountProductMap.value[accountName] = productId @@ -377,6 +385,7 @@ export function useAccounts() { setStatus, markSelectedStatus, markSelectedPending, + markAccountNamesPending, setAccountProduct, getAccountProduct, selectedAccountNames, diff --git a/frontend/src/composables/useProducts.ts b/frontend/src/composables/useProducts.ts index a4c1927..bdfcbfd 100644 --- a/frontend/src/composables/useProducts.ts +++ b/frontend/src/composables/useProducts.ts @@ -1,7 +1,7 @@ import { ref, computed } from 'vue' import { useToast } from './useToast' import { useActivity } from './useActivity' -import { LoadProducts } from '@wails/go/app/App' +import { LoadProducts, LoadProductsForAccounts } from '@wails/go/app/App' export interface Product { commodityId: string @@ -63,21 +63,39 @@ export function useProducts() { products.value.find(p => p.commodityId === selectedProductId.value) ) + const applyProductPayload = (data: ProductPayload, showToastMsg: boolean) => { + products.value = Array.isArray(data.products) ? data.products : [] + productInfo.value = data + + if (selectedProductId.value && !products.value.some(p => p.commodityId === selectedProductId.value)) { + clearSelection() + } + + if (showToastMsg) { + showToast('商品已刷新', `共 ${products.value.length} 个`, 'success') + } + } + const loadProducts = async (showToastMsg = true) => { loading.value = true try { const data = await LoadProducts(true) - products.value = Array.isArray(data.products) ? data.products : [] - productInfo.value = data + applyProductPayload(data, showToastMsg) + } catch (e: any) { + products.value = [] + productInfo.value = null + clearSelection() + showToast('商品刷新失败', e.message, 'error') + } finally { + loading.value = false + } + } - // 如果选中的商品不存在了,清空选择 - if (selectedProductId.value && !products.value.some(p => p.commodityId === selectedProductId.value)) { - clearSelection() - } - - if (showToastMsg) { - showToast('商品已刷新', `共 ${products.value.length} 个`, 'success') - } + const loadProductsForAccounts = async (accountNames: string[], showToastMsg = true) => { + loading.value = true + try { + const data = await LoadProductsForAccounts(accountNames) + applyProductPayload(data, showToastMsg) } catch (e: any) { products.value = [] productInfo.value = null @@ -134,6 +152,7 @@ export function useProducts() { loading, xiaodianTab, loadProducts, + loadProductsForAccounts, selectProduct, clearSelection, setSearch, diff --git a/frontend/src/composables/useScheduler.ts b/frontend/src/composables/useScheduler.ts index 2f04368..c77065a 100644 --- a/frontend/src/composables/useScheduler.ts +++ b/frontend/src/composables/useScheduler.ts @@ -17,7 +17,7 @@ let unsubscribeSeckillState: (() => void) | null = null export function useScheduler() { const { showToast } = useToast() - const { resetStatus, buildAccountProducts, markSelectedPending, markSelectedStatus, queryXiaodianAssets } = useAccounts() + const { resetStatus, buildAccountProducts, markSelectedPending, markAccountNamesPending, markSelectedStatus, queryXiaodianAssets } = useAccounts() const { selectedProductId, selectedProductName } = useProducts() const { currentActivity } = useActivity() const { addLocalLog, clearLocalResults } = useLogs() @@ -161,12 +161,12 @@ export function useScheduler() { } } - const startFixed = async (attempts: number, delayMs: number) => { - const ap = buildAccountProducts(selectedProductId.value || '', false) + const startFixed = async (attempts: number, delayMs: number, accountProducts?: Record) => { + const ap = accountProducts || buildAccountProducts(selectedProductId.value || '', false) if (!ap) return false resetStatus() clearLocalResults() - markSelectedPending('固定抢购中,等待后端结果') + markAccountNamesPending(Object.keys(ap), '固定抢购中,等待后端结果') setRunState('固定抢购已提交') try { const d = await StartFixedSeckill({ accountProducts: ap, attempts, delayMs }) diff --git a/internal/app/app_products.go b/internal/app/app_products.go index 41d7541..b3bb7d5 100644 --- a/internal/app/app_products.go +++ b/internal/app/app_products.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "math/rand/v2" "strings" "time" @@ -13,16 +14,23 @@ import ( // LoadProducts 刷新商品列表。refresh=true 时从斗鱼实时拉取。 func (a *App) LoadProducts(refresh bool) types.ProductPayload { + return a.loadProducts(refresh, nil) +} + +// LoadProductsForAccounts 使用指定账号随机刷新和平小店商品。 +// 库存监控仅使用参与监控的账号,避免无关账号承担角色鉴权请求。 +func (a *App) LoadProductsForAccounts(accountNames []string) types.ProductPayload { + return a.loadProducts(true, accountNames) +} + +func (a *App) loadProducts(refresh bool, accountNames []string) types.ProductPayload { if refresh { cfg := a.getCfg() options := types.XiaodianProductListOptions{PageSize: cfg.XiaodianPageSize} var products []types.Product err := fmt.Errorf("没有可用账号 Cookie") - accounts := a.scheduler.AccountSnapshot() + accounts := productRefreshAccounts(a.scheduler.AccountSnapshot(), accountNames) for _, account := range accounts { - if strings.TrimSpace(account.Cookie) == "" { - continue - } products, err = a.getClient().Products(account.Cookie, options) if err == nil { break @@ -41,6 +49,30 @@ func (a *App) LoadProducts(refresh bool) types.ProductPayload { return a.productPayload("cache") } +func productRefreshAccounts(accounts []types.Account, preferredNames []string) []types.Account { + preferred := make(map[string]struct{}, len(preferredNames)) + for _, name := range preferredNames { + preferred[strings.TrimSpace(name)] = struct{}{} + } + + filtered := make([]types.Account, 0, len(accounts)) + for _, account := range accounts { + if strings.TrimSpace(account.Cookie) == "" { + continue + } + if len(preferred) > 0 { + if _, ok := preferred[account.Name]; !ok { + continue + } + } + filtered = append(filtered, account) + } + rand.Shuffle(len(filtered), func(i, j int) { + filtered[i], filtered[j] = filtered[j], filtered[i] + }) + return filtered +} + // GetAccounts 返回账号列表。 func (a *App) GetAccounts() []types.AccountView { accounts := a.scheduler.AccountSnapshot() diff --git a/internal/app/app_products_test.go b/internal/app/app_products_test.go new file mode 100644 index 0000000..e29b880 --- /dev/null +++ b/internal/app/app_products_test.go @@ -0,0 +1,19 @@ +package app + +import ( + "testing" + + "douyu_go/internal/types" +) + +func TestProductRefreshAccountsUsesOnlyPreferredAccounts(t *testing.T) { + accounts := []types.Account{ + {Name: "a", Cookie: "cookie-a"}, + {Name: "b", Cookie: ""}, + {Name: "c", Cookie: "cookie-c"}, + } + result := productRefreshAccounts(accounts, []string{"c", "b"}) + if len(result) != 1 || result[0].Name != "c" { + t.Fatalf("productRefreshAccounts() = %#v", result) + } +}