This commit is contained in:
Your Name
2026-08-25 11:40:58 +08:00
parent b8ccbaf567
commit f24afa116f
350 changed files with 1910 additions and 371 deletions
+44
View File
@@ -154,6 +154,50 @@ export function firstVisitConversionOverview(params: FirstVisitConversionParams)
)
}
export interface FirstVisitConversionFansDetailParams extends FirstVisitConversionParams {
entity_type: 'dept' | 'member'
entity_id: string | number
admin_id?: number
page_no: number
page_size: number
}
export interface FirstVisitConversionFanDetailRow {
external_userid: string
customer_name: string
wecom_userid: string
wecom_staff_name: string
add_time: string | null
is_deleted: boolean
delete_time: string | null
}
export interface FirstVisitConversionFansDetailResult {
lists?: FirstVisitConversionFanDetailRow[]
rows?: FirstVisitConversionFanDetailRow[]
total?: number
count?: number
page_no?: number
page_size?: number
entity?: {
type?: 'dept' | 'member'
id?: string | number
admin_id?: number
name?: string
add_fans_count?: number
deleted_fans_count?: number
}
date_range?: [string, string]
}
/** 一诊加粉明细:时间、渠道及行实体均由调用方显式透传,服务端仍需按 DataScope 收窄。 */
export function firstVisitConversionFansDetail(params: FirstVisitConversionFansDetailParams) {
return request.get(
{ url: '/firstvisit.conversion/fansDetail', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export interface FirstVisitRegistrationStatsParams {
time_type: 'today' | 'yesterday' | 'week' | 'month'
dept_id?: number
@@ -189,7 +189,13 @@
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="92" align="right">
<template #default="{ row }">
<span class="fan-count-value">
<button
v-if="hasFans(row.add_fans_count)"
type="button"
class="fan-count-value fan-detail-trigger fan-detail-trigger--table"
title="查看该行加粉客户明细"
@click.stop="openFansDetail(row)"
>
{{ formatNumber(row.add_fans_count) }}
<em
v-if="hasDeletedFans(row.deleted_fans_count)"
@@ -198,7 +204,8 @@
>
-{{ formatNumber(row.deleted_fans_count) }}
</em>
</span>
</button>
<span v-else class="fan-count-value is-empty">0</span>
</template>
</el-table-column>
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
@@ -292,6 +299,75 @@
</div>
</div>
</section>
<el-dialog
v-model="fansDetailVisible"
:title="fansDetailTitle"
width="920px"
top="7vh"
append-to-body
destroy-on-close
class="fans-detail-dialog"
@closed="resetFansDetail"
>
<div class="fans-detail-context">
<span><b>统计区间</b>{{ detailRangeText }}</span>
<span><b>媒体渠道</b>{{ detailChannelText }}</span>
<span><b>数据范围</b>{{ dashboard.meta.scope_label || '当前权限范围' }}</span>
</div>
<el-table
v-loading="fansDetailLoading"
:data="fansDetailRows"
:row-class-name="fansDetailRowClassName"
height="430"
class="fans-detail-table"
>
<el-table-column label="客户" min-width="190">
<template #default="{ row }">
<div class="customer-cell">
<strong class="customer-name" :class="{ 'is-deleted': isDeletedFan(row) }">
{{ fanCustomerName(row) }}
</strong>
<small v-if="row.customer_name && row.external_userid">{{ row.external_userid }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="归属员工" min-width="160">
<template #default="{ row }">{{ fanOwnerName(row) }}</template>
</el-table-column>
<el-table-column label="添加时间" min-width="180">
<template #default="{ row }">{{ fanAddTime(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="150" align="center">
<template #default="{ row }">
<div v-if="isDeletedFan(row)" class="deleted-status">
<el-tag type="danger" effect="dark" size="small">已删除</el-tag>
<small v-if="row.delete_time">{{ row.delete_time }}</small>
</div>
<el-tag v-else type="success" effect="plain" size="small">正常</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty :image-size="68" description="当前条件下暂无加粉客户明细" />
</template>
</el-table>
<div class="fans-detail-footer">
<span> {{ formatNumber(fansDetailPager.total) }} 位客户</span>
<el-pagination
v-if="fansDetailPager.total > 0"
v-model:current-page="fansDetailPager.page_no"
v-model:page-size="fansDetailPager.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="fansDetailPager.total"
layout="total, sizes, prev, pager, next"
background
@current-change="loadFansDetail"
@size-change="handleFansDetailSizeChange"
/>
</div>
</el-dialog>
</div>
</template>
@@ -300,7 +376,14 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Lock, Refresh } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
import {
firstVisitConversionFansDetail,
firstVisitConversionOverview,
type FirstVisitConversionFanDetailRow,
type FirstVisitConversionFansDetailParams,
type FirstVisitConversionFansDetailResult,
type FirstVisitConversionParams
} from '@/api/first_visit'
type MetricType = 'count' | 'money' | 'ratio'
type MediaChannelOption = {
@@ -346,6 +429,13 @@ const query = reactive<FirstVisitConversionParams>({
media_channel_code: ''
})
const customDateRange = ref<string[]>([])
const fansDetailVisible = ref(false)
const fansDetailLoading = ref(false)
const fansDetailRows = ref<FirstVisitConversionFanDetailRow[]>([])
const fansDetailPager = reactive({ page_no: 1, page_size: 20, total: 0 })
const fansDetailEntity = ref<{ type: 'dept' | 'member'; id: string | number; adminId?: number; name: string }>({
type: 'dept', id: 0, name: '当前行'
})
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const timeOptions = [
{ label: '今日', value: 'today' },
@@ -424,6 +514,13 @@ const rankingKind = computed(() => dashboard.meta.ranking_kind || (
))
const showRankings = computed(() => rankingKind.value !== 'hidden')
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
const fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉客户明细`)
const detailRangeText = computed(() => {
const startDate = dashboard.meta.start_date || query.start_date || ''
const endDate = dashboard.meta.end_date || query.end_date || ''
return startDate && endDate ? `${startDate}${endDate}` : (dashboard.meta.time_label || '当前区间')
})
const detailChannelText = computed(() => dashboard.meta.selected_media_channel_name || '全部渠道')
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
const targetChartOption = computed(() => ({
@@ -440,6 +537,7 @@ const targetChartOption = computed(() => ({
}))
let latestDashboardRequestId = 0
let latestFansDetailRequestId = 0
async function loadDashboard() {
const requestId = ++latestDashboardRequestId
@@ -492,6 +590,89 @@ function handleDeptChange() {
loadDashboard()
}
function openFansDetail(row: any) {
fansDetailEntity.value = {
type: row.type ? 'member' : 'dept',
id: row.id,
adminId: Number(row.admin_id || 0) || undefined,
name: row.name || '当前行'
}
fansDetailPager.page_no = 1
fansDetailRows.value = []
fansDetailPager.total = 0
fansDetailVisible.value = true
loadFansDetail()
}
async function loadFansDetail() {
if (!fansDetailVisible.value) return
const requestId = ++latestFansDetailRequestId
fansDetailLoading.value = true
try {
const params: FirstVisitConversionFansDetailParams = {
...query,
time_type: query.time_type,
start_date: dashboard.meta.start_date || query.start_date,
end_date: dashboard.meta.end_date || query.end_date,
media_channel_code: dashboard.meta.selected_media_channel_code || query.media_channel_code || '',
entity_type: fansDetailEntity.value.type,
entity_id: fansDetailEntity.value.id,
admin_id: fansDetailEntity.value.adminId,
page_no: fansDetailPager.page_no,
page_size: fansDetailPager.page_size
}
const result = await firstVisitConversionFansDetail(params) as FirstVisitConversionFansDetailResult
if (requestId !== latestFansDetailRequestId) return
const rows = Array.isArray(result?.lists) ? result.lists : (Array.isArray(result?.rows) ? result.rows : [])
fansDetailRows.value = rows
fansDetailPager.total = Number(result?.count ?? result?.total ?? rows.length) || 0
} catch (error: any) {
if (requestId !== latestFansDetailRequestId) return
fansDetailRows.value = []
fansDetailPager.total = 0
ElMessage.error(error?.message || '加粉客户明细加载失败')
} finally {
if (requestId === latestFansDetailRequestId) fansDetailLoading.value = false
}
}
function handleFansDetailSizeChange() {
fansDetailPager.page_no = 1
loadFansDetail()
}
function resetFansDetail() {
++latestFansDetailRequestId
fansDetailLoading.value = false
fansDetailRows.value = []
fansDetailPager.page_no = 1
fansDetailPager.total = 0
}
function hasFans(value: any) {
return Math.round(Number(value || 0)) > 0
}
function isDeletedFan(row: FirstVisitConversionFanDetailRow) {
return Boolean(row.is_deleted)
}
function fanCustomerName(row: FirstVisitConversionFanDetailRow) {
return row.customer_name || row.external_userid || '未知客户'
}
function fanOwnerName(row: FirstVisitConversionFanDetailRow) {
return row.wecom_staff_name || row.wecom_userid || '未绑定员工'
}
function fanAddTime(row: FirstVisitConversionFanDetailRow) {
return row.add_time || '-'
}
function fansDetailRowClassName({ row }: { row: FirstVisitConversionFanDetailRow }) {
return isDeletedFan(row) ? 'is-deleted-fan-row' : ''
}
function formatMetric(key: string, type: MetricType) {
const value = dashboard.summary[key]
if (type === 'money') return formatMoney(value)
@@ -658,6 +839,18 @@ onMounted(loadDashboard)
}
.metric-value { display: flex; justify-content: flex-start; }
.fan-detail-trigger {
margin: 0;
padding: 0;
border: 0;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
&:focus-visible { outline: 2px solid #2f78df; outline-offset: 3px; border-radius: 3px; }
&:disabled { cursor: default; }
}
.deleted-fans-marker {
color: #c65f26;
font-size: .58em;
@@ -666,6 +859,9 @@ onMounted(loadDashboard)
}
.fan-count-value .deleted-fans-marker { font-size: 11px; }
.fan-detail-trigger--table { color: #1769aa; font-weight: 650; }
.fan-detail-trigger--table:hover { color: #0f4f82; }
.fan-count-value.is-empty { color: #98a2b3; }
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
@@ -729,6 +925,47 @@ onMounted(loadDashboard)
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
.target-chart { width: 100%; height: 260px; }
.fans-detail-context {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin: -4px 0 14px;
padding: 10px 12px;
border: 1px solid #e4e9ef;
border-radius: 7px;
color: #5d6b7e;
background: #f7f9fb;
font-size: 12px;
span { display: inline-flex; gap: 7px; }
b { color: #8a95a5; font-weight: 500; }
}
.fans-detail-table {
width: 100%;
.customer-cell { display: grid; gap: 3px; }
.customer-name { color: #273347; font-weight: 650; }
.customer-name.is-deleted { color: #c24137; text-decoration: line-through; text-decoration-color: #e8a39e; }
.customer-cell small { overflow: hidden; color: #98a2b3; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
:deep(.is-deleted-fan-row > td.el-table__cell) { background: #fff3f2 !important; }
}
.deleted-status {
display: inline-grid;
justify-items: center;
gap: 4px;
small { color: #b14e47; font-size: 10px; }
}
.fans-detail-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
color: #8491a2;
font-size: 12px;
}
@media (max-width: 1180px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.range-text { margin-left: 0; }
@@ -744,5 +981,7 @@ onMounted(loadDashboard)
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
.target-summary { grid-template-columns: 1fr; }
.fans-detail-footer { align-items: flex-start; flex-direction: column; }
.fans-detail-footer :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }
}
</style>