feat: 迁移商务与游戏商务画像及交互看板
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import service from '@/utils/request'
|
||||
|
||||
export const getBusinessPortrait = (userId, params) => service({ url: `/sw/business/${userId}/portrait`, method: 'get', params })
|
||||
export const getBindingPortrait = params => service({ url: '/sw/business/bindPlatform/portrait', method: 'get', params })
|
||||
@@ -38,6 +38,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="注册时间" min-width="130"><template #default="{ row }">{{ row.createTime?.slice(0, 10) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" type="primary" link @click="showPortrait(row)">画像</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination">
|
||||
<el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" />
|
||||
@@ -45,6 +46,7 @@
|
||||
</div>
|
||||
<el-drawer append-to-body v-model="channelsDrawer" title="旗下渠道" size="520px" destroy-on-close><business-channels :user-id="channelUserId" /></el-drawer>
|
||||
<el-drawer append-to-body v-model="bindingsDrawer" title="SDK商务绑定详情" size="85%" destroy-on-close><business-bindings :user-id="bindingUserId" /></el-drawer>
|
||||
<business-profile-drawer v-model="portraitOpen" :user-id="portraitUser?.userId || 0" :name="portraitUser?.realName || portraitUser?.userName || ''" />
|
||||
<el-drawer append-to-body v-model="drawer" title="添加子商务" :size="480" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="商务账号" prop="userName"><el-input v-model="form.userName" maxlength="30" /></el-form-item>
|
||||
@@ -68,6 +70,7 @@ import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listBusiness, createBusiness } from '@/api/business'
|
||||
import BusinessBindings from './components/BusinessBindings.vue'
|
||||
import BusinessChannels from './components/BusinessChannels.vue'
|
||||
import BusinessProfileDrawer from './components/BusinessProfileDrawer.vue'
|
||||
|
||||
defineOptions({ name: 'SwBusinessList' })
|
||||
const user = useUserStore()
|
||||
@@ -79,9 +82,13 @@ const channelUserId = ref(0)
|
||||
const showChannels = (row) => { channelUserId.value = row.userId; channelsDrawer.value = true }
|
||||
const showBindings = (row) => { bindingUserId.value = row.userId; bindingsDrawer.value = true }
|
||||
const buttons = useBtnAuth()
|
||||
const portraitOpen = ref(false)
|
||||
const portraitUser = ref(null)
|
||||
const showPortrait = row => { portraitUser.value = row; portraitOpen.value = true }
|
||||
const levels = { 1: '一级商务', 2: '二级商务', 3: '三级商务' }
|
||||
const statuses = { 0: '正常', 1: '停用', 2: '已驳回', 3: '待审核' }
|
||||
const isAdmin = computed(() => Number(user.userInfo.authorityId) === 888)
|
||||
const canQuery = computed(() => isAdmin.value || !!buttons['sw:business:query'])
|
||||
const canAdd = computed(() => (isAdmin.value || buttons['sw:business:add']) && (isAdmin.value || Number(user.userInfo.level) < 3))
|
||||
const isLevel3 = computed(() => Number(user.userInfo.level) === 3)
|
||||
const childLevel = computed(() => levels[isAdmin.value ? 1 : Number(user.userInfo.level || 1) + 1])
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
<div class="kra-table-box">
|
||||
<div v-if="!userId && canBind" class="kra-btn-list"><el-button type="primary" icon="plus" @click="openBind">绑定游戏商务</el-button></div>
|
||||
<el-table v-loading="loading" :data="rows" :row-key="rowKey">
|
||||
<el-table-column prop="platformName" label="所属平台" min-width="80" />
|
||||
<el-table-column label="所属平台" min-width="80"><template #default="{ row }"><el-button v-if="userId && canPortrait && row.status === '0'" type="primary" link @click="showPortrait(row)">{{ row.platformName }}</el-button><span v-else>{{ row.platformName }}</span></template></el-table-column>
|
||||
<el-table-column v-if="userId" prop="platformBusierId" label="ID" min-width="90" />
|
||||
<el-table-column prop="platformBusierAccount" label="游戏商务账号" min-width="140" />
|
||||
<el-table-column v-if="!userId" prop="bindUserName" label="绑定商务" min-width="140" />
|
||||
<el-table-column v-if="!userId" label="旗下渠道数" min-width="110"><template #default="{ row }"><span v-if="row.promoteCount === null" class="text-gray-400">未接入</span><el-button v-else type="primary" link aria-label="查看旗下渠道" :disabled="row.status !== '0'" @click="showChannels(row)">{{ row.promoteCount }}</el-button></template></el-table-column>
|
||||
<el-table-column label="审核状态" width="110"><template #default="{ row }"><el-tag :type="row.status === '0' ? 'success' : row.status === '2' ? 'danger' : 'warning'">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="createTime" label="绑定时间" min-width="130"><template #default="{ row }">{{ row.createTime?.slice(0, 10) }}</template></el-table-column>
|
||||
<el-table-column v-if="!userId" label="操作" width="110" fixed="right">
|
||||
<el-table-column v-if="!userId" label="操作" width="145" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canBind && row.status === '0' && row.userId === user.userInfo.ID" link type="danger" @click="remove(row)">解绑</el-button>
|
||||
<template v-if="row.status === '0'"><el-button v-if="canPortrait" link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="canBind && row.userId === user.userInfo.ID" link type="danger" @click="remove(row)">解绑</el-button></template>
|
||||
<el-button v-else-if="row.status === '2'" link type="primary" @click="showReject(row)">查看驳回</el-button>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
@@ -29,6 +29,7 @@
|
||||
<div v-if="!userId" class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
<el-drawer append-to-body v-model="channelsDrawer" title="旗下渠道" size="520px" destroy-on-close><business-channels v-if="selectedChannel" :user-id="selectedChannel.userId" :source-db="selectedChannel.sourceDb" :platform-busier-id="selectedChannel.platformBusierId" /></el-drawer>
|
||||
<business-profile-drawer v-model="portraitOpen" :user-id="portraitRow?.userId || userId" :source-db="portraitRow?.sourceDb || ''" :platform-busier-id="portraitRow?.platformBusierId || 0" :name="portraitRow?.platformBusierAccount || ''" />
|
||||
<el-drawer append-to-body v-model="dialog" title="绑定游戏商务" size="460px" destroy-on-close>
|
||||
<el-alert title="验证成功后提交 OA 审核,审核通过后可查看对应渠道数据。" type="info" :closable="false" class="mb-4" />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
@@ -66,12 +67,17 @@ import { useUserStore } from '@/pinia/modules/user'
|
||||
import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listBusinessBindings, listUserBindings, bindPlatform, unbindPlatform } from '@/api/business'
|
||||
import BusinessChannels from './BusinessChannels.vue'
|
||||
import BusinessProfileDrawer from './BusinessProfileDrawer.vue'
|
||||
|
||||
defineOptions({ name: 'SwBusinessBindings' })
|
||||
const props = defineProps({ userId: { type: Number, default: 0 } })
|
||||
const emit = defineEmits(['changed'])
|
||||
const user = useUserStore()
|
||||
const buttons = useBtnAuth()
|
||||
const canPortrait = computed(() => Number(user.userInfo.authorityId) === 888 || !!buttons['sw:business:query'])
|
||||
const portraitOpen = ref(false)
|
||||
const portraitRow = ref(null)
|
||||
const showPortrait = row => { portraitRow.value = row; portraitOpen.value = true }
|
||||
const canBind = computed(() => !props.userId && Number(user.userInfo.level) === 3 && Boolean(buttons['sw:business:bind']))
|
||||
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const statuses = { 0: '正常', 2: '已驳回', 3: '待审核' }
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" append-to-body destroy-on-close :title="title" size="min(960px, 100vw)" @closed="reset">
|
||||
<div v-loading="loading">
|
||||
<el-alert v-if="error" type="warning" :closable="false" :title="error" />
|
||||
<template v-if="profile">
|
||||
<h3 class="profile-heading">基本信息</h3>
|
||||
<dl class="profile-info">
|
||||
<div><dt>商务账号</dt><dd>{{ basic.userName || '—' }}</dd></div>
|
||||
<div><dt>姓名</dt><dd>{{ basic.realName || '—' }}</dd></div>
|
||||
<div><dt>等级</dt><dd>{{ basic.levelLabel || (basic.level ?? '—') }}</dd></div>
|
||||
<div><dt>上级商务</dt><dd>{{ basic.parentUserName || basic.parentRealName || '—' }}</dd></div>
|
||||
<div><dt>注册时间</dt><dd>{{ basic.createTime || '—' }}</dd></div>
|
||||
<div><dt>状态</dt><dd><el-tag :type="basic.statusLabel === '正常' ? 'success' : 'info'" effect="plain">{{ basic.statusLabel || '—' }}</el-tag></dd></div>
|
||||
<div><dt>旗下渠道数</dt><dd><span v-if="basic.promoteCount == null">未接入</span><el-button v-else link type="primary" @click="channelsOpen = true">{{ channelCount(basic.promoteCount) }}</el-button></dd></div>
|
||||
<div><dt>本月新增</dt><dd>{{ basic.monthNewPromote == null ? '未接入' : `+${channelCount(basic.monthNewPromote)}` }}</dd></div>
|
||||
</dl>
|
||||
<div class="profile-section-title"><h3 class="profile-heading">核心指标</h3><el-radio-group v-model="period" size="small" :disabled="loading" @change="loadProfile"><el-radio-button v-for="item in periods" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></div>
|
||||
<el-alert v-if="profile.available === false" class="mb-4" type="warning" :closable="false" :title="profile.unavailableReason || '画像数据尚未接入'" />
|
||||
<template v-else-if="profile.metrics">
|
||||
<div class="profile-cards">
|
||||
<el-card v-for="card in cards" :key="card.key" shadow="never">
|
||||
<div class="metric-label">{{ card.label }}</div><strong class="metric-value">{{ card.value }}</strong>
|
||||
<div class="metric-compare" :class="card.tone">{{ card.comparison }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<h3 class="profile-heading">数据趋势</h3>
|
||||
<div class="profile-two-columns">
|
||||
<el-card shadow="never"><template #header>{{ rangeLabel }}注册量{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.registerTrend" title="注册量趋势" /></el-card>
|
||||
<el-card shadow="never"><template #header>{{ rangeLabel }}充值金额{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.payTrend" title="充值金额趋势" money color="var(--el-color-success)" /></el-card>
|
||||
</div>
|
||||
<h3 class="profile-heading">旗下渠道排行</h3>
|
||||
<div class="profile-two-columns">
|
||||
<el-card shadow="never"><template #header>注册量 TOP 5</template><el-table :data="profile.registerTop5" size="small"><el-table-column label="渠道" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.promoteName || '—' }} <el-text type="info" size="small">{{ row.sourceDbLabel }}</el-text></template></el-table-column><el-table-column label="注册量" min-width="100"><template #default="{ row }">{{ number(row.value) }}</template></el-table-column></el-table></el-card>
|
||||
<el-card shadow="never"><template #header>充值金额 TOP 5</template><el-table :data="profile.payTop5" size="small"><el-table-column label="渠道" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.promoteName || '—' }} <el-text type="info" size="small">{{ row.sourceDbLabel }}</el-text></template></el-table-column><el-table-column label="充值金额" min-width="120"><template #default="{ row }">{{ channelMoney(row.value) }}</template></el-table-column></el-table></el-card>
|
||||
</div>
|
||||
</template>
|
||||
<h3 class="profile-heading">关联预警</h3>
|
||||
<el-alert v-if="alertError" type="warning" :closable="false" :title="alertError" />
|
||||
<template v-else>
|
||||
<el-table v-loading="alertsLoading" :data="alerts" size="small">
|
||||
<el-table-column label="时间" width="120"><template #default="{ row }">{{ row.createdAt?.slice(5, 16) || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="targetPromoteName" label="渠道" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="预警类型" min-width="130"><template #default="{ row }">{{ alertType(row) }}</template></el-table-column>
|
||||
<el-table-column prop="alertContent" label="详情" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 5 ? 'success' : 'warning'" effect="plain">{{ row.statusLabel || alertStatuses[row.status] }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="alertTotal" class="kra-pagination"><el-pagination v-model:current-page="alertPage" :page-size="10" :total="alertTotal" layout="total, prev, pager, next" @current-change="loadAlerts" /></div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="visible = false">关闭</el-button></template>
|
||||
</el-drawer>
|
||||
<el-drawer v-model="channelsOpen" append-to-body destroy-on-close title="旗下渠道" size="min(520px, 100vw)"><business-channels :user-id="userId" :source-db="sourceDb" :platform-busier-id="platformBusierId" /></el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getBusinessPortrait, getBindingPortrait } from '@/api/businessPortrait'
|
||||
import { listAlertRecords } from '@/api/alert'
|
||||
import { channelCount, channelMoney } from '../channel-display'
|
||||
import { alertStatuses } from '../alert-display'
|
||||
import BusinessTrend from './BusinessTrend.vue'
|
||||
import BusinessChannels from './BusinessChannels.vue'
|
||||
const props = defineProps({ modelValue: Boolean, userId: { type: Number, default: 0 }, sourceDb: { type: String, default: '' }, platformBusierId: { type: Number, default: 0 }, name: { type: String, default: '' } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
|
||||
const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }, { label: '本月', value: 'thismonth' }]
|
||||
const period = ref('today')
|
||||
const profile = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const channelsOpen = ref(false)
|
||||
const alerts = ref([])
|
||||
const alertPage = ref(1)
|
||||
const alertTotal = ref(0)
|
||||
const alertsLoading = ref(false)
|
||||
const alertError = ref('')
|
||||
let profileVersion = 0, alertVersion = 0
|
||||
const basic = computed(() => profile.value?.basic || {})
|
||||
const sources = computed(() => props.sourceDb || 'lx,hw,yq')
|
||||
const title = computed(() => `${props.platformBusierId ? '游戏商务画像' : '商务画像'}${props.name || basic.value.userName ? ` — ${props.name || basic.value.userName}` : ''}`)
|
||||
const rangeLabel = computed(() => periods.find(item => item.value === period.value)?.label || '今日')
|
||||
const hourly = computed(() => ['today', 'yesterday'].includes(period.value))
|
||||
const number = value => value == null ? '—' : channelCount(String(value).replace(/\.0+$/, ''))
|
||||
const cards = computed(() => [
|
||||
{ key: 'register', label: '注册玩家', format: number }, { key: 'payAmount', label: '充值金额', format: channelMoney },
|
||||
{ key: 'payRate', label: '付费率', format: value => value == null ? '—' : `${value}%` }, { key: 'arpu', label: '人均充值', format: channelMoney }
|
||||
].map(card => {
|
||||
const metric = profile.value?.metrics?.[card.key]
|
||||
const diff = metric?.changeRate ?? metric?.changeDiff
|
||||
const numeric = diff == null ? null : Number(diff)
|
||||
const suffix = metric?.changeRate != null ? '%' : ' 个百分点'
|
||||
return { ...card, value: card.format(metric?.value), tone: numeric == null || numeric === 0 ? '' : numeric > 0 ? 'positive' : 'negative', comparison: diff == null ? `${metric?.compareLabel || ''} ${metric?.compareValue == null ? '—' : card.format(metric.compareValue)}` : `${metric?.compareLabel || ''} ${numeric > 0 ? '↑ ' : numeric < 0 ? '↓ ' : ''}${Math.abs(numeric).toFixed(1)}${suffix}` }
|
||||
}))
|
||||
const alertType = row => ({ register_anomaly: '注册显著暴跌', recharge_anomaly: '充值额暴涨', login_anomaly: '渠道长期未登录' })[row.ruleType] || row.ruleName || row.ruleType || '—'
|
||||
const reset = () => { ++profileVersion; ++alertVersion; profile.value = null; error.value = ''; alerts.value = []; alertTotal.value = 0; alertPage.value = 1; alertError.value = ''; loading.value = false; alertsLoading.value = false; channelsOpen.value = false }
|
||||
const loadProfile = async () => {
|
||||
const version = ++profileVersion
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const res = props.platformBusierId ? await getBindingPortrait({ sourceDb: props.sourceDb, platformBusierId: props.platformBusierId, period: period.value }) : await getBusinessPortrait(props.userId, { sourceDbList: sources.value, period: period.value })
|
||||
if (version !== profileVersion) return
|
||||
if (res.code === 0) profile.value = res.data
|
||||
else { profile.value = null; error.value = res.msg || '画像读取失败' }
|
||||
} catch { if (version === profileVersion) { profile.value = null; error.value = '画像读取失败,请重试' } } finally { if (version === profileVersion) loading.value = false }
|
||||
}
|
||||
const loadAlerts = async () => {
|
||||
const version = ++alertVersion
|
||||
alertsLoading.value = true; alertError.value = ''
|
||||
try {
|
||||
const res = await listAlertRecords({ platformCodes: sources.value, businessId: props.userId, pageNum: alertPage.value, pageSize: 10 })
|
||||
if (version !== alertVersion) return
|
||||
if (res.code === 0 && res.data.available !== false) { alerts.value = res.data.pageData.rows; alertTotal.value = res.data.pageData.total }
|
||||
else { alerts.value = []; alertTotal.value = 0; alertError.value = res.code === 0 ? '渠道数据尚未接入,暂时无法核对关联预警。' : res.msg || '关联预警读取失败' }
|
||||
} catch { if (version === alertVersion) { alerts.value = []; alertError.value = '关联预警读取失败,请重试' } } finally { if (version === alertVersion) alertsLoading.value = false }
|
||||
}
|
||||
watch(() => [props.modelValue, props.userId, props.sourceDb, props.platformBusierId], ([open]) => {
|
||||
if (open && props.userId) { reset(); period.value = 'today'; void loadProfile(); void loadAlerts() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-heading { margin: 20px 0 14px; font-size: 16px; font-weight: 600; }
|
||||
.profile-info { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0; }
|
||||
.profile-info dt, .metric-label, .metric-compare { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.profile-info dd { margin: 6px 0 0; overflow-wrap: anywhere; }
|
||||
.profile-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.profile-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.metric-value { display: block; font-size: 23px; margin: 12px 0; overflow-wrap: anywhere; }
|
||||
.metric-compare.positive { color: var(--el-color-success); }
|
||||
.metric-compare.negative { color: var(--el-color-danger); }
|
||||
.profile-two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
@media (max-width: 700px) { .profile-info, .profile-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .profile-two-columns { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="business-trend">
|
||||
<svg viewBox="0 0 600 260" role="img" :aria-label="title" tabindex="0" @mousemove="hover" @mouseleave="active = null" @focus="active = 0" @blur="active = null" @keydown.left.prevent="move(-1)" @keydown.right.prevent="move(1)">
|
||||
<g v-for="tick in ticks" :key="tick.y">
|
||||
<line x1="52" :y1="tick.y" x2="582" :y2="tick.y" class="grid-line" />
|
||||
<text x="44" :y="tick.y + 4" text-anchor="end" class="axis-text">{{ compact(tick.value) }}</text>
|
||||
</g>
|
||||
<polygon v-if="points.length" :points="area" :fill="color" opacity="0.08" />
|
||||
<polyline :points="line" fill="none" :stroke="color" stroke-width="2.5" stroke-linejoin="round" />
|
||||
<text v-for="index in labelIndexes" :key="index" :x="pointX(index)" y="244" text-anchor="middle" class="axis-text">{{ series[index]?.label }}</text>
|
||||
<template v-if="selected">
|
||||
<line :x1="selected.x" y1="18" :x2="selected.x" y2="220" class="cursor-line" />
|
||||
<circle :cx="selected.x" :cy="selected.y" r="4" :fill="color" />
|
||||
</template>
|
||||
</svg>
|
||||
<div v-if="selected" role="status" aria-live="polite" class="trend-tooltip" :style="{ left: `${Math.max(15, Math.min(85, selected.x / 6))}%` }">
|
||||
<span>{{ selected.label }}</span><strong>{{ formatted(selected.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { channelCount, channelMoney } from '../channel-display'
|
||||
const props = defineProps({ series: { type: Array, default: () => [] }, title: { type: String, default: '' }, money: Boolean, color: { type: String, default: 'var(--el-color-primary)' } })
|
||||
const active = ref(null)
|
||||
const values = computed(() => props.series.map(point => Number.isFinite(Number(point.value)) ? Number(point.value) : 0))
|
||||
const maximum = computed(() => Math.max(1, ...values.value))
|
||||
const minimum = computed(() => Math.min(0, ...values.value))
|
||||
const pointY = value => 220 - (value - minimum.value) / (maximum.value - minimum.value) * 202
|
||||
const pointX = index => 52 + index * 530 / Math.max(1, props.series.length - 1)
|
||||
const points = computed(() => props.series.map((point, index) => ({ ...point, x: pointX(index), y: pointY(values.value[index]) })))
|
||||
const line = computed(() => points.value.map(point => `${point.x},${point.y}`).join(' '))
|
||||
const area = computed(() => `52,${pointY(0)} ${line.value} ${points.value.at(-1)?.x || 52},${pointY(0)}`)
|
||||
const ticks = computed(() => [0, 1, 2, 3, 4].map(index => ({ y: 220 - index * 50.5, value: minimum.value + (maximum.value - minimum.value) * index / 4 })))
|
||||
const labelIndexes = computed(() => [...new Set([0, 1, 2, 3, 4].map(index => Math.round(Math.max(0, props.series.length - 1) * index / 4)))])
|
||||
const selected = computed(() => active.value == null ? null : points.value[active.value])
|
||||
const compact = value => new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
|
||||
const formatted = value => props.money ? channelMoney(value) : channelCount(String(value).replace(/\.0+$/, ''))
|
||||
const hover = event => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const x = (event.clientX - bounds.left) / Math.max(1, bounds.width) * 600
|
||||
active.value = Math.max(0, Math.min(props.series.length - 1, Math.round((x - 52) / 530 * Math.max(0, props.series.length - 1))))
|
||||
}
|
||||
const move = step => { active.value = Math.max(0, Math.min(props.series.length - 1, (active.value ?? 0) + step)) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.business-trend { position: relative; }
|
||||
svg { display: block; width: 100%; outline: none; }
|
||||
svg:focus-visible { outline: 2px solid var(--el-color-primary-light-5); border-radius: 6px; }
|
||||
.grid-line { stroke: var(--el-border-color-lighter); stroke-dasharray: 3 4; }
|
||||
.axis-text { fill: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.cursor-line { stroke: var(--el-border-color); stroke-dasharray: 3 3; }
|
||||
.trend-tooltip { position: absolute; top: 8px; transform: translateX(-50%); display: flex; gap: 12px; padding: 8px 12px; border: 1px solid var(--el-border-color-light); border-radius: 6px; background: var(--el-bg-color-overlay); box-shadow: var(--el-box-shadow-light); pointer-events: none; font-size: 12px; white-space: nowrap; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user