优化前端
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user