优化和平小店兑换记录查询

This commit is contained in:
yml2213
2026-08-09 12:54:37 +08:00
parent 8919d020c5
commit 1cd94c0179
7 changed files with 396 additions and 842 deletions
@@ -5,6 +5,11 @@ import { useAccounts } from '@/composables/useAccounts'
import { useProducts } from '@/composables/useProducts'
import type { Product } from '@/composables/useProducts'
import { useActivity } from '@/composables/useActivity'
import { useToast } from '@/composables/useToast'
import {
QueryXiaodianAccountExchangeRecords,
QueryXiaodianExchangeRecords
} from '@wails/go/app/App'
const {
accounts,
@@ -23,11 +28,14 @@ const {
setAccountProduct
} = useAccounts()
const { products, selectedProductName } = useProducts()
const { products, selectedProductId, selectedProductName, selectProduct, clearSelection } = useProducts()
const { currentActivity } = useActivity()
const { showToast } = useToast()
const isXiaodian = computed(() => currentActivity.value?.id === 'xiaodian')
const productFilterMap = ref<Record<string, string>>({})
const qrImage = ref('')
const recordsModal = ref<{ accountName: string; loading: boolean; error: string; records: any[] } | null>(null)
const exchangeLookup = ref<{ productId: string; productName: string; loading: boolean; results: Record<string, any> } | null>(null)
watch(xiaodianQRCode, async (value) => {
qrImage.value = ''
@@ -38,6 +46,10 @@ watch(xiaodianQRCode, async (value) => {
}
})
watch(selectedProductId, () => {
exchangeLookup.value = null
})
const roleChannel = (type?: string) => type === 'wx' ? '微信' : type === 'qq' ? '手Q' : (type || '')
const handleBind = async () => {
@@ -79,6 +91,54 @@ const filteredAccountProducts = (accountName: string) => {
return matched
}
const selectedLookupProduct = computed(() =>
products.value.find(product => product.commodityId === selectedProductId.value)
)
const handleLookupProductChange = (event: Event) => {
const productID = (event.target as HTMLSelectElement).value
const product = products.value.find(item => item.commodityId === productID)
if (product) {
selectProduct(product.commodityId, product.commodityName)
} else {
clearSelection()
}
}
const querySelectedExchangeRecords = async () => {
if (!selectedLookupProduct.value) {
showToast('提示', '请先选择要查询的商品', 'warn')
return
}
const { commodityId, commodityName } = selectedLookupProduct.value
exchangeLookup.value = { productId: commodityId, productName: commodityName, loading: true, results: {} }
try {
const result = await QueryXiaodianExchangeRecords(commodityId, commodityName)
if (!exchangeLookup.value || exchangeLookup.value.productId !== commodityId) return
exchangeLookup.value.loading = false
exchangeLookup.value.results = result.results || {}
if (!result.success) {
showToast('查询失败', result.error || '兑换记录接口未返回成功', 'error')
return
}
const matched = Object.values(result.results || {}).filter((item: any) => item.success && item.matched).length
const failed = Object.values(result.results || {}).filter((item: any) => !item.success).length
showToast('查询完成', `${commodityName}:${matched} 个账号有兑换记录${failed ? `,${failed} 个查询失败` : ''}`, failed ? 'warn' : 'success')
} catch (e: any) {
if (exchangeLookup.value?.productId === commodityId) exchangeLookup.value.loading = false
showToast('查询失败', e?.message || '兑换记录查询失败', 'error')
}
}
const getLookupResult = (accountName: string) => exchangeLookup.value?.results[accountName]
const getMatchedRecord = (accountName: string) => {
const lookup = getLookupResult(accountName)
if (!lookup?.matched || !exchangeLookup.value) return null
return lookup.records?.find((record: any) => record.commodityId === exchangeLookup.value?.productId) || null
}
// 状态信息计算
const getStatusInfo = (name: string, selected: boolean) => {
const st = statusMap.value[name]
@@ -115,6 +175,41 @@ const getResultMessage = (name: string) => {
if (!msg) return '-'
return msg.length > 46 ? msg.slice(0, 46) + '...' : msg
}
const formatTime = (value: string) => value ? value.replace('T', ' ') : '-'
const orderStatus = (status: string) => {
const labels: Record<string, { text: string; cls: string }> = {
'0': { text: '待支付', cls: 'order-pending' },
'1': { text: '处理中', cls: 'order-pending' },
'2': { text: '已支付', cls: 'order-processing' },
'3': { text: '已完成', cls: 'order-complete' },
'4': { text: '已取消', cls: 'order-cancelled' },
'5': { text: '已退款', cls: 'order-cancelled' }
}
return labels[status] || { text: status ? `状态 ${status}` : '未知', cls: 'order-unknown' }
}
const openExchangeRecords = async (accountName: string) => {
recordsModal.value = { accountName, loading: true, error: '', records: [] }
try {
const result = await QueryXiaodianAccountExchangeRecords(accountName)
if (!recordsModal.value || recordsModal.value.accountName !== accountName) return
recordsModal.value.loading = false
recordsModal.value.error = result.error || ''
recordsModal.value.records = result.records || []
if (result.error) showToast('查询兑换记录失败', result.error, 'error')
} catch (e: any) {
if (!recordsModal.value || recordsModal.value.accountName !== accountName) return
recordsModal.value.loading = false
recordsModal.value.error = e?.message || '查询失败'
showToast('查询兑换记录失败', recordsModal.value.error, 'error')
}
}
const closeExchangeRecords = () => {
recordsModal.value = null
}
</script>
<template>
@@ -124,6 +219,27 @@ const getResultMessage = (name: string) => {
<span class="panel-title">账号作战表</span>
<span class="panel-sub">{{ accounts.length }} 个账号,已选 {{ selectedIndices.size }} 个</span>
</div>
<div v-if="isXiaodian" class="exchange-lookup-actions">
<select
class="exchange-lookup-product"
:value="selectedProductId || ''"
:disabled="products.length === 0 || exchangeLookup?.loading"
aria-label="选择要查询兑换记录的商品"
@change="handleLookupProductChange"
>
<option value="">选择商品</option>
<option v-for="product in products" :key="product.commodityId" :value="product.commodityId">
{{ product.commodityName }} · {{ product.score }}{{ product.payLabel || '点券' }}
</option>
</select>
<button
class="btn btn-compact btn-records"
:disabled="!selectedLookupProduct || exchangeLookup?.loading"
@click="querySelectedExchangeRecords"
>
{{ exchangeLookup?.loading ? '查询中...' : '查询兑换记录' }}
</button>
</div>
</div>
<div class="table-wrap">
@@ -147,6 +263,7 @@ const getResultMessage = (name: string) => {
<th>{{ isXiaodian ? '点券' : '积分' }}</th>
<th v-if="isXiaodian">扭蛋碎片</th>
<th v-if="isXiaodian">角色信息</th>
<th v-if="isXiaodian" class="exchange-record-col">兑换记录</th>
<th class="status-col">状态</th>
<th>最后结果</th>
<th class="account-action-col">操作</th>
@@ -154,7 +271,7 @@ const getResultMessage = (name: string) => {
</thead>
<tbody>
<tr v-if="accounts.length === 0">
<td :colspan="isXiaodian ? 11 : 9" class="empty-state">暂无账号,请加载账号</td>
<td :colspan="isXiaodian ? 12 : 9" class="empty-state">暂无账号,请加载账号</td>
</tr>
<template v-else>
<tr
@@ -239,6 +356,18 @@ const getResultMessage = (name: string) => {
{{ getXiaodianRole(account.name)?.error || '-' }}
</span>
</td>
<td v-if="isXiaodian" class="exchange-record-cell">
<template v-if="exchangeLookup">
<span v-if="exchangeLookup.loading" class="exchange-query-state">查询中</span>
<span v-else-if="!getLookupResult(account.name)?.success" class="exchange-query-state exchange-query-error" :title="getLookupResult(account.name)?.error || '查询失败'">查询失败</span>
<span v-else-if="getLookupResult(account.name)?.matched" class="exchange-query-state exchange-query-matched">已兑换</span>
<span v-else class="exchange-query-state exchange-query-empty">未兑换</span>
<span v-if="getMatchedRecord(account.name)" class="order-status" :class="orderStatus(getMatchedRecord(account.name).status).cls">
{{ orderStatus(getMatchedRecord(account.name).status).text }}
</span>
</template>
<button class="btn btn-compact btn-records" @click.stop="openExchangeRecords(account.name)">记录</button>
</td>
<td class="status-td">
<span
class="status-badge"
@@ -268,6 +397,40 @@ const getResultMessage = (name: string) => {
</div>
</div>
<div v-if="recordsModal" class="qr-backdrop" @click.self="closeExchangeRecords">
<section class="record-modal" role="dialog" aria-modal="true" aria-label="和平小店兑换记录">
<div class="qr-modal-head">
<div>
<div class="panel-title">和平小店兑换记录</div>
<div class="panel-sub">用户 {{ recordsModal.accountName }},共 {{ recordsModal.records.length }} 条</div>
</div>
<button class="btn btn-ghost icon-btn" title="关闭" aria-label="关闭" @click="closeExchangeRecords">×</button>
</div>
<div class="record-modal-body">
<div v-if="recordsModal.loading" class="record-modal-empty">正在查询兑换记录...</div>
<div v-else-if="recordsModal.error" class="record-modal-error">{{ recordsModal.error }}</div>
<div v-else-if="recordsModal.records.length === 0" class="record-modal-empty">暂无兑换记录</div>
<div v-else class="record-table-scroll">
<table class="exchange-record-table">
<thead>
<tr><th>商品</th><th>兑换时间</th><th>支付时间</th><th>价格</th><th>状态</th><th>角色</th></tr>
</thead>
<tbody>
<tr v-for="(record, index) in recordsModal.records" :key="`${record.commodityId}-${record.buyTime}-${index}`">
<td><div class="record-product">{{ record.commodityName || record.commodityId }}</div><div v-if="record.quantity > 1" class="account-sub">数量 ×{{ record.quantity }}</div></td>
<td>{{ formatTime(record.buyTime) }}</td>
<td>{{ formatTime(record.payTime) }}</td>
<td>{{ record.price }}</td>
<td><span class="order-status" :class="orderStatus(record.status).cls">{{ orderStatus(record.status).text }}</span></td>
<td>{{ record.roleName || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<div v-if="xiaodianQRCode" class="qr-backdrop" @click.self="closeXiaodianQRCode">
<section class="qr-modal" role="dialog" aria-modal="true" aria-label="和平小店绑定二维码">
<div class="qr-modal-head">
@@ -314,6 +477,32 @@ const getResultMessage = (name: string) => {
background: var(--surface);
}
.exchange-lookup-actions {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.exchange-lookup-product {
width: min(300px, 32vw);
height: 28px;
padding: 0 8px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--field);
color: var(--text);
font-size: 12px;
outline: none;
}
.exchange-lookup-product:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.exchange-lookup-product:disabled { opacity: .6; cursor: not-allowed; }
.panel-title {
font-size: 13px;
font-weight: 600;
@@ -426,6 +615,45 @@ tbody tr:hover {
min-width: 130px;
}
.exchange-record-col,
.exchange-record-cell {
width: 148px;
white-space: nowrap;
}
.exchange-record-cell {
display: table-cell;
}
.exchange-query-state {
display: inline-flex;
margin-right: 5px;
color: var(--muted);
font-size: 11px;
vertical-align: middle;
}
.exchange-query-matched { color: var(--success); font-weight: 600; }
.exchange-query-empty { color: var(--muted); }
.exchange-query-error { color: var(--danger); }
.exchange-record-cell .order-status {
margin-right: 5px;
vertical-align: middle;
}
.btn-records {
color: var(--accent);
border-color: var(--accent-border);
background: var(--accent-soft);
}
.btn-records:hover {
color: #fff;
border-color: var(--accent);
background: var(--accent);
}
.role-name {
color: var(--text-strong);
font-weight: 500;
@@ -453,6 +681,71 @@ tbody tr:hover {
box-shadow: var(--shadow);
}
.record-modal {
width: min(1180px, 100%);
max-height: min(720px, calc(100vh - 40px));
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.record-modal-body {
min-height: 220px;
overflow: auto;
}
.record-modal-empty,
.record-modal-error {
padding: 42px 20px;
text-align: center;
color: var(--muted);
}
.record-modal-error { color: var(--danger); }
.record-table-scroll { overflow: auto; }
.exchange-record-table {
width: 100%;
min-width: 880px;
border-collapse: separate;
border-spacing: 0;
}
.exchange-record-table th,
.exchange-record-table td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
white-space: nowrap;
}
.exchange-record-table th {
position: sticky;
top: 0;
z-index: 1;
background: var(--surface-2);
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.record-product { color: var(--text-strong); font-weight: 500; }
.order-status { display: inline-flex; padding: 3px 8px; border-radius: 999px; border: 1px solid currentColor; font-size: 11px; font-weight: 500; }
.order-complete { color: var(--success); background: var(--success-soft); }
.order-pending, .order-processing { color: var(--warning); background: var(--warning-soft); }
.order-cancelled { color: var(--danger); background: var(--danger-soft); }
.order-unknown { color: var(--muted); background: var(--surface-2); }
@media (max-width: 1180px) {
.panel-head { align-items: flex-start; flex-direction: column; }
.exchange-lookup-actions { width: 100%; }
.exchange-lookup-product { flex: 1; width: auto; }
}
.qr-modal-head,
.qr-modal-foot {
display: flex;
+30 -245
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ref } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useScheduler } from '@/composables/useScheduler'
import StatusGrid from '@/components/console/StatusGrid.vue'
@@ -19,157 +19,44 @@ const { runState, countdownSeconds, formatRemain } = useScheduler()
const settingsModalRef = ref<InstanceType<typeof SettingsModal> | null>(null)
const currentPage = ref<'console' | 'ck' | 'exchange'>('console')
const workspaceRef = ref<HTMLElement | null>(null)
const resizingWorkspace = ref(false)
const compactViewport = ref(false)
const storedWorkspaceRatio = Number(localStorage.getItem('accountPanelRatio'))
const accountPanelRatio = ref(
Number.isFinite(storedWorkspaceRatio) && storedWorkspaceRatio >= 0.55 && storedWorkspaceRatio <= 0.84
? storedWorkspaceRatio
: 0.76
)
const workspaceStyle = computed(() => ({
gridTemplateColumns: compactViewport.value
? '1fr'
: `minmax(0, ${accountPanelRatio.value}fr) 8px minmax(260px, ${1 - accountPanelRatio.value}fr)`
}))
const openSettings = () => {
settingsModalRef.value?.open()
}
const updateWorkspaceRatio = (clientX: number) => {
const workspace = workspaceRef.value
if (!workspace) return
const rect = workspace.getBoundingClientRect()
const next = (clientX - rect.left) / rect.width
accountPanelRatio.value = Math.max(0.55, Math.min(0.84, next))
}
const startWorkspaceResize = (event: PointerEvent) => {
if (window.matchMedia('(max-width: 1180px)').matches) return
resizingWorkspace.value = true
updateWorkspaceRatio(event.clientX)
document.body.classList.add('resizing-columns')
window.addEventListener('pointermove', handleWorkspacePointerMove)
window.addEventListener('pointerup', stopWorkspaceResize, { once: true })
}
const handleWorkspacePointerMove = (event: PointerEvent) => {
if (resizingWorkspace.value) updateWorkspaceRatio(event.clientX)
}
const stopWorkspaceResize = () => {
if (!resizingWorkspace.value) return
resizingWorkspace.value = false
localStorage.setItem('accountPanelRatio', String(accountPanelRatio.value))
document.body.classList.remove('resizing-columns')
window.removeEventListener('pointermove', handleWorkspacePointerMove)
}
const updateCompactViewport = () => {
compactViewport.value = window.matchMedia('(max-width: 1180px)').matches
}
onMounted(() => {
updateCompactViewport()
window.addEventListener('resize', updateCompactViewport)
window.addEventListener('pointerup', stopWorkspaceResize)
})
onUnmounted(() => {
stopWorkspaceResize()
window.removeEventListener('resize', updateCompactViewport)
window.removeEventListener('pointerup', stopWorkspaceResize)
})
</script>
<template>
<main class="right">
<!-- 顶部控制台头 -->
<header class="console-head">
<div class="headline">
<div class="nav-tabs">
<button
class="nav-tab"
:class="{ active: currentPage === 'console' }"
@click="currentPage = 'console'"
>
抢购控制台
</button>
<button
class="nav-tab"
:class="{ active: currentPage === 'ck' }"
@click="currentPage = 'ck'"
>
CK 管理
</button>
<button
class="nav-tab"
:class="{ active: currentPage === 'exchange' }"
@click="currentPage = 'exchange'"
>
兑换记录
</button>
<button class="nav-tab" :class="{ active: currentPage === 'console' }" @click="currentPage = 'console'">抢购控制台</button>
<button class="nav-tab" :class="{ active: currentPage === 'ck' }" @click="currentPage = 'ck'">CK 管理</button>
<button class="nav-tab" :class="{ active: currentPage === 'exchange' }" @click="currentPage = 'exchange'">兑换记录</button>
</div>
<span
v-if="currentPage === 'console'"
class="run-state"
:class="{ active: runState !== '等待操作' }"
:title="runState"
>
{{ runState }}
</span>
<span v-if="countdownSeconds > 0 && currentPage === 'console'" class="countdown-badge">
{{ formatRemain(countdownSeconds) }}
</span>
<span v-if="currentPage === 'console'" class="run-state" :class="{ active: runState !== '等待操作' }" :title="runState">{{ runState }}</span>
<span v-if="countdownSeconds > 0 && currentPage === 'console'" 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>
<button class="btn btn-ghost icon-btn theme-toggle" :title="theme === 'light' ? '切换到暗色' : '切换到亮色'" :aria-label="theme === 'light' ? '切换到暗色' : '切换到亮色'" @click="toggleTheme">{{ theme === 'light' ? '☀' : '☾' }}</button>
</div>
</header>
<!-- 抢购控制台页面 -->
<template v-if="currentPage === 'console'">
<!-- 状态指标 -->
<StatusGrid :server-time="serverTime" />
<!-- 操作条 -->
<ControlStrip />
<!-- 工作区 -->
<section ref="workspaceRef" class="workspace" :class="{ resizing: resizingWorkspace }" :style="workspaceStyle">
<section class="console-workspace">
<AccountTable />
<div
class="workspace-resizer"
role="separator"
aria-label="调整账号表和日志宽度"
@pointerdown="startWorkspaceResize"
></div>
<aside class="side-stack">
<LogPanel />
</aside>
</section>
<LogPanel />
</template>
<!-- CK管理页面 -->
<template v-else-if="currentPage === 'ck'">
<section class="ck-page">
<CKManager />
</section>
<section class="page-body"><CKManager /></section>
</template>
<!-- 兑换记录页面 -->
<template v-else-if="currentPage === 'exchange'">
<template v-else>
<ExchangeRecords />
</template>
</main>
@@ -196,152 +83,50 @@ onUnmounted(() => {
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;
}
.nav-tabs {
display: flex;
gap: 4px;
}
.headline, .top-actions, .nav-tabs { display: flex; align-items: center; }
.headline { gap: 14px; min-width: 0; }
.top-actions { gap: 6px; }
.nav-tabs { gap: 4px; }
.nav-tab {
height: 28px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: .12s;
}
.nav-tab:hover { color: var(--text-strong); background: var(--surface-2); }
.nav-tab.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.nav-tab:hover {
color: var(--text-strong);
background: var(--surface-2);
}
.nav-tab.active {
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
}
.headline-meta {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.run-state {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
}
.run-state.active {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--accent-border);
}
.top-actions {
display: flex;
align-items: center;
gap: 6px;
}
.countdown-badge {
.run-state, .countdown-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border: 1px solid var(--line);
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;
font-weight: 500;
white-space: nowrap;
}
.run-state { max-width: 220px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); background: var(--surface-2); }
.run-state.active { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-border); }
.countdown-badge { color: var(--danger); background: var(--danger-soft); border-color: var(--danger); font-variant-numeric: tabular-nums; }
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 8px minmax(260px, 1fr);
gap: 14px;
padding: 14px 20px 20px;
align-items: stretch;
.console-workspace {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 14px 20px;
}
.workspace-resizer {
width: 8px;
min-height: 100%;
border-radius: 4px;
cursor: col-resize;
transition: background-color .12s ease;
}
.workspace-resizer:hover,
.workspace.resizing .workspace-resizer {
background: var(--accent-border);
}
:global(body.resizing-columns),
:global(body.resizing-columns *) {
cursor: col-resize !important;
user-select: none;
}
.side-stack {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
height: 100%;
}
.ck-page {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.page-body { flex: 1; min-height: 0; overflow-y: auto; }
@media (max-width: 1180px) {
.right {
overflow: visible;
min-height: auto;
}
.workspace {
grid-template-columns: 1fr;
padding: 14px;
overflow: visible;
}
.workspace-resizer {
display: none;
}
.right { overflow: visible; min-height: auto; }
.console-workspace { min-height: 520px; padding: 14px; }
}
</style>
+33 -589
View File
@@ -1,159 +1,31 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { nextTick, ref, watch } from 'vue'
import { useLogs } from '@/composables/useLogs'
import { useAccounts, useVerifyExchange } from '@/composables/useAccounts'
import { useToast } from '@/composables/useToast'
const { logs, results, clearLogs } = useLogs()
const { accounts, selectedIndices, statusMap } = useAccounts()
const { verifyResults, verifying, verifyAll, clearResults } = useVerifyExchange()
const { showToast } = useToast()
const activeTab = ref<'log' | 'result' | 'stats' | 'verify'>('log')
const { logs, clearLogs } = useLogs()
const expanded = ref(false)
const logBodyRef = ref<HTMLElement | null>(null)
const messageSucceeded = (message: string) => /成功|兑换上限|已抢到/.test(message)
// 自动滚动到底部
watch(logs, async () => {
if (!expanded.value) return
await nextTick()
if (logBodyRef.value) {
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
}
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]
const msg = st && typeof st === 'object' ? String((st as any).message || '') : ''
if (st === true || (st && typeof st === 'object' && ((st as any).ok || messageSucceeded(msg)))) {
success++
} else if (st === false || (st && typeof st === 'object' && !(st as any).pending && msg)) {
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))
// 复验结果列表
const verifyEntries = computed(() => {
return Object.entries(verifyResults.value).map(([name, result]) => ({
name,
...result
}))
})
// 复验统计
const verifyStats = computed(() => {
const entries = verifyEntries.value
const verified = entries.filter(e => e.success && e.count > 0).length
const noRecord = entries.filter(e => e.success && e.count === 0).length
const failed = entries.filter(e => !e.success).length
return { verified, noRecord, failed, total: entries.length }
})
// 格式化时间戳
const formatTime = (timestamp: string) => {
if (!timestamp) return ''
const ts = parseInt(timestamp)
if (isNaN(ts)) return timestamp
// 斗鱼返回的是秒级时间戳
const date = new Date(ts * 1000)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 执行复验
const handleVerify = async () => {
showToast('复验开始', '正在查询斗鱼兑换记录...', 'info')
await verifyAll()
activeTab.value = 'verify'
showToast('复验完成', `已查询 ${verifyStats.value.total} 个账号`, 'success')
}
</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>
<button
class="log-tab"
:class="{ active: activeTab === 'verify' }"
@click="activeTab = 'verify'"
>
复验
</button>
</div>
<div class="log-actions">
<button
class="btn btn-compact btn-verify"
:disabled="verifying"
@click="handleVerify"
>
{{ verifying ? '查询中...' : '🔍 复验' }}
</button>
<button class="btn btn-compact" @click="clearLogs">清空</button>
</div>
<section class="log-drawer" :class="{ expanded }">
<div class="log-drawer-head">
<button class="log-toggle" :aria-expanded="expanded" @click="expanded = !expanded">
<span class="log-chevron" :class="{ expanded }">▶</span>
<span>日志</span>
<span v-if="logs.length" class="log-count">{{ logs.length }}</span>
</button>
<button v-if="expanded" class="btn btn-compact" @click="clearLogs">清空</button>
</div>
<!-- 实时日志 -->
<div
v-show="activeTab === 'log'"
ref="logBodyRef"
class="log-panel"
>
<div v-if="expanded" ref="logBodyRef" class="log-body">
<div v-if="logs.length === 0" class="log-i">[系统] 就绪,等待操作...</div>
<div
v-for="(log, index) in logs"
:key="index"
class="log-line"
:class="`log-${log.type}`"
>
<div v-for="(log, index) in logs" :key="index" class="log-line" :class="`log-${log.type}`">
<template v-if="log.source === 'server'">
<span class="log-prefix">[后端]</span>
<span v-if="log.time" class="log-time">[{{ log.time }}]</span>
@@ -162,455 +34,27 @@ const handleVerify = async () => {
<span v-if="log.account" class="log-account">{{ log.account }}</span>
<span class="log-msg">{{ log.message }}</span>
</template>
<template v-else>
<span class="log-time">[{{ log.time }}]</span>
<span class="log-msg">{{ log.message }}</span>
</template>
<template v-else><span class="log-time">[{{ log.time }}]</span><span class="log-msg">{{ log.message }}</span></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 result-header">=== 最新结果 ===</div>
<div
v-for="[key, value] in resultEntries"
:key="key"
class="log-result"
:class="value.includes('成功') ? 'log-s' : 'log-e'"
>
<span class="result-key">{{ key }}</span>
<span class="result-value">{{ value }}</span>
</div>
</template>
</div>
<!-- 统计 -->
<div
v-show="activeTab === 'stats'"
class="log-panel stats-panel"
>
<div class="stats-grid">
<div class="stat-item stat-success">
<span class="stat-label">成功</span>
<span class="stat-value">{{ stats.success }}</span>
</div>
<div class="stat-item stat-fail">
<span class="stat-label">异常</span>
<span class="stat-value">{{ stats.fail }}</span>
</div>
<div class="stat-item stat-pending">
<span class="stat-label">待处理</span>
<span class="stat-value">{{ stats.pending }}</span>
</div>
</div>
<div class="stat-summary">
已选 {{ stats.selected }} / 总 {{ stats.total }}
</div>
</div>
<!-- 兑换复验 -->
<div
v-show="activeTab === 'verify'"
class="log-panel"
>
<div v-if="verifyEntries.length === 0" class="result-empty">
点击「🔍 复验」查询斗鱼真实兑换记录
</div>
<template v-else>
<div class="verify-summary">
<span class="log-s">✅ {{ verifyStats.verified }}</span>
<span class="log-w">⚠️ {{ verifyStats.noRecord }}</span>
<span class="log-e">❌ {{ verifyStats.failed }}</span>
</div>
<div class="verify-list">
<div
v-for="entry in verifyEntries"
:key="entry.name"
class="verify-item"
:class="{
'verify-success': entry.success && entry.count > 0,
'verify-empty': entry.success && entry.count === 0,
'verify-error': !entry.success
}"
>
<div class="verify-account">{{ entry.name }}</div>
<template v-if="entry.success">
<div v-if="entry.count > 0" class="verify-records">
<div v-for="(record, idx) in entry.records" :key="idx" class="verify-record">
<span class="record-time">{{ formatTime(record.time) }}</span>
<span class="record-name">{{ record.commodityName }}</span>
<span class="record-role">{{ record.showName }}</span>
</div>
</div>
<div v-else class="verify-no-record">无记录</div>
</template>
<div v-else class="verify-error-msg">{{ entry.error }}</div>
</div>
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped>
.log-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-header {
padding: 6px 10px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
background: var(--surface);
flex-shrink: 0;
}
.log-tabs {
display: flex;
gap: 2px;
align-items: center;
}
.log-tab {
height: 24px;
padding: 0 8px;
border-radius: var(--radius-sm);
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 11px;
font-weight: 500;
transition: .12s;
white-space: nowrap;
}
.log-tab:hover {
color: var(--text-strong);
background: var(--surface-2);
}
.log-tab.active {
background: var(--accent-soft);
color: var(--accent);
}
.log-actions {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.btn-verify {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent-border);
font-size: 11px;
}
.btn-verify:hover {
background: var(--accent);
color: #fff;
}
.btn-verify:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 日志面板 */
.log-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 10px;
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 11px;
line-height: 1.6;
background: var(--field);
}
/* 日志行 */
.log-line {
display: flex;
align-items: flex-start;
gap: 4px;
padding: 2px 0;
word-break: break-all;
}
.log-prefix {
color: var(--muted);
flex-shrink: 0;
}
.log-time {
color: var(--muted);
flex-shrink: 0;
font-size: 10px;
}
.log-douyu-time {
color: var(--accent);
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
}
.log-cat {
display: inline-block;
padding: 0 4px;
border: 1px solid var(--line);
border-radius: 3px;
color: var(--muted);
font-size: 10px;
line-height: 1.4;
background: var(--surface);
flex-shrink: 0;
}
.log-account {
display: inline-block;
max-width: 96px;
padding: 0 4px;
border: 1px solid var(--accent-border);
border-radius: 3px;
color: var(--accent);
font-size: 10px;
line-height: 1.4;
background: var(--accent-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.log-msg {
flex: 1;
min-width: 0;
}
/* 颜色 */
.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); }
/* 抢购结果 */
.result-header {
padding-bottom: 6px;
margin-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.log-result {
display: flex;
gap: 8px;
padding: 4px 8px;
margin: 2px 0;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: var(--surface);
font-size: 11px;
}
.result-key {
font-weight: 600;
flex-shrink: 0;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 统计面板 */
.stats-panel {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
}
.stats-grid {
display: flex;
gap: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 16px;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--line);
min-width: 70px;
}
.stat-label {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .05em;
}
.stat-value {
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.stat-success .stat-value { color: var(--success); }
.stat-fail .stat-value { color: var(--danger); }
.stat-pending .stat-value { color: var(--accent); }
.stat-summary {
font-size: 11px;
color: var(--muted);
}
/* 复验相关 */
.verify-summary {
display: flex;
gap: 12px;
padding: 6px 10px;
margin-bottom: 8px;
background: var(--surface);
border-radius: var(--radius-sm);
border: 1px solid var(--line);
font-size: 11px;
font-weight: 500;
}
.verify-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.verify-item {
padding: 8px 10px;
background: var(--surface);
border-radius: var(--radius-sm);
border: 1px solid var(--line);
font-size: 11px;
}
.verify-success {
border-color: var(--success);
background: var(--success-soft);
}
.verify-empty {
border-color: var(--warning);
background: var(--warning-soft);
}
.verify-error {
border-color: var(--danger);
background: var(--danger-soft);
}
.verify-account {
font-weight: 600;
color: var(--text-strong);
margin-bottom: 4px;
font-size: 11px;
}
.verify-records {
display: flex;
flex-direction: column;
gap: 2px;
}
.verify-record {
display: flex;
gap: 8px;
font-size: 11px;
align-items: center;
}
.record-time {
color: var(--muted);
font-size: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.record-name {
color: var(--text);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-role {
color: var(--muted);
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.verify-no-record {
color: var(--muted);
font-size: 11px;
}
.verify-error-msg {
color: var(--danger);
font-size: 11px;
}
.result-empty {
color: var(--muted);
padding: 12px 4px;
font-style: italic;
font-size: 11px;
}
.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;
}
}
.log-drawer { flex: 0 0 auto; margin: 0 20px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); overflow: hidden; }
.log-drawer-head { height: 34px; display: flex; align-items: center; justify-content: space-between; padding: 0 8px 0 12px; background: var(--surface); }
.log-toggle { display: inline-flex; align-items: center; gap: 7px; min-width: 0; border: 0; padding: 0; background: transparent; color: var(--text-strong); cursor: pointer; font-size: 12px; font-weight: 600; }
.log-chevron { color: var(--muted); font-size: 10px; transition: transform .12s ease; }
.log-chevron.expanded { transform: rotate(90deg); }
.log-count { min-width: 18px; padding: 1px 5px; border-radius: 999px; color: var(--muted); background: var(--surface-2); font-size: 10px; font-variant-numeric: tabular-nums; }
.log-body { height: min(260px, 30vh); overflow: auto; border-top: 1px solid var(--line); padding: 8px 10px; background: var(--field); font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 11px; line-height: 1.6; }
.log-line { display: flex; align-items: flex-start; gap: 4px; padding: 2px 0; word-break: break-all; }
.log-prefix, .log-time { color: var(--muted); flex-shrink: 0; font-size: 10px; }
.log-douyu-time { color: var(--accent); flex-shrink: 0; font-size: 10px; font-weight: 600; }
.log-cat, .log-account { display: inline-block; padding: 0 4px; border-radius: 3px; font-size: 10px; line-height: 1.4; flex-shrink: 0; }
.log-cat { border: 1px solid var(--line); color: var(--muted); background: var(--surface); }
.log-account { max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 1px solid var(--accent-border); color: var(--accent); background: var(--accent-soft); }
.log-msg { min-width: 0; }
.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); }
@media (max-width: 1180px) { .log-drawer { margin: 0 14px 14px; } }
</style>
+20
View File
@@ -279,6 +279,26 @@ func (a *App) QueryXiaodianExchangeRecords(commodityID, commodityName string) ty
return result
}
// QueryXiaodianAccountExchangeRecords 查询一个账号的和平小店真实订单,供账号表按需展开使用。
func (a *App) QueryXiaodianAccountExchangeRecords(accountName string) types.XiaodianExchangeAccountResult {
accountName = strings.TrimSpace(accountName)
if !a.getClient().Activity.IsXiaodian {
return types.XiaodianExchangeAccountResult{Error: "当前活动不是和平小店"}
}
if accountName == "" {
return types.XiaodianExchangeAccountResult{Error: "缺少账号名"}
}
cookie, ok := a.scheduler.AccountCookieByName(accountName)
if !ok || strings.TrimSpace(cookie) == "" {
return types.XiaodianExchangeAccountResult{Error: "账号不存在或 Cookie 为空"}
}
records, err := a.getClient().XiaodianExchangeList(cookie)
if err != nil {
return types.XiaodianExchangeAccountResult{Error: "查询失败: " + err.Error()}
}
return types.XiaodianExchangeAccountResult{Success: true, Records: records}
}
// VerifyAndExport 复验并导出结果。
func (a *App) VerifyAndExport(commodityID string) types.VerifyAndExportResult {
accounts := a.scheduler.AccountSnapshot()
+12 -5
View File
@@ -307,10 +307,13 @@ type xiaodianOrderList struct {
}
type xiaodianOrder struct {
BuyTime string `json:"dtBuyTime"`
PayTime string `json:"dtPayTime"`
Status string `json:"iStatus"`
GoodsRaw string `json:"sGoodsInfo"`
BuyTime string `json:"dtBuyTime"`
PayTime string `json:"dtPayTime"`
Status string `json:"iStatus"`
Price xiaodianFlexibleString `json:"iPrice"`
PayAmount xiaodianFlexibleString `json:"iPayAmount"`
RoleName string `json:"sRoleName"`
GoodsRaw string `json:"sGoodsInfo"`
}
type xiaodianOrderGoods struct {
@@ -896,10 +899,14 @@ func (c *DouyuClient) XiaodianExchangeList(cookie string) ([]types.XiaodianExcha
}
}
for _, item := range goods.List {
price := parseXiaodianInt(string(order.PayAmount))
if strings.TrimSpace(string(order.PayAmount)) == "" {
price = parseXiaodianInt(string(order.Price))
}
all = append(all, types.XiaodianExchangeRecord{
CommodityID: item.GoodsID, CommodityName: item.GoodsName,
Quantity: parseXiaodianInt(string(item.Quantity)), BuyTime: order.BuyTime,
PayTime: order.PayTime, Status: order.Status,
PayTime: order.PayTime, Price: price, RoleName: order.RoleName, Status: order.Status,
})
}
}
+4 -1
View File
@@ -331,7 +331,7 @@ func TestXiaodianExchangeListUsesOrderListAndParsesGoods(t *testing.T) {
if got := r.URL.Query().Get("_sid"); got != "8" {
t.Errorf("_sid = %q", got)
}
_, _ = w.Write([]byte(`var info={"data":{"cnt":1,"list":[{"dtBuyTime":"2026-08-08 01:00:42","dtPayTime":"2026-08-08 01:00:55","iStatus":"3","sGoodsInfo":"{\"list\":[{\"iGoodsId\":\"1565395\",\"sGoodsName\":\"改名卡\",\"iQuantity\":\"2\"}]}"}]}};`))
_, _ = w.Write([]byte(`var info={"data":{"cnt":1,"list":[{"dtBuyTime":"2026-08-08 01:00:42","dtPayTime":"2026-08-08 01:00:55","iStatus":"3","iPrice":"200","iPayAmount":"180","sRoleName":"测试角色","sGoodsInfo":"{\"list\":[{\"iGoodsId\":\"1565395\",\"sGoodsName\":\"改名卡\",\"iQuantity\":\"2\"}]}"}]}};`))
default:
http.NotFound(w, r)
}
@@ -356,6 +356,9 @@ func TestXiaodianExchangeListUsesOrderListAndParsesGoods(t *testing.T) {
if got.CommodityID != "1565395" || got.CommodityName != "改名卡" || got.Quantity != 2 || got.Status != "3" {
t.Fatalf("记录解析异常: %#v", got)
}
if got.Price != 180 || got.RoleName != "测试角色" {
t.Fatalf("订单扩展字段异常: %#v", got)
}
}
func testCTNClient(t *testing.T, ctnURL string) *DouyuClient {
+2
View File
@@ -266,5 +266,7 @@ type XiaodianExchangeRecord struct {
Quantity int `json:"quantity"`
BuyTime string `json:"buyTime"`
PayTime string `json:"payTime"`
Price int `json:"price"`
RoleName string `json:"roleName"`
Status string `json:"status"`
}