优化前端

This commit is contained in:
yml2213
2026-06-26 10:56:25 +08:00
parent 88a386b588
commit 54b5bd90ae
39 changed files with 6796 additions and 216 deletions
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useProducts } from '@/composables/useProducts'
import { useAccounts } from '@/composables/useAccounts'
import { useScheduler } from '@/composables/useScheduler'
import { useLogs } from '@/composables/useLogs'
import AppShell from '@/components/layout/AppShell.vue'
import ToastContainer from '@/components/ToastContainer.vue'
const { initTheme } = useTheme()
const { loadProducts, restoreSelection: restoreProductSelection } = useProducts()
const { loadAccounts, restoreSelection: restoreAccountSelection } = useAccounts()
const { clearLogs, fetchServerLogs, checkResults } = useLogs()
const { restoreSchedule, setRunState } = useScheduler()
const { clearResults } = useLogs()
const version = ref('')
const serverTime = ref('--')
// 轮询定时器
let timeTimer: number | null = null
let logTimer: number | null = null
let resultTimer: number | null = null
const App = window.go.main.App
const updateTime = async () => {
try {
const d = await App.GetTime()
serverTime.value = d.serverTime || '--'
} catch {}
}
const startPolling = () => {
// 时间更新 - 每秒
timeTimer = window.setInterval(updateTime, 1000)
// 日志拉取 - 每2秒
logTimer = window.setInterval(fetchServerLogs, 2000)
// 结果检查 - 每2秒
resultTimer = window.setInterval(checkResults, 2000)
}
const stopPolling = () => {
if (timeTimer) clearInterval(timeTimer)
if (logTimer) clearInterval(logTimer)
if (resultTimer) clearInterval(resultTimer)
}
onMounted(async () => {
// 初始化主题
initTheme()
// 恢复本地缓存
restoreProductSelection()
restoreAccountSelection()
// 清空日志
clearLogs()
setRunState('等待操作')
// 加载数据
await loadAccounts()
await loadProducts(false)
// 获取版本号
try {
version.value = await App.GetVersion()
} catch {}
// 清空后端旧结果
await clearResults()
// 开始轮询
updateTime()
startPolling()
fetchServerLogs()
})
onUnmounted(() => {
stopPolling()
})
</script>
<template>
<AppShell :version="version" :server-time="serverTime" />
<ToastContainer />
</template>
<style>
@import '@/styles/variables.css';
@import '@/styles/common.css';
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
const { toasts, removeToast } = useToast()
</script>
<template>
<div class="toast-container" :class="{ show: toasts.length > 0 }">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast"
:class="`toast-${toast.type}`"
@click="removeToast(toast.id)"
>
<div class="toast-title">{{ toast.title }}</div>
<div class="toast-body" v-html="toast.body"></div>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-container {
position: fixed;
top: 20px;
right: -420px;
z-index: 9999;
transition: right .35s cubic-bezier(.16, 1, .3, 1);
}
.toast-container.show {
right: 20px;
}
.toast {
background: var(--surface);
border: 1px solid var(--line);
border-left-width: 3px;
border-radius: var(--radius);
padding: 12px 14px;
margin-bottom: 8px;
min-width: 300px;
max-width: 380px;
box-shadow: var(--shadow);
cursor: pointer;
transition: opacity .3s, transform .3s;
}
.toast-title {
font-size: 12.5px;
font-weight: 600;
margin-bottom: 3px;
color: var(--text-strong);
}
.toast-body {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
.toast-success {
border-left-color: var(--success);
}
.toast-success .toast-title {
color: var(--success);
}
.toast-info {
border-left-color: var(--accent);
}
.toast-info .toast-title {
color: var(--accent);
}
.toast-warn {
border-left-color: var(--warning);
}
.toast-warn .toast-title {
color: var(--warning);
}
.toast-error {
border-left-color: var(--danger);
}
.toast-error .toast-title {
color: var(--danger);
}
/* 动画 */
.toast-enter-active {
transition: all .3s ease;
}
.toast-leave-active {
transition: all .3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(30px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useAccounts } from '@/composables/useAccounts'
import { useProducts } from '@/composables/useProducts'
const {
accounts,
selectedIndices,
statusMap,
toggleAccount,
toggleAll,
getScore
} = useAccounts()
const { selectedProductName } = useProducts()
const parentRef = ref<HTMLElement | null>(null)
const allSelected = computed(() =>
accounts.value.length > 0 && selectedIndices.value.size === accounts.value.length
)
const virtualizer = useVirtualizer(
computed(() => ({
count: accounts.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 48,
overscan: 5
}))
)
// 状态信息计算
const getStatusInfo = (name: string, selected: boolean) => {
const st = statusMap.value[name]
if (st === true) return { text: '成功', cls: 'status-success' }
if (st === false) return { text: '失败', cls: 'status-fail' }
if (st && typeof st === 'object') {
const obj = st as any
if (obj.ok) return { text: '成功', cls: 'status-success' }
const msg = String(obj.message || '')
if (msg.includes('库存已耗完')) return { text: '库存耗尽', cls: 'status-warn' }
if (msg.includes('兑换上限')) return { text: '已达上限', cls: 'status-warn' }
if (msg.includes('未绑定')) return { text: '未绑定', cls: 'status-fail' }
if (msg.includes('精英手册')) return { text: '资格不足', cls: 'status-fail' }
return { text: selected ? '未命中' : '失败', cls: selected ? 'status-warn' : 'status-fail' }
}
return selected ? { text: '待命', cls: 'status-ready' } : { text: '未选择', cls: 'status-idle' }
}
const getResultMessage = (name: string) => {
const st = statusMap.value[name]
let msg = ''
if (st && typeof st === 'object') msg = (st as any).message || ''
else if (st === true) msg = '成功'
else if (st === false) msg = '失败'
if (!msg) return '-'
return msg.length > 46 ? msg.slice(0, 46) + '...' : msg
}
</script>
<template>
<div class="panel">
<div class="panel-head">
<div>
<span class="panel-title">账号作战表</span>
<span class="panel-sub">{{ accounts.length }} 个账号,已选 {{ selectedIndices.size }} 个</span>
</div>
<label class="stat-pill">
<input
type="checkbox"
:checked="allSelected"
@change="toggleAll"
>
全选
</label>
</div>
<div ref="parentRef" class="table-wrap">
<table>
<thead>
<tr>
<th class="select-cell"></th>
<th>账号</th>
<th>凭据</th>
<th>商品</th>
<th>积分</th>
<th class="status-col">状态</th>
<th>最后结果</th>
</tr>
</thead>
<tbody>
<tr v-if="accounts.length === 0">
<td colspan="7" class="empty-state">暂无账号,请加载账号</td>
</tr>
<template v-else>
<tr
v-for="item in virtualizer.getVirtualItems()"
:key="String(item.key)"
:style="{ height: `${item.size}px` }"
>
<td class="select-cell">
<label class="account-check" @click="toggleAccount(item.index)">
<div class="check-box" :class="{ checked: selectedIndices.has(item.index) }">
<span v-if="selectedIndices.has(item.index)">✓</span>
</div>
</label>
</td>
<td>
<div class="account-name">{{ accounts[item.index].name }}</div>
<div class="account-sub">{{ selectedIndices.has(item.index) ? '已加入任务' : '未选择' }}</div>
</td>
<td>
<span
v-if="accounts[item.index].cookie"
class="status-badge status-success"
>
已加载
</span>
<span v-else class="status-badge status-fail">缺失</span>
</td>
<td>
<span v-if="selectedProductName" class="prod-tag">
{{ selectedProductName }}
</span>
<span v-else>-</span>
</td>
<td>{{ getScore(accounts[item.index].name) }}</td>
<td class="status-td">
<span
class="status-badge"
:class="getStatusInfo(accounts[item.index].name, selectedIndices.has(item.index)).cls"
>
{{ getStatusInfo(accounts[item.index].name, selectedIndices.has(item.index)).text }}
</span>
</td>
<td>
<span class="account-sub" :title="getResultMessage(accounts[item.index].name)">
{{ getResultMessage(accounts[item.index].name) }}
</span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.panel-head {
padding: 10px 14px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var(--surface);
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
}
.panel-sub {
font-size: 11.5px;
color: var(--muted);
margin-left: 8px;
font-weight: 400;
}
.panel-head label.stat-pill {
cursor: pointer;
}
.panel-head label.stat-pill input {
margin: 0;
cursor: pointer;
}
.table-wrap {
flex: 1;
overflow: auto;
min-height: 0;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 12.5px;
}
th {
background: var(--surface-2);
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 1;
color: var(--muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
white-space: nowrap;
}
td {
padding: 9px 12px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr {
transition: .1s;
}
tbody tr:hover {
background: var(--surface-2);
}
.select-cell {
width: 36px;
}
.status-col {
width: 96px;
}
.account-name {
font-weight: 500;
color: var(--text-strong);
}
.account-sub {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.account-check {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.check-box {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1.5px solid var(--line-strong);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
transition: .15s;
color: #fff;
font-size: 10px;
font-weight: 700;
}
.check-box.checked {
border-color: var(--accent);
background: var(--accent);
}
.prod-tag {
display: inline-flex;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--line);
padding: 2px 8px;
border-radius: 999px;
font-size: 11.5px;
}
@media (max-width: 1180px) {
.table-wrap {
max-height: 520px;
}
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useScheduler } from '@/composables/useScheduler'
import { useAccounts } from '@/composables/useAccounts'
const { scheduleActive, startNow, startSchedule, cancelSchedule, restoreSchedule } = useScheduler()
const { reloadAccounts, queryAllScore } = useAccounts()
// 从本地缓存恢复定时设置
const { savedTime, savedAdv } = restoreSchedule()
const targetTime = ref(savedTime || '18:00:00')
const advance = ref(savedAdv)
const handleSchedule = () => {
startSchedule(targetTime.value, advance.value)
}
</script>
<template>
<div class="control-strip">
<div class="action-group">
<button class="btn btn-danger" @click="startNow">立即抢购</button>
<button
v-if="!scheduleActive"
class="btn btn-success"
@click="handleSchedule"
>
开启定时
</button>
<button
v-else
class="btn btn-cancel"
@click="cancelSchedule"
>
停止定时
</button>
<button class="btn" @click="reloadAccounts">加载账号</button>
<button class="btn" @click="queryAllScore">查询积分</button>
</div>
<div class="schedule-bar">
<div class="schedule-item">
<label>目标</label>
<input
v-model="targetTime"
type="time"
step="1"
>
</div>
<div class="schedule-item">
<label>提前</label>
<input
v-model.number="advance"
type="number"
min="0"
max="5000"
step="100"
>
<span style="color: var(--muted); font-size: 11.5px">ms</span>
</div>
</div>
</div>
</template>
<style scoped>
.control-strip {
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.action-group {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.schedule-bar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.schedule-item {
display: flex;
align-items: center;
gap: 6px;
}
.schedule-bar label {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.schedule-bar input {
height: 30px;
padding: 0 10px;
background: var(--field);
color: var(--text-strong);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
transition: .15s;
}
.schedule-bar input:focus {
outline: 0;
border-color: var(--accent);
box-shadow: var(--focus);
}
.schedule-bar input[type="time"] {
width: 108px;
text-align: center;
}
.schedule-bar input[type="number"] {
width: 72px;
text-align: center;
}
@media (max-width: 1180px) {
.control-strip {
flex-direction: column;
align-items: stretch;
}
.schedule-bar {
justify-content: flex-end;
}
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAccounts } from '@/composables/useAccounts'
defineProps<{
serverTime: string
}>()
const { accounts, selectedIndices, statusMap } = useAccounts()
const stats = computed(() => {
const selectedNames = [...selectedIndices.value]
.map(i => accounts.value[i]?.name)
.filter(Boolean) as string[]
let success = 0
let fail = 0
for (const name of selectedNames) {
const st = statusMap.value[name]
if (st === true || (st && typeof st === 'object' && (st as any).ok)) {
success++
} else if (st === false || (st && typeof st === 'object' && (st as any).message)) {
fail++
}
}
return {
selected: selectedNames.length,
total: accounts.value.length,
success,
fail,
pending: Math.max(0, selectedNames.length - success - fail)
}
})
</script>
<template>
<div class="status-grid">
<div class="metric">
<span class="metric-label">服务器</span>
<span class="metric-value server">{{ serverTime }}</span>
</div>
<div class="metric">
<span class="metric-label">账号</span>
<span class="metric-value">
<span>{{ stats.selected }}</span>/<span>{{ stats.total }}</span>
</span>
</div>
<div class="metric">
<span class="metric-label">成功</span>
<span class="metric-value success-num">{{ stats.success }}</span>
</div>
<div class="metric">
<span class="metric-label">异常</span>
<span class="metric-value fail-num">{{ stats.fail }}</span>
</div>
<div class="metric">
<span class="metric-label">待处理</span>
<span class="metric-value">{{ stats.pending }}</span>
</div>
</div>
</template>
<style scoped>
.status-grid {
display: flex;
gap: 24px;
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--line);
align-items: center;
}
.metric {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.metric-label {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .06em;
font-weight: 500;
}
.metric-value {
color: var(--text-strong);
font-weight: 600;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.metric-value.server {
color: var(--accent);
}
.metric-value.success-num {
color: var(--success);
}
.metric-value.fail-num {
color: var(--danger);
}
@media (max-width: 1180px) {
.status-grid {
flex-wrap: wrap;
gap: 14px 24px;
}
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import LeftSidebar from './LeftSidebar.vue'
import RightConsole from './RightConsole.vue'
defineProps<{
version: string
serverTime: string
}>()
</script>
<template>
<div class="app-shell">
<LeftSidebar :version="version" />
<RightConsole :server-time="serverTime" />
</div>
</template>
<style scoped>
.app-shell {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
height: 100vh;
min-width: 1080px;
overflow: hidden;
}
@media (max-width: 1180px) {
.app-shell {
display: block;
min-width: 0;
height: auto;
overflow: visible;
}
}
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { useProducts } from '@/composables/useProducts'
import ProductSearch from '@/components/product/ProductSearch.vue'
import ProductList from '@/components/product/ProductList.vue'
import ProductCard from '@/components/product/ProductCard.vue'
defineProps<{
version: string
}>()
const { loadProducts, productInfo, filteredProducts, loading } = useProducts()
</script>
<template>
<aside class="left">
<!-- 品牌区 -->
<div class="brand">
<div>
<h1>斗鱼抢购</h1>
<div class="brand-sub">实时库存 · 多账号 · 定时触发</div>
</div>
<span class="stat-pill">v{{ version }}</span>
</div>
<!-- 操作区 -->
<div class="left-header">
<div class="product-actions">
<button
class="btn btn-primary"
:disabled="loading"
@click="loadProducts(true)"
>
{{ loading ? '刷新中...' : '刷新商品' }}
</button>
</div>
<ProductSearch />
<ProductCard />
<div class="product-meta" :class="{ good: productInfo && !productInfo.stale, warn: productInfo?.stale }">
<template v-if="productInfo?.fetchedAt">
实时库存 {{ filteredProducts.length }} 个,刷新于 {{ productInfo.fetchedAt }}
</template>
<template v-else>库存未刷新</template>
</div>
</div>
<!-- 商品列表 -->
<ProductList />
</aside>
</template>
<style scoped>
.left {
background: var(--surface);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.brand {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}
.brand h1 {
font-size: 15px;
font-weight: 600;
color: var(--text-strong);
letter-spacing: -.01em;
}
.brand-sub {
font-size: 11px;
color: var(--muted);
margin-top: 3px;
letter-spacing: .02em;
}
.left-header {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 10px;
}
.product-actions {
display: flex;
gap: 6px;
align-items: center;
}
.product-actions .btn-primary {
flex: 1;
}
.product-meta {
font-size: 11px;
color: var(--muted);
padding: 0 2px;
}
.product-meta.good {
color: var(--success);
}
.product-meta.warn {
color: var(--warning);
}
@media (max-width: 1180px) {
.left {
height: auto;
overflow: visible;
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
</style>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useScheduler } from '@/composables/useScheduler'
import StatusGrid from '@/components/console/StatusGrid.vue'
import ControlStrip from '@/components/console/ControlStrip.vue'
import AccountTable from '@/components/account/AccountTable.vue'
import LogPanel from '@/components/log/LogPanel.vue'
import SettingsModal from '@/components/modal/SettingsModal.vue'
defineProps<{
serverTime: string
}>()
const { theme, toggleTheme } = useTheme()
const { runState, countdownSeconds, formatRemain } = useScheduler()
const settingsModalRef = ref<InstanceType<typeof SettingsModal> | null>(null)
const openSettings = () => {
settingsModalRef.value?.open()
}
</script>
<template>
<main class="right">
<!-- 顶部控制台头 -->
<header class="console-head">
<div class="headline">
<div>
<div class="headline-title">抢购控制台</div>
<div class="headline-meta">{{ runState }}</div>
</div>
<span v-if="countdownSeconds > 0" class="countdown-badge">
{{ formatRemain(countdownSeconds) }}
</span>
</div>
<div class="top-actions">
<button class="btn btn-ghost icon-btn" title="设置" aria-label="设置" @click="openSettings">⚙</button>
<button
class="btn btn-ghost icon-btn theme-toggle"
:title="theme === 'light' ? '切换到暗色' : '切换到亮色'"
:aria-label="theme === 'light' ? '切换到暗色' : '切换到亮色'"
@click="toggleTheme"
>
{{ theme === 'light' ? '☀' : '☾' }}
</button>
</div>
</header>
<!-- 状态指标 -->
<StatusGrid :server-time="serverTime" />
<!-- 操作条 -->
<ControlStrip />
<!-- 工作区 -->
<section class="workspace">
<AccountTable />
<aside class="side-stack">
<LogPanel />
</aside>
</section>
</main>
<SettingsModal ref="settingsModalRef" />
</template>
<style scoped>
.right {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.console-head {
padding: 14px 20px;
border-bottom: 1px solid var(--line);
background: var(--surface);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.headline {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.headline-title {
font-size: 15px;
font-weight: 600;
color: var(--text-strong);
letter-spacing: -.01em;
}
.headline-meta {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.top-actions {
display: flex;
align-items: center;
gap: 6px;
}
.countdown-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: var(--danger-soft);
color: var(--danger);
border: 1px solid var(--danger);
font-weight: 600;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 14px;
padding: 14px 20px 20px;
align-items: stretch;
flex: 1;
min-height: 0;
overflow: hidden;
}
.side-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
height: 100%;
}
@media (max-width: 1180px) {
.right {
overflow: visible;
min-height: auto;
}
.workspace {
grid-template-columns: 1fr;
padding: 14px;
overflow: visible;
}
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useLogs } from '@/composables/useLogs'
import { useAccounts } from '@/composables/useAccounts'
const { logs, results, clearLogs } = useLogs()
const { accounts, selectedIndices, statusMap } = useAccounts()
const activeTab = ref<'log' | 'result' | 'stats'>('log')
const logBodyRef = ref<HTMLElement | null>(null)
// 自动滚动到底部
watch(logs, async () => {
await nextTick()
if (logBodyRef.value) {
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
}
}, { deep: true })
// 统计数据
const stats = computed(() => {
const selectedNames = [...selectedIndices.value]
.map(i => accounts.value[i]?.name)
.filter(Boolean) as string[]
let success = 0
let fail = 0
for (const name of selectedNames) {
const st = statusMap.value[name]
if (st === true || (st && typeof st === 'object' && (st as any).ok)) {
success++
} else if (st === false || (st && typeof st === 'object' && (st as any).message)) {
fail++
}
}
return {
success,
fail,
pending: Math.max(0, selectedNames.length - success - fail),
selected: selectedNames.length,
total: accounts.value.length
}
})
// 结果列表
const resultEntries = computed(() => Object.entries(results.value))
</script>
<template>
<div class="panel log-wrap">
<div class="log-header">
<div class="log-tabs">
<button
class="log-tab"
:class="{ active: activeTab === 'log' }"
@click="activeTab = 'log'"
>
实时日志
</button>
<button
class="log-tab"
:class="{ active: activeTab === 'result' }"
@click="activeTab = 'result'"
>
抢购结果
</button>
<button
class="log-tab"
:class="{ active: activeTab === 'stats' }"
@click="activeTab = 'stats'"
>
统计
</button>
</div>
<button class="btn btn-compact" @click="clearLogs">清空</button>
</div>
<!-- 实时日志 -->
<div
v-show="activeTab === 'log'"
ref="logBodyRef"
class="log-panel"
>
<div v-if="logs.length === 0" class="log-i">[系统] 就绪,等待操作...</div>
<div
v-for="(log, index) in logs"
:key="index"
:class="`log-${log.type}`"
>
<template v-if="log.source === 'server'">
[后端]
<span v-if="log.category" class="log-cat">{{ log.category }}</span>
{{ log.message }}
</template>
<template v-else>
[{{ log.time }}] {{ log.message }}
</template>
</div>
</div>
<!-- 抢购结果 -->
<div
v-show="activeTab === 'result'"
class="log-panel"
>
<div v-if="resultEntries.length === 0" class="result-empty">暂无抢购结果</div>
<template v-else>
<div class="log-i">=== 最新结果 ===</div>
<div
v-for="[key, value] in resultEntries"
:key="key"
class="log-result"
:class="value.includes('成功') ? 'log-s' : 'log-e'"
>
<b>{{ key }}</b>: {{ value }}
</div>
</template>
</div>
<!-- 统计 -->
<div
v-show="activeTab === 'stats'"
class="log-panel"
>
<div class="log-result log-s">成功账号: {{ stats.success }}</div>
<div class="log-result log-e">异常账号: {{ stats.fail }}</div>
<div class="log-result log-i">待处理账号: {{ stats.pending }}</div>
<div class="log-result">已选账号: {{ stats.selected }} / 总账号: {{ stats.total }}</div>
</div>
</div>
</template>
<style scoped>
.log-wrap {
flex: 1;
min-height: 0;
}
.log-header {
padding: 8px 12px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
background: var(--surface);
}
.log-tabs {
display: flex;
gap: 2px;
align-items: center;
}
.log-tab {
height: 26px;
padding: 0 10px;
border-radius: var(--radius-sm);
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 11.5px;
font-weight: 500;
transition: .12s;
}
.log-tab:hover {
color: var(--text-strong);
background: var(--surface-2);
}
.log-tab.active {
background: var(--accent-soft);
color: var(--accent);
}
.log-panel {
flex: 1;
min-height: 0;
overflow: auto;
padding: 10px 12px;
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 11.5px;
line-height: 1.7;
background: var(--field);
}
.log-s {
color: var(--success);
}
.log-e {
color: var(--danger);
}
.log-i {
color: var(--accent);
}
.log-w {
color: var(--warning);
}
.log-d {
color: var(--muted-2);
}
.log-result {
background: var(--surface);
padding: 6px 9px;
margin: 4px 0;
border-radius: var(--radius-sm);
word-break: break-all;
border: 1px solid var(--line);
}
.log-cat {
display: inline-block;
min-width: 32px;
text-align: center;
margin-right: 6px;
padding: 0 5px;
border: 1px solid var(--line);
border-radius: 3px;
color: var(--muted);
font-size: 10px;
line-height: 1.5;
background: var(--surface);
}
.result-empty {
color: var(--muted);
padding: 14px 4px;
font-style: italic;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
@media (max-width: 1180px) {
.log-wrap {
height: 360px;
}
}
</style>
@@ -0,0 +1,445 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useSettings } from '@/composables/useSettings'
const { settings, loadSettings, saveSettings } = useSettings()
const visible = ref(false)
const formData = ref({
timeApiUrl: '',
traceEnabled: false,
traceMaxBody: 0,
logLevel: 'info',
concurrency: 80,
attempts: 1
})
const open = async () => {
await loadSettings()
formData.value = { ...settings.value }
visible.value = true
}
const close = () => {
visible.value = false
}
const handleSave = async () => {
const success = await saveSettings(formData.value)
if (success) {
close()
}
}
// 暴露给父组件
defineExpose({ open, close })
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal">
<div class="modal-header">
<h3>⚙ 设置</h3>
<button class="modal-close" @click="close">×</button>
</div>
<div class="modal-body">
<!-- 时间 API -->
<div class="form-group">
<label class="form-label">时间 API 地址</label>
<input
v-model="formData.timeApiUrl"
class="form-input"
type="text"
placeholder="留空使用默认地址"
>
<div class="form-hint">用于获取斗鱼服务端时间,一般不需要修改</div>
</div>
<!-- 日志设置 -->
<div class="form-section">
<div class="form-section-title">日志设置</div>
<div class="form-group">
<div class="toggle-row">
<div>
<span class="toggle-label">Trace 日志</span>
<div class="form-hint">开启后会在 logs/ 目录生成 douyu_trace_*.log</div>
</div>
<label class="toggle">
<input v-model="formData.traceEnabled" type="checkbox">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Trace 正文上限</label>
<div class="input-with-unit">
<input
v-model.number="formData.traceMaxBody"
class="form-input"
type="number"
min="0"
step="1024"
placeholder="0"
>
<span class="input-unit">字节</span>
</div>
<div class="form-hint">0 表示不限制</div>
</div>
<div class="form-group">
<label class="form-label">日志显示等级</label>
<select v-model="formData.logLevel" class="form-select">
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
</div>
</div>
<!-- 抢购设置 -->
<div class="form-section">
<div class="form-section-title">抢购设置</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">并发数</label>
<input
v-model.number="formData.concurrency"
class="form-input"
type="number"
min="1"
max="500"
step="10"
>
<div class="form-hint">同时发起的请求数量,建议 50-200</div>
</div>
<div class="form-group">
<label class="form-label">尝试包数</label>
<input
v-model.number="formData.attempts"
class="form-input"
type="number"
min="1"
max="10"
step="1"
>
<div class="form-hint">每个账号生成几个抢购包,轮询使用</div>
</div>
</div>
<div class="info-card">
<div class="info-title">💡 抢购策略说明</div>
<ul class="info-list">
<li>抢购窗口:<strong>75 秒</strong>,自动循环重试</li>
<li>前 8 秒高频(180ms),之后逐渐降速</li>
<li>尝试包数 &gt; 1 时,每轮使用不同的包</li>
</ul>
</div>
<div class="info-card info-card-warn">
<div class="info-title">⚠️ 自动停止条件</div>
<ul class="info-list">
<li><strong>成功</strong> - 抢到商品</li>
<li><strong>库存耗尽</strong> - 商品本次活动库存已耗完</li>
<li><strong>兑换上限</strong> - 该账号已达本次活动兑换上限</li>
<li><strong>未绑定</strong> - 账号未绑定角色</li>
<li><strong>资格不足</strong> - 需要开通精英手册</li>
<li><strong>超时</strong> - 75秒抢购窗口结束</li>
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" @click="close">取消</button>
<button class="btn btn-primary" @click="handleSave">保存</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .5);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.modal {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
width: 520px;
max-width: 92vw;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-strong);
display: flex;
align-items: center;
gap: 8px;
}
.modal-close {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: .15s;
}
.modal-close:hover {
background: var(--surface-2);
color: var(--text-strong);
}
.modal-body {
padding: 20px;
}
.form-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.form-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
margin-bottom: 14px;
}
.form-group {
margin-bottom: 16px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 12.5px;
font-weight: 500;
color: var(--text);
margin-bottom: 6px;
}
.form-hint {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
line-height: 1.5;
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
.form-input, .form-select {
width: 100%;
height: 36px;
padding: 0 12px;
background: var(--field);
color: var(--text-strong);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: 13px;
transition: .15s;
}
.form-input:focus, .form-select:focus {
outline: 0;
border-color: var(--accent);
box-shadow: var(--focus);
}
.input-with-unit {
display: flex;
align-items: center;
gap: 8px;
}
.input-with-unit .form-input {
flex: 1;
}
.input-unit {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.toggle-label {
font-size: 13px;
color: var(--text-strong);
font-weight: 500;
}
.toggle {
position: relative;
width: 44px;
height: 24px;
cursor: pointer;
flex-shrink: 0;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
inset: 0;
background: var(--line-strong);
border-radius: 999px;
transition: .2s;
}
.toggle-slider::before {
content: "";
position: absolute;
left: 2px;
top: 2px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
transition: .2s;
}
.toggle input:checked + .toggle-slider {
background: var(--accent);
}
.toggle input:checked + .toggle-slider::before {
transform: translateX(20px);
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--line);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.info-card {
margin-top: 16px;
padding: 14px;
background: var(--accent-soft);
border: 1px solid var(--accent-border);
border-radius: var(--radius-sm);
}
.info-card-warn {
background: var(--warning-soft);
border-color: var(--warning);
}
.info-card-warn .info-title {
color: var(--warning);
}
.info-title {
font-size: 12.5px;
font-weight: 600;
color: var(--accent);
margin-bottom: 10px;
}
.info-list {
margin: 0;
padding-left: 18px;
font-size: 12px;
color: var(--text);
line-height: 1.8;
}
.info-list li {
margin-bottom: 4px;
}
.info-list li:last-child {
margin-bottom: 0;
}
.info-list strong {
color: var(--text-strong);
font-weight: 600;
}
/* 动画 */
.modal-enter-active {
transition: all .2s ease;
}
.modal-leave-active {
transition: all .2s ease;
}
.modal-enter-from {
opacity: 0;
}
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal {
transform: scale(.95);
opacity: 0;
}
.modal-leave-to .modal {
transform: scale(.95);
opacity: 0;
}
</style>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { useProducts } from '@/composables/useProducts'
const { selectedProductId, selectedProductName, selectedProduct } = useProducts()
</script>
<template>
<div class="selected-product-card" :class="{ 'has-product': selectedProductId }">
<div class="selected-product-label">当前商品</div>
<div class="selected-product-name">{{ selectedProductName || '未选' }}</div>
<div class="selected-product-meta">
<template v-if="selectedProduct">
<span class="chip chip-score">{{ selectedProduct.score }} 积分</span>
<span class="chip chip-stock" :class="{ low: selectedProduct.count < 500 }">
库存 {{ selectedProduct.count }}
</span>
</template>
<template v-else-if="selectedProductId">
<span class="chip">等待刷新</span>
</template>
<template v-else>
<span class="chip">未绑定任务</span>
</template>
</div>
</div>
</template>
<style scoped>
.selected-product-card {
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
}
.selected-product-card.has-product {
border-color: var(--accent-border);
background: var(--accent-soft);
}
.selected-product-label {
font-size: 10px;
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: 6px;
}
.selected-product-name {
font-size: 13px;
color: var(--text-strong);
font-weight: 600;
line-height: 1.4;
word-break: break-all;
}
.selected-product-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
</style>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual'
import { ref, computed, watch } from 'vue'
import { useProducts } from '@/composables/useProducts'
const { filteredProducts, selectedProductId, selectProduct, searchQuery } = useProducts()
const parentRef = ref<HTMLElement | null>(null)
const virtualizer = useVirtualizer(
computed(() => ({
count: filteredProducts.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 58,
overscan: 5
}))
)
const handleSelect = (commodityId: string, commodityName: string) => {
selectProduct(commodityId, commodityName)
}
</script>
<template>
<div ref="parentRef" class="product-list">
<div
v-if="filteredProducts.length === 0"
class="empty-state"
>
{{ searchQuery ? `没有匹配"${searchQuery}"的商品` : '点击「刷新商品」获取列表' }}
</div>
<div
v-else
class="product-list-inner"
:style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }"
>
<div
v-for="item in virtualizer.getVirtualItems()"
:key="String(item.key)"
class="prod-item"
:class="{ active: selectedProductId === filteredProducts[item.index]?.commodityId }"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}"
@click="handleSelect(filteredProducts[item.index].commodityId, filteredProducts[item.index].commodityName)"
>
<img
:src="filteredProducts[item.index].pic"
@error="($event.target as HTMLImageElement).style.display = 'none'"
>
<div class="pinfo">
<div class="pname">{{ filteredProducts[item.index].commodityName }}</div>
<div class="pmeta">
<span class="chip chip-score">{{ filteredProducts[item.index].score }} 积分</span>
<span
class="chip chip-stock"
:class="{ low: filteredProducts[item.index].count < 500 }"
>
库存 {{ filteredProducts[item.index].count }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.product-list {
flex: 1;
overflow-y: auto;
padding: 6px 8px 12px;
min-height: 0;
}
.product-list-inner {
width: 100%;
}
.prod-item {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 10px;
padding: 8px 10px;
margin: 2px 0;
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid transparent;
transition: .12s;
}
.prod-item:hover {
background: var(--surface-2);
}
.prod-item.active {
background: var(--accent-soft);
border-color: var(--accent-border);
}
.prod-item img {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
object-fit: cover;
background: var(--field);
border: 1px solid var(--line);
}
.pinfo {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.pname {
font-size: 12.5px;
font-weight: 500;
color: var(--text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.pmeta {
font-size: 11px;
color: var(--muted);
display: flex;
gap: 6px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useProducts } from '@/composables/useProducts'
const { searchQuery, setSearch, clearSearch } = useProducts()
const inputValue = ref(searchQuery.value)
// 防抖处理
let debounceTimer: number | null = null
const debounceDelay = 200
watch(inputValue, (val) => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = window.setTimeout(() => {
setSearch(val)
}, debounceDelay)
})
const handleClear = () => {
inputValue.value = ''
clearSearch()
}
</script>
<template>
<div class="product-search">
<span class="search-icon">⌕</span>
<input
v-model="inputValue"
type="search"
placeholder="搜索商品名 / ID / 积分 / 库存"
autocomplete="off"
>
<button
v-if="inputValue"
class="search-clear"
type="button"
title="清空"
@click="handleClear"
>
×
</button>
</div>
</template>
<style scoped>
.product-search {
display: flex;
align-items: center;
gap: 8px;
background: var(--field);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0 10px;
transition: .15s;
}
.product-search:focus-within {
border-color: var(--accent);
box-shadow: var(--focus);
}
.product-search input {
flex: 1;
min-width: 0;
height: 32px;
border: 0;
outline: 0;
background: transparent;
font-size: 13px;
}
.product-search input::placeholder {
color: var(--muted-2);
}
.search-icon {
color: var(--muted-2);
font-size: 13px;
}
.search-clear {
width: 20px;
height: 20px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.search-clear:hover {
background: var(--surface-3);
color: var(--text-strong);
}
</style>
+155
View File
@@ -0,0 +1,155 @@
import { ref } from 'vue'
import { useToast } from './useToast'
export interface Account {
index: number
name: string
cookie: string
}
export interface AccountStatus {
ok?: boolean
message?: string
}
const App = window.go.main.App
const accounts = ref<Account[]>([])
const selectedIndices = ref<Set<number>>(new Set())
const statusMap = ref<Record<string, AccountStatus | boolean>>({})
const scoreMap = ref<Record<string, string>>({})
export function useAccounts() {
const { showToast } = useToast()
const loadAccounts = async () => {
const data = await App.GetAccounts()
accounts.value = data || []
// 清理无效选择
selectedIndices.value = new Set(
[...selectedIndices.value].filter(i => i >= 0 && i < accounts.value.length)
)
saveSelection()
}
const reloadAccounts = async () => {
const data = await App.ReloadAccounts()
await loadAccounts()
showToast('账号已刷新', `共 ${data.count} 个账号`, 'success')
}
const toggleAccount = (index: number) => {
if (selectedIndices.value.has(index)) {
selectedIndices.value.delete(index)
} else {
selectedIndices.value.add(index)
}
saveSelection()
}
const toggleAll = () => {
if (selectedIndices.value.size === accounts.value.length) {
selectedIndices.value.clear()
} else {
accounts.value.forEach((_, i) => selectedIndices.value.add(i))
}
saveSelection()
}
const saveSelection = () => {
localStorage.setItem('selAccs', JSON.stringify([...selectedIndices.value]))
}
const restoreSelection = () => {
const saved = localStorage.getItem('selAccs')
if (saved) {
try {
selectedIndices.value = new Set(JSON.parse(saved))
} catch {}
}
const savedStatus = localStorage.getItem('statusMap')
if (savedStatus) {
try {
statusMap.value = JSON.parse(savedStatus)
} catch {}
}
}
const queryAllScore = async () => {
showToast('查询积分', '正在并发查询...', 'info')
const tasks = accounts.value.map(async (a) => {
try {
const d = await App.QueryScore(a.name)
const score = d.score ?? 'N/A'
scoreMap.value[a.name] = String(score)
localStorage.setItem('accScore_' + a.name, String(score))
} catch {}
})
await Promise.all(tasks)
showToast('积分查询完成', '', 'success')
}
const getScore = (name: string): string => {
return scoreMap.value[name] || localStorage.getItem('accScore_' + name) || '-'
}
const resetStatus = () => {
statusMap.value = {}
localStorage.removeItem('statusMap')
}
const setStatus = (name: string, status: AccountStatus | boolean) => {
statusMap.value[name] = status
localStorage.setItem('statusMap', JSON.stringify(statusMap.value))
}
const selectedAccountNames = (): string[] => {
return [...selectedIndices.value]
.map(i => accounts.value[i]?.name)
.filter(Boolean) as string[]
}
const buildAccountProducts = (productId: string): Record<string, string> | null => {
if (selectedIndices.value.size === 0) {
showToast('提示', '请勾选要抢购的账号', 'warn')
return null
}
if (!productId) {
showToast('提示', '请先选择商品', 'warn')
return null
}
const ap: Record<string, string> = {}
selectedIndices.value.forEach(i => {
const account = accounts.value[i]
if (account) {
ap[account.name] = productId
}
})
if (Object.keys(ap).length === 0) {
showToast('提示', '没有有效账号可提交', 'warn')
return null
}
return ap
}
return {
accounts,
selectedIndices,
statusMap,
scoreMap,
loadAccounts,
reloadAccounts,
toggleAccount,
toggleAll,
restoreSelection,
queryAllScore,
getScore,
resetStatus,
setStatus,
selectedAccountNames,
buildAccountProducts
}
}
+113
View File
@@ -0,0 +1,113 @@
import { ref } from 'vue'
export interface LogEntry {
id: number
time: string
level: string
category: string
message: string
}
export interface LogMessage {
time: string
type: 's' | 'e' | 'i' | 'w' | 'd'
message: string
category?: string
source: 'local' | 'server'
}
const App = window.go.main.App
const logs = ref<LogMessage[]>([])
const results = ref<Record<string, string>>({})
const lastLogId = ref(0)
const lastResultsJson = ref('')
export function useLogs() {
const addLocalLog = (message: string, type: LogMessage['type'] = 'i') => {
const now = new Date()
const timeStr = now.toLocaleTimeString() + '.' + String(now.getMilliseconds()).padStart(3, '0')
logs.value.push({
time: timeStr,
type,
message,
source: 'local'
})
// 限制日志数量
if (logs.value.length > 500) {
logs.value = logs.value.slice(-300)
}
}
const fetchServerLogs = async () => {
try {
const d = await App.GetLogs(lastLogId.value)
if (!d.success || !Array.isArray(d.logs) || d.logs.length === 0) return
d.logs.forEach((item: LogEntry) => {
lastLogId.value = Math.max(lastLogId.value, item.id || 0)
let type: LogMessage['type'] = 'i'
if (item.level === 'success') type = 's'
else if (item.level === 'error') type = 'e'
else if (item.level === 'warn') type = 'w'
else if (item.level === 'debug') type = 'd'
// 清理日志前缀
const cleanMsg = String(item.message || '').replace(/\[[^\]]+\]\[(info|success|warn|error)\]\s*/i, '')
logs.value.push({
time: item.time || '',
type,
message: cleanMsg,
category: item.category,
source: 'server'
})
})
// 限制日志数量
if (logs.value.length > 500) {
logs.value = logs.value.slice(-300)
}
} catch {}
}
const checkResults = async () => {
try {
const d = await App.GetResults()
const json = JSON.stringify(d)
if (json !== lastResultsJson.value && Object.keys(d).length > 0) {
lastResultsJson.value = json
results.value = d
return true
}
return false
} catch {
return false
}
}
const clearLogs = () => {
logs.value = []
results.value = {}
lastResultsJson.value = ''
lastLogId.value = 0
}
const clearResults = async () => {
await App.ClearResults()
results.value = {}
lastResultsJson.value = ''
}
return {
logs,
results,
addLocalLog,
fetchServerLogs,
checkResults,
clearLogs,
clearResults
}
}
+126
View File
@@ -0,0 +1,126 @@
import { ref, computed } from 'vue'
import { useToast } from './useToast'
export interface Product {
commodityId: string
commodityName: string
score: number
count: number
pic: string
status: number
openStatus: number
storeStatus: number
timeLimited: number
countLimited: number
eliteLimited: number
}
export interface ProductPayload {
success: boolean
source: string
products: Product[]
count: number
fetchedAt: string
ageSecond: number
stale: boolean
}
const App = window.go.main.App
const products = ref<Product[]>([])
const productInfo = ref<ProductPayload | null>(null)
const selectedProductId = ref<string | null>(null)
const selectedProductName = ref('')
const searchQuery = ref('')
const loading = ref(false)
export function useProducts() {
const { showToast } = useToast()
const filteredProducts = computed(() => {
const keyword = searchQuery.value.toLowerCase().trim()
if (!keyword) return products.value
return products.value.filter(p =>
[p.commodityName, p.commodityId, p.score, p.count].some(
v => String(v ?? '').toLowerCase().includes(keyword)
)
)
})
const selectedProduct = computed(() =>
products.value.find(p => p.commodityId === selectedProductId.value)
)
const loadProducts = async (showToastMsg = true) => {
loading.value = true
try {
const data = await App.LoadProducts(true)
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')
}
} catch (e: any) {
products.value = []
productInfo.value = null
clearSelection()
showToast('商品刷新失败', e.message, 'error')
} finally {
loading.value = false
}
}
const selectProduct = (id: string, name: string) => {
selectedProductId.value = id
selectedProductName.value = name
localStorage.setItem('selProd', id)
localStorage.setItem('selProdName', name)
}
const clearSelection = () => {
selectedProductId.value = null
selectedProductName.value = ''
localStorage.removeItem('selProd')
localStorage.removeItem('selProdName')
}
const setSearch = (query: string) => {
searchQuery.value = query.trim()
}
const clearSearch = () => {
searchQuery.value = ''
}
const restoreSelection = () => {
const savedId = localStorage.getItem('selProd')
const savedName = localStorage.getItem('selProdName')
if (savedId) {
selectedProductId.value = savedId
selectedProductName.value = savedName || ''
}
}
return {
products,
productInfo,
selectedProductId,
selectedProductName,
selectedProduct,
searchQuery,
filteredProducts,
loading,
loadProducts,
selectProduct,
clearSelection,
setSearch,
clearSearch,
restoreSelection
}
}
+149
View File
@@ -0,0 +1,149 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useAccounts } from './useAccounts'
import { useProducts } from './useProducts'
const App = window.go.main.App
const scheduleActive = ref(false)
const countdownSeconds = ref(0)
const countdownTimer = ref<number | null>(null)
const runState = ref('等待操作')
export function useScheduler() {
const { showToast } = useToast()
const { resetStatus, buildAccountProducts } = useAccounts()
const { selectedProductId, selectedProductName } = useProducts()
const setRunState = (text: string) => {
runState.value = text || '等待操作'
}
const startNow = async () => {
const ap = buildAccountProducts(selectedProductId.value || '')
if (!ap) return
resetStatus()
setRunState('立即抢购已提交')
showToast('抢购中', `${Object.keys(ap).length} 个账号正在抢购`, 'info', 3000)
try {
const d = await App.StartSeckill(ap)
setRunState(d.msg || '已提交')
} catch (e: any) {
setRunState('提交失败')
showToast('提交失败', e.message, 'error')
}
}
const startSchedule = async (targetTime: string, advance: number) => {
const ap = buildAccountProducts(selectedProductId.value || '')
if (!ap) return
if (!targetTime) {
showToast('提示', '请选择目标时间', 'warn')
return
}
// 格式化时间
let timeStr = targetTime
if (timeStr.split(':').length === 2) timeStr += ':00'
const today = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(new Date()).replace(/\//g, '-')
const fullTime = today + ' ' + timeStr
resetStatus()
setRunState('定时配置中')
try {
const d = await App.SetSchedule({
accountProducts: ap,
targetTime: fullTime,
advance
})
scheduleActive.value = true
localStorage.setItem('targetTime', timeStr)
localStorage.setItem('advance', String(advance))
setRunState(`定时已开启,触发 ${d.triggerTime}`)
showToast(
'定时已开启',
`触发时刻: ${d.triggerTime}<br>剩余时间: ${formatRemain(d.remainSecond)}`,
'success',
5000
)
startCountdown(d.remainSecond)
} catch (e: any) {
setRunState('定时设置失败')
showToast('定时失败', e.message, 'error')
}
}
const cancelSchedule = async () => {
try {
await App.CancelSchedule()
} catch {}
scheduleActive.value = false
localStorage.removeItem('targetTime')
localStorage.removeItem('advance')
stopCountdown()
setRunState('定时已停止')
showToast('定时已停止', '抢购任务已取消', 'warn')
}
const startCountdown = (sec: number) => {
stopCountdown()
const endTime = Date.now() + sec * 1000
countdownTimer.value = window.setInterval(() => {
const remain = Math.max(0, Math.ceil((endTime - Date.now()) / 1000))
countdownSeconds.value = remain
if (remain <= 0) {
scheduleActive.value = false
setRunState('定时已触发')
stopCountdown()
}
}, 500)
}
const stopCountdown = () => {
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
countdownTimer.value = null
}
countdownSeconds.value = 0
}
const formatRemain = (sec: number): string => {
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
const restoreSchedule = () => {
const savedTime = localStorage.getItem('targetTime')
const savedAdv = localStorage.getItem('advance')
return { savedTime, savedAdv: savedAdv ? parseInt(savedAdv) : 300 }
}
return {
scheduleActive,
countdownSeconds,
runState,
setRunState,
startNow,
startSchedule,
cancelSchedule,
formatRemain,
restoreSchedule
}
}
+65
View File
@@ -0,0 +1,65 @@
import { ref } from 'vue'
import { useToast } from './useToast'
export interface Settings {
timeApiUrl: string
traceEnabled: boolean
traceMaxBody: number
logLevel: string
concurrency: number
attempts: number
}
const App = window.go.main.App
const settings = ref<Settings>({
timeApiUrl: '',
traceEnabled: false,
traceMaxBody: 0,
logLevel: 'info',
concurrency: 80,
attempts: 1
})
export function useSettings() {
const { showToast } = useToast()
const loadSettings = async () => {
try {
const cfg = await App.GetSettings()
settings.value = {
timeApiUrl: cfg.timeApiUrl || '',
traceEnabled: !!cfg.traceEnabled,
traceMaxBody: cfg.traceMaxBody || 0,
logLevel: cfg.logLevel || 'info',
concurrency: cfg.concurrency || 80,
attempts: cfg.attempts || 1
}
} catch (e: any) {
showToast('读取设置失败', e.message, 'error')
}
}
const saveSettings = async (newSettings: Settings) => {
try {
const d = await App.SaveSettings(newSettings)
if (d.success) {
settings.value = { ...newSettings }
showToast('设置已保存', '配置已生效', 'success')
return true
} else {
showToast('保存失败', d.error || '', 'error')
return false
}
} catch (e: any) {
showToast('保存失败', e.message, 'error')
return false
}
}
return {
settings,
loadSettings,
saveSettings
}
}
+29
View File
@@ -0,0 +1,29 @@
import { ref, watch, onMounted } from 'vue'
export type Theme = 'dark' | 'light'
const theme = ref<Theme>('dark')
export function useTheme() {
const applyTheme = (t: Theme) => {
theme.value = t
document.body.dataset.theme = t
localStorage.setItem('uiTheme', t)
}
const toggleTheme = () => {
applyTheme(theme.value === 'light' ? 'dark' : 'light')
}
const initTheme = () => {
const saved = localStorage.getItem('uiTheme') as Theme | null
const prefersLight = window.matchMedia?.('(prefers-color-scheme: light)').matches
applyTheme(saved || (prefersLight ? 'light' : 'dark'))
}
return {
theme,
toggleTheme,
initTheme
}
}
+38
View File
@@ -0,0 +1,38 @@
import { ref, reactive } from 'vue'
export interface Toast {
id: number
title: string
body: string
type: 'success' | 'error' | 'warn' | 'info'
duration: number
}
const toasts = ref<Toast[]>([])
let nextId = 0
export function useToast() {
const showToast = (title: string, body: string, type: Toast['type'] = 'info', duration = 4000) => {
const id = nextId++
const toast: Toast = { id, title, body, type, duration }
toasts.value.push(toast)
// 自动移除
setTimeout(() => {
removeToast(id)
}, duration)
}
const removeToast = (id: number) => {
const index = toasts.value.findIndex(t => t.id === id)
if (index !== -1) {
toasts.value.splice(index, 1)
}
}
return {
toasts,
showToast,
removeToast
}
}
+31
View File
@@ -0,0 +1,31 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
// Wails Go 绑定类型声明
interface Window {
go: {
main: {
App: {
LoadProducts: (refresh: boolean) => Promise<any>
GetAccounts: () => Promise<any[]>
ReloadAccounts: () => Promise<any>
StartSeckill: (accountProducts: Record<string, string>) => Promise<any>
SetSchedule: (req: any) => Promise<any>
CancelSchedule: () => Promise<any>
GetResults: () => Promise<Record<string, string>>
GetLogs: (since: number) => Promise<any>
GetTime: () => Promise<any>
ClearResults: () => Promise<any>
QueryScore: (accountName: string) => Promise<any>
GetVersion: () => Promise<string>
GetSettings: () => Promise<any>
SaveSettings: (cfg: any) => Promise<any>
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
+211
View File
@@ -0,0 +1,211 @@
/* ============ 按钮 ============ */
.btn {
height: 30px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 12.5px;
font-weight: 500;
white-space: nowrap;
transition: .12s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn:hover {
background: var(--surface-3);
border-color: var(--line-strong);
}
.btn:focus-visible {
outline: 0;
box-shadow: var(--focus);
}
.btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
}
.btn-danger:hover {
filter: brightness(1.08);
}
.btn-success {
background: var(--success);
border-color: var(--success);
color: #fff;
}
.btn-success:hover {
filter: brightness(1.08);
}
.btn-cancel {
background: transparent;
color: var(--danger);
border-color: var(--danger);
}
.btn-cancel:hover {
background: var(--danger-soft);
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--muted);
}
.btn-ghost:hover {
color: var(--text-strong);
background: var(--surface-2);
border-color: var(--line);
}
.icon-btn {
width: 36px;
height: 36px;
padding: 0;
font-size: 18px;
}
.btn-compact {
height: 26px;
font-size: 11.5px;
padding: 0 10px;
}
/* ============ 状态标签 ============ */
.stat-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
/* ============ 芯片 ============ */
.chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 7px;
border-radius: 999px;
background: var(--surface-3);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
color: var(--muted);
}
.chip-score {
color: var(--warning);
background: var(--warning-soft);
}
.chip-stock {
color: var(--success);
background: var(--success-soft);
}
.chip-stock.low {
color: var(--danger);
background: var(--danger-soft);
}
/* ============ 状态徽章 ============ */
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface-2);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.status-idle {
color: var(--muted);
}
.status-ready {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--accent-border);
}
.status-success {
color: var(--success);
background: var(--success-soft);
border-color: var(--success);
}
.status-fail {
color: var(--danger);
background: var(--danger-soft);
border-color: var(--danger);
}
.status-warn {
color: var(--warning);
background: var(--warning-soft);
border-color: var(--warning);
}
/* ============ 表单 ============ */
.form-input, .form-select {
width: 100%;
height: 32px;
padding: 0 10px;
background: var(--field);
color: var(--text-strong);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: 12.5px;
transition: .15s;
}
.form-input:focus, .form-select:focus {
outline: 0;
border-color: var(--accent);
box-shadow: var(--focus);
}
/* ============ 空状态 ============ */
.empty-state {
text-align: center;
color: var(--muted);
padding: 48px 16px;
font-size: 12px;
line-height: 1.7;
}
+108
View File
@@ -0,0 +1,108 @@
/* ============ CSS 变量系统 ============ */
:root {
color-scheme: dark;
--bg: #0f1419;
--surface: #161b22;
--surface-2: #1c232c;
--surface-3: #232b36;
--field: #0d1117;
--text: #c9d1d9;
--text-strong: #f0f6fc;
--muted: #8b949e;
--muted-2: #6e7681;
--line: #21262d;
--line-strong: #30363d;
--accent: #388bfd;
--accent-soft: rgba(56, 139, 253, .12);
--accent-border: rgba(56, 139, 253, .35);
--danger: #f85149;
--danger-soft: rgba(248, 81, 73, .12);
--success: #3fb950;
--success-soft: rgba(63, 185, 80, .12);
--warning: #d29922;
--warning-soft: rgba(210, 153, 34, .12);
--orange: #f78166;
--orange-soft: rgba(247, 129, 102, .12);
--shadow: 0 1px 0 rgba(0, 0, 0, .04), 0 8px 24px rgba(0, 0, 0, .18);
--radius: 8px;
--radius-sm: 6px;
--focus: 0 0 0 3px rgba(56, 139, 253, .3);
}
body[data-theme="light"] {
color-scheme: light;
--bg: #f6f8fa;
--surface: #ffffff;
--surface-2: #f6f8fa;
--surface-3: #eaeef2;
--field: #ffffff;
--text: #1f2328;
--text-strong: #0d1117;
--muted: #656d76;
--muted-2: #8c959f;
--line: #d0d7de;
--line-strong: #afb8c1;
--accent: #0969da;
--accent-soft: rgba(9, 105, 218, .08);
--accent-border: rgba(9, 105, 218, .3);
--danger: #cf222e;
--danger-soft: rgba(207, 34, 46, .08);
--success: #1a7f37;
--success-soft: rgba(26, 127, 55, .08);
--warning: #9a6700;
--warning-soft: rgba(154, 103, 0, .1);
--orange: #bc4c00;
--orange-soft: rgba(188, 76, 0, .08);
--shadow: 0 1px 0 rgba(31, 35, 40, .04), 0 8px 24px rgba(140, 149, 159, .15);
--focus: 0 0 0 3px rgba(9, 105, 218, .3);
}
/* ============ 全局重置 ============ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100vh;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button, input, select {
font: inherit;
color: inherit;
}
::selection {
background: var(--accent-soft);
color: var(--text-strong);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-2);
}