优化前端

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
+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>