fix: align business pages and detail drawers with prototype

This commit is contained in:
Yvan
2026-11-05 16:18:39 +08:00
parent 27cacffc7f
commit db11b6eb54
28 changed files with 603 additions and 87 deletions
+13 -8
View File
@@ -11,10 +11,10 @@
<div class="kra-table-box" v-loading="loading">
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3" />
<el-tabs v-model="tab"><el-tab-pane label="商务绩效" name="business" /><el-tab-pane label="趋势图" name="trend" /><el-tab-pane label="小组绩效" name="groups" /></el-tabs>
<div v-if="tab === 'business'" class="matrix-toolbar"><el-select v-model="groupFilter" clearable @change="load" placeholder="全部小组" style="width:190px"><el-option v-for="group in groupOptions" :key="group.id" :label="group.name" :value="group.id" /></el-select><el-text type="info">每行包含本人及全部下级,组小计和合计按渠道去重;按住表格可拖动查看。</el-text></div>
<div v-if="tab === 'business'" class="matrix-toolbar"><el-select v-model="groupFilter" clearable @change="load" placeholder="全部小组" style="width:190px"><el-option v-for="group in groupOptions" :key="group.id" :label="group.name" :value="group.id" /></el-select><el-text type="info">点数字查看该商务对应周期的渠道明细</el-text></div>
<performance-scroll v-if="tab === 'business' && data" vertical>
<table class="matrix" :style="{ width: `${businessWidth}px` }"><colgroup><col v-for="col in businessColumns" :key="col.key" :style="{ width: `${col.width}px` }" /><template v-for="period in descendingPeriods" :key="period.key"><col v-for="metric in metrics" :key="metric.key" style="width:110px" /></template></colgroup>
<thead><tr><th v-for="col in businessColumns" :key="col.key" rowspan="2" class="sticky" :style="{ left: `${col.left}px` }" @click="col.sort && sortBy(col.key)"><el-tooltip :content="col.tip || col.label"><span>{{ col.label }}<span v-if="col.sort" class="sort-icon">{{ query.sort === col.key ? query.order === 'asc' ? '▲' : '▼' : '◇' }}</span></span></el-tooltip></th><th v-for="period in descendingPeriods" :key="period.key" colspan="3">{{ period.label }}</th></tr><tr><template v-for="period in descendingPeriods" :key="period.key"><th v-for="metric in metrics" :key="metric.key" :class="metric.key">{{ metric.label }}</th></template></tr></thead>
<thead><tr><th v-for="col in businessColumns" :key="col.key" rowspan="2" class="sticky" :style="{ left: `${col.left}px` }" @click="col.sort && sortBy(col.key)"><el-tooltip :content="col.tip || col.label"><span>{{ col.label }}<span v-if="col.sort" class="sort-icon" :class="query.sort === col.key ? query.order : ''"><i class="sort-up" /><i class="sort-down" /></span></span></el-tooltip></th><th v-for="period in descendingPeriods" :key="period.key" colspan="3">{{ period.label }}</th></tr><tr><template v-for="period in descendingPeriods" :key="period.key"><th v-for="metric in metrics" :key="metric.key" :class="metric.key">{{ metric.label }}</th></template></tr></thead>
<tbody><tr v-for="row in matrixRows" :key="row.key" :class="{ subtotal: row.subtotal, departed: row.member?.employment === 'departed' }">
<td v-if="row.span" :rowspan="row.span" class="sticky group-cell" style="left:0"><strong>{{ row.groupName || '—' }}</strong><div v-if="row.groupName" class="muted">{{ row.memberCount }} 人</div></td>
<td class="sticky" :style="{ left: `${businessColumns[1].left}px` }" :title="row.member ? `含 ${row.descendantCount} 个下级商务` : '成员及其下级的渠道并集去重'"><strong>{{ row.name }}</strong><div v-if="row.member" class="muted">{{ businessLevels[row.member.level] }}</div></td>
@@ -23,7 +23,7 @@
<td class="sticky" :style="{ left: `${businessColumns[7].left}px` }">{{ rateText(effectiveRate(row.total)) }}</td>
<template v-for="period in descendingPeriods" :key="period.key"><td v-for="metric in metrics" :key="metric.key" :class="metric.key"><button :disabled="!Number(row.periods[period.key]?.[metric.key])" @click="drill(row, period, metric.key)">{{ displayMetric(metric.key, row.periods[period.key]?.[metric.key] ?? 0) }}</button></td></template>
</tr><tr v-if="!matrixRows.length"><td :colspan="8 + descendingPeriods.length * 3">暂无符合条件的商务</td></tr></tbody>
<tfoot><tr><td colspan="4" class="sticky" style="left:0">筛选结果去重合计</td><td v-for="(metric,index) in metrics" :key="metric.key" class="sticky" :style="{ left: `${businessColumns[index + 4].left}px` }">{{ displayMetric(metric.key, data?.matrixTotal?.[metric.key] ?? 0) }}</td><td class="sticky" :style="{ left: `${businessColumns[7].left}px` }">{{ data?.matrixRate == null ? '—' : `${data.matrixRate}%` }}</td><template v-for="period in descendingPeriods" :key="period.key"><td v-for="metric in metrics" :key="metric.key">{{ displayMetric(metric.key, data?.matrixTrend?.[period.key]?.[metric.key] ?? 0) }}</td></template></tr></tfoot>
<tfoot><tr><td colspan="4" class="sticky" style="left:0" title="可见范围内全部渠道去重合计,仅随统计区间变化,不随商务名称、层级或小组筛选变化。">可见范围合计</td><td v-for="(metric,index) in metrics" :key="metric.key" class="sticky" :style="{ left: `${businessColumns[index + 4].left}px` }">{{ displayMetric(metric.key, data?.matrixTotal?.[metric.key] ?? 0) }}</td><td class="sticky" :style="{ left: `${businessColumns[7].left}px` }">{{ data?.matrixRate == null ? '—' : `${data.matrixRate}%` }}</td><template v-for="period in descendingPeriods" :key="period.key"><td v-for="metric in metrics" :key="metric.key">{{ displayMetric(metric.key, data?.matrixTrend?.[period.key]?.[metric.key] ?? 0) }}</td></template></tr></tfoot>
</table>
</performance-scroll>
<template v-if="tab === 'groups' && data">
@@ -44,7 +44,7 @@
</template>
<div v-if="tab === 'trend' && data" class="trend-layout"><performance-trend :periods="data?.periods || []" :trend="data?.trend || {}" /><div><el-text type="info">共 {{ descendingPeriods.length }} {{ data?.granularity === 'day' ? '天,表内可滚动' : '个月' }}</el-text><el-table scrollbar-always-on :data="trendRows" height="421" class="mt-2"><el-table-column prop="label" :label="data?.granularity === 'day' ? '日期' : '月份'" width="90" /><el-table-column v-for="metric in metrics" :key="metric.key" :label="metric.label" min-width="100"><template #default="{ row }">{{ displayMetric(metric.key, row.values[metric.key]) }}</template></el-table-column><el-table-column label="有效率" width="95"><template #default="{ row }">{{ rateText(effectiveRate(row.values)) }}</template></el-table-column><el-table-column v-for="metric in metrics" :key="`${metric.key}-change`" :label="`${metric.label}环比`" min-width="115"><template #default="{ row }">{{ row.changes[metric.key] }}</template></el-table-column></el-table></div></div>
</div>
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :title="detailTitle" />
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :context="detailContext" :title="detailTitle" />
</div>
</template>
<script setup>
@@ -73,15 +73,15 @@ const businessColumns = withOffsets([{ key: 'group', label: '小组', width: 125
const businessWidth = computed(() => businessColumns.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 330)
const groupColumns = computed(() => withOffsets([{ key: 'name', label: '小组 / 成员', width: 220 }, { key: 'count', label: '人数 / 在职', width: 105 }, ...(data.value?.granularity === 'day' ? [{ key: 'target', label: '目标', width: 160 }] : []), { key: 'actual', label: '达成', width: 165 }, { key: 'rate', label: '完成率', width: 145 }]))
const groupWidth = computed(() => groupColumns.value.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 325)
const matrixRows = computed(() => { const result = [], groups = new Map((data.value?.groups || []).map(g => [g.group.id, g])); const blocks = new Map(); for (const row of (data.value?.rows || [])) { const key = row.member.groupId || `none-${row.member.userId}`; if (!blocks.has(key)) blocks.set(key, []); blocks.get(key).push(row) } for (const [id, members] of blocks) { const group = groups.get(id); members.forEach((row, i) => result.push({ ...row, key: `person-${row.member.userId}`, kind: 'person', id: row.member.userId, name: displayMember(row.member), span: group ? i === 0 ? members.length + 1 : 0 : 1, groupName: group?.group.name, memberCount: members.length })); if (group) result.push({ key: `subtotal-${id}`, kind: 'group', id, name: `${group.group.name} 小计`, subtotal: true, memberIds: members.map(m => m.member.userId), total: group.uniqueTotal, periods: group.uniquePeriods }) } return result })
const matrixRows = computed(() => { const result = [], groups = new Map((data.value?.groups || []).map(g => [g.group.id, g])); const blocks = new Map(); for (const row of (data.value?.rows || [])) { const key = row.member.groupId || `none-${row.member.userId}`; if (!blocks.has(key)) blocks.set(key, []); blocks.get(key).push(row) } for (const [id, members] of blocks) { const group = groups.get(id); members.forEach((row, i) => result.push({ ...row, key: `person-${row.member.userId}`, kind: 'person', id: row.member.userId, name: displayMember(row.member), span: group ? i === 0 ? members.length + 1 : 0 : 1, groupName: group?.group.name, memberCount: members.length })); if (group) result.push({ key: `subtotal-${id}`, kind: 'group', id, name: `${group.group.name} 小计`, groupName: group.group.name, subtotal: true, memberIds: members.map(m => m.member.userId), total: group.uniqueTotal, periods: group.uniquePeriods }) } return result })
const toggleGroup = id => { const next = new Set(expanded.value); if (next.has(id)) next.delete(id); else next.add(id); expanded.value = next }
const teamRows = computed(() => (data.value?.groups || []).flatMap(group => { const row = { key: `group-${group.group.id}`, id: group.group.id, kind: 'team', name: group.group.name, total: group.total, periods: group.periods, goals: group.goals, memberCount: group.group.memberIds.length, overlapping: group.group.overlapping }; return [row, ...(expanded.value.has(group.group.id) ? group.members.map(m => ({ ...m, key: `group-${group.group.id}-person-${m.member.userId}`, id: m.member.userId, kind: 'person', name: displayMember(m.member) })) : [])] }))
const periodTarget = (row, period, metric) => { const value = row.goals?.[period.key.slice(0,7)]?.[metric.goal]; if (value == null) return null; if (data.value?.granularity !== 'day') return value; const [y, m] = period.key.split('-').map(Number); return value / new Date(y, m, 0).getDate() }
const periodRate = (row, period) => completionRate(row.periods[period.key]?.pay, periodTarget(row, period, metrics[2]))
const groupRate = (row, metric) => { let actual = 0, target = 0; const months = new Set(); for (const p of data.value?.periods || []) { const month = p.key.slice(0,7), value = row.goals?.[month]?.[metric.goal]; if (value == null) continue; actual += Number(row.periods[p.key]?.[metric.key] || 0); if (!months.has(month)) { target += value; months.add(month) } } return completionRate(actual, target) }
const trendRows = computed(() => (data.value?.periods || []).map((p, i, arr) => { const values = data.value.trend[p.key], previous = i ? data.value.trend[arr[i - 1].key] : data.value.prior; return { ...p, values, changes: Object.fromEntries(metrics.map(m => [m.key, previous && Number(previous[m.key]) ? `${((Number(values[m.key]) - Number(previous[m.key])) / Number(previous[m.key]) * 100).toFixed(1)}%` : '—'])) } }).reverse())
const detailOpen = ref(false), detailQuery = ref(null), detailTitle = ref('')
const drill = (row, period, metric) => { detailQuery.value = { kind: row.kind, targetId: row.id, memberIds: row.memberIds?.join(','), metric, begin: period?.begin || data.value.begin, end: period?.end || data.value.end }; detailTitle.value = `${row.name} · ${period?.key || '区间'} · ${metrics.find(m => m.key === metric).label}明细`; detailOpen.value = true }
const detailOpen = ref(false), detailQuery = ref(null), detailContext = ref(null), detailTitle = ref('')
const drill = (row, period, metric) => { detailQuery.value = { kind: row.kind, targetId: row.id, memberIds: row.memberIds?.join(','), metric, begin: period?.begin || data.value.begin, end: period?.end || data.value.end }; detailContext.value = { name: row.name, groupName: row.groupName || row.member?.groupName || (row.kind === 'group' || row.kind === 'team' ? row.name : ''), periodKey: period?.key, descendantCount: row.descendantCount, metrics: { ...(period ? row.periods[period.key] : row.total) }, settings: data.value.settings }; detailTitle.value = `${row.name} · ${period?.key || '区间'} · ${metrics.find(m => m.key === metric).label}明细`; detailOpen.value = true }
const download = async () => { if (exporting.value || !data.value || !loadedParams.value || loading.value) return; exporting.value = true; try { const result = await exportPerformance({ ...loadedParams.value, groupId: tab.value === 'business' ? loadedParams.value.groupId : undefined, view: tab.value }); const blob = result instanceof Blob ? result : result.data; if (!(blob instanceof Blob) || blob.type.includes('json')) return; const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = '商务绩效统计.xlsx'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } finally { exporting.value = false } }
onMounted(load)
</script>
@@ -103,7 +103,12 @@ onMounted(load)
.matrix button:not(:disabled):hover { text-decoration: underline; }
.open { color: #4f8cff; }.effective { color: #22b98a; }.pay { color: #b98b1f; }
.muted, .metric-line small { color: var(--el-text-color-secondary); font-size: 11px; }
.sort-icon { margin-left: 5px; color: var(--el-color-primary); }
.sort-icon { display: inline-flex; flex-direction: column; gap: 2px; margin-left: 6px; vertical-align: middle; }
.sort-icon i { width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; }
.sort-up { border-bottom: 5px solid var(--el-text-color-placeholder); }
.sort-down { border-top: 5px solid var(--el-text-color-placeholder); }
.sort-icon.asc .sort-up { border-bottom-color: var(--el-color-primary); }
.sort-icon.desc .sort-down { border-top-color: var(--el-color-primary); }
.subtotal td, tfoot td { font-weight: 600; background: var(--el-fill-color-light) !important; }
.subtotal td { border-top: 1px dashed var(--el-border-color); }
.departed { color: var(--el-text-color-placeholder); }
+29 -10
View File
@@ -1,6 +1,5 @@
<template>
<div class="sw-page">
<div class="channel-status-summary"><el-card v-for="card in statusCards" :key="card.label" shadow="never"><el-tooltip content="统计当前筛选结果的全部渠道,正常、锁定、解除合作三类互斥,合计等于渠道数量。"><span>{{ card.label }}</span></el-tooltip><strong>{{ card.value == null ? '—' : channelCount(card.value) }}</strong></el-card></div>
<div class="kra-search-box">
<el-form :model="query" :inline="true" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
@@ -19,6 +18,13 @@
</div>
<div class="kra-table-box">
<div class="kra-btn-list"><el-button v-if="canAdd" type="primary" icon="plus" @click="adding = true">添加渠道</el-button></div>
<div class="channel-status-summary" role="status">
<div v-for="card in statusCards" :key="card.label" class="channel-status-item">
<el-tooltip content="统计当前筛选结果的全部渠道,正常、锁定、解除合作三类互斥,合计等于渠道数量。"><span>{{ card.label }}</span></el-tooltip>
<strong :class="card.type">{{ card.value == null ? '—' : channelCount(card.value) }}</strong>
</div>
<span class="channel-status-hint">按当前搜索结果统计</span>
</div>
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="渠道数据尚未接入。" />
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" @sort-change="sortChanged">
@@ -33,8 +39,8 @@
<el-table-column label="结算周期" width="110"><template #default="{ row }">{{ row.settlementDays == null ? '—' : `${row.settlementDays} 天` }}</template></el-table-column>
<el-table-column label="渠道类型" min-width="100"><template #default="{ row }"><el-tag v-if="row.registerType === 0 || row.registerType === 1" size="small" :type="row.registerType === 1 ? 'warning' : 'info'" effect="plain">{{ row.registerType === 1 ? '公会' : '个人' }}</el-tag><span v-else class="sw-empty-value">—</span></template></el-table-column>
<el-table-column label="推广游戏" min-width="115"><template #default="{ row }"><el-button v-if="row.gameCount != null" link type="primary" @click="openGames(row)">{{ channelCount(row.gameCount) }} 款</el-button><span v-else>未接入</span></template></el-table-column>
<el-table-column label="玩家数量" min-width="110" align="right" class-name="sw-col-number"><template #default="{ row }"><metric-value :value="row.playerCount" /></template></el-table-column>
<el-table-column label="累计充值" min-width="150" align="right" class-name="sw-col-number"><template #default="{ row }"><metric-value kind="money" :value="row.totalPayAmount" /></template></el-table-column>
<el-table-column prop="playerCount" label="玩家数量" min-width="110" align="right" class-name="sw-col-number" sortable="custom" :sort-orders="['descending','ascending',null]"><template #default="{ row }"><metric-value :value="row.playerCount" /></template></el-table-column>
<el-table-column prop="totalPayAmount" label="累计充值" min-width="150" align="right" class-name="sw-col-number" sortable="custom" :sort-orders="['descending','ascending',null]"><template #default="{ row }"><metric-value kind="money" :value="row.totalPayAmount" /></template></el-table-column>
<el-table-column prop="createdAt" label="注册时间" min-width="150" sortable="custom" :sort-orders="['descending','ascending',null]" class-name="sw-col-meta"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
<el-table-column label="最后登录时间" min-width="150" class-name="sw-col-meta"><template #default="{ row }">{{ row.lastLoginAt ? dateText(row.lastLoginAt) : '—' }}</template></el-table-column>
<el-table-column label="有效端" width="100"><template #default="{ row }"><el-tag size="small" :type="row.effective ? 'success' : 'info'">{{ row.effective ? '是' : '否' }}</el-tag></template></el-table-column>
@@ -51,7 +57,8 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, onBeforeUnmount, onDeactivated, onActivated, reactive, ref } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
@@ -68,14 +75,14 @@ import ChannelProfileDrawer from './components/ChannelProfileDrawer.vue'
defineOptions({ name: 'SwChannelList' })
const user = useUserStore()
const buttons = useBtnAuth()
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || !!buttons['sw:promote:add'])
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 3 && !!buttons['sw:promote:add'])
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const levels = { 1: '一级渠道', 2: '二级渠道', 3: '三级渠道' }
const statuses = { 0: '正常', '-1': '已锁定', '-2': '解除合作' }
const sources = ref(Object.keys(platforms))
const query = reactive({ pageNum: 1, pageSize: 10, busierAccount: '', keyword: '', phone: '', settlementDays: undefined, promoteLevel: undefined, registerType: undefined, lockStatus: undefined, effective: undefined, sortField: '', sortOrder: '' })
const createdDates = ref([]), loginDates = ref([]), statusCounts = ref(null), error = ref('')
const statusCards = computed(() => [{ label: '渠道数量', value: statusCounts.value ? total.value : null }, { label: '正常渠道', value: statusCounts.value?.normal }, { label: '锁定渠道', value: statusCounts.value?.locked }, { label: '解除合作', value: statusCounts.value?.terminated }])
const statusCards = computed(() => [{ label: '渠道数量', type: 'total', value: statusCounts.value ? total.value : null }, { label: '正常渠道', type: 'normal', value: statusCounts.value?.normal }, { label: '锁定渠道', type: 'locked', value: statusCounts.value?.locked }, { label: '解除合作', type: 'terminated', value: statusCounts.value?.terminated }])
const rows = ref([])
const total = ref(0)
const available = ref(true)
@@ -87,12 +94,15 @@ const profileOpen = ref(false)
const profileChannel = ref(null)
const openProfile = row => { profileChannel.value = row; profileOpen.value = true }
let requestVersion = 0
const requests = createLatestRequest()
let interrupted = false
const load = async () => {
const version = ++requestVersion
const request = requests.start()
if (!sources.value.length) { rows.value = []; total.value = 0; statusCounts.value = null; error.value = ''; available.value = true; loading.value = false; return }
loading.value = true; error.value = ''
try {
const res = await listChannels({ ...query, sourceDb: sources.value.join(','), busierAccount: query.busierAccount.trim(), keyword: query.keyword.trim(), phone: query.phone.trim(), createdBegin: createdDates.value?.[0] || '', createdEnd: createdDates.value?.[1] || '', loginBegin: loginDates.value?.[0] || '', loginEnd: loginDates.value?.[1] || '' })
const res = await listChannels({ ...query, sourceDb: sources.value.join(','), busierAccount: query.busierAccount.trim(), keyword: query.keyword.trim(), phone: query.phone.trim(), createdBegin: createdDates.value?.[0] || '', createdEnd: createdDates.value?.[1] || '', loginBegin: loginDates.value?.[0] || '', loginEnd: loginDates.value?.[1] || '' }, { signal: request.signal })
if (version !== requestVersion) return
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total; statusCounts.value = res.data.available === false ? null : res.data.statusCounts; available.value = res.data.available !== false }
else { rows.value = []; total.value = 0; statusCounts.value = null; error.value = res.msg || '渠道查询失败' }
@@ -102,10 +112,19 @@ const sortChanged = ({ prop, order }) => { query.sortField = order ? prop : '';
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
const openGames = row => { selected.value = row; gamesOpen.value = true }
onMounted(load)
const cancel = () => { ++requestVersion; requests.cancel(); loading.value = false }
onBeforeUnmount(cancel)
onDeactivated(() => { interrupted = loading.value; cancel() })
onActivated(() => { if (interrupted) { interrupted = false; load() } })
</script>
<style scoped>
.channel-status-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 16px; }
.channel-status-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 28px; padding: 12px 16px; margin-bottom: 14px; border-radius: 6px; background: var(--el-fill-color-light); }
.channel-status-item { display: inline-flex; align-items: baseline; gap: 8px; }
.channel-status-summary span { color: var(--el-text-color-secondary); font-size: 13px; }
.channel-status-summary strong { display: block; font-size: 26px; margin-top: 10px; }
@media(max-width:600px) { .channel-status-summary { grid-template-columns: repeat(2,minmax(0,1fr)); } }
.channel-status-summary strong { font-size: 20px; font-variant-numeric: tabular-nums; }
.channel-status-summary .total { color: var(--el-color-primary); }
.channel-status-summary .normal { color: var(--el-color-success); }
.channel-status-summary .locked { color: var(--el-color-warning); }
.channel-status-summary .terminated { color: var(--el-color-danger); }
.channel-status-hint { margin-left: auto; }
</style>
+4 -4
View File
@@ -14,7 +14,7 @@
<el-alert v-if="error || optionError" class="mb-4" type="error" :closable="false" :title="error || optionError" />
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="区服统计所需业务汇总尚未接入。" />
<div class="sw-table-toolbar"><span class="sw-sort-label" role="status">{{ currentSort }}</span></div>
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.gameId}:${row.serverNum}`" show-summary :summary-method="summaries" :default-sort="{ prop: 'totalPay', order: 'descending' }" @sort-change="sortChanged">
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.gameId}:${row.serverNum}`" show-summary :summary-method="summaries" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column prop="serverNum" label="区服编号" min-width="110" show-overflow-tooltip />
<el-table-column prop="serverName" label="区服名称" min-width="130" show-overflow-tooltip />
@@ -43,7 +43,7 @@ const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const dates = ref(normalizeReportDates())
const sources = ref(Object.keys(platforms)), games = ref([]), gameOptions = ref([]), serverOptions = ref([])
const selectedGame = computed({ get: () => games.value[0] || '', set: value => { games.value = value ? [value] : [] } })
const query = reactive({ pageNum: 1, pageSize: 10, serverNum: '', sortField: 'totalPay', sortOrder: 'desc' })
const query = reactive({ pageNum: 1, pageSize: 10, serverNum: '', sortField: '', sortOrder: 'desc' })
const rows = ref([]), total = ref(0), summary = ref(null), result = ref(null), loading = ref(false), available = ref(true), error = ref(''), optionError = ref('')
const gameLoading = ref(false), serverLoading = ref(false)
let version = 0, gameVersion = 0, serverVersion = 0
@@ -80,8 +80,8 @@ const search = () => {
else if (!games.value.length) ElMessage.warning('请选择游戏')
return load()
}
const sortChanged = event => { query.pageNum = 1; Object.assign(query, remoteSort(event, 'totalPay')); return load() }
const currentSort = computed(() => sortDescription(query, { ...Object.fromEntries(columns.map(c => [c.key, c.label])), openTime: '开服日期' }))
const sortChanged = event => { query.pageNum = 1; Object.assign(query, remoteSort(event, '')); return load() }
const currentSort = computed(() => query.sortField ? sortDescription(query, { ...Object.fromEntries(columns.map(c => [c.key, c.label])), openTime: '开服日期' }) : '排序:默认顺序')
const loadGames = async () => {
const current = ++gameVersion; gameLoading.value = true; optionError.value = ''
try {
@@ -2,7 +2,7 @@
<div class="sw-page">
<div v-if="!userId" class="kra-search-box">
<el-form :inline="true" :model="query" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="query.sourceDb" multiple clearable placeholder="全部平台" style="width: 220px"><el-option v-for="(label, value) in platforms" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="所属平台"><el-select v-model="query.sourceDb" multiple clearable placeholder="全部平台" style="width: 220px" @change="search"><el-option v-for="(label, value) in platforms" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="SDK商务账号"><el-input v-model="query.platformBusierAccount" clearable /></el-form-item>
<el-form-item label="审核状态"><el-select v-model="query.status" clearable style="width: 130px"><el-option v-for="(label, value) in statuses" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">查询</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
@@ -16,7 +16,7 @@
<el-table-column prop="platformBusierAccount" label="SDK商务账号" class-name="sw-col-identity" 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 size="small" :type="row.status === '0' ? 'success' : row.status === '2' ? 'danger' : 'warning'">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
<el-table-column label="审核状态" width="110"><template #default="{ row }"><el-button link aria-label="查看审核详情" @click="showReject(row)"><el-tag size="small" :type="row.status === '0' ? 'success' : row.status === '2' ? 'danger' : 'warning'">{{ statuses[row.status] || row.status }}</el-tag></el-button></template></el-table-column>
<el-table-column prop="createTime" label="绑定时间" class-name="sw-col-meta" min-width="130"><template #default="{ row }">{{ row.createTime?.slice(0, 10) }}</template></el-table-column>
<el-table-column v-if="!userId" label="操作" width="145" fixed="right">
<template #default="{ row }">
@@ -1,6 +1,6 @@
<template>
<div class="sw-page">
<el-tabs v-model="tab"><el-tab-pane label="商务列表" name="accounts" /><el-tab-pane label="小组列表" name="groups" /><el-tab-pane label="绑定列表" name="bindings" /></el-tabs>
<el-tabs v-model="tab"><el-tab-pane label="商务列表" name="accounts" /><el-tab-pane label="小组列表" name="groups" /><el-tab-pane v-if="canSeeBindings" label="绑定列表" name="bindings" /></el-tabs>
<business-bindings v-if="tab === 'bindings'" @changed="refresh" />
<template v-else>
<div class="kra-search-box">
@@ -36,7 +36,7 @@
<el-table-column label="有效率" width="90"><template #default="{ row }"><span :class="row.rate != null && Number(row.rate) < 50 ? 'low-rate' : 'effective'">{{ row.rate == null ? '—' : `${row.rate}%` }}</span></template></el-table-column>
<el-table-column prop="hireDate" label="入职日期" width="115" class-name="sw-col-meta"><template #default="{ row }">{{ row.hireDate || '—' }}</template></el-table-column>
<el-table-column label="在职状态" width="100"><template #default="{ row }"><el-tag size="small" :type="employmentTypes[row.employment]">{{ employmentLabels[row.employment] }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="195" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="row.canEdit" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-if="row.canSetGoal" link type="primary" @click="openGoal('person', row.userId, displayMember(row))">设目标</el-button></template></el-table-column>
<el-table-column label="操作" width="265" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="row.canEdit" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-if="row.canSetGoal" :link="hasGoal(row.currentGoal)" :plain="!hasGoal(row.currentGoal)" :type="hasGoal(row.currentGoal) ? 'warning' : 'primary'" @click="openGoal('person', row.userId, `${displayMember(row)}(${businessLevels[row.level]})`)">{{ goalSummary(row.currentGoal) }}</el-button></template></el-table-column>
</el-table>
<el-table scrollbar-always-on v-else v-loading="loading" :data="groupRows" row-key="id" @sort-change="sortGroups">
<el-table-column prop="name" label="小组" min-width="145" sortable="custom"><template #default="{ row }">{{ row.name }} <el-tag v-if="row.mine" size="small">本组</el-tag></template></el-table-column>
@@ -52,7 +52,7 @@
</template>
<team-settings-dialog v-model="teamSettings" :catalog="catalog" @saved="teamsSaved" />
<goal-drawer v-model="goalOpen" :target="goalTarget" @saved="loadMetrics" />
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :title="detailTitle" />
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :context="detailContext" :title="detailTitle" />
<el-drawer class="sw-dialog" v-model="channelsOpen" append-to-body title="旗下渠道" size="520px" destroy-on-close><business-channels :user-id="selectedID" /></el-drawer>
<el-drawer v-model="bindingsOpen" append-to-body title="SDK商务绑定详情" size="85%" destroy-on-close><business-bindings :user-id="selectedID" /></el-drawer>
<business-profile-drawer v-model="portraitOpen" :user-id="selectedID" :name="selectedName" />
@@ -72,6 +72,7 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
@@ -79,7 +80,7 @@ import { createBusiness } from '@/api/business'
import { getTeams, getManagement, updateMember } from '@/api/performance'
import LevelTag from './LevelTag.vue'
import MetricValue from './MetricValue.vue'
import { businessLevels, employmentLabels, employmentTypes, displayMember } from '../performance-display'
import { businessLevels, employmentLabels, employmentTypes, displayMember, currentMonth, hasGoal, goalSummary } from '../performance-display'
import TeamSettingsDialog from './TeamSettings.vue'
import GoalDrawer from './GoalDrawer.vue'
import PerformanceDetailDrawer from './PerformanceDetailDrawer.vue'
@@ -88,13 +89,19 @@ import BusinessChannels from './BusinessChannels.vue'
import BusinessProfileDrawer from './BusinessProfileDrawer.vue'
const user = useUserStore(), buttons = useBtnAuth()
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) < 3 && !!buttons['sw:business:add'])
const tab = ref('accounts'), catalog = ref({ groups: [], members: [], revision: 0 }), metrics = ref(null), loading = ref(false), metricLoading = ref(false), error = ref(''), metricError = ref('')
const canSeeBindings = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 3)
const route = useRoute(), router = useRouter()
const tab = computed({
get: () => { const value = { '#bind': 'bindings', '#grp': 'groups' }[route.hash] || (['accounts', 'groups', 'bindings'].includes(route.query.tab) ? route.query.tab : 'accounts'); return value === 'bindings' && !canSeeBindings.value ? 'accounts' : value },
set: value => router.replace({ query: { ...route.query, tab: value }, hash: '' })
})
const catalog = ref({ groups: [], members: [], revision: 0 }), metrics = ref(null), loading = ref(false), metricLoading = ref(false), error = ref(''), metricError = ref('')
const filters = reactive({ name: '', account: '', level: '', group: '', employment: '' }), applied = ref({ ...filters }), groupFilters = reactive({ keyword: '', relation: '', employment: '' })
const page = ref(1), size = ref(10), groupOrder = ref(null), groupSort = ref({})
const metricsByID = computed(() => new Map((metrics.value?.rows || []).map(row => [row.member.userId, row])))
const filteredMembers = computed(() => {
const f = applied.value, order = new Map(catalog.value.groups.map((g, i) => [g.id, i]))
return catalog.value.members.map(member => ({ ...member, metrics: metricsByID.value.get(member.userId)?.total, rate: metricsByID.value.get(member.userId)?.rate })).filter(m => (!f.name || `${displayMember(m)} ${m.realName}`.includes(f.name)) && (!f.account || m.userName.toLowerCase().includes(f.account.toLowerCase())) && (!f.level || m.level === f.level) && (!f.group || String(m.groupId) === f.group) && (!f.employment || m.employment === f.employment)).sort((a, b) => {
return catalog.value.members.map(member => ({ ...member, metrics: metricsByID.value.get(member.userId)?.total, rate: metricsByID.value.get(member.userId)?.rate, currentGoal: metricsByID.value.get(member.userId)?.goals?.[currentMonth()] })).filter(m => (!f.name || `${displayMember(m)} ${m.realName}`.includes(f.name)) && (!f.account || m.userName.toLowerCase().includes(f.account.toLowerCase())) && (!f.level || m.level === f.level) && (!f.group || String(m.groupId) === f.group) && (!f.employment || m.employment === f.employment)).sort((a, b) => {
if (!!a.groupId !== !!b.groupId) return a.groupId ? -1 : 1
if (groupOrder.value) { const c = a.groupName.localeCompare(b.groupName, 'zh-CN'); if (c) return groupOrder.value === 'ascending' ? c : -c }
return (order.get(a.groupId) ?? Infinity) - (order.get(b.groupId) ?? Infinity) || 0
@@ -116,10 +123,10 @@ const reset = () => { Object.assign(filters, { name: '', account: '', level: '',
const sortMembers = ({ order }) => { groupOrder.value = order; page.value = 1 }
const sortGroups = value => { groupSort.value = value }
const viewMembers = group => { reset(); filters.group = String(group.id); search(); tab.value = 'accounts' }
const teamSettings = ref(false), goalOpen = ref(false), goalTarget = ref(null), detailOpen = ref(false), detailQuery = ref(null), detailTitle = ref('')
const teamSettings = ref(false), goalOpen = ref(false), goalTarget = ref(null), detailOpen = ref(false), detailQuery = ref(null), detailContext = ref(null), detailTitle = ref('')
const teamsSaved = value => { catalog.value = value; if (filters.group && filters.group !== '0' && !value.groups.some(g => String(g.id) === filters.group)) { filters.group = ''; search() } loadMetrics() }
const openGoal = (kind, id, name) => { goalTarget.value = { kind, id, name }; goalOpen.value = true }
const details = (row, metric) => { detailQuery.value = { kind: 'person', targetId: row.userId, metric, begin: metrics.value.begin, end: metrics.value.end }; detailTitle.value = `${displayMember(row)} · ${metric === 'open' ? '开端' : '有效端'}明细`; detailOpen.value = true }
const details = (row, metric) => { detailQuery.value = { kind: 'person', targetId: row.userId, metric, begin: metrics.value.begin, end: metrics.value.end }; detailContext.value = { name: displayMember(row), groupName: row.groupName, descendantCount: metricsByID.value.get(row.userId)?.descendantCount, metrics: { ...row.metrics }, settings: metrics.value.settings }; detailTitle.value = `${displayMember(row)} · ${metric === 'open' ? '开端' : '有效端'}明细`; detailOpen.value = true }
const channelsOpen = ref(false), bindingsOpen = ref(false), portraitOpen = ref(false), selectedID = ref(0), selectedName = ref('')
const showChannels = row => { selectedID.value = row.userId; channelsOpen.value = true }
const showBindings = row => { selectedID.value = row.userId; bindingsOpen.value = true }
@@ -5,13 +5,15 @@
<template v-if="profile">
<h3 class="profile-heading">基本信息</h3>
<dl class="profile-info">
<div><dt>渠道名称</dt><dd>{{ basic.nickname || '—' }}</dd></div>
<div><dt>渠道名称</dt><dd>{{ basic.nickname || basic.account || '—' }}</dd></div>
<div><dt>渠道ID</dt><dd>{{ basic.promoteId ?? '—' }}</dd></div>
<div><dt>归属商务</dt><dd>{{ basic.busierDisplayName || '—' }}</dd></div>
<div><dt>渠道类型</dt><dd>{{ basic.registerTypeLabel || '—' }}</dd></div>
<div><dt>渠道层级</dt><dd>{{ basic.promoteLevelLabel || '—' }}</dd></div>
<div><dt>上级渠道</dt><dd>{{ basic.parentPromoteName || '—' }}</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><el-tag :type="{ 正常: 'success', 锁定: 'warning', 解除合作: 'danger' }[basic.lockStatusLabel] || 'info'" effect="plain">{{ basic.lockStatusLabel || basic.statusLabel || '—' }}</el-tag></dd></div>
<div><dt>手机号</dt><dd>{{ basic.phone || '—' }}</dd></div>
<div><dt>结算周期</dt><dd>{{ basic.settlementDays == null ? '—' : `${basic.settlementDays} 天` }}</dd></div>
<div><dt>推广游戏</dt><dd><el-button v-if="basic.gameCount != null" type="primary" link @click="gamesOpen = true">{{ channelCount(basic.gameCount) }} 款</el-button><span v-else>未接入</span></dd></div>
<div><dt>注册时间</dt><dd>{{ basic.createTime || '—' }}</dd></div>
<div><dt>最后登录时间</dt><dd>{{ basic.lastLoginTime || '—' }}</dd></div>
+1 -1
View File
@@ -18,7 +18,7 @@
</template>
<el-table scrollbar-always-on v-else :data="history" size="small">
<el-table-column prop="month" label="月份" width="85" />
<el-table-column v-for="metric in metrics" :key="metric.key" :label="metric.label" min-width="120"><template #default="{ row }"><span v-if="row.goal?.[metric.goal] != null">{{ displayTarget(metric.goal, row.goal[metric.goal]) }} / {{ row.available === false ? '未接入' : displayMetric(metric.key, row.actual[metric.key]) }}</span><span v-else>—</span></template></el-table-column>
<el-table-column v-for="metric in metrics" :key="metric.key" :label="`${metric.label}(目标 / 实际)`" min-width="150"><template #default="{ row }">{{ displayTarget(metric.goal, row.goal?.[metric.goal]) }} / {{ row.available === false ? '未接入' : displayMetric(metric.key, row.actual[metric.key]) }}</template></el-table-column>
<el-table-column label="达成" width="95"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status === 'missing' ? `差 ${row.missing} 项` : statuses[row.status] }}</el-tag></template></el-table-column>
<el-table-column width="60"><template #default="{ row }"><el-button v-if="row.canEdit" link type="primary" @click="editMonth(row.month)">编辑</el-button></template></el-table-column>
</el-table>
@@ -1,17 +1,26 @@
<template>
<el-drawer class="sw-dialog" v-model="visible" append-to-body :title="title" size="min(1100px,96vw)" destroy-on-close>
<el-text type="info">{{ query?.begin }} 至 {{ query?.end }} · {{ query?.kind === 'team' ? '按成员贡献统计,同一渠道可计入多位成员' : '所选成员及全部下级商务,按渠道去重' }}</el-text>
<el-descriptions class="detail-summary" :column="2" border>
<el-descriptions-item label="统计范围">{{ context?.name || '所选商务' }}<div class="detail-scope">{{ scopeText }}</div></el-descriptions-item>
<el-descriptions-item :label="periodLabel">{{ context?.periodKey || `${query?.begin || '—'} 至 ${query?.end || '—'}` }}</el-descriptions-item>
<el-descriptions-item label="所属小组">{{ context?.groupName || '—' }}</el-descriptions-item>
<el-descriptions-item label="开端数">{{ displayMetric('open', context?.metrics?.open) }}</el-descriptions-item>
<el-descriptions-item label="有效端数 / 有效率">{{ displayMetric('effective', context?.metrics?.effective) }} / {{ rateText(effectiveRate(context?.metrics)) }}</el-descriptions-item>
<el-descriptions-item label="区间充值流水">{{ displayMetric('pay', context?.metrics?.pay) }}</el-descriptions-item>
</el-descriptions>
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mt-3" />
<el-table scrollbar-always-on v-loading="loading" :data="rows" class="mt-3" :row-key="row => `${row.sourceDb}:${row.id}:${row.contributorId || 0}`">
<el-table-column prop="sourceDbLabel" label="所属平台" width="100" />
<el-table-column v-if="query?.kind === 'team'" prop="contributorName" label="计入成员" min-width="140" />
<el-table-column prop="account" label="渠道" min-width="150" />
<el-table-column prop="account" label="渠道名称" min-width="150"><template #default="{ row }">{{ row.name || row.account || '—' }}</template></el-table-column>
<el-table-column prop="ownerName" label="归属商务" min-width="140" />
<el-table-column label="开端日期" width="125"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
<el-table-column label="判定窗口流水" min-width="145"><template #default="{ row }">{{ displayMetric('pay', row.windowPay) }}</template></el-table-column>
<el-table-column :label="`${windowDays} 天流水`" min-width="145"><template #header><el-tooltip :content="`渠道注册后 ${windowDays} 天判定窗口内的累计充值,与本次所选统计区间的流水不同。`"><span>{{ windowDays }} 天流水</span></el-tooltip></template><template #default="{ row }">{{ displayMetric('pay', row.windowPay) }}</template></el-table-column>
<el-table-column label="区间充值流水" min-width="145"><template #default="{ row }">{{ displayMetric('pay', row.periodPay) }}</template></el-table-column>
<el-table-column label="有效端" width="90"><template #default="{ row }"><el-tag :type="row.qualifiedAt ? 'success' : 'info'">{{ row.qualifiedAt ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="达标日期" width="125"><template #default="{ row }">{{ dateText(row.qualifiedAt) }}</template></el-table-column>
<el-table-column label="达标日期" width="125"><template #default="{ row }">{{ row.qualifiedAt ? dateText(row.qualifiedAt) : '未达标' }}</template></el-table-column>
</el-table>
<el-text class="detail-hint" type="info">{{ detailHint }}</el-text>
<el-pagination v-model:current-page="page" v-model:page-size="size" class="mt-4" :total="total" :page-sizes="[20,50,100]" layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" />
</el-drawer>
</template>
@@ -19,15 +28,23 @@
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { getPerformanceDetails } from '@/api/performance'
import { dateText, displayMetric } from '../performance-display'
const props = defineProps({ modelValue: Boolean, query: Object, title: { type: String, default: '渠道明细' } }), emit = defineEmits(['update:modelValue'])
import { dateText, displayMetric, effectiveRate, rateText } from '../performance-display'
const props = defineProps({ modelValue: Boolean, query: Object, context: Object, title: { type: String, default: '渠道明细' } }), emit = defineEmits(['update:modelValue'])
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const rows = ref([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), error = ref('')
const requests = createLatestRequest()
const load = async () => { if (!visible.value || !props.query) return; const request = requests.start(); loading.value = true; error.value = ''; try { const res = await getPerformanceDetails({ ...props.query, page: page.value, size: size.value }, { signal: request.signal }); if (!request.isCurrent()) return; if (res.code !== 0) { error.value = res.msg || '明细读取失败'; rows.value = []; total.value = 0; return } rows.value = res.data.rows; total.value = res.data.total } catch { if (request.isCurrent()) error.value = '明细读取失败,请重试' } finally { if (request.isCurrent()) loading.value = false } }
const windowDays = computed(() => props.context?.settings?.days ?? 30)
const periodLabel = computed(() => props.context?.periodKey?.length === 10 ? '统计日期' : props.context?.periodKey?.length === 7 ? '统计月份' : '统计区间')
const scopeText = computed(() => props.query?.kind === 'team' ? '按成员贡献统计,同一渠道可计入多位成员' : props.context?.descendantCount != null ? `本人及全部下级(${props.context.descendantCount + 1} 个商务),按渠道去重` : '所选成员及全部下级商务,按渠道去重')
const detailHint = computed(() => ({ open: '只列所选区间内新开端的渠道;跨月达标不计入本区间有效端数。', effective: '只列所选区间内达成有效端的渠道,开端日期可能在之前的月份。', pay: '只列所选区间内产生充值流水的渠道;区间流水与开端后的判定窗口流水分别统计。', all: '展示所选区间内有开端、达标或充值记录的渠道。' }[props.query?.metric] || ''))
const load = async () => { if (!visible.value || !props.query) return; const request = requests.start(); loading.value = true; error.value = ''; try { const res = await getPerformanceDetails({ ...props.query, page: page.value, size: size.value }, { signal: request.signal }); if (!request.isCurrent()) return; if (res.code !== 0) { error.value = res.msg || '明细读取失败'; rows.value = []; total.value = 0; return } rows.value = res.data.rows; total.value = res.data.total } catch { if (request.isCurrent()) { error.value = '明细读取失败,请重试'; rows.value = []; total.value = 0 } } finally { if (request.isCurrent()) loading.value = false } }
const search = () => { page.value = 1; return load() }
const cancel = () => { requests.cancel(); loading.value = false }
watch(() => [props.modelValue, props.query], ([open]) => { if (open) { rows.value = []; search() } else cancel() }, { flush: 'sync' })
watch(() => [props.modelValue, props.query], ([open]) => { if (open) { rows.value = []; total.value = 0; search() } else cancel() }, { flush: 'sync' })
onBeforeUnmount(cancel)
onDeactivated(() => { visible.value = false; cancel() })
</script>
<style scoped>
.detail-scope { margin-top: 4px; font-size: 12px; color: var(--el-text-color-secondary); }
.detail-hint { display: block; margin-top: 12px; line-height: 1.7; }
</style>
@@ -2,12 +2,12 @@
<div ref="host" class="performance-trend">
<div class="legend"><span v-for="line in lines" :key="line.key"><i :style="{ background: line.color }" />{{ line.label }}{{ line.key === 'pay' ? '(右轴,万元)' : '' }}</span></div>
<svg :viewBox="`0 0 ${width} 396`" role="img" aria-label="开端、有效端和充值流水趋势图" 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 [0,1,2,3,4]" :key="tick"><line x1="52" :x2="width - 60" :y1="350 - tick * 78" :y2="350 - tick * 78" class="grid" /><text x="45" :y="354 - tick * 78" text-anchor="end">{{ Math.round(maxCount * tick / 4) }}</text><text :x="width - 52" :y="354 - tick * 78" style="fill:#d49b20">{{ ((minPay + paySpan * tick / 4) / 10000).toFixed(2) }}</text></g>
<g v-for="tick in [0,1,2,3,4]" :key="tick"><line x1="52" :x2="width - 60" :y1="350 - tick * 78" :y2="350 - tick * 78" class="grid" /><text x="45" :y="354 - tick * 78" text-anchor="end">{{ Math.round(maxCount * tick / 4) }}</text><text :x="width - 52" :y="354 - tick * 78" style="fill:#d49b20">{{ (minPay + paySpan * tick / 4) / 10000 }}</text></g>
<polyline v-for="line in lines" :key="line.key" :points="points(line.key)" fill="none" :style="{ stroke: line.color }" stroke-width="2.5" stroke-linejoin="round" />
<text v-for="i in labelIndexes" :key="i" :x="x(i)" y="383" text-anchor="middle">{{ periods[i]?.label }}</text>
<line v-if="selected" :x1="x(active)" :x2="x(active)" y1="28" y2="350" class="cursor" />
</svg>
<div v-if="selected" class="trend-tooltip" :style="{ left: `${Math.max(8, Math.min(width - 210, x(active)))}px` }"><strong>{{ selected.key }}</strong><div v-for="line in lines" :key="line.key" :style="{ color: line.color }">{{ line.label }}:{{ displayMetric(line.key, trend[selected.key]?.[line.key] || 0) }}</div></div>
<div v-if="selected" class="trend-tooltip" :style="{ left: `${Math.max(8, Math.min(width - 210, x(active)))}px` }"><strong>{{ selected.key }}</strong><div v-for="line in lines" :key="line.key" :style="{ color: line.color }">{{ line.label }}:{{ line.key === 'pay' ? `¥${(Number(trend[selected.key]?.pay || 0) / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 6 })}万` : displayMetric(line.key, trend[selected.key]?.[line.key] || 0) }}</div></div>
</div>
</template>
<script setup>
@@ -18,8 +18,8 @@ const host = ref(), width = ref(600), active = ref(null)
const lines = [{ key: 'open', label: '开端', color: '#4f8cff' }, { key: 'effective', label: '有效端', color: '#22b98a' }, { key: 'pay', label: '充值流水', color: '#d49b20' }]
const maxCount = computed(() => Math.ceil(Math.max(4, ...Object.values(props.trend).flatMap(v => [v.open, v.effective])) / 4) * 4)
const maxPay = computed(() => Math.max(1, ...Object.values(props.trend).map(v => Number(v.pay))))
const minPay = computed(() => Math.min(0, ...Object.values(props.trend).map(v => Number(v.pay))))
const paySpan = computed(() => Math.max(1, maxPay.value - minPay.value))
const minPay = computed(() => Math.floor(Math.min(0, ...Object.values(props.trend).map(v => Number(v.pay))) / 10000) * 10000)
const paySpan = computed(() => Math.max(40000, Math.ceil((maxPay.value - minPay.value) / 40000) * 40000))
const x = index => 52 + index * (width.value - 112) / Math.max(1, props.periods.length - 1)
const points = key => props.periods.map((p, i) => { const value = Number(props.trend[p.key]?.[key] || 0); return `${x(i)},${350 - (key === 'pay' ? (value - minPay.value) / paySpan.value : value / maxCount.value) * 312}` }).join(' ')
const labelIndexes = computed(() => [...new Set([0,1,2,3,4].map(i => Math.round(i * Math.max(0, props.periods.length - 1) / 4)))])
@@ -19,7 +19,7 @@
<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="统计所需业务汇总尚未接入。" />
<div class="sw-table-toolbar"><span class="sw-sort-label" role="status">{{ currentSort }}</span><el-button v-if="isChannel" :loading="exporting" :disabled="loading || !loadedQuery || !available" @click="download">导出</el-button></div>
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" :default-sort="isChannel ? undefined : { prop: 'totalPay', order: 'descending' }" show-summary :summary-method="summaries" @sort-change="sortChanged">
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" show-summary :summary-method="summaries" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column :prop="isChannel ? 'promoteName' : 'gameName'" :label="isChannel ? '渠道名称' : '游戏名称'" min-width="190" show-overflow-tooltip class-name="sw-col-identity"><template #default="{ row }"><el-button v-if="isChannel && row.promoteId > 0" link type="primary" @click="openChannel(row)">{{ row.promoteName }}</el-button><span v-else>{{ isChannel ? row.promoteName : row.gameName }}</span></template></el-table-column>
<el-table-column v-if="isChannel" prop="busierName" label="归属商务" min-width="140" show-overflow-tooltip />
@@ -55,7 +55,7 @@ const startShortcuts = shortcuts.map(item => ({ text: item.text, value: () => it
const endShortcuts = shortcuts.map(item => ({ text: item.text, value: () => item.value()[1] }))
const sources = ref(Object.keys(platforms)), games = ref([]), gameOptions = ref([]), gameLoading = ref(false)
const selectedGame = computed({ get: () => games.value[0] || '', set: value => { games.value = value ? [value] : [] } })
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', sortField: isChannel.value ? '' : 'totalPay', sortOrder: 'desc' })
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', sortField: '', sortOrder: 'desc' })
const rows = ref([]), total = ref(0), summary = ref(null), result = ref(null), loading = ref(false), available = ref(true), error = ref('')
const loadedQuery = ref(null), exporting = ref(false)
let version = 0, gameVersion = 0
@@ -97,7 +97,7 @@ const download = async () => {
} catch { ElMessage.error('导出失败,请重试') } finally { exporting.value = false }
}
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
const sortChanged = event => { Object.assign(query, remoteSort(event, isChannel.value ? '' : 'totalPay')); return search() }
const sortChanged = event => { Object.assign(query, remoteSort(event, '')); return search() }
const currentSort = computed(() => query.sortField ? sortDescription(query, { ...Object.fromEntries(Object.values(fields).map(f => [f.key, f.label])), gameId: '游戏ID' }) : '排序:默认顺序')
const gameChanged = () => { analysisGameSelection.value = selectedGame.value; return search() }
const sourceChanged = async () => {
+5
View File
@@ -19,4 +19,9 @@ export const dateText = value => value == null ? '—' : new Date(Number(value)
export const currentMonth = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` }
export const monthOffset = (month, offset) => { const [y, m] = month.split('-').map(Number), d = new Date(y, m - 1 + offset, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` }
export const hasGoal = goal => goal && [goal.open, goal.effective, goal.amount].some(v => v != null)
export const goalSummary = goal => {
if (!hasGoal(goal)) return '设目标'
const amount = goal.amount == null ? '—' : goal.amount >= 10000 ? `${(goal.amount / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}万` : String(goal.amount)
return `目标 ${goal.open ?? '—'}/${goal.effective ?? '—'}/${amount}`
}
export const effectiveRate = metrics => metrics?.open ? Number(metrics.effective) / Number(metrics.open) * 100 : null
+8 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { goalAchieved, completionRate, hasGoal, monthOffset, personalGoalRows } from './performance-display.js'
import { goalAchieved, completionRate, hasGoal, monthOffset, personalGoalRows, goalSummary } from './performance-display.js'
test('goal attainment compares exact cents even when rounded percentages look complete', () => {
assert.equal(goalAchieved('9007199254740990.99', 9007199254740991, 'pay'), false)
@@ -24,3 +24,10 @@ test('empty and zero targets stay distinct and year boundaries retain the month'
assert.equal(monthOffset('2026-01', -1), '2025-12')
assert.equal(monthOffset('2026-12', 1), '2027-01')
})
test('goal button summarizes configured targets and keeps unset and zero distinct', () => {
assert.equal(goalSummary(null), '设目标')
assert.equal(goalSummary({ open: null, effective: null, amount: null }), '设目标')
assert.equal(goalSummary({ open: 30, effective: 12, amount: 2000000 }), '目标 30/12/200万')
assert.equal(goalSummary({ open: 0, effective: null, amount: 0 }), '目标 0/—/0')
})