更新
This commit is contained in:
@@ -299,3 +299,41 @@ export function assistantPerformanceRevisitRate(params: {
|
||||
}): Promise<AssistantRevisitRateResult> {
|
||||
return request.get({ url: '/stats.assistantPerformance/revisitRate', params })
|
||||
}
|
||||
|
||||
|
||||
/** 独立指派周期统计,与原 revisitRate 按月报表分别调用。 */
|
||||
export type RevisitRateCohortType = 'new' | 'inherit'
|
||||
|
||||
export interface RevisitRateQuery {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
}
|
||||
|
||||
export interface RevisitRateDetailQuery extends RevisitRateQuery {
|
||||
scope_assistant_id?: number
|
||||
dept_id?: number
|
||||
}
|
||||
|
||||
const revisitCohortPrefix = (cohortType: RevisitRateCohortType) =>
|
||||
cohortType === 'inherit' ? '/stats.revisitInheritRate' : '/stats.revisitAssignRate'
|
||||
|
||||
export function revisitCohortOverview(params: RevisitRateQuery, cohortType: RevisitRateCohortType) {
|
||||
return request.get({ url: `${revisitCohortPrefix(cohortType)}/overview`, params })
|
||||
}
|
||||
|
||||
export function revisitCohortDeptOptions(cohortType: RevisitRateCohortType) {
|
||||
return request.get({ url: `${revisitCohortPrefix(cohortType)}/deptOptions` })
|
||||
}
|
||||
|
||||
export function revisitCohortAssignLines(params: RevisitRateDetailQuery, cohortType: RevisitRateCohortType) {
|
||||
return request.get({ url: `${revisitCohortPrefix(cohortType)}/assignLines`, params })
|
||||
}
|
||||
|
||||
export function revisitCohortVisitOrderLines(
|
||||
params: RevisitRateDetailQuery & { slot: number },
|
||||
cohortType: RevisitRateCohortType
|
||||
) {
|
||||
return request.get({ url: `${revisitCohortPrefix(cohortType)}/visitOrderLines`, params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,901 @@
|
||||
<template>
|
||||
<div class="revisit-rate-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="rate-filter-form">
|
||||
<el-form-item :label="dateLabel">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
range-separator="至"
|
||||
:clearable="false"
|
||||
style="width: 280px"
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="deptId"
|
||||
:data="deptTreeOptions"
|
||||
placeholder="二中心(全部)"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:props="deptTreeProps"
|
||||
style="width: 210px"
|
||||
@change="handleDeptChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="医助">
|
||||
<el-select
|
||||
v-model="assistantId"
|
||||
placeholder="全部医助"
|
||||
filterable
|
||||
clearable
|
||||
:loading="optionsLoading"
|
||||
style="width: 180px"
|
||||
@change="loadData"
|
||||
>
|
||||
<el-option
|
||||
v-for="assistant in assistantOptions"
|
||||
:key="assistant.id"
|
||||
:label="assistant.name"
|
||||
:value="assistant.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popover placement="bottom-start" :width="540" trigger="hover">
|
||||
<template #reference>
|
||||
<span class="rate-caliber-trigger"
|
||||
><el-icon><InfoFilled /></el-icon>统计口径</span
|
||||
>
|
||||
</template>
|
||||
<div class="rate-caliber">
|
||||
<p>
|
||||
<b>{{ cohortLabel }}</b
|
||||
>:按所选{{
|
||||
dateLabel
|
||||
}}范围确定患者,订单从对应指派后累计至查询当天;结束日期允许选择未来日期,但未来订单不会提前计入。
|
||||
</p>
|
||||
<p v-if="isInherit">
|
||||
<b>继承诊次重新起算</b>:继承后的第 1 笔有效订单算二诊,第 2
|
||||
笔算三诊,依次递增;不使用继承前的全局诊次,也不叠加诊次偏移。只统计继承患者,不混入新指派患者。
|
||||
</p>
|
||||
<p v-else>
|
||||
只统计新指派患者,不混入继承患者。继承患者在独立的「继承复诊接诊率」页面统计。
|
||||
</p>
|
||||
<p>
|
||||
<b>N 诊接诊率</b> = N 诊单数 ÷ 本页{{ cohortLabel }};分母为 0
|
||||
显示「—」。<b>复诊合计接诊率</b> = 复诊合计单数 ÷ 本页{{
|
||||
cohortLabel
|
||||
}},表示人均复诊成交单数,可超过 100%。
|
||||
</p>
|
||||
<p>
|
||||
订单仅计入有效业务订单,剔除已取消 / 拒收 /
|
||||
退款;归属按服务端的患者持有记录计算。医助按人事部门归组;仅统计「二中心」及其组织下级,选定部门时包含其下级。
|
||||
</p>
|
||||
<p>
|
||||
部门行和合计行按诊单去重,不能直接将医助行相加。灰字为「单数 /
|
||||
分母」。
|
||||
</p>
|
||||
</div>
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert class="mt-3" type="info" :closable="false" show-icon :title="reportHint" />
|
||||
<el-alert
|
||||
v-if="optionsError"
|
||||
class="mt-3"
|
||||
type="warning"
|
||||
:title="optionsError"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="queryError"
|
||||
class="mt-3"
|
||||
type="error"
|
||||
:title="queryError"
|
||||
:closable="false"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<div class="rate-kpi-grid">
|
||||
<div
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
class="rate-kpi-card"
|
||||
:class="{ 'is-clickable': card.clickable }"
|
||||
@click="card.clickable && card.onClick()"
|
||||
>
|
||||
<div class="rate-kpi-label">{{ card.label }}</div>
|
||||
<div class="rate-kpi-value">{{ card.value }}</div>
|
||||
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="rate-card-header">
|
||||
<span>{{ reportTitle }} · 部门 / 医助明细</span>
|
||||
<span class="rate-card-hint">{{ dateLabel }}:{{ rangeText }}</span>
|
||||
<span v-if="observedUntil" class="rate-card-hint"
|
||||
>订单统计截至:{{ observedUntil }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="rate-card-hint mt-2">
|
||||
点击{{ cohortLabel }}、各诊次单数可查看患者与订单;底栏合计按诊单去重。
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
size="small"
|
||||
border
|
||||
row-key="row_key"
|
||||
:tree-props="{ children: 'children' }"
|
||||
:default-expand-all="true"
|
||||
show-summary
|
||||
:summary-method="getSummaries"
|
||||
>
|
||||
<el-table-column label="部门 / 医助" min-width="200" fixed>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.is_dept" class="rate-dept-cell"
|
||||
>{{ row.dept_name
|
||||
}}<span class="rate-dept-count"
|
||||
>{{ row.assistant_count }} 人</span
|
||||
></span
|
||||
>
|
||||
<span v-else>{{ row.assistant_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:prop="cohortCountKey"
|
||||
:label="cohortLabel"
|
||||
min-width="100"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ cohortLabel }}</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`所选${dateLabel}范围内的${cohortLabel},部门和合计按诊单去重`"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row[cohortCountKey] > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row)"
|
||||
>{{ row[cohortCountKey] }}</el-link
|
||||
>
|
||||
<span v-else>{{ row[cohortCountKey] ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template v-for="slot in visitSlots" :key="slot">
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_count`"
|
||||
:label="`${slotLabel(slot)}单数`"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ slotLabel(slot) }}单数</span>
|
||||
<el-tooltip effect="dark" placement="top" :content="slotCountTip(slot)"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderDetail(slot, row)"
|
||||
>{{ row[`visit${slot}_count`] }}</el-link
|
||||
>
|
||||
<span v-else>{{ row[`visit${slot}_count`] ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_rate`"
|
||||
:label="`${slotLabel(slot)}接诊率`"
|
||||
min-width="110"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ slotLabel(slot) }}接诊率</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`${slotLabel(slot)}单数 ÷ ${cohortLabel};分母为 0 显示「—」`"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="rate-cell">
|
||||
<span class="rate-cell-main">{{
|
||||
formatRate(row[`visit${slot}_rate`])
|
||||
}}</span
|
||||
><span class="rate-cell-sub">{{
|
||||
denText(row, `visit${slot}`)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
<el-table-column
|
||||
prop="visit_all_count"
|
||||
label="复诊合计单数"
|
||||
min-width="110"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>复诊合计单数</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
content="所选患者从对应指派后累计至查询当天的全部复诊订单数"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderLines && row.visit_all_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderDetail(0, row)"
|
||||
>{{ row.visit_all_count }}</el-link
|
||||
>
|
||||
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="visit_all_rate"
|
||||
label="复诊合计接诊率"
|
||||
min-width="128"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>复诊合计接诊率</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`复诊合计单数 ÷ ${cohortLabel};表示人均复诊成交单数,可超过 100%`"
|
||||
><el-icon class="rate-th-hint"><InfoFilled /></el-icon
|
||||
></el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="rate-cell">
|
||||
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span
|
||||
><span class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="detailTitle"
|
||||
width="1080px"
|
||||
top="6vh"
|
||||
destroy-on-close
|
||||
@close="invalidateDetail"
|
||||
>
|
||||
<p class="rate-detail-hint">{{ detailHint }}</p>
|
||||
<el-alert
|
||||
v-if="detailError"
|
||||
class="mb-3"
|
||||
type="error"
|
||||
:title="detailError"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-table
|
||||
v-loading="detailLoading"
|
||||
:data="detailRows"
|
||||
size="small"
|
||||
border
|
||||
stripe
|
||||
max-height="60vh"
|
||||
>
|
||||
<template v-if="detailType === 'assign'">
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
|
||||
>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
|
||||
><template #default="{ row }">{{
|
||||
row.patient_phone || '—'
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
prop="assistant_names"
|
||||
:label="isInherit ? '继承医助' : '被指派医助'"
|
||||
min-width="140"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="assign_count"
|
||||
:label="isInherit ? '范围内继承次数' : '范围内指派次数'"
|
||||
width="120"
|
||||
align="right"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="first_assign_time_text"
|
||||
:label="isInherit ? '首次继承时间' : '首次指派时间'"
|
||||
min-width="160"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="last_assign_time_text"
|
||||
:label="isInherit ? '最近继承时间' : '最近指派时间'"
|
||||
min-width="160"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table-column
|
||||
prop="order_no"
|
||||
label="订单号"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderDetail && row.order_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderInfo(row.order_id)"
|
||||
>{{ row.order_no || `#${row.order_id}` }}</el-link
|
||||
>
|
||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="detailSlot === 0 || isInherit"
|
||||
prop="visit_slot"
|
||||
:label="isInherit ? '继承后诊次' : '诊次'"
|
||||
width="100"
|
||||
align="center"
|
||||
><template #default="{ row }">{{
|
||||
slotLabel(Number(row.visit_slot))
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
v-if="isInherit"
|
||||
prop="global_visit_slot"
|
||||
label="全局诊次(参考)"
|
||||
width="130"
|
||||
align="center"
|
||||
><template #default="{ row }">{{
|
||||
row.global_visit_slot ? slotLabel(Number(row.global_visit_slot)) : '—'
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>{{ row.patient_name || `诊单#${row.diagnosis_id}` }}</el-link
|
||||
>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120"
|
||||
><template #default="{ row }">{{
|
||||
row.patient_phone || '—'
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="amount" label="金额" width="100" align="right"
|
||||
><template #default="{ row }">¥ {{ row.amount }}</template></el-table-column
|
||||
>
|
||||
<el-table-column
|
||||
prop="cohort_assign_time_text"
|
||||
:label="isInherit ? '继承时间' : '指派时间'"
|
||||
min-width="160"
|
||||
/>
|
||||
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
|
||||
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
|
||||
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
|
||||
</template>
|
||||
</el-table>
|
||||
<div class="rate-detail-footer">共 {{ detailCount }} 条</div>
|
||||
</el-dialog>
|
||||
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
|
||||
<diagnosis-edit-popup ref="diagnosisEditRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
revisitCohortAssignLines,
|
||||
revisitCohortDeptOptions,
|
||||
revisitCohortOverview,
|
||||
revisitCohortVisitOrderLines,
|
||||
type RevisitRateCohortType,
|
||||
type RevisitRateDetailQuery,
|
||||
type RevisitRateQuery
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
const props = defineProps<{ cohortType: RevisitRateCohortType }>()
|
||||
const OrderDetailDrawer = defineAsyncComponent(
|
||||
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
|
||||
)
|
||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
interface MetricPack extends Record<string, any> {
|
||||
assigned_count: number
|
||||
inherit_count: number
|
||||
assigned_total_count: number
|
||||
visit_all_count: number
|
||||
visit_all_den: number
|
||||
visit_all_rate: number | null
|
||||
}
|
||||
interface StatsRow extends MetricPack {
|
||||
row_key: string
|
||||
is_dept: 0 | 1
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
assistant_id?: number
|
||||
assistant_name?: string
|
||||
assistant_count?: number
|
||||
children?: StatsRow[]
|
||||
}
|
||||
interface DeptOption {
|
||||
id: number
|
||||
pid: number
|
||||
name: string
|
||||
}
|
||||
interface DeptTreeNode extends DeptOption {
|
||||
children?: DeptTreeNode[]
|
||||
}
|
||||
interface AssistantOption {
|
||||
id: number
|
||||
name: string
|
||||
dept_id: number
|
||||
}
|
||||
interface SummaryCard {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
clickable: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const isInherit = computed(() => props.cohortType === 'inherit')
|
||||
const cohortLabel = computed(() => (isInherit.value ? '继承数' : '新指派数'))
|
||||
const cohortCountKey = computed(() => (isInherit.value ? 'inherit_count' : 'assigned_count'))
|
||||
const dateLabel = computed(() => (isInherit.value ? '继承日期' : '指派日期'))
|
||||
const reportTitle = computed(() => (isInherit.value ? '继承复诊接诊率' : '按指派周期复诊率'))
|
||||
const reportHint = computed(() =>
|
||||
isInherit.value
|
||||
? '按继承日期范围选择患者,订单累计至查询当天。继承后第 1 笔有效订单算二诊,第 2 笔算三诊;忽略继承前全局诊次及偏移,新指派患者另页统计。'
|
||||
: '按新指派日期范围选择患者,订单累计至查询当天,跨月继续累计。继承患者在独立的「继承复诊接诊率」页面统计。'
|
||||
)
|
||||
const permissionPrefix = computed(() =>
|
||||
isInherit.value ? 'stats.revisitInheritRate' : 'stats.revisitAssignRate'
|
||||
)
|
||||
const canViewAssignLines = computed(() => hasPermission([`${permissionPrefix.value}/assignLines`]))
|
||||
const canViewOrderLines = computed(() =>
|
||||
hasPermission([`${permissionPrefix.value}/visitOrderLines`])
|
||||
)
|
||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
const defaultDateRange = (): [string, string] => {
|
||||
const now = new Date()
|
||||
return [formatDate(new Date(now.getFullYear(), now.getMonth(), 1)), formatDate(now)]
|
||||
}
|
||||
const dateRange = ref<[string, string]>(defaultDateRange())
|
||||
const deptId = ref<number | undefined>()
|
||||
const assistantId = ref<number | undefined>()
|
||||
const loading = ref(false)
|
||||
const queryError = ref('')
|
||||
const rows = ref<StatsRow[]>([])
|
||||
const total = ref<MetricPack | null>(null)
|
||||
const visitSlots = ref<number[]>([2, 3, 4])
|
||||
const observedUntil = ref('')
|
||||
/** 下钻只复用已展示报表的查询快照,避免读取正在编辑的筛选。 */
|
||||
const loadedQuery = ref<RevisitRateQuery | null>(null)
|
||||
let overviewRequestId = 0
|
||||
let disposed = false
|
||||
const rangeText = computed(() =>
|
||||
loadedQuery.value
|
||||
? `${loadedQuery.value.start_date} 至 ${loadedQuery.value.end_date}`
|
||||
: `${dateRange.value[0]} 至 ${dateRange.value[1]}`
|
||||
)
|
||||
const currentQuery = (): RevisitRateQuery => ({
|
||||
start_date: dateRange.value[0],
|
||||
end_date: dateRange.value[1],
|
||||
dept_ids: deptId.value ? String(deptId.value) : '',
|
||||
assistant_id: assistantId.value || undefined
|
||||
})
|
||||
|
||||
const deptOptions = ref<DeptOption[]>([])
|
||||
const allAssistants = ref<AssistantOption[]>([])
|
||||
const optionsLoading = ref(false)
|
||||
const optionsError = ref('')
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
const deptTreeOptions = computed<DeptTreeNode[]>(() => {
|
||||
const nodes = new Map<number, DeptTreeNode>()
|
||||
deptOptions.value.forEach((item) => nodes.set(item.id, { ...item, children: [] }))
|
||||
const roots: DeptTreeNode[] = []
|
||||
nodes.forEach((node) => {
|
||||
const parent = nodes.get(node.pid)
|
||||
if (parent && parent !== node) parent.children!.push(node)
|
||||
else roots.push(node)
|
||||
})
|
||||
const stripEmptyChildren = (node: DeptTreeNode) => {
|
||||
if (node.children?.length) node.children.forEach(stripEmptyChildren)
|
||||
else delete node.children
|
||||
}
|
||||
roots.forEach(stripEmptyChildren)
|
||||
return roots
|
||||
})
|
||||
const selectedDeptIds = computed(() => {
|
||||
if (!deptId.value) return null
|
||||
const ids = new Set<number>([Number(deptId.value)])
|
||||
let expanded = true
|
||||
while (expanded) {
|
||||
expanded = false
|
||||
deptOptions.value.forEach((item) => {
|
||||
if (ids.has(item.pid) && !ids.has(item.id)) {
|
||||
ids.add(item.id)
|
||||
expanded = true
|
||||
}
|
||||
})
|
||||
}
|
||||
return ids
|
||||
})
|
||||
const assistantOptions = computed(() =>
|
||||
allAssistants.value.filter(
|
||||
(item) => !selectedDeptIds.value || selectedDeptIds.value.has(item.dept_id)
|
||||
)
|
||||
)
|
||||
const handleDeptChange = () => {
|
||||
if (assistantId.value && !assistantOptions.value.some((item) => item.id === assistantId.value))
|
||||
assistantId.value = undefined
|
||||
void loadData()
|
||||
}
|
||||
const loadDeptOptions = async () => {
|
||||
optionsLoading.value = true
|
||||
optionsError.value = ''
|
||||
try {
|
||||
const res = await revisitCohortDeptOptions(props.cohortType)
|
||||
if (disposed) return
|
||||
deptOptions.value = (res?.rows ?? []).map((item: DeptOption) => ({
|
||||
...item,
|
||||
id: Number(item.id),
|
||||
pid: Number(item.pid)
|
||||
}))
|
||||
allAssistants.value = (res?.assistants ?? []).map((item: AssistantOption) => ({
|
||||
...item,
|
||||
id: Number(item.id),
|
||||
dept_id: Number(item.dept_id)
|
||||
}))
|
||||
} catch {
|
||||
if (!disposed) optionsError.value = '部门及医助选项加载失败,请刷新页面重试。'
|
||||
} finally {
|
||||
if (!disposed) optionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
const slotLabel = (slot: number) => {
|
||||
if (slot === 0) return '复诊合计'
|
||||
if (slot >= 1 && slot <= 10) return `${cnDigits[slot]}诊`
|
||||
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
|
||||
return `${slot}诊`
|
||||
}
|
||||
const slotCountTip = (slot: number) =>
|
||||
isInherit.value
|
||||
? `所选继承患者在继承后的第 ${slot - 1} 笔有效订单数,累计至查询当天;不使用继承前全局诊次和偏移`
|
||||
: `所选新指派患者的${slotLabel(slot)}有效订单数,从对应指派后累计至查询当天`
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
const denText = (row: Record<string, any>, prefix: string) =>
|
||||
`${Number(row[`${prefix}_count`] ?? 0)} / ${row[`${prefix}_den`] ?? '—'}`
|
||||
const summaryCards = computed<SummaryCard[]>(() => {
|
||||
const t = total.value
|
||||
const cards: SummaryCard[] = [
|
||||
{
|
||||
key: 'cohort',
|
||||
label: cohortLabel.value,
|
||||
value: t ? String(t[cohortCountKey.value] ?? 0) : '—',
|
||||
sub: `按${dateLabel.value}范围选择患者`,
|
||||
clickable: !!(canViewAssignLines.value && t && t[cohortCountKey.value] > 0),
|
||||
onClick: () => void openAssignDetail(null)
|
||||
}
|
||||
]
|
||||
visitSlots.value.forEach((slot) =>
|
||||
cards.push({
|
||||
key: `visit${slot}`,
|
||||
label: `${slotLabel(slot)}接诊率`,
|
||||
value: t ? formatRate(t[`visit${slot}_rate`]) : '—',
|
||||
sub: t
|
||||
? `${slotLabel(slot)}下单 ${t[`visit${slot}_count`] ?? 0} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}`
|
||||
: '',
|
||||
clickable: !!(canViewOrderLines.value && t && t[`visit${slot}_count`] > 0),
|
||||
onClick: () => void openOrderDetail(slot, null)
|
||||
})
|
||||
)
|
||||
cards.push({
|
||||
key: 'visit_all',
|
||||
label: '复诊合计接诊率',
|
||||
value: t ? formatRate(t.visit_all_rate) : '—',
|
||||
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 分母 ${t.visit_all_den ?? '—'}` : '',
|
||||
clickable: !!(canViewOrderLines.value && t && t.visit_all_count > 0),
|
||||
onClick: () => void openOrderDetail(0, null)
|
||||
})
|
||||
return cards
|
||||
})
|
||||
/** 服务端已按诊单去重,合计不能由各医助行相加。 */
|
||||
const getSummaries = ({ columns }: { columns: Array<{ property?: string }> }) =>
|
||||
columns.map((column, index) => {
|
||||
if (index === 0) return '合计'
|
||||
const t = total.value
|
||||
if (!t) return ''
|
||||
const prop = column.property || ''
|
||||
if (prop === cohortCountKey.value) return String(t[prop] ?? 0)
|
||||
const match = /^visit(\d+|_all)_(count|rate)$/.exec(prop)
|
||||
if (!match) return ''
|
||||
return match[2] === 'rate'
|
||||
? `${formatRate(t[prop])}(${denText(t, `visit${match[1]}`)})`
|
||||
: String(t[prop] ?? 0)
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
const requestId = ++overviewRequestId
|
||||
invalidateDetail()
|
||||
detailVisible.value = false
|
||||
loadedQuery.value = null
|
||||
rows.value = []
|
||||
total.value = null
|
||||
observedUntil.value = ''
|
||||
queryError.value = ''
|
||||
loading.value = true
|
||||
const query = currentQuery()
|
||||
try {
|
||||
const res = await revisitCohortOverview(query, props.cohortType)
|
||||
if (disposed || requestId !== overviewRequestId) return
|
||||
rows.value = res?.rows ?? []
|
||||
total.value = res?.total ?? null
|
||||
observedUntil.value = res?.observed_until ?? ''
|
||||
const slots: number[] = Array.isArray(res?.slots) ? res.slots.map(Number) : []
|
||||
visitSlots.value = slots.length
|
||||
? [
|
||||
...new Set(
|
||||
slots.filter((slot) => Number.isInteger(slot) && slot >= 2 && slot <= 50)
|
||||
)
|
||||
].sort((a, b) => a - b)
|
||||
: [2, 3, 4]
|
||||
loadedQuery.value = { ...query }
|
||||
} catch {
|
||||
if (!disposed && requestId === overviewRequestId)
|
||||
queryError.value = '报表加载失败,请重新查询。'
|
||||
} finally {
|
||||
if (!disposed && requestId === overviewRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailType = ref<'assign' | 'orders'>('assign')
|
||||
const detailTitle = ref('')
|
||||
const detailHint = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref('')
|
||||
const detailRows = ref<any[]>([])
|
||||
const detailCount = ref(0)
|
||||
const detailSlot = ref(2)
|
||||
let detailRequestId = 0
|
||||
const invalidateDetail = () => {
|
||||
++detailRequestId
|
||||
detailLoading.value = false
|
||||
detailRows.value = []
|
||||
detailCount.value = 0
|
||||
detailError.value = ''
|
||||
}
|
||||
const rowScope = (
|
||||
row: StatsRow | null
|
||||
): Pick<RevisitRateDetailQuery, 'scope_assistant_id' | 'dept_id'> & { label: string } => {
|
||||
if (!row) return { label: '合计' }
|
||||
return row.is_dept
|
||||
? { dept_id: row.dept_id, label: row.dept_name }
|
||||
: { scope_assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||
}
|
||||
const openDetail = async (type: 'assign' | 'orders', slot: number, row: StatsRow | null) => {
|
||||
if (loading.value || !loadedQuery.value) return
|
||||
if (type === 'assign' ? !canViewAssignLines.value : !canViewOrderLines.value) return
|
||||
const query = { ...loadedQuery.value }
|
||||
const { label, ...scope } = rowScope(row)
|
||||
const requestId = ++detailRequestId
|
||||
detailType.value = type
|
||||
detailSlot.value = slot
|
||||
detailTitle.value = `${label} · ${type === 'assign' ? `${cohortLabel.value}明细` : `${slotLabel(slot)}订单明细`}(${query.start_date} 至 ${query.end_date})`
|
||||
detailHint.value = `${dateLabel.value}:${query.start_date} 至 ${query.end_date};订单统计截至:${observedUntil.value || '查询当天'}${isInherit.value && type === 'orders' ? '。继承后首笔有效订单算二诊,全局诊次仅供参考。' : '。'}`
|
||||
detailRows.value = []
|
||||
detailCount.value = 0
|
||||
detailError.value = ''
|
||||
detailLoading.value = true
|
||||
detailVisible.value = true
|
||||
try {
|
||||
// assistant_id 保留顶层筛选,scope_assistant_id 独立约束医助行。
|
||||
const params: RevisitRateDetailQuery = { ...query, ...scope }
|
||||
const res =
|
||||
type === 'assign'
|
||||
? await revisitCohortAssignLines(params, props.cohortType)
|
||||
: await revisitCohortVisitOrderLines({ ...params, slot }, props.cohortType)
|
||||
if (disposed || requestId !== detailRequestId || !detailVisible.value) return
|
||||
detailRows.value = res?.rows ?? []
|
||||
detailCount.value = Number(res?.count ?? detailRows.value.length)
|
||||
} catch {
|
||||
if (!disposed && requestId === detailRequestId)
|
||||
detailError.value = '明细加载失败,请关闭后重试。'
|
||||
} finally {
|
||||
if (!disposed && requestId === detailRequestId) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
const openAssignDetail = (row: StatsRow | null) => openDetail('assign', 0, row)
|
||||
const openOrderDetail = (slot: number, row: StatsRow | null) => openDetail('orders', slot, row)
|
||||
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
|
||||
const openOrderInfo = (orderId: number) => {
|
||||
if (canViewOrderDetail && orderId) orderDetailRef.value?.open(orderId)
|
||||
}
|
||||
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
|
||||
const openPatientDetail = (diagnosisId: number) => {
|
||||
if (canViewPatientDetail && diagnosisId) diagnosisEditRef.value?.openViewOnly(diagnosisId)
|
||||
}
|
||||
const handleReset = () => {
|
||||
dateRange.value = defaultDateRange()
|
||||
deptId.value = undefined
|
||||
assistantId.value = undefined
|
||||
void loadData()
|
||||
}
|
||||
onMounted(() => {
|
||||
void loadDeptOptions()
|
||||
void loadData()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
++overviewRequestId
|
||||
++detailRequestId
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revisit-rate-page {
|
||||
.rate-filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 0;
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.rate-caliber-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
cursor: help;
|
||||
}
|
||||
.rate-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.rate-kpi-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
&.is-clickable {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease;
|
||||
&:hover {
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
}
|
||||
.rate-kpi-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.rate-kpi-value {
|
||||
margin-top: 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.rate-kpi-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
.rate-card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.rate-card-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.rate-dept-cell {
|
||||
font-weight: 600;
|
||||
.rate-dept-count {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
.rate-th-hint {
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
.rate-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.3;
|
||||
.rate-cell-main {
|
||||
font-weight: 500;
|
||||
}
|
||||
.rate-cell-sub {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
.rate-detail-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.rate-detail-footer {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.rate-caliber {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
p + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<cohort-report cohort-type="new" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="revisitAssignRateStatsPage">
|
||||
import CohortReport from './components/CohortReport.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<cohort-report cohort-type="inherit" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="revisitInheritRateStatsPage">
|
||||
import CohortReport from '../revisit_assign_rate/components/CohortReport.vue'
|
||||
</script>
|
||||
Reference in New Issue
Block a user