优化前端
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>尝试包数 > 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+31
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user