feat: automate business upgrades and sync reviewed frontend
This commit is contained in:
@@ -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 === '未接入'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user