支持后台用户手动录入个人业绩与账户消耗,独立计算转化指标,接入 DataScope 数据权限。 Co-authored-by: Cursor <cursoragent@cursor.com>
394 lines
13 KiB
Vue
394 lines
13 KiB
Vue
<template>
|
|
<div class="self-input-stats-page">
|
|
<el-card class="!border-none" shadow="never">
|
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
|
<el-form-item label="时间范围">
|
|
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
|
<el-radio-button label="today">今天</el-radio-button>
|
|
<el-radio-button label="yesterday">昨天</el-radio-button>
|
|
<el-radio-button label="week">最近7天</el-radio-button>
|
|
<el-radio-button label="month">最近30天</el-radio-button>
|
|
<el-radio-button label="custom">自定义</el-radio-button>
|
|
</el-radio-group>
|
|
</el-form-item>
|
|
|
|
<el-form-item v-if="queryParams.time_type === 'custom'">
|
|
<el-date-picker
|
|
v-model="dateRange"
|
|
type="daterange"
|
|
value-format="YYYY-MM-DD"
|
|
range-separator="至"
|
|
start-placeholder="开始日期"
|
|
end-placeholder="结束日期"
|
|
style="width: 260px"
|
|
@change="handleCustomDateChange"
|
|
/>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="自媒体来源">
|
|
<el-input
|
|
v-model="queryParams.media_source"
|
|
placeholder="筛选自媒体来源"
|
|
clearable
|
|
style="width: 220px"
|
|
@keyup.enter="handleQuery"
|
|
/>
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
|
<el-button @click="handleReset">重置</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
|
|
<div class="stats-kpi-grid">
|
|
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card">
|
|
<div class="stats-kpi-label">{{ card.label }}</div>
|
|
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
|
{{ renderMetric(card.key, overview.summary, card.type) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<el-card class="!border-none mt-4 stats-detail-card" shadow="never">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<div class="card-header-main">
|
|
<span class="card-title">业绩明细</span>
|
|
<span class="card-hint">{{ dateRangeText }}</span>
|
|
</div>
|
|
<div class="card-header-actions">
|
|
<el-button v-perms="['stats.personal_yeji/add']" type="primary" @click="handleAddYeji">
|
|
<template #icon>
|
|
<icon name="el-icon-Plus" />
|
|
</template>
|
|
新增业绩
|
|
</el-button>
|
|
<router-link
|
|
v-perms="['stats.personal_account_cost/lists']"
|
|
to="/stats/self_input/account_cost"
|
|
>
|
|
<el-button>账户消耗录入</el-button>
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<el-table
|
|
v-loading="pager.loading"
|
|
:data="pager.lists"
|
|
border
|
|
size="large"
|
|
max-height="640"
|
|
>
|
|
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
|
|
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
|
|
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
|
<el-table-column
|
|
v-for="col in tableColumns"
|
|
:key="col.key"
|
|
:label="col.label"
|
|
:min-width="col.minWidth || 100"
|
|
align="right"
|
|
>
|
|
<template #default="{ row }">
|
|
{{ renderMetric(col.key, row, col.type) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
|
|
<el-table-column label="操作" width="140" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-button
|
|
v-if="canMutateRow(row)"
|
|
v-perms="['stats.personal_yeji/edit']"
|
|
type="primary"
|
|
link
|
|
@click="handleEditYeji(row)"
|
|
>
|
|
编辑
|
|
</el-button>
|
|
<el-button
|
|
v-if="canMutateRow(row)"
|
|
v-perms="['stats.personal_yeji/delete']"
|
|
type="danger"
|
|
link
|
|
@click="handleDeleteYeji(row.id)"
|
|
>
|
|
删除
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<div class="flex justify-end mt-4">
|
|
<pagination v-model="pager" @change="fetchOverview" />
|
|
</div>
|
|
</el-card>
|
|
|
|
<yeji-edit-popup v-if="showYejiEdit" ref="yejiEditRef" @success="fetchOverview" @close="showYejiEdit = false" />
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup name="selfInputStats">
|
|
import {
|
|
getSelfInputOverview,
|
|
personalYejiDelete,
|
|
} from '@/api/self_input_stats'
|
|
import { usePaging } from '@/hooks/usePaging'
|
|
import useUserStore from '@/stores/modules/user'
|
|
import feedback from '@/utils/feedback'
|
|
|
|
import YejiEditPopup from './yeji-edit.vue'
|
|
|
|
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
|
|
|
|
interface MetricCard {
|
|
key: string
|
|
label: string
|
|
type: MetricType
|
|
}
|
|
|
|
interface MetricColumn {
|
|
key: string
|
|
label: string
|
|
type: MetricType
|
|
minWidth?: number
|
|
}
|
|
|
|
const userStore = useUserStore()
|
|
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
|
|
const showYejiEdit = ref(false)
|
|
const dateRange = ref<string[]>([])
|
|
|
|
const overview = reactive<Record<string, any>>({
|
|
date_range: [],
|
|
summary: {},
|
|
})
|
|
|
|
const queryParams = reactive({
|
|
media_source: '',
|
|
time_type: 'today',
|
|
start_date: '',
|
|
end_date: '',
|
|
})
|
|
|
|
const fetchOverviewList = async (params: Record<string, any>) => {
|
|
if (queryParams.time_type === 'custom') {
|
|
params.start_date = dateRange.value[0] || ''
|
|
params.end_date = dateRange.value[1] || ''
|
|
}
|
|
const res = await getSelfInputOverview(params)
|
|
Object.assign(overview, res?.extend || {})
|
|
return res
|
|
}
|
|
|
|
const { pager, getLists } = usePaging({
|
|
fetchFun: fetchOverviewList,
|
|
params: queryParams,
|
|
firstLoading: true,
|
|
})
|
|
|
|
const summaryCards: MetricCard[] = [
|
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
|
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
|
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
|
{ key: 'order_amount', label: '订单金额', type: 'money' },
|
|
{ key: 'account_cost', label: '账户消耗', type: 'money' },
|
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
|
|
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
|
|
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
|
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
|
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
|
|
{ key: 'cash_cost', label: '现金成本', type: 'money' },
|
|
{ key: 'roi', label: 'ROI', type: 'ratio' },
|
|
]
|
|
|
|
const tableColumns: MetricColumn[] = [
|
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
|
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
|
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
|
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
|
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
|
|
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
|
|
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
|
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
|
|
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
|
|
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
|
|
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
|
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 120 },
|
|
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', minWidth: 110 },
|
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 100 },
|
|
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 100 },
|
|
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
|
|
]
|
|
|
|
const dateRangeText = computed(() => {
|
|
if (!overview.date_range?.length) return '未选择'
|
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
|
})
|
|
|
|
const currentAdminId = computed(() => Number(userStore.userInfo?.id || 0))
|
|
const isRoot = computed(() => Number(userStore.userInfo?.root || 0) === 1)
|
|
|
|
const canMutateRow = (row: Record<string, any>) => {
|
|
if (isRoot.value) return true
|
|
return Number(row.creator_id) === currentAdminId.value
|
|
}
|
|
|
|
const fetchOverview = async () => {
|
|
try {
|
|
await getLists()
|
|
} catch (error: any) {
|
|
console.error('获取自录转化统计失败:', error)
|
|
ElMessage.error(error?.msg || '获取统计数据失败')
|
|
}
|
|
}
|
|
|
|
const handleTimeTypeChange = () => {
|
|
if (queryParams.time_type !== 'custom') {
|
|
dateRange.value = []
|
|
pager.page = 1
|
|
fetchOverview()
|
|
}
|
|
}
|
|
|
|
const handleCustomDateChange = () => {
|
|
if (dateRange.value.length === 2) {
|
|
pager.page = 1
|
|
fetchOverview()
|
|
}
|
|
}
|
|
|
|
const handleQuery = () => {
|
|
pager.page = 1
|
|
fetchOverview()
|
|
}
|
|
|
|
const handleReset = () => {
|
|
queryParams.media_source = ''
|
|
queryParams.time_type = 'today'
|
|
dateRange.value = []
|
|
pager.page = 1
|
|
pager.size = 15
|
|
fetchOverview()
|
|
}
|
|
|
|
const renderMetric = (key: string, source: Record<string, any>, type = 'count') => {
|
|
const value = source?.[key] ?? 0
|
|
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
|
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
|
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
|
return String(value ?? 0)
|
|
}
|
|
|
|
const handleAddYeji = async () => {
|
|
showYejiEdit.value = true
|
|
await nextTick()
|
|
yejiEditRef.value?.open('add')
|
|
}
|
|
|
|
const handleEditYeji = async (row: Record<string, any>) => {
|
|
showYejiEdit.value = true
|
|
await nextTick()
|
|
yejiEditRef.value?.open('edit')
|
|
yejiEditRef.value?.setFormData(row)
|
|
}
|
|
|
|
const handleDeleteYeji = async (id: number) => {
|
|
await feedback.confirm('确定删除该业绩记录?')
|
|
await personalYejiDelete({ id })
|
|
fetchOverview()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await fetchOverview()
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.self-input-stats-page {
|
|
padding: 20px;
|
|
}
|
|
|
|
.stats-filter-form {
|
|
:deep(.el-form-item) {
|
|
margin-bottom: 0;
|
|
}
|
|
}
|
|
|
|
.stats-kpi-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
|
gap: 16px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.stats-kpi-card {
|
|
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
|
border: 1px solid #ebf1ff;
|
|
border-radius: 14px;
|
|
padding: 18px 16px;
|
|
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
|
}
|
|
|
|
.stats-kpi-label {
|
|
color: #6b7280;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.stats-kpi-value {
|
|
margin-top: 12px;
|
|
font-size: 28px;
|
|
line-height: 1.1;
|
|
font-weight: 700;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.stats-kpi-value.is-money {
|
|
font-size: 24px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.card-header-main {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 10px;
|
|
}
|
|
|
|
.card-header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.card-hint {
|
|
color: #909399;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.stats-detail-card :deep(.el-card__body) {
|
|
padding-top: 0;
|
|
}
|
|
</style>
|