优化和平小店兑换记录查询
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user