新增库存监控并优化开发日志
This commit is contained in:
@@ -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]) }"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user