Files
zyt/admin/src/views/fans/yeji.vue
T
2026-05-12 10:59:03 +08:00

4833 lines
184 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 &gt; 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>