feat: automate business upgrades and sync reviewed frontend

This commit is contained in:
Yvan
2026-11-06 15:48:35 +08:00
parent 7df74bc8e8
commit 9fccd804a3
58 changed files with 5689 additions and 259 deletions
+26
View File
@@ -0,0 +1,26 @@
const textOf = async blob => (await blob.text()).replace(/^\uFEFF/, '').trim()
const looksLikeMessage = text => text.startsWith('{') || text.startsWith('[') || text.startsWith('导出')
export async function readBlobBusinessMessage(blob) {
if (!(blob instanceof Blob)) return ''
const type = (blob.type || '').toLowerCase()
const declaredText = type.includes('json') || type.startsWith('text/')
if (!declaredText) {
const head = (await textOf(blob.slice(0, 64)))
if (!looksLikeMessage(head)) return ''
}
const text = await textOf(blob)
if (!text) return ''
if (!text.startsWith('{') && !text.startsWith('[')) return declaredText || text.startsWith('导出') ? text : ''
try {
const data = JSON.parse(text)
if (!data || typeof data !== 'object' || Array.isArray(data)) return ''
const message = data.msg || data.message || ''
if ('code' in data && Number(data.code) !== 0) return message || '导出失败'
if (typeof message === 'string' && (message.includes('导出') || message.includes('10000'))) return message
return ''
} catch {
return ''
}
}
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readBlobBusinessMessage } from './blobError.js'
const jsonBlob = (value, type = 'application/json') => new Blob([JSON.stringify(value)], { type })
test('reads the export limit message from a json blob', async () => {
const message = '导出数据超过10000条,请缩小时间范围或筛选条件'
assert.equal(await readBlobBusinessMessage(jsonBlob({ code: 500, msg: message })), message)
})
test('reads the same message when the file type is not json', async () => {
const message = '导出数据超过10000条,请缩小时间范围或筛选条件'
assert.equal(await readBlobBusinessMessage(jsonBlob({ code: 500, msg: message }, 'application/octet-stream')), message)
})
test('reads a plain-text export error', async () => {
const message = '导出数据超过10000条,请缩小时间范围或筛选条件'
assert.equal(await readBlobBusinessMessage(new Blob([message], { type: 'text/plain' })), message)
})
test('leaves spreadsheet and unrelated json files alone', async () => {
const sheet = new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00])], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
assert.equal(await readBlobBusinessMessage(sheet), '')
assert.equal(await readBlobBusinessMessage(jsonBlob({ versionName: '1.0', versionCode: '1', message: '发版说明' })), '')
assert.equal(await readBlobBusinessMessage(jsonBlob({ code: 0, msg: 'ok' })), '')
assert.equal(await readBlobBusinessMessage(jsonBlob({ msg: '导出数据超过10000条,请缩小时间范围或筛选条件' })), '导出数据超过10000条,请缩小时间范围或筛选条件')
})
+24 -2
View File
@@ -3,6 +3,7 @@ import { useUserStore } from '@/pinia/modules/user'
import { ElLoading, ElMessage } from 'element-plus'
import { emitter } from '@/utils/bus'
import { isPasswordChangeRequiredError } from '@/utils/requestError'
import { readBlobBusinessMessage } from '@/utils/blobError'
import router from '@/router/index'
const DEFAULT_REQUEST_TIMEOUT = 1000 * 60 * 10
@@ -189,8 +190,20 @@ const showErrorMessage = (rawMessage) => {
activeErrorMessages.set(message, instance)
}
const rejectBlobBusinessError = async (response, error) => {
const message = await readBlobBusinessMessage(response?.data)
if (!message) return null
showErrorMessage(message)
const notified = error || new Error(message)
notified.businessMessage = message
notified.exportNotified = true
notified.config = notified.config || response?.config
notified.response = notified.response || response
return notified
}
service.interceptors.response.use(
(response) => {
async (response) => {
const userStore = useUserStore()
if (!response.config.donNotShowLoading) {
@@ -201,6 +214,12 @@ service.interceptors.response.use(
userStore.setToken(response.headers['new-token'])
}
if (response.data instanceof Blob) {
const notified = await rejectBlobBusinessError(response)
if (notified) return Promise.reject(notified)
return response
}
if (typeof response.data.code === 'undefined') {
return response
}
@@ -216,7 +235,7 @@ service.interceptors.response.use(
return response.data.msg ? response.data : response
},
(error) => {
async (error) => {
if (!error.config?.donNotShowLoading) {
closeLoading(error.config?.loadingOption)
}
@@ -257,6 +276,9 @@ service.interceptors.response.use(
return Promise.reject(error)
}
const notified = await rejectBlobBusinessError(error.response, error)
if (notified) return Promise.reject(notified)
emitter.emit('show-error', {
code: error.response.status,
message: getErrorMessage(error)
@@ -27,7 +27,7 @@
<el-alert v-if="kpiError" :title="kpiError" type="warning" :closable="false" />
<div v-loading="kpiLoading" class="kpi-grid">
<article v-for="item in kpiList" :key="item.label" class="kpi-card">
<el-tooltip :content="item.tip"><p class="kpi-label">{{ item.label }}</p></el-tooltip>
<p class="kpi-label"><span class="has-tip" @mouseenter="showFieldTip($event, item.tip)" @mouseleave="hideFieldTip">{{ item.label }}</span></p>
<p class="kpi-value">{{ item.value }}</p>
<p class="kpi-change" :class="{ up: item.up, down: item.down }">{{ item.change }}</p>
</article>
@@ -188,6 +188,9 @@
</div>
</section>
</div>
<teleport to="body">
<div v-if="fieldTip.open" ref="fieldTipRef" class="field-tip" :class="{ ready: fieldTip.ready }" :style="{ left: `${fieldTip.x}px`, top: `${fieldTip.y}px` }">{{ fieldTip.text }}</div>
</teleport>
</div>
</template>
@@ -252,6 +255,35 @@ const overviewPeriod = ref('today')
const overviewDates = ref([])
const kpiError = ref('')
const kpiList = ref(overviewCards(null))
const fieldTip = ref({ open: false, ready: false, text: '', x: 0, y: 0 })
const fieldTipRef = ref()
let fieldTipToken = 0
const placeFieldTip = anchor => {
const el = fieldTipRef.value
if (!el) return
const margin = 8
const left = Math.max(margin, Math.min(anchor.left + anchor.width / 2 - el.offsetWidth / 2, window.innerWidth - el.offsetWidth - margin))
const above = anchor.top - el.offsetHeight - margin
fieldTip.value = { ...fieldTip.value, ready: true, x: left, y: above >= margin ? above : anchor.bottom + margin }
}
const showFieldTip = async (event, text) => {
const token = ++fieldTipToken
const anchor = event.currentTarget.getBoundingClientRect()
fieldTip.value = { open: true, ready: false, text, x: -9999, y: 0 }
await nextTick()
if (token === fieldTipToken) placeFieldTip(anchor)
}
const hideFieldTip = () => {
fieldTipToken += 1
if (fieldTip.value.open) fieldTip.value = { ...fieldTip.value, open: false, ready: false }
}
const hideFieldTipOnScroll = () => {
if (fieldTip.value.open) hideFieldTip()
}
const requestVersions = { summary: 0, register: 0, recharge: 0, offline: 0, low: 0, alerts: 0 }
const kpiLoading = ref(false)
const alertLoading = ref(false)
@@ -759,6 +791,7 @@ onMounted(() => {
fetchLowRechargeRank()
})
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', hideFieldTipOnScroll, true)
themeObserver = new MutationObserver(() => { renderRegisterChart(); renderRechargeChart() })
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
@@ -769,6 +802,7 @@ onBeforeUnmount(() => {
stopAlertTimer()
if (alertAnimTimer) clearTimeout(alertAnimTimer)
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', hideFieldTipOnScroll, true)
registerChart?.dispose()
rechargeChart?.dispose()
registerChart = undefined
@@ -861,6 +895,16 @@ onBeforeUnmount(() => {
line-height: 1.4;
}
.has-tip {
cursor: help;
border-bottom: 1px dashed transparent;
}
.has-tip:hover {
color: var(--dash-blue);
border-bottom-color: var(--dash-blue);
}
.kpi-value {
margin: 0;
color: var(--dash-title);
@@ -1244,3 +1288,27 @@ html.dark .alert-body {
}
}
</style>
<style lang="scss">
.field-tip {
position: fixed;
z-index: 3000;
visibility: hidden;
max-width: 320px;
padding: 8px 10px;
border-radius: 8px;
background: #1e2433;
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: 1.55;
text-align: left;
white-space: pre-line;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
pointer-events: none;
}
.field-tip.ready {
visibility: visible;
}
</style>
@@ -1,14 +1,30 @@
import { channelCount, channelMoney } from '../../sw/channel-display.js'
const formatTipAmount = amount => {
const value = Number(amount)
return Number.isFinite(value) ? value.toLocaleString('en-US') : String(amount)
}
const effectiveTip = data => {
const days = data?.settings?.days ?? 30
const amount = formatTipAmount(data?.settings?.amount ?? 3000)
return [
'所选区间内达成有效端的渠道数。',
`判定:渠道注册后 ${days} 天内累计充值流水满 ¥${amount} 即达成,按达标当天计入;`,
`注册满 ${days} 天仍未达标的不算,之后流水上来也不补记;达标后标记保持。`,
'阈值与天数由超管在「设置」中配置。'
].join('\n')
}
const definitions = [
{ key: 'todayRegister', label: '玩家注册' },
{ key: 'todayActive', label: '活跃玩家' },
{ key: 'todayPay', label: '充值金额', money: true },
{ key: 'todayRegister', label: '玩家注册', tip: '所选区间内新增注册玩家数。\n勾选右上角「身份证去重」后按实名身份证去重:同一身份证多账号计 1 人,无实名信息不计入。' },
{ key: 'todayActive', label: '活跃玩家', tip: '所选区间内登录游戏的去重玩家数。\n勾选「身份证去重」后按实名身份证去重,无实名信息不计入。' },
{ key: 'todayPay', label: '充值金额', money: true, tip: '所选区间内所有渠道的充值成功金额汇总,已扣除失败 / 取消订单。' },
{ key: 'effectivePromote', label: '有效端' },
{ key: 'activePromote', label: '活跃渠道数' },
{ key: 'newPromote', label: '新增渠道数' },
{ key: 'lockedPromote', label: '锁定渠道', event: true },
{ key: 'terminatedPromote', label: '解除合作渠道', event: true }
{ key: 'activePromote', label: '活跃渠道数', tip: '所选区间内至少产生 1 个注册或 1 笔充值的渠道数。' },
{ key: 'newPromote', label: '新增渠道数', tip: '所选区间内新建立合作关系的渠道数,以渠道在系统中首次创建 / 启用时间为准。' },
{ key: 'lockedPromote', label: '锁定渠道', event: true, tip: '所选区间内被锁定的渠道数,按锁定时间落在区间内统计。\n统计的是「这段时间内发生了多少」,与渠道当前状态无关:例如 1 月锁定、2 月恢复正常,在 1 月区间里仍算锁定渠道。' },
{ key: 'terminatedPromote', label: '解除合作渠道', event: true, tip: '所选区间内被解除合作的渠道数,按解除合作时间落在区间内统计。\n解除合作与锁定分开展示,二者都属于被锁定的范畴。' }
]
export const overviewCards = (data) => definitions.map(definition => {
@@ -23,9 +39,7 @@ export const overviewCards = (data) => definitions.map(definition => {
change,
up: known && changeRate > 0,
down: known && changeRate < 0,
tip: definition.key === 'effectivePromote'
? `渠道注册后 ${data?.settings?.days ?? 30} 天内充值满 ¥${data?.settings?.amount ?? 3000},按达标日期统计。`
: definition.event ? '按所选区间内的状态变更事件统计。' : '所选时间区间内的数据,与上一等长周期比较。'
tip: definition.key === 'effectivePromote' ? effectiveTip(data) : definition.tip
}
})
@@ -14,7 +14,9 @@ test('overview preserves exact money, eight cards, and unavailable event data',
assert.equal(cards.find(c => c.key === 'todayRegister').value, '0')
assert.equal(cards.find(c => c.key === 'todayActive').value, '5,998')
assert.equal(cards.find(c => c.key === 'lockedPromote').value, '未接入')
assert.match(cards.find(c => c.key === 'effectivePromote').tip, /15 天内充值满 ¥500/)
assert.equal(cards.find(c => c.key === 'todayRegister').tip, '所选区间内新增注册玩家数。\n勾选右上角「身份证去重」后按实名身份证去重:同一身份证多账号计 1 人,无实名信息不计入。')
assert.match(cards.find(c => c.key === 'effectivePromote').tip, /注册后 15 天内累计充值流水满 ¥500/)
assert.match(cards.find(c => c.key === 'lockedPromote').tip, /1 月锁定、2 月恢复正常/)
assert.ok(overviewCards(null).every(c => c.value === '—'))
assert.ok(overviewCards({ available: false }).every(c => c.value === '未接入'))
})
+2 -2
View File
@@ -10,11 +10,11 @@
@click="handleCommand"
/>
<icon-button
<!-- <icon-button
icon="lucide:settings"
label="设置"
@click="toggleSetting"
/>
/> -->
<icon-button
v-if="settings.header.refresh.visible"
+3 -3
View File
@@ -35,11 +35,11 @@
<CustomPic style="margin-top: 8px" :pic-src="scope.row.headerImg" />
</template>
</el-table-column>
<el-table-column align="left" label="ID" min-width="50" prop="ID" sortable="custom" />
<el-table-column align="left" label="ID" min-width="90" prop="ID" sortable="custom" />
<el-table-column
align="left"
label="用户名"
min-width="150"
min-width="200"
prop="userName"
/>
<el-table-column
@@ -139,7 +139,7 @@
</el-select>
</template>
</el-table-column>
<el-table-column align="left" label="启用" min-width="150">
<el-table-column align="left" label="启用" min-width="70">
<template #default="scope">
<el-switch
v-model="scope.row.enable"
+163 -34
View File
@@ -10,7 +10,7 @@
<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>
<el-tabs v-model="tab"><el-tab-pane label="商务绩效" name="business" /><el-tab-pane label="小组绩效" name="groups" /><el-tab-pane label="趋势图" name="trend" /></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>
@@ -27,20 +27,83 @@
</table>
</performance-scroll>
<template v-if="tab === 'groups' && data">
<el-text tag="p" type="info" class="mb-3">小组目标独立设置,实际为成员绩效之和;目标可能在月中调整。{{ data?.granularity === 'day' ? '当日目标 = 月目标 ÷ 当月天数;左侧展示当月目标和达成。' : '完成率只纳入已设置相应目标的月份。' }}</el-text>
<p v-if="isDaily" class="es-grp-note">左侧「小组 / 人数 / 目标 / 达成 / 完成率」与小组信息一起固定:<b>目标 / 达成 / 完成率 = 所选单月的绩效</b>(与月视图同源同数,目标按月设置,完成率只显示百分比不加条);右侧按日倒序展示<b>到今天为止</b>的数据(未来日期不占列),<b>每日「目标 / 实际」两列与月视图同构</b>,格内 = 开端 / 有效端 / 流水三项指标,实际列下方细条横跨两格 = 当日完成率进度条(当日目标 = 月目标 ÷ 当月天数,当日实际为当天数据,各日之和 = 当月实际);未设目标的日显示「—」;点组名前「▸」展开组员当日明细。</p>
<p v-else class="es-grp-note">左侧「小组 / 人数 / 达成 / 完成率」与小组信息一起固定:<b>达成 = 区间合计的开端 / 有效端 / 流水</b>(区间内所有月份累计,不依赖目标,未设目标的组也照常显示);完成率 = <b>有目标月份的合计实际 ÷ 合计目标</b>(三指标分行,只显示百分比不加条,达满 100% 变绿);每月「目标 / 实际」两列,格内显示开端 / 有效端 / 充值流水三项指标,<b>实际列下方的完成率条横跨「目标 / 实际」两格</b>;点组名前「▸」展开组员绩效与目标。目标按月设置,<b>当月目标可能中途调整,本表始终显示最新一次保存的值,历史月不变</b>。</p>
<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 class="es-grp" :style="{ width: `${groupWidth}px` }">
<colgroup>
<col v-for="(width, index) in groupFixed" :key="index" :style="{ width: `${width}px` }" />
<template v-for="period in groupPeriods" :key="period.key"><col style="width:104px" /><col style="width:104px" /></template>
</colgroup>
<thead>
<tr>
<th class="es-gf1" rowspan="2">小组</th>
<th class="es-gf2" rowspan="2">人数</th>
<th v-if="isDaily" class="es-gf3" rowspan="2">目标</th>
<th :class="isDaily ? 'es-gf4' : 'es-gf3'" rowspan="2">达成</th>
<th :class="isDaily ? 'es-gf5' : 'es-gf4'" rowspan="2">完成率</th>
<th v-for="(period, index) in groupPeriods" :key="period.key" colspan="2" :class="{ 'es-wk': index > 0, 'es-today': isToday(period.key) }">{{ period.label }}</th>
</tr>
<tr>
<template v-for="(period, index) in groupPeriods" :key="`${period.key}-sub`">
<th :class="{ 'es-wk': index > 0 }">目标</th>
<th>实际</th>
</template>
</tr>
</thead>
<tbody>
<tr v-for="row in teamRows" :key="row.key" :class="row.member ? ['es-grp-mem', { 'es-mem-out': row.member.employment === 'departed' }] : 'es-grp-hd'">
<td class="es-gf1" :class="{ 'es-grp-name': !row.member }">
<button v-if="!row.member" type="button" class="es-grp-arrow" :aria-expanded="expanded.has(row.id)" title="展开 / 收起组员绩效与目标" @click="toggleGroup(row.id)">{{ expanded.has(row.id) ? '▾' : '▸' }}</button>
<span v-if="row.member" class="es-grp-memname">{{ row.name }}</span>
<template v-else>{{ row.name }}</template>
</td>
<td class="es-gf2" :class="{ 'es-grp-lv': !!row.member }">{{ row.member ? levelShort(row.member.level) : row.memberCount }}</td>
<td v-if="isDaily" :class="[monthGoal(row) ? 'es-grp-cell es-grp-t' : 'es-grp-none', 'es-gf3']" :title="monthGoal(row) ? undefined : '当月未设置目标'">
<template v-if="monthGoal(row)"><div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b>{{ groupFmt(goalNumber(monthGoal(row), metric), metric.key) }}</b></div></template>
<template v-else>—</template>
</td>
<td class="es-grp-cell" :class="isDaily ? 'es-gf4' : 'es-gf3'">
<div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b :class="isDaily && monthGoal(row) ? achievedClass(row.total?.[metric.key], goalNumber(monthGoal(row), metric)) : ''">{{ groupFmt(row.total?.[metric.key], metric.key) }}</b></div>
</td>
<td :class="[rateReady(row) ? 'es-grp-cell' : 'es-grp-none', isDaily ? 'es-gf5' : 'es-gf4']" :title="rateReady(row) ? '有目标月份的合计实际 ÷ 合计目标' : (isDaily ? '当月未设置目标' : '未设置目标')">
<template v-if="rateReady(row)"><div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b :class="{ 'g-ok': metricRate(row, metric) != null && metricRate(row, metric) >= 100 }">{{ groupRateText(metricRate(row, metric)) }}</b></div></template>
<template v-else>—</template>
</td>
<template v-for="(period, index) in groupPeriods" :key="`${row.key}-${period.key}`">
<template v-if="isFutureDay(period.key)">
<td class="es-grp-none" :class="{ 'es-wk': bodyWeek(period, index) }">—</td>
<td class="es-grp-none">—</td>
</template>
<template v-else-if="periodGoal(row, period)">
<td class="es-grp-cell es-grp-t" :class="{ 'es-wk': bodyWeek(period, index), 'es-today': isToday(period.key) }">
<div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b>{{ groupFmt(periodGoalValue(row, period, metric), metric.key) }}</b></div>
</td>
<td class="es-grp-cell" :class="{ 'es-today-r': isToday(period.key) }">
<div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b :class="achievedClass(row.periods?.[period.key]?.[metric.key], periodGoalValue(row, period, metric))">{{ groupFmt(row.periods?.[period.key]?.[metric.key] ?? 0, metric.key) }}</b></div>
<div v-if="roundedRate(amountRate(row, period)) != null" class="es-pbar-wrap" :title="barTitle(row, period)">
<div class="es-pbar"><i :data-ok="amountRate(row, period) >= 100 ? '1' : null" :style="{ width: `${progressWidth(amountRate(row, period))}%` }" /></div>
<span class="es-pbar-pct" :class="{ 'g-ok': amountRate(row, period) >= 100 }">{{ groupRateText(amountRate(row, period)) }}</span>
</div>
</td>
</template>
<template v-else>
<td class="es-grp-none" :class="{ 'es-wk': bodyWeek(period, index) }" title="未设置目标">—</td>
<td class="es-grp-none" title="未设置目标">—</td>
</template>
</template>
</tr>
<tr v-if="!teamRows.length"><td class="es-grp-empty" :colspan="groupFixed.length + groupPeriods.length * 2">当前范围内没有小组</td></tr>
</tbody>
</table>
</performance-scroll>
<div class="es-grp-legend">
<span v-if="isDaily">按日显示(未跨月):左侧「目标 / 达成 / 完成率」= 所选当月的绩效(与月视图同源同数,目标按月设置,完成率只显示百分比不加条);每日两列 = 当日目标 / 当日实际,格内三行 = 开端 / 有效(有效端)/ 流水(充值流水),实际列下方细条横跨「目标 / 实际」两格 = 当日完成率进度条;只显示到今天为止的日期(未来日期不占列),今天列 = 最左一列</span>
<span v-else>每月两列 = 目标 / 实际,格内三行 = 开端 / 有效(有效端)/ 流水(充值流水),实际列下方细条横跨「目标 / 实际」两格 = 当月完成率进度条;左侧「达成」= 区间合计的开端 / 有效端 / 流水、「完成率」= 有目标月份的合计完成率(只显示百分比不加条),与小组信息一起冻结,月份之间有竖线分段</span>
<span>当月({{ currentMonthNumber }} 月)为进行中:实际照常显示,并计入左侧「达成」</span>
<span>没设目标显示「—」:未设目标的组「完成率」单独一格、月视图没设目标的月份「目标 / 实际」各自一格;日视图未设目标仍显示当月实际,目标 / 完成率显示「—」</span>
<span class="accent"><b>表格可按住左键左右拖动</b>——只拖动表格内容(页面其他部分不动);表格下方常驻一条横向滚动条,点拖皆可;上下看随页面滚动</span>
</div>
</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>
@@ -51,7 +114,8 @@
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 { readBlobBusinessMessage } from '@/utils/blobError'
import { performanceMetrics as metrics, businessLevels, employmentLabels, displayMember, displayMetric, 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'
@@ -71,18 +135,51 @@ const descendingPeriods = computed(() => [...(data.value?.periods || [])].revers
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 groupMetrics = [{ key: 'open', label: '开端', goal: 'open' }, { key: 'effective', label: '有效', goal: 'effective' }, { key: 'pay', label: '流水', goal: 'amount' }]
const pad = n => String(n).padStart(2, '0')
const nowDate = new Date()
const isToday = key => { const [year, month, day] = String(key).split('-').map(Number); return year === nowDate.getFullYear() && month === nowDate.getMonth() + 1 && day === nowDate.getDate() }
const currentMonthNumber = nowDate.getMonth() + 1
const isDaily = computed(() => data.value?.granularity === 'day')
const currentMonthKey = `${nowDate.getFullYear()}-${pad(nowDate.getMonth() + 1)}`
const isFutureDay = key => { const [year, month, day] = String(key).split('-').map(Number); if (!year || !month || !day) return false; return new Date(year, month - 1, day) > new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate()) }
const groupPeriods = computed(() => isDaily.value ? descendingPeriods.value.filter(period => !String(period.key).startsWith(currentMonthKey) || !isFutureDay(period.key)) : descendingPeriods.value)
const groupFixed = computed(() => isDaily.value ? [110, 52, 92, 92, 92] : [110, 52, 92, 92])
const groupWidth = computed(() => groupFixed.value.reduce((sum, width) => sum + width, 0) + groupPeriods.value.length * 208)
const levelShort = level => ['', '一级', '二级', '三级'][level] || '三级'
const monthGoal = row => { const goal = row.goals?.[data.value?.begin?.slice(0, 7)]; return hasGoal(goal) ? goal : null }
const periodGoal = (row, period) => { const goal = row.goals?.[period.key.slice(0, 7)]; return hasGoal(goal) ? goal : null }
const goalNumber = (goal, metric) => goal?.[metric.goal] ?? null
const periodGoalValue = (row, period, metric) => { const goal = periodGoal(row, period); if (!goal) return null; const value = goalNumber(goal, metric); if (value == null || !isDaily.value) return value; const [year, month] = period.key.split('-').map(Number); return Number(value) / new Date(year, month, 0).getDate() }
const achievedClass = (actual, target) => target == null ? '' : Number(actual || 0) >= Number(target) ? 'g-ok' : 'g-no'
const groupFmt = (value, key) => { if (value == null) return '—'; if (key === 'pay') { const wan = Number(value) / 10000; return `¥${wan >= 100 ? Math.round(wan).toLocaleString('en-US') : Math.round(wan * 10) / 10}万` } let n = Number(value); if (n % 1 !== 0) n = Math.round(n * 10) / 10; return n.toLocaleString('en-US') }
const roundedRate = rate => rate == null ? null : Math.round(Number(rate) * 10) / 10
const groupRateText = rate => { const rounded = roundedRate(rate); return rounded == null ? '—' : `${rounded}%` }
const rateReady = row => isDaily.value ? !!monthGoal(row) : (data.value?.periods || []).some(period => hasGoal(row.goals?.[period.key.slice(0, 7)]))
const metricRate = (row, metric) => isDaily.value ? completionRate(row.total?.[metric.key], monthGoal(row) ? goalNumber(monthGoal(row), metric) : null) : groupRate(row, metric)
const amountRate = (row, period) => completionRate(row.periods?.[period.key]?.pay, periodGoalValue(row, period, groupMetrics[2]))
const barTitle = (row, period) => `${isDaily.value ? '当日完成率' : '当月完成率'} ${groupRateText(amountRate(row, period))}:实际流水 ${groupFmt(row.periods?.[period.key]?.pay, 'pay')} ÷ 目标流水 ${groupFmt(periodGoalValue(row, period, groupMetrics[2]), 'pay')}`
const bodyWeek = (period, index) => index > 0 && (!isDaily.value || new Date(`${period.key}T00:00:00`).getDay() === 1)
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 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 += Number(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), 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 } }
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
const message = await readBlobBusinessMessage(blob)
if (message) { ElMessage.error(message); return }
if (!(blob instanceof Blob)) { ElMessage.error('导出失败'); return }
const url = URL.createObjectURL(blob), a = document.createElement('a')
a.href = url; a.download = '商务绩效统计.xlsx'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (error) { if (!error?.exportNotified) ElMessage.error(error?.businessMessage || '导出失败,请重试') } finally { exporting.value = false }
}
onMounted(load)
</script>
<style scoped>
@@ -90,7 +187,7 @@ onMounted(load)
.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-toolbar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap;margin-top: 10px; }
.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; }
@@ -112,20 +209,52 @@ onMounted(load)
.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); }
.es-grp-note { margin: 0 0 10px; font-size: 12px; line-height: 1.7; color: var(--el-text-color-secondary); }
.es-grp-note b { color: var(--el-text-color-regular); }
.es-grp { --grp-hd: #eef3ff; --grp-line: #d5def2; --grp-mem: #f6f8fc; --grp-out: #8b90a3; --grp-track: rgba(0, 0, 0, .08); border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: 12px; }
.es-grp th, .es-grp td { box-sizing: border-box; padding: 6px; border-bottom: 1px solid var(--el-border-color-lighter); white-space: nowrap; text-align: center; background: var(--el-bg-color); vertical-align: top; }
.es-grp thead th { position: sticky; top: 0; z-index: 3; height: 30px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-weight: 600; }
.es-grp thead tr:nth-child(2) th { top: 30px; height: 26px; }
.es-grp .es-gf1 { position: sticky; left: 0; z-index: 3; }
.es-grp .es-gf2 { position: sticky; left: 110px; z-index: 3; }
.es-grp .es-gf3 { position: sticky; left: 162px; z-index: 3; }
.es-grp .es-gf4 { position: sticky; left: 254px; z-index: 4; border-right: 1px solid var(--el-border-color); }
.es-grp .es-gf5 { position: sticky; left: 346px; z-index: 4; border-right: 1px solid var(--el-border-color); }
.es-grp thead .es-gf1 { z-index: 7; }
.es-grp thead .es-gf2, .es-grp thead .es-gf3 { z-index: 6; }
.es-grp thead .es-gf4, .es-grp thead .es-gf5 { z-index: 7; }
.es-grp tr.es-grp-hd > td { background: var(--grp-hd); border-top: 2px solid var(--grp-line); }
.es-grp tr.es-grp-mem td { background: var(--grp-mem); font-size: 11px; }
.es-grp tr.es-grp-mem td.es-gf1 { padding-left: 20px; box-shadow: inset 3px 0 0 color-mix(in srgb, var(--el-color-primary) 50%, transparent); }
.es-grp tr.es-grp-mem.es-mem-out td { color: var(--grp-out); }
.es-grp-name { font-weight: 600; color: var(--el-text-color-primary); }
.es-grp-lv { font-size: 11px; color: var(--el-text-color-secondary); }
.es-grp-memname { position: relative; display: inline-block; padding-left: 13px; color: var(--el-text-color-regular); }
.es-grp-memname::before { content: ''; position: absolute; left: 1px; top: 50%; width: 7px; height: 9px; margin-top: -6px; border-left: 2px solid color-mix(in srgb, var(--el-color-primary) 55%, transparent); border-bottom: 2px solid color-mix(in srgb, var(--el-color-primary) 55%, transparent); border-radius: 0 0 0 3px; }
.es-grp-arrow { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; margin-right: 3px; padding: 0; border: 0; border-radius: 5px; appearance: none; background: color-mix(in srgb, var(--el-color-primary) 14%, transparent); color: var(--el-color-primary); font-size: 13px; line-height: 1; vertical-align: -6px; cursor: pointer; }
.es-grp-arrow:hover { background: color-mix(in srgb, var(--el-color-primary) 30%, transparent); color: #fff; }
.es-grp-cell { padding: 5px 7px !important; text-align: left !important; overflow: visible; }
.es-grp-none { color: var(--el-text-color-secondary); }
.es-grp-empty { padding: 26px !important; color: var(--el-text-color-secondary); }
.g3 { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; line-height: 19px; }
.g3 i { font-style: normal; font-size: 11px; color: var(--el-text-color-secondary); }
.g3 b { font-weight: 600; font-variant-numeric: tabular-nums; color: var(--el-text-color-primary); }
.es-grp-t .g3 b { color: var(--el-text-color-secondary); font-weight: 500; }
.es-grp tr.es-grp-mem .g3 b { color: var(--el-text-color-regular); font-weight: 500; }
.es-grp .g3 b.g-ok, .es-grp tr.es-grp-mem .g3 b.g-ok, .es-pbar-pct.g-ok { color: var(--el-color-success); font-weight: 600; }
.es-grp .g3 b.g-no, .es-grp tr.es-grp-mem .g3 b.g-no { color: var(--el-text-color-secondary); font-weight: 500; }
.es-grp .es-wk { border-left: 1px solid var(--el-border-color); }
.es-grp td.es-today { box-shadow: inset 1px 0 0 color-mix(in srgb, var(--el-color-primary) 55%, transparent); }
.es-grp td.es-today-r { box-shadow: inset -1px 0 0 color-mix(in srgb, var(--el-color-primary) 55%, transparent); }
.es-grp thead th.es-today { background: color-mix(in srgb, var(--el-color-primary) 14%, var(--el-fill-color-light)) !important; color: var(--el-color-primary); }
.es-pbar-wrap { display: flex; align-items: center; gap: 4px; width: calc(100% + 104px); margin: 4px 0 0 -104px; }
.es-pbar { flex: 1; height: 3px; border-radius: 2px; background: var(--grp-track); overflow: hidden; }
.es-pbar i { display: block; height: 100%; background: var(--el-color-primary); border-radius: 2px; }
.es-pbar i[data-ok="1"] { background: var(--el-color-success); }
.es-pbar-pct { flex: none; font-size: 10px; color: var(--el-text-color-secondary); font-variant-numeric: tabular-nums; }
.es-grp-legend { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 2px 0; font-size: 12px; line-height: 1.7; color: var(--el-text-color-secondary); }
.es-grp-legend .accent { color: var(--el-color-primary); }
:global(html.dark) .es-grp { --grp-hd: #202741; --grp-line: #3a4468; --grp-mem: #1b2033; --grp-out: #6b7086; --grp-track: rgba(255, 255, 255, .09); }
.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; } }
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="sw-page">
<div class="kra-search-box">
<div class="kra-search-box" style="padding-bottom: 10px;">
<el-form :inline="true" @submit.prevent="load">
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="全部平台" style="width: 240px" @change="load"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item><el-radio-group :model-value="dimension" @change="changeDimension"><el-radio-button v-for="(label, value) in dimensions" :key="value" :value="value">{{ label }}排行</el-radio-button></el-radio-group></el-form-item>
@@ -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" @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="所属平台"><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="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>
+109 -36
View File
@@ -1,40 +1,58 @@
<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 v-if="canSeeBindings" 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>
<div class="sw-page biz-mgmt">
<div class="ranking-tabs" role="tablist">
<button type="button" class="ranking-tab" :class="{ active: tab === 'accounts' }" @click="tab = 'accounts'">商务列表</button>
<button type="button" class="ranking-tab" :class="{ active: tab === 'groups' }" @click="tab = 'groups'">小组列表</button>
<button v-if="canSeeBindings" type="button" class="ranking-tab" :class="{ active: tab === 'bindings' }" @click="tab = 'bindings'">绑定列表</button>
</div>
<div v-if="bindingsSeen" v-show="tab === 'bindings'" class="bind-panel">
<div class="form-hint">注:绑定列表仅三级商务可用(只有三级商务可绑定游戏 SDK 商务);一级 / 二级商务无此入口,查看时列表为空。</div>
<business-bindings @changed="onBindingsChanged" />
</div>
<div v-show="tab !== 'bindings'" class="biz-roster">
<div class="kra-search-box biz-toolbar">
<el-form v-if="tab === 'accounts'" inline @submit.prevent="search">
<el-form-item><el-input v-model="filters.name" clearable placeholder="商务名称" style="width:160px" /></el-form-item>
<el-form-item><el-input v-model="filters.account" clearable placeholder="商务账号" style="width:160px" /></el-form-item>
<el-form-item><el-select v-model="filters.level" clearable placeholder="全部等级" 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-select v-model="filters.group" clearable placeholder="全部小组" 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><el-select v-model="filters.employment" clearable placeholder="全部在职状态" style="width:140px"><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>
<div class="spacer" />
<el-form-item>
<el-button v-if="catalog.canManage" @click="teamSettings = true">小组设置</el-button>
<el-button v-if="canAdd" type="primary" @click="openCreate">+ 添加子商务</el-button>
</el-form-item>
</el-form>
<el-form v-else inline @submit.prevent>
<el-form-item><el-input v-model="groupFilters.keyword" clearable placeholder="小组名称 / 成员姓名" style="width:180px" /></el-form-item>
<el-form-item><el-select v-model="groupFilters.relation" clearable placeholder="全部关系" style="width:150px"><el-option label="我所在的小组" value="mine" /><el-option label="下级的小组" value="sub" /></el-select></el-form-item>
<el-form-item><el-select v-model="groupFilters.employment" clearable placeholder="全部状态" 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-form-item>
<div class="spacer" />
<el-form-item><el-button v-if="catalog.canManage" @click="teamSettings = true">小组设置</el-button></el-form-item>
</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" />
<p v-if="tab === 'groups'" class="gl-scope">
<span>可见范围:</span><b>{{ meName }}</b><span>及全部下级商务</span>
<span class="gl-scope-dot">·</span>
<template v-if="myGroupName"><span>你所在小组:</span><b>{{ myGroupName }}</b></template>
<template v-else><span>你当前:</span><b>未分组</b></template>
</p>
<el-alert v-if="error || (tab === 'accounts' && metricError)" :title="error || metricError" type="error" :closable="false" class="mb-3" />
<el-table scrollbar-always-on v-if="tab === 'accounts'" v-loading="loading" :data="pagedMembers" row-key="userId" @sort-change="sortMembers">
<el-table-column prop="userId" label="ID" width="80" class-name="sw-col-meta" />
<el-table-column prop="groupName" label="小组" width="125" sortable="custom"><template #default="{ row }">{{ row.groupName || '—' }}</template></el-table-column>
<el-table-column prop="userName" label="商务账号" class-name="sw-col-identity" min-width="130" show-overflow-tooltip />
<el-table-column label="商务等级" width="105"><template #default="{ row }"><level-tag :level="row.level" /></template></el-table-column>
<el-table-column prop="parentUserName" label="上级商务" min-width="120" show-overflow-tooltip />
<el-table-column label="商务名称 / 负责人" min-width="150"><template #default="{ row }"><strong class="sw-business-name" :title="displayMember(row)">{{ displayMember(row) }}</strong><div class="muted">{{ row.realName || '—' }}</div></template></el-table-column>
<el-table-column label="姓名" min-width="150"><template #default="{ row }"><strong class="sw-business-name bz-name" :title="displayMember(row)">{{ displayMember(row) }}</strong><div class="bz-sub">{{ 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" class-name="sw-col-meta"><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 size="small" :type="employmentTypes[row.employment]">{{ employmentLabels[row.employment] }}</el-tag></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>
@@ -49,9 +67,9 @@
</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>
</div>
<team-settings-dialog v-model="teamSettings" :catalog="catalog" @saved="teamsSaved" />
<goal-drawer v-model="goalOpen" :target="goalTarget" @saved="loadMetrics" />
<goal-drawer v-model="goalOpen" :target="goalTarget" @saved="() => loadMetrics(true)" />
<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>
@@ -71,7 +89,7 @@
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
@@ -91,11 +109,24 @@ const user = useUserStore(), buttons = useBtnAuth()
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) < 3 && !!buttons['sw:business:add'])
const canSeeBindings = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 3)
const route = useRoute(), router = useRouter()
const tabNames = ['accounts', 'groups', 'bindings']
const tabState = ref((() => { const value = { '#bind': 'bindings', '#grp': 'groups' }[route.hash] || (tabNames.includes(route.query.tab) ? route.query.tab : 'accounts'); return value === 'bindings' && !canSeeBindings.value ? 'accounts' : value })())
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: '' })
get: () => tabState.value,
set: value => {
const next = value === 'bindings' && !canSeeBindings.value ? 'accounts' : value
if (tabState.value === next) return
tabState.value = next
const query = { ...route.query, tab: next }
const location = router.resolve({ path: route.path, query, hash: '' })
const state = window.history.state
window.history.replaceState(state ? { ...state, current: location.fullPath } : state, '', location.href)
}
})
const catalog = ref({ groups: [], members: [], revision: 0 }), metrics = ref(null), loading = ref(false), metricLoading = ref(false), error = ref(''), metricError = ref('')
const rosterReady = ref(false), metricsReady = ref(false), bindingsSeen = ref(false)
const meName = computed(() => user.userInfo.nickName || user.userInfo.userName || '当前账号')
const myGroupName = computed(() => catalog.value.groups.find(group => group.mine)?.name || catalog.value.members.find(member => member.userId === user.userInfo.ID)?.groupName || '')
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])))
@@ -115,16 +146,43 @@ const groupRows = computed(() => {
})
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 loadTeams = async (force = false) => {
if (!force && (rosterReady.value || loading.value)) return
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; rosterReady.value = true } else error.value = res.msg || '小组及成员读取失败'
} catch { if (v === teamVersion) error.value = '小组及成员读取失败,请重试' } finally { if (v === teamVersion) loading.value = false }
}
const loadMetrics = async (force = false) => {
if (!force && (metricsReady.value || metricLoading.value)) return
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; metricsReady.value = true } else metricError.value = res.msg || '绩效读取失败'
} catch { if (v === metricVersion) metricError.value = '绩效读取失败,请重试' } finally { if (v === metricVersion) metricLoading.value = false }
}
const refresh = () => Promise.all([loadTeams(true), loadMetrics(true)])
const onBindingsChanged = () => { if (rosterReady.value) loadTeams(true) }
const activate = name => {
if (name === 'bindings') { bindingsSeen.value = true; return }
loadTeams()
if (name === 'accounts') loadMetrics()
}
watch(tab, activate, { immediate: true })
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), 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 teamsSaved = value => { catalog.value = value; rosterReady.value = true; if (filters.group && filters.group !== '0' && !value.groups.some(g => String(g.id) === filters.group)) { filters.group = ''; search() } loadMetrics(true) }
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 }; 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('')
@@ -136,12 +194,27 @@ const rules = computed(() => ({ userName: [{ required: true, message: '请输入
const openCreate = () => { const today = new Date(); editing.value = false; form.value = { userName: '', realName: '', phoneNumber: '', hireDate: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`, 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>
.sw-business-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ranking-tabs { display: flex; margin: 0 0 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.ranking-tab { margin: 0 0 -1px; padding: 8px 18px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--el-text-color-secondary); font-size: 14px; line-height: 22px; cursor: pointer; }
.ranking-tab.active { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.ranking-tab:hover { color: var(--el-color-primary); }
.form-hint { margin: 0 0 12px; font-size: 12px; line-height: 1.5; color: #86909c; }
.gl-scope { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; font-size: 12px; color: var(--el-text-color-placeholder); }
.gl-scope b { color: var(--el-text-color-secondary); font-weight: 600; }
.gl-scope-dot { opacity: .45; margin: 0 2px; }
.biz-mgmt.sw-page .biz-toolbar { padding-bottom: 16px; }
.biz-mgmt.sw-page .biz-toolbar :deep(.el-form) { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; width: 100%; }
.biz-mgmt.sw-page .biz-toolbar :deep(.el-form-item) { margin: 0; }
.biz-toolbar .spacer { flex: 1 1 12px; }
.biz-mgmt.sw-page .biz-roster :deep(.el-table) { font-size: 12px; }
.biz-mgmt.sw-page .biz-roster :deep(th.el-table__cell),
.biz-mgmt.sw-page .biz-roster :deep(td.el-table__cell) { padding: 8px 0; }
.sw-business-name { display: block; overflow: hidden; color: var(--el-text-color-primary); text-overflow: ellipsis; white-space: nowrap; }
.bz-sub { color: var(--el-text-color-placeholder); font-size: 11px; line-height: 1.4; }
.muted { color: var(--el-text-color-secondary); font-size: 12px; }
.open { color: var(--el-color-primary); }
.effective { color: var(--el-color-success); }
.open { color: var(--el-color-primary); font-weight: 600; }
.effective { color: var(--el-color-success); font-weight: 600; }
.low-rate { color: var(--el-color-warning); }
</style>
+6 -2
View File
@@ -49,6 +49,7 @@ import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listLedger, exportLedger, listLedgerGames } from '@/api/ledger'
import { readBlobBusinessMessage } from '@/utils/blobError'
import { channelMoney } from '../channel-display'
import { createGameOptionsLoader } from '../game-options-loader'
import { remoteSort, sortDescription } from '../table-display'
@@ -121,10 +122,13 @@ const download = async () => {
try {
const result = await exportLedger(props.kind, params)
const blob = result instanceof Blob ? result : result.data
if (!(blob instanceof Blob)) return
if (blob.type.includes('json')) return
const message = await readBlobBusinessMessage(blob)
if (message) { ElMessage.error(message); return }
if (!(blob instanceof Blob)) { ElMessage.error('导出失败'); return }
const url = URL.createObjectURL(blob), link = document.createElement('a')
link.href = url; link.download = spend.value ? '充值明细.xlsx' : '注册明细.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (error) {
if (!error?.exportNotified) ElMessage.error(error?.businessMessage || '导出失败,请重试')
} finally { exporting.value = false }
}
onMounted(load)
@@ -1,5 +1,5 @@
<template>
<el-drawer class="sw-dialog" v-model="visible" append-to-body :title="title" size="min(1100px,96vw)" destroy-on-close>
<el-drawer class="sw-dialog performance-detail-drawer" v-model="visible" append-to-body :title="title" size="min(1100px,96vw)" destroy-on-close>
<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>
@@ -8,8 +8,9 @@
<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-alert v-if="error" :title="error" type="error" :closable="false" class="detail-alert" />
<div class="detail-table-wrap">
<el-table scrollbar-always-on height="100%" v-loading="loading" :data="rows" :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"><template #default="{ row }">{{ row.name || row.account || '—' }}</template></el-table-column>
@@ -20,8 +21,9 @@
<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 }">{{ row.qualifiedAt ? dateText(row.qualifiedAt) : '未达标' }}</template></el-table-column>
</el-table>
</div>
<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-pagination v-model:current-page="page" v-model:page-size="size" class="detail-pager" :total="total" :page-sizes="[20,50,100]" layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" />
</el-drawer>
</template>
<script setup>
@@ -46,5 +48,16 @@ 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; }
.detail-alert { flex-shrink: 0; margin-top: 12px; }
.detail-table-wrap { flex: 1; min-height: 0; margin-top: 12px; }
.detail-hint { display: block; flex-shrink: 0; margin-top: 12px; line-height: 1.7; }
.detail-pager { flex-shrink: 0; margin-top: 16px; }
</style>
<!-- 抽屉被 teleport 到 body,scoped 样式到不了抽屉容器 -->
<style>
.performance-detail-drawer .el-drawer__body {
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
@@ -13,7 +13,7 @@ const thumbWidth = computed(() => Math.max(36, width.value * width.value / Math.
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 start = event => { if (event.button !== 0 || event.target.closest('input,textarea,select,.el-select,.es-grp-arrow,button,a')) 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 } }
@@ -39,6 +39,7 @@
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listAnalytics, exportChannelAnalytics } from '@/api/analytics'
import { readBlobBusinessMessage } from '@/utils/blobError'
import { listLedgerGames } from '@/api/ledger'
import { channelCount } from '../channel-display'
import { createGameOptionsLoader } from '../game-options-loader'
@@ -92,11 +93,12 @@ const download = async () => {
exporting.value = true
try {
const response = await exportChannelAnalytics(loadedQuery.value), blob = response instanceof Blob ? response : response.data
if (!(blob instanceof Blob)) return
if (blob.type.includes('json')) { const data = JSON.parse(await blob.text()); ElMessage.error(data.msg || '导出失败'); return }
const message = await readBlobBusinessMessage(blob)
if (message) { ElMessage.error(message); return }
if (!(blob instanceof Blob)) { ElMessage.error('导出失败'); return }
const url = URL.createObjectURL(blob), link = document.createElement('a')
link.href = url; link.download = '渠道数据统计.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch { ElMessage.error('导出失败,请重试') } finally { exporting.value = false }
} catch (error) { if (!error?.exportNotified) ElMessage.error(error?.businessMessage || '导出失败,请重试') } 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, '')); return search() }
+185 -44
View File
@@ -1,65 +1,206 @@
<template>
<el-dialog class="sw-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>
<el-dialog class="sw-dialog team-settings-dialog" v-model="visible" append-to-body align-center title="小组设置" width="760px" :close-on-click-modal="false" destroy-on-close header-class="gm-dialog-head" body-class="gm-dialog-body" footer-class="gm-dialog-foot">
<p v-if="error" class="gm-error">{{ error }}</p>
<div class="gm-body" v-loading="saving">
<div class="gm-left">
<div class="gm-head"><span>小组</span><button type="button" class="gm-link" @click="startAdd">+ 新建组</button></div>
<div v-if="adding" class="gm-add">
<input ref="addInput" v-model="newName" maxlength="10" placeholder="组名(1-10 字)" @keyup.enter="confirmAdd" @keyup.esc="cancelAdd">
<button type="button" class="gm-mini primary" @click="confirmAdd">确定</button>
<button type="button" class="gm-mini ghost" @click="cancelAdd">取消</button>
</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 class="gm-tree">
<div v-if="!draft.length" class="gm-empty">还没有小组,点右上角「+ 新建组」</div>
<div v-for="group in draft" :key="keyOf(group)" class="gm-grp-item" :class="{ active: selected === keyOf(group) }" @click="selected = keyOf(group)">
<span class="gm-grp-name">{{ group.name }}</span>
<span class="gm-cnt">{{ group.memberIds.length }} 人</span>
<span v-if="group.canEdit" class="ops" @click.stop>
<button type="button" class="gm-op" @click="rename(group)">重命名</button>
<button type="button" class="gm-op del" @click="remove(group)">删除</button>
</span>
</div>
<div class="gm-un">
<div class="gm-grp-item" :class="{ active: selected === 'none' }" @click="selected = 'none'">未分组<span class="gm-cnt">{{ ungrouped.length }} 人</span></div>
</div>
</div>
</section>
</div>
<div class="gm-right">
<div class="gm-head"><span>{{ rightTitle }}</span></div>
<div class="gm-search"><input v-model="keyword" type="text" placeholder="搜索商务名称"></div>
<div class="gm-mem">
<div v-if="peerText" class="gm-peer-warn">{{ peerText }}</div>
<p v-if="active?.partial && !active.canEdit" class="gm-note">此组包含权限范围外成员,不能重命名或删除。</p>
<div v-if="selected !== 'none' && !active" class="gm-empty">请先在左侧选择一个组</div>
<div v-else-if="selected === 'none' && !ungrouped.length" class="gm-empty">所有商务都已分组</div>
<div v-else-if="!members.length" class="gm-empty">没有匹配的商务</div>
<template v-else>
<label v-for="member in members" :key="member.userId" class="gm-mem-row" :class="{ disabled: !canCheck }">
<input type="checkbox" :checked="!!active?.memberIds.includes(member.userId)" :disabled="!canCheck" @change="toggle(member.userId, $event.target.checked)">
<span>{{ displayMember(member) }}</span>
<span class="j">{{ member.parentUserName || '一级组织' }}</span>
</label>
</template>
</div>
</div>
</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>
<template #footer>
<span class="gm-foot-note">一个商务只能属于一个组;取消勾选后回到「未分组」</span>
<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 { computed, nextTick, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { saveTeams } from '@/api/performance'
import { displayMember, businessLevels } from '../performance-display'
import { displayMember } 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('')
const adding = ref(false), newName = ref(''), addInput = ref(null)
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 } }
const members = computed(() => {
const source = selected.value === 'none' ? ungrouped.value : props.catalog.members
const kw = keyword.value.trim().toLowerCase()
return source.filter(member => !kw || `${displayMember(member)} ${member.userName || ''}`.toLowerCase().includes(kw))
})
const canCheck = computed(() => !!active.value?.canEdit)
const rightTitle = computed(() => {
if (selected.value === 'none') return `未分组 · ${ungrouped.value.length} 人`
return active.value ? `${active.value.name} · ${active.value.memberIds.length} 人` : '选择左侧小组'
})
const peerConflicts = computed(() => {
const ids = new Set(active.value?.memberIds || [])
if (!ids.size) return []
const byId = new Map(props.catalog.members.map(member => [Number(member.userId), member]))
const pairs = []
for (const member of props.catalog.members) {
if (!ids.has(member.userId)) continue
const parentId = String(member.idPath || '').split(',').map(Number).filter(id => ids.has(id)).at(-1)
const parent = byId.get(parentId)
if (parent) pairs.push(`${displayMember(parent)} 与 ${displayMember(member)}`)
}
return pairs
})
const peerText = computed(() => {
const pairs = peerConflicts.value
if (!pairs.length) return ''
const more = pairs.length > 3 ? ` 等 ${pairs.length} 组` : ''
return `⚠ 组员应当平级:${pairs.slice(0, 3).join('、')}${more} 是上下级关系,放在同一个组会让团队绩效重复计算(只提示,不拦截)。`
})
watch(visible, value => {
if (!value) return
draft.value = props.catalog.groups.map(group => ({ ...group, memberIds: [...group.memberIds] }))
const first = draft.value.find(group => group.memberIds.length) || draft.value[0]
selected.value = first ? keyOf(first) : 'none'
keyword.value = ''
error.value = ''
adding.value = false
newName.value = ''
})
const startAdd = async () => { adding.value = true; newName.value = ''; await nextTick(); addInput.value?.focus() }
const cancelAdd = () => { adding.value = false; newName.value = '' }
const confirmAdd = () => {
const name = newName.value.trim()
if (!name) { ElMessage.warning('请输入组名'); return }
if ([...name].length > 10) { ElMessage.warning('组名最多 10 个字'); return }
const group = { id: 0, temporaryID: `new-${++temporaryID}`, name, memberIds: [], canEdit: true }
draft.value.push(group)
selected.value = keyOf(group)
cancelAdd()
ElMessage.success(`已新建组「${name}」`)
}
const rename = async group => {
try {
const res = await ElMessageBox.prompt('组名为 1 至 10 个字', '重命名小组', { inputValue: group.name, inputValidator: text => text?.trim() && [...text.trim()].length <= 10 || '请输入 1 至 10 个字的组名' })
group.name = res.value.trim()
} catch { /* 取消重命名 */ }
}
const remove = async group => {
try { await ElMessageBox.confirm(`删除后组内 ${group.memberIds.length} 人将回到「未分组」,成员账号和历史数据保留。确认删除?`, '删除组', { type: 'warning' }) } catch { return }
draft.value = draft.value.filter(item => item !== group)
selected.value = draft.value.length ? keyOf(draft.value[0]) : 'none'
ElMessage.success('组已删除')
}
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(group => !group.canEdit)
selected.value = draft.value.length ? keyOf(draft.value[0]) : 'none'
ElMessage.success('已恢复默认分组,记得点保存')
}
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(group => ({ id: group.id, name: group.name, memberIds: group.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; } }
.gm-error { margin: 0; padding: 8px 16px; font-size: 12px; color: var(--el-color-danger); background: var(--el-color-danger-light-9); }
.gm-body { display: flex; height: 430px; }
.gm-left { width: 240px; flex-shrink: 0; border-right: 1px solid var(--el-border-color-lighter); display: flex; flex-direction: column; }
.gm-right { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.gm-head { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; border-bottom: 1px solid var(--el-border-color-lighter); font-size: 12px; color: var(--el-text-color-secondary); }
.gm-link { border: 0; background: none; color: var(--el-color-primary); font-size: 12px; padding: 0 2px; cursor: pointer; }
.gm-link:hover { opacity: .8; }
.gm-add { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--el-border-color-lighter); background: var(--el-fill-color-light); }
.gm-add input { flex: 1; min-width: 0; }
.gm-search { padding: 8px 12px; border-bottom: 1px solid var(--el-border-color-lighter); }
.gm-search input, .gm-add input { width: 100%; height: 32px; padding: 0 10px; border: 1px solid var(--el-border-color); border-radius: 4px; background: var(--el-bg-color); color: var(--el-text-color-primary); font-size: 13px; outline: none; box-sizing: border-box; }
.gm-search input:focus, .gm-add input:focus { border-color: var(--el-color-primary); }
.gm-mini { height: 28px; padding: 0 10px; border-radius: 4px; border: 1px solid transparent; font-size: 12px; cursor: pointer; white-space: nowrap; }
.gm-mini.primary { background: var(--el-color-primary); color: #fff; }
.gm-mini.ghost { background: transparent; color: var(--el-text-color-regular); border-color: var(--el-border-color); }
.gm-tree, .gm-mem { flex: 1; overflow-y: auto; }
.gm-tree { padding: 6px 8px; }
.gm-mem { padding: 6px 12px; }
.gm-grp-item { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 6px 8px; margin-bottom: 2px; border-radius: 4px; font-size: 13px; color: var(--el-text-color-primary); cursor: pointer; }
.gm-grp-item:hover { background: var(--el-fill-color-light); }
.gm-grp-item.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
.gm-grp-item .ops { margin-left: auto; display: flex; gap: 2px; opacity: 0; pointer-events: none; }
.gm-grp-item:hover .ops, .gm-grp-item:focus-within .ops { opacity: 1; pointer-events: auto; }
.gm-grp-name { min-width: 0; }
.gm-op { border: 0; background: none; color: var(--el-text-color-placeholder); font-size: 11px; padding: 0 3px; cursor: pointer; }
.gm-op:hover { color: var(--el-color-primary); }
.gm-op.del:hover { color: var(--el-color-danger); }
.gm-cnt { color: var(--el-text-color-placeholder); font-weight: 400; font-size: 11px; }
.gm-un { margin-top: 6px; border-top: 1px dashed var(--el-border-color); padding-top: 6px; }
.gm-peer-warn { margin: 8px 0 4px; padding: 8px 10px; border-radius: 4px; font-size: 11px; line-height: 1.6; color: var(--el-color-warning); background: var(--el-color-warning-light-9); border: 1px solid color-mix(in srgb, var(--el-color-warning) 25%, transparent); }
.gm-note { margin: 8px 0 4px; font-size: 12px; line-height: 1.5; color: var(--el-text-color-secondary); }
.gm-mem-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 4px; font-size: 12px; color: var(--el-text-color-primary); cursor: pointer; }
.gm-mem-row:hover { background: var(--el-fill-color-light); }
.gm-mem-row.disabled { cursor: default; }
.gm-mem-row input { margin: 0; accent-color: var(--el-color-primary); }
.gm-mem-row .j { color: var(--el-text-color-placeholder); font-size: 11px; margin-left: auto; white-space: nowrap; }
.gm-empty { color: var(--el-text-color-placeholder); font-size: 12px; padding: 26px 0; text-align: center; }
.gm-foot-note { margin-right: auto; font-size: 12px; color: var(--el-text-color-placeholder); text-align: left; line-height: 1.5; }
@media (max-width: 640px) {
.gm-body { flex-direction: column; height: auto; max-height: 70vh; }
.gm-left { width: 100%; max-height: 220px; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); }
}
</style>
<style>
.team-settings-dialog.el-dialog { max-width: 96vw; padding: 0; }
.team-settings-dialog .gm-dialog-head.el-dialog__header { margin: 0; padding: 14px 48px 14px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.team-settings-dialog .gm-dialog-head .el-dialog__title { font-size: 16px; font-weight: 600; }
.team-settings-dialog .el-dialog__headerbtn { top: 6px; right: 6px; width: 36px; height: 36px; }
.team-settings-dialog .gm-dialog-body { padding: 0; }
.team-settings-dialog .gm-dialog-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--el-border-color-lighter); }
</style>
+2 -2
View File
@@ -186,8 +186,8 @@
<template #default="scope">
<div class="px-4 py-2">
<p v-if="scope.row.errorMsg" class="text-red-500 break-all">错误:{{ scope.row.errorMsg }}</p>
<p v-if="scope.row.output" class="break-all whitespace-pre-wrap mt-1">输出:{{ scope.row.output }}</p>
<p v-if="!scope.row.errorMsg && !scope.row.output" class="text-gray-400">该次执行未记录详细输出,请结合执行状态、开始时间及耗时判断。</p>
<p v-if="scope.row.output" class="break-all mt-1">输出:{{ scope.row.output }}</p>
<p v-if="!scope.row.errorMsg && !scope.row.output" class="text-gray-400">无详情</p>
</div>
</template>
</el-table-column>
+2 -1
View File
@@ -940,8 +940,9 @@ const downloadJson = async (row) => {
ElMessage.success('下载成功')
} catch (error) {
if (error?.exportNotified) return
console.error('下载失败:', error)
ElMessage.error('下载失败')
ElMessage.error(error?.businessMessage || '下载失败')
}
}