feat: automate business upgrades and sync reviewed frontend

This commit is contained in:
Yvan
2026-11-06 15:48:35 +08:00
parent 7df74bc8e8
commit 9fccd804a3
58 changed files with 5689 additions and 259 deletions
@@ -27,7 +27,7 @@
<el-alert v-if="kpiError" :title="kpiError" type="warning" :closable="false" />
<div v-loading="kpiLoading" class="kpi-grid">
<article v-for="item in kpiList" :key="item.label" class="kpi-card">
<el-tooltip :content="item.tip"><p class="kpi-label">{{ item.label }}</p></el-tooltip>
<p class="kpi-label"><span class="has-tip" @mouseenter="showFieldTip($event, item.tip)" @mouseleave="hideFieldTip">{{ item.label }}</span></p>
<p class="kpi-value">{{ item.value }}</p>
<p class="kpi-change" :class="{ up: item.up, down: item.down }">{{ item.change }}</p>
</article>
@@ -188,6 +188,9 @@
</div>
</section>
</div>
<teleport to="body">
<div v-if="fieldTip.open" ref="fieldTipRef" class="field-tip" :class="{ ready: fieldTip.ready }" :style="{ left: `${fieldTip.x}px`, top: `${fieldTip.y}px` }">{{ fieldTip.text }}</div>
</teleport>
</div>
</template>
@@ -252,6 +255,35 @@ const overviewPeriod = ref('today')
const overviewDates = ref([])
const kpiError = ref('')
const kpiList = ref(overviewCards(null))
const fieldTip = ref({ open: false, ready: false, text: '', x: 0, y: 0 })
const fieldTipRef = ref()
let fieldTipToken = 0
const placeFieldTip = anchor => {
const el = fieldTipRef.value
if (!el) return
const margin = 8
const left = Math.max(margin, Math.min(anchor.left + anchor.width / 2 - el.offsetWidth / 2, window.innerWidth - el.offsetWidth - margin))
const above = anchor.top - el.offsetHeight - margin
fieldTip.value = { ...fieldTip.value, ready: true, x: left, y: above >= margin ? above : anchor.bottom + margin }
}
const showFieldTip = async (event, text) => {
const token = ++fieldTipToken
const anchor = event.currentTarget.getBoundingClientRect()
fieldTip.value = { open: true, ready: false, text, x: -9999, y: 0 }
await nextTick()
if (token === fieldTipToken) placeFieldTip(anchor)
}
const hideFieldTip = () => {
fieldTipToken += 1
if (fieldTip.value.open) fieldTip.value = { ...fieldTip.value, open: false, ready: false }
}
const hideFieldTipOnScroll = () => {
if (fieldTip.value.open) hideFieldTip()
}
const requestVersions = { summary: 0, register: 0, recharge: 0, offline: 0, low: 0, alerts: 0 }
const kpiLoading = ref(false)
const alertLoading = ref(false)
@@ -759,6 +791,7 @@ onMounted(() => {
fetchLowRechargeRank()
})
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', hideFieldTipOnScroll, true)
themeObserver = new MutationObserver(() => { renderRegisterChart(); renderRechargeChart() })
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
@@ -769,6 +802,7 @@ onBeforeUnmount(() => {
stopAlertTimer()
if (alertAnimTimer) clearTimeout(alertAnimTimer)
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', hideFieldTipOnScroll, true)
registerChart?.dispose()
rechargeChart?.dispose()
registerChart = undefined
@@ -861,6 +895,16 @@ onBeforeUnmount(() => {
line-height: 1.4;
}
.has-tip {
cursor: help;
border-bottom: 1px dashed transparent;
}
.has-tip:hover {
color: var(--dash-blue);
border-bottom-color: var(--dash-blue);
}
.kpi-value {
margin: 0;
color: var(--dash-title);
@@ -1244,3 +1288,27 @@ html.dark .alert-body {
}
}
</style>
<style lang="scss">
.field-tip {
position: fixed;
z-index: 3000;
visibility: hidden;
max-width: 320px;
padding: 8px 10px;
border-radius: 8px;
background: #1e2433;
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: 1.55;
text-align: left;
white-space: pre-line;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
pointer-events: none;
}
.field-tip.ready {
visibility: visible;
}
</style>
@@ -1,14 +1,30 @@
import { channelCount, channelMoney } from '../../sw/channel-display.js'
const formatTipAmount = amount => {
const value = Number(amount)
return Number.isFinite(value) ? value.toLocaleString('en-US') : String(amount)
}
const effectiveTip = data => {
const days = data?.settings?.days ?? 30
const amount = formatTipAmount(data?.settings?.amount ?? 3000)
return [
'所选区间内达成有效端的渠道数。',
`判定:渠道注册后 ${days} 天内累计充值流水满 ¥${amount} 即达成,按达标当天计入;`,
`注册满 ${days} 天仍未达标的不算,之后流水上来也不补记;达标后标记保持。`,
'阈值与天数由超管在「设置」中配置。'
].join('\n')
}
const definitions = [
{ key: 'todayRegister', label: '玩家注册' },
{ key: 'todayActive', label: '活跃玩家' },
{ key: 'todayPay', label: '充值金额', money: true },
{ key: 'todayRegister', label: '玩家注册', tip: '所选区间内新增注册玩家数。\n勾选右上角「身份证去重」后按实名身份证去重:同一身份证多账号计 1 人,无实名信息不计入。' },
{ key: 'todayActive', label: '活跃玩家', tip: '所选区间内登录游戏的去重玩家数。\n勾选「身份证去重」后按实名身份证去重,无实名信息不计入。' },
{ key: 'todayPay', label: '充值金额', money: true, tip: '所选区间内所有渠道的充值成功金额汇总,已扣除失败 / 取消订单。' },
{ key: 'effectivePromote', label: '有效端' },
{ key: 'activePromote', label: '活跃渠道数' },
{ key: 'newPromote', label: '新增渠道数' },
{ key: 'lockedPromote', label: '锁定渠道', event: true },
{ key: 'terminatedPromote', label: '解除合作渠道', event: true }
{ key: 'activePromote', label: '活跃渠道数', tip: '所选区间内至少产生 1 个注册或 1 笔充值的渠道数。' },
{ key: 'newPromote', label: '新增渠道数', tip: '所选区间内新建立合作关系的渠道数,以渠道在系统中首次创建 / 启用时间为准。' },
{ key: 'lockedPromote', label: '锁定渠道', event: true, tip: '所选区间内被锁定的渠道数,按锁定时间落在区间内统计。\n统计的是「这段时间内发生了多少」,与渠道当前状态无关:例如 1 月锁定、2 月恢复正常,在 1 月区间里仍算锁定渠道。' },
{ key: 'terminatedPromote', label: '解除合作渠道', event: true, tip: '所选区间内被解除合作的渠道数,按解除合作时间落在区间内统计。\n解除合作与锁定分开展示,二者都属于被锁定的范畴。' }
]
export const overviewCards = (data) => definitions.map(definition => {
@@ -23,9 +39,7 @@ export const overviewCards = (data) => definitions.map(definition => {
change,
up: known && changeRate > 0,
down: known && changeRate < 0,
tip: definition.key === 'effectivePromote'
? `渠道注册后 ${data?.settings?.days ?? 30} 天内充值满 ¥${data?.settings?.amount ?? 3000},按达标日期统计。`
: definition.event ? '按所选区间内的状态变更事件统计。' : '所选时间区间内的数据,与上一等长周期比较。'
tip: definition.key === 'effectivePromote' ? effectiveTip(data) : definition.tip
}
})
@@ -14,7 +14,9 @@ test('overview preserves exact money, eight cards, and unavailable event data',
assert.equal(cards.find(c => c.key === 'todayRegister').value, '0')
assert.equal(cards.find(c => c.key === 'todayActive').value, '5,998')
assert.equal(cards.find(c => c.key === 'lockedPromote').value, '未接入')
assert.match(cards.find(c => c.key === 'effectivePromote').tip, /15 天内充值满 ¥500/)
assert.equal(cards.find(c => c.key === 'todayRegister').tip, '所选区间内新增注册玩家数。\n勾选右上角「身份证去重」后按实名身份证去重:同一身份证多账号计 1 人,无实名信息不计入。')
assert.match(cards.find(c => c.key === 'effectivePromote').tip, /注册后 15 天内累计充值流水满 ¥500/)
assert.match(cards.find(c => c.key === 'lockedPromote').tip, /1 月锁定、2 月恢复正常/)
assert.ok(overviewCards(null).every(c => c.value === '—'))
assert.ok(overviewCards({ available: false }).every(c => c.value === '未接入'))
})