4833 lines
184 KiB
Vue
4833 lines
184 KiB
Vue
<template>
|
||
<div class="yeji-page">
|
||
<header class="yeji-masthead">
|
||
<div class="yeji-masthead__inner">
|
||
<h1 class="yeji-masthead__title">业绩看板</h1>
|
||
<p class="yeji-masthead__desc">渠道 · 部门 · 日期维度的业绩分析</p>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ── 顶部筛选 ────────────────────────────── -->
|
||
<el-card class="yeji-panel filter-card" shadow="never">
|
||
<div class="filter-surface">
|
||
<div class="filter-row filter-row--top">
|
||
<div class="filter-field">
|
||
<span class="lbl">渠道来源</span>
|
||
<el-select
|
||
v-model="selectedChannel"
|
||
filterable
|
||
placeholder="渠道筛选"
|
||
class="filter-select filter-select--channel"
|
||
:loading="channelOptionsLoading"
|
||
@change="loadData"
|
||
>
|
||
<el-option label="全部" value="" />
|
||
<el-option-group
|
||
v-for="g in channelGroups"
|
||
:key="g.group_name"
|
||
:label="g.group_name"
|
||
>
|
||
<el-option
|
||
v-for="ch in g.channels"
|
||
:key="ch.channel_code"
|
||
:label="ch.channel_name"
|
||
:value="ch.channel_code"
|
||
>
|
||
<div class="channel-opt-row">
|
||
<span class="opt-name">{{ ch.channel_name }}</span>
|
||
<span class="opt-count">{{
|
||
ch.customer_count > 0 ? ch.customer_count : ''
|
||
}}</span>
|
||
</div>
|
||
</el-option>
|
||
</el-option-group>
|
||
</el-select>
|
||
</div>
|
||
|
||
<div class="filter-field filter-field--wide">
|
||
<span class="lbl">展示部门</span>
|
||
<div
|
||
class="filter-select filter-select--dept filter-dept-tree-wrap"
|
||
v-loading="deptOptionsLoading"
|
||
>
|
||
<el-tree-select
|
||
v-model="selectedDeptIds"
|
||
:data="deptTreeOptions"
|
||
multiple
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
filterable
|
||
check-strictly
|
||
:render-after-expand="false"
|
||
:default-expand-all="true"
|
||
placeholder="默认:所有「中心」部门"
|
||
class="filter-dept-tree-select"
|
||
node-key="id"
|
||
:props="deptTreeSelectProps"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-field filter-field--date-top">
|
||
<span class="lbl">日期范围</span>
|
||
<el-date-picker
|
||
v-model="customRange"
|
||
type="daterange"
|
||
value-format="YYYY-MM-DD"
|
||
start-placeholder="开始"
|
||
end-placeholder="结束"
|
||
:clearable="true"
|
||
:shortcuts="dateShortcuts"
|
||
class="filter-date-picker"
|
||
/>
|
||
</div>
|
||
|
||
<div class="filter-actions">
|
||
<el-button
|
||
type="primary"
|
||
class="filter-btn filter-btn--query"
|
||
:loading="loading"
|
||
@click="loadData"
|
||
>
|
||
<el-icon class="filter-btn__icon"><Search /></el-icon>
|
||
查询
|
||
</el-button>
|
||
<el-button
|
||
class="filter-btn filter-btn--reset"
|
||
:disabled="loading"
|
||
@click="handleReset"
|
||
>
|
||
<el-icon class="filter-btn__icon"><RefreshRight /></el-icon>
|
||
重置
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="filter-quick">
|
||
<span class="filter-quick__title">快捷日期</span>
|
||
<div class="date-pills" role="group" aria-label="快捷日期">
|
||
<button
|
||
v-for="sc in dateShortcuts"
|
||
:key="sc.text"
|
||
type="button"
|
||
class="date-pill"
|
||
@click="applyDateShortcut(sc)"
|
||
>
|
||
{{ sc.text }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card class="yeji-panel yeji-main-tabs-card" shadow="never">
|
||
<div class="yeji-table-tabs-head">
|
||
<span class="yeji-table-tabs-head__title">表格统计</span>
|
||
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览;「部门 / 排名」列在滚动时保持固定。</span>
|
||
</div>
|
||
<el-tabs v-model="yejiDisplayTab" class="yeji-view-tabs">
|
||
<el-tab-pane label="医生统计" name="doctor" lazy>
|
||
<el-card
|
||
v-if="canViewDoctorDailyStats"
|
||
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
|
||
shadow="never"
|
||
>
|
||
<template #header>
|
||
<div class="doctor-daily-card__head">
|
||
<div class="doctor-daily-card__title-block">
|
||
<span class="doctor-daily-card__title">医生明细</span>
|
||
<span v-if="doctorDailyRangeText" class="doctor-daily-card__range">{{
|
||
doctorDailyRangeText
|
||
}}</span>
|
||
</div>
|
||
<div class="doctor-daily-card__tools">
|
||
<el-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="doctorDailyLoading"
|
||
@click="loadDoctorDailyStats"
|
||
>
|
||
刷新
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<el-table
|
||
v-loading="doctorDailyLoading"
|
||
:data="doctorDailyRows"
|
||
size="small"
|
||
border
|
||
stripe
|
||
class="doctor-daily-table"
|
||
show-summary
|
||
:summary-method="getDoctorDailySummaries"
|
||
>
|
||
<el-table-column prop="doctor_name" label="医生" min-width="100" fixed />
|
||
<el-table-column
|
||
prop="system_prescription_count"
|
||
label="系统开方"
|
||
min-width="88"
|
||
align="right"
|
||
/>
|
||
<el-table-column
|
||
prop="manual_prescription_count"
|
||
label="手动开方"
|
||
min-width="88"
|
||
align="right"
|
||
/>
|
||
<el-table-column prop="deal_amount" label="成交金额" min-width="110" align="right">
|
||
<template #default="{ row }">¥ {{ formatDoctorDailyMoney(row.deal_amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="deal_order_count" label="接诊诊单" min-width="88" align="right" />
|
||
<el-table-column prop="appointment_completed" label="挂号完成" min-width="88" align="right" />
|
||
<el-table-column prop="appointment_missed" label="过号" min-width="72" align="right" />
|
||
<el-table-column prop="appointment_cancelled" label="取消" min-width="72" align="right" />
|
||
</el-table>
|
||
</el-card>
|
||
<el-empty v-else description="当前账号无医生统计权限" />
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="医助排行榜" name="leaderboard" lazy>
|
||
<div
|
||
v-if="leaderboardBlock || leaderboardsLoading"
|
||
class="leaderboards-wrap"
|
||
v-loading="leaderboardsLoading"
|
||
>
|
||
<template v-if="leaderboardBlock">
|
||
<p class="leaderboard-hint">
|
||
{{ leaderboardBlock.range_note }}
|
||
<span class="muted">
|
||
{{ leaderboardBlock.start_date }} ~ {{ leaderboardBlock.end_date }}
|
||
</span>
|
||
</p>
|
||
<div class="tables-wrap">
|
||
<template v-for="lb in leaderboardBlock.leaderboards" :key="lb.dept_id">
|
||
<div v-if="lb.rows.length > 0" class="yeji-table leaderboard-table">
|
||
<div class="yeji-table__header">
|
||
{{ lb.dept_name }} · 医助排行榜
|
||
<span v-if="leaderboardBlock.channel_name" class="leaderboard-sub">
|
||
({{ leaderboardBlock.channel_name }})
|
||
</span>
|
||
</div>
|
||
<div class="yeji-table__scroll" tabindex="0" role="region" aria-label="排行榜,可横向滚动">
|
||
<table class="yeji-table__body yeji-table__body--leaderboard">
|
||
<thead>
|
||
<tr>
|
||
<th class="col-rank">排名</th>
|
||
<th class="col-abbr">部门</th>
|
||
<th class="col-name">姓名</th>
|
||
<th class="col-consult">接诊单数</th>
|
||
<th class="col-deal">接诊诊单</th>
|
||
<th class="col-assign">被指派数</th>
|
||
<th class="col-appointment">预约诊单</th>
|
||
<template v-if="lb.er_center_subtree">
|
||
<th class="col-revisit">复诊合计</th>
|
||
<th
|
||
v-for="slot in leaderboardRevisitSlotColumnsForLb(lb)"
|
||
:key="'lbh-' + lb.dept_id + '-' + slot"
|
||
class="col-revisit-slot"
|
||
>
|
||
{{ yejiRevisitSlotColumnTitle(slot) }}
|
||
</th>
|
||
</template>
|
||
<th>诊金</th>
|
||
<th>接诊率<br /><span class="th-sub">(元/进线)</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="r in lb.rows"
|
||
:key="r.admin_id"
|
||
class="yeji-row--action"
|
||
@click="onLeaderboardAssistantRowClick(r)"
|
||
>
|
||
<td class="col-rank">{{ r.rank }}</td>
|
||
<td class="col-abbr">{{ r.dept_abbr }}</td>
|
||
<td class="col-name">{{ r.name }}</td>
|
||
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
|
||
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_count) }}</td>
|
||
<td class="col-assign">{{ formatLeaderboardInt(r.assign_count ?? 0) }}</td>
|
||
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
|
||
<span
|
||
v-if="Number(r.appointment_count ?? 0) > 0"
|
||
class="yeji-lead-cell--link"
|
||
>{{ formatLeaderboardInt(r.appointment_count ?? 0) }}</span>
|
||
<template v-else>{{
|
||
formatLeaderboardInt(r.appointment_count ?? 0)
|
||
}}</template>
|
||
</td>
|
||
<template v-if="lb.er_center_subtree">
|
||
<td class="col-revisit" @click.stop="onLeaderboardRevisitTotalClick(r)">
|
||
<span
|
||
v-if="Number(r.revisit_count ?? 0) > 0"
|
||
class="yeji-lead-cell--link"
|
||
>{{ formatLeaderboardInt(r.revisit_count ?? 0) }}</span>
|
||
<template v-else>{{
|
||
formatLeaderboardInt(r.revisit_count ?? 0)
|
||
}}</template>
|
||
</td>
|
||
<td
|
||
v-for="slot in leaderboardRevisitSlotColumnsForLb(lb)"
|
||
:key="'lbc-' + r.admin_id + '-' + slot"
|
||
class="col-revisit-slot"
|
||
@click.stop="onLeaderboardRevisitSlotClick(r, slot)"
|
||
>
|
||
<span
|
||
v-if="yejiRevisitSlotAt(r, slot) > 0"
|
||
class="yeji-lead-cell--link"
|
||
>{{ formatLeaderboardInt(yejiRevisitSlotAt(r, slot)) }}</span>
|
||
<template v-else>{{
|
||
formatLeaderboardInt(yejiRevisitSlotAt(r, slot))
|
||
}}</template>
|
||
</td>
|
||
</template>
|
||
<td>{{ formatMoney(r.fee_amount) }}</td>
|
||
<td>{{ formatMoney(r.consult_rate) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="yeji-table__foot yeji-table__foot--left">
|
||
<span>诊金单位:元 · 诊金/接诊诊单/复诊均按「订单创建人 = 该医助」归属,与部门「合计业绩 / 接诊诊单 / 复诊」同口径 · 被指派数与部门业绩表同口径 · 接诊率 = 诊金 ÷ 进线(元/进线)</span>
|
||
<span v-if="lb.er_center_subtree">
|
||
· 复诊列为二中心口径(同一诊单业务单序列第 2 笔起),点击数字查看对应业务订单
|
||
</span>
|
||
<span>
|
||
· 预约诊单:预约日期在统计区间内,状态含已预约/已完成/已过号(不含已取消),医助归属与接诊单数一致(按挂号 assistant_id 优先,否则诊单 assistant_id);各科组展示全部医助。
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div
|
||
v-if="!leaderboardsLoading && leaderboardVisibleTables === 0"
|
||
class="empty-tip leaderboard-empty"
|
||
>
|
||
<el-empty description="当前筛选下暂无医助排行数据" />
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<el-empty
|
||
v-else-if="!leaderboardsLoading"
|
||
class="yeji-charts-empty"
|
||
description="当前筛选下暂无医助排行数据"
|
||
/>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
||
<!-- ── 业绩表(4 张/N 张);目标看板见「目标看板(卡片)」Tab ── -->
|
||
<div v-loading="loading" class="tables-wrap">
|
||
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
||
<el-empty description="暂无数据" />
|
||
</div>
|
||
|
||
<div
|
||
v-for="(tb, idx) in tables"
|
||
:key="idx"
|
||
class="yeji-table"
|
||
>
|
||
<div class="yeji-table__header">
|
||
{{ buildTableTitle(tb) }}
|
||
</div>
|
||
<div class="yeji-table__scroll" tabindex="0" role="region" aria-label="业绩数据表,可横向滚动">
|
||
<table class="yeji-table__body yeji-table__body--data">
|
||
<thead>
|
||
<tr>
|
||
<th class="col-dept">部门</th>
|
||
<th>
|
||
<span>进线数据</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
add_external_contact 事件数,按接待企微成员归属部门;定渠道时须客户带对应企微标签。<br />
|
||
<template v-if="tb.channel_name">
|
||
<b>二中心及其组织下级</b>(与部门 ErCenter 子树一致)在选定渠道下进线计 <b>0</b>。
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th>
|
||
<span>被指派数</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
<b>tcm_diagnosis_assign_log</b> 行数(成功指派:<b>to_assistant_id > 0</b>),**仅按 <b>related_po_create_time</b>** 落在所选区间内(与日志表快照一致;诊单手动指派已写入该字段)。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||
选定渠道时收窄与「{{ tb.channel_name }}」业绩同口径(标签诊单或 channels EXISTS)。<br />
|
||
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>。
|
||
</template>
|
||
<template v-else><br />未选渠道时不限客户来源,与「合计业绩」列渠道无关。</template>
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th>
|
||
<span>复诊合计</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
仅<b>名称含「二中心」</b>的部门及其组织下级:候选订单为有诊单医助的非取消业务单,按患者 <b>create_time</b> 排序,<b>第 1 笔不计</b>;第 2 笔起计入合计,分列复诊2、复诊三…。<b>归属</b>按订单创建人的人事部门,与<b>合计业绩</b>列同口径。非二中心子树部门为 0。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th
|
||
v-for="slot in yejiRevisitSlotColumns"
|
||
:key="'yjv-h-' + slot"
|
||
>
|
||
<span>{{ yejiRevisitSlotColumnTitle(slot) }}</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 280px; line-height: 1.7; font-size: 12px">
|
||
该列统计区间内患者第 <b>{{ slot }}</b> 笔业务单条数(第 1 笔初诊单列不计);归属与合计列相同。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th>
|
||
<span>合计业绩</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 300px; line-height: 1.7; font-size: 12px">
|
||
按订单<b>创建时间</b>统计(剔除履约已取消),金额与<b>接诊诊单</b>列一致:仅按<b>订单创建人</b>的人事部门落在本展示行(含下级)计入,多行命中时取最深的展示部门。与<b>医助排行榜诊金</b>同口径。<br />
|
||
<b>不受渠道筛选影响</b>。与侧栏按部门打开的业务订单列表对齐。<br />
|
||
点击金额可查看对应业务订单明细(侧栏默认「合计业绩」全量列表)。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th v-if="tb.channel_name" class="col-completed col-narrow-head">
|
||
<span class="yeji-th-label">{{ yejiChannelMetricShort('perf') }}</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 340px; line-height: 1.7; font-size: 12px">
|
||
「{{ tb.channel_name }}」渠道业绩(与卡片标题渠道一致)。与「处方订单列表」业绩<b>同口径</b>:按订单
|
||
<b>create_time</b> 归日,剔除履约已取消(4)。<br />
|
||
渠道判定用 EXISTS:该患者有任一挂号 <b>channels 命中字典 + status=3</b>(不限挂号时点)。<br />
|
||
点击金额可查看该部门本渠道订单明细(侧栏「渠道筛选」列表)。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info" aria-label="说明"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th>
|
||
<span>预约诊单</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
已预约 / 已完成 / 已过号(不含已取消),按<b>预约日期</b>落在区间内统计;部门归属医助优先,否则接诊医生;与<b>医助排行榜 · 预约诊单</b>及明细弹窗同口径。<br />
|
||
点击数字可查看每条挂号记录。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th>
|
||
<span>已完成挂号</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
仅<b>已完成</b>(status=3),与 admin「医生预约 / 已完成」列表计数条件一致(appointment_date、诊单未软删);部门归属医助优先,否则接诊医生。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th>
|
||
<span>接诊诊单</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
<<<<<<< HEAD
|
||
<b>业务订单条数</b>(非取消):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ≠ 4</b>;按<b>订单创建人</b>的人事部门落在该部门子树即计入(表格多行命中时取最深的展示部门)。与<b>合计业绩 / 医助排行榜接诊诊单</b>同口径。选定渠道时本列仍为全量。
|
||
=======
|
||
<b>业务订单条数</b>(非取消):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ≠ 4</b>;与列表筛选
|
||
<b>assistant_dept_id</b> 时一致——<b>创建人</b>人事部门优先,无创建人则诊单 <b>医助</b>;落在该部门子树即计入(表格多行命中时取最深的展示部门)。与侧栏勾选「与表格业绩对齐」时的集合可能略有差异。选定渠道时本列仍为全量。
|
||
>>>>>>> master
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th v-if="tb.channel_name" class="col-completed col-narrow-head">
|
||
<span class="yeji-th-label">{{ yejiChannelMetricShort('deal') }}</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
「{{ tb.channel_name }}」渠道成交单数;与渠道业绩同源:区间内 <b>非取消业务订单</b> 条数,按订单
|
||
<b>create_time</b> 归日;渠道判定同业绩列。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info" aria-label="说明"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th v-if="tb.channel_name" class="col-completed col-narrow-head">
|
||
<span class="yeji-th-label">{{ yejiChannelMetricShort('aov') }}</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 280px; line-height: 1.7; font-size: 12px">
|
||
「{{ tb.channel_name }}」渠道客单价 ≈ 渠道业绩 ÷ 渠道成交单。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info" aria-label="说明"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th>
|
||
<span>ROI</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
<template v-if="tb.channel_name">
|
||
有渠道时:<b>合计行 ROI</b> = <b>{{ tb.channel_name }}业绩合计</b>(与上表该渠道业绩列合计一致,<b>不含二中心及其组织下级</b>)÷ <b>投放成本合计</b>。<br />
|
||
部门行 ROI = 该部门 <b>{{ tb.channel_name }}业绩</b> ÷ 该部门 <b>投放成本</b>;<b>二中心子树</b>部门该渠道业绩与进线为 0、投放成本分摊进线计 0,故无 ROI。与「合计业绩」列无关。
|
||
</template>
|
||
<template v-else>
|
||
<b>合计行 ROI</b> = 全表业绩合计(<b>不含二中心子树</b>各部门行业绩)÷ <b>投放成本合计</b>;<br />
|
||
部门行 = 该部门「合计业绩」口径业绩 ÷ 该部门均摊投放成本;<b>二中心子树</b>部门不参与成本分摊(进线占比计 0),故无 ROI。
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
<th>
|
||
<span>投放成本</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||
区间与渠道与账务表一致后得到<b>总投放成本</b>;<b>单进线成本</b> = 总成本 ÷ 各展示部门<b>进线合计</b>;<br />
|
||
各部门行投放成本 = 单进线成本 × 该部门进线(四舍五入尾差并入进线最多的部门)。<b>二中心及其组织下级</b>进线在分摊中计 <b>0</b>(与 ROI 分子扣除范围一致)。
|
||
</div>
|
||
</template>
|
||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="r in tb.rows"
|
||
:key="`${idx}-${r.dept_id}-${r.dept_name}`"
|
||
class="yeji-row--action"
|
||
:class="{
|
||
'yeji-row--unassigned': isUnassignedYejiRow(r),
|
||
'yeji-row--idle': r.dept_id <= 0 && !isUnassignedYejiRow(r),
|
||
}"
|
||
@click="onYejiDeptRowClick(tb, r)"
|
||
>
|
||
<td class="col-dept">{{ r.dept_name }}</td>
|
||
<td @click="onLeadCountCellClick($event, tb, r)">
|
||
<span
|
||
v-if="r.dept_id > 0 && Number(r.lead_count) > 0"
|
||
class="yeji-lead-cell--link"
|
||
>{{ formatInt(r.lead_count) }}</span>
|
||
<template v-else>{{ formatInt(r.lead_count) }}</template>
|
||
</td>
|
||
<td>{{ formatInt(r.assign_count ?? 0) }}</td>
|
||
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
|
||
<span
|
||
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
|
||
class="yeji-lead-cell--link"
|
||
>{{ formatInt(r.revisit_count ?? 0) }}</span>
|
||
<template v-else>{{ formatInt(r.revisit_count ?? 0) }}</template>
|
||
</td>
|
||
<td
|
||
v-for="slot in yejiRevisitSlotColumns"
|
||
:key="'yjv-' + idx + '-' + r.dept_id + '-' + slot"
|
||
@click.stop="onYejiRevisitSlotCellClick(tb, r, slot)"
|
||
>
|
||
<span
|
||
v-if="r.dept_id > 0 && yejiRevisitSlotAt(r, slot) > 0"
|
||
class="yeji-lead-cell--link"
|
||
>{{ formatInt(yejiRevisitSlotAt(r, slot)) }}</span>
|
||
<template v-else>{{ formatInt(yejiRevisitSlotAt(r, slot)) }}</template>
|
||
</td>
|
||
<td>
|
||
<span
|
||
v-if="yejiPerfCellDrawerEligible(tb, r, 'total')"
|
||
class="yeji-lead-cell--link"
|
||
@click.stop="onYejiPerformanceCellClick(tb, r, 'total')"
|
||
>{{ formatMoney(r.performance_amount) }}</span>
|
||
<template v-else>{{ formatMoney(r.performance_amount) }}</template>
|
||
</td>
|
||
<td
|
||
v-if="tb.channel_name"
|
||
class="col-completed"
|
||
>
|
||
<span
|
||
v-if="yejiPerfCellDrawerEligible(tb, r, 'channel')"
|
||
class="yeji-lead-cell--link"
|
||
@click.stop="onYejiPerformanceCellClick(tb, r, 'channel')"
|
||
>{{ formatMoney(r.completed_performance_amount) }}</span>
|
||
<template v-else>{{ formatMoney(r.completed_performance_amount) }}</template>
|
||
</td>
|
||
<td @click.stop="onYejiDeptAppointmentBookedClick(tb, r)">
|
||
<span
|
||
v-if="
|
||
yejiAppointmentBookedCellClickable(r) &&
|
||
Number(r.appointment_booked_count ?? 0) > 0
|
||
"
|
||
class="yeji-lead-cell--link"
|
||
>{{ formatInt(r.appointment_booked_count ?? 0) }}</span>
|
||
<template v-else>{{ formatInt(r.appointment_booked_count ?? 0) }}</template>
|
||
</td>
|
||
<td>{{ formatInt(r.consult_count) }}</td>
|
||
<td>{{ formatInt(r.deal_order_count) }}</td>
|
||
<td v-if="tb.channel_name" class="col-completed">{{ formatInt(r.channel_consult_count) }}</td>
|
||
<td v-if="tb.channel_name" class="col-completed">{{ formatNumber(r.channel_avg_price) }}</td>
|
||
<td :class="roiClass(r.roi)">{{ formatRoi(r.roi) }}</td>
|
||
<td>{{ formatMoney(r.cost_amount) }}</td>
|
||
</tr>
|
||
<tr class="total-row">
|
||
<td class="col-dept">合计</td>
|
||
<td>{{ formatInt(tb.total.lead_count) }}</td>
|
||
<td>{{ formatInt(tb.total.assign_count ?? 0) }}</td>
|
||
<td>{{ formatInt(tb.total.revisit_count ?? 0) }}</td>
|
||
<td
|
||
v-for="slot in yejiRevisitSlotColumns"
|
||
:key="'yjvt-' + idx + '-' + slot"
|
||
>{{ formatInt(yejiRevisitSlotAt(tb.total, slot)) }}</td>
|
||
<td>
|
||
<span
|
||
v-if="yejiPerfTotalRowDrawerEligible(tb, 'total')"
|
||
class="yeji-lead-cell--link"
|
||
@click.stop="onYejiPerformanceTotalRowClick(tb, 'total')"
|
||
>{{ formatMoney(tb.total.performance_amount) }}</span>
|
||
<template v-else>{{ formatMoney(tb.total.performance_amount) }}</template>
|
||
</td>
|
||
<td
|
||
v-if="tb.channel_name"
|
||
class="col-completed"
|
||
>
|
||
<span
|
||
v-if="yejiPerfTotalRowDrawerEligible(tb, 'channel')"
|
||
class="yeji-lead-cell--link"
|
||
@click.stop="onYejiPerformanceTotalRowClick(tb, 'channel')"
|
||
>{{ formatMoney(tb.total.completed_performance_amount) }}</span>
|
||
<template v-else>{{ formatMoney(tb.total.completed_performance_amount) }}</template>
|
||
</td>
|
||
<td>{{ formatInt(tb.total.appointment_booked_count ?? 0) }}</td>
|
||
<td>{{ formatInt(tb.total.consult_count) }}</td>
|
||
<td>{{ formatInt(tb.total.deal_order_count) }}</td>
|
||
<td v-if="tb.channel_name" class="col-completed">{{ formatInt(tb.total.channel_consult_count) }}</td>
|
||
<td v-if="tb.channel_name" class="col-completed">{{ formatNumber(tb.total.channel_avg_price) }}</td>
|
||
<td :class="roiClass(tb.total.roi)">{{ formatRoi(tb.total.roi) }}</td>
|
||
<td>{{ formatMoney(tb.total.cost_amount) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="yeji-table__foot">
|
||
<span>区间:{{ tb.start_date }} ~ {{ tb.end_date }}</span>
|
||
<span v-if="tb.channel_name"> · 渠道:{{ tb.channel_name }}</span>
|
||
<span v-if="tb.total.cost_per_lead != null && tb.total.cost_per_lead > 0">
|
||
· 单进线成本:{{ formatMoney(tb.total.cost_per_lead) }} 元
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="目标看板(卡片)" name="zyyt_matrix" lazy>
|
||
<div v-loading="targetProgressLoading" class="tp-matrix-wrap">
|
||
<template v-if="targetProgressCard">
|
||
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
|
||
<div class="tp-matrix" role="list">
|
||
<div
|
||
v-for="sec in targetProgressCard.sections"
|
||
:key="'tm-' + sec.dept_id"
|
||
class="tp-matrix-card"
|
||
role="listitem"
|
||
>
|
||
<div class="tp-matrix-card__head">
|
||
<div class="tp-matrix-card__titles">
|
||
<span v-if="sec.parent_dept_name" class="tp-matrix-card__parent">{{
|
||
sec.parent_dept_name
|
||
}}</span>
|
||
<span class="tp-matrix-card__name">{{ sec.dept_name }}目标</span>
|
||
</div>
|
||
<span class="tp-matrix-card__target">{{ formatMoneyW(sec.target_amount) }}</span>
|
||
</div>
|
||
<table class="tp-matrix-card__table">
|
||
<tbody>
|
||
<tr>
|
||
<td class="tp-matrix-card__lbl">昨日诊金</td>
|
||
<td class="tp-matrix-card__num">{{ formatInt(sec.yesterday_amount) }}</td>
|
||
</tr>
|
||
<tr v-if="sec.douyin_amount !== null">
|
||
<td class="tp-matrix-card__lbl">抖音总诊金</td>
|
||
<td class="tp-matrix-card__num">{{ formatInt(sec.douyin_amount) }}</td>
|
||
</tr>
|
||
<tr v-if="sec.tuifen_amount !== null">
|
||
<td class="tp-matrix-card__lbl">推粉总诊金</td>
|
||
<td class="tp-matrix-card__num">{{ formatInt(sec.tuifen_amount) }}</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="tp-matrix-card__lbl">总诊金</td>
|
||
<td class="tp-matrix-card__num tp-matrix-card__num--hot">
|
||
{{ formatInt(sec.total_amount) }}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="tp-matrix-card__lbl">目标进度</td>
|
||
<td class="tp-matrix-card__num">{{ formatMoney(sec.target_progress) }}</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="tp-matrix-card__lbl">完成进度</td>
|
||
<td
|
||
class="tp-matrix-card__num tp-matrix-card__num--hot"
|
||
:class="achievementClass(sec.completion_rate)"
|
||
>
|
||
{{ formatAchievement(sec.completion_rate) }}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<el-empty
|
||
v-else-if="!targetProgressLoading"
|
||
class="tp-matrix-empty"
|
||
description="暂无目标数据,请先配置当月部门目标或为部门设置诊金目标后刷新页面"
|
||
/>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</el-card>
|
||
|
||
<el-card class="yeji-panel yeji-charts-only-card" shadow="never">
|
||
<div class="yeji-charts-only-head">统计图</div>
|
||
<div
|
||
v-loading="loading || (canViewDoctorDailyStats && doctorDailyLoading) || leaderboardsLoading"
|
||
class="yeji-charts-stack"
|
||
>
|
||
<template v-if="chartsHasAnyData">
|
||
<section
|
||
v-if="
|
||
canViewDoctorDailyStats &&
|
||
(doctorDealBarHasData ||
|
||
doctorRxStackHasData ||
|
||
doctorAppointmentPieHasData ||
|
||
doctorScatterHasData ||
|
||
doctorRadarHasData)
|
||
"
|
||
class="yeji-chart-block"
|
||
>
|
||
<h3 class="yeji-chart-block__title">
|
||
医生统计
|
||
<span v-if="doctorDailyRangeText" class="yeji-chart-block__sub">{{
|
||
doctorDailyRangeText
|
||
}}</span>
|
||
</h3>
|
||
<el-row :gutter="16">
|
||
<el-col :xs="24" :lg="9">
|
||
<div class="yeji-chart-panel">
|
||
<div class="yeji-chart-panel__label">成交金额 TOP(条形)</div>
|
||
<v-charts
|
||
v-if="doctorDealBarHasData"
|
||
class="yeji-echart"
|
||
:option="doctorDealBarOption"
|
||
autoresize
|
||
/>
|
||
<el-empty
|
||
v-else
|
||
description="暂无成交金额数据"
|
||
:image-size="72"
|
||
/>
|
||
</div>
|
||
</el-col>
|
||
<el-col :xs="24" :lg="8">
|
||
<div class="yeji-chart-panel">
|
||
<div class="yeji-chart-panel__label">开方构成(堆叠柱)</div>
|
||
<v-charts
|
||
v-if="doctorRxStackHasData"
|
||
class="yeji-echart yeji-echart--short"
|
||
:option="doctorRxStackOption"
|
||
autoresize
|
||
/>
|
||
<el-empty
|
||
v-else
|
||
description="暂无开方数据"
|
||
:image-size="72"
|
||
/>
|
||
</div>
|
||
</el-col>
|
||
<el-col :xs="24" :lg="7">
|
||
<div class="yeji-chart-panel">
|
||
<div class="yeji-chart-panel__label">挂号结果(环形 · 表尾合计)</div>
|
||
<v-charts
|
||
v-if="doctorAppointmentPieHasData"
|
||
class="yeji-echart yeji-echart--short"
|
||
:option="doctorAppointmentPieOption"
|
||
autoresize
|
||
/>
|
||
<el-empty
|
||
v-else
|
||
description="暂无挂号汇总"
|
||
:image-size="72"
|
||
/>
|
||
</div>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16" class="yeji-chart-subrow">
|
||
<el-col :xs="24" :lg="12">
|
||
<div class="yeji-chart-panel">
|
||
<div class="yeji-chart-panel__label">接诊诊单 × 金额(散点)</div>
|
||
<v-charts
|
||
v-if="doctorScatterHasData"
|
||
class="yeji-echart yeji-echart--compact"
|
||
:option="doctorScatterOption"
|
||
autoresize
|
||
/>
|
||
<el-empty
|
||
v-else
|
||
description="至少两名医生同时有接诊诊单与金额时可展示"
|
||
:image-size="72"
|
||
/>
|
||
</div>
|
||
</el-col>
|
||
<el-col :xs="24" :lg="12">
|
||
<div class="yeji-chart-panel">
|
||
<div class="yeji-chart-panel__label">TOP 医生雷达(相对最高值归一)</div>
|
||
<v-charts
|
||
v-if="doctorRadarHasData"
|
||
class="yeji-echart yeji-echart--compact"
|
||
:option="doctorRadarOption"
|
||
autoresize
|
||
/>
|
||
<el-empty
|
||
v-else
|
||
description="至少两名医生有数据时可展示"
|
||
:image-size="72"
|
||
/>
|
||
</div>
|
||
</el-col>
|
||
</el-row>
|
||
</section>
|
||
|
||
<template v-for="(tb, cidx) in tables" :key="'dept-chart-' + cidx">
|
||
<section v-if="yejiDeptChartHasData(tb)" class="yeji-chart-block">
|
||
<h3 class="yeji-chart-block__title">{{ buildTableTitle(tb) }}</h3>
|
||
<v-charts
|
||
class="yeji-echart yeji-echart--tall"
|
||
:option="getYejiDeptComboOption(tb)"
|
||
autoresize
|
||
/>
|
||
<el-row
|
||
v-if="deptFunnelHasData(tb) || deptPerformancePieHasData(tb)"
|
||
:gutter="16"
|
||
class="yeji-chart-subrow"
|
||
>
|
||
<el-col v-if="deptFunnelHasData(tb)" :xs="24" :md="12">
|
||
<div class="yeji-chart-panel">
|
||
<div class="yeji-chart-panel__label">
|
||
进线 → 接诊 → 成交(汇总漏斗)
|
||
</div>
|
||
<v-charts
|
||
class="yeji-echart yeji-echart--compact"
|
||
:option="getDeptFunnelOption(tb)"
|
||
autoresize
|
||
/>
|
||
</div>
|
||
</el-col>
|
||
<el-col v-if="deptPerformancePieHasData(tb)" :xs="24" :md="12">
|
||
<div class="yeji-chart-panel">
|
||
<div class="yeji-chart-panel__label">
|
||
部门合计业绩占比(TOP10 + 其它)
|
||
</div>
|
||
<v-charts
|
||
class="yeji-echart yeji-echart--compact"
|
||
:option="getDeptPerformancePieOption(tb)"
|
||
autoresize
|
||
/>
|
||
</div>
|
||
</el-col>
|
||
</el-row>
|
||
</section>
|
||
</template>
|
||
|
||
<template v-if="leaderboardBlock">
|
||
<section
|
||
v-for="lb in leaderboardChartBlocks"
|
||
:key="'lb-chart-' + lb.dept_id"
|
||
class="yeji-chart-block"
|
||
>
|
||
<h3 class="yeji-chart-block__title">
|
||
{{ lb.dept_name }} · 医助
|
||
<span
|
||
v-if="leaderboardBlock.channel_name"
|
||
class="yeji-chart-block__sub"
|
||
>
|
||
({{ leaderboardBlock.channel_name }})
|
||
</span>
|
||
<span class="yeji-chart-block__sub yeji-chart-block__sub--muted">
|
||
{{ leaderboardBlock.start_date }}~{{ leaderboardBlock.end_date }}
|
||
</span>
|
||
</h3>
|
||
<el-row :gutter="16">
|
||
<el-col :xs="24" :lg="leaderboardPieHasData(lb) ? 14 : 24">
|
||
<v-charts
|
||
class="yeji-echart"
|
||
:option="getLeaderboardBarOption(lb, leaderboardBlock)"
|
||
autoresize
|
||
/>
|
||
</el-col>
|
||
<el-col v-if="leaderboardPieHasData(lb)" :xs="24" :lg="10">
|
||
<div class="yeji-chart-panel">
|
||
<div class="yeji-chart-panel__label">诊金占比</div>
|
||
<v-charts
|
||
class="yeji-echart yeji-echart--compact"
|
||
:option="getLeaderboardPieOption(lb, leaderboardBlock)"
|
||
autoresize
|
||
/>
|
||
</div>
|
||
</el-col>
|
||
</el-row>
|
||
</section>
|
||
</template>
|
||
</template>
|
||
<el-empty
|
||
v-else-if="
|
||
!loading &&
|
||
(!canViewDoctorDailyStats || !doctorDailyLoading) &&
|
||
!leaderboardsLoading
|
||
"
|
||
class="yeji-charts-empty"
|
||
description="当前筛选下暂无图表数据,请调整条件后查询"
|
||
/>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 末级部门 / 医助排行:侧栏业务订单(不跳转路由) -->
|
||
<el-drawer
|
||
v-model="orderDrawerVisible"
|
||
direction="rtl"
|
||
size="min(1120px, 98vw)"
|
||
destroy-on-close
|
||
class="yeji-order-drawer"
|
||
:show-close="true"
|
||
>
|
||
<template #header>
|
||
<div class="yeji-order-drawer__header">
|
||
<h3 class="yeji-order-drawer__heading">{{ orderDrawerTitle }}</h3>
|
||
<p class="yeji-order-drawer__sub">{{ orderDrawerHint }}</p>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="yeji-order-drawer__shell">
|
||
<section
|
||
class="yeji-order-drawer__stats"
|
||
role="region"
|
||
aria-label="订单汇总"
|
||
>
|
||
<div class="yeji-order-drawer__stat-card">
|
||
<span class="yeji-order-drawer__stat-label">符合条件的订单</span>
|
||
<span class="yeji-order-drawer__stat-value">{{ orderDrawerCount.toLocaleString('zh-CN') }} 笔</span>
|
||
</div>
|
||
<div class="yeji-order-drawer__stat-card">
|
||
<span class="yeji-order-drawer__stat-label">
|
||
约诊
|
||
<span class="yeji-order-drawer__stat-note">(已完成挂号;日期按挂号 appointment_date;医助=挂号或诊单,与看板接诊一致)</span>
|
||
</span>
|
||
<span class="yeji-order-drawer__stat-value">{{
|
||
orderDrawerConsultCount === null ? '—' : orderDrawerConsultCount.toLocaleString('zh-CN')
|
||
}} 次</span>
|
||
</div>
|
||
<template v-if="orderDrawerChannelSplitActive && orderDrawerSplitPerfCache">
|
||
<div class="yeji-order-drawer__stat-card yeji-order-drawer__stat-card--total">
|
||
<span class="yeji-order-drawer__stat-label">
|
||
合计业绩
|
||
<span class="yeji-order-drawer__stat-note"
|
||
>(与看板「合计业绩」列同口径,不限渠道;{{ orderDrawerSplitPerfCache.totalOrderCount.toLocaleString('zh-CN') }} 笔)</span
|
||
>
|
||
</span>
|
||
<span class="yeji-order-drawer__stat-value yeji-order-drawer__stat-value--money">
|
||
¥{{ formatOrderDrawerMoney(orderDrawerSplitPerfCache.totalAmount) }}
|
||
</span>
|
||
</div>
|
||
<div class="yeji-order-drawer__stat-card yeji-order-drawer__stat-card--accent">
|
||
<span class="yeji-order-drawer__stat-label">
|
||
{{ orderDrawerChannelLabel }}业绩
|
||
<span class="yeji-order-drawer__stat-note"
|
||
>(与看板「{{ orderDrawerChannelLabel }}业绩」同口径;{{
|
||
orderDrawerSplitPerfCache.channelOrderCount.toLocaleString('zh-CN')
|
||
}} 笔)</span
|
||
>
|
||
</span>
|
||
<span class="yeji-order-drawer__stat-value yeji-order-drawer__stat-value--money">
|
||
¥{{ formatOrderDrawerMoney(orderDrawerChannelStatAmount) }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
<div
|
||
v-else
|
||
class="yeji-order-drawer__stat-card yeji-order-drawer__stat-card--accent"
|
||
>
|
||
<span class="yeji-order-drawer__stat-label">
|
||
业务订单金额合计
|
||
<span class="yeji-order-drawer__stat-note">(不含履约已取消,与上方业绩口径一致)</span>
|
||
</span>
|
||
<span class="yeji-order-drawer__stat-value yeji-order-drawer__stat-value--money">
|
||
¥{{ formatOrderDrawerMoney(orderDrawerTotalAmount) }}
|
||
</span>
|
||
</div>
|
||
<div
|
||
v-if="orderDrawerLists.length > 0"
|
||
class="yeji-order-drawer__stat-card yeji-order-drawer__stat-card--muted"
|
||
>
|
||
<span class="yeji-order-drawer__stat-label">本页小计</span>
|
||
<span class="yeji-order-drawer__stat-value">¥{{ formatOrderDrawerMoney(orderDrawerPageAmountSum) }}</span>
|
||
</div>
|
||
</section>
|
||
|
||
<el-tabs
|
||
v-if="orderDrawerChannelSplitActive"
|
||
v-model="orderDrawerListTab"
|
||
class="yeji-order-drawer__list-tabs"
|
||
@tab-change="onOrderDrawerListTabChange"
|
||
>
|
||
<el-tab-pane label="合计业绩(全量订单)" name="total" />
|
||
<el-tab-pane :label="`渠道筛选(${orderDrawerChannelLabel})`" name="channel" />
|
||
</el-tabs>
|
||
|
||
<div v-loading="orderDrawerLoading" class="yeji-order-drawer__body">
|
||
<el-table
|
||
v-if="orderDrawerLists.length > 0 || orderDrawerLoading"
|
||
:data="orderDrawerLists"
|
||
size="small"
|
||
stripe
|
||
border
|
||
max-height="58vh"
|
||
class="yeji-order-drawer__table"
|
||
:empty-text="orderDrawerLoading ? '加载中…' : '暂无数据'"
|
||
>
|
||
<el-table-column type="index" label="#" width="46" :index="orderDrawerIndexMethod" />
|
||
<el-table-column prop="order_no" label="订单号" min-width="152" show-overflow-tooltip />
|
||
<el-table-column label="关联挂号" min-width="218">
|
||
<template #default="{ row }">
|
||
<div v-if="row.linked_appointment" class="yeji-order-appt-lines">
|
||
<div class="yeji-order-appt-line">
|
||
<span class="yeji-order-appt-k">日期</span>
|
||
{{ orderDrawerApptSchedule(row.linked_appointment) }}
|
||
</div>
|
||
<div class="yeji-order-appt-line">
|
||
<span class="yeji-order-appt-k">状态</span>
|
||
{{ row.linked_appointment.status_desc || '—' }}
|
||
<span
|
||
v-if="row.linked_appointment.doctor_name"
|
||
class="yeji-order-appt-sub"
|
||
>
|
||
· {{ row.linked_appointment.doctor_name }}
|
||
</span>
|
||
</div>
|
||
<div class="yeji-order-appt-line">
|
||
<span class="yeji-order-appt-k">类型</span>
|
||
{{ row.linked_appointment.appointment_type_desc || '—' }}
|
||
</div>
|
||
<div class="yeji-order-appt-line yeji-order-appt-line--channel">
|
||
<span class="yeji-order-appt-k">渠道</span>
|
||
<span class="yeji-order-appt-channel-text">
|
||
{{
|
||
row.linked_appointment.channel_source_desc ||
|
||
row.linked_appointment.channel_source ||
|
||
'—'
|
||
}}
|
||
<span
|
||
v-if="
|
||
orderDrawerLinkedApptChannelDictValue(row.linked_appointment) !==
|
||
''
|
||
"
|
||
class="yeji-order-appt-sub"
|
||
>
|
||
(字典 value
|
||
{{ orderDrawerLinkedApptChannelDictValue(row.linked_appointment) }}
|
||
)
|
||
</span>
|
||
</span>
|
||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||
<template #content>
|
||
<div style="max-width: 280px; line-height: 1.65; font-size: 12px">
|
||
「字典 value」= 系统字典
|
||
<b>channels</b> 的 value,与业绩表里按渠道收窄时所用<b>挂号 channels</b>一致;便于和进线列所选渠道对照,<b>不是</b>企微
|
||
add_external_contact 单上的独立编号。
|
||
</div>
|
||
</template>
|
||
<el-icon class="yeji-order-appt-info" aria-label="说明"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
<span v-else class="yeji-order-appt-empty">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="金额" width="108" align="right">
|
||
<template #default="{ row }">
|
||
<span class="yeji-order-amt">¥{{ row.amount ?? '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="recipient_name" label="收货人" width="96" show-overflow-tooltip />
|
||
<el-table-column label="履约" width="104" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
{{ orderFulfillmentBrief(row.fulfillment_status) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" min-width="148">
|
||
<template #default="{ row }">
|
||
{{ formatOrderDrawerTime(row.create_time) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="doctor_name" label="开方人" width="88" show-overflow-tooltip />
|
||
<el-table-column prop="assistant_name" label="诊单医助" width="88" show-overflow-tooltip />
|
||
<el-table-column prop="creator_name" label="创建人" width="88" show-overflow-tooltip />
|
||
</el-table>
|
||
<el-empty v-if="!orderDrawerLoading && orderDrawerLists.length === 0" description="暂无业务订单" />
|
||
|
||
<div class="yeji-order-drawer__pager">
|
||
<el-pagination
|
||
:current-page="orderDrawerPage"
|
||
:page-size="orderDrawerPageSize"
|
||
background
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
:total="orderDrawerCount"
|
||
:page-sizes="[10, 15, 20, 50]"
|
||
:disabled="orderDrawerLoading"
|
||
:hide-on-single-page="false"
|
||
@current-change="onOrderDrawerPageChange"
|
||
@size-change="onOrderDrawerPageSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-drawer>
|
||
|
||
<el-dialog
|
||
v-model="unassignedDialogVisible"
|
||
width="min(680px, 94vw)"
|
||
destroy-on-close
|
||
class="yeji-unassigned-dialog"
|
||
>
|
||
<template #header>
|
||
<div class="yeji-unassigned-dialog__head">
|
||
<span class="yeji-unassigned-dialog__title">「未归属中心」业绩拆解</span>
|
||
<p class="yeji-unassigned-dialog__sub">{{ unassignedDialogSubtitle }}</p>
|
||
</div>
|
||
</template>
|
||
<p v-if="unassignedDialogNote" class="yeji-unassigned-dialog__note">{{ unassignedDialogNote }}</p>
|
||
<div v-loading="unassignedDialogLoading" class="yeji-unassigned-dialog__body">
|
||
<el-table
|
||
v-if="unassignedDialogRows.length > 0 || unassignedDialogLoading"
|
||
:data="unassignedDialogRows"
|
||
size="small"
|
||
stripe
|
||
border
|
||
max-height="440"
|
||
class="yeji-unassigned-dialog__table"
|
||
:empty-text="unassignedDialogLoading ? '加载中…' : '暂无数据'"
|
||
>
|
||
<el-table-column prop="name" label="诊单医助" min-width="120" show-overflow-tooltip />
|
||
<el-table-column label="账号" width="88" align="right">
|
||
<template #default="{ row }">
|
||
{{ row.admin_id > 0 ? row.admin_id : '—' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="订单数" width="88" align="right">
|
||
<template #default="{ row }">{{ formatInt(row.order_count) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="业绩金额" min-width="112" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty
|
||
v-if="!unassignedDialogLoading && unassignedDialogRows.length === 0"
|
||
description="暂无拆解数据"
|
||
/>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="leadLinesDialogVisible"
|
||
width="min(920px, 96vw)"
|
||
destroy-on-close
|
||
class="yeji-leadlines-dialog"
|
||
>
|
||
<template #header>
|
||
<div class="yeji-unassigned-dialog__head">
|
||
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
|
||
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
|
||
</div>
|
||
</template>
|
||
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
|
||
<div v-loading="leadLinesLoading" class="yeji-unassigned-dialog__body">
|
||
<el-table
|
||
v-if="leadLinesRows.length > 0 || leadLinesLoading"
|
||
:data="leadLinesRows"
|
||
size="small"
|
||
stripe
|
||
border
|
||
max-height="440"
|
||
class="yeji-unassigned-dialog__table"
|
||
:empty-text="leadLinesLoading ? '加载中…' : '暂无数据'"
|
||
>
|
||
<el-table-column prop="event_time_text" label="进线时间" min-width="156" show-overflow-tooltip />
|
||
<el-table-column prop="reception_admin_name" label="接待" width="88" show-overflow-tooltip />
|
||
<el-table-column prop="external_contact_name" label="客户" min-width="100" show-overflow-tooltip />
|
||
<el-table-column prop="external_userid" label="外部联系人ID" min-width="140" show-overflow-tooltip />
|
||
<el-table-column prop="user_id" label="企微成员ID" min-width="120" show-overflow-tooltip />
|
||
<el-table-column prop="state" label="渠道参数" width="96" show-overflow-tooltip />
|
||
</el-table>
|
||
<el-empty
|
||
v-if="!leadLinesLoading && leadLinesRows.length === 0"
|
||
description="暂无进线明细"
|
||
/>
|
||
<div v-if="leadLinesCount > 0" class="yeji-leadlines-dialog__pager">
|
||
<el-pagination
|
||
:current-page="leadLinesPage"
|
||
:page-size="leadLinesPageSize"
|
||
background
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
:total="leadLinesCount"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
:disabled="leadLinesLoading"
|
||
:hide-on-single-page="false"
|
||
@current-change="onLeadLinesPageChange"
|
||
@size-change="onLeadLinesPageSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="appointmentLinesDialogVisible"
|
||
width="min(960px, 96vw)"
|
||
destroy-on-close
|
||
class="yeji-leadlines-dialog"
|
||
>
|
||
<template #header>
|
||
<div class="yeji-unassigned-dialog__head">
|
||
<span class="yeji-unassigned-dialog__title">预约诊单 · 挂号明细</span>
|
||
<p class="yeji-unassigned-dialog__sub">{{ appointmentLinesSubtitle }}</p>
|
||
</div>
|
||
</template>
|
||
<p v-if="appointmentLinesApiNote" class="yeji-unassigned-dialog__note">{{ appointmentLinesApiNote }}</p>
|
||
<div v-loading="appointmentLinesLoading" class="yeji-unassigned-dialog__body">
|
||
<el-table
|
||
v-if="appointmentLinesRows.length > 0 || appointmentLinesLoading"
|
||
:data="appointmentLinesRows"
|
||
size="small"
|
||
stripe
|
||
border
|
||
max-height="440"
|
||
class="yeji-unassigned-dialog__table"
|
||
:empty-text="appointmentLinesLoading ? '加载中…' : '暂无数据'"
|
||
>
|
||
<el-table-column type="index" label="#" width="46" :index="appointmentLinesIndexMethod" />
|
||
<el-table-column prop="appointment_date" label="预约日期" width="108" />
|
||
<el-table-column prop="appointment_time" label="时段" min-width="96" show-overflow-tooltip />
|
||
<el-table-column prop="status_desc" label="状态" width="84" />
|
||
<el-table-column prop="appointment_type_desc" label="类型" width="92" />
|
||
<el-table-column prop="patient_name" label="患者" min-width="92" show-overflow-tooltip />
|
||
<el-table-column prop="patient_phone" label="手机" min-width="116" show-overflow-tooltip />
|
||
<el-table-column prop="doctor_name" label="接诊医生" width="92" show-overflow-tooltip />
|
||
<el-table-column label="诊单ID" width="84" align="right">
|
||
<template #default="{ row }">{{ formatInt(row.diagnosis_id ?? 0) }}</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
v-if="appointmentLinesShowChannelColumn"
|
||
prop="channel_source"
|
||
label="渠道来源"
|
||
width="96"
|
||
show-overflow-tooltip
|
||
/>
|
||
</el-table>
|
||
<el-empty
|
||
v-if="!appointmentLinesLoading && appointmentLinesRows.length === 0"
|
||
description="暂无挂号记录"
|
||
/>
|
||
<div v-if="appointmentLinesCount > 0" class="yeji-leadlines-dialog__pager">
|
||
<el-pagination
|
||
:current-page="appointmentLinesPage"
|
||
:page-size="appointmentLinesPageSize"
|
||
background
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
:total="appointmentLinesCount"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
:disabled="appointmentLinesLoading"
|
||
:hide-on-single-page="false"
|
||
@current-change="onAppointmentLinesPageChange"
|
||
@size-change="onAppointmentLinesPageSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="revisitBreakdownVisible"
|
||
width="min(560px, 94vw)"
|
||
destroy-on-close
|
||
class="yeji-unassigned-dialog"
|
||
>
|
||
<template #header>
|
||
<div class="yeji-unassigned-dialog__head">
|
||
<span class="yeji-unassigned-dialog__title">复诊 · 医助拆解</span>
|
||
<p class="yeji-unassigned-dialog__sub">{{ revisitBreakdownSubtitle }}</p>
|
||
</div>
|
||
</template>
|
||
<p v-if="revisitBreakdownNote" class="yeji-unassigned-dialog__note">{{ revisitBreakdownNote }}</p>
|
||
<div v-loading="revisitBreakdownLoading" class="yeji-unassigned-dialog__body">
|
||
<el-table
|
||
v-if="revisitBreakdownRows.length > 0 || revisitBreakdownLoading"
|
||
:data="revisitBreakdownRows"
|
||
size="small"
|
||
stripe
|
||
border
|
||
max-height="440"
|
||
class="yeji-unassigned-dialog__table"
|
||
:empty-text="revisitBreakdownLoading ? '加载中…' : '暂无数据'"
|
||
>
|
||
<el-table-column prop="name" label="医助" min-width="120" show-overflow-tooltip />
|
||
<el-table-column label="业务订单" width="108" align="right">
|
||
<template #default="{ row }">
|
||
<span
|
||
v-if="Number(row.order_count) > 0"
|
||
class="yeji-lead-cell--link"
|
||
role="button"
|
||
tabindex="0"
|
||
@click="onYejiRevisitBreakdownOrderClick(row)"
|
||
@keydown.enter.prevent="onYejiRevisitBreakdownOrderClick(row)"
|
||
>{{ formatInt(row.order_count) }} 笔</span>
|
||
<template v-else>{{ formatInt(row.order_count) }}</template>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty
|
||
v-if="!revisitBreakdownLoading && revisitBreakdownRows.length === 0"
|
||
description="暂无拆解数据"
|
||
/>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import vCharts from 'vue-echarts'
|
||
import {
|
||
doctorDailyStatsOverview,
|
||
yejiStatsChannelOptions,
|
||
yejiStatsDeptOptions,
|
||
yejiStatsLeaderboard,
|
||
yejiStatsMulti,
|
||
yejiStatsOverview,
|
||
yejiStatsUnassignedBreakdown,
|
||
yejiStatsLeadLines,
|
||
yejiStatsAppointmentLines,
|
||
yejiStatsRevisitBreakdown,
|
||
} from '@/api/stats'
|
||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||
import { prescriptionOrderLists } from '@/api/tcm'
|
||
import useUserStore from '@/stores/modules/user'
|
||
|
||
interface DeptOption {
|
||
id: number
|
||
name: string
|
||
pid: number
|
||
path: string
|
||
}
|
||
|
||
interface DeptTreeNode extends DeptOption {
|
||
children?: DeptTreeNode[]
|
||
}
|
||
|
||
/** 与组织树一致:value 为部门 id,树形展示上下级 */
|
||
const deptTreeSelectProps = {
|
||
value: 'id',
|
||
label: 'name',
|
||
children: 'children',
|
||
}
|
||
|
||
function buildDeptTreeFromFlat(flat: DeptOption[]): DeptTreeNode[] {
|
||
if (!flat.length) {
|
||
return []
|
||
}
|
||
const map = new Map<number, DeptTreeNode>()
|
||
for (const d of flat) {
|
||
map.set(d.id, { ...d, children: [] })
|
||
}
|
||
const roots: DeptTreeNode[] = []
|
||
for (const d of flat) {
|
||
const node = map.get(d.id)!
|
||
const pid = Number(d.pid)
|
||
if (pid > 0 && map.has(pid)) {
|
||
map.get(pid)!.children!.push(node)
|
||
} else {
|
||
roots.push(node)
|
||
}
|
||
}
|
||
const stripEmptyChildren = (n: DeptTreeNode) => {
|
||
if (n.children?.length) {
|
||
n.children.forEach(stripEmptyChildren)
|
||
} else {
|
||
delete n.children
|
||
}
|
||
}
|
||
roots.forEach(stripEmptyChildren)
|
||
const sortLevel = (nodes: DeptTreeNode[]) => {
|
||
nodes.sort((a, b) => {
|
||
const ac = a.name.includes('中心') ? -1 : 1
|
||
const bc = b.name.includes('中心') ? -1 : 1
|
||
if (ac !== bc) {
|
||
return ac - bc
|
||
}
|
||
return a.id - b.id
|
||
})
|
||
for (const n of nodes) {
|
||
if (n.children?.length) {
|
||
sortLevel(n.children)
|
||
}
|
||
}
|
||
}
|
||
sortLevel(roots)
|
||
return roots
|
||
}
|
||
|
||
interface ChannelOption {
|
||
channel_code: string
|
||
channel_name: string
|
||
source_tag_id: string
|
||
source_group_name: string
|
||
customer_count: number
|
||
}
|
||
|
||
interface ChannelGroup {
|
||
group_name: string
|
||
channels: ChannelOption[]
|
||
}
|
||
|
||
interface YejiUnassignedBreakdownRow {
|
||
admin_id: number
|
||
name: string
|
||
order_count: number
|
||
amount: number
|
||
}
|
||
|
||
interface YejiRevisitBreakdownRow {
|
||
admin_id: number
|
||
name: string
|
||
order_count: number
|
||
}
|
||
|
||
/** 侧栏「诊单医助」引用(排行榜行与复诊拆解行通用) */
|
||
type YejiAssistantDrawerRef = { admin_id: number; name: string }
|
||
|
||
interface YejiLeadLineRow {
|
||
id: number
|
||
event_time: number
|
||
event_time_text: string
|
||
external_userid: string
|
||
user_id: string
|
||
state: string
|
||
reception_admin_name: string
|
||
external_contact_name: string
|
||
}
|
||
|
||
interface YejiRow {
|
||
dept_id: number
|
||
dept_name: string
|
||
lead_count: number
|
||
assign_count: number
|
||
revisit_count?: number
|
||
revisit_slots?: Record<string, number>
|
||
/** 预约诊单(status 1/3/4),与医助排行榜「预约诊单」一致 */
|
||
appointment_booked_count?: number
|
||
consult_count: number
|
||
deal_order_count: number
|
||
channel_consult_count: number
|
||
performance_amount: number
|
||
completed_performance_amount: number
|
||
channel_avg_price: number | null
|
||
cost_amount: number
|
||
roi: number | null
|
||
}
|
||
|
||
interface YejiTable {
|
||
label: string
|
||
start_date: string
|
||
end_date: string
|
||
channel_code: string
|
||
channel_name: string
|
||
channel_group: string
|
||
channel_filter_note: string
|
||
rows: YejiRow[]
|
||
total: {
|
||
lead_count: number
|
||
assign_count: number
|
||
revisit_count?: number
|
||
revisit_slots?: Record<string, number>
|
||
appointment_booked_count?: number
|
||
consult_count: number
|
||
deal_order_count: number
|
||
channel_consult_count: number
|
||
performance_amount: number
|
||
completed_performance_amount: number
|
||
channel_avg_price: number | null
|
||
cost_amount: number
|
||
cost_per_lead: number | null
|
||
roi: number | null
|
||
}
|
||
}
|
||
|
||
interface LeaderboardPack {
|
||
start_date: string
|
||
end_date: string
|
||
channel_code: string
|
||
channel_name: string
|
||
channel_filter_note: string
|
||
range_note: string
|
||
leaderboards: Array<{
|
||
dept_id: number
|
||
dept_name: string
|
||
dept_abbr: string
|
||
er_center_subtree?: boolean
|
||
rows: Array<{
|
||
rank: number
|
||
admin_id: number
|
||
name: string
|
||
dept_abbr: string
|
||
fee_amount: number
|
||
lead_count?: number
|
||
assign_count?: number
|
||
consult_count: number
|
||
deal_order_count: number
|
||
appointment_count?: number
|
||
consult_rate: number
|
||
revisit_count?: number
|
||
revisit_slots?: Record<string, number>
|
||
}>
|
||
}>
|
||
}
|
||
|
||
const loading = ref(false)
|
||
const deptOptions = ref<DeptOption[]>([])
|
||
const deptOptionsLoading = ref(false)
|
||
const deptTreeOptions = computed(() => buildDeptTreeFromFlat(deptOptions.value))
|
||
const selectedDeptIds = ref<number[]>([])
|
||
const channelGroups = ref<ChannelGroup[]>([])
|
||
const channelOptionsLoading = ref(false)
|
||
const selectedChannel = ref<string>('')
|
||
const customRange = ref<[string, string] | null>(null)
|
||
const tables = ref<YejiTable[]>([])
|
||
const YEJI_REVISIT_SLOT_CAP = 30
|
||
|
||
function maxFromRevisitSlots(rs: unknown, m: number): number {
|
||
if (!rs || typeof rs !== 'object') {
|
||
return m
|
||
}
|
||
for (const k of Object.keys(rs as Record<string, unknown>)) {
|
||
const num = parseInt(k, 10)
|
||
if (!Number.isNaN(num) && num >= 2) {
|
||
m = Math.max(m, num)
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
function maxRevisitSlotInYejiTables(tbs: YejiTable[]): number {
|
||
let mm = 0
|
||
for (const tb of tbs) {
|
||
for (const r of tb.rows) {
|
||
mm = maxFromRevisitSlots(r.revisit_slots, mm)
|
||
}
|
||
mm = maxFromRevisitSlots(tb.total?.revisit_slots, mm)
|
||
}
|
||
return mm
|
||
}
|
||
|
||
const yejiRevisitSlotColumns = computed(() => {
|
||
const max = Math.min(maxRevisitSlotInYejiTables(tables.value), YEJI_REVISIT_SLOT_CAP)
|
||
if (max < 2) {
|
||
return []
|
||
}
|
||
return Array.from({ length: max - 1 }, (_, i) => i + 2)
|
||
})
|
||
|
||
function maxRevisitSlotInLeaderboardLb(lb: LeaderboardPack['leaderboards'][number]): number {
|
||
if (!lb.er_center_subtree) {
|
||
return 0
|
||
}
|
||
let mm = 0
|
||
for (const r of lb.rows) {
|
||
mm = maxFromRevisitSlots(r.revisit_slots, mm)
|
||
}
|
||
return mm
|
||
}
|
||
|
||
function leaderboardRevisitSlotColumnsForLb(lb: LeaderboardPack['leaderboards'][number]): number[] {
|
||
const max = Math.min(maxRevisitSlotInLeaderboardLb(lb), YEJI_REVISIT_SLOT_CAP)
|
||
if (max < 2) {
|
||
return []
|
||
}
|
||
return Array.from({ length: max - 1 }, (_, i) => i + 2)
|
||
}
|
||
|
||
function yejiRevisitSlotColumnTitle(slot: number): string {
|
||
if (slot === 2) {
|
||
return '复诊2'
|
||
}
|
||
const cn = ['三', '四', '五', '六', '七', '八', '九', '十']
|
||
if (slot >= 3 && slot <= 10) {
|
||
return `复诊${cn[slot - 3]}`
|
||
}
|
||
return `复诊${slot}`
|
||
}
|
||
|
||
function yejiRevisitSlotAt(
|
||
row:
|
||
| Pick<YejiRow, 'revisit_slots'>
|
||
| Pick<YejiTable['total'], 'revisit_slots'>
|
||
| Pick<LeaderboardPack['leaderboards'][0]['rows'][0], 'revisit_slots'>,
|
||
slot: number
|
||
): number {
|
||
const rs = row?.revisit_slots
|
||
if (!rs || typeof rs !== 'object') {
|
||
return 0
|
||
}
|
||
const v = rs[slot] ?? rs[String(slot)]
|
||
const n = Number(v)
|
||
return Number.isFinite(n) ? n : 0
|
||
}
|
||
|
||
const leaderboardBlock = ref<LeaderboardPack | null>(null)
|
||
const leaderboardsLoading = ref(false)
|
||
|
||
const unassignedDialogVisible = ref(false)
|
||
const unassignedDialogLoading = ref(false)
|
||
const unassignedDialogSubtitle = ref('')
|
||
const unassignedDialogNote = ref('')
|
||
const unassignedDialogRows = ref<YejiUnassignedBreakdownRow[]>([])
|
||
|
||
const revisitBreakdownVisible = ref(false)
|
||
const revisitBreakdownLoading = ref(false)
|
||
const revisitBreakdownSubtitle = ref('')
|
||
const revisitBreakdownNote = ref('')
|
||
const revisitBreakdownRows = ref<YejiRevisitBreakdownRow[]>([])
|
||
const revisitBreakdownMeta = ref<{ start: string; end: string; revisitSlot: number } | null>(null)
|
||
|
||
const leadLinesDialogVisible = ref(false)
|
||
const leadLinesLoading = ref(false)
|
||
const leadLinesSubtitle = ref('')
|
||
const leadLinesApiNote = ref('')
|
||
const leadLinesRows = ref<YejiLeadLineRow[]>([])
|
||
const leadLinesCount = ref(0)
|
||
const leadLinesPage = ref(1)
|
||
const leadLinesPageSize = ref(20)
|
||
const leadLinesContext = ref<{ tb: YejiTable; row: YejiRow } | null>(null)
|
||
|
||
const appointmentLinesDialogVisible = ref(false)
|
||
const appointmentLinesLoading = ref(false)
|
||
const appointmentLinesSubtitle = ref('')
|
||
const appointmentLinesApiNote = ref('')
|
||
const appointmentLinesRows = ref<Array<Record<string, unknown>>>([])
|
||
const appointmentLinesCount = ref(0)
|
||
const appointmentLinesPage = ref(1)
|
||
const appointmentLinesPageSize = ref(20)
|
||
type AppointmentLinesCtx =
|
||
| {
|
||
mode: 'assistant'
|
||
assistant_id: number
|
||
name: string
|
||
start_date: string
|
||
end_date: string
|
||
}
|
||
| {
|
||
mode: 'dept'
|
||
dept_id: number
|
||
dept_name: string
|
||
start_date: string
|
||
end_date: string
|
||
}
|
||
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
|
||
|
||
const appointmentLinesShowChannelColumn = computed(() =>
|
||
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
|
||
)
|
||
|
||
/** 图表 / 数据表切换 */
|
||
const yejiDisplayTab = ref<'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'>('zyyt')
|
||
|
||
/** 与菜单 stats.doctorDailyStats/overview 一致;无权限则不展示医生统计表/图、不请求接口 */
|
||
const userStore = useUserStore()
|
||
const DOCTOR_DAILY_STATS_PERM = 'stats.doctorDailyStats/overview'
|
||
const canViewDoctorDailyStats = computed(() => {
|
||
const perms = userStore.perms || []
|
||
return perms.some(p => p === '*' || p === DOCTOR_DAILY_STATS_PERM)
|
||
})
|
||
|
||
const leaderboardChartBlocks = computed(() =>
|
||
(leaderboardBlock.value?.leaderboards ?? []).filter(lb => lb.rows.length > 0)
|
||
)
|
||
|
||
function yejiDeptChartHasData(tb: YejiTable): boolean {
|
||
return tb.rows.some(
|
||
r =>
|
||
r.dept_id > 0 &&
|
||
(Number(r.lead_count) > 0 ||
|
||
Number(r.consult_count) > 0 ||
|
||
Number(r.deal_order_count) > 0 ||
|
||
Number(r.performance_amount) > 0 ||
|
||
Number(r.completed_performance_amount) > 0)
|
||
)
|
||
}
|
||
|
||
/** 医生统计(与筛选项联动) */
|
||
const doctorDailyLoading = ref(false)
|
||
const doctorDailyRows = ref<Array<Record<string, any>>>([])
|
||
const doctorDailyTotal = ref<Record<string, any>>({})
|
||
const doctorDailyRange = ref<{ start: string; end: string } | null>(null)
|
||
|
||
const doctorDailyRangeText = computed(() => {
|
||
const r = doctorDailyRange.value
|
||
if (!r?.start || !r?.end) return ''
|
||
return r.start === r.end ? `区间 ${r.start}` : `区间 ${r.start} ~ ${r.end}`
|
||
})
|
||
|
||
const doctorDealBarHasData = computed(() =>
|
||
doctorDailyRows.value.some(r => Number(r.deal_amount ?? 0) > 0)
|
||
)
|
||
|
||
const doctorRxStackHasData = computed(() =>
|
||
doctorDailyRows.value.some(
|
||
r =>
|
||
Number(r.system_prescription_count ?? 0) > 0 ||
|
||
Number(r.manual_prescription_count ?? 0) > 0
|
||
)
|
||
)
|
||
|
||
const doctorChartsHasData = computed(() => {
|
||
if (!canViewDoctorDailyStats.value) {
|
||
return false
|
||
}
|
||
return (
|
||
doctorDealBarHasData.value ||
|
||
doctorRxStackHasData.value ||
|
||
doctorAppointmentPieHasData.value ||
|
||
doctorScatterHasData.value ||
|
||
doctorRadarHasData.value
|
||
)
|
||
})
|
||
|
||
const chartsHasAnyData = computed(() => {
|
||
if (doctorChartsHasData.value) {
|
||
return true
|
||
}
|
||
for (const tb of tables.value) {
|
||
if (yejiDeptChartHasData(tb)) {
|
||
return true
|
||
}
|
||
}
|
||
if (leaderboardChartBlocks.value.length > 0) {
|
||
return true
|
||
}
|
||
return false
|
||
})
|
||
|
||
const doctorAppointmentPieHasData = computed(() => {
|
||
const t = doctorDailyTotal.value
|
||
return (
|
||
Number(t.appointment_completed ?? 0) +
|
||
Number(t.appointment_missed ?? 0) +
|
||
Number(t.appointment_cancelled ?? 0) >
|
||
0
|
||
)
|
||
})
|
||
|
||
const doctorAppointmentPieOption = computed(() => {
|
||
const t = doctorDailyTotal.value
|
||
const data = [
|
||
{ value: Number(t.appointment_completed ?? 0), name: '挂号完成' },
|
||
{ value: Number(t.appointment_missed ?? 0), name: '过号' },
|
||
{ value: Number(t.appointment_cancelled ?? 0), name: '取消' },
|
||
].filter(d => d.value > 0)
|
||
return {
|
||
color: ['#0d9488', '#d97706', '#94a3b8'],
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||
legend: { bottom: 0, type: 'scroll', textStyle: { fontSize: 10 } },
|
||
series: [
|
||
{
|
||
name: '挂号',
|
||
type: 'pie',
|
||
radius: ['42%', '68%'],
|
||
center: ['50%', '46%'],
|
||
avoidLabelOverlap: true,
|
||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||
label: { formatter: '{b}\n{c}', fontSize: 11 },
|
||
data,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const doctorScatterHasData = computed(
|
||
() =>
|
||
doctorDailyRows.value.filter(
|
||
r => Number(r.deal_order_count ?? 0) > 0 && Number(r.deal_amount ?? 0) > 0
|
||
).length >= 2
|
||
)
|
||
|
||
const doctorScatterOption = computed(() => {
|
||
const rows = doctorDailyRows.value.filter(
|
||
r => Number(r.deal_order_count ?? 0) > 0 && Number(r.deal_amount ?? 0) > 0
|
||
)
|
||
const maxAmt = Math.max(...rows.map(r => Number(r.deal_amount)), 1)
|
||
const data = rows.map(r => {
|
||
const amt = Number(r.deal_amount)
|
||
return {
|
||
name: String(r.doctor_name || '—'),
|
||
value: [Number(r.deal_order_count), amt],
|
||
symbolSize: 12 + Math.sqrt(amt / maxAmt) * 26,
|
||
}
|
||
})
|
||
return {
|
||
color: ['#2563eb'],
|
||
tooltip: {
|
||
trigger: 'item',
|
||
formatter: (p: any) => {
|
||
const v = p.data?.value as number[] | undefined
|
||
if (!Array.isArray(v)) return ''
|
||
return `${p.data.name as string}<br/>接诊诊单:${v[0]}<br/>金额:${Number(v[1]).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
|
||
},
|
||
},
|
||
grid: { left: 48, right: 24, top: 28, bottom: 32, containLabel: true },
|
||
xAxis: {
|
||
type: 'value',
|
||
name: '接诊诊单',
|
||
nameLocation: 'middle',
|
||
nameGap: 28,
|
||
splitLine: { lineStyle: { type: 'dashed', opacity: 0.55 } },
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
name: '成交金额',
|
||
nameLocation: 'middle',
|
||
nameGap: 40,
|
||
splitLine: { lineStyle: { type: 'dashed', opacity: 0.55 } },
|
||
},
|
||
series: [
|
||
{
|
||
type: 'scatter',
|
||
data,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const doctorRadarHasData = computed(() => {
|
||
const rows = doctorDailyRows.value.filter(
|
||
r =>
|
||
Number(r.deal_amount ?? 0) +
|
||
Number(r.deal_order_count ?? 0) +
|
||
Number(r.system_prescription_count ?? 0) +
|
||
Number(r.manual_prescription_count ?? 0) >
|
||
0
|
||
)
|
||
return rows.length >= 2
|
||
})
|
||
|
||
const doctorRadarOption = computed(() => {
|
||
const rows = [...doctorDailyRows.value]
|
||
.filter(
|
||
r =>
|
||
Number(r.deal_amount ?? 0) +
|
||
Number(r.deal_order_count ?? 0) +
|
||
Number(r.system_prescription_count ?? 0) +
|
||
Number(r.manual_prescription_count ?? 0) >
|
||
0
|
||
)
|
||
.sort((a, b) => Number(b.deal_amount) - Number(a.deal_amount))
|
||
.slice(0, 5)
|
||
if (rows.length < 2) {
|
||
return { radar: { indicator: [] }, series: [{ type: 'radar', data: [] }] }
|
||
}
|
||
const lab = (r: Record<string, any>) => {
|
||
const n = String(r.doctor_name || '—')
|
||
return n.length > 7 ? `${n.slice(0, 7)}…` : n
|
||
}
|
||
const rx = (r: Record<string, any>) =>
|
||
Number(r.system_prescription_count ?? 0) + Number(r.manual_prescription_count ?? 0)
|
||
const avg = (r: Record<string, any>) => {
|
||
const v = Number(r.avg_deal_amount)
|
||
if (Number.isFinite(v) && v > 0) return v
|
||
const o = Number(r.deal_order_count)
|
||
return o > 0 ? Number(r.deal_amount) / o : 0
|
||
}
|
||
const maxDeal = Math.max(...rows.map(r => Number(r.deal_amount)), 1e-9)
|
||
const maxOrd = Math.max(...rows.map(r => Number(r.deal_order_count)), 1e-9)
|
||
const maxRx = Math.max(...rows.map(rx), 1e-9)
|
||
const maxAp = Math.max(...rows.map(r => Number(r.appointment_completed ?? 0)), 1e-9)
|
||
const maxAvg = Math.max(...rows.map(avg), 1e-9)
|
||
|
||
const data = rows.map(r => ({
|
||
name: lab(r),
|
||
value: [
|
||
(Number(r.deal_amount) / maxDeal) * 100,
|
||
(Number(r.deal_order_count) / maxOrd) * 100,
|
||
(rx(r) / maxRx) * 100,
|
||
(Number(r.appointment_completed ?? 0) / maxAp) * 100,
|
||
(avg(r) / maxAvg) * 100,
|
||
],
|
||
}))
|
||
|
||
return {
|
||
color: ['#2563eb', '#0d9488', '#d97706', '#7c3aed', '#64748b'],
|
||
tooltip: { trigger: 'item' },
|
||
legend: { bottom: 0, type: 'scroll', textStyle: { fontSize: 10 } },
|
||
radar: {
|
||
indicator: [
|
||
{ name: '成交金额', max: 100 },
|
||
{ name: '接诊诊单', max: 100 },
|
||
{ name: '开方量', max: 100 },
|
||
{ name: '挂号完成', max: 100 },
|
||
{ name: '客单价', max: 100 },
|
||
],
|
||
radius: '58%',
|
||
center: ['50%', '48%'],
|
||
splitNumber: 4,
|
||
axisName: { fontSize: 10, color: '#64748b' },
|
||
},
|
||
series: [
|
||
{
|
||
type: 'radar',
|
||
data,
|
||
emphasis: {
|
||
lineStyle: { width: 3 },
|
||
},
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const doctorDealBarOption = computed(() => {
|
||
const rows = [...doctorDailyRows.value]
|
||
.filter(r => Number(r.deal_amount ?? 0) > 0)
|
||
.sort((a, b) => Number(b.deal_amount) - Number(a.deal_amount))
|
||
.slice(0, 20)
|
||
const names = rows.map(r => {
|
||
const n = String(r.doctor_name || '—')
|
||
return n.length > 10 ? `${n.slice(0, 10)}…` : n
|
||
})
|
||
const vals = rows.map(r => Number(r.deal_amount ?? 0))
|
||
return {
|
||
color: ['#2563eb'],
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
valueFormatter: (v: number) =>
|
||
`${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`,
|
||
},
|
||
grid: { left: 8, right: 16, top: 28, bottom: 8, containLabel: true },
|
||
xAxis: {
|
||
type: 'value',
|
||
axisLabel: { fontSize: 11 },
|
||
splitLine: { lineStyle: { type: 'dashed', opacity: 0.6 } },
|
||
},
|
||
yAxis: {
|
||
type: 'category',
|
||
data: [...names].reverse(),
|
||
axisLabel: { fontSize: 11 },
|
||
},
|
||
series: [
|
||
{
|
||
name: '成交金额',
|
||
type: 'bar',
|
||
data: [...vals].reverse(),
|
||
barMaxWidth: 22,
|
||
itemStyle: { borderRadius: [0, 6, 6, 0] },
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const doctorRxStackOption = computed(() => {
|
||
const rows = [...doctorDailyRows.value]
|
||
.filter(
|
||
r =>
|
||
Number(r.system_prescription_count ?? 0) + Number(r.manual_prescription_count ?? 0) >
|
||
0
|
||
)
|
||
.sort(
|
||
(a, b) =>
|
||
Number(b.system_prescription_count ?? 0) +
|
||
Number(b.manual_prescription_count ?? 0) -
|
||
(Number(a.system_prescription_count ?? 0) + Number(a.manual_prescription_count ?? 0))
|
||
)
|
||
.slice(0, 14)
|
||
const cats = rows.map(r => {
|
||
const n = String(r.doctor_name || '—')
|
||
return n.length > 6 ? `${n.slice(0, 6)}…` : n
|
||
})
|
||
return {
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||
legend: { top: 0, textStyle: { fontSize: 11 } },
|
||
grid: { left: 44, right: 16, top: 32, bottom: cats.length > 10 ? 72 : 28 },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: cats,
|
||
axisLabel: { rotate: cats.length > 8 ? 32 : 0, fontSize: 10 },
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
splitLine: { lineStyle: { type: 'dashed', opacity: 0.6 } },
|
||
},
|
||
series: [
|
||
{
|
||
name: '系统开方',
|
||
type: 'bar',
|
||
stack: 'rx',
|
||
barMaxWidth: 18,
|
||
data: rows.map(r => Number(r.system_prescription_count ?? 0)),
|
||
itemStyle: { color: '#2563eb' },
|
||
},
|
||
{
|
||
name: '手动开方',
|
||
type: 'bar',
|
||
stack: 'rx',
|
||
barMaxWidth: 18,
|
||
data: rows.map(r => Number(r.manual_prescription_count ?? 0)),
|
||
itemStyle: { color: '#d97706' },
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
function buildDoctorDailyRequestParams(): Record<string, any> {
|
||
const p: Record<string, any> = {}
|
||
if (selectedChannel.value) {
|
||
p.channel_code = selectedChannel.value
|
||
}
|
||
if (customRange.value?.[0] && customRange.value?.[1]) {
|
||
p.start_date = customRange.value[0]
|
||
p.end_date = customRange.value[1]
|
||
} else {
|
||
const ymd = formatLocalYmd(new Date())
|
||
p.start_date = ymd
|
||
p.end_date = ymd
|
||
}
|
||
|
||
return p
|
||
}
|
||
|
||
function formatDoctorDailyMoney(n: number | string | undefined) {
|
||
const x = Number(n ?? 0)
|
||
|
||
return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
}
|
||
|
||
function getDoctorDailySummaries(param: { columns: any[] }) {
|
||
const t = doctorDailyTotal.value
|
||
const sums: string[] = []
|
||
param.columns.forEach((column, index) => {
|
||
if (index === 0) {
|
||
sums[index] = '合计'
|
||
|
||
return
|
||
}
|
||
switch (column.property) {
|
||
case 'system_prescription_count':
|
||
sums[index] = String(t.system_prescription_count ?? 0)
|
||
break
|
||
case 'manual_prescription_count':
|
||
sums[index] = String(t.manual_prescription_count ?? 0)
|
||
break
|
||
case 'deal_amount':
|
||
sums[index] = `¥ ${formatDoctorDailyMoney(t.deal_amount)}`
|
||
break
|
||
case 'deal_order_count':
|
||
sums[index] = String(t.deal_order_count ?? 0)
|
||
break
|
||
case 'appointment_completed':
|
||
sums[index] = String(t.appointment_completed ?? 0)
|
||
break
|
||
case 'appointment_missed':
|
||
sums[index] = String(t.appointment_missed ?? 0)
|
||
break
|
||
case 'appointment_cancelled':
|
||
sums[index] = String(t.appointment_cancelled ?? 0)
|
||
break
|
||
default:
|
||
sums[index] = ''
|
||
}
|
||
})
|
||
|
||
return sums
|
||
}
|
||
|
||
async function loadDoctorDailyStats() {
|
||
doctorDailyLoading.value = true
|
||
try {
|
||
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
|
||
doctorDailyRows.value = res?.rows ?? []
|
||
doctorDailyTotal.value = res?.total ?? {}
|
||
if (res?.start_date && res?.end_date) {
|
||
doctorDailyRange.value = { start: res.start_date, end: res.end_date }
|
||
}
|
||
} catch (_e) {
|
||
doctorDailyRows.value = []
|
||
doctorDailyTotal.value = {}
|
||
doctorDailyRange.value = null
|
||
} finally {
|
||
doctorDailyLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ── 业务订单侧栏(末级部门 / 医助行点击,接口同处方业务订单列表) ──
|
||
const orderDrawerVisible = ref(false)
|
||
const orderDrawerLoading = ref(false)
|
||
const orderDrawerTitle = ref('')
|
||
const orderDrawerHint = ref('')
|
||
const orderDrawerLists = ref<Array<Record<string, any>>>([])
|
||
const orderDrawerCount = ref(0)
|
||
const orderDrawerPage = ref(1)
|
||
const orderDrawerPageSize = ref(15)
|
||
/** 侧栏列表:有渠道筛选时,合计业绩=全量订单 / 渠道=收窄订单 */
|
||
const orderDrawerListTab = ref<'total' | 'channel'>('channel')
|
||
/** 渠道模式下接口返回的合计 vs 渠道业绩快照(切换 Tab 时用于对照卡片) */
|
||
const orderDrawerSplitPerfCache = ref<{
|
||
totalAmount: number
|
||
totalOrderCount: number
|
||
channelAmount: number
|
||
channelOrderCount: number
|
||
} | null>(null)
|
||
|
||
type OrderDrawerFilter =
|
||
| {
|
||
mode: 'dept'
|
||
deptId: number
|
||
deptName: string
|
||
start: string
|
||
end: string
|
||
/** 当前业绩表各展示行 dept_id,用于侧栏与「接诊诊单」列对齐(创建人优先 + 最深展示行) */
|
||
yejiTableRowDeptIds?: number[]
|
||
}
|
||
| {
|
||
mode: 'assistant'
|
||
assistantId: number
|
||
assistantName: string
|
||
start: string
|
||
end: string
|
||
/** 未传=普通全量业务单;传 0=二中心复诊全部;≥2=对应复诊分项 */
|
||
erCenterRevisitSlot?: number
|
||
}
|
||
|
||
const orderDrawerFilter = ref<OrderDrawerFilter | null>(null)
|
||
/** 列表接口 extend,含 stats_order_amount_performance(与当前筛选、不含已取消口径一致) */
|
||
const orderDrawerExtend = ref<Record<string, any> | null>(null)
|
||
|
||
const orderDrawerTotalAmount = computed(() => {
|
||
const ex = orderDrawerExtend.value
|
||
if (!ex) return null
|
||
const raw = ex.stats_order_amount_performance ?? ex.stats_order_amount_not_cancelled
|
||
if (raw === undefined || raw === null || raw === '') return null
|
||
const n = Number(raw)
|
||
return Number.isFinite(n) ? n : null
|
||
})
|
||
|
||
/** 约诊/接诊条数(与业绩看板「预约诊单」同口径,来自列表 extend) */
|
||
const orderDrawerConsultCount = computed(() => {
|
||
const ex = orderDrawerExtend.value
|
||
if (!ex || ex.stats_yeji_consult_count === undefined || ex.stats_yeji_consult_count === null) {
|
||
return null
|
||
}
|
||
const n = Number(ex.stats_yeji_consult_count)
|
||
return Number.isFinite(n) ? n : null
|
||
})
|
||
|
||
const orderDrawerPageAmountSum = computed(() =>
|
||
orderDrawerLists.value.reduce((s, row) => s + Number(row.amount ?? 0), 0)
|
||
)
|
||
|
||
const orderDrawerChannelSplitActive = computed(
|
||
() => Boolean(selectedChannel.value) && orderDrawerVisible.value
|
||
)
|
||
|
||
const orderDrawerChannelLabel = computed(() => {
|
||
const code = selectedChannel.value
|
||
if (!code) return ''
|
||
for (const g of channelGroups.value) {
|
||
const c = g.channels?.find((x: ChannelOption) => x.channel_code === code)
|
||
if (c?.channel_name) return String(c.channel_name)
|
||
}
|
||
return code
|
||
})
|
||
|
||
const orderDrawerChannelStatAmount = computed(() => {
|
||
const c = orderDrawerSplitPerfCache.value
|
||
if (c && orderDrawerListTab.value === 'total') {
|
||
return Number.isFinite(c.channelAmount) ? c.channelAmount : null
|
||
}
|
||
return orderDrawerTotalAmount.value
|
||
})
|
||
|
||
// ── 目标进度卡片 ──────────────────────────────
|
||
interface TargetProgressSection {
|
||
dept_id: number
|
||
dept_name: string
|
||
/** 组织树上直接上级部门名称(便于区分同名组) */
|
||
parent_dept_name: string
|
||
target_amount: number
|
||
total_amount: number
|
||
yesterday_amount: number
|
||
douyin_amount: number | null // 名称含「自媒体1」的子部门合计
|
||
tuifen_amount: number | null // 名称含「自媒体3」或「自媒体4」的子部门合计
|
||
target_progress: number // 按已过天数均摊的目标进度
|
||
completion_rate: number | null // 总诊金 / 目标金额
|
||
}
|
||
interface TargetProgressCard {
|
||
title: string
|
||
sections: TargetProgressSection[]
|
||
}
|
||
const targetProgressCard = ref<TargetProgressCard | null>(null)
|
||
const targetProgressLoading = ref(false)
|
||
|
||
function ymCurrent(): string {
|
||
const d = new Date()
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||
}
|
||
function daysInMonth(ym: string): number {
|
||
const [y, m] = ym.split('-').map(Number)
|
||
return new Date(y, m, 0).getDate()
|
||
}
|
||
|
||
async function loadTargetProgress() {
|
||
targetProgressLoading.value = true
|
||
targetProgressCard.value = null
|
||
try {
|
||
const ym = ymCurrent()
|
||
const targetRes: any = await deptPerformanceTargetMonthMatrix({ year_month: ym })
|
||
const treeRows: any[] = targetRes?.rows ?? []
|
||
if (!treeRows.length) return
|
||
|
||
// 从目标树平铺,找到 depth>=1 且目标>0 的节点作为卡片分区
|
||
function flatWithDepth(nodes: any[], depth = 0): Array<{ node: any; depth: number }> {
|
||
const r: Array<{ node: any; depth: number }> = []
|
||
for (const n of nodes ?? []) {
|
||
r.push({ node: n, depth })
|
||
r.push(...flatWithDepth(n.children ?? [], depth + 1))
|
||
}
|
||
return r
|
||
}
|
||
const all = flatWithDepth(treeRows)
|
||
const cardDepts = all
|
||
.filter(x => x.depth >= 1 && Number(x.node.target_amount) > 0)
|
||
.map(x => x.node)
|
||
if (!cardDepts.length) return
|
||
|
||
function resolveParentDeptName(deptId: number): string {
|
||
const self = deptOptions.value.find(d => Number(d.id) === deptId)
|
||
if (!self) return ''
|
||
const pid = Number(self.pid)
|
||
if (!pid) return ''
|
||
const parent = deptOptions.value.find(d => Number(d.id) === pid)
|
||
return parent?.name ? String(parent.name) : ''
|
||
}
|
||
|
||
// 用 deptOptions(完整部门列表,含 pid)做层级查找
|
||
// 目标树只包含有目标的节点,无法得到 0 目标的子部门(如自媒体1/3/4)
|
||
function descIdsFromOptions(deptId: number): number[] {
|
||
const result: number[] = []
|
||
const queue = [deptId]
|
||
while (queue.length) {
|
||
const pid = queue.shift()!
|
||
for (const d of deptOptions.value) {
|
||
if (Number(d.pid) === pid) {
|
||
result.push(d.id)
|
||
queue.push(d.id)
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
const allIds = new Set<number>()
|
||
const specs = cardDepts.map(dept => {
|
||
const deptId = Number(dept.dept_id)
|
||
const childIds = descIdsFromOptions(deptId)
|
||
const totalIds = childIds.length ? childIds : [deptId]
|
||
totalIds.forEach(id => allIds.add(id))
|
||
return {
|
||
dept_id: deptId,
|
||
dept_name: String(dept.dept_name),
|
||
target_amount: Number(dept.target_amount),
|
||
totalIds,
|
||
}
|
||
})
|
||
|
||
if (!allIds.size) return
|
||
|
||
// 从 channelGroups 找自媒体1/3/4 的 channel_code
|
||
const allChannels = channelGroups.value.flatMap(g => g.channels)
|
||
const douyinCode = allChannels.find(c => c.channel_name.includes('自媒体1'))?.channel_code
|
||
const t3Code = allChannels.find(c => c.channel_name.includes('自媒体3'))?.channel_code
|
||
const t4Code = allChannels.find(c => c.channel_name.includes('自媒体4'))?.channel_code
|
||
|
||
const deptIdsStr = Array.from(allIds).join(',')
|
||
|
||
// 并发拉取:无渠道(总诊金/昨日)+ 各渠道(completed_performance_amount)
|
||
const [perfRes, douyinRes, t3Res, t4Res] = await Promise.all([
|
||
yejiStatsMulti({ dept_ids: deptIdsStr }) as any,
|
||
douyinCode ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: douyinCode }) as any : Promise.resolve(null),
|
||
t3Code ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: t3Code }) as any : Promise.resolve(null),
|
||
t4Code ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: t4Code }) as any : Promise.resolve(null),
|
||
])
|
||
|
||
const getMonthTbl = (res: any) => (res?.tables ?? []).find((t: any) => String(t.label).includes('月')) ?? res?.tables?.[0]
|
||
const getYdayTbl = (res: any) => (res?.tables ?? []).find((t: any) => String(t.label).includes('昨')) ?? res?.tables?.[res?.tables?.length - 1]
|
||
|
||
function buildMap(tbl: any, field: string): Map<number, number> {
|
||
const m = new Map<number, number>()
|
||
for (const r of tbl?.rows ?? []) m.set(Number(r.dept_id), Number(r[field] ?? 0))
|
||
return m
|
||
}
|
||
|
||
const monthPerf = buildMap(getMonthTbl(perfRes), 'performance_amount')
|
||
const ydayPerf = buildMap(getYdayTbl(perfRes), 'performance_amount')
|
||
const douyinPerf = douyinRes ? buildMap(getMonthTbl(douyinRes), 'completed_performance_amount') : null
|
||
const t3Perf = t3Res ? buildMap(getMonthTbl(t3Res), 'completed_performance_amount') : null
|
||
const t4Perf = t4Res ? buildMap(getMonthTbl(t4Res), 'completed_performance_amount') : null
|
||
|
||
const sumIds = (m: Map<number, number>, ids: number[]) => ids.reduce((s, id) => s + (m.get(id) ?? 0), 0)
|
||
|
||
const today = new Date()
|
||
const totalDays = daysInMonth(ym)
|
||
const elapsed = today.getDate()
|
||
|
||
const sections: TargetProgressSection[] = specs.map(spec => {
|
||
const total = sumIds(monthPerf, spec.totalIds)
|
||
const yesterday = sumIds(ydayPerf, spec.totalIds)
|
||
|
||
const douyinAmt = douyinPerf ? sumIds(douyinPerf, spec.totalIds) : null
|
||
const t3Amt = t3Perf ? sumIds(t3Perf, spec.totalIds) : null
|
||
const t4Amt = t4Perf ? sumIds(t4Perf, spec.totalIds) : null
|
||
// 推粉 = 自媒体3 + 自媒体4,有任一即显示
|
||
const tuifenAmt = (t3Amt !== null || t4Amt !== null) ? (t3Amt ?? 0) + (t4Amt ?? 0) : null
|
||
|
||
const targetProgress = Math.round(spec.target_amount * (elapsed / totalDays) * 100) / 100
|
||
const completion = spec.target_amount > 0 ? total / spec.target_amount : null
|
||
return {
|
||
dept_id: spec.dept_id,
|
||
dept_name: spec.dept_name,
|
||
parent_dept_name: resolveParentDeptName(spec.dept_id),
|
||
target_amount: spec.target_amount,
|
||
total_amount: total,
|
||
yesterday_amount: yesterday,
|
||
douyin_amount: douyinAmt && douyinAmt > 0 ? douyinAmt : null,
|
||
tuifen_amount: tuifenAmt && tuifenAmt > 0 ? tuifenAmt : null,
|
||
target_progress: targetProgress,
|
||
completion_rate: completion,
|
||
}
|
||
})
|
||
|
||
// 取根部门名作为卡片标题前缀
|
||
const rootName = treeRows[0]?.dept_name ?? ''
|
||
const m = today.getMonth() + 1
|
||
const d = today.getDate()
|
||
targetProgressCard.value = {
|
||
title: `${rootName}诊金-${m}.${d}`,
|
||
sections,
|
||
}
|
||
} catch {
|
||
// 静默失败,不影响主页面
|
||
} finally {
|
||
targetProgressLoading.value = false
|
||
}
|
||
}
|
||
|
||
const channelFilterNote = computed(() => {
|
||
for (const t of tables.value) {
|
||
if (t.channel_filter_note) return t.channel_filter_note
|
||
}
|
||
return ''
|
||
})
|
||
|
||
const leaderboardVisibleTables = computed(() => {
|
||
const list = leaderboardBlock.value?.leaderboards
|
||
if (!list) return 0
|
||
return list.filter((x) => x.rows.length > 0).length
|
||
})
|
||
|
||
const dateShortcuts = [
|
||
{
|
||
text: '昨天',
|
||
value: () => {
|
||
const d = new Date()
|
||
d.setDate(d.getDate() - 1)
|
||
return [d, d]
|
||
},
|
||
},
|
||
{
|
||
text: '最近七天',
|
||
value: () => {
|
||
const e = new Date()
|
||
const s = new Date()
|
||
s.setDate(s.getDate() - 6)
|
||
return [s, e]
|
||
},
|
||
},
|
||
{
|
||
text: '本月',
|
||
value: () => {
|
||
const e = new Date()
|
||
const s = new Date(e.getFullYear(), e.getMonth(), 1)
|
||
return [s, e]
|
||
},
|
||
},
|
||
{
|
||
text: '今日',
|
||
value: () => {
|
||
const d = new Date()
|
||
return [d, d]
|
||
},
|
||
},
|
||
{
|
||
text: '本周',
|
||
value: () => {
|
||
const e = new Date()
|
||
const s = new Date()
|
||
const day = s.getDay() || 7
|
||
s.setDate(s.getDate() - (day - 1))
|
||
return [s, e]
|
||
},
|
||
},
|
||
{
|
||
text: '上月',
|
||
value: () => {
|
||
const e0 = new Date()
|
||
const s = new Date(e0.getFullYear(), e0.getMonth() - 1, 1)
|
||
const e = new Date(e0.getFullYear(), e0.getMonth(), 0)
|
||
return [s, e]
|
||
},
|
||
},
|
||
]
|
||
|
||
function applyDateShortcut(shortcut: (typeof dateShortcuts)[number]) {
|
||
const pair = shortcut.value() as [Date, Date]
|
||
customRange.value = [formatLocalYmd(pair[0]), formatLocalYmd(pair[1])]
|
||
loadData()
|
||
}
|
||
|
||
async function loadDeptOptions() {
|
||
deptOptionsLoading.value = true
|
||
try {
|
||
const res: DeptOption[] = await yejiStatsDeptOptions()
|
||
deptOptions.value = (res || []).sort((a, b) => {
|
||
const aIsCenter = a.name.includes('中心') ? -1 : 1
|
||
const bIsCenter = b.name.includes('中心') ? -1 : 1
|
||
if (aIsCenter !== bIsCenter) return aIsCenter - bIsCenter
|
||
return a.id - b.id
|
||
})
|
||
} catch (_e) {
|
||
// ignore
|
||
} finally {
|
||
deptOptionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadChannelOptions() {
|
||
channelOptionsLoading.value = true
|
||
try {
|
||
const res: any = await yejiStatsChannelOptions()
|
||
const groups: ChannelGroup[] = res?.groups || []
|
||
channelGroups.value = groups
|
||
} catch (_e) {
|
||
// ignore
|
||
} finally {
|
||
channelOptionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadLeaderboard(range: { start: string; end: string }) {
|
||
leaderboardsLoading.value = true
|
||
try {
|
||
const p: Record<string, any> = {
|
||
start_date: range.start,
|
||
end_date: range.end,
|
||
}
|
||
if (selectedDeptIds.value.length > 0) {
|
||
p.dept_ids = selectedDeptIds.value.join(',')
|
||
}
|
||
if (selectedChannel.value) {
|
||
p.channel_code = selectedChannel.value
|
||
}
|
||
const res: any = await yejiStatsLeaderboard(p as any)
|
||
leaderboardBlock.value = {
|
||
start_date: res.start_date,
|
||
end_date: res.end_date,
|
||
channel_code: res.channel_code || '',
|
||
channel_name: res.channel_name || '',
|
||
channel_filter_note: res.channel_filter_note || '',
|
||
range_note: res.range_note || '',
|
||
leaderboards: res.leaderboards || [],
|
||
}
|
||
} catch (_e) {
|
||
leaderboardBlock.value = null
|
||
} finally {
|
||
leaderboardsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
leaderboardBlock.value = null
|
||
try {
|
||
const baseParams: Record<string, any> = {}
|
||
if (selectedDeptIds.value.length > 0) {
|
||
baseParams.dept_ids = selectedDeptIds.value.join(',')
|
||
}
|
||
if (selectedChannel.value) {
|
||
baseParams.channel_code = selectedChannel.value
|
||
}
|
||
|
||
// 自定义日期 → 单表 overview 模式;否则 → 多表 multi
|
||
if (customRange.value && customRange.value[0] && customRange.value[1]) {
|
||
const [s, e] = customRange.value
|
||
const params: Record<string, any> = { ...baseParams, start_date: s, end_date: e }
|
||
const res: any = await yejiStatsOverview(params as any)
|
||
tables.value = [
|
||
{
|
||
label: s === e ? s : `${s} ~ ${e}`,
|
||
start_date: res.start_date,
|
||
end_date: res.end_date,
|
||
channel_code: res.channel_code || '',
|
||
channel_name: res.channel_name || '',
|
||
channel_group: res.channel_group || '',
|
||
channel_filter_note: res.channel_filter_note || '',
|
||
rows: res.rows || [],
|
||
total: res.total,
|
||
},
|
||
]
|
||
await loadLeaderboard({ start: res.start_date, end: res.end_date })
|
||
return
|
||
}
|
||
|
||
const res: any = await yejiStatsMulti(baseParams as any)
|
||
tables.value = (res?.tables || []).map((t: any) => ({
|
||
label: t.label,
|
||
start_date: t.start_date,
|
||
end_date: t.end_date,
|
||
channel_code: t.channel_code || '',
|
||
channel_name: t.channel_name || '',
|
||
channel_group: t.channel_group || '',
|
||
channel_filter_note: t.channel_filter_note || '',
|
||
rows: t.rows || [],
|
||
total: t.total,
|
||
}))
|
||
const ymd = formatLocalYmd(new Date())
|
||
await loadLeaderboard({ start: ymd, end: ymd })
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
if (canViewDoctorDailyStats.value) {
|
||
void loadDoctorDailyStats()
|
||
} else {
|
||
doctorDailyRows.value = []
|
||
doctorDailyTotal.value = {}
|
||
doctorDailyRange.value = null
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleReset() {
|
||
selectedDeptIds.value = []
|
||
customRange.value = null
|
||
selectedChannel.value = ''
|
||
loadData()
|
||
}
|
||
|
||
/** 是否存在子部门(用于下钻 / 末级打开业务订单侧栏) */
|
||
function deptHasChildren(deptId: number): boolean {
|
||
if (deptId <= 0) return false
|
||
return deptOptions.value.some(d => Number(d.pid) === deptId)
|
||
}
|
||
|
||
function orderFulfillmentBrief(s: unknown): string {
|
||
const m: Record<number, string> = {
|
||
1: '待双审通过',
|
||
2: '待发货',
|
||
3: '已完成',
|
||
4: '已取消',
|
||
5: '已发货',
|
||
6: '已签收',
|
||
7: '进行中',
|
||
8: '暂不制药',
|
||
9: '拒收',
|
||
10: '退款',
|
||
11: '保留药方',
|
||
12: '制药缓发',
|
||
}
|
||
return m[Number(s)] ?? '—'
|
||
}
|
||
|
||
function formatOrderDrawerMoney(v: number | null | undefined) {
|
||
if (v === null || v === undefined || !Number.isFinite(Number(v))) {
|
||
return '—'
|
||
}
|
||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
}
|
||
|
||
function orderDrawerIndexMethod(index: number) {
|
||
return (orderDrawerPage.value - 1) * orderDrawerPageSize.value + index + 1
|
||
}
|
||
|
||
/** 挂号展示:预约日期 + 时段 + 时点 */
|
||
function orderDrawerApptSchedule(a: Record<string, any>): string {
|
||
const date = a?.appointment_date != null && String(a.appointment_date) !== '' ? String(a.appointment_date) : ''
|
||
const period = a?.period != null && String(a.period) !== '' ? String(a.period) : ''
|
||
const time = a?.appointment_time != null && String(a.appointment_time) !== '' ? String(a.appointment_time) : ''
|
||
const tail = [period, time].filter(Boolean).join(' ')
|
||
if (date && tail) return `${date} ${tail}`
|
||
return date || tail || '—'
|
||
}
|
||
|
||
/** 挂号 channels 字典 value(后端 channel_dict_value,与业绩/进线所用字典同源);无数值则空串 */
|
||
function orderDrawerLinkedApptChannelDictValue(a: Record<string, any> | null | undefined): string {
|
||
if (!a) return ''
|
||
const v = a.channel_dict_value
|
||
if (v !== null && v !== undefined && v !== '') {
|
||
const n = Number(v)
|
||
if (Number.isFinite(n)) {
|
||
return String(Math.trunc(n))
|
||
}
|
||
}
|
||
const raw = a.channel_source
|
||
if (raw === null || raw === undefined) return ''
|
||
const s = String(raw).trim()
|
||
if (s === '' || !/^\d+$/.test(s)) return ''
|
||
return String(parseInt(s, 10))
|
||
}
|
||
|
||
function formatOrderDrawerTime(t: unknown): string {
|
||
if (t == null || t === '') return '—'
|
||
const n = Number(t)
|
||
if (Number.isFinite(n) && n >= 1e9 && n < 1e15) {
|
||
const ms = n < 1e12 ? n * 1000 : n
|
||
const d = new Date(ms)
|
||
if (!Number.isNaN(d.getTime())) {
|
||
const y = d.getFullYear()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
const h = String(d.getHours()).padStart(2, '0')
|
||
const min = String(d.getMinutes()).padStart(2, '0')
|
||
return `${y}-${m}-${day} ${h}:${min}`
|
||
}
|
||
}
|
||
const s = String(t)
|
||
return s.length >= 16 ? s.slice(0, 16) : s
|
||
}
|
||
|
||
/** 业务订单侧栏列表筛选(不含分页) */
|
||
function buildOrderDrawerListParams(): Record<string, any> | null {
|
||
const f = orderDrawerFilter.value
|
||
if (!f) return null
|
||
const params: Record<string, any> = {
|
||
start_time: `${f.start} 00:00:00`,
|
||
end_time: `${f.end} 23:59:59`,
|
||
/** 与业绩列口径一致:不列出履约已取消(4) */
|
||
exclude_fulfillment_cancelled: 1,
|
||
/**
|
||
* 业绩看板入口标志:后端 PrescriptionOrderLists::applyDoctorAssistantFilters 看到此标志后,
|
||
* assistant_id / assistant_dept_id 一律按订单创建人收窄(与表格诊金/合计业绩/复诊同口径),
|
||
* 而非默认的「创建人 ∪ 诊单医助」并集。同时启用 extend 字段(约诊数、合计业绩对照)。
|
||
*/
|
||
yeji_order_drawer: 1,
|
||
}
|
||
if (f.mode === 'dept') {
|
||
params.assistant_dept_id = f.deptId
|
||
if (selectedDeptIds.value.length > 0) {
|
||
params.dept_ids = selectedDeptIds.value.join(',')
|
||
}
|
||
} else {
|
||
params.assistant_id = f.assistantId
|
||
if (f.erCenterRevisitSlot !== undefined) {
|
||
params.yeji_er_center_revisit_only = 1
|
||
params.yeji_er_center_revisit_slot = f.erCenterRevisitSlot
|
||
}
|
||
}
|
||
if (
|
||
selectedChannel.value &&
|
||
orderDrawerVisible.value &&
|
||
orderDrawerListTab.value === 'channel'
|
||
) {
|
||
params.channel_code = selectedChannel.value
|
||
}
|
||
|
||
return params
|
||
}
|
||
|
||
async function hydrateOrderDrawerSplitPerfCache() {
|
||
const base = buildOrderDrawerListParams()
|
||
if (!base || !selectedChannel.value) return
|
||
const params: Record<string, any> = {
|
||
...base,
|
||
channel_code: selectedChannel.value,
|
||
page_no: 1,
|
||
page_size: 1,
|
||
}
|
||
try {
|
||
const res: any = await prescriptionOrderLists(params)
|
||
const ex = res?.extend && typeof res.extend === 'object' ? res.extend : null
|
||
if (ex && ex.stats_yeji_drawer_total_performance_amount != null) {
|
||
orderDrawerSplitPerfCache.value = {
|
||
totalAmount: Number(ex.stats_yeji_drawer_total_performance_amount),
|
||
totalOrderCount: Number(ex.stats_yeji_drawer_total_performance_order_count ?? 0),
|
||
channelAmount: Number(ex.stats_order_amount_performance ?? 0),
|
||
channelOrderCount: Number(res?.count ?? 0),
|
||
}
|
||
}
|
||
} catch {
|
||
// 对照指标失败不打断列表
|
||
}
|
||
}
|
||
|
||
async function fetchOrderDrawerPage() {
|
||
const base = buildOrderDrawerListParams()
|
||
if (!base) return
|
||
if (selectedChannel.value && orderDrawerListTab.value === 'total') {
|
||
orderDrawerSplitPerfCache.value = null
|
||
}
|
||
orderDrawerLoading.value = true
|
||
try {
|
||
const params: Record<string, any> = {
|
||
...base,
|
||
page_no: orderDrawerPage.value,
|
||
page_size: orderDrawerPageSize.value,
|
||
}
|
||
const res: any = await prescriptionOrderLists(params)
|
||
orderDrawerLists.value = Array.isArray(res?.lists) ? res.lists : []
|
||
orderDrawerCount.value = Number(res?.count ?? 0)
|
||
orderDrawerExtend.value = res?.extend && typeof res.extend === 'object' ? res.extend : null
|
||
if (
|
||
selectedChannel.value &&
|
||
orderDrawerListTab.value === 'channel' &&
|
||
orderDrawerExtend.value &&
|
||
orderDrawerExtend.value.stats_yeji_drawer_total_performance_amount != null
|
||
) {
|
||
orderDrawerSplitPerfCache.value = {
|
||
totalAmount: Number(orderDrawerExtend.value.stats_yeji_drawer_total_performance_amount),
|
||
totalOrderCount: Number(
|
||
orderDrawerExtend.value.stats_yeji_drawer_total_performance_order_count ?? 0
|
||
),
|
||
channelAmount: Number(orderDrawerExtend.value.stats_order_amount_performance ?? 0),
|
||
channelOrderCount: Number(res?.count ?? 0),
|
||
}
|
||
}
|
||
if (
|
||
selectedChannel.value &&
|
||
orderDrawerVisible.value &&
|
||
orderDrawerListTab.value === 'total'
|
||
) {
|
||
await hydrateOrderDrawerSplitPerfCache()
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载业务订单失败')
|
||
orderDrawerLists.value = []
|
||
orderDrawerCount.value = 0
|
||
orderDrawerExtend.value = null
|
||
} finally {
|
||
orderDrawerLoading.value = false
|
||
}
|
||
}
|
||
|
||
function openOrderDrawerByDept(tb: YejiTable, row: YejiRow, opts?: { listTab?: 'total' | 'channel' }) {
|
||
const channelSplit = Boolean(selectedChannel.value && tb.channel_name)
|
||
orderDrawerListTab.value = channelSplit ? opts?.listTab ?? 'channel' : 'channel'
|
||
orderDrawerSplitPerfCache.value = null
|
||
orderDrawerFilter.value = {
|
||
mode: 'dept',
|
||
deptId: row.dept_id,
|
||
deptName: row.dept_name,
|
||
start: tb.start_date,
|
||
end: tb.end_date,
|
||
yejiTableRowDeptIds: tb.rows.map(r => r.dept_id).filter((id): id is number => typeof id === 'number' && id > 0),
|
||
}
|
||
orderDrawerTitle.value = `业务订单 · ${row.dept_name}`
|
||
orderDrawerHint.value = `创建时间:${tb.start_date} ~ ${tb.end_date} · 与「合计业绩 / 接诊诊单」同口径:订单创建人人事部门 ∈ 本部门子树 · 不含履约已取消`
|
||
orderDrawerPage.value = 1
|
||
orderDrawerVisible.value = true
|
||
void fetchOrderDrawerPage()
|
||
}
|
||
|
||
/** 顶部「展示部门」仅选中 1 个根节点时:合计行业绩对应该子树下的订单汇总 */
|
||
function openOrderDrawerByYejiTableScope(
|
||
tb: YejiTable,
|
||
rootDeptId: number,
|
||
listTab: 'total' | 'channel'
|
||
) {
|
||
const channelSplit = Boolean(selectedChannel.value && tb.channel_name)
|
||
orderDrawerListTab.value = channelSplit ? listTab : 'channel'
|
||
orderDrawerSplitPerfCache.value = null
|
||
orderDrawerFilter.value = {
|
||
mode: 'dept',
|
||
deptId: rootDeptId,
|
||
deptName: '当前展示合计',
|
||
start: tb.start_date,
|
||
end: tb.end_date,
|
||
yejiTableRowDeptIds: tb.rows.map(r => r.dept_id).filter((id): id is number => typeof id === 'number' && id > 0),
|
||
}
|
||
orderDrawerTitle.value = '业务订单 · 当前展示合计'
|
||
orderDrawerHint.value = `创建时间:${tb.start_date} ~ ${tb.end_date} · 已选展示部门 1 个根节点(含其下全部展示行)· 不含履约已取消`
|
||
orderDrawerPage.value = 1
|
||
orderDrawerVisible.value = true
|
||
void fetchOrderDrawerPage()
|
||
}
|
||
|
||
function yejiPerfCellDrawerEligible(tb: YejiTable, row: YejiRow, kind: 'total' | 'channel'): boolean {
|
||
if (isUnassignedYejiRow(row) || row.dept_id <= 0) {
|
||
return false
|
||
}
|
||
if (kind === 'channel' && !tb.channel_name) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function yejiPerfTotalRowDrawerEligible(tb: YejiTable, kind: 'total' | 'channel'): boolean {
|
||
if (selectedDeptIds.value.length !== 1) {
|
||
return false
|
||
}
|
||
if (kind === 'channel' && !tb.channel_name) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function onYejiPerformanceCellClick(tb: YejiTable, row: YejiRow, kind: 'total' | 'channel') {
|
||
if (!yejiPerfCellDrawerEligible(tb, row, kind)) {
|
||
return
|
||
}
|
||
const listTab: 'total' | 'channel' = kind === 'total' ? 'total' : 'channel'
|
||
openOrderDrawerByDept(tb, row, { listTab })
|
||
}
|
||
|
||
function onYejiPerformanceTotalRowClick(tb: YejiTable, kind: 'total' | 'channel') {
|
||
if (!yejiPerfTotalRowDrawerEligible(tb, kind)) {
|
||
return
|
||
}
|
||
const rootDeptId = selectedDeptIds.value[0]
|
||
if (!rootDeptId) {
|
||
return
|
||
}
|
||
const listTab: 'total' | 'channel' = kind === 'total' ? 'total' : 'channel'
|
||
openOrderDrawerByYejiTableScope(tb, rootDeptId, listTab)
|
||
}
|
||
|
||
function openOrderDrawerByAssistant(row: LeaderboardPack['leaderboards'][0]['rows'][0], start: string, end: string) {
|
||
orderDrawerListTab.value = 'channel'
|
||
orderDrawerSplitPerfCache.value = null
|
||
orderDrawerFilter.value = {
|
||
mode: 'assistant',
|
||
assistantId: row.admin_id,
|
||
assistantName: row.name,
|
||
start,
|
||
end,
|
||
}
|
||
orderDrawerTitle.value = `业务订单 · ${row.name}`
|
||
orderDrawerHint.value = `创建时间:${start} ~ ${end} · 创建人=${row.name}(与排行榜诊金同口径)· 不含履约已取消`
|
||
orderDrawerPage.value = 1
|
||
orderDrawerVisible.value = true
|
||
void fetchOrderDrawerPage()
|
||
}
|
||
|
||
function openOrderDrawerByAssistantErCenterRevisit(
|
||
assistant: YejiAssistantDrawerRef,
|
||
start: string,
|
||
end: string,
|
||
revisitSlot: number
|
||
) {
|
||
orderDrawerListTab.value = 'channel'
|
||
orderDrawerSplitPerfCache.value = null
|
||
orderDrawerFilter.value = {
|
||
mode: 'assistant',
|
||
assistantId: assistant.admin_id,
|
||
assistantName: assistant.name,
|
||
start,
|
||
end,
|
||
erCenterRevisitSlot: revisitSlot,
|
||
}
|
||
const slotLbl = revisitSlot === 0 ? '复诊合计' : yejiRevisitSlotColumnTitle(revisitSlot)
|
||
orderDrawerTitle.value = `业务订单 · ${assistant.name} · ${slotLbl}`
|
||
orderDrawerHint.value = `创建时间:${start} ~ ${end} · 创建人=${assistant.name}(与部门表 / 排行榜复诊同口径)· 仅二中心复诊口径 · ${slotLbl} · 不含履约已取消`
|
||
orderDrawerPage.value = 1
|
||
orderDrawerVisible.value = true
|
||
void fetchOrderDrawerPage()
|
||
}
|
||
|
||
function onLeaderboardRevisitTotalClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
|
||
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
|
||
return
|
||
}
|
||
if (Number(r.revisit_count ?? 0) <= 0) {
|
||
return
|
||
}
|
||
const { start_date, end_date } = leaderboardBlock.value
|
||
openOrderDrawerByAssistantErCenterRevisit(
|
||
{ admin_id: r.admin_id, name: r.name },
|
||
start_date,
|
||
end_date,
|
||
0
|
||
)
|
||
}
|
||
|
||
function onLeaderboardRevisitSlotClick(r: LeaderboardPack['leaderboards'][0]['rows'][0], slot: number) {
|
||
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
|
||
return
|
||
}
|
||
if (yejiRevisitSlotAt(r, slot) <= 0) {
|
||
return
|
||
}
|
||
const { start_date, end_date } = leaderboardBlock.value
|
||
openOrderDrawerByAssistantErCenterRevisit(
|
||
{ admin_id: r.admin_id, name: r.name },
|
||
start_date,
|
||
end_date,
|
||
slot
|
||
)
|
||
}
|
||
|
||
function onOrderDrawerListTabChange() {
|
||
orderDrawerPage.value = 1
|
||
void fetchOrderDrawerPage()
|
||
}
|
||
|
||
function onOrderDrawerPageChange(p: number) {
|
||
orderDrawerPage.value = p
|
||
void fetchOrderDrawerPage()
|
||
}
|
||
|
||
function onOrderDrawerPageSizeChange(size: number) {
|
||
orderDrawerPageSize.value = size
|
||
orderDrawerPage.value = 1
|
||
void fetchOrderDrawerPage()
|
||
}
|
||
|
||
function isUnassignedYejiRow(row: YejiRow): boolean {
|
||
return row.dept_id <= 0 && row.dept_name === '未归属中心'
|
||
}
|
||
|
||
function onYejiRevisitTotalCellClick(tb: YejiTable, row: YejiRow) {
|
||
if (row.dept_id <= 0 || isUnassignedYejiRow(row) || Number(row.revisit_count ?? 0) <= 0) {
|
||
return
|
||
}
|
||
void openYejiRevisitDeptBreakdown(tb, row, 0)
|
||
}
|
||
|
||
function onYejiRevisitSlotCellClick(tb: YejiTable, row: YejiRow, slot: number) {
|
||
if (row.dept_id <= 0 || isUnassignedYejiRow(row) || yejiRevisitSlotAt(row, slot) <= 0) {
|
||
return
|
||
}
|
||
void openYejiRevisitDeptBreakdown(tb, row, slot)
|
||
}
|
||
|
||
function onYejiRevisitBreakdownOrderClick(row: YejiRevisitBreakdownRow) {
|
||
const meta = revisitBreakdownMeta.value
|
||
if (!meta || Number(row.order_count) <= 0) {
|
||
return
|
||
}
|
||
openOrderDrawerByAssistantErCenterRevisit(
|
||
{ admin_id: row.admin_id, name: row.name },
|
||
meta.start,
|
||
meta.end,
|
||
meta.revisitSlot
|
||
)
|
||
}
|
||
|
||
async function openYejiRevisitDeptBreakdown(tb: YejiTable, row: YejiRow, revisitSlot: number) {
|
||
const slotLbl = revisitSlot === 0 ? '复诊合计' : yejiRevisitSlotColumnTitle(revisitSlot)
|
||
revisitBreakdownSubtitle.value = `${row.dept_name} · ${slotLbl} · ${tb.start_date} ~ ${tb.end_date}`
|
||
revisitBreakdownMeta.value = {
|
||
start: tb.start_date,
|
||
end: tb.end_date,
|
||
revisitSlot,
|
||
}
|
||
revisitBreakdownNote.value = ''
|
||
revisitBreakdownRows.value = []
|
||
revisitBreakdownVisible.value = true
|
||
revisitBreakdownLoading.value = true
|
||
try {
|
||
const p: Record<string, string | number> = {
|
||
start_date: tb.start_date,
|
||
end_date: tb.end_date,
|
||
dept_id: row.dept_id,
|
||
revisit_slot: revisitSlot,
|
||
}
|
||
if (selectedDeptIds.value.length > 0) {
|
||
p.dept_ids = selectedDeptIds.value.join(',')
|
||
}
|
||
if (selectedChannel.value) {
|
||
p.channel_code = selectedChannel.value
|
||
}
|
||
const res: any = await yejiStatsRevisitBreakdown(p as any)
|
||
revisitBreakdownRows.value = Array.isArray(res?.rows) ? res.rows : []
|
||
revisitBreakdownNote.value = typeof res?.note === 'string' ? res.note : ''
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载复诊拆解失败')
|
||
revisitBreakdownRows.value = []
|
||
revisitBreakdownNote.value = ''
|
||
} finally {
|
||
revisitBreakdownLoading.value = false
|
||
}
|
||
}
|
||
|
||
function buildUnassignedBreakdownSubtitle(tb: YejiTable): string {
|
||
const parts = [`区间:${tb.start_date} ~ ${tb.end_date}`]
|
||
if (selectedChannel.value) {
|
||
parts.push(`渠道:${tb.channel_name || selectedChannel.value}`)
|
||
}
|
||
if (selectedDeptIds.value.length > 0) {
|
||
parts.push(`已选展示部门 ${selectedDeptIds.value.length} 个`)
|
||
}
|
||
return parts.join(' · ')
|
||
}
|
||
|
||
async function openUnassignedBreakdown(tb: YejiTable) {
|
||
unassignedDialogSubtitle.value = buildUnassignedBreakdownSubtitle(tb)
|
||
unassignedDialogNote.value = ''
|
||
unassignedDialogRows.value = []
|
||
unassignedDialogVisible.value = true
|
||
unassignedDialogLoading.value = true
|
||
try {
|
||
const p: Record<string, string> = {
|
||
start_date: tb.start_date,
|
||
end_date: tb.end_date,
|
||
}
|
||
if (selectedDeptIds.value.length > 0) {
|
||
p.dept_ids = selectedDeptIds.value.join(',')
|
||
}
|
||
if (selectedChannel.value) {
|
||
p.channel_code = selectedChannel.value
|
||
}
|
||
const res: any = await yejiStatsUnassignedBreakdown(p as any)
|
||
unassignedDialogRows.value = Array.isArray(res?.rows) ? res.rows : []
|
||
unassignedDialogNote.value = typeof res?.note === 'string' ? res.note : ''
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载未归属拆解失败')
|
||
unassignedDialogRows.value = []
|
||
unassignedDialogNote.value = ''
|
||
} finally {
|
||
unassignedDialogLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||
if (row.dept_id <= 0 || Number(row.lead_count) <= 0) {
|
||
return
|
||
}
|
||
ev.stopPropagation()
|
||
void openLeadLinesDialog(tb, row)
|
||
}
|
||
|
||
async function fetchLeadLinesPage() {
|
||
const ctx = leadLinesContext.value
|
||
if (!ctx) {
|
||
return
|
||
}
|
||
const { tb, row } = ctx
|
||
leadLinesLoading.value = true
|
||
try {
|
||
const p: Record<string, string | number> = {
|
||
start_date: tb.start_date,
|
||
end_date: tb.end_date,
|
||
dept_id: row.dept_id,
|
||
page: leadLinesPage.value,
|
||
page_size: leadLinesPageSize.value,
|
||
}
|
||
if (selectedDeptIds.value.length > 0) {
|
||
p.dept_ids = selectedDeptIds.value.join(',')
|
||
}
|
||
if (selectedChannel.value) {
|
||
p.channel_code = selectedChannel.value
|
||
}
|
||
const res: any = await yejiStatsLeadLines(p as any)
|
||
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||
leadLinesCount.value = Number(res?.count ?? 0)
|
||
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载进线明细失败')
|
||
leadLinesRows.value = []
|
||
leadLinesCount.value = 0
|
||
leadLinesApiNote.value = ''
|
||
} finally {
|
||
leadLinesLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function openLeadLinesDialog(tb: YejiTable, row: YejiRow) {
|
||
leadLinesContext.value = { tb, row }
|
||
leadLinesSubtitle.value = `${row.dept_name} · ${buildUnassignedBreakdownSubtitle(tb)}`
|
||
leadLinesApiNote.value = ''
|
||
leadLinesRows.value = []
|
||
leadLinesCount.value = 0
|
||
leadLinesPage.value = 1
|
||
leadLinesPageSize.value = 20
|
||
leadLinesDialogVisible.value = true
|
||
await fetchLeadLinesPage()
|
||
}
|
||
|
||
function onLeadLinesPageChange(p: number) {
|
||
leadLinesPage.value = p
|
||
void fetchLeadLinesPage()
|
||
}
|
||
|
||
function onLeadLinesPageSizeChange(size: number) {
|
||
leadLinesPageSize.value = size
|
||
leadLinesPage.value = 1
|
||
void fetchLeadLinesPage()
|
||
}
|
||
|
||
function appointmentLinesIndexMethod(index: number) {
|
||
return (appointmentLinesPage.value - 1) * appointmentLinesPageSize.value + index + 1
|
||
}
|
||
|
||
/** 业绩主表中「预约诊单」列是否可点开明细(含未归属中心行 dept_id=0) */
|
||
function yejiAppointmentBookedCellClickable(r: YejiRow): boolean {
|
||
if (isUnassignedYejiRow(r)) {
|
||
return true
|
||
}
|
||
return r.dept_id > 0
|
||
}
|
||
|
||
async function fetchAppointmentLinesPage() {
|
||
const ctx = appointmentLinesContext.value
|
||
if (!ctx) {
|
||
return
|
||
}
|
||
appointmentLinesLoading.value = true
|
||
try {
|
||
const p: Record<string, string | number> = {
|
||
start_date: ctx.start_date,
|
||
end_date: ctx.end_date,
|
||
page: appointmentLinesPage.value,
|
||
page_size: appointmentLinesPageSize.value,
|
||
}
|
||
if (ctx.mode === 'dept') {
|
||
p.dept_id = ctx.dept_id
|
||
} else {
|
||
p.assistant_id = ctx.assistant_id
|
||
}
|
||
if (selectedDeptIds.value.length > 0) {
|
||
p.dept_ids = selectedDeptIds.value.join(',')
|
||
}
|
||
if (selectedChannel.value) {
|
||
p.channel_code = selectedChannel.value
|
||
}
|
||
const res: any = await yejiStatsAppointmentLines(p as any)
|
||
appointmentLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||
appointmentLinesCount.value = Number(res?.count ?? 0)
|
||
appointmentLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载挂号明细失败')
|
||
appointmentLinesRows.value = []
|
||
appointmentLinesCount.value = 0
|
||
appointmentLinesApiNote.value = ''
|
||
} finally {
|
||
appointmentLinesLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onLeaderboardAppointmentCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
|
||
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
|
||
return
|
||
}
|
||
if (Number(r.appointment_count ?? 0) <= 0) {
|
||
return
|
||
}
|
||
const lb = leaderboardBlock.value
|
||
appointmentLinesContext.value = {
|
||
mode: 'assistant',
|
||
assistant_id: r.admin_id,
|
||
name: r.name,
|
||
start_date: lb.start_date,
|
||
end_date: lb.end_date,
|
||
}
|
||
appointmentLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ~ ${lb.end_date}`
|
||
appointmentLinesApiNote.value = ''
|
||
appointmentLinesRows.value = []
|
||
appointmentLinesCount.value = 0
|
||
appointmentLinesPage.value = 1
|
||
appointmentLinesPageSize.value = 20
|
||
appointmentLinesDialogVisible.value = true
|
||
void fetchAppointmentLinesPage()
|
||
}
|
||
|
||
function onYejiDeptAppointmentBookedClick(tb: YejiTable, r: YejiRow) {
|
||
if (!yejiAppointmentBookedCellClickable(r) || Number(r.appointment_booked_count ?? 0) <= 0) {
|
||
return
|
||
}
|
||
appointmentLinesContext.value = {
|
||
mode: 'dept',
|
||
dept_id: r.dept_id,
|
||
dept_name: r.dept_name,
|
||
start_date: tb.start_date,
|
||
end_date: tb.end_date,
|
||
}
|
||
appointmentLinesSubtitle.value = `${r.dept_name} · ${tb.start_date} ~ ${tb.end_date}`
|
||
appointmentLinesApiNote.value = ''
|
||
appointmentLinesRows.value = []
|
||
appointmentLinesCount.value = 0
|
||
appointmentLinesPage.value = 1
|
||
appointmentLinesPageSize.value = 20
|
||
appointmentLinesDialogVisible.value = true
|
||
void fetchAppointmentLinesPage()
|
||
}
|
||
|
||
function onAppointmentLinesPageChange(p: number) {
|
||
appointmentLinesPage.value = p
|
||
void fetchAppointmentLinesPage()
|
||
}
|
||
|
||
function onAppointmentLinesPageSizeChange(size: number) {
|
||
appointmentLinesPageSize.value = size
|
||
appointmentLinesPage.value = 1
|
||
void fetchAppointmentLinesPage()
|
||
}
|
||
|
||
/**
|
||
* 部门业绩行:有子部门 → 仅筛选该节点(后端展开子行);末级 → 侧栏业务订单;「未归属中心」→ 按诊单医助拆解弹窗
|
||
*/
|
||
function onYejiDeptRowClick(tb: YejiTable, row: YejiRow) {
|
||
if (isUnassignedYejiRow(row)) {
|
||
void openUnassignedBreakdown(tb)
|
||
return
|
||
}
|
||
if (row.dept_id <= 0) return
|
||
if (deptHasChildren(row.dept_id)) {
|
||
selectedDeptIds.value = [row.dept_id]
|
||
loadData()
|
||
return
|
||
}
|
||
openOrderDrawerByDept(tb, row)
|
||
}
|
||
|
||
/** 医助排行:侧栏按诊单医助展示业务订单 */
|
||
function onLeaderboardAssistantRowClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
|
||
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) return
|
||
const { start_date, end_date } = leaderboardBlock.value
|
||
openOrderDrawerByAssistant(r, start_date, end_date)
|
||
}
|
||
|
||
// 表标题:参考截图「【4月】各分部 自媒体1 数据」格式
|
||
function buildTableTitle(t: YejiTable): string {
|
||
const channelLbl = t.channel_name ? ` · ${t.channel_name}` : ''
|
||
return `${t.label}${channelLbl}`
|
||
}
|
||
|
||
function getYejiDeptComboOption(tb: YejiTable) {
|
||
const rows = tb.rows.filter(r => r.dept_id > 0)
|
||
const cats = rows.map(r =>
|
||
r.dept_name.length > 10 ? `${r.dept_name.slice(0, 10)}…` : r.dept_name
|
||
)
|
||
const palette = ['#64748b', '#38bdf8', '#6366f1']
|
||
const series: any[] = [
|
||
{
|
||
name: '进线',
|
||
type: 'bar',
|
||
data: rows.map(r => r.lead_count),
|
||
yAxisIndex: 0,
|
||
barMaxWidth: 16,
|
||
},
|
||
{
|
||
name: '接诊',
|
||
type: 'bar',
|
||
data: rows.map(r => r.consult_count),
|
||
yAxisIndex: 0,
|
||
barMaxWidth: 16,
|
||
},
|
||
{
|
||
name: '成交单',
|
||
type: 'bar',
|
||
data: rows.map(r => r.deal_order_count),
|
||
yAxisIndex: 0,
|
||
barMaxWidth: 16,
|
||
},
|
||
{
|
||
name: '合计业绩',
|
||
type: 'bar',
|
||
data: rows.map(r => r.performance_amount),
|
||
yAxisIndex: 1,
|
||
barMaxWidth: 14,
|
||
itemStyle: { color: '#d97706' },
|
||
},
|
||
]
|
||
if (tb.channel_name) {
|
||
series.push({
|
||
name: `${tb.channel_name}业绩`,
|
||
type: 'bar',
|
||
data: rows.map(r => r.completed_performance_amount),
|
||
yAxisIndex: 1,
|
||
barMaxWidth: 14,
|
||
itemStyle: { color: '#0d9488' },
|
||
})
|
||
}
|
||
const needZoom = rows.length > 10
|
||
return {
|
||
color: [...palette, '#d97706', '#0d9488'],
|
||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||
legend: { top: 8, type: 'scroll', textStyle: { fontSize: 11 } },
|
||
grid: {
|
||
left: 52,
|
||
right: 52,
|
||
top: 48,
|
||
bottom: needZoom ? 56 : 28,
|
||
containLabel: false,
|
||
},
|
||
dataZoom: needZoom
|
||
? [
|
||
{
|
||
type: 'slider',
|
||
bottom: 4,
|
||
start: 0,
|
||
end: Math.min(100, Math.ceil((12 / Math.max(rows.length, 1)) * 100)),
|
||
},
|
||
]
|
||
: [],
|
||
xAxis: {
|
||
type: 'category',
|
||
data: cats,
|
||
axisLabel: { rotate: cats.length > 8 ? 28 : 0, fontSize: 10 },
|
||
},
|
||
yAxis: [
|
||
{
|
||
type: 'value',
|
||
name: '数量',
|
||
splitLine: { lineStyle: { type: 'dashed', opacity: 0.55 } },
|
||
},
|
||
{ type: 'value', name: '金额', splitLine: { show: false } },
|
||
],
|
||
series,
|
||
}
|
||
}
|
||
|
||
function getLeaderboardBarOption(
|
||
lb: LeaderboardPack['leaderboards'][number],
|
||
_pack: LeaderboardPack
|
||
) {
|
||
const rows = [...lb.rows].sort((a, b) => Number(b.fee_amount) - Number(a.fee_amount)).slice(0, 15)
|
||
return {
|
||
color: ['#2563eb'],
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
axisPointer: { type: 'shadow' },
|
||
valueFormatter: (v: number) => `${Number(v).toLocaleString('zh-CN')} 元`,
|
||
},
|
||
grid: { left: 8, right: 20, top: 12, bottom: 8, containLabel: true },
|
||
xAxis: {
|
||
type: 'value',
|
||
splitLine: { lineStyle: { type: 'dashed', opacity: 0.6 } },
|
||
},
|
||
yAxis: {
|
||
type: 'category',
|
||
data: rows
|
||
.map(r => (r.name.length > 8 ? `${r.name.slice(0, 8)}…` : r.name))
|
||
.reverse(),
|
||
axisLabel: { fontSize: 11 },
|
||
},
|
||
series: [
|
||
{
|
||
name: '诊金',
|
||
type: 'bar',
|
||
data: rows.map(r => Number(r.fee_amount ?? 0)).reverse(),
|
||
barMaxWidth: 20,
|
||
itemStyle: { borderRadius: [0, 6, 6, 0] },
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function leaderboardPieHasData(lb: LeaderboardPack['leaderboards'][number]): boolean {
|
||
return lb.rows.some(r => Number(r.fee_amount) > 0)
|
||
}
|
||
|
||
function getLeaderboardPieOption(
|
||
lb: LeaderboardPack['leaderboards'][number],
|
||
_pack: LeaderboardPack
|
||
) {
|
||
const rows = [...lb.rows]
|
||
.filter(r => Number(r.fee_amount) > 0)
|
||
.sort((a, b) => Number(b.fee_amount) - Number(a.fee_amount))
|
||
.slice(0, 10)
|
||
const data = rows.map(r => ({
|
||
name: r.name.length > 6 ? `${r.name.slice(0, 6)}…` : r.name,
|
||
value: Number(r.fee_amount),
|
||
}))
|
||
return {
|
||
color: [
|
||
'#2563eb',
|
||
'#0d9488',
|
||
'#d97706',
|
||
'#7c3aed',
|
||
'#64748b',
|
||
'#0891b2',
|
||
'#65a30d',
|
||
'#c026d3',
|
||
'#ea580c',
|
||
'#4f46e5',
|
||
],
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} 元 ({d}%)' },
|
||
legend: { type: 'scroll', bottom: 0, textStyle: { fontSize: 9 } },
|
||
series: [
|
||
{
|
||
name: '诊金',
|
||
type: 'pie',
|
||
radius: ['38%', '64%'],
|
||
center: ['50%', '42%'],
|
||
data,
|
||
itemStyle: { borderRadius: 5, borderColor: '#fff', borderWidth: 2 },
|
||
label: { formatter: '{b}\n{d}%', fontSize: 10 },
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function deptFunnelHasData(tb: YejiTable): boolean {
|
||
return (
|
||
Number(tb.total.lead_count) + Number(tb.total.consult_count) + Number(tb.total.deal_order_count) >
|
||
0
|
||
)
|
||
}
|
||
|
||
function getDeptFunnelOption(tb: YejiTable) {
|
||
const lead = Number(tb.total.lead_count ?? 0)
|
||
const consult = Number(tb.total.consult_count ?? 0)
|
||
const deal = Number(tb.total.deal_order_count ?? 0)
|
||
return {
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
||
color: ['#2563eb', '#38bdf8', '#0d9488'],
|
||
series: [
|
||
{
|
||
type: 'funnel',
|
||
left: '8%',
|
||
width: '84%',
|
||
minSize: '12%',
|
||
sort: 'none',
|
||
gap: 6,
|
||
label: {
|
||
show: true,
|
||
position: 'inside',
|
||
formatter: '{b}\n{c}',
|
||
fontSize: 11,
|
||
},
|
||
data: [
|
||
{ value: lead, name: '进线' },
|
||
{ value: consult, name: '接诊' },
|
||
{ value: deal, name: '成交单' },
|
||
],
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
function deptPerformancePieHasData(tb: YejiTable): boolean {
|
||
return tb.rows.some(r => r.dept_id > 0 && Number(r.performance_amount) > 0)
|
||
}
|
||
|
||
function getDeptPerformancePieOption(tb: YejiTable) {
|
||
const rows = tb.rows
|
||
.filter(r => r.dept_id > 0 && Number(r.performance_amount) > 0)
|
||
.sort((a, b) => Number(b.performance_amount) - Number(a.performance_amount))
|
||
if (!rows.length) {
|
||
return { series: [{ type: 'pie', data: [] }] }
|
||
}
|
||
const top = rows.slice(0, 10)
|
||
const pieRows: { name: string; value: number }[] = top.map(r => ({
|
||
name: r.dept_name.length > 8 ? `${r.dept_name.slice(0, 8)}…` : r.dept_name,
|
||
value: Number(r.performance_amount),
|
||
}))
|
||
if (rows.length > 10) {
|
||
const rest = rows.slice(10).reduce((s, r) => s + Number(r.performance_amount), 0)
|
||
if (rest > 0) pieRows.push({ name: '其它', value: rest })
|
||
}
|
||
return {
|
||
color: [
|
||
'#2563eb',
|
||
'#0d9488',
|
||
'#d97706',
|
||
'#7c3aed',
|
||
'#64748b',
|
||
'#0891b2',
|
||
'#65a30d',
|
||
'#c026d3',
|
||
'#ea580c',
|
||
'#4f46e5',
|
||
'#94a3b8',
|
||
],
|
||
tooltip: { trigger: 'item', formatter: '{b}<br/>{c} 元({d}%)' },
|
||
legend: { type: 'scroll', bottom: 0, textStyle: { fontSize: 10 } },
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: ['36%', '62%'],
|
||
center: ['50%', '44%'],
|
||
data: pieRows,
|
||
itemStyle: { borderRadius: 5, borderColor: '#fff', borderWidth: 2 },
|
||
label: { formatter: '{b}\n{d}%', fontSize: 10 },
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
// 选渠道时表头缩略(卡片标题已含渠道名,避免「自媒体1成交单」过长挤压)
|
||
function yejiChannelMetricShort(kind: 'perf' | 'deal' | 'aov'): string {
|
||
const m: Record<string, string> = { perf: '渠道业绩', deal: '渠道成交', aov: '渠道客单' }
|
||
return m[kind] ?? kind
|
||
}
|
||
|
||
// ── 格式化 ───────────────────────────────
|
||
function formatInt(v: any) {
|
||
if (v === null || v === undefined || v === '') return '0'
|
||
return Number(v).toLocaleString('en-US')
|
||
}
|
||
function formatMoney(v: any) {
|
||
if (v === null || v === undefined || v === '') return '0.0'
|
||
return Number(v).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||
}
|
||
/** 排行榜整数列(接诊单数等) */
|
||
function formatLeaderboardInt(v: any) {
|
||
if (v === null || v === undefined || v === '') return '0'
|
||
const n = Number(v)
|
||
if (!Number.isFinite(n)) return '0'
|
||
return Math.round(n).toLocaleString('zh-CN')
|
||
}
|
||
function formatNumber(v: any) {
|
||
if (v === null || v === undefined || v === '') return '—'
|
||
return Number(v).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||
}
|
||
function formatRoi(v: any) {
|
||
if (v === null || v === undefined || v === '') return '—'
|
||
return Number(v).toFixed(1)
|
||
}
|
||
function roiClass(v: any) {
|
||
if (v === null || v === undefined || v === '') return ''
|
||
const n = Number(v)
|
||
if (n >= 1) return 'roi-good'
|
||
if (n >= 0.8) return 'roi-mid'
|
||
return 'roi-bad'
|
||
}
|
||
function formatAchievement(v: number | null): string {
|
||
if (v === null) return '—'
|
||
return (v * 100).toFixed(2) + '%'
|
||
}
|
||
function achievementClass(v: number | null): string {
|
||
if (v === null) return ''
|
||
if (v >= 1) return 'tp-good'
|
||
if (v >= 0.8) return 'tp-mid'
|
||
return 'tp-bad'
|
||
}
|
||
function formatMoneyW(v: number): string {
|
||
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||
return String(v)
|
||
}
|
||
|
||
/** 本地日历日 yyyy-mm-dd(排行榜「今日」、日期快捷与日期控件一致) */
|
||
function formatLocalYmd(d: Date): string {
|
||
const y = d.getFullYear()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
return `${y}-${m}-${day}`
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
||
loadData()
|
||
loadTargetProgress()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
/* ═══════════════════════════════════════════════
|
||
业绩看板 — Clean Modern Dashboard
|
||
═══════════════════════════════════════════════ */
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||
|
||
.yeji-page {
|
||
min-width: 0;
|
||
--yj-brand: #6366f1;
|
||
--yj-brand-soft: #eef2ff;
|
||
--yj-teal: #0ea5e9;
|
||
--yj-teal-soft: #f0f9ff;
|
||
--yj-accent: #f43f5e;
|
||
--yj-accent-soft: #fff1f2;
|
||
--yj-ink: #1e293b;
|
||
--yj-ink-secondary: #475569;
|
||
--yj-muted: #64748b;
|
||
--yj-line: #e2e8f0;
|
||
--yj-line-strong: #cbd5e1;
|
||
--yj-surface: #ffffff;
|
||
--yj-surface-muted: #f8fafc;
|
||
--yj-page: #f1f5f9;
|
||
--yj-shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
|
||
--yj-shadow-md: 0 4px 16px rgba(0,0,0,0.06);
|
||
--yj-radius: 16px;
|
||
--yj-radius-sm: 10px;
|
||
--yj-font: 'Inter', var(--el-font-family), system-ui, sans-serif;
|
||
/* 部门列竖条(表头略深、与指标列区分) */
|
||
--yj-dept-col-bg: #eef2ff;
|
||
--yj-dept-col-bg-hover: #e0e7ff;
|
||
--yj-dept-col-head-start: #e0e7ff;
|
||
--yj-dept-col-head-end: #e8ecff;
|
||
--yj-dept-col-border: #c7d2fe;
|
||
--yj-dept-col-head-ink: #3730a3;
|
||
|
||
min-height: 100%;
|
||
padding: 24px;
|
||
font-family: var(--yj-font);
|
||
color: var(--yj-ink);
|
||
background: var(--yj-page);
|
||
}
|
||
|
||
.yeji-masthead {
|
||
margin-bottom: 24px;
|
||
|
||
&__inner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 28px 32px;
|
||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||
border-radius: var(--yj-radius);
|
||
box-shadow: var(--yj-shadow-md);
|
||
}
|
||
|
||
&__title {
|
||
margin: 0;
|
||
font-size: 1.5rem;
|
||
font-weight: 800;
|
||
letter-spacing: -0.03em;
|
||
color: #fff;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
&__desc {
|
||
margin: 6px 0 0;
|
||
font-size: 0.875rem;
|
||
line-height: 1.5;
|
||
color: #94a3b8;
|
||
}
|
||
}
|
||
|
||
.yeji-panel {
|
||
margin-bottom: 20px;
|
||
border-radius: var(--yj-radius);
|
||
border: 1px solid var(--yj-line);
|
||
background: var(--yj-surface);
|
||
box-shadow: var(--yj-shadow-sm);
|
||
|
||
:deep(.el-card__body) {
|
||
padding: 0;
|
||
}
|
||
}
|
||
|
||
.filter-card {
|
||
overflow: hidden;
|
||
border-radius: var(--yj-radius);
|
||
}
|
||
|
||
.yeji-main-tabs-card {
|
||
margin-bottom: 20px;
|
||
box-shadow: var(--yj-shadow-md);
|
||
|
||
:deep(.el-card__body) {
|
||
padding: 0 0 18px;
|
||
}
|
||
}
|
||
|
||
.yeji-charts-only-card {
|
||
margin-bottom: 20px;
|
||
box-shadow: var(--yj-shadow-md);
|
||
|
||
:deep(.el-card__body) {
|
||
padding: 0;
|
||
}
|
||
|
||
.yeji-charts-stack {
|
||
padding: 16px 20px 4px;
|
||
}
|
||
}
|
||
|
||
.yeji-charts-only-head {
|
||
padding: 16px 20px 12px;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--yj-ink);
|
||
border-bottom: 1px solid var(--yj-line);
|
||
}
|
||
|
||
.yeji-table-tabs-head {
|
||
padding: 14px 20px 10px;
|
||
background: var(--yj-surface-muted);
|
||
border-bottom: 1px solid var(--yj-line);
|
||
|
||
&__title {
|
||
display: block;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--yj-ink);
|
||
line-height: 1.3;
|
||
}
|
||
|
||
&__hint {
|
||
display: block;
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--yj-muted);
|
||
line-height: 1.45;
|
||
}
|
||
}
|
||
|
||
.yeji-tab-pane-stack {
|
||
min-height: 100px;
|
||
}
|
||
|
||
.yeji-zyyt-intro {
|
||
margin: 0 20px 14px;
|
||
padding-top: 4px;
|
||
font-size: 0.8125rem;
|
||
line-height: 1.65;
|
||
color: #64748b;
|
||
}
|
||
|
||
.yeji-view-tabs {
|
||
:deep(.el-tabs__header) {
|
||
margin: 0;
|
||
padding: 0 20px;
|
||
background: var(--yj-surface-muted);
|
||
border-bottom: 1px solid var(--yj-line);
|
||
}
|
||
:deep(.el-tabs__nav-wrap::after) { display: none; }
|
||
:deep(.el-tabs__nav) { border: none; }
|
||
:deep(.el-tabs__item) {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
height: 44px;
|
||
line-height: 44px;
|
||
padding: 0 20px;
|
||
color: var(--yj-muted);
|
||
border-bottom: 2px solid transparent;
|
||
transition: color 0.2s, border-color 0.2s;
|
||
&:hover { color: var(--yj-ink); }
|
||
&.is-active {
|
||
color: var(--yj-brand);
|
||
border-bottom-color: var(--yj-brand);
|
||
font-weight: 700;
|
||
}
|
||
}
|
||
:deep(.el-tabs__active-bar) { display: none; }
|
||
:deep(.el-tabs__content) {
|
||
padding: 20px;
|
||
overflow: visible;
|
||
}
|
||
:deep(.el-tab-pane) { overflow: visible; }
|
||
}
|
||
|
||
.yeji-charts-intro {
|
||
margin: 0 0 16px;
|
||
padding: 12px 16px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
color: var(--yj-muted);
|
||
background: var(--yj-surface-muted);
|
||
border-radius: var(--yj-radius-sm);
|
||
border: 1px solid var(--yj-line);
|
||
}
|
||
|
||
.yeji-charts-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
min-height: 120px;
|
||
}
|
||
|
||
.yeji-charts-empty { padding: 48px 16px; }
|
||
|
||
.yeji-chart-block {
|
||
padding: 20px;
|
||
background: var(--yj-surface);
|
||
border-radius: var(--yj-radius-sm);
|
||
border: 1px solid var(--yj-line);
|
||
|
||
&__title {
|
||
margin: 0 0 16px;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--yj-ink);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 8px 12px;
|
||
}
|
||
&__sub { font-size: 12px; font-weight: 600; color: var(--yj-teal); }
|
||
&__sub--muted { font-size: 11px; font-weight: 500; color: var(--yj-muted); }
|
||
}
|
||
|
||
.yeji-chart-subrow { margin-top: 4px; }
|
||
|
||
.yeji-chart-panel {
|
||
border: 1px solid var(--yj-line);
|
||
border-radius: var(--yj-radius-sm);
|
||
padding: 14px;
|
||
background: var(--yj-surface);
|
||
height: 100%;
|
||
transition: box-shadow 0.2s;
|
||
&:hover { box-shadow: var(--yj-shadow-sm); }
|
||
&__label {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.03em;
|
||
color: var(--yj-muted);
|
||
margin-bottom: 10px;
|
||
}
|
||
}
|
||
|
||
.yeji-echart {
|
||
width: 100%;
|
||
height: 320px;
|
||
min-height: 240px;
|
||
}
|
||
|
||
.yeji-echart--short {
|
||
height: 280px;
|
||
min-height: 220px;
|
||
}
|
||
|
||
.yeji-echart--tall {
|
||
height: 400px;
|
||
min-height: 300px;
|
||
}
|
||
|
||
.yeji-echart--compact {
|
||
height: 288px;
|
||
min-height: 232px;
|
||
}
|
||
|
||
.doctor-daily-card--nested {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.doctor-daily-card {
|
||
box-shadow: var(--yj-shadow-sm);
|
||
|
||
:deep(.el-card__header) {
|
||
padding: 16px 22px;
|
||
background: var(--yj-surface-muted);
|
||
border-bottom: 1px solid var(--yj-line);
|
||
}
|
||
|
||
:deep(.el-card__body) {
|
||
padding: 18px 22px 22px;
|
||
}
|
||
|
||
&__head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
&__title {
|
||
font-weight: 700;
|
||
font-size: 1.0625rem;
|
||
color: var(--yj-ink);
|
||
}
|
||
|
||
&__title-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
&__range {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--yj-teal);
|
||
}
|
||
|
||
&__tools {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
&__lbl {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
&__picker {
|
||
width: 150px;
|
||
}
|
||
|
||
&__hint {
|
||
margin: 0 0 16px;
|
||
padding: 14px 16px;
|
||
font-size: 12px;
|
||
line-height: 1.65;
|
||
color: var(--yj-ink-secondary);
|
||
background: var(--yj-brand-soft);
|
||
border-radius: var(--yj-radius-sm);
|
||
border: 1px solid rgba(37, 99, 235, 0.12);
|
||
}
|
||
}
|
||
|
||
.doctor-daily-table {
|
||
width: 100%;
|
||
border-radius: var(--yj-radius-sm);
|
||
overflow: hidden;
|
||
|
||
:deep(.el-table__header th) {
|
||
background: var(--yj-surface-muted) !important;
|
||
color: var(--yj-ink-secondary) !important;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
}
|
||
|
||
:deep(.el-table__footer-wrapper td) {
|
||
font-weight: 700;
|
||
background: var(--yj-accent-soft) !important;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.filter-surface {
|
||
padding: 20px;
|
||
background: var(--yj-surface);
|
||
}
|
||
|
||
.filter-row {
|
||
&--top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
gap: 16px 20px;
|
||
}
|
||
}
|
||
|
||
.filter-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
|
||
.lbl {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--yj-muted);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
&--wide {
|
||
flex: 1 1 280px;
|
||
min-width: 240px;
|
||
}
|
||
|
||
&--date-top {
|
||
flex: 1 1 320px;
|
||
min-width: 260px;
|
||
}
|
||
}
|
||
|
||
.filter-select {
|
||
width: 100%;
|
||
|
||
&--channel {
|
||
min-width: 220px;
|
||
max-width: 320px;
|
||
}
|
||
|
||
&--dept {
|
||
min-width: 280px;
|
||
}
|
||
}
|
||
|
||
.filter-dept-tree-wrap {
|
||
min-height: 32px;
|
||
}
|
||
|
||
.filter-dept-tree-select {
|
||
width: 100%;
|
||
}
|
||
|
||
.filter-date-picker {
|
||
width: 100%;
|
||
max-width: 320px;
|
||
}
|
||
|
||
.filter-hint {
|
||
display: none; /* 已在模板中移除,保留class以免报错 */
|
||
}
|
||
|
||
.filter-quick {
|
||
margin-top: 16px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px 14px;
|
||
}
|
||
|
||
.filter-quick__title {
|
||
flex-shrink: 0;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--yj-muted);
|
||
}
|
||
|
||
.date-pills {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.date-pill {
|
||
appearance: none;
|
||
margin: 0;
|
||
border: 1px solid var(--yj-line);
|
||
background: var(--yj-surface);
|
||
color: var(--yj-ink-secondary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
font-family: inherit;
|
||
padding: 7px 14px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
line-height: 1.4;
|
||
transition: all 0.15s ease;
|
||
&:hover {
|
||
border-color: var(--yj-brand);
|
||
color: var(--yj-brand);
|
||
background: var(--yj-brand-soft);
|
||
}
|
||
&:focus-visible {
|
||
outline: 2px solid var(--yj-brand);
|
||
outline-offset: 2px;
|
||
}
|
||
}
|
||
|
||
.filter-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.filter-btn {
|
||
padding: 8px 20px;
|
||
font-weight: 600;
|
||
border-radius: var(--yj-radius-sm);
|
||
&__icon { margin-right: 4px; font-size: 15px; vertical-align: -2px; }
|
||
&--query {
|
||
background: var(--yj-brand);
|
||
color: #fff;
|
||
border: none;
|
||
&:hover { opacity: 0.9; color: #fff; }
|
||
}
|
||
&--reset {
|
||
border: 1px solid var(--yj-line);
|
||
background: var(--yj-surface);
|
||
&:hover { border-color: var(--yj-brand); color: var(--yj-brand); }
|
||
&:disabled { opacity: 0.5; }
|
||
}
|
||
}
|
||
|
||
.channel-opt-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
width: 100%;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.opt-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.opt-count {
|
||
flex-shrink: 0;
|
||
font-size: 12px;
|
||
color: var(--yj-muted);
|
||
}
|
||
|
||
.filter-alert {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.tables-wrap {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
gap: 20px;
|
||
}
|
||
|
||
.leaderboards-wrap {
|
||
margin-top: 22px;
|
||
min-height: 48px;
|
||
}
|
||
|
||
.leaderboard-hint {
|
||
margin: 0 0 16px;
|
||
padding: 14px 16px;
|
||
font-size: 13px;
|
||
color: var(--yj-ink-secondary);
|
||
line-height: 1.6;
|
||
background: var(--yj-surface);
|
||
border: 1px solid var(--yj-line);
|
||
border-radius: var(--yj-radius-sm);
|
||
box-shadow: var(--yj-shadow-sm);
|
||
|
||
.muted {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: var(--yj-muted);
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
/* 目标完成率色条:目标看板(卡片)Tab 复用 */
|
||
.tp-good {
|
||
color: #15803d !important;
|
||
font-weight: 700;
|
||
}
|
||
.tp-mid {
|
||
color: #c2410c !important;
|
||
font-weight: 700;
|
||
}
|
||
.tp-bad {
|
||
color: #b91c1c !important;
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* 目标看板卡片栅格(与顶部「甄养堂」条带数据同源,8 列铺满) */
|
||
.tp-matrix-wrap {
|
||
min-height: 120px;
|
||
padding: 8px 4px 16px;
|
||
}
|
||
|
||
.tp-matrix__title {
|
||
margin: 0 0 18px;
|
||
text-align: center;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--yj-ink);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.tp-matrix {
|
||
display: grid;
|
||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
@media (max-width: 1600px) {
|
||
.tp-matrix {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.tp-matrix {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
.tp-matrix-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
border: 1px solid var(--yj-line);
|
||
border-radius: var(--yj-radius-sm);
|
||
background: var(--yj-surface);
|
||
box-shadow: var(--yj-shadow-sm);
|
||
font-size: 12px;
|
||
overflow: hidden;
|
||
|
||
&__head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
background: var(--yj-brand-soft);
|
||
border-bottom: 1px solid var(--yj-line);
|
||
}
|
||
|
||
&__titles {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
|
||
&__parent {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--yj-muted);
|
||
line-height: 1.2;
|
||
}
|
||
|
||
&__name {
|
||
font-weight: 700;
|
||
font-size: 12px;
|
||
color: var(--yj-ink);
|
||
line-height: 1.35;
|
||
}
|
||
|
||
&__target {
|
||
flex-shrink: 0;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--yj-accent);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
&__table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
&__lbl {
|
||
padding: 7px 10px;
|
||
color: var(--yj-muted);
|
||
font-size: 11px;
|
||
border-bottom: 1px solid var(--yj-line);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
&__num {
|
||
padding: 7px 10px;
|
||
font-weight: 600;
|
||
text-align: right;
|
||
border-bottom: 1px solid var(--yj-line);
|
||
color: var(--yj-ink);
|
||
font-variant-numeric: tabular-nums;
|
||
|
||
&--hot {
|
||
color: var(--yj-accent);
|
||
font-weight: 700;
|
||
}
|
||
}
|
||
|
||
tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
}
|
||
|
||
.tp-matrix-empty {
|
||
padding: 32px 16px;
|
||
}
|
||
|
||
.leaderboard-sub {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
opacity: 0.95;
|
||
}
|
||
|
||
.leaderboard-table {
|
||
.th-sub {
|
||
display: block;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.col-rank {
|
||
width: 52px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.col-abbr {
|
||
min-width: 56px;
|
||
}
|
||
|
||
.col-name {
|
||
min-width: 72px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.col-consult {
|
||
width: 84px;
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.col-deal {
|
||
width: 84px;
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.col-assign {
|
||
width: 84px;
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.col-appointment {
|
||
width: 88px;
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
}
|
||
|
||
.leaderboard-empty {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.yeji-table__foot--left {
|
||
text-align: left;
|
||
}
|
||
|
||
.empty-tip {
|
||
grid-column: 1 / -1;
|
||
background: var(--yj-surface);
|
||
border-radius: var(--yj-radius-sm);
|
||
padding: 36px 16px;
|
||
border: 1px dashed var(--yj-line-strong);
|
||
}
|
||
|
||
.yeji-th-label {
|
||
display: inline-block;
|
||
line-height: 1.25;
|
||
vertical-align: middle;
|
||
max-width: 6.5rem;
|
||
}
|
||
|
||
.yeji-table {
|
||
background: var(--yj-surface);
|
||
border-radius: var(--yj-radius);
|
||
overflow: hidden;
|
||
box-shadow: var(--yj-shadow-sm);
|
||
border: 1px solid var(--yj-line);
|
||
|
||
&__scroll {
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-gutter: stable;
|
||
outline: none;
|
||
|
||
&::-webkit-scrollbar {
|
||
height: 10px;
|
||
}
|
||
&::-webkit-scrollbar-thumb {
|
||
background: color-mix(in srgb, var(--yj-muted) 50%, transparent);
|
||
border-radius: 999px;
|
||
}
|
||
&::-webkit-scrollbar-track {
|
||
background: var(--yj-surface-muted);
|
||
}
|
||
|
||
&:focus-visible {
|
||
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--yj-brand) 40%, transparent);
|
||
}
|
||
}
|
||
|
||
&__header {
|
||
background: var(--yj-surface-muted);
|
||
color: var(--yj-ink);
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
padding: 14px 16px;
|
||
border-bottom: 1px solid var(--yj-line);
|
||
}
|
||
|
||
&__body {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: var(--yj-ink);
|
||
|
||
thead th.col-dept,
|
||
thead th.col-abbr {
|
||
min-width: 88px;
|
||
background: linear-gradient(
|
||
180deg,
|
||
var(--yj-dept-col-head-start) 0%,
|
||
var(--yj-dept-col-head-end) 100%
|
||
);
|
||
color: var(--yj-dept-col-head-ink);
|
||
font-weight: 700;
|
||
border-right: 2px solid var(--yj-dept-col-border);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||
}
|
||
|
||
thead th.col-abbr {
|
||
min-width: 56px;
|
||
}
|
||
|
||
tbody td.col-dept,
|
||
tbody td.col-abbr {
|
||
background: var(--yj-dept-col-bg);
|
||
color: var(--yj-ink);
|
||
font-weight: 600;
|
||
border-right: 2px solid var(--yj-dept-col-border);
|
||
text-align: center;
|
||
}
|
||
|
||
tbody tr.yeji-row--action:not(.yeji-row--idle):hover td.col-dept,
|
||
tbody tr.yeji-row--action:not(.yeji-row--idle):hover td.col-abbr {
|
||
background: var(--yj-dept-col-bg-hover);
|
||
}
|
||
|
||
.total-row td.col-dept {
|
||
background: color-mix(in srgb, var(--yj-accent-soft) 55%, var(--yj-dept-col-bg) 45%);
|
||
color: var(--yj-ink);
|
||
font-weight: 700;
|
||
border-right: 2px solid color-mix(in srgb, var(--yj-dept-col-border) 55%, var(--yj-line) 45%);
|
||
}
|
||
|
||
thead th {
|
||
background: #f1f5f9;
|
||
color: var(--yj-ink-secondary);
|
||
font-weight: 600;
|
||
padding: 10px 8px;
|
||
border: 1px solid var(--yj-line);
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: 12px;
|
||
.col-info {
|
||
margin-left: 4px;
|
||
color: var(--yj-muted);
|
||
cursor: help;
|
||
vertical-align: -2px;
|
||
font-size: 14px;
|
||
transition: color 0.15s;
|
||
&:hover { color: var(--yj-brand); }
|
||
}
|
||
}
|
||
|
||
tbody td {
|
||
padding: 10px 8px;
|
||
border: 1px solid var(--yj-line);
|
||
white-space: nowrap;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
tbody tr.yeji-row--action {
|
||
cursor: pointer;
|
||
transition: background 0.18s ease;
|
||
|
||
&:hover {
|
||
background: var(--yj-brand-soft);
|
||
}
|
||
|
||
&.yeji-row--unassigned {
|
||
cursor: pointer;
|
||
}
|
||
|
||
&.yeji-row--idle {
|
||
cursor: default;
|
||
|
||
&:hover {
|
||
background: transparent;
|
||
}
|
||
}
|
||
}
|
||
|
||
.col-completed {
|
||
color: #0f766e;
|
||
font-weight: 600;
|
||
background: var(--yj-teal-soft);
|
||
}
|
||
|
||
.total-row {
|
||
font-weight: 700;
|
||
background: var(--yj-accent-soft);
|
||
color: var(--yj-ink);
|
||
|
||
td {
|
||
border-color: rgba(217, 119, 6, 0.2);
|
||
}
|
||
}
|
||
|
||
.roi-good {
|
||
color: #15803d;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.roi-mid {
|
||
color: #c2410c;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.roi-bad {
|
||
color: #b91c1c;
|
||
font-weight: 600;
|
||
}
|
||
|
||
&.yeji-table__body--data {
|
||
min-width: max(100%, 1180px);
|
||
width: max-content;
|
||
|
||
thead th.col-dept,
|
||
tbody td.col-dept {
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 3;
|
||
}
|
||
thead th.col-dept {
|
||
z-index: 6;
|
||
text-align: left;
|
||
padding-left: 12px;
|
||
padding-right: 10px;
|
||
box-shadow: 8px 0 20px -12px rgba(15, 23, 42, 0.35);
|
||
}
|
||
tbody td.col-dept {
|
||
box-shadow: 8px 0 20px -12px rgba(15, 23, 42, 0.28);
|
||
white-space: normal;
|
||
max-width: 300px;
|
||
line-height: 1.35;
|
||
text-align: left;
|
||
padding-left: 12px;
|
||
padding-right: 10px;
|
||
word-break: break-word;
|
||
}
|
||
.total-row td.col-dept {
|
||
box-shadow: 8px 0 20px -12px rgba(15, 23, 42, 0.28);
|
||
}
|
||
thead th:not(.col-dept) {
|
||
min-width: 88px;
|
||
}
|
||
thead th.col-narrow-head {
|
||
min-width: 76px;
|
||
max-width: 112px;
|
||
padding: 8px 6px;
|
||
vertical-align: middle;
|
||
line-height: 1.2;
|
||
}
|
||
}
|
||
|
||
&.yeji-table__body--leaderboard {
|
||
min-width: max(100%, 780px);
|
||
width: max-content;
|
||
|
||
thead th.col-rank,
|
||
tbody td.col-rank {
|
||
position: sticky;
|
||
left: 0;
|
||
z-index: 3;
|
||
}
|
||
thead th.col-rank {
|
||
z-index: 6;
|
||
background: linear-gradient(
|
||
180deg,
|
||
var(--yj-dept-col-head-start) 0%,
|
||
var(--yj-dept-col-head-end) 100%
|
||
) !important;
|
||
box-shadow: 8px 0 20px -12px rgba(15, 23, 42, 0.3);
|
||
}
|
||
tbody td.col-rank {
|
||
background: var(--yj-surface);
|
||
box-shadow: 8px 0 20px -12px rgba(15, 23, 42, 0.22);
|
||
}
|
||
tbody tr.yeji-row--action:hover td.col-rank {
|
||
background: var(--yj-brand-soft);
|
||
}
|
||
}
|
||
}
|
||
|
||
&__foot {
|
||
padding: 11px 14px;
|
||
font-size: 12px;
|
||
color: var(--yj-muted);
|
||
background: var(--yj-surface-muted);
|
||
border-top: 1px solid var(--yj-line);
|
||
text-align: right;
|
||
line-height: 1.5;
|
||
}
|
||
}
|
||
|
||
.yeji-order-drawer__hint {
|
||
margin: 0 0 12px;
|
||
font-size: 13px;
|
||
color: var(--yj-muted);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.yeji-order-drawer__header {
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.yeji-order-drawer__heading {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--yj-ink);
|
||
line-height: 1.35;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.yeji-order-drawer__sub {
|
||
margin: 6px 0 0;
|
||
font-size: 13px;
|
||
color: var(--yj-muted);
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.yeji-order-drawer__shell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.yeji-order-drawer__stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.yeji-order-drawer__stat-card {
|
||
border: 1px solid var(--yj-line);
|
||
border-radius: var(--yj-radius-sm);
|
||
padding: 14px 16px;
|
||
background: var(--yj-surface);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
transition:
|
||
border-color 0.2s ease,
|
||
box-shadow 0.2s ease;
|
||
|
||
&:hover {
|
||
border-color: rgba(37, 99, 235, 0.22);
|
||
box-shadow: var(--yj-shadow-sm);
|
||
}
|
||
|
||
&--accent {
|
||
border-color: rgba(220, 38, 38, 0.18);
|
||
background: linear-gradient(180deg, #fef2f2 0%, var(--el-fill-color-blank) 100%);
|
||
}
|
||
|
||
&--muted {
|
||
background: #f8fafc;
|
||
}
|
||
|
||
&--total {
|
||
border-color: rgba(217, 119, 6, 0.22);
|
||
background: linear-gradient(180deg, #fffbeb 0%, var(--el-fill-color-blank) 100%);
|
||
}
|
||
}
|
||
|
||
.yeji-order-drawer__list-tabs {
|
||
:deep(.el-tabs__header) {
|
||
margin: 0;
|
||
padding: 0;
|
||
border-bottom: 1px solid var(--yj-line);
|
||
}
|
||
|
||
:deep(.el-tabs__content) {
|
||
display: none;
|
||
}
|
||
|
||
:deep(.el-tabs__nav-wrap::after) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.yeji-order-drawer__stat-label {
|
||
font-size: 12px;
|
||
color: var(--yj-muted);
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.yeji-order-drawer__stat-note {
|
||
display: block;
|
||
margin-top: 2px;
|
||
font-size: 11px;
|
||
font-weight: 400;
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
|
||
.yeji-order-drawer__stat-value {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--yj-ink);
|
||
font-variant-numeric: tabular-nums;
|
||
|
||
&--money {
|
||
font-size: 21px;
|
||
color: #b91c1c;
|
||
}
|
||
}
|
||
|
||
.yeji-order-drawer__body {
|
||
flex: 1;
|
||
min-height: 120px;
|
||
}
|
||
|
||
.yeji-order-drawer__pager {
|
||
margin-top: 4px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--yj-line);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.yeji-order-appt-lines {
|
||
line-height: 1.45;
|
||
font-size: 12px;
|
||
color: var(--yj-ink, #0f172a);
|
||
}
|
||
|
||
.yeji-order-appt-line {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 4px 6px;
|
||
margin-bottom: 2px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
&.yeji-order-appt-line--channel {
|
||
align-items: flex-start;
|
||
|
||
.yeji-order-appt-channel-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.yeji-order-appt-info {
|
||
font-size: 14px;
|
||
color: var(--yj-muted, #94a3b8);
|
||
cursor: help;
|
||
flex-shrink: 0;
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.yeji-order-appt-k {
|
||
flex: 0 0 auto;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--yj-muted, #64748b);
|
||
}
|
||
|
||
.yeji-order-appt-sub {
|
||
font-size: 11px;
|
||
color: var(--yj-muted, #64748b);
|
||
}
|
||
|
||
.yeji-order-appt-empty {
|
||
font-size: 12px;
|
||
color: var(--yj-muted, #94a3b8);
|
||
}
|
||
|
||
.yeji-order-amt {
|
||
color: #b91c1c;
|
||
font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
:deep(.yeji-order-drawer.el-drawer) {
|
||
--el-drawer-padding-primary: 18px;
|
||
}
|
||
|
||
:deep(.yeji-order-drawer .el-drawer__header) {
|
||
margin-bottom: 10px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--yj-line, #e2e8f0);
|
||
}
|
||
|
||
:deep(.yeji-order-drawer .el-pagination) {
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.yeji-unassigned-dialog__head {
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.yeji-unassigned-dialog__title {
|
||
display: block;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--yj-ink, #0f172a);
|
||
}
|
||
|
||
.yeji-unassigned-dialog__sub {
|
||
margin: 6px 0 0;
|
||
font-size: 12px;
|
||
color: var(--yj-ink-secondary, #64748b);
|
||
font-weight: 400;
|
||
}
|
||
|
||
.yeji-unassigned-dialog__note {
|
||
margin: 0 0 12px;
|
||
padding: 10px 12px;
|
||
font-size: 12px;
|
||
line-height: 1.65;
|
||
color: var(--yj-ink-secondary, #64748b);
|
||
background: var(--yj-accent-soft, #fff7ed);
|
||
border-radius: 8px;
|
||
border: 1px solid color-mix(in srgb, var(--yj-line, #e2e8f0) 80%, transparent);
|
||
}
|
||
|
||
.yeji-unassigned-dialog__body {
|
||
min-height: 120px;
|
||
}
|
||
|
||
.yeji-lead-cell--link {
|
||
color: var(--yj-brand, #2563eb);
|
||
font-weight: 600;
|
||
text-decoration: underline;
|
||
text-underline-offset: 2px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
tbody tr.yeji-row--action:hover .yeji-lead-cell--link {
|
||
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
|
||
}
|
||
|
||
tbody tr.total-row:hover .yeji-lead-cell--link {
|
||
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
|
||
}
|
||
|
||
.yeji-leadlines-dialog__pager {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.date-pill,
|
||
.filter-btn,
|
||
.yeji-table__body tbody tr.yeji-row--action,
|
||
.yeji-order-drawer__stat-card {
|
||
transition: none;
|
||
}
|
||
}
|
||
</style>
|