This commit is contained in:
Your Name
2026-10-17 16:08:24 +08:00
parent c67b3eff69
commit 56f85f7d89
57 changed files with 4170 additions and 57 deletions
+38
View File
@@ -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>