fix: 完成商务迁移复核与接口优化并清理历史资料

This commit is contained in:
Yvan
2026-11-04 14:45:02 +08:00
parent 5a96be6a79
commit 297e80827d
176 changed files with 6803 additions and 6002 deletions
+28 -22
View File
@@ -1,27 +1,16 @@
<template>
<el-popover placement="bottom-end" :width="250" trigger="click" @show="load">
<template #reference>
<icon-button icon="lucide:sliders-horizontal" label="统计口径" />
</template>
<div v-loading="loading" class="space-y-3">
<div class="font-medium">全局统计口径</div>
<div class="flex items-center justify-between">
<span>包含下级渠道</span>
<el-switch :model-value="preference.includeSubPromote" :disabled="loading || !loaded" @change="save('includeSubPromote', $event)" />
</div>
<div class="flex items-center justify-between">
<span>身份证去重</span>
<el-switch :model-value="preference.idcardDedup" :disabled="loading || !loaded" @change="save('idcardDedup', $event)" />
</div>
<p class="text-xs text-gray-500">用于商务数据查询,充值金额不受身份证去重影响。</p>
</div>
</el-popover>
<div class="business-preferences" role="group" aria-label="统计口径" :aria-busy="loading">
<el-checkbox :model-value="preference.includeSubPromote" :disabled="loading || !loaded" @update:model-value="save('includeSubPromote', $event)">包含下级渠道</el-checkbox>
<el-tooltip content="按身份证合并玩家统计,充值金额不受影响。" placement="bottom">
<el-checkbox :model-value="preference.idcardDedup" :disabled="loading || !loaded" @update:model-value="save('idcardDedup', $event)">身份证去重</el-checkbox>
</el-tooltip>
<el-button v-if="!loaded && !loading" link type="primary" size="small" @click="load">重试</el-button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import IconButton from '@/components/iconButton/index.vue'
import { getUserPreference, updateUserPreference } from '@/api/business'
import { emitter } from '@/utils/bus'
@@ -38,6 +27,8 @@ const load = async () => {
preference.value = res.data
loaded.value = true
}
} catch {
ElMessage.error('统计口径读取失败,请重试')
} finally {
loading.value = false
}
@@ -46,16 +37,31 @@ const load = async () => {
const save = async (key, value) => {
if (loading.value || !loaded.value) return
loading.value = true
const previous = preference.value
const next = { ...previous, [key]: value }
preference.value = next
try {
const next = { ...preference.value, [key]: value }
const res = await updateUserPreference(next)
if (res.code === 0) {
preference.value = next
ElMessage.success('统计口径已更新')
emitter.emit('reload')
}
} else preference.value = previous
} catch {
preference.value = previous
ElMessage.error('统计口径保存失败,请重试')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.business-preferences { display: flex; align-items: center; gap: 14px; margin-right: 12px; padding-right: 16px; border-right: 1px solid var(--el-border-color-lighter); }
.business-preferences :deep(.el-checkbox) { margin: 0; white-space: nowrap; }
@media (max-width: 900px) {
.business-preferences { flex-direction: column; align-items: flex-start; gap: 0; margin-right: 4px; padding-right: 8px; }
.business-preferences :deep(.el-checkbox) { height: 24px; }
.business-preferences :deep(.el-checkbox__label) { font-size: 12px; }
}
</style>
+12 -7
View File
@@ -195,7 +195,7 @@ service.interceptors.response.use(
closeLoading(response.config.loadingOption)
}
if (response.headers['new-token']) {
if (response.headers['new-token'] && userStore.token && userStore.token === response.config.headers?.['x-token']) {
userStore.setToken(response.headers['new-token'])
}
@@ -229,14 +229,19 @@ service.interceptors.response.use(
}
if (error.response.status === 401) {
// Session expiry must take effect even if another error dialog is open or dismissed.
const userStore = useUserStore()
const requestToken = error.config?.headers?.['x-token']
if (requestToken && userStore.token && requestToken !== userStore.token) {
return Promise.reject(error)
}
userStore.ClearStorage()
if (router.currentRoute.value.name !== 'Login') {
router.replace({ name: 'Login' })
}
emitter.emit('show-error', {
code: '401',
message: getErrorMessage(error),
fn: () => {
const userStore = useUserStore()
userStore.ClearStorage()
router.push({ name: 'Login', replace: true })
}
message: getErrorMessage(error)
})
return Promise.reject(error)
}
@@ -1,7 +1,10 @@
<template>
<div class="business-dashboard">
<el-card shadow="never">
<div class="business-toolbar"><strong>商务数据</strong><el-select v-model="sources" multiple collapse-tags clearable placeholder="请选择所属平台" style="width: 260px" @change="refresh"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></div>
<div class="business-toolbar">
<slot name="heading"><strong>商务工作台</strong></slot>
<div class="business-platform-filter"><span class="business-muted">数据平台</span><el-select v-model="sources" multiple collapse-tags collapse-tags-tooltip clearable placeholder="请选择所属平台" aria-label="数据平台" @change="refresh"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></div>
</div>
<el-alert v-if="!sources.length" class="mt-3" type="info" :closable="false" title="请选择所属平台后查看数据" />
<el-alert v-else-if="summaryError" class="mt-3" type="error" :closable="false" :title="summaryError" />
<el-alert v-else-if="summary?.available === false" class="mt-3" type="warning" :closable="false" title="工作台所需业务数据尚未接入。" />
@@ -23,21 +26,9 @@
</el-card>
<div class="business-ranks">
<el-card v-for="panel in panels" :key="panel.key" v-loading="panel.loading" shadow="never">
<div class="business-rank-heading"><strong>{{ panel.title }}</strong><el-radio-group v-if="panel.period" v-model="panel.period" size="small" @change="loadRank(panel)"><el-radio-button v-for="option in periods" :key="option.value" :value="option.value">{{ option.label }}</el-radio-button></el-radio-group></div>
<div class="business-rank-heading"><strong :title="panel.title">{{ panel.title }}</strong><el-select v-if="panel.period" v-model="panel.period" class="business-rank-period" size="small" :aria-label="`${panel.title}统计周期`" @change="loadRank(panel)"><el-option v-for="option in periods" :key="option.value" :value="option.value" :label="option.label" /></el-select></div>
<el-alert v-if="panel.error" class="mt-3" type="warning" :closable="false" :title="panel.error" />
<template v-else-if="panel.chart && panel.rows.length">
<div v-for="row in panel.rows" :key="`${row.sourceDb}:${row.promoteId}`" class="business-bar">
<div class="business-bar-heading"><span class="business-bar-name" :title="row.promoteName">{{ row.rank }}. {{ row.promoteName }} <el-text type="info" size="small">{{ row.sourceDbLabel }}</el-text></span><strong>{{ rankValue(panel, row.value) }}</strong></div>
<el-progress :percentage="barWidth(panel, row)" :show-text="false" :stroke-width="8" />
</div>
</template>
<el-table v-else-if="!panel.chart" :data="panel.rows" :row-key="row => `${row.sourceDb}:${row.promoteId}`" :empty-text="sources.length ? '暂无数据' : '请选择所属平台'">
<el-table-column prop="sourceDbLabel" label="所属平台" width="92" />
<el-table-column prop="rank" label="排名" width="65" />
<el-table-column prop="promoteName" label="渠道名称" min-width="160" show-overflow-tooltip />
<el-table-column :label="panel.key === 'unlogin' ? '未登录时长' : '充值金额'" min-width="140"><template #default="{ row }">{{ rankValue(panel, row.value) }}</template></el-table-column>
</el-table>
<el-empty v-else :description="sources.length ? '暂无数据' : '请选择所属平台'" :image-size="65" />
<compact-ranking-table v-else :title="panel.title" :rows="rankingRows(panel)" :metric-label="panel.key === 'register' ? '注册量' : panel.key === 'unlogin' ? '未登录天数' : '充值金额'" :value-formatter="value => rankValue(panel, value)" :wide-metric="panel.key !== 'register'" :empty-text="sources.length ? '暂无数据' : '请选择所属平台'" />
</el-card>
</div>
</div>
@@ -50,6 +41,7 @@ import { ElMessage } from 'element-plus'
import { getWorkbenchSummary, getWorkbenchRanks } from '@/api/workbench'
import { listAlertRecords } from '@/api/alert'
import { channelCount, channelMoney } from '@/view/sw/channel-display'
import CompactRankingTable from '@/view/sw/components/CompactRankingTable.vue'
const router = useRouter(), carousel = ref()
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
@@ -58,8 +50,8 @@ const alerts = reactive({ rows: [], total: null, loading: false, error: '', vers
const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }]
const cards = [{ key: 'todayRegister', label: '今日渠道玩家注册' }, { key: 'todayActive', label: '今日渠道活跃玩家' }, { key: 'todayPay', label: '今日充值金额', money: true }, { key: 'monthPay', label: '本月充值金额', money: true }, { key: 'activePromote', label: '活跃渠道数' }, { key: 'newPromote', label: '新增渠道数' }]
const panels = reactive([
{ key: 'register', endpoint: 'registerTop', title: '渠道注册 TOP10', period: 'today', chart: true },
{ key: 'payHigh', endpoint: 'payRank', title: '渠道充值 TOP10', period: 'today', order: 'desc', chart: true },
{ key: 'register', endpoint: 'registerTop', title: '渠道注册 TOP10', period: 'today' },
{ key: 'payHigh', endpoint: 'payRank', title: '渠道充值 TOP10', period: 'today', order: 'desc' },
{ key: 'unlogin', endpoint: 'unloginTop', title: '未登录渠道排行 TOP10' },
{ key: 'payLow', endpoint: 'payRank', title: '充值金额最低排行 TOP10', period: 'last30', order: 'asc' }
].map(panel => ({ ...panel, rows: [], loading: false, error: '', version: 0 })))
@@ -74,8 +66,7 @@ const metricCompare = key => {
return `${card.compareLabel} ${Number(card.changeRate) > 0 ? '+' : ''}${card.changeRate}%`
}
const rankValue = (panel, value) => panel.key === 'register' ? count(value) : panel.key === 'unlogin' ? `${count(value)} 天` : channelMoney(value)
// 浮点数仅用于条形宽度,金额标签始终使用接口十进制字符串。
const barWidth = (panel, row) => { const max = Math.max(...panel.rows.map(item => Number(item.value)), 0); return max > 0 ? Math.min(100, Number(row.value) / max * 100) : 0 }
const rankingRows = panel => panel.rows.map(row => ({ rowKey: `${row.sourceDb}:${row.promoteId}`, rank: row.rank, objectName: row.promoteName, sourceDbLabel: row.sourceDbLabel, metricValue: row.value }))
const loadSummary = async () => {
const current = ++summaryVersion; summary.value = null; summaryError.value = ''
if (!sources.value.length) { summaryLoading.value = false; return }
@@ -110,16 +101,20 @@ onMounted(refresh)
<style scoped>
.business-dashboard { display: flex; flex-direction: column; gap: 12px; }
.business-toolbar, .business-rank-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.business-platform-filter { display: flex; align-items: center; gap: 12px; }
.business-platform-filter .el-select { width: 240px; }
.business-kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.business-value { font-size: 26px; font-weight: 600; margin: 14px 0 10px; overflow-wrap: anywhere; }
.business-muted { color: var(--el-text-color-secondary); font-size: 13px; }
.business-ranks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.business-rank-heading { margin-bottom: 16px; }
.business-bar { margin-bottom: 16px; }
.business-bar-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 7px; font-size: 13px; }
.business-bar-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.business-bar-heading strong { flex-shrink: 0; }
.business-ranks { display: grid; align-items: start; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.business-ranks > .el-card { min-width: 0; }
.business-ranks :deep(.el-card__body) { padding: 16px; }
.business-rank-heading { margin-bottom: 12px; min-height: 26px; gap: 8px; flex-wrap: nowrap; }
.business-rank-heading strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.business-rank-period { width: 82px; flex-shrink: 0; }
.business-alert { border: 0; background: transparent; color: var(--el-text-color-primary); text-align: left; width: 100%; cursor: pointer; padding: 10px 0; line-height: 1.6; }
@media (max-width: 1100px) { .business-ranks { grid-template-columns: 1fr; } }
@media (max-width: 700px) { .business-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 1500px) { .business-kpis { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
@media (max-width: 1279px) { .business-ranks { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 767px) { .business-ranks { grid-template-columns: 1fr; } }
@media (max-width: 700px) { .business-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .business-platform-filter { width: 100%; } .business-platform-filter .el-select { flex: 1; min-width: 0; } }
</style>
+8 -11
View File
@@ -1,16 +1,14 @@
<template>
<div class="h-full kra-container2 overflow-auto bg-main">
<div class="space-y-2 py-2">
<kra-card class="rounded-xl border border-slate-200/80 bg-white px-5 py-6 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<p class="text-xs tracking-[0.2em] text-muted-foreground">DASHBOARD</p>
<h1 class="mt-2 text-xl font-semibold text-base-text lg:text-2xl">欢迎回来,{{ userStore.userInfo.nickName }}</h1>
<p class="mt-2 text-sm text-muted-foreground">{{ today }} · 商务工作台</p>
</kra-card>
<business-dashboard />
<div class="grid grid-cols-1 items-stretch gap-2 xl:grid-cols-2">
<kra-card title="快捷功能" show-action custom-class="min-h-[300px]"><kra-quick-link /></kra-card>
<kra-card title="公告" show-action custom-class="min-h-[300px]"><kra-notice /></kra-card>
</div>
<business-dashboard>
<template #heading>
<div>
<h1 class="text-xl font-semibold text-base-text">欢迎回来,{{ userStore.userInfo.nickName }}</h1>
<p class="mt-2 text-sm text-muted-foreground">{{ today }} · 商务工作台</p>
</div>
</template>
</business-dashboard>
</div>
</div>
</template>
@@ -19,7 +17,6 @@
import { computed } from 'vue'
import { useUserStore } from '@/pinia/modules/user'
import BusinessDashboard from './components/BusinessDashboard.vue'
import { KraNotice, KraQuickLink, KraCard } from './components'
defineOptions({ name: 'Dashboard' })
const userStore = useUserStore()
const today = computed(() => new Date().toLocaleDateString('zh-CN'))
+4 -4
View File
@@ -7,14 +7,14 @@
boxShadow: 'var(--kra-header-shadow)'
}"
>
<div class="flex items-center cursor-pointer flex-1">
<div class="flex min-w-0 items-center cursor-pointer flex-1">
<!-- 移动端(<640):展开抽屉菜单的入口,替代 Logo -->
<button
v-if="isMobile"
type="button"
aria-label="打开菜单"
:class="cn(
'flex h-9 w-9 cursor-pointer appearance-none items-center justify-center rounded-md bg-transparent text-base-text transition-colors hover:bg-muted',
'flex h-9 w-9 shrink-0 cursor-pointer appearance-none items-center justify-center rounded-md bg-transparent text-base-text transition-colors hover:bg-muted',
FOCUS_RING
)"
@click="appStore.toggleMobileMenu(true)"
@@ -71,11 +71,11 @@
/>
</div>
<div class="ml-2 flex items-center gap-3">
<div class="ml-1 sm:ml-2 flex items-center gap-1 sm:gap-3">
<tools />
<!-- 0.5px 极细分隔线,区分工具区与用户区 -->
<div
class="h-6 w-px bg-border"
class="hidden sm:block h-6 w-px bg-border"
role="separator"
/>
<!-- hover 触发:鼠标移上用户胶囊即展开操作菜单;点击胶囊则钉住展开(移出也保持),点击面板外或选择菜单项收起 -->
+3 -1
View File
@@ -1,9 +1,10 @@
<template>
<!-- 工具按钮组:扁平图标,无独立描边/阴影,hover 时图标 morph 为文案(取代 tooltip)。 -->
<div class="flex items-center gap-1 mx-3">
<div class="flex items-center gap-1 mx-0 sm:mx-3">
<business-preference />
<icon-button
v-if="settings.header.search.visible"
class="hidden sm:inline-flex"
icon="lucide:search"
label="搜索"
@click="handleCommand"
@@ -17,6 +18,7 @@
<icon-button
v-if="settings.header.refresh.visible"
class="hidden sm:inline-flex"
icon="lucide:refresh-cw"
label="刷新"
:icon-class="showRefreshAnmite ? 'animate-spin' : ''"
+12 -3
View File
@@ -1,6 +1,11 @@
<template>
<div>
<el-tabs v-model="activeTab"><el-tab-pane label="商务列表" name="accounts" /><el-tab-pane v-if="isLevel3" label="绑定列表" name="bindings" /></el-tabs>
<div v-if="isLevel3" class="business-view-switch">
<el-radio-group v-model="activeTab" aria-label="商务管理视图">
<el-radio-button value="accounts">商务列表</el-radio-button>
<el-radio-button value="bindings">绑定列表</el-radio-button>
</el-radio-group>
</div>
<business-bindings v-if="isLevel3 && activeTab === 'bindings'" @changed="load" />
<div v-show="activeTab === 'accounts'" class="kra-search-box">
<el-form :inline="true" :model="query" @submit.prevent="search">
@@ -20,8 +25,8 @@
</el-form>
</div>
<div v-show="activeTab === 'accounts'" class="kra-table-box">
<div class="kra-btn-list">
<el-button v-if="canAdd" type="primary" icon="plus" @click="openCreate">添加子商务</el-button>
<div v-if="canAdd" class="kra-btn-list">
<el-button type="primary" icon="plus" @click="openCreate">添加子商务</el-button>
</div>
<el-table v-loading="loading" :data="rows" row-key="userId">
<el-table-column prop="userName" label="商务账号" min-width="140" />
@@ -130,3 +135,7 @@ const save = async () => {
watch(isLevel3, (visible) => { if (!visible) activeTab.value = 'accounts' })
onMounted(load)
</script>
<style scoped>
.business-view-switch { margin-bottom: 12px; }
</style>
+9 -4
View File
@@ -9,8 +9,9 @@
</el-form>
</div>
<div class="kra-table-box">
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="OA 渠道激活数据源尚未接入或暂不可用。" />
<el-table v-loading="loading" :data="rows" row-key="id">
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="OA 渠道激活数据源尚未接入或暂不可用。" description="渠道激活记录来自 OA;商务库中的渠道数据不会自动生成激活记录。请联系管理员检查 OA 数据源连接。" />
<el-table v-loading="loading" :data="rows" row-key="id" :empty-text="emptyText">
<el-table-column prop="platformTitle" label="所属平台" min-width="100" />
<el-table-column prop="promoteAccount" label="渠道名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="busierAccount" label="绑定商务" min-width="130" show-overflow-tooltip />
@@ -55,6 +56,7 @@ const query = reactive({ pageNum: 1, pageSize: 10, platformNames: Object.keys(pl
const rows = ref([])
const total = ref(0)
const available = ref(true)
const error = ref('')
const loading = ref(false)
const dialog = ref(false)
const flowLoading = ref(false)
@@ -63,6 +65,7 @@ const selected = ref(null)
const detail = ref(null)
let listVersion = 0
let detailVersion = 0
const emptyText = computed(() => !query.platformNames.length ? '请选择所属平台' : error.value ? '查询失败,请重试' : !available.value ? 'OA 数据源暂不可用' : '当前筛选条件及账号权限下暂无激活记录')
const date = value => value ? value.slice(0, 10) : '—'
// 金额按字符串分组,保留超出 JavaScript 精确整数范围的金额。
const currency = value => {
@@ -80,14 +83,16 @@ const period = computed(() => {
})
const load = async () => {
const version = ++listVersion
// 保留总数直到查询完成;清零会让分页组件把当前页自动校正为 1。
rows.value = []; error.value = ''; available.value = true
if (!query.platformNames.length) { rows.value = []; total.value = 0; available.value = true; loading.value = false; return }
loading.value = true
try {
const res = await listChannelActivations({ ...query, platformNames: query.platformNames.join(','), promoteAccount: query.promoteAccount.trim(), busierAccount: query.busierAccount.trim() })
if (version !== listVersion) return
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total; available.value = res.data.available !== false }
else { rows.value = []; total.value = 0 }
} catch { if (version === listVersion) { rows.value = []; total.value = 0 } } finally { if (version === listVersion) loading.value = false }
else error.value = res.msg || '渠道激活查询失败,请重试'
} catch { if (version === listVersion) error.value = '渠道激活查询失败,请重试' } finally { if (version === listVersion) loading.value = false }
}
const search = () => { query.pageNum = 1; return load() }
const reset = () => { Object.assign(query, { platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '' }); return search() }
+7 -13
View File
@@ -14,13 +14,7 @@
<div v-loading="loading" class="ranking-panels">
<div v-for="panel in panels" :key="panel.key" class="kra-table-box ranking-panel">
<h3>{{ panel.title }}</h3>
<el-table :data="displayRows(panel)" row-key="rowKey" :row-class-name="({ row }) => row.rank <= 3 && !row.placeholder ? 'ranking-top' : ''">
<el-table-column label="所属平台" min-width="90"><template #default="{ row }">{{ row.sourceDbLabel || '—' }}</template></el-table-column>
<el-table-column label="#" width="45"><template #default="{ row }">{{ row.placeholder ? '—' : row.rank }}</template></el-table-column>
<el-table-column :label="`${dimensions[dimension]}名称`" min-width="130" show-overflow-tooltip><template #default="{ row }">{{ row.objectName || '—' }}</template></el-table-column>
<el-table-column :label="panel.label" min-width="125"><template #default="{ row }">{{ metric(panel, row.metricValue) }}</template></el-table-column>
<el-table-column label="上期环比" min-width="112"><template #default="{ row }"><el-text :type="row.momStatus > 0 ? 'success' : row.momStatus < 0 ? 'danger' : 'info'">{{ change(panel, row) }}</el-text></template></el-table-column>
</el-table>
<compact-ranking-table :title="panel.title" :rows="displayRows(panel)" :identity-label="dimensions[dimension]" :metric-label="panel.label" :value-formatter="value => metric(panel, value)" :wide-metric="panel.money" show-change />
</div>
</div>
</div>
@@ -30,6 +24,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { listRankings } from '@/api/ranking'
import { channelCount, channelMoney } from './channel-display'
import CompactRankingTable from './components/CompactRankingTable.vue'
defineOptions({ name: 'RankingAnalysis' })
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }, dimensions = { channel: '渠道', business: '商务', game: '游戏' }
const sources = ref(Object.keys(platforms)), dimension = ref('channel')
@@ -53,7 +48,6 @@ const displayRows = panel => {
return rows
}
const metric = (panel, value) => value == null ? '—' : panel.money ? channelMoney(value) : panel.rate ? `${value}%` : channelCount(value)
const change = (panel, row) => row.momValue == null ? '—' : `${row.momStatus > 0 ? '+' : ''}${row.momValue}${panel.rate ? ' 个百分点' : '%'}`
const load = async () => {
const current = ++version; loading.value = true; result.value = null; error.value = ''; available.value = true
try {
@@ -70,9 +64,9 @@ onMounted(load)
</script>
<style scoped>
.ranking-panels { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.ranking-panel { min-width: 0; }
.ranking-panel h3 { margin: 0 0 16px; font-weight: 600; }
:deep(.ranking-top) { --el-table-tr-bg-color: var(--el-color-primary-light-9); }
@media (max-width: 1100px) { .ranking-panels { grid-template-columns: 1fr; } }
.ranking-panels { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.ranking-panel { min-width: 0; padding: 14px 10px; }
.ranking-panel h3 { margin: 0 0 12px; font-weight: 600; }
@media (max-width: 1279px) { .ranking-panels { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 767px) { .ranking-panels { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,73 @@
<template>
<div class="compact-ranking">
<div class="ranking-table-wrap">
<table class="ranking-table">
<caption class="sr-only">{{ title }}</caption>
<colgroup>
<col class="ranking-position-col"><col><col class="ranking-platform-col">
<col :class="wideMetric ? 'ranking-money-col' : 'ranking-metric-col'">
<col v-if="showChange" class="ranking-change-col">
</colgroup>
<thead><tr>
<th scope="col">#</th><th scope="col">{{ identityLabel }}</th><th scope="col">平台</th>
<th scope="col" class="ranking-numeric">{{ metricLabel }}</th>
<th v-if="showChange" scope="col" class="ranking-numeric">上期环比</th>
</tr></thead>
<tbody>
<tr v-for="row in rows" :key="row.rowKey" :class="{ 'ranking-top': row.rank <= 3 && !row.placeholder }">
<td class="ranking-number">{{ row.placeholder ? '—' : row.rank }}</td>
<td :title="row.objectName">{{ row.objectName || '—' }}</td>
<td class="ranking-platform">{{ row.sourceDbLabel || '—' }}</td>
<td class="ranking-numeric" :title="valueFormatter(row.metricValue)">{{ valueFormatter(row.metricValue) }}</td>
<td v-if="showChange" class="ranking-numeric" :title="change(row)"><span :class="row.momStatus > 0 ? 'ranking-up' : row.momStatus < 0 ? 'ranking-down' : 'ranking-platform'">{{ change(row) }}</span></td>
</tr>
<tr v-if="!rows.length"><td :colspan="showChange ? 5 : 4" class="ranking-empty">{{ emptyText }}</td></tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, required: true },
rows: { type: Array, required: true },
identityLabel: { type: String, default: '渠道' },
metricLabel: { type: String, required: true },
valueFormatter: { type: Function, required: true },
wideMetric: Boolean,
showChange: Boolean,
emptyText: { type: String, default: '暂无数据' }
})
const change = row => row.momValue == null ? '—' : `${row.momStatus > 0 ? '+' : ''}${row.momValue}%`
</script>
<style scoped>
.compact-ranking { min-width: 0; container-type: inline-size; }
.ranking-table-wrap { border: 1px solid var(--el-border-color-lighter); border-radius: 6px; overflow: hidden; }
.ranking-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }
.ranking-position-col { width: 30px; }
.ranking-platform-col { width: 38px; }
.ranking-metric-col { width: 66px; }
.ranking-money-col { width: 102px; }
.ranking-change-col { width: 96px; }
.ranking-table th, .ranking-table td { height: 38px; box-sizing: border-box; padding: 0 5px; border-bottom: 1px solid var(--el-border-color-lighter); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: left; }
.ranking-table th { height: 36px; color: var(--el-text-color-secondary); font-size: 12px; font-weight: 500; background: var(--el-fill-color-light); }
.ranking-table tr:last-child td { border-bottom: 0; }
.ranking-table .ranking-numeric { text-align: right; }
.ranking-table .ranking-top { background: var(--el-color-primary-light-9); }
.ranking-table tbody tr:hover { background: var(--el-fill-color-light); }
.ranking-table .ranking-empty { height: 100px; text-align: center; color: var(--el-text-color-secondary); }
.ranking-platform, .ranking-number { color: var(--el-text-color-secondary); font-size: 12px; }
.ranking-up { color: var(--el-color-success); }
.ranking-down { color: var(--el-color-danger); }
@container (max-width: 300px) {
.ranking-table { font-size: 12px; }
.ranking-position-col { width: 24px; }
.ranking-platform-col { width: 28px; }
.ranking-metric-col { width: 58px; }
.ranking-money-col { width: 86px; }
.ranking-change-col { width: 80px; }
.ranking-table th, .ranking-table td { padding: 0 2px; }
}
</style>