优化前端

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