feat: 完善小组绩效与有效端统计并修复审查问题

This commit is contained in:
Yvan
2026-11-05 01:10:03 +08:00
parent ca9ce99b66
commit dc97690eb1
75 changed files with 5033 additions and 296 deletions
+13
View File
@@ -0,0 +1,13 @@
import service from '@/utils/request'
const get = (url, params) => service({ url, params, method: 'get', donNotShowLoading: true })
export const getTeams = () => get('/sw/groups')
export const saveTeams = data => service({ url: '/sw/groups', method: 'put', data })
export const updateMember = data => service({ url: '/sw/business/profile', method: 'put', data })
export const getManagement = params => get('/sw/business/management', params)
export const getPerformance = params => get('/sw/performance', params)
export const getPerformanceDetails = params => get('/sw/performance/details', params)
export const getGoals = params => get('/sw/goals', params)
export const saveGoal = data => service({ url: '/sw/goals', method: 'put', data })
export const getMyGoal = () => get('/sw/goals/mine')
export const getDashboardOverview = params => get('/sw/workbench/overview', params)
export const exportPerformance = params => service({ url: '/sw/performance/export', method: 'get', params, responseType: 'blob' })
+1
View File
@@ -55,6 +55,7 @@
"/src/view/sw/AlertList.vue": "SwAlertList",
"/src/view/sw/AlertRules.vue": "SwAlertRules",
"/src/view/sw/BusinessList.vue": "SwBusinessList",
"/src/view/sw/BusinessPerformance.vue": "SwBusinessPerformance",
"/src/view/sw/ChannelActivationList.vue": "SwChannelActivationList",
"/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics",
"/src/view/sw/ChannelList.vue": "SwChannelList",
@@ -9,9 +9,14 @@
<el-alert v-else-if="summaryError" class="mt-3" type="error" :closable="false" :title="summaryError" />
<el-alert v-else-if="summary?.available === false" class="mt-3" type="warning" :closable="false" title="工作台所需业务数据尚未接入。" />
</el-card>
<my-goal-card />
<div class="overview-period">
<el-radio-group v-model="overviewPeriod" @change="overviewPeriod !== 'custom' && loadSummary()"><el-radio-button v-for="option in periods" :key="option.value" :value="option.value">{{ option.label }}</el-radio-button><el-radio-button value="custom">自定义</el-radio-button></el-radio-group>
<template v-if="overviewPeriod === 'custom'"><el-date-picker v-model="overviewDates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" /><el-button type="primary" @click="loadSummary">搜索</el-button></template>
</div>
<div v-loading="summaryLoading" class="business-kpis">
<el-card v-for="card in cards" :key="card.key" shadow="never">
<div class="business-muted">{{ card.label }}</div>
<el-tooltip :content="card.key === 'effectivePromote' ? `渠道注册后 ${summary?.settings?.days ?? 30} 天内充值满 ¥${summary?.settings?.amount ?? 3000},按达标日期统计。` : card.event ? '按所选区间内的状态变更事件统计,与上一等长周期比较。' : '所选时间区间内的数据,与上一等长周期比较。'"><div class="business-muted">{{ card.label }}</div></el-tooltip>
<div class="business-value">{{ metricValue(card) }}</div>
<el-text :type="rateType(summary?.[card.key]?.changeRate)" size="small">{{ metricCompare(card.key) }}</el-text>
</el-card>
@@ -38,7 +43,9 @@
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getWorkbenchSummary, getWorkbenchRanks } from '@/api/workbench'
import { getWorkbenchRanks } from '@/api/workbench'
import { getDashboardOverview } from '@/api/performance'
import MyGoalCard from '@/view/sw/components/MyGoalCard.vue'
import { listAlertRecords } from '@/api/alert'
import { channelCount, channelMoney } from '@/view/sw/channel-display'
import CompactRankingTable from '@/view/sw/components/CompactRankingTable.vue'
@@ -46,9 +53,10 @@ import CompactRankingTable from '@/view/sw/components/CompactRankingTable.vue'
const router = useRouter(), carousel = ref()
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const sources = ref(Object.keys(platforms)), summary = ref(null), summaryLoading = ref(false), summaryError = ref('')
const overviewPeriod = ref('today'), overviewDates = ref([])
const alerts = reactive({ rows: [], total: null, loading: false, error: '', version: 0 })
const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }]
const cards = [{ key: 'todayRegister', label: '今日渠道玩家注册' }, { key: 'todayActive', label: '今日渠道活跃玩家' }, { key: 'todayPay', label: '今日充值金额', money: true }, { key: 'monthPay', label: '本月充值金额', money: true }, { key: 'activePromote', label: '活跃渠道数' }, { key: 'newPromote', label: '新增渠道数' }]
const cards = [{ key: 'todayRegister', label: '玩家注册' }, { key: 'todayActive', label: '活跃玩家' }, { key: 'todayPay', label: '充值金额', money: true }, { key: 'effectivePromote', label: '有效端' }, { key: 'activePromote', label: '活跃渠道数' }, { key: 'newPromote', label: '新增渠道数' }, { key: 'lockedPromote', label: '锁定渠道', event: true }, { key: 'terminatedPromote', label: '解除合作渠道', event: true }]
const panels = reactive([
{ key: 'register', endpoint: 'registerTop', title: '渠道注册 TOP10', period: 'today' },
{ key: 'payHigh', endpoint: 'payRank', title: '渠道充值 TOP10', period: 'today', order: 'desc' },
@@ -57,21 +65,22 @@ const panels = reactive([
].map(panel => ({ ...panel, rows: [], loading: false, error: '', version: 0 })))
let summaryVersion = 0
const count = value => value == null ? '未获取' : channelCount(String(value).split('.')[0])
const metricValue = card => { const value = summary.value?.[card.key]?.value; if (value == null) return !sources.value.length ? '—' : summary.value?.available === false ? '未接入' : '未获取'; return card.money ? channelMoney(value) : count(value) }
const metricValue = card => { const value = summary.value?.[card.key]?.value; if (card.event && summary.value && value == null) return '未接入'; if (value == null) return !sources.value.length ? '—' : summary.value?.available === false ? '未接入' : '未获取'; return card.money ? channelMoney(value) : count(value) }
const rateType = rate => rate == null || Number(rate) === 0 ? 'info' : Number(rate) > 0 ? 'success' : 'danger'
const metricCompare = key => {
const card = summary.value?.[key]
if (card?.value == null) return '—'
if (card?.value == null) return card?.compareLabel === '事件数据未接入' ? card.compareLabel : '—'
if (card.changeRate == null) return `${card.compareLabel}:无可比数据`
return `${card.compareLabel} ${Number(card.changeRate) > 0 ? '+' : ''}${card.changeRate}%`
}
const rankValue = (panel, value) => panel.key === 'register' ? count(value) : panel.key === 'unlogin' ? `${count(value)} 天` : channelMoney(value)
const rankingRows = panel => panel.rows.map(row => ({ rowKey: `${row.sourceDb}:${row.promoteId}`, rank: row.rank, objectName: row.promoteName, sourceDbLabel: row.sourceDbLabel, metricValue: row.value }))
const loadSummary = async () => {
if (overviewPeriod.value === 'custom' && (!overviewDates.value?.[0] || !overviewDates.value?.[1] || overviewDates.value[0] > overviewDates.value[1])) { ElMessage.warning('请选择正确的起止日期'); return }
const current = ++summaryVersion; summary.value = null; summaryError.value = ''
if (!sources.value.length) { summaryLoading.value = false; return }
summaryLoading.value = true
try { const res = await getWorkbenchSummary({ sourceDbList: sources.value.join(',') }); if (current !== summaryVersion) return; if (res.code === 0) summary.value = res.data; else summaryError.value = res.msg || '商务指标读取失败' }
try { const res = await getDashboardOverview({ sourceDbList: sources.value.join(','), period: overviewPeriod.value, begin: overviewDates.value?.[0], end: overviewDates.value?.[1] }); if (current !== summaryVersion) return; if (res.code === 0) summary.value = { ...res.data, ...res.data.cards }; else summaryError.value = res.msg || '商务指标读取失败' }
catch { if (current === summaryVersion) summaryError.value = '商务指标读取失败,请重试' } finally { if (current === summaryVersion) summaryLoading.value = false }
}
const loadRank = async panel => {
@@ -100,6 +109,7 @@ onMounted(refresh)
<style scoped>
.business-dashboard { display: flex; flex-direction: column; gap: 12px; }
.overview-period { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-top: 8px; }
.business-toolbar, .business-rank-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.business-platform-filter { display: flex; align-items: center; gap: 12px; }
.business-platform-filter .el-select { width: 240px; }
+2 -138
View File
@@ -1,141 +1,5 @@
<template>
<div>
<div v-if="isLevel3" class="business-view-switch">
<el-radio-group v-model="activeTab" aria-label="商务管理视图">
<el-radio-button value="accounts">商务列表</el-radio-button>
<el-radio-button value="bindings">绑定列表</el-radio-button>
</el-radio-group>
</div>
<business-bindings v-if="isLevel3 && activeTab === 'bindings'" @changed="load" />
<div v-show="activeTab === 'accounts'" class="kra-search-box">
<el-form :inline="true" :model="query" @submit.prevent="search">
<el-form-item label="商务账号"><el-input v-model="query.userName" clearable placeholder="请输入账号" /></el-form-item>
<el-form-item label="姓名"><el-input v-model="query.realName" clearable placeholder="请输入姓名" /></el-form-item>
<el-form-item label="商务等级">
<el-select v-model="query.level" clearable placeholder="全部" style="width: 130px">
<el-option v-for="(label, value) in levels" :key="value" :label="label" :value="Number(value)" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部" style="width: 130px">
<el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="value" />
</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>
</el-form>
</div>
<div v-show="activeTab === 'accounts'" class="kra-table-box">
<div v-if="canAdd" class="kra-btn-list">
<el-button type="primary" icon="plus" @click="openCreate">添加子商务</el-button>
</div>
<el-table v-loading="loading" :data="rows" row-key="userId">
<el-table-column prop="userName" label="商务账号" min-width="140" />
<el-table-column label="商务等级" width="110"><template #default="{ row }">{{ levels[row.level] || '系统账号' }}</template></el-table-column>
<el-table-column prop="parentUserName" label="上级商务" min-width="140" />
<el-table-column prop="realName" label="姓名" min-width="100" />
<el-table-column prop="phoneNumber" label="手机号" min-width="130" />
<el-table-column prop="bindCount" label="SDK商务" width="120"><template #default="{ row }"><el-button link type="primary" aria-label="查看SDK商务绑定详情" @click="showBindings(row)">{{ row.bindCount }}</el-button></template></el-table-column>
<el-table-column prop="promoteCount" label="旗下渠道数" width="120">
<template #default="{ row }">
<el-tooltip v-if="row.promoteCount === null" content="当前数据库尚未接入渠道数据,商务资料和绑定数仍可查看。"><span class="text-gray-400">未接入</span></el-tooltip>
<el-button v-else type="primary" link aria-label="查看旗下渠道" @click="showChannels(row)">{{ row.promoteCount }}</el-button>
</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 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" />
</div>
</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>
<el-form-item label="登录密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="new-password" /></el-form-item>
<el-form-item label="姓名" prop="realName"><el-input v-model="form.realName" maxlength="30" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phoneNumber" maxlength="11" /></el-form-item>
<el-form-item label="QQ"><el-input v-model="form.qqNumber" maxlength="20" /></el-form-item>
<el-form-item label="商务等级"><span>{{ childLevel }}</span></el-form-item>
<el-form-item label="所属商务"><span>{{ user.userInfo.nickName || user.userInfo.userName }}</span></el-form-item>
</el-form>
<template #footer><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">确定</el-button></template>
</el-drawer>
</div>
</template>
<template><business-management /></template>
<script setup>
import { computed, reactive, ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
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'
import BusinessManagement from './components/BusinessManagement.vue'
defineOptions({ name: 'SwBusinessList' })
const user = useUserStore()
const activeTab = ref('accounts')
const bindingsDrawer = ref(false)
const bindingUserId = ref(0)
const channelsDrawer = ref(false)
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])
const query = reactive({ pageNum: 1, pageSize: 10, userName: '', realName: '', level: undefined, status: undefined, orderByColumn: '', isAsc: '' })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const drawer = ref(false)
const saving = ref(false)
const formRef = ref()
const form = ref({})
const rules = {
userName: [{ required: true, message: '请输入商务账号', trigger: 'blur' }, { min: 2, max: 30, message: '账号长度为 2 到 30 个字符', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 5, max: 50, message: '密码长度为 5 到 50 个字符', trigger: 'blur' }],
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }]
}
const statusType = (status) => ({ 0: 'success', 1: 'info', 2: 'danger', 3: 'warning' })[status] || 'info'
const load = async () => {
loading.value = true
try {
const res = await listBusiness(query)
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total }
} finally { loading.value = false }
}
const search = () => { query.pageNum = 1; return load() }
const reset = () => { Object.assign(query, { userName: '', realName: '', level: undefined, status: undefined }); return search() }
const openCreate = () => {
form.value = { userName: '', password: '', realName: '', phoneNumber: '', qqNumber: '', status: '0' }
drawer.value = true
}
const save = async () => {
if (!await formRef.value.validate().catch(() => false)) return
saving.value = true
try {
const res = await createBusiness({ ...form.value, nickName: form.value.realName })
if (res.code === 0) { ElMessage.success('添加成功'); drawer.value = false; await load() }
} finally { saving.value = false }
}
watch(isLevel3, (visible) => { if (!visible) activeTab.value = 'accounts' })
onMounted(load)
</script>
<style scoped>
.business-view-switch { margin-bottom: 12px; }
</style>
+128
View File
@@ -0,0 +1,128 @@
<template>
<div class="performance-page">
<div class="kra-search-box"><el-form inline @submit.prevent="search">
<el-form-item label="年份"><el-select v-model="query.year" style="width:110px"><el-option v-for="year in years" :key="year" :label="year" :value="year" /></el-select></el-form-item>
<el-form-item label="月份区间"><el-select v-model="query.beginMonth" style="width:95px"><el-option v-for="month in 12" :key="month" :label="`${month} 月`" :value="month" /></el-select><span class="mx-2">至</span><el-select v-model="query.endMonth" style="width:95px"><el-option v-for="month in 12" :key="month" :label="`${month} 月`" :value="month" /></el-select></el-form-item>
<el-form-item label="商务名称"><el-input v-model="query.name" clearable placeholder="名称、账号或负责人" /></el-form-item>
<el-form-item label="层级"><el-select v-model="query.level" clearable style="width:130px"><el-option v-for="(label,value) in businessLevels" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="reset">重置</el-button><el-button :loading="exporting" @click="download">导出</el-button></el-form-item>
</el-form></div>
<div class="performance-cards"><el-card v-for="card in cards" :key="card.label" shadow="never"><el-tooltip :content="card.tip"><span>{{ card.label }}</span></el-tooltip><strong>{{ card.value }}</strong></el-card></div>
<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>
<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>
<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>
<td class="sticky" :style="{ left: `${businessColumns[2].left}px` }">{{ row.member?.hireDate || '—' }}</td><td class="sticky" :style="{ left: `${businessColumns[3].left}px` }">{{ row.member ? employmentLabels[row.member.employment] : '—' }}</td>
<td v-for="(metric,index) in metrics" :key="metric.key" class="sticky" :class="metric.key" :style="{ left: `${businessColumns[index + 4].left}px` }"><button :disabled="!Number(row.total[metric.key])" @click="drill(row, null, metric.key)">{{ displayMetric(metric.key, row.total[metric.key]) }}</button></td>
<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>
</table>
</performance-scroll>
<template v-if="tab === 'groups' && data">
<el-text tag="p" type="info" class="mb-3">小组目标独立设置,实际为成员绩效之和;目标可能在月中调整。{{ data?.granularity === 'day' ? '当日目标 = 月目标 ÷ 当月天数;左侧展示当月目标和达成。' : '完成率只纳入已设置相应目标的月份。' }}</el-text>
<performance-scroll>
<table class="matrix group-matrix" :style="{ width: `${groupWidth}px` }"><colgroup><col v-for="col in groupColumns" :key="col.key" :style="{ width: `${col.width}px` }" /><template v-for="period in descendingPeriods" :key="period.key"><col style="width:155px" /><col style="width:170px" /></template></colgroup>
<thead><tr><th v-for="col in groupColumns" :key="col.key" class="sticky" rowspan="2" :style="{ left: `${col.left}px` }">{{ col.label }}</th><th v-for="period in descendingPeriods" :key="period.key" colspan="2">{{ period.label }}</th></tr><tr><template v-for="period in descendingPeriods" :key="period.key"><th>目标</th><th>实际</th></template></tr></thead>
<tbody><tr v-for="row in teamRows" :key="row.key" :class="{ 'group-heading': !row.member, 'member-row': row.member, departed: row.member?.employment === 'departed' }">
<td class="sticky" style="left:0"><button v-if="!row.member" class="expand" :aria-expanded="expanded.has(row.id)" @click="toggleGroup(row.id)">{{ expanded.has(row.id) ? '▾' : '▸' }}</button><span v-else class="tree-line">└</span><strong>{{ row.name }}</strong><el-tooltip v-if="row.overlapping" content="组员包含上下级,成员绩效相加会发生重叠"><span class="warning"> ⚠</span></el-tooltip></td>
<td class="sticky" :style="{ left: `${groupColumns[1].left}px` }">{{ row.member ? employmentLabels[row.member.employment] : `${row.memberCount} 人` }}</td>
<td v-if="data?.granularity === 'day'" class="sticky" :style="{ left: `${groupColumns[2].left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayTarget(metric.goal, row.goals?.[data.begin.slice(0,7)]?.[metric.goal]) }}</span></div></td>
<td class="sticky" :style="{ left: `${groupColumns[data?.granularity === 'day' ? 3 : 2].left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayMetric(metric.key, row.total[metric.key]) }}</span></div></td>
<td class="sticky" :style="{ left: `${groupColumns.at(-1).left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line" :title="groupRate(row, metric) == null ? '未设置目标' : '有目标月份的实际合计 ÷ 目标合计'"><small>{{ metric.label }}</small><span>{{ rateText(groupRate(row, metric)) }}</span></div></td>
<template v-for="period in descendingPeriods" :key="period.key"><td class="target-cell"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayTarget(metric.goal, periodTarget(row, period, metric)) }}</span></div></td><td class="actual-cell"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><button v-if="hasGoal(row.goals?.[period.key.slice(0,7)])" :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><span v-else title="未设置目标">—</span></div><div v-if="periodRate(row, period) != null" class="cross-progress"><span><i :class="{ complete: periodRate(row, period) >= 100 }" :style="{ width: `${progressWidth(periodRate(row, period))}%` }" /></span><small>{{ rateText(periodRate(row, period)) }}</small></div></td></template>
</tr><tr v-if="!teamRows.length"><td :colspan="groupColumns.length + descendingPeriods.length * 2">暂无可见小组</td></tr></tbody>
</table>
</performance-scroll>
</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 :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" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getPerformance, exportPerformance } from '@/api/performance'
import { performanceMetrics as metrics, businessLevels, employmentLabels, displayMember, displayMetric, displayTarget, rateText, completionRate, progressWidth, effectiveRate, hasGoal } from './performance-display'
import PerformanceScroll from './components/PerformanceScroll.vue'
import PerformanceTrend from './components/PerformanceTrend.vue'
import PerformanceDetailDrawer from './components/PerformanceDetailDrawer.vue'
defineOptions({ name: 'SwBusinessPerformance' })
const now = new Date(), years = Array.from({ length: now.getFullYear() - 2019 }, (_, i) => now.getFullYear() + 1 - i)
const query = reactive({ year: now.getFullYear(), beginMonth: now.getMonth() + 1, endMonth: now.getMonth() + 1, name: '', level: undefined, sort: '', order: '' })
const tab = ref('business'), data = ref(null), loading = ref(false), error = ref(''), exporting = ref(false), groupFilter = ref(), groupOptions = ref([]), expanded = ref(new Set())
let version = 0
const params = () => ({ ...query, groupId: groupFilter.value || undefined })
const loadedParams = ref(null)
const load = async () => { if (query.beginMonth > query.endMonth) { ElMessage.warning('开始月份不能晚于结束月份'); return } const v = ++version; loading.value = true; error.value = ''; expanded.value = new Set(); try { const requested = params(); const res = await getPerformance(requested); if (v !== version) return; if (res.code !== 0) { error.value = res.msg || '绩效读取失败'; data.value = null; return } if (res.data.available === false) { data.value = null; error.value = '绩效所需业务数据尚未接入'; return } data.value = res.data; loadedParams.value = requested; groupOptions.value = res.data.groups.map(g => g.group) } catch { if (v === version) { error.value = '绩效读取失败,请重试'; data.value = null } } finally { if (v === version) loading.value = false } }
const search = () => { query.sort = ''; query.order = ''; return load() }
const reset = () => { query.name = ''; query.level = undefined; return search() }
const sortBy = key => { if (query.sort !== key) { query.sort = key; query.order = 'asc' } else if (query.order === 'asc') query.order = 'desc'; else { query.sort = ''; query.order = '' } return load() }
const cards = computed(() => [{ label: '开端数', value: data.value?.total?.open ?? '—', tip: '区间内新接入的渠道数,按渠道创建日期统计。' }, { label: '有效端数', value: data.value?.total?.effective ?? '—', tip: `注册后 ${data.value?.settings?.days ?? 30} 天内成功充值达到 ¥${data.value?.settings?.amount ?? 3000},按达标日期统计。` }, { label: '有效率', value: data.value?.rate == null ? '—' : `${data.value.rate}%`, tip: '有效端 ÷ 开端,跨月达标时单月可能超过100%。' }, { label: '有产出商务数', value: data.value?.productiveMembers ?? '—', tip: '本人直接归属渠道在区间内有开端或有效端的商务人数,不重复计算上级。' }])
const descendingPeriods = computed(() => [...(data.value?.periods || [])].reverse())
const withOffsets = columns => { let left = 0; return columns.map(col => { const result = { ...col, left }; left += col.width; return result }) }
const businessColumns = withOffsets([{ key: 'group', label: '小组', width: 125 }, { key: 'member', label: '商务', width: 150 }, { key: 'hire', label: '入职', width: 110 }, { key: 'employment', label: '在职', width: 80 }, { key: 'open', label: '区间开端', width: 105, sort: true }, { key: 'effective', label: '区间有效端', width: 110, sort: true }, { key: 'pay', label: '区间充值流水', width: 150 }, { key: 'rate', label: '有效率', width: 95, sort: true }])
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 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 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>
<style scoped>
.performance-page { min-width: 0; }
.performance-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 16px; }
.performance-cards span { color: var(--el-text-color-secondary); font-size: 13px; }
.performance-cards strong { display: block; font-size: 28px; margin-top: 12px; }
.matrix-toolbar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap; }
.matrix { table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: 12px; }
.matrix th, .matrix td { box-sizing: border-box; padding: 10px; border-bottom: 1px solid var(--el-border-color-lighter); border-right: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); overflow-wrap: anywhere; text-align: right; }
.matrix th { position: sticky; top: 0; z-index: 3; height: 38px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); cursor: pointer; }
.matrix thead tr:nth-child(2) th { top: 38px; }
.matrix .sticky { position: sticky; z-index: 2; }
.matrix th.sticky { z-index: 5; text-align: left; }
.matrix td:nth-child(-n+2) { text-align: left; }
.matrix button { color: inherit; padding: 0; background: none; border: 0; font: inherit; cursor: pointer; }
.matrix button:disabled { cursor: default; opacity: .65; }
.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); }
.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); }
.group-heading td { background: var(--el-color-primary-light-9); border-top: 2px solid var(--el-border-color); }
.member-row td { color: var(--el-text-color-secondary); font-size: 11px; }
.member-row td:first-child { border-left: 3px solid var(--el-color-primary); padding-left: 22px; }
.tree-line { color: var(--el-color-primary); margin-right: 7px; }
.expand { width: 22px; height: 22px; border-radius: 4px !important; background: var(--el-color-primary-light-8) !important; color: var(--el-color-primary) !important; margin-right: 8px; }
.metric-line { display: flex; align-items: start; justify-content: space-between; gap: 6px; min-height: 27px; white-space: nowrap; }
.group-matrix td { vertical-align: top; }
.target-cell { padding-bottom: 35px !important; }
.actual-cell { position: relative; padding-bottom: 35px !important; overflow: visible; }
.cross-progress { position: absolute; width: 305px; bottom: 10px; right: 10px; display: flex; align-items: center; gap: 6px; z-index: 1; }
.cross-progress > span { flex: 1; background: var(--el-fill-color-darker); height: 5px; border-radius: 3px; overflow: hidden; }
.cross-progress i { display: block; height: 100%; background: var(--el-color-primary); }
.cross-progress i.complete { background: var(--el-color-success); }
.warning { color: var(--el-color-warning); }
.trend-layout { display: grid; grid-template-columns: 1.32fr 1fr; gap: 22px; align-items: start; min-width: 0; }
.trend-layout > * { min-width: 0; }
@media(max-width:1180px) { .trend-layout { grid-template-columns: 1fr; } }
@media(max-width:600px) { .performance-cards { grid-template-columns: repeat(2,minmax(0,1fr)); } }
</style>
+6 -1
View File
@@ -8,6 +8,7 @@
<el-form-item label="渠道层级"><el-select v-model="query.promoteLevel" clearable placeholder="全部层级" style="width: 140px"><el-option v-for="(label, value) in levels" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item label="渠道类型"><el-select v-model="query.registerType" clearable placeholder="全部类型" style="width: 140px"><el-option label="个人" :value="0" /><el-option label="公会" :value="1" /></el-select></el-form-item>
<el-form-item label="状态"><el-select v-model="query.lockStatus" clearable placeholder="全部状态" style="width: 140px"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item label="有效端"><el-select v-model="query.effective" clearable placeholder="全部" style="width:120px"><el-option label="是" :value="true" /><el-option label="否" :value="false" /></el-select></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button></el-form-item>
</el-form>
</div>
@@ -26,6 +27,9 @@
<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="130"><template #default="{ row }">{{ channelCount(row.playerCount) }}</template></el-table-column>
<el-table-column label="累计充值" min-width="160"><template #default="{ row }">{{ channelMoney(row.totalPayAmount) }}</template></el-table-column>
<el-table-column label="注册时间" min-width="145"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
<el-table-column label="有效端" width="100"><template #default="{ row }"><el-tag :type="row.effective ? 'success' : 'info'">{{ row.effective ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="达标时间" min-width="145"><template #default="{ row }">{{ dateText(row.qualifiedAt) }}</template></el-table-column>
<el-table-column label="状态" min-width="110"><template #default="{ row }"><el-tag :type="{ 0: 'info', '-1': 'danger', '-2': 'warning' }[row.lockStatus] || 'info'" effect="plain">{{ statuses[row.lockStatus] ?? row.lockStatus ?? '—' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openProfile(row)">画像</el-button></template></el-table-column>
</el-table>
@@ -44,6 +48,7 @@ import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listChannels } from '@/api/channel'
import { channelCount, channelMoney } from './channel-display'
import { dateText } from './performance-display'
import AddChannelDrawer from './components/AddChannelDrawer.vue'
import ChannelGameDrawer from './components/ChannelGameDrawer.vue'
import ChannelProfileDrawer from './components/ChannelProfileDrawer.vue'
@@ -56,7 +61,7 @@ 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: '', promoteLevel: undefined, registerType: undefined, lockStatus: undefined })
const query = reactive({ pageNum: 1, pageSize: 10, busierAccount: '', keyword: '', promoteLevel: undefined, registerType: undefined, lockStatus: undefined, effective: undefined })
const rows = ref([])
const total = ref(0)
const available = ref(true)
+2 -2
View File
@@ -85,12 +85,12 @@ const save = async () => {
if (saving.value) return
saving.value = true
try {
await ElMessageBox.confirm(`确认保存有效端参数?达标金额 ${amountText.value},判定窗口 ${form.days} 天。`, '保存设置', { type: 'warning', confirmButtonText: '确认保存', cancelButtonText: '取消' })
await ElMessageBox.confirm(`确认保存并按新规则重算有效端?达标金额 ${amountText.value},判定窗口 ${form.days} 天,历史统计也会随之更新。`, '保存设置', { type: 'warning', confirmButtonText: '确认保存', cancelButtonText: '取消' })
} catch { saving.value = false; return }
try {
const res = await saveOtherSettings({ amount: form.amount, days: form.days })
if (res.code !== 0) { error.value = res.msg || '保存失败,请重试'; return }
apply(res.data); error.value = ''; status.value = '参数已保存'
apply(res.data); error.value = ''; status.value = '参数已保存,有效端判定已更新'
ElMessage.success('参数已保存')
} catch { error.value = '保存失败,请重试' } finally { saving.value = false }
}
@@ -0,0 +1,137 @@
<template>
<div>
<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>
<business-bindings v-if="tab === 'bindings'" @changed="refresh" />
<template v-else>
<div class="kra-search-box">
<el-form inline @submit.prevent="search">
<template v-if="tab === 'accounts'">
<el-form-item label="商务名称"><el-input v-model="filters.name" clearable placeholder="商务名称或负责人" /></el-form-item>
<el-form-item label="商务账号"><el-input v-model="filters.account" clearable /></el-form-item>
<el-form-item label="等级"><el-select v-model="filters.level" clearable style="width:130px"><el-option v-for="(label, value) in businessLevels" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item label="小组"><el-select v-model="filters.group" clearable style="width:180px"><el-option label="未分组" value="0" /><el-option v-for="group in catalog.groups" :key="group.id" :label="`${group.name}(${group.memberIds.length} 人)`" :value="String(group.id)" /></el-select></el-form-item>
<el-form-item label="在职状态"><el-select v-model="filters.employment" clearable style="width:130px"><el-option v-for="(label, value) in employmentLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
</template>
<template v-else>
<el-form-item label="小组或成员"><el-input v-model="groupFilters.keyword" clearable /></el-form-item>
<el-form-item label="与我的关系"><el-select v-model="groupFilters.relation" clearable style="width:160px"><el-option label="我所在的小组" value="mine" /><el-option label="下级的小组" value="sub" /></el-select></el-form-item>
<el-form-item label="在职状态"><el-select v-model="groupFilters.employment" clearable style="width:150px"><el-option v-for="(label, value) in employmentLabels" :key="value" :label="`含${label}`" :value="value" /></el-select></el-form-item>
</template>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list"><el-button v-if="tab === 'accounts' && canAdd" type="primary" @click="openCreate">添加子商务</el-button><el-button v-if="catalog.canManage" plain type="primary" @click="teamSettings = true">小组设置</el-button></div>
<el-alert v-if="error || metricError" :title="error || metricError" type="error" :closable="false" class="mb-3" />
<el-table v-if="tab === 'accounts'" v-loading="loading" :data="pagedMembers" row-key="userId" @sort-change="sortMembers">
<el-table-column prop="userId" label="ID" width="75" />
<el-table-column prop="userName" label="商务账号" min-width="130" />
<el-table-column label="等级" width="105"><template #default="{ row }">{{ businessLevels[row.level] }}</template></el-table-column>
<el-table-column prop="parentUserName" label="上级商务" min-width="120" />
<el-table-column label="商务 / 负责人" min-width="150"><template #default="{ row }"><strong>{{ displayMember(row) }}</strong><div class="muted">{{ row.realName || '—' }}</div></template></el-table-column>
<el-table-column label="SDK商务" width="100"><template #default="{ row }"><el-button v-if="row.bindCount" link type="primary" @click="showBindings(row)">{{ row.bindCount }}</el-button><span v-else class="muted">0</span></template></el-table-column>
<el-table-column label="旗下渠道数" width="115"><template #default="{ row }"><el-button v-if="row.promoteCount != null" link type="primary" @click="showChannels(row)">{{ row.promoteCount }}</el-button><span v-else>未接入</span></template></el-table-column>
<el-table-column v-for="metric in ['open', 'effective']" :key="metric" :label="metric === 'open' ? '开端数' : '有效端数'" width="105"><template #header><el-tooltip :content="periodTip"><span>{{ metric === 'open' ? '开端数' : '有效端数' }}</span></el-tooltip></template><template #default="{ row }"><el-button v-if="row.metrics?.[metric]" link :class="metric" @click="details(row, metric)">{{ row.metrics[metric] }}</el-button><span v-else :class="metric">{{ metricLoading ? '…' : row.metrics ? 0 : '—' }}</span></template></el-table-column>
<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"><template #default="{ row }">{{ row.hireDate || '—' }}</template></el-table-column>
<el-table-column prop="groupName" label="小组" width="125" sortable="custom"><template #default="{ row }">{{ row.groupName || '—' }}</template></el-table-column>
<el-table-column label="在职状态" width="100"><template #default="{ row }"><el-tag :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>
<el-table 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>
<el-table-column prop="relation" label="与我的关系" min-width="145" />
<el-table-column prop="memberCount" label="成员数" width="110" sortable="custom" />
<el-table-column label="成员" min-width="230"><template #default="{ row }"><span :title="row.memberNames.join('、')">{{ row.memberNames.slice(0, 4).join('、') }}{{ row.memberCount > 4 ? ` 等 ${row.memberCount} 人` : '' }}</span></template></el-table-column>
<el-table-column label="在职状态" min-width="170"><template #default="{ row }">{{ Object.entries(row.employments).map(([key, value]) => `${employmentLabels[key]} ${value}`).join(' · ') }}</template></el-table-column>
<el-table-column prop="channelCount" label="旗下渠道数" width="135" sortable="custom" />
<el-table-column label="操作" width="180"><template #default="{ row }"><el-button v-if="row.canSetGoal" link type="primary" @click="openGoal('group', row.id, row.name)">设目标</el-button><el-button link type="primary" @click="viewMembers(row)">查看成员</el-button></template></el-table-column>
</el-table>
<div v-if="tab === 'accounts'" class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="filteredMembers.length" :page-sizes="[10,20,50,100]" layout="total, sizes, prev, pager, next" /></div>
</div>
</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" />
<el-drawer 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" />
<el-drawer v-model="memberDrawer" append-to-body :title="editing ? '编辑商务' : '添加子商务'" size="min(480px,96vw)" destroy-on-close>
<el-form ref="memberForm" :model="form" :rules="rules" label-position="top" :disabled="saving">
<el-form-item label="商务账号" prop="userName"><el-input v-model="form.userName" :disabled="editing" maxlength="30" /></el-form-item>
<template v-if="editing"><el-form-item label="商务名称"><el-input :model-value="form.nickName" disabled /></el-form-item><el-form-item label="等级 / 上级"><span>{{ businessLevels[form.level] }} · {{ form.parentUserName || '无上级' }}</span></el-form-item></template>
<el-form-item :label="editing ? '登录密码(留空不修改)' : '登录密码'" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="50" /></el-form-item>
<el-form-item label="负责人姓名" prop="realName"><el-input v-model="form.realName" maxlength="30" /></el-form-item>
<el-form-item label="联系电话" prop="phoneNumber"><el-input v-model="form.phoneNumber" maxlength="20" /></el-form-item>
<el-form-item label="入职日期" prop="hireDate"><el-date-picker v-model="form.hireDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item v-if="editing" label="在职状态"><el-select v-model="form.employment"><el-option v-for="(label,value) in employmentLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="memberDrawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></template>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { createBusiness } from '@/api/business'
import { getTeams, getManagement, updateMember } from '@/api/performance'
import { businessLevels, employmentLabels, employmentTypes, displayMember } from '../performance-display'
import TeamSettingsDialog from './TeamSettings.vue'
import GoalDrawer from './GoalDrawer.vue'
import PerformanceDetailDrawer from './PerformanceDetailDrawer.vue'
import BusinessBindings from './BusinessBindings.vue'
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 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) => {
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
})
})
const pagedMembers = computed(() => filteredMembers.value.slice((page.value - 1) * size.value, page.value * size.value))
const groupRows = computed(() => {
const members = new Map(catalog.value.members.map(m => [m.userId, m]))
const rows = catalog.value.groups.filter(g => g.memberIds.length).map(group => { const people = group.memberIds.map(id => members.get(id)).filter(Boolean), employments = {}; for (const m of people) employments[m.employment] = (employments[m.employment] || 0) + 1; return { ...group, memberNames: people.map(displayMember), memberCount: people.length, employments } }).filter(g => (!groupFilters.keyword || `${g.name} ${g.memberNames.join(' ')}`.includes(groupFilters.keyword)) && (!groupFilters.relation || (groupFilters.relation === 'mine' ? g.mine : !g.mine)) && (!groupFilters.employment || g.employments[groupFilters.employment]))
return rows.sort((a, b) => { const { prop, order } = groupSort.value; if (!order) return Number(b.mine) - Number(a.mine); const result = prop === 'name' ? a.name.localeCompare(b.name, 'zh-CN') : a[prop] - b[prop]; return order === 'ascending' ? result : -result })
})
const periodTip = computed(() => metrics.value && metrics.value.begin <= metrics.value.end ? `${metrics.value.begin} 至 ${metrics.value.end},本人及全部下级商务,按渠道去重统计。` : '本年尚无已结束月份。')
let teamVersion = 0, metricVersion = 0
const loadTeams = async () => { const v = ++teamVersion; loading.value = true; error.value = ''; try { const res = await getTeams(); if (v !== teamVersion) return; if (res.code === 0) catalog.value = res.data; else error.value = res.msg || '小组及成员读取失败' } catch { if (v === teamVersion) error.value = '小组及成员读取失败,请重试' } finally { if (v === teamVersion) loading.value = false } }
const loadMetrics = async () => { const v = ++metricVersion; metricLoading.value = true; metricError.value = ''; try { const res = await getManagement({}); if (v !== metricVersion) return; if (res.code === 0) metrics.value = res.data; else metricError.value = res.msg || '绩效读取失败' } catch { if (v === metricVersion) metricError.value = '绩效读取失败,请重试' } finally { if (v === metricVersion) metricLoading.value = false } }
const refresh = () => Promise.all([loadTeams(), loadMetrics()])
const search = () => { applied.value = { ...filters }; page.value = 1 }
const reset = () => { Object.assign(filters, { name: '', account: '', level: '', group: '', employment: '' }); search() }
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 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 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 }
const showPortrait = row => { selectedID.value = row.userId; selectedName.value = displayMember(row); portraitOpen.value = true }
const memberDrawer = ref(false), editing = ref(false), memberForm = ref(), saving = ref(false), form = ref({})
const rules = computed(() => ({ userName: [{ required: true, message: '请输入商务账号' }], realName: [{ required: true, message: '请输入负责人姓名' }], hireDate: [{ required: true, message: '请选择入职日期' }], phoneNumber: [{ pattern: /^[0-9 -]*$/, message: '联系电话只允许数字、空格和短横线', trigger: 'blur' }], password: [{ validator: (_r, value, cb) => cb((editing.value && !value || value?.length >= 5 && value.length <= 50) ? undefined : new Error('密码为5至50个字符')) }] }))
const openCreate = () => { editing.value = false; form.value = { userName: '', realName: '', phoneNumber: '', hireDate: '', password: '' }; memberDrawer.value = true }
const openEdit = row => { editing.value = true; form.value = { ...row, password: '' }; memberDrawer.value = true }
const saveMember = async () => { if (saving.value || !await memberForm.value.validate().catch(() => false)) return; saving.value = true; try { const data = editing.value ? { userId: form.value.userId, realName: form.value.realName, phoneNumber: form.value.phoneNumber, hireDate: form.value.hireDate, employment: form.value.employment, password: form.value.password } : { ...form.value, nickName: form.value.realName, status: '0' }; const res = await (editing.value ? updateMember(data) : createBusiness(data)); if (res.code === 0) { ElMessage.success('保存成功'); memberDrawer.value = false; await refresh() } } finally { saving.value = false } }
onMounted(refresh)
</script>
<style scoped>
.muted { color: var(--el-text-color-secondary); font-size: 12px; }
.open { color: var(--el-color-primary); }
.effective { color: var(--el-color-success); }
.low-rate { color: var(--el-color-warning); }
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<el-drawer v-model="visible" append-to-body :title="`设置目标 · ${target?.name || data?.name || ''}`" size="min(650px, 96vw)" destroy-on-close>
<div v-loading="loading">
<el-alert v-if="error" type="error" :title="error" :closable="false" class="mb-3" />
<el-tabs v-model="tab"><el-tab-pane label="设置目标" name="settings" /><el-tab-pane label="目标记录" name="history" /></el-tabs>
<template v-if="tab === 'settings'">
<el-form label-position="top" :disabled="saving || !data?.canEdit">
<el-form-item label="月份"><el-select v-model="month" @change="fill"><el-option v-for="m in monthChoices" :key="m" :value="m" :label="`${m}${m === currentMonth() ? '(本月)' : ''}`" /></el-select></el-form-item>
<el-text v-if="target?.kind === 'group'" tag="p" class="mb-4">当前团队绩效:开端 {{ selected?.actual.open || 0 }} · 有效端 {{ selected?.actual.effective || 0 }} · {{ displayMetric('pay', selected?.actual.pay || '0') }}</el-text>
<el-form-item v-for="metric in metrics" :key="metric.key" :label="`${metric.label}(${metric.key === 'pay' ? '元' : '个'})`">
<el-input v-model="form[metric.goal]" inputmode="numeric" clearable placeholder="留空表示不考核" />
<el-text size="small" type="info" class="mt-2">{{ target?.kind === 'group' ? '当前实际' : '上月目标' }}:{{ target?.kind === 'group' ? displayMetric(metric.key, selected?.actual[metric.key]) : displayTarget(metric.goal, previous?.goal?.[metric.goal]) }}</el-text>
</el-form-item>
</el-form>
<el-text v-if="target?.kind === 'group'" tag="p" type="info">参考:成员个人目标合计 {{ data?.memberGoals?.[month]?.open || 0 }} / {{ data?.memberGoals?.[month]?.effective || 0 }} / {{ displayMetric('pay', data?.memberGoals?.[month]?.pay || '0') }},不会自动替代小组目标。</el-text>
<el-text tag="p" type="info" class="mt-3">实际绩效自动统计;个人包含本人及全部下级,小组为组内成员绩效之和。留空表示该项不考核,三项全空表示未设目标。</el-text>
<el-text v-if="selected?.goal?.updatedBy" tag="p" type="info" size="small" class="mt-3">最后修改人:#{{ selected.goal.updatedBy }} · {{ selected.goal.updatedAt }}</el-text>
</template>
<el-table 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 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>
</div>
<template #footer><div v-if="tab === 'settings'"><el-button :disabled="saving || !data?.canEdit" @click="clear">清空本月目标</el-button><el-button type="primary" :loading="saving" :disabled="loading || !data?.canEdit" @click="save">保存</el-button></div></template>
</el-drawer>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getGoals, saveGoal } from '@/api/performance'
import { performanceMetrics as metrics, currentMonth, monthOffset, displayMetric, displayTarget } from '../performance-display'
const props = defineProps({ modelValue: Boolean, target: Object }), emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const loading = ref(false), saving = ref(false), error = ref(''), data = ref(null), tab = ref('settings'), month = ref(currentMonth())
const form = reactive({ open: '', effective: '', amount: '' })
let version = 0
const selected = computed(() => data.value?.months.find(m => m.month === month.value)), previous = computed(() => data.value?.months.find(m => m.month === monthOffset(month.value, -1)))
const history = computed(() => (data.value?.months || []).filter(m => m.month <= currentMonth()).slice(0, 6))
const monthChoices = computed(() => [...new Set([month.value, ...[-1, 0, 1].map(n => monthOffset(currentMonth(), n))])].sort())
const statuses = { unset: '未设目标', ongoing: '进行中', future: '未开始', achieved: '达成', unavailable: '未接入' }
const statusType = status => ({ achieved: 'success', missing: 'danger', ongoing: 'primary' }[status] || 'info')
const fill = () => { for (const key of Object.keys(form)) form[key] = selected.value?.goal?.[key] == null ? '' : String(selected.value.goal[key]) }
const load = async () => { const current = ++version; loading.value = true; error.value = ''; try { const res = await getGoals({ kind: props.target.kind, targetId: props.target.id }); if (current !== version) return; if (res.code !== 0) { error.value = res.msg || '目标读取失败'; return } data.value = res.data; fill() } catch { if (current === version) error.value = '目标读取失败,请重试' } finally { if (current === version) loading.value = false } }
watch(visible, v => { if (v) { data.value = null; tab.value = 'settings'; month.value = currentMonth(); load() } else version++ })
const editMonth = value => { month.value = value; fill(); tab.value = 'settings' }
const persist = async values => { saving.value = true; error.value = ''; try { const res = await saveGoal({ kind: props.target.kind, targetId: props.target.id, month: month.value, ...values }); if (res.code !== 0) { error.value = res.msg || '目标保存失败'; return } ElMessage.success('目标已保存'); emit('saved'); await load() } catch { error.value = '目标保存失败,请重试' } finally { saving.value = false } }
const save = async () => { if (saving.value) return; const values = {}; for (const [key, raw] of Object.entries(form)) { if (raw.trim() === '') { values[key] = null; continue } if (!/^\d+$/.test(raw.trim()) || !Number.isSafeInteger(Number(raw))) { ElMessage.warning('目标须为非负整数,或留空'); return } values[key] = Number(raw) } await persist(values) }
const clear = async () => { if (saving.value) return; try { await ElMessageBox.confirm(`确认清空 ${month.value} 的全部目标?`, '清空目标', { type: 'warning' }) } catch { return } await persist({ open: null, effective: null, amount: null }) }
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<el-card v-if="error || rows.length" v-loading="loading" shadow="never" class="my-goal-card">
<div class="goal-heading"><strong>我的目标</strong><el-text type="info">{{ data?.month }} · 本月累计</el-text></div>
<el-alert v-if="error" :title="error" type="warning" :closable="false" />
<div v-for="row in rows" :key="row.key" class="goal-row">
<span>{{ row.label }}</span><strong>{{ displayMetric(row.key, row.actual) }} / {{ displayTarget(row.goal, row.target) }}</strong>
<div class="goal-progress"><span><i :class="{ achieved: row.achieved }" :style="{ width: `${progressWidth(row.rate)}%` }" /></span><small>{{ rateText(row.rate) }}</small></div>
<el-tag :type="row.achieved ? 'success' : 'primary'">{{ row.achieved ? '已达标' : '未达标' }}</el-tag>
</div>
</el-card>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { getMyGoal } from '@/api/performance'
import { performanceMetrics, displayMetric, displayTarget, completionRate, progressWidth, rateText, goalAchieved } from '../performance-display'
const data = ref(null), error = ref(''), loading = ref(false)
const rows = computed(() => performanceMetrics.filter(metric => Number(data.value?.goal?.[metric.goal]) > 0).map(metric => { const target = data.value.goal[metric.goal], actual = data.value.actual[metric.key]; return { ...metric, target, actual, rate: completionRate(actual, target), achieved: goalAchieved(actual, target, metric.key) } }))
onMounted(async () => { loading.value = true; try { const res = await getMyGoal(); if (res.code === 0) data.value = res.data; else error.value = res.msg || '本月目标读取失败' } catch { error.value = '本月目标读取失败,请刷新重试' } finally { loading.value = false } })
</script>
<style scoped>
.my-goal-card { width: calc(50% - 6px); min-width: 550px; }
.goal-heading { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.goal-row { display: grid; grid-template-columns: 70px auto minmax(100px,1fr) 68px; align-items: center; gap: 12px; margin: 12px 0; font-size: 13px; }
.goal-progress { display: flex; align-items: center; gap: 6px; }
.goal-progress > span { flex: 1; height: 7px; border-radius: 4px; overflow: hidden; background: var(--el-fill-color-dark); }
.goal-progress i { display: block; height: 100%; background: var(--el-color-primary); }
.goal-progress i.achieved { background: var(--el-color-success); }
@media(max-width:800px) { .my-goal-card { width: 100%; min-width: 0; } .goal-row { grid-template-columns: 65px 1fr 68px; } .goal-progress { grid-row: 2; grid-column: 1 / 4; } }
</style>
@@ -0,0 +1,29 @@
<template>
<el-drawer 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-alert v-if="error" :title="error" type="error" :closable="false" class="mt-3" />
<el-table v-loading="loading" :data="rows" class="mt-3" :row-key="row => `${row.sourceDb}:${row.id}:${row.contributorId || 0}`">
<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="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="区间充值流水" 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>
<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>
<script setup>
import { computed, ref, watch } from 'vue'
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'])
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('')
let version = 0
const load = async () => { if (!visible.value || !props.query) return; const v = ++version; loading.value = true; error.value = ''; try { const res = await getPerformanceDetails({ ...props.query, page: page.value, size: size.value }); if (v !== version) 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 (v === version) error.value = '明细读取失败,请重试' } finally { if (v === version) loading.value = false } }
const search = () => { page.value = 1; return load() }
watch(visible, v => { if (v) { rows.value = []; search() } else version++ })
</script>
@@ -0,0 +1,35 @@
<template>
<div class="performance-scroll">
<div ref="viewport" class="table-viewport" :class="{ dragging }" :style="{ maxHeight: vertical ? '600px' : 'none' }" @scroll="measure" @pointerdown="start" @pointermove="move" @pointerup="end" @pointercancel="end" @click.capture="captureClick"><div ref="content" class="table-content"><slot /></div></div>
<div v-if="maximum > 0" ref="track" class="scroll-track" role="scrollbar" tabindex="0" aria-label="表格横向滚动" aria-orientation="horizontal" :aria-valuemin="0" :aria-valuemax="Math.round(maximum)" :aria-valuenow="Math.round(left)" @pointerdown="trackStart" @pointermove="trackMove" @pointerup="trackEnd" @pointercancel="trackEnd" @keydown.left.prevent="scrollBy(-100)" @keydown.right.prevent="scrollBy(100)"><div class="scroll-thumb" :style="{ width: `${thumbWidth}px`, transform: `translateX(${thumbLeft}px)` }" /></div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref } from 'vue'
const props = defineProps({ vertical: Boolean })
const viewport = ref(), content = ref(), track = ref(), width = ref(0), scrollWidth = ref(0), left = ref(0), dragging = ref(false)
const maximum = computed(() => Math.max(0, scrollWidth.value - width.value))
const thumbWidth = computed(() => Math.max(36, width.value * width.value / Math.max(1, scrollWidth.value)))
const thumbLeft = computed(() => maximum.value ? left.value / maximum.value * Math.max(0, width.value - thumbWidth.value) : 0)
let pointer, trackPointer, observer, suppressClick = false, resetTimer
const measure = () => { if (!viewport.value) return; width.value = viewport.value.clientWidth; scrollWidth.value = viewport.value.scrollWidth; left.value = viewport.value.scrollLeft }
const start = event => { if (event.button !== 0 || event.target.closest('input,textarea,select,.el-select')) return; pointer = { x: event.clientX, y: event.clientY, left: viewport.value.scrollLeft, top: viewport.value.scrollTop, id: event.pointerId } }
const move = event => { if (!pointer) return; const dx = event.clientX - pointer.x, dy = event.clientY - pointer.y; if (!dragging.value && Math.max(Math.abs(dx), props.vertical ? Math.abs(dy) : 0) <= 5) return; dragging.value = true; suppressClick = true; viewport.value.setPointerCapture(event.pointerId); event.preventDefault(); viewport.value.scrollLeft = pointer.left - dx; if (props.vertical) viewport.value.scrollTop = pointer.top - dy }
const end = event => { if (viewport.value?.hasPointerCapture(event.pointerId)) viewport.value.releasePointerCapture(event.pointerId); pointer = null; dragging.value = false; clearTimeout(resetTimer); resetTimer = setTimeout(() => { suppressClick = false }, 100) }
const captureClick = event => { if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false } }
const trackStart = event => { if (event.button !== 0) return; event.preventDefault(); const rect = track.value.getBoundingClientRect(); if (!event.target.classList.contains('scroll-thumb')) viewport.value.scrollLeft = (event.clientX - rect.left - thumbWidth.value / 2) / Math.max(1, width.value - thumbWidth.value) * maximum.value; trackPointer = { x: event.clientX, left: viewport.value.scrollLeft }; track.value.setPointerCapture(event.pointerId) }
const trackMove = event => { if (!trackPointer) return; viewport.value.scrollLeft = trackPointer.left + (event.clientX - trackPointer.x) / Math.max(1, width.value - thumbWidth.value) * maximum.value }
const trackEnd = event => { if (track.value?.hasPointerCapture(event.pointerId)) track.value.releasePointerCapture(event.pointerId); trackPointer = null }
const scrollBy = amount => { viewport.value.scrollLeft += amount }
onMounted(() => { observer = new ResizeObserver(measure); observer.observe(viewport.value); observer.observe(content.value); measure() })
onUpdated(() => nextTick(measure))
onBeforeUnmount(() => { observer?.disconnect(); clearTimeout(resetTimer) })
</script>
<style scoped>
.performance-scroll { min-width: 0; max-width: 100%; }
.table-viewport { overflow: auto; border: 1px solid var(--el-border-color-light); border-radius: 6px; width: 100%; }
.table-content { width: max-content; min-width: 100%; }
.dragging, .dragging * { cursor: grabbing !important; user-select: none !important; }
.scroll-track { height: 16px; background: var(--el-fill-color-dark); border-radius: 8px; margin-top: 8px; position: relative; cursor: pointer; touch-action: none; }
.scroll-thumb { height: 12px; position: absolute; top: 2px; background: var(--el-color-primary); opacity: .75; border-radius: 7px; cursor: grab; }
</style>
@@ -0,0 +1,42 @@
<template>
<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>
<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>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { displayMetric } from '../performance-display'
const props = defineProps({ periods: { type: Array, default: () => [] }, trend: { type: Object, default: () => ({}) } })
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 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)))])
const selected = computed(() => active.value == null ? null : props.periods[active.value])
const hover = event => { const rect = event.currentTarget.getBoundingClientRect(); active.value = Math.max(0, Math.min(props.periods.length - 1, Math.round((event.clientX - rect.left - 52) / Math.max(1, width.value - 112) * Math.max(0, props.periods.length - 1)))) }
const move = step => { active.value = Math.max(0, Math.min(props.periods.length - 1, (active.value ?? 0) + step)) }
let observer
onMounted(() => { observer = new ResizeObserver(() => { width.value = Math.max(300, host.value.clientWidth) }); observer.observe(host.value) })
onBeforeUnmount(() => observer?.disconnect())
</script>
<style scoped>
.performance-trend { position: relative; min-width: 0; }
svg { display: block; width: 100%; height: 396px; }
text { fill: var(--el-text-color-secondary); font-size: 11px; }
.grid { stroke: var(--el-border-color-lighter); stroke-dasharray: 3 4; }
.cursor { stroke: var(--el-text-color-secondary); stroke-dasharray: 4 4; }
.legend { display: flex; gap: 15px; flex-wrap: wrap; font-size: 12px; }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; }
.trend-tooltip { position: absolute; top: 35px; border: 1px solid var(--el-border-color-light); border-radius: 6px; padding: 12px; background: var(--el-bg-color-overlay); box-shadow: var(--el-box-shadow-light); pointer-events: none; font-size: 12px; line-height: 1.8; min-width: 180px; }
</style>
@@ -28,6 +28,7 @@
<el-table-column v-else prop="gameId" label="游戏ID" min-width="100" />
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.width || 110" sortable="custom"><template #header><el-tooltip :content="metricTips[column.key]" placement="top"><span>{{ column.label }}</span></el-tooltip></template><template #default="{ row }">{{ format(column.type, row[column.key]) }}</template></el-table-column>
</el-table>
<p v-if="isChannel" class="mt-4">合计:有效端 <el-text type="success">{{ result?.effectiveCount == null ? '未接入' : result.effectiveCount }}</el-text> 个</p>
<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" /></div>
</div>
</div>
@@ -0,0 +1,65 @@
<template>
<el-dialog v-model="visible" append-to-body title="小组设置" width="min(820px, 95vw)" :close-on-click-modal="false" destroy-on-close>
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3" />
<div class="team-editor" v-loading="saving">
<aside>
<div v-for="group in draft" :key="keyOf(group)" class="team-entry" :class="{ active: selected === keyOf(group) }">
<button @click="selected = keyOf(group)">{{ group.name }} <small>{{ group.memberIds.length }} 人</small></button>
<el-dropdown v-if="group.canEdit" trigger="click"><el-button link aria-label="小组操作">⋯</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item @click="rename(group)">重命名</el-dropdown-item><el-dropdown-item @click="remove(group)">删除小组</el-dropdown-item></el-dropdown-menu></template></el-dropdown>
</div>
<button class="ungrouped" :class="{ active: selected === 'none' }" @click="selected = 'none'">未分组 <small>{{ ungrouped.length }} 人</small></button>
<el-button class="mt-3" plain type="primary" @click="add">新建小组</el-button>
</aside>
<section>
<div class="team-title"><strong>{{ active?.name || '未分组' }}</strong><el-input v-model="keyword" clearable placeholder="搜索商务名称或账号" /></div>
<el-alert v-if="overlap" title="组员应当平级,当前选择包含上下级商务。小组绩效按成员相加时会有重叠。" type="warning" :closable="false" class="mb-3" />
<el-alert v-if="active?.partial && !active.canEdit" title="此组包含权限范围外成员,不能重命名或删除。" type="info" :closable="false" class="mb-3" />
<el-empty v-if="!members.length" description="没有匹配的商务" :image-size="64" />
<div v-for="member in members" :key="member.userId" class="member-choice">
<el-checkbox :model-value="active?.memberIds.includes(member.userId) || false" :disabled="!active || !active.canEdit" @change="toggle(member.userId, $event)">{{ displayMember(member) }}</el-checkbox>
<span>{{ businessLevels[member.level] }} · {{ member.parentUserName || '无上级' }}</span>
<small>{{ groupOf(member.userId)?.name || '未分组' }}</small>
</div>
</section>
</div>
<template #footer><el-button :disabled="saving" @click="reset">恢复默认</el-button><el-button :disabled="saving" @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { saveTeams } from '@/api/performance'
import { displayMember, businessLevels } from '../performance-display'
const props = defineProps({ modelValue: Boolean, catalog: { type: Object, default: () => ({ groups: [], members: [], revision: 0 }) } })
const emit = defineEmits(['update:modelValue', 'saved'])
const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
const draft = ref([]), selected = ref('none'), keyword = ref(''), saving = ref(false), error = ref('')
let temporaryID = 0
const keyOf = group => String(group.id || group.temporaryID)
const active = computed(() => draft.value.find(group => keyOf(group) === selected.value))
const groupOf = id => draft.value.find(group => group.memberIds.includes(id))
const ungrouped = computed(() => props.catalog.members.filter(member => !groupOf(member.userId)))
const members = computed(() => (active.value ? props.catalog.members : ungrouped.value).filter(member => `${displayMember(member)} ${member.userName}`.toLowerCase().includes(keyword.value.toLowerCase())))
const overlap = computed(() => { const ids = new Set(active.value?.memberIds || []); return props.catalog.members.some(m => ids.has(m.userId) && String(m.idPath || '').split(',').some(id => ids.has(Number(id)))) })
watch(visible, value => { if (value) { draft.value = props.catalog.groups.map(g => ({ ...g, memberIds: [...g.memberIds] })); selected.value = draft.value.length ? keyOf(draft.value[0]) : 'none'; keyword.value = ''; error.value = '' } })
const namePrompt = async (title, value = '') => { try { const res = await ElMessageBox.prompt('组名为 1 至 10 个字', title, { inputValue: value, inputValidator: text => text?.trim() && [...text.trim()].length <= 10 || '请输入 1 至 10 个字的组名' }); return res.value.trim() } catch { return null } }
const add = async () => { const name = await namePrompt('新建小组'); if (!name) return; const group = { id: 0, temporaryID: `new-${++temporaryID}`, name, memberIds: [], canEdit: true }; draft.value.push(group); selected.value = keyOf(group) }
const rename = async group => { const name = await namePrompt('重命名小组', group.name); if (name) group.name = name }
const remove = async group => { try { await ElMessageBox.confirm(`删除后 ${group.memberIds.length} 位成员将回到未分组,成员账号和历史数据保留。`, '删除小组', { type: 'warning' }) } catch { return } draft.value = draft.value.filter(g => g !== group); selected.value = draft.value.length ? keyOf(draft.value[0]) : 'none' }
const toggle = (id, checked) => { for (const group of draft.value) group.memberIds = group.memberIds.filter(value => value !== id); if (checked && active.value) active.value.memberIds.push(id) }
const reset = async () => { try { await ElMessageBox.confirm('默认不预设小组,将清空当前可管理小组,成员回到未分组。点击保存后生效。', '恢复默认', { type: 'warning' }) } catch { return } draft.value = draft.value.filter(g => !g.canEdit); selected.value = 'none' }
const save = async () => { if (saving.value) return; saving.value = true; error.value = ''; try { const res = await saveTeams({ revision: props.catalog.revision, groups: draft.value.map(g => ({ id: g.id, name: g.name, memberIds: g.memberIds })) }); if (res.code !== 0) { error.value = res.msg || '保存失败'; return } ElMessage.success('小组设置已保存'); emit('saved', res.data); visible.value = false } catch { error.value = '小组设置保存失败,请重试' } finally { saving.value = false } }
</script>
<style scoped>
.team-editor { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 20px; min-height: 340px; }
aside { border-right: 1px solid var(--el-border-color-light); padding-right: 16px; }
.team-entry { display: flex; align-items: center; border-radius: 5px; }
.team-entry button, .ungrouped { background: none; border: none; color: var(--el-text-color-primary); text-align: left; padding: 12px 8px; cursor: pointer; width: 100%; }
.active { background: var(--el-color-primary-light-9) !important; }
small, .member-choice span { color: var(--el-text-color-secondary); font-size: 12px; }
.team-title { display: flex; gap: 12px; align-items: center; margin-bottom: 15px; }
.team-title .el-input { flex: 1; }
.member-choice { display: flex; align-items: center; gap: 10px; padding: 7px 0; flex-wrap: wrap; }
.member-choice small { margin-left: auto; }
@media(max-width:600px) { .team-editor { grid-template-columns: 1fr; } aside { border-right: 0; } }
</style>
+18
View File
@@ -0,0 +1,18 @@
import { channelMoney, channelCount } from './channel-display.js'
import { compareAmounts } from './table-display.js'
export const employmentLabels = { employed: '在职', probation: '试用', departed: '离职' }
export const employmentTypes = { employed: 'success', probation: 'warning', departed: 'info' }
export const businessLevels = { 1: '一级商务', 2: '二级商务', 3: '三级商务' }
export const performanceMetrics = [{ key: 'open', label: '开端', goal: 'open' }, { key: 'effective', label: '有效端', goal: 'effective' }, { key: 'pay', label: '充值流水', goal: 'amount' }]
export const displayMember = member => member?.nickName || member?.userName || '—'
export const displayMetric = (key, value) => value == null ? '—' : key === 'pay' || key === 'amount' ? channelMoney(value) : channelCount(value)
export const displayTarget = (key, value) => value == null ? '—' : key === 'amount' ? channelMoney(value) : Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })
export const completionRate = (actual, target) => target == null || Number(target) <= 0 ? null : Number(actual || 0) / Number(target) * 100
export const goalAchieved = (actual, target, key) => target != null && Number(target) > 0 && (key === 'pay' ? compareAmounts(actual, String(target)) >= 0 : Number(actual) >= target)
export const rateText = rate => rate == null ? '—' : `${rate.toFixed(1)}%`
export const progressWidth = rate => Math.max(0, Math.min(100, rate || 0))
export const dateText = value => value == null ? '—' : new Date(Number(value) * 1000).toLocaleDateString('zh-CN').replaceAll('/', '-')
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 effectiveRate = metrics => metrics?.open ? Number(metrics.effective) / Number(metrics.open) * 100 : null
@@ -0,0 +1,18 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { goalAchieved, completionRate, hasGoal, monthOffset } from './performance-display.js'
test('goal attainment compares exact cents even when rounded percentages look complete', () => {
assert.equal(goalAchieved('9007199254740990.99', 9007199254740991, 'pay'), false)
assert.equal(goalAchieved('9007199254740991.00', 9007199254740991, 'pay'), true)
assert.equal(goalAchieved('100.00', 0, 'pay'), false)
assert.equal(goalAchieved(10, 10, 'open'), true)
assert.equal(completionRate(12, 10), 120)
})
test('empty and zero targets stay distinct and year boundaries retain the month', () => {
assert.equal(hasGoal({ open: null, effective: null, amount: null }), false)
assert.equal(hasGoal({ open: 0, effective: null, amount: null }), true)
assert.equal(monthOffset('2026-01', -1), '2025-12')
assert.equal(monthOffset('2026-12', 1), '2027-01')
})