新增库存监控并优化开发日志

This commit is contained in:
yml2213
2026-08-09 14:06:55 +08:00
parent 8cf29598a5
commit 87cb1dc3f0
5 changed files with 223 additions and 28 deletions
+165 -24
View File
@@ -18,7 +18,7 @@ const {
} = useAccounts()
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const { products, selectedProductId, loadProducts, selectProduct } = useProducts()
const { products, selectedProductId, selectedProductName, loadProducts, selectProduct } = useProducts()
const { showToast } = useToast()
const fixedKeyword = ref(localStorage.getItem('xiaodianFixedKeyword') || localStorage.getItem('xiaodianWatchKeyword') || '')
const fixedAttempts = ref(Number(localStorage.getItem('xiaodianFixedAttempts') || 3))
@@ -29,6 +29,16 @@ const keywordWatching = ref(false)
let keywordTimer: number | null = null
let keywordStartTimer: number | null = null
let keywordRefreshing = false
const stockWatchStartTime = ref(localStorage.getItem('xiaodianStockWatchStartTime') || '00:00:00')
const stockWatchIntervalSec = ref(Number(localStorage.getItem('xiaodianStockWatchIntervalSec') || 3))
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 || '请先在左侧选择商品')
let stockWatchTimer: number | null = null
let stockWatchStartTimer: number | null = null
let stockRefreshing = false
// 从本地缓存恢复定时设置
const { savedTime } = restoreSchedule()
@@ -81,9 +91,17 @@ const saveFixedConfig = () => {
localStorage.setItem('xiaodianWatchIntervalSec', String(watchIntervalSec.value))
}
const saveStockWatchConfig = () => {
localStorage.setItem('xiaodianStockWatchStartTime', stockWatchStartTime.value)
localStorage.setItem('xiaodianStockWatchIntervalSec', String(stockWatchIntervalSec.value))
localStorage.setItem('xiaodianStockWatchAttempts', String(stockWatchAttempts.value))
localStorage.setItem('xiaodianStockWatchDelayMs', String(stockWatchDelayMs.value))
}
const startFixedBuy = () => {
saveFixedConfig()
if (keywordWatching.value) stopKeywordWatch()
stopKeywordWatch()
stopStockWatch()
void startFixed(fixedAttempts.value, fixedDelayMs.value)
}
@@ -100,12 +118,25 @@ const stopKeywordWatch = () => {
keywordRefreshing = false
}
const stopStockWatch = () => {
stockWatching.value = false
if (stockWatchTimer) {
clearInterval(stockWatchTimer)
stockWatchTimer = null
}
if (stockWatchStartTimer) {
clearTimeout(stockWatchStartTimer)
stockWatchStartTimer = null
}
stockRefreshing = false
}
const beginKeywordWatch = async () => {
if (!keywordWatching.value || !isXiaodian.value) return
await loadProducts(true)
const keyword = fixedKeyword.value.trim().toLowerCase()
const hit = products.value.find(p => p.commodityName.toLowerCase().includes(keyword))
if (hit) {
if (hit && !scheduleActive.value && !seckillRunning.value) {
stopKeywordWatch()
selectProduct(hit.commodityId, hit.commodityName)
await startFixed(fixedAttempts.value, fixedDelayMs.value)
@@ -113,7 +144,7 @@ const beginKeywordWatch = async () => {
}
const interval = Math.max(1, Math.min(600, Number(watchIntervalSec.value) || 3)) * 1000
keywordTimer = window.setInterval(async () => {
if (!keywordWatching.value || scheduleActive.value || keywordRefreshing) return
if (!keywordWatching.value || scheduleActive.value || seckillRunning.value || keywordRefreshing) return
keywordRefreshing = true
try {
await loadProducts(false)
@@ -135,38 +166,102 @@ const toggleKeywordWatch = () => {
stopKeywordWatch()
return
}
keywordWatching.value = !keywordWatching.value
if (keywordWatching.value) {
saveFixedConfig()
if (!fixedKeyword.value.trim()) {
stopKeywordWatch()
showToast('请输入关键词', '设置商品关键词后再开始监控', 'warn')
return
}
const [hours, minutes, seconds] = watchStartTime.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('监控已设置', `${watchStartTime.value} 开始监控`, 'info', 3000)
keywordStartTimer = window.setTimeout(() => {
keywordStartTimer = null
void beginKeywordWatch()
}, waitMs)
} else {
if (!fixedKeyword.value.trim()) {
showToast('请输入关键词', '设置商品关键词后再开始监控', 'warn')
return
}
saveFixedConfig()
stopStockWatch()
keywordWatching.value = true
const [hours, minutes, seconds] = watchStartTime.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('监控已设置', `${watchStartTime.value} 开始关键词监控`, 'info', 3000)
keywordStartTimer = window.setTimeout(() => {
keywordStartTimer = null
void beginKeywordWatch()
}, waitMs)
} else {
void beginKeywordWatch()
}
}
const tryStartStockBuy = async () => {
const target = stockWatchTarget.value
if (!target) 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
stopStockWatch()
selectProduct(product.commodityId, product.commodityName)
const stockText = product.stockLabel || `库存 ${product.count}`
showToast('检测到库存', `${product.commodityName} ${stockText},开始固定抢购`, 'info', 3000)
await startFixed(stockWatchAttempts.value, stockWatchDelayMs.value)
return true
}
const beginStockWatch = async () => {
if (!stockWatching.value || !isXiaodian.value || !stockWatchTarget.value) return
await loadProducts(true)
if (await tryStartStockBuy()) 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 tryStartStockBuy()
} finally {
stockRefreshing = false
}
}, interval)
}
const toggleStockWatch = () => {
if (stockWatching.value) {
stopStockWatch()
return
}
const product = products.value.find(item => item.commodityId === selectedProductId.value)
if (!product) {
showToast('请选择商品', '请先在左侧选择要监控库存的商品', 'warn')
return
}
saveStockWatchConfig()
stopKeywordWatch()
stockWatchTarget.value = { id: product.commodityId, name: product.commodityName }
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)
stockWatchStartTimer = window.setTimeout(() => {
stockWatchStartTimer = null
void beginStockWatch()
}, waitMs)
} else {
void beginStockWatch()
}
}
watch(isXiaodian, (enabled) => {
if (!enabled && keywordWatching.value) {
if (!enabled) {
stopKeywordWatch()
stopStockWatch()
}
})
onUnmounted(() => {
stopKeywordWatch()
stopStockWatch()
unsubscribeSchedulerEvents()
})
@@ -242,6 +337,36 @@ onUnmounted(() => {
</div>
</div>
<div v-if="isXiaodian" class="keyword-row stock-watch-row">
<span class="settings-title keyword-title">库存抢购</span>
<div class="xiaodian-settings">
<span class="settings-title">监控</span>
<div class="schedule-item">
<label>启动</label>
<input v-model="stockWatchStartTime" type="time" step="1" @change="saveStockWatchConfig">
</div>
<div class="schedule-item">
<label>刷新</label>
<input v-model.number="stockWatchIntervalSec" type="number" min="1" max="600" step="1" @change="saveStockWatchConfig">
<span class="input-unit">秒</span>
</div>
<span class="settings-title buy-title">抢购</span>
<span class="stock-watch-target" :title="stockWatchTargetName">{{ stockWatchTargetName }}</span>
<div class="schedule-item">
<label>次数</label>
<input v-model.number="stockWatchAttempts" type="number" min="1" max="100000" step="1" @change="saveStockWatchConfig">
</div>
<div class="schedule-item">
<label>间隔</label>
<input v-model.number="stockWatchDelayMs" type="number" min="0" max="60000" step="50" @change="saveStockWatchConfig">
<span class="input-unit">ms</span>
</div>
<button class="btn btn-watch btn-stock-watch" :class="{ watching: stockWatching }" @click="toggleStockWatch">
{{ stockWatching ? '停止监控' : '启动监控' }}
</button>
</div>
</div>
<!-- 非和平小店仅显示目标时间,保持原有定时布局。 -->
<div v-else class="schedule-bar">
<div class="schedule-item">
@@ -289,6 +414,10 @@ onUnmounted(() => {
padding-top: 2px;
}
.stock-watch-row {
padding-top: 0;
}
.target-item {
flex: 0 0 auto;
margin-left: 4px;
@@ -365,6 +494,18 @@ onUnmounted(() => {
.btn-fixed { border-color: var(--warning); color: var(--warning); }
.btn-watch { border-color: var(--accent-border); color: var(--accent); }
.btn-watch.watching { background: var(--accent-soft); }
.btn-stock-watch { border-color: var(--success); color: var(--success); }
.btn-stock-watch.watching { background: var(--success-soft); }
.stock-watch-target {
display: inline-block;
max-width: 180px;
overflow: hidden;
color: var(--text-strong);
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1180px) {
.control-strip {
@@ -10,6 +10,12 @@ const priceLabel = computed(() => {
if (!isXiaodian.value) return '积分'
return selectedProduct.value?.payLabel || '点券'
})
const displayPrice = computed(() => {
if (!selectedProduct.value) return ''
return isXiaodian.value && selectedProduct.value.payType === 1
? selectedProduct.value.score / 10
: selectedProduct.value.score
})
const stockText = computed(() => {
if (!selectedProduct.value) return ''
if (isXiaodian.value && selectedProduct.value.stockLabel) return selectedProduct.value.stockLabel
@@ -30,7 +36,7 @@ const stockIsLow = computed(() => {
<div class="selected-product-name">{{ selectedProductName || '未选' }}</div>
<div class="selected-product-meta">
<template v-if="selectedProduct">
<span class="chip chip-score">{{ selectedProduct.score }} {{ priceLabel }}</span>
<span class="chip chip-score">{{ displayPrice }} {{ priceLabel }}</span>
<span class="chip chip-stock" :class="{ low: stockIsLow }">
{{ stockText }}
</span>
@@ -8,6 +8,9 @@ const { filteredProducts, selectedProductId, selectProduct, searchQuery, xiaodia
const { currentActivity } = useActivity()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const priceLabel = (product: { payLabel?: string }) => isXiaodian.value ? (product.payLabel || '点券') : '积分'
// 和平小店接口的点券价格使用内部单位;仅展示时换算,抢购仍保留原始值。
const displayPrice = (product: { score: number; payType?: number }) =>
isXiaodian.value && product.payType === 1 ? product.score / 10 : product.score
const stockText = (product: { count: number; stockLabel?: string }) => {
if (isXiaodian.value && product.stockLabel) return product.stockLabel
if (isXiaodian.value && product.count < 0) return '库存未公开'
@@ -82,8 +85,8 @@ watch(xiaodianTab, () => {
<div class="pmeta">
<span
class="chip chip-score"
:title="`${filteredProducts[item.index].score} ${priceLabel(filteredProducts[item.index])}`"
>{{ filteredProducts[item.index].score }} {{ priceLabel(filteredProducts[item.index]) }}</span>
:title="`${displayPrice(filteredProducts[item.index])} ${priceLabel(filteredProducts[item.index])}`"
>{{ displayPrice(filteredProducts[item.index]) }} {{ priceLabel(filteredProducts[item.index]) }}</span>
<span
class="chip chip-stock"
:class="{ low: stockIsLow(filteredProducts[item.index]) }"
+14 -1
View File
@@ -31,6 +31,10 @@ func DefaultAppPaths() types.AppPaths {
accountFile = resolvePath(value)
legacyAccountFile = ""
}
logDir := defaultLogDir(dataDir)
if value := os.Getenv("DOUYU_LOG_DIR"); value != "" {
logDir = resolvePath(value)
}
return types.AppPaths{
DataDir: dataDir,
@@ -38,12 +42,21 @@ func DefaultAppPaths() types.AppPaths {
AccountFile: accountFile,
LegacyConfigFile: legacyConfigFile,
LegacyAccountFile: legacyAccountFile,
LogDir: resolvePath(envOrDefault("DOUYU_LOG_DIR", filepath.Join(dataDir, constants.DefaultLogDir))),
LogDir: logDir,
SuccessDir: resolvePath(envOrDefault("DOUYU_SUCCESS_DIR", filepath.Join(dataDir, constants.DefaultSuccessDir))),
FontFiles: imgpkg.CollectFontCandidates(),
}
}
// defaultLogDir 在 wails dev 中把日志放在项目工作目录,便于开发时直接查看;
// 正式包始终使用用户应用数据目录,避免写入应用包或受限目录。
func defaultLogDir(dataDir string) string {
if os.Getenv("devserver") != "" || os.Getenv("frontenddevserverurl") != "" {
return resolvePath(constants.DefaultLogDir)
}
return filepath.Join(dataDir, constants.DefaultLogDir)
}
func defaultAppDataDir() string {
if dir, err := os.UserConfigDir(); err == nil && dir != "" {
return filepath.Join(dir, constants.DefaultAppDataDirName)
+32
View File
@@ -63,6 +63,8 @@ func TestDefaultAppPathsUseInternalDataDir(t *testing.T) {
t.Setenv("DOUYU_USERS", "")
t.Setenv("DOUYU_LOG_DIR", "")
t.Setenv("DOUYU_SUCCESS_DIR", "")
t.Setenv("devserver", "")
t.Setenv("frontenddevserverurl", "")
paths := DefaultAppPaths()
if paths.DataDir != dir {
@@ -78,3 +80,33 @@ func TestDefaultAppPathsUseInternalDataDir(t *testing.T) {
t.Fatalf("应用数据子目录异常: logs=%q success=%q", paths.LogDir, paths.SuccessDir)
}
}
func TestDefaultAppPathsUseProjectLogsInWailsDev(t *testing.T) {
dir := t.TempDir()
t.Setenv("DOUYU_DATA_DIR", dir)
t.Setenv("DOUYU_LOG_DIR", "")
t.Setenv("devserver", "localhost:34115")
t.Setenv("frontenddevserverurl", "")
paths := DefaultAppPaths()
want, err := filepath.Abs("logs")
if err != nil {
t.Fatal(err)
}
if paths.LogDir != want {
t.Fatalf("开发日志目录 = %q, want %q", paths.LogDir, want)
}
}
func TestExplicitLogDirOverridesWailsDev(t *testing.T) {
dir := t.TempDir()
customLogDir := filepath.Join(dir, "custom-logs")
t.Setenv("DOUYU_DATA_DIR", filepath.Join(dir, "data"))
t.Setenv("DOUYU_LOG_DIR", customLogDir)
t.Setenv("devserver", "localhost:34115")
paths := DefaultAppPaths()
if paths.LogDir != customLogDir {
t.Fatalf("显式日志目录 = %q, want %q", paths.LogDir, customLogDir)
}
}