fix: align business V2 fields and page interactions

This commit is contained in:
Yvan
2026-11-07 02:00:12 +08:00
parent c2f3ebdf4a
commit 4038e3b42f
19 changed files with 290 additions and 127 deletions
+24
View File
@@ -0,0 +1,24 @@
# 商务原型需求约定
本文件记录跨版本核对时需要保留的产品决定。商务按原型的 **V2 原型说明**核对;OA 按 OA 原型的说明核对,OA 本轮只实现后端。
## 已确认决定
- **激活归属**:审核通过只进入观察期,不改渠道归属;累计流水达标后才换绑。此项以用户明确更正为准,覆盖商务原型中“审核通过后即归属”的文字。
- **暂缓页面(2026-11-07)**:原型包中有完整的 `game-payment.html`(付费分析)及 `game-retention.html`(留存分析),V2 清单将它们列为菜单外页面。用户已确认“先放着,记一下”。本轮不实现这两页、不新增入口;后续恢复时需确认入口和统计口径,不能直接当作已完成。
- **OA 分支**:在 `oa` 最新 `master` 基础上创建 `codex/sw-new-oa-integration`,修改合入 `dev` 后更新测试服;不使用尚未迁移商务业务的 `oa-new`。
## 同一原型内的冲突处理
- 商务绩效矩阵:采用 2026-11-03 的“组内排序、按显示成员及其下级并集计算矩阵合计”;上方卡片仍统计权限范围全量。2026-11-02 的“整表跨组排序、不随筛选的矩阵合计”是旧描述。
- 小组:2026-11-03 已移除组长及工作台团队目标卡。首页仅保留“我的目标”;团队目标在小组列表设置、在小组绩效复盘。
- 目标按钮:采用商务列表当前详细说明,已设置时显示目标摘要;不采用较早改动汇总中的“按钮恒定不显示已设值”。
- 新增与编辑:新增子商务的联系电话、入职日期选填,未填日期默认当天;编辑时姓名与入职日期必填。分别遵循各自弹窗的说明。
- 演示数据与原型导航工具:原型中的模拟入池按钮、随机数据、版本选择、原型说明/更新记录/改动汇总悬浮按钮用于设计评审,不作为业务系统生产功能。
## 数据语义
- 激活列表、申请池、绑定期注册与流水读 OA 对应 SDK 数据源,避免商务历史报表副本不同步造成遗漏或数值冲突。
- 锁定/解除合作在工作台按事件发生日期统计;渠道列表汇总按当前状态统计,两者不要求数字相同。
- 充值与注册明细页排序仅改变当前页顺序;报表类列表排序在查询结果范围内执行,汇总行不参与。
- 真实历史事件缺失时不以当前状态补造历史;没有覆盖完整统计周期时明确显示不可用。
+5 -5
View File
@@ -98,13 +98,10 @@ func validateAccount(req *dto.BusinessAccountRequest, create bool) (*int, error)
if req == nil {
return nil, business.ErrAccountInput
}
if create && req.HireDate == nil {
return nil, business.ErrMemberInput
}
if req.Phone != nil && !validMemberPhone(*req.Phone) {
return nil, business.ErrMemberInput
}
if req.HireDate != nil {
if req.HireDate != nil && (strings.TrimSpace(*req.HireDate) != "" || !create) {
if _, err := time.Parse("2006-01-02", *req.HireDate); err != nil {
return nil, business.ErrMemberInput
}
@@ -158,7 +155,10 @@ func (s *AccountService) Create(ctx context.Context, req *dto.BusinessAccountReq
}
a := &business.Account{Username: stringValue(req.Username), NickName: stringValue(req.NickName), RealName: stringValue(req.RealName), Phone: stringValue(req.Phone),
Email: stringValue(req.Email), QQNumber: stringValue(req.QQNumber), Password: req.Password, RejectReason: stringValue(req.RejectReason)}
a.HireDate = stringValue(req.HireDate)
a.HireDate = strings.TrimSpace(stringValue(req.HireDate))
if a.HireDate == "" {
a.HireDate = time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02")
}
if status != nil {
a.Status = *status
}
+24 -3
View File
@@ -6,8 +6,28 @@ import (
"kra/internal/biz/business"
"kra/internal/service/dto"
"testing"
"time"
)
func TestCreateMemberDefaultsOptionalHireDate(t *testing.T) {
f := &accountUsecaseFake{}
s := &AccountService{uc: f}
username, name := "newmember", "负责人"
for _, date := range []*string{nil, new(string)} {
_, err := s.Create(context.Background(), &dto.BusinessAccountRequest{Username: &username, RealName: &name, Password: "abc123", HireDate: date})
if err != nil {
t.Fatal(err)
}
if f.created.HireDate != time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02") {
t.Fatalf("default date: %s", f.created.HireDate)
}
}
invalid := "2026-02-30"
if _, err := s.Create(context.Background(), &dto.BusinessAccountRequest{Username: &username, RealName: &name, Password: "abc123", HireDate: &invalid}); err == nil {
t.Fatal("invalid explicit hire date accepted")
}
}
type accountUsecaseFake struct {
AccountUsecase
created *business.Account
@@ -68,14 +88,15 @@ func TestAccountQueryEmptyFiltersAndPageBounds(t *testing.T) {
}
}
func TestCreateBusinessRequiresHireDateAndValidPhone(t *testing.T) {
func TestCreateBusinessDefaultsHireDateAndValidatesPhone(t *testing.T) {
f := &accountUsecaseFake{}
s := &AccountService{uc: f}
name, owner, phone, hire := "member", "负责人", "abc", "2026-11-01"
req := &dto.BusinessAccountRequest{Username: &name, RealName: &owner, Password: "test-password"}
if _, err := s.Create(context.Background(), req); err != business.ErrMemberInput || f.created != nil {
t.Fatal("missing hire date must not reach usecase")
if _, err := s.Create(context.Background(), req); err != nil || f.created.HireDate == "" {
t.Fatal("missing hire date should default to today")
}
f.created = nil
req.HireDate, req.Phone = &hire, &phone
if _, err := s.Create(context.Background(), req); err != business.ErrMemberInput || f.created != nil {
t.Fatal("invalid phone must not reach usecase")
@@ -1,5 +1,6 @@
<template>
<div class="dashboard sw-page">
<my-goal-card />
<div class="dashboard-toolbar">
<div class="platform-filter">
<!-- <span class="platform-label">数据平台</span> -->
@@ -19,7 +20,6 @@
</div>
</div>
<my-goal-card />
<div class="overview-period">
<el-radio-group v-model="overviewPeriod" @change="overviewPeriod !== 'custom' && fetchKpiSummary()"><el-radio-button v-for="range in chartRangeOptions" :key="range.value" :value="range.value">{{ range.label }}</el-radio-button><el-radio-button value="custom">自定义</el-radio-button></el-radio-group>
<template v-if="overviewPeriod === 'custom'"><el-date-picker v-model="overviewDates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" /><el-button type="primary" @click="fetchKpiSummary">搜索</el-button></template>
+11 -10
View File
@@ -25,16 +25,16 @@
<el-table scrollbar-always-on v-loading="loading" :data="rows" row-key="id" :row-class-name="({ row }) => row.status === 6 || row.lost ? 'alert-muted' : ''" @selection-change="selected = $event">
<el-table-column v-if="canGovern" type="selection" width="46" :selectable="row => row.status !== 6" />
<el-table-column prop="platformName" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformName" /></template></el-table-column>
<el-table-column label="触发时间" min-width="120"><template #default="{ row }">{{ shortTime(row.createdAt) }}</template></el-table-column>
<el-table-column prop="ruleName" label="规则名称" min-width="130" show-overflow-tooltip />
<el-table-column label="规则类型" min-width="130"><template #default="{ row }"><el-tag effect="plain" :style="row.warningColor ? { color: row.warningColor, borderColor: row.warningColor } : {}">{{ row.ruleTypeLabel || row.ruleType }}</el-tag></template></el-table-column>
<el-table-column prop="targetPromoteName" label="预警对象" min-width="160" show-overflow-tooltip />
<el-table-column prop="businessName" label="归属商务" min-width="130" show-overflow-tooltip />
<el-table-column label="流失状态" width="105"><template #default="{ row }">{{ row.lost == null ? '未接入' : row.lost ? '已流失' : '未流失' }}</template></el-table-column>
<el-table-column label="总注册玩家" width="120"><template #default="{ row }"><span title="预警触发时的累计快照">{{ row.totalPlayers ?? '—' }}</span></template></el-table-column>
<el-table-column label="总充值流水" width="140"><template #default="{ row }"><span title="预警触发时的累计快照">{{ row.totalPay == null ? '—' : `¥${row.totalPay}` }}</span></template></el-table-column>
<el-table-column prop="alertContent" label="预警内容" min-width="280" show-overflow-tooltip />
<el-table-column label="处理状态" width="110"><template #default="{ row }"><el-tag :type="statusTone(row.status)" effect="plain">{{ row.statusLabel || statuses[row.status] }}</el-tag></template></el-table-column>
<el-table-column label="触发时间" min-width="120"><template #header><field-label label="触发时间" /></template><template #default="{ row }">{{ shortTime(row.createdAt) }}</template></el-table-column>
<el-table-column prop="ruleName" label="规则名称" min-width="130" show-overflow-tooltip ><template #header><field-label label="规则名称" /></template></el-table-column>
<el-table-column label="规则类型" min-width="130"><template #header><field-label label="规则类型" /></template><template #default="{ row }"><el-tag effect="plain" :style="row.warningColor ? { color: row.warningColor, borderColor: row.warningColor } : {}">{{ row.ruleTypeLabel || row.ruleType }}</el-tag></template></el-table-column>
<el-table-column prop="targetPromoteName" label="预警对象" min-width="160" show-overflow-tooltip ><template #header><field-label label="预警对象" /></template></el-table-column>
<el-table-column prop="businessName" label="归属商务" min-width="130" show-overflow-tooltip ><template #header><field-label label="归属商务" /></template></el-table-column>
<el-table-column label="流失状态" width="105"><template #header><field-label label="流失状态" /></template><template #default="{ row }">{{ row.lost == null ? '未接入' : row.lost ? '已流失' : '未流失' }}</template></el-table-column>
<el-table-column label="总注册玩家" width="120"><template #header><field-label label="总注册玩家" /></template><template #default="{ row }"><span title="预警触发时的累计快照">{{ row.totalPlayers ?? '—' }}</span></template></el-table-column>
<el-table-column label="总充值流水" width="140"><template #header><field-label label="总充值流水" /></template><template #default="{ row }"><span title="预警触发时的累计快照">{{ row.totalPay == null ? '—' : `¥${row.totalPay}` }}</span></template></el-table-column>
<el-table-column prop="alertContent" label="预警内容" min-width="280" show-overflow-tooltip ><template #header><field-label label="预警内容" /></template></el-table-column>
<el-table-column label="处理状态" width="110"><template #header><field-label label="处理状态" /></template><template #default="{ row }"><el-tag :type="statusTone(row.status)" effect="plain">{{ row.statusLabel || statuses[row.status] }}</el-tag></template></el-table-column>
<el-table-column label="操作" :width="canGovern ? 230 : 115" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" link type="primary" @click="open(row)">{{ actionLabel(row.status) }}</el-button><el-button v-if="canGovern && row.status !== 6" link type="warning" :disabled="acting" @click="ignore([row.id])">忽略</el-button><el-button v-if="canGovern" link type="primary" :disabled="acting" @click="exempt(row)">免预警</el-button></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
@@ -44,6 +44,7 @@
</template>
<script setup>
import FieldLabel from './components/FieldLabel.vue'
import PlatformTag from './components/PlatformTag.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
+35 -23
View File
@@ -1,11 +1,11 @@
<template>
<div class="sw-page">
<el-tabs v-model="tab" @tab-change="changeTab"><el-tab-pane label="激活列表" name="activate" /><el-tab-pane label="申请绑定" name="apply" /><el-tab-pane label="流失记录" name="churn" /></el-tabs>
<activation-resources v-if="tab !== 'activate'" :key="tab" :mode="tab" @applied="showActivations" />
<el-tabs v-model="tab" @tab-change="changeTab"><el-tab-pane v-for="(label,key) in tabLabels" :key="key" :name="key"><template #label>{{ label }}<span v-if="tabCounts[key] != null" class="tab-count">{{ tabCounts[key] }}</span></template></el-tab-pane></el-tabs>
<activation-resources v-if="tab !== 'activate'" :key="tab" :mode="tab" @applied="showActivations" @loaded="resourceLoaded" />
<template v-else>
<div class="kra-search-box">
<el-form :model="query" :inline="true" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="query.platformNames" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="所属平台"><el-select v-model="query.platformNames" multiple collapse-tags clearable placeholder="全部平台" style="width: 210px"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="渠道名称"><el-input v-model="query.promoteAccount" clearable placeholder="渠道名称 / 账号" /></el-form-item>
<el-form-item label="绑定商务"><el-input v-model="query.busierAccount" clearable placeholder="绑定商务" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.statuses" clearable placeholder="全部状态" style="width:140px"><el-option v-for="(label,value) in statuses" :key="value" :label="label" :value="value" /></el-select></el-form-item>
@@ -16,14 +16,14 @@
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="OA 渠道激活数据源尚未接入或暂不可用。" description="渠道激活记录来自 OA;商务库中的渠道数据不会自动生成激活记录。请联系管理员检查 OA 数据源连接。" />
<el-table scrollbar-always-on v-loading="loading" :data="rows" row-key="id" :empty-text="emptyText">
<el-table-column prop="platformTitle" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformTitle" /></template></el-table-column>
<el-table-column prop="promoteAccount" label="渠道名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="busierAccount" label="绑定商务" min-width="130" show-overflow-tooltip />
<el-table-column prop="platformTitle" label="所属平台" width="104"><template #header><field-label label="所属平台" /></template><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformTitle" /></template></el-table-column>
<el-table-column prop="promoteAccount" label="渠道名称" min-width="140" show-overflow-tooltip ><template #header><field-label label="渠道名称" /></template></el-table-column>
<el-table-column prop="busierAccount" label="绑定商务" min-width="130" show-overflow-tooltip ><template #header><field-label label="绑定商务" /></template></el-table-column>
<el-table-column prop="bindAt" sortable :sort-method="(a,b) => (a.bindAt || a.createdAt || '').localeCompare(b.bindAt || b.createdAt || '')" label="绑定日期" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ date(row.bindAt || row.createdAt) }}</template></el-table-column>
<el-table-column label="解绑时间" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ date(row.unbindAt) }}</template></el-table-column>
<el-table-column prop="accumAmount" sortable :sort-method="compareAmount" label="累计流水金额" min-width="160" align="right"><template #default="{ row }"><metric-value kind="money" :value="row.bindAt && row.metricsAvailable ? row.accumAmount : null" /></template></el-table-column>
<el-table-column label="累计注册玩家" min-width="145" align="right"><template #default="{ row }"><metric-value kind="count" :value="row.registeredPlayers" /></template></el-table-column>
<el-table-column label="激活状态" width="110"><template #default="{ row }"><el-tag effect="plain" :type="statusTone(row.status)">{{ row.statusText || row.status || '—' }}</el-tag></template></el-table-column>
<el-table-column label="解绑时间" min-width="120" class-name="sw-col-meta"><template #header><field-label label="解绑时间" /></template><template #default="{ row }">{{ date(row.unbindAt) }}</template></el-table-column>
<el-table-column prop="accumAmount" sortable :sort-method="compareAmount" label="累计流水金额" min-width="160" align="right"><template #header><field-label label="累计流水金额" /></template><template #default="{ row }"><metric-value kind="money" :value="row.bindAt && row.metricsAvailable ? row.accumAmount : null" /></template></el-table-column>
<el-table-column label="累计注册玩家" min-width="145" align="right"><template #header><field-label label="累计注册玩家" /></template><template #default="{ row }"><metric-value kind="count" :value="row.registeredPlayers" /></template></el-table-column>
<el-table-column label="激活状态" width="110"><template #header><field-label label="激活状态" /></template><template #default="{ row }"><el-tag effect="plain" :type="statusTone(row.status)">{{ row.statusText || row.status || '—' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><el-button v-if="row.bindAt && !['pending','rejected'].includes(row.status)" link type="primary" @click="openFlow(row)">流水明细</el-button><span v-else>—</span></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
@@ -40,8 +40,8 @@
<template v-else-if="detail">
<el-alert class="mb-4" type="info" title="仅统计绑定期间产生的流水。" :closable="false" />
<el-table scrollbar-always-on :data="detail.list" max-height="320">
<el-table-column prop="platformTitle" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformTitle" /></template></el-table-column>
<el-table-column prop="gameName" label="游戏名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="platformTitle" label="所属平台" width="104"><template #header><field-label label="所属平台" /></template><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformTitle" /></template></el-table-column>
<el-table-column prop="gameName" label="游戏名称" min-width="160" show-overflow-tooltip ><template #header><field-label label="游戏名称" /></template></el-table-column>
<el-table-column label="累计金额" min-width="150" align="right"><template #default="{ row }"><metric-value kind="money" :value="row.totalAmount" /></template></el-table-column>
</el-table>
<p class="sw-table-summary">绑定期间累计流水:<strong>{{ currency(detail.totalAmount) }}</strong></p>
@@ -53,28 +53,37 @@
</template>
<script setup>
import { compareAmounts } from './table-display'
import FieldLabel from './components/FieldLabel.vue'
import ActivationResources from './components/ActivationResources.vue'
import { useRoute, useRouter } from 'vue-router'
import PlatformTag from './components/PlatformTag.vue'
import MetricValue from './components/MetricValue.vue'
import { computed, onBeforeUnmount, onDeactivated, onMounted, reactive, ref, watch } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { listChannelActivations, getActivationSpend } from '@/api/activation'
import { listChannelActivations, getActivationSpend, listActivationPool, listActivationChurn } from '@/api/activation'
defineOptions({ name: 'SwChannelActivationList' })
const route = useRoute(), router = useRouter()
const tabFromHash = () => ['apply','churn'].includes(route.hash.slice(1)) ? route.hash.slice(1) : 'activate'
const tab = ref(tabFromHash())
const tabLabels = { activate: '激活列表', apply: '申请绑定', churn: '流失记录' }
const tabCounts = reactive({ activate: null, apply: null, churn: null })
const resourceLoaded = ({ mode, total }) => { tabCounts[mode] = total }
const refreshCounts = async () => {
const queries = { activate: listChannelActivations, apply: listActivationPool, churn: listActivationChurn }
await Promise.allSettled(Object.entries(queries).map(async ([key, fetch]) => {
try { const res = await fetch({ pageNum: 1, pageSize: 1 }); tabCounts[key] = res.code === 0 && res.data.available !== false ? res.data.total : null } catch { tabCounts[key] = null }
}))
}
const statuses = { pending: '审核中', in_progress: '进行中', reached: '已达标', unbound: '已解绑' }
const changeTab = value => { router.replace({ hash: `#${value}` }); if (value === 'activate') load() }
const showActivations = () => { tab.value = 'activate'; changeTab('activate') }
const showActivations = () => { tab.value = 'activate'; changeTab('activate'); refreshCounts() }
watch(() => route.hash, () => { const next = tabFromHash(); if (next !== tab.value) { tab.value = next; if (next === 'activate') load() } })
const compareAmount = (a,b) => {
const cents = value => { const [whole, fraction = ''] = String(value || '0').split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2,'0').slice(0,2)) }
const x = cents(a.accumAmount), y = cents(b.accumAmount); return x === y ? 0 : x > y ? 1 : -1
}
const compareAmount = (a,b) => compareAmounts(a.accumAmount,b.accumAmount)
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const query = reactive({ pageNum: 1, pageSize: 10, platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '', statuses: '' })
const query = reactive({ pageNum: 1, pageSize: 10, platformNames: [], promoteAccount: '', busierAccount: '', statuses: '' })
const rows = ref([])
const total = ref(0)
const available = ref(true)
@@ -87,7 +96,7 @@ const selected = ref(null)
const detail = ref(null)
let listVersion = 0
const detailRequests = createLatestRequest()
const emptyText = computed(() => !query.platformNames.length ? '请选择所属平台' : error.value ? '查询失败,请重试' : !available.value ? 'OA 数据源暂不可用' : '当前筛选条件及账号权限下暂无激活记录')
const emptyText = computed(() => error.value ? '查询失败,请重试' : !available.value ? 'OA 数据源暂不可用' : '当前筛选条件及账号权限下暂无激活记录')
const date = value => value ? value.slice(0, 10) : '—'
// 金额按字符串分组,保留超出 JavaScript 精确整数范围的金额。
const currency = value => {
@@ -107,7 +116,6 @@ const load = async () => {
const version = ++listVersion
// 保留总数直到查询完成;清零会让分页组件把当前页自动校正为 1。
rows.value = []; error.value = ''; available.value = true
if (!query.platformNames.length) { rows.value = []; total.value = 0; available.value = true; loading.value = false; return }
loading.value = true
try {
const res = await listChannelActivations({ ...query, platformNames: query.platformNames.join(','), promoteAccount: query.promoteAccount.trim(), busierAccount: query.busierAccount.trim() })
@@ -117,7 +125,7 @@ const load = async () => {
} catch { if (version === listVersion) error.value = '渠道激活查询失败,请重试' } finally { if (version === listVersion) loading.value = false }
}
const search = () => { query.pageNum = 1; return load() }
const reset = () => { Object.assign(query, { platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '', statuses: '' }); return search() }
const reset = () => { Object.assign(query, { platformNames: [], promoteAccount: '', busierAccount: '', statuses: '' }); return search() }
const clearDetail = () => { detailRequests.cancel(); detail.value = null; selected.value = null; flowError.value = ''; flowLoading.value = false }
const openFlow = async row => {
clearDetail()
@@ -135,5 +143,9 @@ const openFlow = async row => {
watch(dialog, open => { if (!open) clearDetail() }, { flush: 'sync' })
onBeforeUnmount(clearDetail)
onDeactivated(() => { dialog.value = false; clearDetail() })
onMounted(() => { if (tab.value === 'activate') load() })
onMounted(() => { refreshCounts(); if (tab.value === 'activate') load() })
</script>
<style scoped>
.tab-count { margin-left: 6px; padding: 1px 6px; border-radius: 9px; background: var(--el-fill-color); font-size: 12px; font-variant-numeric: tabular-nums; }
</style>
+19 -18
View File
@@ -28,24 +28,24 @@
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="渠道数据尚未接入。" />
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" @sort-change="sortChanged">
<el-table-column prop="platformName" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.platformName" /></template></el-table-column>
<el-table-column prop="id" label="ID" min-width="90" class-name="sw-col-meta" />
<el-table-column prop="account" label="渠道名称" class-name="sw-col-identity" min-width="150" show-overflow-tooltip />
<el-table-column label="渠道层级" min-width="110"><template #default="{ row }"><level-tag :level="row.promoteLevel" channel /></template></el-table-column>
<el-table-column prop="busierDisplayName" label="归属商务" min-width="200" show-overflow-tooltip />
<el-table-column label="上级渠道" min-width="130" show-overflow-tooltip><template #default="{ row }">{{ row.parentName || '—' }}</template></el-table-column>
<el-table-column label="负责人" min-width="100"><template #default="{ row }">{{ row.realName || '—' }}</template></el-table-column>
<el-table-column prop="phone" label="手机号" min-width="130" class-name="sw-col-meta"><template #default="{ row }">{{ row.phone || '—' }}</template></el-table-column>
<el-table-column label="结算周期" width="110"><template #default="{ row }">{{ row.settlementDays == null ? '—' : `${row.settlementDays} 天` }}</template></el-table-column>
<el-table-column label="渠道类型" min-width="100"><template #default="{ row }"><el-tag v-if="row.registerType === 0 || row.registerType === 1" size="small" :type="row.registerType === 1 ? 'warning' : 'info'" effect="plain">{{ row.registerType === 1 ? '公会' : '个人' }}</el-tag><span v-else class="sw-empty-value">—</span></template></el-table-column>
<el-table-column label="推广游戏" min-width="115"><template #default="{ row }"><el-button v-if="row.gameCount != null" link type="primary" @click="openGames(row)">{{ channelCount(row.gameCount) }} 款</el-button><span v-else>未接入</span></template></el-table-column>
<el-table-column prop="playerCount" label="玩家数量" min-width="110" align="right" class-name="sw-col-number" sortable="custom" :sort-orders="['descending','ascending',null]"><template #default="{ row }"><metric-value :value="row.playerCount" /></template></el-table-column>
<el-table-column prop="totalPayAmount" label="累计充值" min-width="150" align="right" class-name="sw-col-number" sortable="custom" :sort-orders="['descending','ascending',null]"><template #default="{ row }"><metric-value kind="money" :value="row.totalPayAmount" /></template></el-table-column>
<el-table-column prop="createdAt" label="注册时间" min-width="150" sortable="custom" :sort-orders="['descending','ascending',null]" class-name="sw-col-meta"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
<el-table-column label="最后登录时间" min-width="150" class-name="sw-col-meta"><template #default="{ row }">{{ row.lastLoginAt ? dateText(row.lastLoginAt) : '—' }}</template></el-table-column>
<el-table-column label="有效端" width="100"><template #default="{ row }"><el-tag size="small" :type="row.effective ? 'success' : 'info'">{{ row.effective ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="达标时间" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ dateText(row.qualifiedAt) }}</template></el-table-column>
<el-table-column label="状态" min-width="110"><template #default="{ row }"><el-tag size="small" :type="{ 0: 'success', '-1': 'danger', '-2': 'info' }[row.lockStatus] || 'info'" effect="plain">{{ statuses[row.lockStatus] ?? row.lockStatus ?? '—' }}</el-tag></template></el-table-column>
<el-table-column prop="platformName" label="所属平台" width="104"><template #header><field-label label="所属平台" /></template><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.platformName" /></template></el-table-column>
<el-table-column prop="id" label="ID" min-width="90" class-name="sw-col-meta" ><template #header><field-label label="ID" /></template></el-table-column>
<el-table-column prop="account" label="渠道名称" class-name="sw-col-identity" min-width="150" show-overflow-tooltip ><template #header><field-label label="渠道名称" /></template></el-table-column>
<el-table-column label="渠道层级" min-width="110"><template #header><field-label label="渠道层级" /></template><template #default="{ row }"><level-tag :level="row.promoteLevel" channel /></template></el-table-column>
<el-table-column prop="busierDisplayName" label="归属商务" min-width="200" show-overflow-tooltip ><template #header><field-label label="归属商务" /></template></el-table-column>
<el-table-column label="上级渠道" min-width="130" show-overflow-tooltip><template #header><field-label label="上级渠道" /></template><template #default="{ row }">{{ row.parentName || '—' }}</template></el-table-column>
<el-table-column label="负责人" min-width="100"><template #header><field-label label="负责人" /></template><template #default="{ row }">{{ row.realName || '—' }}</template></el-table-column>
<el-table-column prop="phone" label="手机号" min-width="130" class-name="sw-col-meta"><template #header><field-label label="手机号" /></template><template #default="{ row }">{{ row.phone || '—' }}</template></el-table-column>
<el-table-column label="结算周期" width="110"><template #header><field-label label="结算周期" /></template><template #default="{ row }">{{ row.settlementDays == null ? '—' : `${row.settlementDays} 天` }}</template></el-table-column>
<el-table-column label="渠道类型" min-width="100"><template #header><field-label label="渠道类型" /></template><template #default="{ row }"><el-tag v-if="row.registerType === 0 || row.registerType === 1" size="small" :type="row.registerType === 1 ? 'warning' : 'info'" effect="plain">{{ row.registerType === 1 ? '公会' : '个人' }}</el-tag><span v-else class="sw-empty-value">—</span></template></el-table-column>
<el-table-column label="推广游戏" min-width="115"><template #header><field-label label="推广游戏" /></template><template #default="{ row }"><el-button v-if="row.gameCount != null" link type="primary" @click="openGames(row)">{{ channelCount(row.gameCount) }} 款</el-button><span v-else>未接入</span></template></el-table-column>
<el-table-column prop="playerCount" label="玩家数量" min-width="110" align="right" class-name="sw-col-number" sortable="custom" :sort-orders="['descending','ascending',null]"><template #header><field-label label="玩家数量" /></template><template #default="{ row }"><metric-value :value="row.playerCount" /></template></el-table-column>
<el-table-column prop="totalPayAmount" label="累计充值" min-width="150" align="right" class-name="sw-col-number" sortable="custom" :sort-orders="['descending','ascending',null]"><template #header><field-label label="累计充值" /></template><template #default="{ row }"><metric-value kind="money" :value="row.totalPayAmount" /></template></el-table-column>
<el-table-column prop="createdAt" label="注册时间" min-width="150" sortable="custom" :sort-orders="['descending','ascending',null]" class-name="sw-col-meta"><template #header><field-label label="注册时间" /></template><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
<el-table-column label="最后登录时间" min-width="150" class-name="sw-col-meta"><template #header><field-label label="最后登录时间" /></template><template #default="{ row }">{{ row.lastLoginAt ? dateText(row.lastLoginAt) : '—' }}</template></el-table-column>
<el-table-column label="有效端" width="100"><template #header><field-label label="有效端" /></template><template #default="{ row }"><el-tag size="small" :type="row.effective ? 'success' : 'info'">{{ row.effective ? '是' : '否' }}</el-tag></template></el-table-column>
<el-table-column label="达标时间" min-width="120" class-name="sw-col-meta"><template #header><field-label label="达标时间" /></template><template #default="{ row }">{{ dateText(row.qualifiedAt) }}</template></el-table-column>
<el-table-column label="状态" min-width="110"><template #header><field-label label="状态" /></template><template #default="{ row }"><el-tag size="small" :type="{ 0: 'success', '-1': 'danger', '-2': 'info' }[row.lockStatus] || 'info'" effect="plain">{{ statuses[row.lockStatus] ?? row.lockStatus ?? '—' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openProfile(row)">画像</el-button></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
@@ -57,6 +57,7 @@
</template>
<script setup>
import FieldLabel from './components/FieldLabel.vue'
import { computed, onMounted, onBeforeUnmount, onDeactivated, onActivated, reactive, ref } from 'vue'
import { createLatestRequest } from '@/utils/latestRequest'
import { ElMessage } from 'element-plus'
+9 -8
View File
@@ -11,14 +11,14 @@
<el-alert class="mb-4" type="info" :closable="false" title="名单内的渠道不再产生新预警,历史预警保留。渠道进入激活池后自动移出,加入与移出记录永久保留。" />
<el-table v-loading="loading" :data="rows" row-key="id" scrollbar-always-on :row-class-name="({ row }) => row.active ? '' : 'exempt-removed'">
<el-table-column label="平台" width="95"><template #default="{ row }"><platform-tag :code="row.sourceDb" /></template></el-table-column>
<el-table-column prop="channelName" label="渠道名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="addedName" label="加入人" min-width="110" />
<el-table-column prop="addedAt" label="加入时间" width="170" />
<el-table-column prop="reason" label="加入原因" min-width="210" show-overflow-tooltip />
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.active ? 'success' : 'info'">{{ row.active ? '在名单' : '已移出' }}</el-tag></template></el-table-column>
<el-table-column prop="removedName" label="移出人" min-width="140" />
<el-table-column prop="removedAt" label="移出时间" width="170" />
<el-table-column prop="removeReason" label="移出原因" min-width="190"><template #default="{ row }"><span :title="row.removeReason.includes('自动') ? '30 天累计流水不足 ¥500,或连续未登录超过 15 天。系统自动判定,恢复预警。' : ''">{{ row.removeReason || '—' }}</span></template></el-table-column>
<el-table-column prop="channelName" label="渠道名称" min-width="160" show-overflow-tooltip ><template #header><field-label label="渠道名称" /></template></el-table-column>
<el-table-column prop="addedName" label="加入人" min-width="110" ><template #header><field-label label="加入人" /></template></el-table-column>
<el-table-column prop="addedAt" label="加入时间" width="170" ><template #header><field-label label="加入时间" /></template></el-table-column>
<el-table-column prop="reason" label="加入原因" min-width="210" show-overflow-tooltip ><template #header><field-label label="加入原因" /></template></el-table-column>
<el-table-column label="状态" width="100"><template #header><field-label label="状态" tip="在名单或已移出;移出后保留历史记录并恢复检测。" /></template><template #default="{ row }"><el-tag :type="row.active ? 'success' : 'info'">{{ row.active ? '在名单' : '已移出' }}</el-tag></template></el-table-column>
<el-table-column prop="removedName" label="移出人" min-width="140" ><template #header><field-label label="移出人" /></template></el-table-column>
<el-table-column prop="removedAt" label="移出时间" width="170" ><template #header><field-label label="移出时间" /></template></el-table-column>
<el-table-column prop="removeReason" label="移出原因" min-width="190"><template #header><field-label label="移出原因" /></template><template #default="{ row }"><span :title="row.removeReason.includes('自动') ? '30 天累计流水不足 ¥500,或连续未登录超过 15 天。系统自动判定,恢复预警。' : ''">{{ row.removeReason || '—' }}</span></template></el-table-column>
<el-table-column label="操作" fixed="right" width="80"><template #default="{ row }"><el-button v-if="row.active && canManage" link type="danger" :disabled="busy" @click="remove(row)">移出</el-button><span v-else>—</span></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
@@ -34,6 +34,7 @@
</template>
<script setup>
import FieldLabel from './components/FieldLabel.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
+5 -4
View File
@@ -15,10 +15,10 @@
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="区服统计所需业务汇总尚未接入。" />
<div class="sw-table-toolbar"><span class="sw-sort-label" role="status">{{ currentSort }}</span></div>
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.gameId}:${row.serverNum}`" show-summary :summary-method="summaries" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column prop="serverNum" label="区服编号" min-width="110" show-overflow-tooltip />
<el-table-column prop="serverName" label="区服名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="openTime" label="开服日期" min-width="120" sortable="custom" class-name="sw-col-meta"><template #default="{ row }">{{ openDate(row.openTime) }}</template></el-table-column>
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #header><field-label label="所属平台" /></template><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column prop="serverNum" label="区服编号" min-width="110" show-overflow-tooltip ><template #header><field-label label="区服编号" /></template></el-table-column>
<el-table-column prop="serverName" label="区服名称" min-width="130" show-overflow-tooltip ><template #header><field-label label="区服名称" /></template></el-table-column>
<el-table-column prop="openTime" label="开服日期" min-width="120" sortable="custom" class-name="sw-col-meta"><template #header><field-label label="开服日期" /></template><template #default="{ row }">{{ openDate(row.openTime) }}</template></el-table-column>
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.money ? 140 : 110" sortable="custom" align="right" class-name="sw-col-number"><template #header><el-tooltip :content="metricTips[column.key]" placement="top"><span>{{ column.label }}</span></el-tooltip></template><template #default="{ row }"><metric-value :kind="column.money ? 'money' : 'count'" :value="row[column.key]" /></template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
@@ -27,6 +27,7 @@
</template>
<script setup>
import FieldLabel from './components/FieldLabel.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listLedgerGames } from '@/api/ledger'
@@ -2,11 +2,11 @@
<div>
<div class="kra-search-box">
<el-form :inline="true" :model="query" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="query.platformNames" multiple collapse-tags clearable placeholder="全部平台" style="width:190px"><el-option v-for="(label,value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="所属平台"><el-select @change="search" v-model="query.platformNames" multiple collapse-tags clearable placeholder="全部平台" style="width:190px"><el-option v-for="(label,value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="渠道名称"><el-input v-model="query.promoteAccount" clearable /></el-form-item>
<template v-if="isPool">
<el-form-item label="渠道类型"><el-select v-model="query.channelType" clearable placeholder="全部类型" style="width:130px"><el-option v-for="value in ['公会','个人']" :key="value" :value="value" /></el-select></el-form-item>
<el-form-item label="锁定状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:140px"><el-option v-for="value in ['正常','锁定','解除合作']" :key="value" :value="value" /></el-select></el-form-item>
<el-form-item label="渠道类型"><el-select @change="search" v-model="query.channelType" clearable placeholder="全部类型" style="width:130px"><el-option v-for="value in ['公会','个人']" :key="value" :value="value" /></el-select></el-form-item>
<el-form-item label="锁定状态"><el-select @change="search" v-model="query.status" clearable placeholder="全部状态" style="width:140px"><el-option v-for="value in ['正常','锁定','解除合作']" :key="value" :value="value" /></el-select></el-form-item>
</template>
<template v-else>
<el-form-item label="换绑到的商务"><el-input v-model="query.target" clearable /></el-form-item>
@@ -19,26 +19,26 @@
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-4" />
<el-alert v-else-if="!available" title="数据暂不可用,请稍后重试或联系管理员。" type="warning" :closable="false" class="mb-4" />
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" @sort-change="sortChanged">
<el-table-column label="所属平台" width="110"><template #default="{row}"><platform-tag :code="row.sourceDb" :label="row.platformTitle" /></template></el-table-column>
<el-table-column label="渠道名称" min-width="180"><template #default="{row}">{{ row.name }} <el-tag v-if="row.rejected" type="danger" size="small">已驳回</el-tag></template></el-table-column>
<el-table-column label="所属平台" width="110"><template #header><field-label label="所属平台" /></template><template #default="{row}"><platform-tag :code="row.sourceDb" :label="row.platformTitle" /></template></el-table-column>
<el-table-column label="渠道名称" min-width="180"><template #header><field-label label="渠道名称" /></template><template #default="{row}">{{ row.name }} <el-tag v-if="row.rejected" type="danger" size="small">已驳回</el-tag></template></el-table-column>
<template v-if="isPool">
<el-table-column prop="channelType" label="渠道类型" width="120" />
<el-table-column label="锁定状态" width="120"><template #default="{row}"><el-tag :type="row.status === '正常' ? 'success' : row.status === '锁定' ? 'warning' : 'danger'">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="noLoginDays" label="未登录天数" sortable width="150"><template #default="{row}">{{ row.noLoginDays == null ? '—' : `${row.noLoginDays} 天` }}</template></el-table-column>
<el-table-column prop="channelType" label="渠道类型" width="120" ><template #header><field-label label="渠道类型" /></template></el-table-column>
<el-table-column label="锁定状态" width="120"><template #header><field-label label="锁定状态" /></template><template #default="{row}"><el-tag :type="row.status === '正常' ? 'success' : row.status === '锁定' ? 'warning' : 'danger'">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="noLoginDays" label="未登录天数" sortable width="150"><template #header><field-label label="未登录天数" /></template><template #default="{row}">{{ row.noLoginDays == null ? '—' : `${row.noLoginDays} 天` }}</template></el-table-column>
<el-table-column label="操作" width="120"><template #default="{row}"><el-button link type="primary" @click="openApplication(row)">申请绑定</el-button></template></el-table-column>
</template>
<template v-else>
<el-table-column prop="at" label="流失时间" width="180" />
<el-table-column prop="from" label="原归属商务" min-width="150" />
<el-table-column label="换绑到的商务" min-width="150"><template #default="{row}"><el-tag v-if="row.toPool" type="warning">渠道激活池</el-tag><span v-else>{{ row.to || '—' }}</span></template></el-table-column>
<el-table-column prop="reason" label="流失原因" min-width="140" />
<el-table-column prop="operator" label="操作人" min-width="130" />
<el-table-column prop="at" label="流失时间" width="180" ><template #header><field-label label="流失时间" /></template></el-table-column>
<el-table-column prop="from" label="原归属商务" min-width="150" ><template #header><field-label label="原归属商务" /></template></el-table-column>
<el-table-column label="换绑到的商务" min-width="150"><template #header><field-label label="换绑到的商务" /></template><template #default="{row}"><el-tag v-if="row.toPool" type="warning">渠道激活池</el-tag><span v-else>{{ row.to || '—' }}</span></template></el-table-column>
<el-table-column prop="reason" label="流失原因" min-width="140" ><template #header><field-label label="流失原因" /></template></el-table-column>
<el-table-column prop="operator" label="操作人" min-width="130" ><template #header><field-label label="操作人" /></template></el-table-column>
</template>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="search" /></div>
</div>
<el-dialog v-model="dialog" title="申请绑定" width="min(520px,95vw)" append-to-body :close-on-click-modal="!submitting" :show-close="!submitting">
<el-descriptions :column="1"><el-descriptions-item label="渠道名称">{{ selected?.name }}</el-descriptions-item><el-descriptions-item label="所属平台">{{ selected?.platformTitle }}</el-descriptions-item><el-descriptions-item label="锁定状态">{{ selected?.status }}</el-descriptions-item></el-descriptions>
<el-descriptions :column="1"><el-descriptions-item label="渠道名称">{{ selected?.name }}</el-descriptions-item><el-descriptions-item label="所属平台 / 类型">{{ selected?.platformTitle }} / {{ selected?.channelType }}</el-descriptions-item><el-descriptions-item label="未登录天数">{{ selected?.noLoginDays == null ? '—' : `${selected.noLoginDays} 天` }}</el-descriptions-item><el-descriptions-item label="锁定状态">{{ selected?.status }}</el-descriptions-item></el-descriptions>
<el-alert v-if="selected?.rejected" :title="`上次申请已驳回 ${selected.rejectedAt || ''}`" :description="selected.rejectReason || '请联系审核人了解原因'" type="warning" :closable="false" class="mb-4" />
<el-form label-width="85px"><el-form-item label="绑定商务" required><el-select v-model="busierId" placeholder="选择本人已绑定的游戏商务" style="width:100%" :loading="optionsLoading" :disabled="submitting"><el-option v-for="option in applicableOptions" :key="option.id" :value="option.id" :label="`${option.account}(ID ${option.id} · ${option.level}级)`" /></el-select></el-form-item></el-form>
<el-alert v-if="applyError" :title="applyError" type="error" :closable="false" />
@@ -49,13 +49,14 @@
</template>
<script setup>
import FieldLabel from './FieldLabel.vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listActivationPool, listActivationChurn, listActivationOptions, applyActivation } from '@/api/activation'
import { createLatestRequest } from '@/utils/latestRequest'
import PlatformTag from './PlatformTag.vue'
const props = defineProps({ mode: { type: String, required: true } }), emit = defineEmits(['applied'])
const props = defineProps({ mode: { type: String, required: true } }), emit = defineEmits(['applied','loaded'])
const isPool = computed(() => props.mode === 'apply'), platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const defaults = () => ({ pageNum: 1, pageSize: 10, platformNames: [], promoteAccount: '', channelType: '', status: '', target: '' })
const query = reactive(defaults()), dates = ref([]), rows = ref([]), total = ref(0), loading = ref(false), error = ref(''), available = ref(true)
@@ -68,7 +69,7 @@ const load = async () => {
const res = await (isPool.value ? listActivationPool : listActivationChurn)({ ...query, platformNames: query.platformNames.join(','), begin: dates.value?.[0] || '', end: dates.value?.[1] || '' }, { signal: request.signal })
if (!request.isCurrent()) return
if (res.code !== 0) throw new Error(res.msg || '查询失败')
rows.value = res.data.rows; total.value = res.data.total; available.value = res.data.available
rows.value = res.data.rows; total.value = res.data.total; available.value = res.data.available; emit('loaded', { mode: props.mode, total: total.value })
} catch (e) { if (request.isCurrent()) { rows.value = []; total.value = 0; error.value = e.message || '查询失败,请重试' } }
finally { if (request.isCurrent()) loading.value = false }
}
@@ -84,9 +85,10 @@ const openApplication = async row => {
}
const submit = async () => {
if (submitting.value || !busierId.value || !selected.value) return
submitting.value = true
const channel = selected.value, option = applicableOptions.value.find(item => item.id === busierId.value)
if (!option) return
try { await ElMessageBox.confirm(`确认申请绑定「${channel.name}」到「${option.account}」?提交后等待 OA 审核。`, '提交绑定申请', { type: 'warning' }) } catch { return }
if (!option) { submitting.value = false; return }
try { await ElMessageBox.confirm(`确认申请绑定「${channel.name}」到「${option.account}」?提交后等待 OA 审核。`, '提交绑定申请', { type: 'warning' }) } catch { submitting.value = false; return }
submitting.value = true; applyError.value = ''
try { const res = await applyActivation({ poolId: channel.id, busierId: option.id, sourceDb: channel.sourceDb }); if (res.code !== 0) throw new Error(res.msg); dialog.value = false; ElMessage.success('申请已提交,请在激活列表查看进度'); emit('applied') }
catch (e) { applyError.value = e.message || '提交失败,请重试' }
@@ -42,27 +42,27 @@
</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="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 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 prop="userId" label="ID" width="80" class-name="sw-col-meta" ><template #header><field-label label="ID" /></template></el-table-column>
<el-table-column prop="userName" label="商务账号" class-name="sw-col-identity" min-width="130" show-overflow-tooltip ><template #header><field-label label="商务账号" /></template></el-table-column>
<el-table-column label="商务等级" width="105"><template #header><field-label label="商务等级" /></template><template #default="{ row }"><level-tag :level="row.level" /></template></el-table-column>
<el-table-column prop="parentUserName" label="上级商务" min-width="120" show-overflow-tooltip ><template #header><field-label label="上级商务" /></template></el-table-column>
<el-table-column label="商务名称 / 负责人" min-width="150"><template #header><field-label label="商务名称 / 负责人" /></template><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 #header><field-label label="SDK商务" /></template><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 #header><field-label label="旗下渠道数" /></template><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="90"><template #header><field-label label="有效率" /></template><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 #header><field-label label="入职日期" /></template><template #default="{ row }">{{ row.hireDate || '—' }}</template></el-table-column>
<el-table-column prop="groupName" label="小组" width="125" sortable="custom"><template #header><field-label label="小组" /></template><template #default="{ row }">{{ row.groupName || '—' }}</template></el-table-column>
<el-table-column label="在职状态" width="100"><template #header><field-label label="在职状态" /></template><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>
<el-table scrollbar-always-on v-else v-loading="loading" :data="groupRows" row-key="id" @sort-change="sortGroups">
<el-table-column prop="name" label="小组" min-width="145" sortable="custom"><template #default="{ row }">{{ row.name }} <el-tag v-if="row.mine" size="small">本组</el-tag></template></el-table-column>
<el-table-column prop="relation" label="与我的关系" min-width="145" />
<el-table-column prop="memberCount" label="成员数" width="110" sortable="custom" />
<el-table-column label="成员" min-width="230"><template #default="{ row }"><span :title="row.memberNames.join('、')">{{ row.memberNames.slice(0, 4).join('、') }}{{ row.memberCount > 4 ? ` 等 ${row.memberCount} 人` : '' }}</span></template></el-table-column>
<el-table-column label="在职状态" min-width="170"><template #default="{ row }">{{ Object.entries(row.employments).map(([key, value]) => `${employmentLabels[key]} ${value}`).join(' · ') }}</template></el-table-column>
<el-table-column prop="channelCount" label="旗下渠道数" width="135" sortable="custom" align="right"><template #default="{ row }"><metric-value :value="row.channelCount" /></template></el-table-column>
<el-table-column prop="name" label="小组" min-width="145" sortable="custom"><template #header><field-label label="小组" /></template><template #default="{ row }">{{ row.name }} <el-tag v-if="row.mine" size="small">本组</el-tag></template></el-table-column>
<el-table-column prop="relation" label="与我的关系" min-width="145" ><template #header><field-label label="与我的关系" /></template></el-table-column>
<el-table-column prop="memberCount" label="成员数" width="110" sortable="custom" ><template #header><field-label label="成员数" /></template></el-table-column>
<el-table-column label="成员" min-width="230"><template #header><field-label label="成员" /></template><template #default="{ row }"><span :title="row.memberNames.join('、')">{{ row.memberNames.slice(0, 4).join('、') }}{{ row.memberCount > 4 ? ` 等 ${row.memberCount} 人` : '' }}</span></template></el-table-column>
<el-table-column label="在职状态" min-width="170"><template #header><field-label label="在职状态" /></template><template #default="{ row }">{{ Object.entries(row.employments).map(([key, value]) => `${employmentLabels[key]} ${value}`).join(' · ') }}</template></el-table-column>
<el-table-column prop="channelCount" label="旗下渠道数" width="135" sortable="custom" align="right"><template #header><field-label label="旗下渠道数" /></template><template #default="{ row }"><metric-value :value="row.channelCount" /></template></el-table-column>
<el-table-column label="操作" width="180"><template #default="{ row }"><el-button v-if="row.canSetGoal" link type="primary" @click="openGoal('group', row.id, row.name)">设目标</el-button><el-button link type="primary" @click="viewMembers(row)">查看成员</el-button></template></el-table-column>
</el-table>
<div v-if="tab === 'accounts'" class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="filteredMembers.length" :page-sizes="[10,20,50,100]" layout="total, sizes, prev, pager, next" /></div>
@@ -89,6 +89,7 @@
</div>
</template>
<script setup>
import FieldLabel from './FieldLabel.vue'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
@@ -190,7 +191,7 @@ const showChannels = row => { selectedID.value = row.userId; channelsOpen.value
const showBindings = row => { selectedID.value = row.userId; bindingsOpen.value = true }
const showPortrait = row => { selectedID.value = row.userId; selectedName.value = displayMember(row); portraitOpen.value = true }
const memberDrawer = ref(false), editing = ref(false), memberForm = ref(), saving = ref(false), form = ref({})
const rules = computed(() => ({ userName: [{ required: true, message: '请输入商务账号' }], realName: [{ required: true, message: '请输入负责人姓名' }], hireDate: [{ required: true, message: '请选择入职日期' }], phoneNumber: [{ pattern: /^[0-9 -]*$/, message: '联系电话只允许数字、空格和短横线', trigger: 'blur' }], password: [{ validator: (_r, value, cb) => cb((editing.value && !value || value?.length >= 5 && value.length <= 50) ? undefined : new Error('密码为5至50个字符')) }] }))
const rules = computed(() => ({ userName: [{ required: true, message: '请输入商务账号' }], realName: [{ required: true, message: '请输入负责人姓名' }], hireDate: [{ required: editing.value, message: '请选择入职日期' }], phoneNumber: [{ pattern: /^[0-9 -]*$/, message: '联系电话只允许数字、空格和短横线', trigger: 'blur' }], password: [{ validator: (_r, value, cb) => cb((editing.value && !value || value?.length >= 5 && value.length <= 50) ? undefined : new Error('密码为5至50个字符')) }] }))
const openCreate = () => { 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 } }
@@ -0,0 +1,9 @@
<template>
<el-tooltip :content="tip || fieldTips[label] || label" placement="top" :show-after="150">
<span tabindex="0">{{ label }}</span>
</el-tooltip>
</template>
<script setup>
import { fieldTips } from '../field-tips'
defineProps({ label: { type: String, required: true }, tip: { type: String, default: '' } })
</script>
+16 -13
View File
@@ -19,23 +19,22 @@
<div class="sw-table-toolbar"><el-button v-if="canExport" type="warning" plain icon="download" :loading="exporting" @click="download">导出</el-button><span class="sw-sort-label" role="status">{{ currentSort }}</span></div>
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="明细所需业务数据尚未接入。" />
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" :row-class-name="rowClass" :default-sort="{ prop: defaultSortField, order: 'descending' }" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column v-if="spend" prop="id" label="ID" min-width="90" class-name="sw-col-meta" sortable="custom" />
<el-table-column prop="userId" label="玩家ID" min-width="110" class-name="sw-col-meta" />
<el-table-column v-if="!spend" prop="registerTime" label="注册时间" min-width="165" sortable="custom" class-name="sw-col-meta"><template #default="{ row }">{{ unixTime(row.registerTime) }}</template></el-table-column>
<el-table scrollbar-always-on v-loading="loading" :data="displayRows" :row-key="row => `${row.sourceDb}:${row.id}`" :row-class-name="rowClass" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #header><field-label label="所属平台" /></template><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column prop="userId" label="玩家ID" min-width="110" class-name="sw-col-meta" ><template #header><field-label label="玩家ID" /></template></el-table-column>
<el-table-column v-if="!spend" prop="registerTime" label="注册时间" min-width="165" sortable="custom" class-name="sw-col-meta"><template #header><field-label label="注册时间" /></template><template #default="{ row }">{{ unixTime(row.registerTime) }}</template></el-table-column>
<el-table-column v-if="spend" prop="payOrderNumber" label="订单号" min-width="190" show-overflow-tooltip />
<el-table-column :prop="spend ? 'gameName' : 'fgameName'" label="游戏名称" min-width="150" show-overflow-tooltip />
<el-table-column :prop="spend ? 'gameName' : 'fgameName'" label="游戏名称" min-width="150" show-overflow-tooltip ><template #header><field-label label="游戏名称" /></template></el-table-column>
<el-table-column v-if="spend" prop="serverName" label="区服" min-width="140" show-overflow-tooltip />
<el-table-column v-if="spend" prop="gamePlayerName" label="角色" min-width="130" show-overflow-tooltip />
<el-table-column :prop="spend ? 'payAmount' : 'totalPayAmount'" :label="spend ? '实付金额' : '累计充值'" min-width="150" sortable="custom" align="right" class-name="sw-col-number"><template #default="{ row }"><metric-value kind="money" :muted="spend && row.payStatus !== 1" :value="spend ? row.payAmount : row.totalPayAmount" /></template></el-table-column>
<el-table-column v-if="spend" prop="payTime" label="充值时间" min-width="165" sortable="custom" class-name="sw-col-meta"><template #default="{ row }">{{ unixTime(row.payTime) }}</template></el-table-column>
<el-table-column :prop="spend ? 'payAmount' : 'totalPayAmount'" :label="spend ? '实付金额' : '累计充值'" min-width="150" sortable="custom" align="right" class-name="sw-col-number"><template #header><field-label :label="spend ? '实付金额' : '累计充值'" :tip="spend ? '该笔支付的实际金额;排序仅影响当前页。' : '玩家历史成功充值合计;排序仅影响当前页。'" /></template><template #default="{ row }"><metric-value kind="money" :muted="spend && row.payStatus !== 1" :value="spend ? row.payAmount : row.totalPayAmount" /></template></el-table-column>
<el-table-column v-if="spend" prop="payTime" label="充值时间" min-width="165" sortable="custom" class-name="sw-col-meta"><template #header><field-label label="充值时间" /></template><template #default="{ row }">{{ unixTime(row.payTime) }}</template></el-table-column>
<el-table-column v-if="spend" label="支付方式" min-width="100"><template #default="{ row }">{{ payWays[row.payWay] ?? row.payWay ?? '—' }}</template></el-table-column>
<el-table-column v-if="spend" label="支付状态" min-width="110"><template #default="{ row }"><el-tag size="small" :type="{ 1: 'success', 2: 'warning', 0: 'danger' }[row.payStatus] || 'info'">{{ statuses[row.payStatus] ?? row.payStatus ?? '—' }}</el-tag></template></el-table-column>
<el-table-column :label="spend ? 'IP' : '注册IP'" min-width="130"><template #default="{ row }">{{ (spend ? row.spendIp : row.registerIp) || '—' }}</template></el-table-column>
<el-table-column v-if="!spend" prop="deviceName" label="机型" min-width="140" show-overflow-tooltip />
<el-table-column prop="promoteAccount" label="渠道名称" class-name="sw-col-identity" min-width="170" show-overflow-tooltip />
<el-table-column prop="busierName" label="归属商务" min-width="190" show-overflow-tooltip />
<el-table-column prop="promoteAccount" label="渠道名称" class-name="sw-col-identity" min-width="170" show-overflow-tooltip ><template #header><field-label label="渠道名称" /></template></el-table-column>
<el-table-column prop="busierName" label="归属商务" min-width="190" show-overflow-tooltip ><template #header><field-label label="归属商务" /></template></el-table-column>
</el-table>
<p v-if="spend" class="sw-table-summary">合计:成功支付总额 <strong>{{ error ? '未获取' : available ? channelMoney(totalPayAmount) : '未接入' }}</strong></p>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
@@ -44,6 +43,7 @@
</template>
<script setup>
import FieldLabel from './FieldLabel.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
@@ -52,7 +52,7 @@ 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'
import { pageSort } from '../table-display'
import PlatformTag from './PlatformTag.vue'
import MetricValue from './MetricValue.vue'
import { formatReportDate as ymd, normalizeReportDates, reportDateShortcuts as shortcuts } from '../report-dates'
@@ -71,8 +71,11 @@ const gamesLoading = ref(false), exporting = ref(false), loading = ref(false), a
const error = ref('')
const rows = ref([]), total = ref(0), totalPayAmount = ref('0.00')
const query = reactive({ pageNum: 1, pageSize: 10, busierAccount: '', promoteAccount: '', gamePlayerName: '', payOrderNumber: '', payStatus: undefined, payWay: undefined, sortField: defaultSortField, sortOrder: 'desc' })
const currentSort = computed(() => sortDescription(query, { id: 'ID', payTime: '充值时间', registerTime: '注册时间', payAmount: '实付金额', totalPayAmount: '累计充值' }))
const sortChanged = event => { Object.assign(query, remoteSort(event, defaultSortField)); return search() }
const pageOrder = ref({})
const sortLabels = { payTime: '充值时间', registerTime: '注册时间', payAmount: '实付金额', totalPayAmount: '累计充值' }
const displayRows = computed(() => pageSort(rows.value, pageOrder.value, ['payAmount','totalPayAmount']))
const currentSort = computed(() => pageOrder.value.order ? `当前页排序:${sortLabels[pageOrder.value.prop]} · ${pageOrder.value.order === 'ascending' ? '升序' : '降序'}` : '当前页排序:默认顺序')
const sortChanged = event => { pageOrder.value = event }
let listVersion = 0, gameVersion = 0
const buildQuery = () => {
dates.value = normalizeReportDates(dates.value)
@@ -11,7 +11,6 @@
<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>
<el-table-column prop="ownerName" label="归属商务" min-width="140" />
@@ -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,.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 start = event => { if (event.button !== 0 || event.target.closest('input,textarea,select,.el-select')) return; pointer = { x: event.clientX, y: event.clientY, left: viewport.value.scrollLeft, top: viewport.value.scrollTop, id: event.pointerId } }
const move = event => { if (!pointer) return; const dx = event.clientX - pointer.x, dy = event.clientY - pointer.y; if (!dragging.value && Math.max(Math.abs(dx), props.vertical ? Math.abs(dy) : 0) <= 5) return; dragging.value = true; suppressClick = true; viewport.value.setPointerCapture(event.pointerId); event.preventDefault(); viewport.value.scrollLeft = pointer.left - dx; if (props.vertical) viewport.value.scrollTop = pointer.top - dy }
const end = event => { if (viewport.value?.hasPointerCapture(event.pointerId)) viewport.value.releasePointerCapture(event.pointerId); pointer = null; dragging.value = false; clearTimeout(resetTimer); resetTimer = setTimeout(() => { suppressClick = false }, 100) }
const captureClick = event => { if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false } }
@@ -22,10 +22,10 @@
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="统计所需业务汇总尚未接入。" />
<div class="sw-table-toolbar"><span class="sw-sort-label" role="status">{{ currentSort }}</span><el-button v-if="isChannel" :loading="exporting" :disabled="loading || !loadedQuery || !available" @click="download">导出</el-button></div>
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" show-summary :summary-method="summaries" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #header><field-label label="所属平台" /></template><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column :prop="isChannel ? 'promoteName' : 'gameName'" :label="isChannel ? '渠道名称' : '游戏名称'" min-width="190" show-overflow-tooltip class-name="sw-col-identity"><template #default="{ row }"><el-button v-if="isChannel && row.promoteId > 0" link type="primary" @click="openChannel(row)">{{ row.promoteName }}</el-button><span v-else>{{ isChannel ? row.promoteName : row.gameName }}</span></template></el-table-column>
<el-table-column v-if="isChannel" prop="busierName" label="归属商务" min-width="140" show-overflow-tooltip />
<el-table-column v-if="isChannel" label="渠道类型" min-width="100"><template #default="{ row }"><el-tag v-if="row.promoteType === 0 || row.promoteType === 1" size="small" :type="row.promoteType === 1 ? 'warning' : 'info'" effect="plain">{{ row.promoteType === 1 ? '公会' : '个人' }}</el-tag><span v-else class="sw-empty-value">—</span></template></el-table-column>
<el-table-column v-if="isChannel" prop="busierName" label="归属商务" min-width="140" show-overflow-tooltip ><template #header><field-label label="归属商务" /></template></el-table-column>
<el-table-column v-if="isChannel" label="渠道类型" min-width="100"><template #header><field-label label="渠道类型" /></template><template #default="{ row }"><el-tag v-if="row.promoteType === 0 || row.promoteType === 1" size="small" :type="row.promoteType === 1 ? 'warning' : 'info'" effect="plain">{{ row.promoteType === 1 ? '公会' : '个人' }}</el-tag><span v-else class="sw-empty-value">—</span></template></el-table-column>
<el-table-column v-else prop="gameId" label="游戏ID" min-width="100" sortable="custom" class-name="sw-col-meta" />
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.width || 110" sortable="custom" align="right" class-name="sw-col-number"><template #header><el-tooltip :content="metricTips[column.key]" placement="top"><span>{{ column.label }}</span></el-tooltip></template><template #default="{ row }"><metric-value :kind="column.type" :value="row[column.key]" /></template></el-table-column>
</el-table>
@@ -36,6 +36,7 @@
</template>
<script setup>
import FieldLabel from './FieldLabel.vue'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listAnalytics, exportChannelAnalytics } from '@/api/analytics'
+68
View File
@@ -0,0 +1,68 @@
export const fieldTips = {
ID: '本系统自动生成的唯一编号。',
所属平台: '渠道所属的游戏平台;不同平台的相同编号分别统计。',
商务账号: '创建时设置的商务登录账号,编辑资料时不可修改。',
商务等级: '商务组织层级:一级、二级、三级。',
上级商务: '该商务在组织树中的直属上级。',
'商务名称 / 负责人': '商务名称与负责人姓名双行展示,联系电话在编辑弹窗维护。',
SDK商务: '已通过 OA 审核的游戏商务绑定数量;点击查看绑定详情。',
旗下渠道数: '直接归属的渠道数;小组合计按成员直接归属渠道去重。',
入职日期: '负责人加入公司的日期,与账号注册时间无关。',
小组: '商务所属小组,由小组设置统一维护;未分组显示“—”。',
与我的关系: '本组表示自己所在组;下级组说明最近的下级商务。',
成员数: '可见范围内的小组成员数,包含在职、试用与离职成员。',
成员: '小组成员名单;超过四人省略展示,悬浮名单可查看全部。',
在职状态: '在职、试用、离职;离职不删除历史账号与产出。',
有效率: '有效端数 ÷ 开端数 × 100%;开端为零时显示“—”。',
渠道名称: '渠道在所属游戏平台中的名称或账号。',
渠道层级: '渠道邀请关系共三级,表示上下级关系,不代表质量。',
归属商务: '该渠道所归属的商务;统计遵循当前账号可见范围。',
上级渠道: '直接邀请该渠道的上级;一级渠道的上级为其归属商务。',
负责人: '渠道或商务负责人姓名。',
手机号: '渠道负责人联系方式,支持部分号码筛选。',
渠道类型: '公会或个人。',
结算周期: '渠道合同约定的充值分成结算周期,单位为天。',
推广游戏: '渠道已申请推广的游戏数量,点击查看各游戏数据。',
玩家数量: '累计注册主账号数;身份证去重开启时按非空实名身份证去重。',
累计充值: '历史成功充值金额合计。',
注册时间: '账号在系统中的注册时间。',
最后登录时间: '渠道账号最近一次登录时间。',
有效端: '注册后在设置页配置的窗口内达标为“是”;超期不补记,达标后保持。',
达标时间: '首次在判定窗口内达到有效端金额门槛的时间。',
状态: '渠道当前合作状态:正常、锁定、解除合作。',
绑定商务: '申请时选择承接该渠道的游戏商务;达标后才改变渠道归属。',
绑定日期: '观察期流水起算日期;审核中的记录显示申请提交时间。',
解绑时间: '激活绑定关系结束时间,未结束时显示“—”。',
累计流水金额: '绑定期间成功充值合计,包含关联平台与游戏;审核中显示“—”。',
累计注册玩家: '绑定期间新增注册主账号;开启身份证去重时跨平台合并实名,无实名不计入。',
激活状态: '审核中、进行中、已达标、已解绑;已驳回的渠道可重新申请。',
锁定状态: '正常、锁定、解除合作均允许申请;是否恢复合作由 OA 审核确认。',
未登录天数: '最近登录距今的天数,未登录过按注册日起算;只供参考,不限制申请。',
流失时间: '渠道发生商务换绑的时间;进入激活池也属于换绑。',
原归属商务: '该次换绑前的商务。',
换绑到的商务: '换绑后的商务,进入激活池时显示“渠道激活池”。',
流失原因: '记录商务间换绑或换绑进池的原因。',
操作人: '执行本次操作的账号或系统任务。',
玩家ID: '游戏平台中的玩家编号,不是充值流水记录编号。',
玩家账号: '游戏平台中的玩家登录账号。',
游戏名称: '当前可见渠道已申请的游戏。',
充值时间: '该笔支付发生的时间。',
触发时间: '预警最近一次命中的时间;未处理记录再次命中可刷新。',
规则名称: '触发预警的监控规则名称。',
规则类型: '规则配置的预警分类,去重按渠道与该类型区分。',
预警对象: '预警命中的一级渠道;监控可汇总其下级渠道数据。',
流失状态: '打开列表时实时计算的流失状态,与预警生成时的快照不同。',
总注册玩家: '预警触发时的累计注册快照,后续数据变化不会改写历史快照。',
总充值流水: '预警触发时的累计成功充值快照,后续数据变化不会改写历史快照。',
预警内容: '该次规则命中的指标、阈值与异常说明。',
处理状态: '未处理 → 处理中 → 待审核 → 已处理;另有审核驳回与已忽略。',
加入人: '将渠道加入免预警名单的操作人。',
加入时间: '免预警生效的时间,之后不再产生新预警。',
加入原因: '免预警的原因,供后续核查。',
移出人: '解除免预警的人;自动移出时显示系统。',
移出时间: '解除免预警的时间,之后恢复检测。',
移出原因: '区分人工移出与进入激活池后的系统自动移出。',
区服编号: '游戏内区服编号,按平台、游戏分别识别。',
区服名称: '区服名称,文本列不参与数值排序。',
开服日期: '该区服的实际开服日期。'
}
+8 -1
View File
@@ -23,10 +23,17 @@ export const metricTips = {
newPayUserCount: '所选周期内首次产生成功充值的玩家数,此前从未付费。'
}
// Sorting must describe the full query, never just the current page.
// Report tables sort the full result; detail ledgers explicitly sort the loaded page.
export const remoteSort = ({ prop, order }, defaultField) => ({
sortField: order ? prop : defaultField,
sortOrder: order === 'ascending' ? 'asc' : 'desc'
})
export const sortDescription = (query, labels) => `排序:${labels[query.sortField] || query.sortField} · ${query.sortOrder === 'asc' ? '升序' : '降序'}`
// Do not reorder the server page or mutate it; clearing sort restores its original order.
export const pageSort = (rows, { prop, order } = {}, moneyFields = []) => {
if (!prop || !order) return rows
const compare = moneyFields.includes(prop) ? compareAmounts : (a,b) => String(a ?? '').localeCompare(String(b ?? ''), undefined, { numeric: true })
return [...rows].sort((a,b) => (order === 'ascending' ? 1 : -1) * compare(a[prop],b[prop]))
}
+12
View File
@@ -0,0 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { pageSort } from './table-display.js'
test('detail sorting preserves the loaded page, exact money and default order', () => {
const page = [{ id: 'b', amount: '9007199254740993.02', stamp: '20' }, { id: 'a', amount: '9007199254740993.01', stamp: '3' }]
assert.deepEqual(pageSort(page, { prop: 'amount', order: 'ascending' }, ['amount']).map(x => x.id), ['a','b'])
assert.deepEqual(pageSort(page, { prop: 'stamp', order: 'ascending' }).map(x => x.id), ['a','b'])
assert.deepEqual(page.map(x => x.id), ['b','a'])
assert.equal(pageSort(page, { prop: 'amount', order: null }), page)
assert.equal(page.length, 2)
})