This commit is contained in:
Your Name
2026-08-07 09:16:15 +08:00
parent 8bbd6f7885
commit 16d301f302
354 changed files with 967 additions and 456 deletions
+2 -1
View File
@@ -4,7 +4,7 @@ export interface MyPatientListParams {
page_no: number
page_size: number
keyword?: string
status_filter?: '' | 'unconfirmed' | 'booked' | 'completed' | 'missed'
status_filter?: '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
start_date?: string
end_date?: string
}
@@ -141,6 +141,7 @@ export interface FirstVisitConversionParams {
time_type: 'today' | 'yesterday' | 'week' | 'month' | 'quarter' | 'year'
dept_id?: number
assistant_id?: number
media_channel_code?: string
}
/** 一诊综合数据转化:服务端按当前角色 DataScope 与所选部门/员工取交集。 */
+2 -2
View File
@@ -1,9 +1,9 @@
import request from '@/utils/request'
/** 角色数据驾驶舱:服务端统一按当前管理员的数据范围聚合。 */
export function performanceDashboardOverview() {
export function performanceDashboardOverview(params?: { ranking_dept_id?: number }) {
return request.get(
{ url: '/stats.performanceDashboard/overview', timeout: 120000 },
{ url: '/stats.performanceDashboard/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
@@ -51,6 +51,24 @@
@change="handleDeptChange"
/>
</div>
<div class="filter-item">
<span class="filter-label">渠道</span>
<el-select
v-model="query.media_channel_code"
clearable
filterable
placeholder="全部渠道"
class="channel-select"
@change="loadDashboard"
>
<el-option
v-for="item in dashboard.filters.media_channels"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</el-select>
</div>
<span class="range-text">{{ dashboard.meta.start_date }} {{ dashboard.meta.end_date }}</span>
</section>
@@ -104,7 +122,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>部门层级汇总父级包含其下级数据</p>
<p>部门层级汇总开口率=开口/加粉挂号率=付费挂号/加粉接诊率=接诊诊单/加粉</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -124,14 +142,24 @@
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
<el-table-column label="诊金额" min-width="120" align="right">
<el-table-column label="诊金额" min-width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<el-table-column label="开口率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column>
<el-table-column label="挂号率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
<el-table-column label="ROI" min-width="86" align="right">
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
</el-table-column>
@@ -212,9 +240,14 @@ type MetricType = 'count' | 'money' | 'ratio'
const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '', open_count_source: ''
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
},
filters: {
departments: [] as any[],
assistants: [] as Array<{ id: number; name: string }>,
media_channels: [] as Array<{ code: string; name: string }>
},
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
summary: {} as Record<string, any>,
rankings: { orders: [] as any[], amounts: [] as any[] },
rows: [] as any[],
@@ -227,7 +260,12 @@ const emptyDashboard = () => ({
const dashboard = reactive(emptyDashboard())
const loading = ref(false)
const query = reactive<FirstVisitConversionParams>({ time_type: 'today', dept_id: undefined, assistant_id: undefined })
const query = reactive<FirstVisitConversionParams>({
time_type: 'today',
dept_id: undefined,
assistant_id: undefined,
media_channel_code: ''
})
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const timeOptions = [
{ label: '今日', value: 'today' },
@@ -252,6 +290,7 @@ const scopeDescription = computed(() => {
const parts = [`${dashboard.meta.time_label || '当前区间'}数据`, dashboard.meta.scope_label || '当前权限范围']
if (dashboard.meta.selected_dept_name) parts.push(dashboard.meta.selected_dept_name)
if (dashboard.meta.selected_assistant_name) parts.push(dashboard.meta.selected_assistant_name)
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
return parts.join(' · ')
})
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
@@ -388,6 +427,7 @@ onMounted(loadDashboard)
.range-text { margin-left: auto; }
.employee-select { width: 190px; }
.dept-select { width: 220px; }
.channel-select { width: 180px; }
.metric-grid {
display: grid;
@@ -469,7 +509,7 @@ onMounted(loadDashboard)
.page-heading, .heading-meta { align-items: flex-start; flex-direction: column; }
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
.employee-select, .dept-select { width: 100%; }
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
.target-summary { grid-template-columns: 1fr; }
}
@@ -98,6 +98,14 @@
<small>客单价 {{ nullableMoney(dashboard.summary.avg_order_amount) }}</small>
</div>
</article>
<article class="metric-card metric-card--orange">
<div class="metric-icon"><el-icon><DataLine /></el-icon></div>
<div>
<span>总接诊率</span>
<strong>{{ formatPercent(dashboard.summary.receive_conversion_rate) }}</strong>
<small>总接诊 {{ formatNumber(dashboard.summary.order_count) }} ÷ 总面诊 {{ formatNumber(dashboard.summary.interview_count) }}</small>
</div>
</article>
<article class="metric-card metric-card--cyan">
<div class="metric-icon"><el-icon><CircleCheck /></el-icon></div>
<div>
@@ -143,28 +151,7 @@
</article>
</section>
<section class="two-column-grid analytics-grid">
<article class="panel funnel-panel">
<div class="panel-heading">
<div><h2>经营转化漏斗</h2><p>挂号 面诊 接诊 成交</p></div>
<span>人数 / 单数</span>
</div>
<div class="funnel-wrap">
<div
v-for="(stage, index) in dashboard.funnel"
:key="stage.key"
class="funnel-stage"
:class="`stage-${index + 1}`"
:style="{ width: `${100 - index * 15}%` }"
>
<span>{{ stage.label }}</span><strong>{{ formatNumber(stage.value) }}</strong>
</div>
</div>
<div class="funnel-insight">
最大流失发生在 <strong>{{ funnelLoss.label }}</strong>流失 {{ formatNumber(funnelLoss.value) }}
</div>
</article>
<section class="analytics-grid">
<article class="panel trend-panel">
<div class="panel-heading">
<div><h2> 30 天成交金额趋势</h2><p>{{ dashboard.trend.start_date }} {{ dashboard.trend.end_date }}</p></div>
@@ -304,7 +291,6 @@ const emptyDashboard = () => ({
receive_conversion_rate: null as number | null, missed_count: 0, cancelled_count: 0
},
rankings: { amounts: [] as any[], conversion: [] as any[] },
funnel: [] as Array<{ key: string; label: string; value: number }>,
trend: { start_date: '', end_date: '', dates: [] as string[], labels: [] as string[], amounts: [] as number[] },
alerts: [] as any[],
alert_threshold: 15,
@@ -339,19 +325,6 @@ const visibleDetailRows = computed(() => {
if (query.doctor_id || showZeroRows.value) return dashboard.rows
return businessRows.value
})
const funnelLoss = computed(() => {
const stages = dashboard.funnel
let result = { label: '暂无可比阶段', value: 0 }
let maxLoss = -1
for (let index = 0; index < stages.length - 1; index++) {
const loss = Math.max(0, Number(stages[index].value) - Number(stages[index + 1].value))
if (loss > maxLoss) {
maxLoss = loss
result = { label: `${stages[index].label}${stages[index + 1].label}`, value: loss }
}
}
return result
})
const trendChartOption = computed(() => ({
animationDuration: 450,
grid: { left: 20, right: 20, top: 18, bottom: 18, containLabel: true },
@@ -580,7 +553,7 @@ h2 { font-size: 15px; line-height: 1.4; }
.metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
@@ -605,6 +578,7 @@ h2 { font-size: 15px; line-height: 1.4; }
.metric-card--green .metric-icon { color: #37a465; background: #eef8f1; }
.metric-card--indigo .metric-icon { color: #556dde; background: #eef0fd; }
.metric-card--blue .metric-icon { color: #3976e6; background: #edf3ff; }
.metric-card--orange .metric-icon { color: #d77a2c; background: #fff4e9; }
.metric-card--cyan .metric-icon { color: #138da1; background: #eaf7f8; }
.metric-card span { display: block; color: #748296; font-size: 12px; }
.metric-card strong { display: block; margin: 4px 0 3px; font-size: 24px; line-height: 1.1; }
@@ -637,25 +611,8 @@ h2 { font-size: 15px; line-height: 1.4; }
.bar-track .is-blue { background: var(--blue); }
.bar-track .is-teal { background: var(--teal); }
.analytics-grid { display: grid; grid-template-columns: minmax(0, 1fr); }
.analytics-grid .panel { min-height: 300px; }
.funnel-wrap { display: flex; align-items: center; flex-direction: column; gap: 5px; padding: 12px 48px 7px; }
.funnel-stage {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 38px;
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
color: #fff;
font-size: 12px;
}
.funnel-stage strong { font-size: 14px; }
.stage-1 { background: #258bd4; }
.stage-2 { background: #20a290; }
.stage-3 { background: #ed913f; }
.stage-4 { background: #45a96d; }
.funnel-insight { margin: 8px 16px 16px; padding: 8px 10px; border-radius: 7px; color: #617085; background: #f4f7f8; font-size: 11px; }
.funnel-insight strong { color: #d4772c; }
.trend-chart { width: 100%; height: 238px; }
.alert-panel { border-color: #dfe8e7; }
@@ -12,37 +12,71 @@
</div>
<div class="filter-panel">
<el-input
v-model="formData.keyword"
class="keyword-input"
clearable
:prefix-icon="Search"
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
<el-select v-model="formData.prescription_audit_status" clearable placeholder="处方审核" @change="handleSearch">
<el-option v-for="item in auditOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="formData.payment_slip_audit_status" clearable placeholder="支付单审核" @change="handleSearch">
<el-option v-for="item in auditOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="formData.fulfillment_status" clearable placeholder="履约状态" @change="handleSearch">
<el-option v-for="item in fulfillmentOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-date-picker
v-model="dateRange"
class="date-range"
type="daterange"
range-separator=""
start-placeholder="创建开始"
end-placeholder="创建结束"
value-format="YYYY-MM-DD"
clearable
@change="handleDateChange"
/>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<div class="filter-heading">
<div>
<h3>订单检索</h3>
<p>审核与履约状态直接展示点击后立即筛选</p>
</div>
<el-button link :disabled="!hasActiveFilters" @click="resetFilters">清空全部条件</el-button>
</div>
<div class="status-filter-list">
<div class="status-filter-row">
<span class="filter-label">处方审核</span>
<el-radio-group v-model="formData.prescription_audit_status" @change="handleSearch">
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="status-filter-row">
<span class="filter-label">支付单审核</span>
<el-radio-group v-model="formData.payment_slip_audit_status" @change="handleSearch">
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="status-filter-row status-filter-row--fulfillment">
<span class="filter-label">履约状态</span>
<el-radio-group v-model="formData.fulfillment_status" @change="handleSearch">
<el-radio-button v-for="item in fulfillmentFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="search-filter-row">
<label class="filter-field filter-field--keyword">
<span>关键词</span>
<el-input
v-model="formData.keyword"
clearable
:prefix-icon="Search"
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</label>
<label class="filter-field filter-field--date">
<span>创建时间</span>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
@change="handleDateChange"
/>
</label>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</div>
</div>
<div class="metric-grid">
@@ -66,6 +100,26 @@
<strong>{{ summary.completed }}</strong>
<small></small>
</div>
<button
class="metric-card metric-card--interactive metric-rejected"
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
type="button"
@click="toggleRejectedFilter"
>
<span>拒收订单</span>
<strong>{{ summary.rejected }}</strong>
<small> · 点击查看明细</small>
</button>
<button
class="metric-card metric-card--interactive metric-rejected-rate"
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
type="button"
@click="toggleRejectedFilter"
>
<span>拒收率</span>
<strong>{{ percent(summary.rejectionRate) }}</strong>
<small>拒收订单 ÷ 同检索范围订单</small>
</button>
</div>
<el-table
@@ -203,12 +257,14 @@ const formData = reactive({
end_date: ''
})
const auditOptions = [
const auditFilterOptions: Array<{ label: string; value: SelectValue }> = [
{ label: '全部', value: '' },
{ label: '待审核', value: 0 },
{ label: '已通过', value: 1 },
{ label: '已驳回', value: 2 }
]
const fulfillmentOptions = [
const fulfillmentFilterOptions: Array<{ label: string; value: SelectValue }> = [
{ label: '全部', value: '' },
{ label: '待双审通过', value: 1 },
{ label: '待发货', value: 2 },
{ label: '已完成', value: 3 },
@@ -234,9 +290,19 @@ const summary = computed(() => ({
orders: Number(pager.extend?.summary?.orders || 0),
amount: Number(pager.extend?.summary?.amount || 0),
pending: Number(pager.extend?.summary?.pending || 0),
completed: Number(pager.extend?.summary?.completed || 0)
completed: Number(pager.extend?.summary?.completed || 0),
rejected: Number(pager.extend?.summary?.rejected || 0),
rejectionRate: Number(pager.extend?.summary?.rejection_rate || 0)
}))
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
const hasActiveFilters = computed(() => Boolean(
formData.keyword.trim()
|| formData.prescription_audit_status !== ''
|| formData.payment_slip_audit_status !== ''
|| formData.fulfillment_status !== ''
|| formData.start_date
|| formData.end_date
))
function handleSearch() {
resetPage()
@@ -263,10 +329,20 @@ function resetFilters() {
resetPage()
}
function toggleRejectedFilter() {
formData.fulfillment_status = Number(formData.fulfillment_status) === 9 ? '' : 9
resetPage()
}
function money(value: number | string) {
return Number(value || 0).toFixed(2)
}
function percent(value: number | string) {
const number = Number(value || 0)
return `${Number.isInteger(number) ? number.toFixed(0) : number.toFixed(2)}%`
}
function auditTagType(status: number): 'success' | 'warning' | 'danger' | 'info' {
if (Number(status) === 1) return 'success'
if (Number(status) === 2) return 'danger'
@@ -335,31 +411,121 @@ onMounted(getLists)
}
.filter-panel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding: 14px;
padding: 16px;
border: 1px solid #e3e8ef;
border-radius: 10px;
background: #fbfcfd;
}
:deep(.el-select) {
width: 132px;
.filter-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e8edf2;
h3 {
margin: 0;
color: #273244;
font-size: 14px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 12px;
}
}
.keyword-input {
width: min(340px, 100%);
.status-filter-list {
display: grid;
gap: 10px;
padding: 13px 0;
border-bottom: 1px solid #e8edf2;
}
.date-range {
width: 250px;
.status-filter-row {
display: flex;
align-items: flex-start;
gap: 12px;
:deep(.el-radio-group) {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 6px;
}
:deep(.el-radio-button__inner) {
min-width: 72px;
padding: 7px 12px;
color: #5f6b7d;
border: 1px solid #dfe5ec;
border-radius: 6px;
box-shadow: none;
background: #fff;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
:deep(.el-radio-button:first-child .el-radio-button__inner),
:deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 6px;
}
:deep(.el-radio-button.is-active .el-radio-button__inner) {
color: #fff;
border-color: #0f9185;
background: #0f9185;
box-shadow: none;
}
}
.filter-label {
flex: 0 0 76px;
padding-top: 7px;
color: #475467;
font-size: 12px;
font-weight: 600;
}
.search-filter-row {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(300px, 360px) auto;
align-items: end;
gap: 12px;
padding-top: 13px;
}
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
> span {
color: #667085;
font-size: 12px;
font-weight: 500;
}
:deep(.el-date-editor) {
width: 100%;
}
}
.filter-actions {
display: flex;
align-items: center;
:deep(.el-button + .el-button) {
margin-left: 8px;
}
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
margin: 14px 0;
}
@@ -367,6 +533,8 @@ onMounted(getLists)
.metric-card {
min-height: 82px;
padding: 14px 16px;
text-align: left;
font: inherit;
border: 1px solid #e3e8ef;
border-radius: 10px;
background: #fff;
@@ -386,6 +554,39 @@ onMounted(getLists)
}
}
.metric-card--interactive {
color: inherit;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
&:hover,
&:focus-visible {
border-color: #e5a9a3;
box-shadow: 0 7px 18px rgba(132, 45, 40, 0.08);
transform: translateY(-1px);
outline: none;
}
&:active {
transform: translateY(0);
}
&.is-active {
border-color: #d9685f;
box-shadow: 0 0 0 2px rgba(217, 104, 95, 0.11);
}
}
.metric-rejected,
.metric-rejected-rate {
border-color: #f0cbc7;
background: #fff9f8;
strong {
color: #c64f47;
}
}
.metric-warning {
border-color: #f3d8aa;
background: #fffcf5;
@@ -466,10 +667,24 @@ onMounted(getLists)
padding-top: 16px;
}
@media (max-width: 1400px) {
.metric-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.search-filter-row {
grid-template-columns: 1fr 1fr;
}
.filter-actions {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
@@ -482,10 +697,24 @@ onMounted(getLists)
grid-template-columns: 1fr;
}
.filter-panel > *,
.filter-panel :deep(.el-select),
.date-range {
width: 100%;
.status-filter-row,
.search-filter-row {
display: flex;
align-items: stretch;
flex-direction: column;
}
.filter-label {
padding-top: 0;
}
.filter-actions {
display: grid;
grid-template-columns: 1fr 1fr;
:deep(.el-button) {
width: 100%;
}
}
}
</style>
@@ -343,7 +343,7 @@ const AppointmentPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosi
const OrderPanel = defineAsyncComponent(() => import('./components/OrderPanel.vue'))
const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPanel.vue'))
type StatusFilter = '' | 'unconfirmed' | 'booked' | 'completed' | 'missed'
type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
const router = useRouter()
@@ -386,8 +386,8 @@ const formData = reactive({
const statusOptions: Array<{ label: string; value: StatusFilter }> = [
{ label: '全部', value: '' },
{ label: '未确认', value: 'unconfirmed' },
{ label: '已挂号', value: 'booked' },
{ label: '未预约', value: 'unbooked' },
{ label: '待面诊', value: 'pending_interview' },
{ label: '已完成', value: 'completed' },
{ label: '已过号', value: 'missed' }
]
@@ -153,7 +153,23 @@
<h2>{{ dashboard.rankings.appointments.title }}</h2>
<p>{{ appointmentRankingSubtitle }}</p>
</div>
<span class="panel-meta">实时</span>
<div class="panel-header-actions">
<el-tree-select
v-if="dashboard.rankings.appointments.kind !== 'doctor'"
v-model="rankingDeptId"
:data="dashboard.filters.ranking_departments"
:props="departmentTreeProps"
node-key="id"
check-strictly
clearable
filterable
default-expand-all
placeholder="全部可见部门"
class="ranking-dept-select"
@change="handleRankingDeptChange"
/>
<span class="panel-meta">实时</span>
</div>
</div>
<div v-if="dashboard.rankings.appointments.items.length" class="ranking-list">
<div
@@ -363,6 +379,7 @@ const createInitialDashboard = () => ({
today: {
add_fans_count: 0,
appointment_total_count: 0,
low_amount_payment_count: 0,
interview_count: 0,
completed_order_count: 0,
completed_order_amount: 0,
@@ -372,6 +389,7 @@ const createInitialDashboard = () => ({
comparisons: {
add_fans_count: emptyComparison(),
appointment_total_count: emptyComparison(),
low_amount_payment_count: emptyComparison(),
interview_count: emptyComparison(),
completed_order_count: emptyComparison(),
completed_order_amount: emptyComparison(),
@@ -392,6 +410,10 @@ const createInitialDashboard = () => ({
items: [] as RankingItem[],
},
},
filters: {
ranking_departments: [] as any[],
ranking_dept_id: 0,
},
trend: {
date_range: [] as string[],
dates: [] as string[],
@@ -421,8 +443,10 @@ const loading = ref(false)
const loaded = ref(false)
const errorMessage = ref('')
const activeTrend = ref<TrendKey>('appointments')
const rankingDeptId = ref<number | undefined>()
const now = ref(new Date())
let clockTimer: ReturnType<typeof setInterval> | undefined
const departmentTreeProps = { value: 'id', label: 'name', children: 'children' }
const trendOptions = [
{ label: '挂号', value: 'appointments' },
@@ -453,6 +477,13 @@ const todayMetrics = computed(() => [
hint: '状态为已预约、已完成或改期,排除已取消',
comparisons: [dashboard.today.comparisons.appointment_total_count],
},
{
key: 'lowAmountPayments',
label: '今日 10 元及以下收款',
value: `${formatNumber(dashboard.today.low_amount_payment_count)}`,
hint: '实收金额大于 0 且不超过 10 元的已支付订单',
comparisons: [dashboard.today.comparisons.low_amount_payment_count],
},
{
key: 'orders',
label: '今日接诊 / 诊单金额',
@@ -472,7 +503,7 @@ const todayMetrics = computed(() => [
},
{
key: 'receiveRate',
label: '面诊接诊率',
label: '接诊率',
value: formatPercent(dashboard.today.interview_receive_rate),
hint: '诊单数 / 面诊数',
comparisons: [dashboard.today.comparisons.interview_receive_rate],
@@ -621,8 +652,9 @@ const loadDashboard = async () => {
loading.value = true
errorMessage.value = ''
try {
const res: any = await performanceDashboardOverview()
const res: any = await performanceDashboardOverview({ ranking_dept_id: rankingDeptId.value })
Object.assign(dashboard, createInitialDashboard(), res || {})
rankingDeptId.value = Number(dashboard.filters.ranking_dept_id || 0) || undefined
loaded.value = true
} catch (error: any) {
errorMessage.value = error?.msg || error?.message || '驾驶舱数据加载失败,请稍后重试'
@@ -631,6 +663,10 @@ const loadDashboard = async () => {
}
}
const handleRankingDeptChange = () => {
loadDashboard()
}
onMounted(() => {
loadDashboard()
clockTimer = setInterval(() => {
@@ -775,7 +811,7 @@ onBeforeUnmount(() => {
}
.operating-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 14px;
}
@@ -921,6 +957,17 @@ onBeforeUnmount(() => {
}
}
.panel-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ranking-dept-select {
width: 180px;
}
.panel-meta {
padding: 4px 7px;
color: var(--dash-text-muted);
@@ -1088,6 +1135,10 @@ onBeforeUnmount(() => {
.dashboard-grid--bottom {
grid-template-columns: 1fr;
}
.operating-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@@ -1132,6 +1183,15 @@ onBeforeUnmount(() => {
flex-direction: column;
}
.panel-header-actions {
align-items: flex-end;
flex-direction: column;
}
.ranking-dept-select {
width: 150px;
}
.ranking-copy span {
display: none;
}
File diff suppressed because one or more lines are too long