Merge branch 'cursor/self-input-stats'

This commit is contained in:
2026-05-22 12:07:36 +08:00
26 changed files with 2904 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import request from '@/utils/request'
export function getSelfInputOverview(params: any) {
return request.get({ url: '/stats.self_input/overview', params })
}
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
export function getSelfInputMediaSourceOptions() {
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
}
export function personalYejiLists(params: any) {
return request.get({ url: '/stats.personal_yeji/lists', params })
}
export function personalYejiAdd(data: any) {
return request.post({ url: '/stats.personal_yeji/add', data })
}
export function personalYejiEdit(data: any) {
return request.post({ url: '/stats.personal_yeji/edit', data })
}
export function personalYejiDetail(params: any) {
return request.get({ url: '/stats.personal_yeji/detail', params })
}
export function personalYejiDelete(params: any) {
return request.post({ url: '/stats.personal_yeji/delete', params })
}
export function personalAccountCostLists(params: any) {
return request.get({ url: '/stats.personal_account_cost/lists', params })
}
export function personalAccountCostAdd(data: any) {
return request.post({ url: '/stats.personal_account_cost/add', data })
}
export function personalAccountCostEdit(data: any) {
return request.post({ url: '/stats.personal_account_cost/edit', data })
}
export function personalAccountCostDetail(params: any) {
return request.get({ url: '/stats.personal_account_cost/detail', params })
}
export function personalAccountCostDelete(params: any) {
return request.post({ url: '/stats.personal_account_cost/delete', params })
}
@@ -0,0 +1,82 @@
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
:clearable="clearable"
:filterable="filterable"
:allow-create="allowCreate"
:default-first-option="allowCreate"
:disabled="disabled"
:loading="loading"
:class="selectClass"
:style="selectStyle"
@update:model-value="emit('update:modelValue', $event)"
@change="emit('change', $event)"
@visible-change="onVisibleChange"
>
<el-option
v-for="item in normalizedOptions"
:key="item.name"
:label="item.name"
:value="item.name"
/>
<!-- 旧记录可能存在不在当前字典内的值保留显示避免编辑时反查不到 -->
<el-option
v-if="modelValue && !hasCurrentValue"
:key="`__legacy_${modelValue}`"
:label="`${modelValue}(已停用)`"
:value="modelValue"
/>
</el-select>
</template>
<script lang="ts" setup>
interface OptionItem {
name: string
value?: string
}
const props = withDefaults(
defineProps<{
modelValue?: string
options: Array<OptionItem | string>
loading?: boolean
placeholder?: string
clearable?: boolean
filterable?: boolean
allowCreate?: boolean
disabled?: boolean
selectClass?: string
selectStyle?: string | Record<string, string>
}>(),
{
modelValue: '',
loading: false,
placeholder: '请选择自媒体来源',
clearable: true,
filterable: true,
allowCreate: false,
disabled: false,
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [value: string]
'visible-change': [visible: boolean]
}>()
const normalizedOptions = computed<OptionItem[]>(() => {
return (props.options || []).map((item) =>
typeof item === 'string' ? { name: item } : { name: item.name, value: item.value }
)
})
const hasCurrentValue = computed(() => {
return normalizedOptions.value.some((item) => item.name === props.modelValue)
})
const onVisibleChange = (visible: boolean) => {
emit('visible-change', visible)
}
</script>
@@ -0,0 +1,254 @@
<template>
<div class="personal-account-cost-page">
<el-card class="!border-none mb-4" shadow="never">
<div class="flex flex-wrap">
<div class="w-1/2 md:w-1/3">
<div class="leading-10">当前筛选天数</div>
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
</div>
<div class="w-1/2 md:w-1/3">
<div class="leading-10">累计账户消耗 ()</div>
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
</div>
</div>
</el-card>
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" inline>
<el-form-item label="日期范围">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
:data="deptOptions"
node-key="id"
:props="deptTreeProps"
:default-expand-all="true"
check-strictly
placeholder="全部部门"
clearable
filterable
class="w-[220px]"
@change="resetPage"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<media-source-select
v-model="queryParams.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="全部来源"
:allow-create="false"
select-class="w-[220px]"
@change="resetPage"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
<el-form-item class="w-[280px]" label="备注/录入人">
<el-input
v-model="queryParams.remark"
placeholder="备注 / 创建人"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<el-button v-perms="['stats.personal_account_cost/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
</div>
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="日期" prop="cost_date" min-width="120" />
<el-table-column label="自媒体来源" prop="media_source" min-width="160" show-overflow-tooltip />
<el-table-column label="账户消耗" min-width="120">
<template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="录入人" prop="creator_name" min-width="100" />
<el-table-column label="部门" prop="dept_name" min-width="140">
<template #default="{ row }">
<el-tooltip
v-if="row.dept_path"
:content="row.dept_path"
placement="top"
effect="dark"
>
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
</el-tooltip>
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else class="text-gray-400">未分配</span>
</template>
</el-table-column>
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
<el-table-column label="更新时间" prop="update_time" min-width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['stats.personal_account_cost/edit']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['stats.personal_account_cost/delete']"
type="danger"
link
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<cost-edit-popup
v-if="showEdit"
ref="editRef"
:media-source-options="mediaSourceOptions"
:media-source-loading="mediaSourceLoading"
@success="handleCostSuccess"
@close="showEdit = false"
/>
</div>
</template>
<script lang="ts" setup name="personalAccountCost">
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
import { deptAll } from '@/api/org/department'
import { usePaging } from '@/hooks/usePaging'
import { getRoutePath } from '@/router'
import feedback from '@/utils/feedback'
import CostEditPopup from './cost-edit.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
const router = useRouter()
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
const showEdit = ref(false)
const deptOptions = ref<any[]>([])
const {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
} = useMediaSourceOptions()
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
const goSelfInput = () => {
if (selfInputPath.value) {
router.push(selfInputPath.value)
}
}
const queryParams = reactive({
start_date: '',
end_date: '',
media_source: '',
dept_id: undefined as number | undefined,
remark: '',
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: personalAccountCostLists,
params: queryParams,
})
const handleReset = () => {
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.remark = ''
resetPage()
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
if (visible) {
refreshMediaSourceOptions()
}
}
const handleCostSuccess = async () => {
await refreshMediaSourceOptions()
getLists()
}
const handleAdd = async () => {
await refreshMediaSourceOptions()
showEdit.value = true
await nextTick()
editRef.value?.open('add')
}
const handleEdit = async (row: Record<string, any>) => {
showEdit.value = true
await nextTick()
editRef.value?.open('edit')
editRef.value?.setFormData(row)
}
const handleDelete = async (id: number) => {
await feedback.confirm('确定删除该账户消耗记录?')
await personalAccountCostDelete({ id })
getLists()
}
const loadDeptOptions = async () => {
try {
const res = await deptAll({ apply_data_scope: 1 })
if (Array.isArray(res)) {
deptOptions.value = res
}
} catch (e) {
deptOptions.value = []
}
}
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
})
</script>
<style scoped lang="scss">
.personal-account-cost-page {
padding: 20px;
}
.dept-cell {
cursor: help;
border-bottom: 1px dashed #c0c4cc;
}
</style>
@@ -0,0 +1,142 @@
<template>
<div class="edit-popup">
<popup
ref="popupRef"
:title="popupTitle"
:async="true"
width="520px"
@confirm="handleSubmit"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
<el-form-item label="日期" prop="cost_date">
<el-date-picker
v-model="formData.cost_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
:disabled="mode === 'edit'"
class="w-full"
/>
</el-form-item>
<el-form-item label="自媒体来源" prop="media_source">
<media-source-select
v-model="formData.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="请选择自媒体来源"
:allow-create="false"
:disabled="mode === 'edit'"
select-class="w-full"
/>
</el-form-item>
<el-form-item label="账户消耗" prop="amount">
<el-input-number
v-model="formData.amount"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
maxlength="255"
show-word-limit
placeholder="选填"
/>
</el-form-item>
</el-form>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import {
personalAccountCostAdd,
personalAccountCostEdit,
} from '@/api/self_input_stats'
import Popup from '@/components/popup/index.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import type { MediaSourceOption } from './useMediaSourceOptions'
withDefaults(
defineProps<{
mediaSourceOptions?: MediaSourceOption[]
mediaSourceLoading?: boolean
}>(),
{
mediaSourceOptions: () => [],
mediaSourceLoading: false,
}
)
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const mode = ref<'add' | 'edit'>('add')
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
const formData = reactive({
id: '',
cost_date: '',
media_source: '',
amount: 0,
remark: '',
})
const formRules = {
cost_date: [{ required: true, message: '请选择日期', trigger: ['change'] }],
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
amount: [{ required: true, message: '请输入账户消耗金额', trigger: ['blur', 'change'] }],
}
const resetForm = () => {
formData.id = ''
formData.cost_date = ''
formData.media_source = ''
formData.amount = 0
formData.remark = ''
}
const handleSubmit = async () => {
await formRef.value?.validate()
if (mode.value === 'edit') {
await personalAccountCostEdit(formData)
} else {
await personalAccountCostAdd(formData)
}
popupRef.value?.close()
emit('success')
}
const open = (type: 'add' | 'edit' = 'add') => {
mode.value = type
resetForm()
popupRef.value?.open()
}
const setFormData = (data: Record<string, any>) => {
formData.id = data.id ?? ''
formData.cost_date = data.cost_date ?? ''
formData.media_source = data.media_source ?? ''
formData.amount = Number(data.amount ?? 0)
formData.remark = data.remark ?? ''
}
const handleClose = () => {
emit('close')
}
defineExpose({
open,
setFormData,
})
</script>
+499
View File
@@ -0,0 +1,499 @@
<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-tree-select
v-model="queryParams.dept_id"
:data="deptOptions"
node-key="id"
:props="deptTreeProps"
:default-expand-all="true"
check-strictly
placeholder="全部部门"
clearable
filterable
style="width: 220px"
@change="handleQuery"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<media-source-select
v-model="queryParams.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="全部来源"
:allow-create="false"
select-style="width: 220px"
@change="handleQuery"
@visible-change="onMediaSourceDropdownVisible"
/>
</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 visibleSummaryCards" :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>
<el-button
v-if="accountCostEntryPath"
v-perms="['stats.personal_account_cost/lists']"
@click="goAccountCostEntry"
>
账户消耗录入
</el-button>
</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 label="部门" prop="dept_name" min-width="140">
<template #default="{ row }">
<el-tooltip
v-if="row.dept_path"
:content="row.dept_path"
placement="top"
effect="dark"
>
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
</el-tooltip>
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else class="text-gray-400">未分配</span>
</template>
</el-table-column>
<el-table-column
v-for="col in visibleTableColumns"
: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-perms="['stats.personal_yeji/edit']"
type="primary"
link
@click="handleEditYeji(row)"
>
编辑
</el-button>
<el-button
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"
:media-source-options="mediaSourceOptions"
:media-source-loading="mediaSourceLoading"
@success="handleYejiSuccess"
@close="showYejiEdit = false"
/>
</div>
</template>
<script lang="ts" setup name="selfInputStats">
import {
getSelfInputOverview,
personalYejiDelete,
} from '@/api/self_input_stats'
import { deptAll } from '@/api/org/department'
import { usePaging } from '@/hooks/usePaging'
import { getRoutePath } from '@/router'
import feedback from '@/utils/feedback'
import MediaSourceSelect from './MediaSourceSelect.vue'
import YejiEditPopup from './yeji-edit.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
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 router = useRouter()
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
const showYejiEdit = ref(false)
const dateRange = ref<string[]>([])
const deptOptions = ref<any[]>([])
const {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
} = useMediaSourceOptions()
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const overview = reactive<Record<string, any>>({
date_range: [],
summary: {},
can_view_finance: false,
})
const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi'])
const canViewFinance = computed(() => Boolean(overview.can_view_finance))
const queryParams = reactive({
media_source: '',
dept_id: undefined as number | undefined,
time_type: 'today',
start_date: '',
end_date: '',
})
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
const goAccountCostEntry = () => {
if (accountCostEntryPath.value) {
router.push(accountCostEntryPath.value)
}
}
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 loadDeptOptions = async () => {
try {
const res = await deptAll({ apply_data_scope: 1 })
if (Array.isArray(res)) {
deptOptions.value = res
}
} catch (e) {
deptOptions.value = []
}
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
if (visible) {
refreshMediaSourceOptions()
}
}
const handleYejiSuccess = async () => {
await refreshMediaSourceOptions()
await fetchOverview()
}
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
})
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 visibleSummaryCards = computed(() =>
canViewFinance.value
? summaryCards
: summaryCards.filter((card) => !FINANCE_KEYS.has(card.key))
)
const visibleTableColumns = computed(() =>
canViewFinance.value
? tableColumns
: tableColumns.filter((col) => !FINANCE_KEYS.has(col.key))
)
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
})
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.dept_id = undefined
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 () => {
await refreshMediaSourceOptions()
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);
}
.dept-cell {
cursor: help;
border-bottom: 1px dashed #c0c4cc;
}
.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>
@@ -0,0 +1,63 @@
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
/**
* 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。
* 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。
*/
export interface MediaSourceOption {
name: string
value: string
}
export function useMediaSourceOptions() {
const mediaSourceOptions = ref<MediaSourceOption[]>([])
const mediaSourceLoading = ref(false)
const normalize = (raw: any): MediaSourceOption[] => {
if (!Array.isArray(raw)) return []
const list: MediaSourceOption[] = []
const seen = new Set<string>()
for (const item of raw) {
if (typeof item === 'string') {
const name = item.trim()
if (name && !seen.has(name)) {
seen.add(name)
list.push({ name, value: '' })
}
continue
}
if (item && typeof item === 'object') {
const name = String(item.name ?? '').trim()
if (name && !seen.has(name)) {
seen.add(name)
list.push({ name, value: String(item.value ?? '') })
}
}
}
return list
}
const loadMediaSourceOptions = async (force = false) => {
if (!force && mediaSourceOptions.value.length > 0) {
return
}
mediaSourceLoading.value = true
try {
const res = await getSelfInputMediaSourceOptions()
mediaSourceOptions.value = normalize(res)
} catch {
mediaSourceOptions.value = []
} finally {
mediaSourceLoading.value = false
}
}
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
return {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
}
}
@@ -0,0 +1,234 @@
<template>
<div class="edit-popup">
<popup
ref="popupRef"
:title="popupTitle"
:async="true"
width="640px"
@confirm="handleSubmit"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
<el-form-item label="业绩日期" prop="yeji_date">
<el-date-picker
v-model="formData.yeji_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
:disabled="mode === 'edit'"
class="w-full"
/>
</el-form-item>
<el-form-item label="自媒体来源" prop="media_source">
<media-source-select
v-model="formData.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="请选择自媒体来源"
:allow-create="false"
:disabled="mode === 'edit'"
select-class="w-full"
/>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="加粉数" prop="add_fans_count">
<el-input-number
v-model="formData.add_fans_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总开口" prop="total_open_count">
<el-input-number
v-model="formData.total_open_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="未回复" prop="unreplied_count">
<el-input-number
v-model="formData.unreplied_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="付费挂号" prop="paid_appointment_count">
<el-input-number
v-model="formData.paid_appointment_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="免费挂号" prop="free_appointment_count">
<el-input-number
v-model="formData.free_appointment_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="面诊" prop="interview_count">
<el-input-number
v-model="formData.interview_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="订单金额" prop="order_amount">
<el-input-number
v-model="formData.order_amount"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="接诊诊单" prop="completed_order_count">
<el-input-number
v-model="formData.completed_order_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
maxlength="255"
show-word-limit
placeholder="选填"
/>
</el-form-item>
</el-form>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
import Popup from '@/components/popup/index.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import type { MediaSourceOption } from './useMediaSourceOptions'
withDefaults(
defineProps<{
mediaSourceOptions?: MediaSourceOption[]
mediaSourceLoading?: boolean
}>(),
{
mediaSourceOptions: () => [],
mediaSourceLoading: false,
}
)
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const mode = ref<'add' | 'edit'>('add')
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑业绩' : '新增业绩'))
const formData = reactive({
id: '',
yeji_date: '',
media_source: '',
add_fans_count: 0,
total_open_count: 0,
unreplied_count: 0,
paid_appointment_count: 0,
free_appointment_count: 0,
interview_count: 0,
order_amount: 0,
completed_order_count: 0,
remark: '',
})
const formRules = {
yeji_date: [{ required: true, message: '请选择业绩日期', trigger: ['change'] }],
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
add_fans_count: [{ required: true, message: '请填写加粉数', trigger: ['blur', 'change'] }],
}
const resetForm = () => {
formData.id = ''
formData.yeji_date = ''
formData.media_source = ''
formData.add_fans_count = 0
formData.total_open_count = 0
formData.unreplied_count = 0
formData.paid_appointment_count = 0
formData.free_appointment_count = 0
formData.interview_count = 0
formData.order_amount = 0
formData.completed_order_count = 0
formData.remark = ''
}
const handleSubmit = async () => {
await formRef.value?.validate()
if (mode.value === 'edit') {
await personalYejiEdit(formData)
} else {
await personalYejiAdd(formData)
}
popupRef.value?.close()
emit('success')
}
const open = (type: 'add' | 'edit' = 'add') => {
mode.value = type
resetForm()
popupRef.value?.open()
}
const setFormData = (data: Record<string, any>) => {
formData.id = data.id ?? ''
formData.yeji_date = data.yeji_date ?? ''
formData.media_source = data.media_source ?? ''
formData.add_fans_count = Number(data.add_fans_count ?? 0)
formData.total_open_count = Number(data.total_open_count ?? 0)
formData.unreplied_count = Number(data.unreplied_count ?? 0)
formData.paid_appointment_count = Number(data.paid_appointment_count ?? 0)
formData.free_appointment_count = Number(data.free_appointment_count ?? 0)
formData.interview_count = Number(data.interview_count ?? 0)
formData.order_amount = Number(data.order_amount ?? 0)
formData.completed_order_count = Number(data.completed_order_count ?? 0)
formData.remark = data.remark ?? ''
}
const handleClose = () => {
emit('close')
}
defineExpose({
open,
setFormData,
})
</script>