2067 lines
78 KiB
Vue
2067 lines
78 KiB
Vue
<template>
|
||
<div class="cs-page">
|
||
<header class="cs-masthead">
|
||
<div class="cs-masthead__inner">
|
||
<div class="cs-masthead__main">
|
||
<span class="cs-masthead__eyebrow">
|
||
<span class="cs-masthead__dot"></span>
|
||
财务结算 · 业绩台账
|
||
</span>
|
||
<h1 class="cs-masthead__title">提成结算业绩</h1>
|
||
<p class="cs-masthead__desc">
|
||
订单池默认与<strong>处方订单列表</strong>同源:<strong>创建时间 start_time~end_time</strong> +
|
||
<strong>fulfillment_status</strong>(默认完成态 3),账号<strong>列表可见性/数据域</strong>一并生效。
|
||
选结算月<strong>仍可</strong>套用「物流签收<strong>与</strong>尾款支付<strong>均须在</strong><strong>结算月 7 日 24 点</strong>前」拆分「本期提成 / 顺延下期」
|
||
(仅有签收在范围内、尾款晚于截止仍会顺延);
|
||
展示部门/渠道下拉与列表不同,勾选后会与列表<strong>计数不同</strong>。
|
||
</p>
|
||
</div>
|
||
<dl v-if="meta.settlement_month || meta.cutoff_end" class="cs-masthead__stats">
|
||
<div class="cs-stat">
|
||
<dt class="cs-stat__label">结算月</dt>
|
||
<dd class="cs-stat__value">{{ meta.settlement_month || settlementMonth }}</dd>
|
||
</div>
|
||
<div v-if="meta.order_month || meta.order_start_time" class="cs-stat">
|
||
<dt class="cs-stat__label">订单时间窗</dt>
|
||
<dd class="cs-stat__value cs-stat__value--xs">{{ orderWindowLabel }}</dd>
|
||
</div>
|
||
<div v-if="meta.cutoff_end" class="cs-stat">
|
||
<dt class="cs-stat__label">截止时刻</dt>
|
||
<dd class="cs-stat__value cs-stat__value--xs">{{ meta.cutoff_end }}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
</header>
|
||
|
||
<el-card class="cs-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-date-picker
|
||
v-model="settlementMonth"
|
||
type="month"
|
||
value-format="YYYY-MM"
|
||
placeholder="选择结算月"
|
||
class="filter-month"
|
||
/>
|
||
</div>
|
||
<div class="filter-field filter-field--rangewrap">
|
||
<span class="lbl">订单创建</span>
|
||
<el-date-picker
|
||
v-model="orderTimeRange"
|
||
type="datetimerange"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
range-separator="至"
|
||
start-placeholder="列表 start_time"
|
||
end-placeholder="列表 end_time"
|
||
class="filter-datetimerange"
|
||
/>
|
||
<el-checkbox v-model="requireSystemAutoOnly" class="filter-sa-check">
|
||
仅系统代开处方
|
||
</el-checkbox>
|
||
</div>
|
||
<div class="filter-field">
|
||
<span class="lbl">渠道来源</span>
|
||
<el-select
|
||
v-model="selectedChannel"
|
||
filterable
|
||
clearable
|
||
placeholder="全部"
|
||
class="filter-select filter-select--channel"
|
||
:loading="channelOptionsLoading"
|
||
@change="loadOverview"
|
||
>
|
||
<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"
|
||
/>
|
||
</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-actions">
|
||
<el-button
|
||
type="primary"
|
||
class="filter-btn filter-btn--query"
|
||
:loading="loading"
|
||
@click="loadOverview"
|
||
>
|
||
<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>
|
||
</el-card>
|
||
|
||
<el-alert type="info" :closable="false" show-icon class="cs-alert">
|
||
<template #title>
|
||
<span class="cs-alert__title">统计口径</span>
|
||
</template>
|
||
<div class="cs-alert__body">
|
||
<p v-if="meta.rule_note">{{ meta.rule_note }}</p>
|
||
<p v-else>
|
||
请选择结算月并设置<strong>订单创建时间范围</strong>(与处方订单列表 URL 参数
|
||
<code>start_time</code>/<code>end_time</code>/<code>fulfillment_status</code>
|
||
一致);切换结算月会自动带出<strong>上一自然月整段</strong>作为默认时段,可按业务修改(例如 2026-04-02~04-30)。
|
||
「仅系统代开处方」勾选后与列表在未限制开方类型时的条数会不一致。
|
||
</p>
|
||
<p v-if="meta.cutoff_end" class="cs-alert__meta">
|
||
当前结算月:<strong>{{ meta.settlement_month }}</strong>
|
||
· 订单时间:<strong>{{ orderWindowLabel }}</strong>
|
||
· 履约状态:<strong>{{ meta.fulfillment_status ?? 3 }}</strong>
|
||
· 截止时刻:<strong>{{ meta.cutoff_end }}</strong>
|
||
<template v-if="meta.channel_name">
|
||
· 渠道:<strong>{{ meta.channel_name }}</strong>
|
||
</template>
|
||
<template v-if="meta.settlement_month">
|
||
· 处方池:<strong>{{
|
||
meta.require_system_auto_prescription ? '仅系统代开' : '含手动与系统'
|
||
}}</strong>
|
||
<template v-if="meta.explicit_order_time_window">
|
||
· 列表对齐:<strong>是</strong></template>
|
||
</template>
|
||
</p>
|
||
</div>
|
||
</el-alert>
|
||
|
||
<el-card class="cs-panel cs-reconcile-card" shadow="never">
|
||
<div class="cs-reconcile">
|
||
<div class="cs-reconcile__head">
|
||
<div class="cs-reconcile__tags">
|
||
<el-tag v-if="settlementConfirmedLocked" type="success" effect="dark">已确定业绩</el-tag>
|
||
<el-tag v-else-if="confirmInfo?.status === 2" type="warning" effect="plain">已撤回确定</el-tag>
|
||
<el-tag v-else type="info" effect="plain">待确定业绩</el-tag>
|
||
<span
|
||
v-if="settlementConfirmedLocked && confirmInfo?.confirmed_at_text"
|
||
class="cs-reconcile__meta"
|
||
>
|
||
{{ confirmInfo.confirmed_admin_name || '—' }} · {{ confirmInfo.confirmed_at_text }}
|
||
</span>
|
||
<span
|
||
v-else-if="confirmInfo?.status === 2 && confirmInfo.revoked_at_text"
|
||
class="cs-reconcile__meta"
|
||
>
|
||
撤回:{{ confirmInfo.revoked_admin_name || '—' }} · {{ confirmInfo.revoked_at_text }}
|
||
</span>
|
||
</div>
|
||
<div class="cs-reconcile__toolbar">
|
||
<el-button type="primary" plain @click="openReconcileDialog">业绩明细核对</el-button>
|
||
<el-button :disabled="settlementConfirmedLocked || reconcileSaving" @click="handleSaveReconcile">
|
||
保存核对备注
|
||
</el-button>
|
||
<el-button
|
||
type="warning"
|
||
:disabled="settlementConfirmedLocked || confirmSubmitting"
|
||
@click="handleConfirmFinalize"
|
||
>
|
||
确定本期业绩
|
||
</el-button>
|
||
<el-button
|
||
v-if="settlementConfirmedLocked"
|
||
type="danger"
|
||
plain
|
||
:loading="confirmRevoking"
|
||
@click="handleConfirmRevoke"
|
||
>
|
||
撤回确定
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<el-input
|
||
v-model="reconcileNote"
|
||
type="textarea"
|
||
:rows="2"
|
||
maxlength="500"
|
||
show-word-limit
|
||
placeholder="核对备注(可与财务线下对齐后填写;确定业绩前可反复保存)"
|
||
:disabled="settlementConfirmedLocked"
|
||
class="cs-reconcile__note"
|
||
/>
|
||
<p v-if="Number(totalAgg.carry_in_order_count) > 0" class="cs-reconcile__carry">
|
||
上期顺延汇入本期:<strong>{{ totalAgg.carry_in_order_count }}</strong> 单(已并入当前汇总)
|
||
</p>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-card class="cs-panel cs-summary-card" shadow="never">
|
||
<el-tabs v-model="summaryTab" class="cs-summary-tabs">
|
||
<el-tab-pane label="部门汇总" name="dept">
|
||
<div class="cs-table-head">
|
||
<div class="cs-table-head__row">
|
||
<span class="cs-table-head__title">部门汇总</span>
|
||
<div v-if="drilledFromDept" class="cs-drill-state">
|
||
<el-tag size="small" type="info" effect="plain" class="cs-drill-state__tag">
|
||
当前下钻:{{ drilledFromDept.path || drilledFromDept.name }}
|
||
</el-tag>
|
||
<el-button link type="primary" class="cs-drill-state__back" @click="handleDrillUp">
|
||
<el-icon><ArrowUp /></el-icon>
|
||
{{ drillUpLabel }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<span class="cs-table-head__hint">
|
||
金额单位:元;与业绩看板相同的部门摊列规则(业绩归属医助 → 展示「中心」)。
|
||
<strong>点击部门</strong>:若有子部门则下钻一层;末级部门弹出「已完成业绩订单」明细。
|
||
</span>
|
||
</div>
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="rows"
|
||
border
|
||
stripe
|
||
size="small"
|
||
class="cs-table cs-table--clickable"
|
||
show-summary
|
||
:summary-method="getSummaries"
|
||
>
|
||
<el-table-column prop="dept_name" label="部门" min-width="220" fixed>
|
||
<template #default="{ row }">
|
||
<el-link
|
||
v-if="row.dept_id > 0"
|
||
type="primary"
|
||
:underline="false"
|
||
class="cs-dept-link"
|
||
@click="handleDeptRowClick(row)"
|
||
>
|
||
<span class="cs-dept-link__text">{{ row.dept_name }}</span>
|
||
<el-icon class="cs-dept-link__icon">
|
||
<ArrowRight v-if="deptHasChildren(row.dept_id)" />
|
||
<Tickets v-else />
|
||
</el-icon>
|
||
</el-link>
|
||
<span v-else class="cs-muted">{{ row.dept_name }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="completed_order_count" label="已完成单数" min-width="104" align="right" />
|
||
<el-table-column prop="completed_order_amount" label="已完成金额" min-width="112" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="current_period_count" label="本期提成单数" min-width="118" align="right" />
|
||
<el-table-column prop="current_period_amount" label="本期提成金额" min-width="118" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.current_period_amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="deferred_period_count" label="顺延下期单数" min-width="118" align="right" />
|
||
<el-table-column prop="deferred_period_amount" label="顺延下期金额" min-width="118" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.deferred_period_amount) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="按医助下发" name="assistant">
|
||
<div class="cs-table-head">
|
||
<span class="cs-table-head__title">业绩归属医助 × 挂号渠道</span>
|
||
<span class="cs-table-head__hint">
|
||
按诊单业绩归属医助 <strong>×</strong> 挂号渠道(doctor_appointment.channels)拆分到行;
|
||
<strong>「复诊2」「复诊3」「自媒体1」等保持各自独立</strong>,未关联挂号统一归到「未匹配挂号渠道」。
|
||
同一医助多行<strong>自动合并「医助 / 归属中心」单元格</strong>,「顺延汇入」为上期结转进入本期订单池的单数。
|
||
<strong>展示部门</strong>有勾选时,本表仅列出<strong>可归入该部门上下文</strong>的业绩医助(与人事部门落在所选部门范围内的账号一致)。
|
||
<strong>点击挂号渠道</strong>:查看该行对应的业务订单明细(与上方部门筛选一致)。
|
||
</span>
|
||
</div>
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="assistantChannelRows"
|
||
border
|
||
stripe
|
||
size="small"
|
||
class="cs-table cs-table--grouped"
|
||
show-summary
|
||
:summary-method="getPersonSummaries"
|
||
:span-method="assistantSpanMethod"
|
||
>
|
||
<el-table-column prop="assistant_name" label="医助" min-width="120" fixed show-overflow-tooltip />
|
||
<el-table-column prop="primary_dept_name" label="归属中心" min-width="120" show-overflow-tooltip />
|
||
<el-table-column prop="appt_channel_name" label="挂号渠道" min-width="120" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<el-tag
|
||
size="small"
|
||
class="cs-channel-tag cs-channel-tag--clickable"
|
||
:class="channelTagClass(row.appt_channel_name)"
|
||
effect="plain"
|
||
@click.stop="openOrdersForAssistantChannel(row)"
|
||
>
|
||
{{ row.appt_channel_name }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="carry_in_order_count" label="顺延汇入单数" min-width="112" align="right" />
|
||
<el-table-column prop="completed_order_count" label="已完成单数" min-width="104" align="right" />
|
||
<el-table-column prop="completed_order_amount" label="已完成金额" min-width="112" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="current_period_count" label="本期提成单数" min-width="118" align="right" />
|
||
<el-table-column prop="current_period_amount" label="本期提成金额" min-width="118" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.current_period_amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="deferred_period_count" label="顺延下期单数" min-width="118" align="right" />
|
||
<el-table-column prop="deferred_period_amount" label="顺延下期金额" min-width="118" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.deferred_period_amount) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="按医生下发" name="doctor">
|
||
<div class="cs-table-head">
|
||
<span class="cs-table-head__title">处方开方医生 × 挂号渠道</span>
|
||
<span class="cs-table-head__hint">
|
||
按处方创建账号(开方人) <strong>×</strong> 挂号渠道拆分;
|
||
可与医助维度交叉核对;同一医生多行自动合并「医生 / 归属中心」单元格。
|
||
<strong>展示部门</strong>有勾选时,与医助表相同,仅统计<strong>该部门上下文内</strong>订单对应的开方医生。
|
||
<strong>点击挂号渠道</strong>:查看该行对应的业务订单明细。
|
||
</span>
|
||
</div>
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="doctorChannelRows"
|
||
border
|
||
stripe
|
||
size="small"
|
||
class="cs-table cs-table--grouped"
|
||
show-summary
|
||
:summary-method="getPersonSummaries"
|
||
:span-method="doctorSpanMethod"
|
||
>
|
||
<el-table-column prop="doctor_name" label="医生" min-width="120" fixed show-overflow-tooltip />
|
||
<el-table-column prop="primary_dept_name" label="归属中心" min-width="120" show-overflow-tooltip />
|
||
<el-table-column prop="appt_channel_name" label="挂号渠道" min-width="120" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<el-tag
|
||
size="small"
|
||
class="cs-channel-tag cs-channel-tag--clickable"
|
||
:class="channelTagClass(row.appt_channel_name)"
|
||
effect="plain"
|
||
@click.stop="openOrdersForDoctorChannel(row)"
|
||
>
|
||
{{ row.appt_channel_name }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="carry_in_order_count" label="顺延汇入单数" min-width="112" align="right" />
|
||
<el-table-column prop="completed_order_count" label="已完成单数" min-width="104" align="right" />
|
||
<el-table-column prop="completed_order_amount" label="已完成金额" min-width="112" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="current_period_count" label="本期提成单数" min-width="118" align="right" />
|
||
<el-table-column prop="current_period_amount" label="本期提成金额" min-width="118" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.current_period_amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="deferred_period_count" label="顺延下期单数" min-width="118" align="right" />
|
||
<el-table-column prop="deferred_period_amount" label="顺延下期金额" min-width="118" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.deferred_period_amount) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</el-card>
|
||
|
||
<el-dialog
|
||
v-model="reconcileDialogVisible"
|
||
title="业绩明细核对"
|
||
width="1280px"
|
||
destroy-on-close
|
||
class="cs-dialog"
|
||
@open="onReconcileDialogOpen"
|
||
>
|
||
<div class="cs-dialog__bar">
|
||
<el-radio-group v-model="linesBucket" size="small" @change="loadOrderLines(1)">
|
||
<el-radio-button label="all">全部</el-radio-button>
|
||
<el-radio-button label="current">本期提成</el-radio-button>
|
||
<el-radio-button label="deferred">顺延下期</el-radio-button>
|
||
</el-radio-group>
|
||
</div>
|
||
<el-table v-loading="linesLoading" :data="linesRows" border stripe size="small" max-height="440">
|
||
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
|
||
<el-table-column label="患者" min-width="112" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<div class="cs-patient-cell">
|
||
<span class="cs-patient-cell__name">{{ row.patient_name || '—' }}</span>
|
||
<span v-if="row.patient_phone || row.patient_gender_text" class="cs-patient-cell__meta cs-muted">
|
||
<template v-if="row.patient_gender_text">{{ row.patient_gender_text }}</template>
|
||
<template v-if="row.patient_gender_text && row.patient_phone"> · </template>
|
||
<template v-if="row.patient_phone">{{ row.patient_phone }}</template>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="挂号日期" min-width="124" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span>{{ row.appointment_date_text || '—' }}</span>
|
||
<el-tag
|
||
v-if="row.appointment_status && Number(row.appointment_status) !== 3"
|
||
class="cs-tag-appt-muted"
|
||
type="info"
|
||
size="small"
|
||
effect="plain"
|
||
>未完结挂号</el-tag
|
||
>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="挂号渠道" min-width="124" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.appt_channel_name || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="assistant_name" label="业绩医助" min-width="100" show-overflow-tooltip />
|
||
<el-table-column prop="doctor_name" label="开方医生" min-width="100" show-overflow-tooltip />
|
||
<el-table-column prop="primary_dept_name" label="归属部门" min-width="100" show-overflow-tooltip />
|
||
<el-table-column prop="amount" label="金额" width="108" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="分段" width="88" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.bucket === 'current'" type="success" size="small">本期</el-tag>
|
||
<el-tag v-else type="warning" size="small">顺延</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="结转" width="72" align="center">
|
||
<template #default="{ row }">
|
||
<span v-if="row.is_carry_in">是</span>
|
||
<span v-else class="cs-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="顺延说明" min-width="200" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span v-if="row.bucket === 'deferred' && row.bucket_defer_text" class="cs-muted">{{
|
||
row.bucket_defer_text
|
||
}}</span>
|
||
<span v-else class="cs-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="物流签收" min-width="152" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.sign_time_text || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="pay_time_text" label="尾款支付" min-width="152" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.pay_time_text || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="create_time_text" label="订单创建" width="152" />
|
||
</el-table>
|
||
<div class="cs-dialog__pager">
|
||
<el-pagination
|
||
layout="total, prev, pager, next"
|
||
:total="linesTotal"
|
||
:page-size="linesPageSize"
|
||
:current-page="linesPage"
|
||
@current-change="loadOrderLines"
|
||
/>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="leafOrdersVisible"
|
||
:title="leafOrdersDialogTitle"
|
||
width="1280px"
|
||
destroy-on-close
|
||
class="cs-dialog"
|
||
>
|
||
<div class="cs-dialog__bar cs-dialog__bar--split">
|
||
<el-radio-group v-model="leafOrdersBucket" size="small" @change="loadLeafOrders(1)">
|
||
<el-radio-button label="all">全部已完成</el-radio-button>
|
||
<el-radio-button label="current">本期提成</el-radio-button>
|
||
<el-radio-button label="deferred">顺延下期</el-radio-button>
|
||
</el-radio-group>
|
||
<span class="cs-dialog__hint">
|
||
结算月:<strong>{{ meta.settlement_month || settlementMonth }}</strong>
|
||
<template v-if="orderWindowLabel && orderWindowLabel !== '—'"
|
||
> · 订单创建:<strong>{{ orderWindowLabel }}</strong></template
|
||
>
|
||
<template v-if="meta.channel_name"> · 渠道:<strong>{{ meta.channel_name }}</strong></template>
|
||
</span>
|
||
</div>
|
||
<el-table v-loading="leafOrdersLoading" :data="leafOrdersRows" border stripe size="small" max-height="440">
|
||
<el-table-column prop="order_no" label="业务订单号" min-width="128" show-overflow-tooltip />
|
||
<el-table-column label="患者" min-width="112" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<div class="cs-patient-cell">
|
||
<span class="cs-patient-cell__name">{{ row.patient_name || '—' }}</span>
|
||
<span v-if="row.patient_phone || row.patient_gender_text" class="cs-patient-cell__meta cs-muted">
|
||
<template v-if="row.patient_gender_text">{{ row.patient_gender_text }}</template>
|
||
<template v-if="row.patient_gender_text && row.patient_phone"> · </template>
|
||
<template v-if="row.patient_phone">{{ row.patient_phone }}</template>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="快递单号" min-width="132" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.tracking_number || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="挂号日期" min-width="124" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span>{{ row.appointment_date_text || '—' }}</span>
|
||
<el-tag
|
||
v-if="row.appointment_status && Number(row.appointment_status) !== 3"
|
||
class="cs-tag-appt-muted"
|
||
type="info"
|
||
size="small"
|
||
effect="plain"
|
||
>未完结挂号</el-tag
|
||
>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="挂号渠道" min-width="124" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.appt_channel_name || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="assistant_name" label="业绩医助" min-width="100" show-overflow-tooltip />
|
||
<el-table-column prop="doctor_name" label="开方医生" min-width="100" show-overflow-tooltip />
|
||
<el-table-column prop="primary_dept_name" label="归属部门" min-width="120" show-overflow-tooltip />
|
||
<el-table-column prop="amount" label="金额" width="108" align="right">
|
||
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="分段" width="88" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.bucket === 'current'" type="success" size="small">本期</el-tag>
|
||
<el-tag v-else type="warning" size="small">顺延</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="结转" width="72" align="center">
|
||
<template #default="{ row }">
|
||
<span v-if="row.is_carry_in">是</span>
|
||
<span v-else class="cs-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="顺延说明" min-width="200" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span v-if="row.bucket === 'deferred' && row.bucket_defer_text" class="cs-muted">{{
|
||
row.bucket_defer_text
|
||
}}</span>
|
||
<span v-else class="cs-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="物流签收" min-width="152" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.sign_time_text || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="pay_time_text" label="尾款支付" min-width="152" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.pay_time_text || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="create_time_text" label="订单创建" width="152" />
|
||
</el-table>
|
||
<div class="cs-dialog__pager">
|
||
<el-pagination
|
||
layout="total, prev, pager, next"
|
||
:total="leafOrdersTotal"
|
||
:page-size="leafOrdersPageSize"
|
||
:current-page="leafOrdersPage"
|
||
@current-change="loadLeafOrders"
|
||
/>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { ArrowRight, ArrowUp, RefreshRight, Search, Tickets } from '@element-plus/icons-vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import {
|
||
commissionSettlementChannelOptions,
|
||
commissionSettlementConfirmFinalize,
|
||
commissionSettlementConfirmRevoke,
|
||
commissionSettlementDeptOptions,
|
||
commissionSettlementOrderLines,
|
||
commissionSettlementOverview,
|
||
commissionSettlementSaveReconcile,
|
||
} from '@/api/stats'
|
||
|
||
interface DeptOption {
|
||
id: number
|
||
name: string
|
||
pid: number
|
||
path: string
|
||
}
|
||
|
||
interface DeptTreeNode extends DeptOption {
|
||
children?: DeptTreeNode[]
|
||
}
|
||
|
||
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 CsRow {
|
||
dept_id: number
|
||
dept_name: string
|
||
completed_order_count: number
|
||
completed_order_amount: number
|
||
current_period_count: number
|
||
current_period_amount: number
|
||
deferred_period_count: number
|
||
deferred_period_amount: number
|
||
}
|
||
|
||
interface CsAssistRow {
|
||
assistant_id: number
|
||
assistant_name: string
|
||
primary_dept_id: number
|
||
primary_dept_name: string
|
||
carry_in_order_count: number
|
||
completed_order_count: number
|
||
completed_order_amount: number
|
||
current_period_count: number
|
||
current_period_amount: number
|
||
deferred_period_count: number
|
||
deferred_period_amount: number
|
||
}
|
||
|
||
interface CsDoctorRow {
|
||
doctor_id: number
|
||
doctor_name: string
|
||
primary_dept_id: number
|
||
primary_dept_name: string
|
||
carry_in_order_count: number
|
||
completed_order_count: number
|
||
completed_order_amount: number
|
||
current_period_count: number
|
||
current_period_amount: number
|
||
deferred_period_count: number
|
||
deferred_period_amount: number
|
||
}
|
||
|
||
type LeafOrderDrillDept = { mode: 'dept'; dept_id: number; dept_name: string }
|
||
type LeafOrderDrillChannel = {
|
||
mode: 'channel'
|
||
title: string
|
||
assistant_id?: number
|
||
doctor_id?: number
|
||
appt_channel_value: number
|
||
}
|
||
type LeafOrderDrill = LeafOrderDrillDept | LeafOrderDrillChannel
|
||
|
||
interface CsAssistChannelRow extends CsAssistRow {
|
||
appt_channel_value: number
|
||
appt_channel_name: string
|
||
}
|
||
|
||
interface CsDoctorChannelRow extends CsDoctorRow {
|
||
appt_channel_value: number
|
||
appt_channel_name: string
|
||
}
|
||
|
||
type CsPersonMetricRow = CsAssistRow | CsDoctorRow | CsAssistChannelRow | CsDoctorChannelRow
|
||
|
||
function defaultSettlementMonth(): string {
|
||
const d = new Date()
|
||
const y = d.getFullYear()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
return `${y}-${m}`
|
||
}
|
||
|
||
function pad2(n: number): string {
|
||
return String(n).padStart(2, '0')
|
||
}
|
||
|
||
function formatDateTime(d: Date): string {
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
||
}
|
||
|
||
/** 订单池默认:结算月「上一自然月」整段(与未传 start/end 的后端行为一致) */
|
||
function defaultOrderTimeRangeForSettlementMonth(sm: string): [string, string] {
|
||
const m = /^(\d{4})-(\d{2})$/.exec(sm)
|
||
const y = m ? parseInt(m[1], 10) : new Date().getFullYear()
|
||
const monthNum = m ? parseInt(m[2], 10) : new Date().getMonth() + 1
|
||
const start = new Date(y, monthNum - 2, 1, 0, 0, 0)
|
||
const end = new Date(y, monthNum - 1, 0, 23, 59, 59)
|
||
return [formatDateTime(start), formatDateTime(end)]
|
||
}
|
||
|
||
const settlementMonth = ref<string>(defaultSettlementMonth())
|
||
const orderTimeRange = ref<[string, string] | null>(defaultOrderTimeRangeForSettlementMonth(settlementMonth.value))
|
||
const requireSystemAutoOnly = ref(false)
|
||
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 rows = ref<CsRow[]>([])
|
||
const assistantRows = ref<CsAssistRow[]>([])
|
||
const doctorRows = ref<CsDoctorRow[]>([])
|
||
const assistantChannelRows = ref<CsAssistChannelRow[]>([])
|
||
const doctorChannelRows = ref<CsDoctorChannelRow[]>([])
|
||
const summaryTab = ref<'dept' | 'assistant' | 'doctor'>('dept')
|
||
const totalAgg = ref<{
|
||
completed_order_count?: number
|
||
completed_order_amount?: number
|
||
current_period_count?: number
|
||
current_period_amount?: number
|
||
deferred_period_count?: number
|
||
deferred_period_amount?: number
|
||
carry_in_order_count?: number
|
||
}>({})
|
||
const meta = reactive({
|
||
settlement_month: '',
|
||
order_month: '',
|
||
order_start_time: '',
|
||
order_end_time: '',
|
||
fulfillment_status: 3,
|
||
explicit_order_time_window: false,
|
||
cutoff_end: '',
|
||
rule_note: '',
|
||
channel_name: '',
|
||
require_system_auto_prescription: false,
|
||
})
|
||
|
||
const confirmInfo = ref<{
|
||
status?: number
|
||
reconcile_note?: string
|
||
confirmed_admin_name?: string
|
||
confirmed_at_text?: string
|
||
revoked_admin_name?: string
|
||
revoked_at_text?: string
|
||
is_confirmed_locked?: boolean
|
||
} | null>(null)
|
||
const reconcileNote = ref('')
|
||
const reconcileSaving = ref(false)
|
||
const confirmSubmitting = ref(false)
|
||
const confirmRevoking = ref(false)
|
||
|
||
/** 仅 status=1 时锁定,禁止保存备注 / 重复确定;status=2 已撤回可再操作 */
|
||
const settlementConfirmedLocked = computed(() => Number(confirmInfo.value?.status) === 1)
|
||
|
||
const reconcileDialogVisible = ref(false)
|
||
const linesLoading = ref(false)
|
||
const linesBucket = ref<string>('all')
|
||
const linesRows = ref<Record<string, any>[]>([])
|
||
const linesTotal = ref(0)
|
||
const linesPage = ref(1)
|
||
const linesPageSize = ref(20)
|
||
|
||
const leafOrdersVisible = ref(false)
|
||
const leafOrderDrill = ref<LeafOrderDrill | null>(null)
|
||
const leafOrdersBucket = ref<string>('all')
|
||
const leafOrdersRows = ref<Record<string, any>[]>([])
|
||
const leafOrdersTotal = ref(0)
|
||
const leafOrdersPage = ref(1)
|
||
const leafOrdersPageSize = ref(20)
|
||
const leafOrdersLoading = ref(false)
|
||
|
||
const leafOrdersDialogTitle = computed(() => {
|
||
const d = leafOrderDrill.value
|
||
if (!d) return '已完成业绩订单'
|
||
if (d.mode === 'dept') return `已完成业绩订单 · ${d.dept_name}`
|
||
return d.title
|
||
})
|
||
|
||
const orderWindowLabel = computed(() => {
|
||
if (meta.order_start_time && meta.order_end_time) {
|
||
return `${meta.order_start_time}~${meta.order_end_time}`
|
||
}
|
||
return meta.order_month || '—'
|
||
})
|
||
|
||
const deptChildrenMap = computed(() => {
|
||
const map = new Map<number, number[]>()
|
||
for (const d of deptOptions.value) {
|
||
const pid = Number(d.pid)
|
||
const arr = map.get(pid) ?? []
|
||
arr.push(d.id)
|
||
map.set(pid, arr)
|
||
}
|
||
return map
|
||
})
|
||
|
||
const deptByIdMap = computed(() => {
|
||
const map = new Map<number, DeptOption>()
|
||
for (const d of deptOptions.value) {
|
||
map.set(d.id, d)
|
||
}
|
||
return map
|
||
})
|
||
|
||
const drilledFromDept = computed<DeptOption | null>(() => {
|
||
if (selectedDeptIds.value.length !== 1) return null
|
||
const id = selectedDeptIds.value[0]
|
||
const node = deptByIdMap.value.get(id)
|
||
return node ?? null
|
||
})
|
||
|
||
const drillUpLabel = computed<string>(() => {
|
||
const cur = drilledFromDept.value
|
||
if (!cur) return '返回顶层'
|
||
const parentId = Number(cur.pid)
|
||
const parent = parentId > 0 ? deptByIdMap.value.get(parentId) : null
|
||
if (!parent || Number(parent.pid) <= 0) return '返回顶层'
|
||
return `返回上级 · ${parent.name}`
|
||
})
|
||
|
||
function deptHasChildren(deptId: number): boolean {
|
||
if (!deptId || deptId <= 0) return false
|
||
const arr = deptChildrenMap.value.get(deptId)
|
||
return !!(arr && arr.length > 0)
|
||
}
|
||
|
||
function handleDeptRowClick(row: CsRow) {
|
||
if (!row || !row.dept_id || row.dept_id <= 0) return
|
||
if (deptHasChildren(row.dept_id)) {
|
||
selectedDeptIds.value = [row.dept_id]
|
||
loadOverview()
|
||
return
|
||
}
|
||
openOrderLinesForDept(row.dept_id, row.dept_name)
|
||
}
|
||
|
||
function handleDrillUp() {
|
||
const cur = drilledFromDept.value
|
||
if (!cur) {
|
||
selectedDeptIds.value = []
|
||
loadOverview()
|
||
return
|
||
}
|
||
const parentId = Number(cur.pid)
|
||
const parent = parentId > 0 ? deptByIdMap.value.get(parentId) : null
|
||
/** 父级为根(pid<=0)或在视图里没有实际意义时直接回到「默认中心视图」,避免一步跳到「全部部门」 */
|
||
if (!parent || Number(parent.pid) <= 0) {
|
||
selectedDeptIds.value = []
|
||
} else {
|
||
selectedDeptIds.value = [parentId]
|
||
}
|
||
loadOverview()
|
||
}
|
||
|
||
function openOrderLinesForDept(deptId: number, deptName: string) {
|
||
if (!settlementMonth.value) {
|
||
ElMessage.warning('请选择结算月')
|
||
return
|
||
}
|
||
leafOrderDrill.value = { mode: 'dept', dept_id: deptId, dept_name: deptName }
|
||
leafOrdersBucket.value = 'all'
|
||
leafOrdersPage.value = 1
|
||
leafOrdersRows.value = []
|
||
leafOrdersTotal.value = 0
|
||
leafOrdersVisible.value = true
|
||
loadLeafOrders(1)
|
||
}
|
||
|
||
function openOrdersForAssistantChannel(row: CsAssistChannelRow) {
|
||
if (!settlementMonth.value) {
|
||
ElMessage.warning('请选择结算月')
|
||
return
|
||
}
|
||
leafOrderDrill.value = {
|
||
mode: 'channel',
|
||
title: `业务订单 · ${row.appt_channel_name} · 医助 ${row.assistant_name}`,
|
||
assistant_id: row.assistant_id,
|
||
appt_channel_value: Number(row.appt_channel_value ?? 0),
|
||
}
|
||
leafOrdersBucket.value = 'all'
|
||
leafOrdersPage.value = 1
|
||
leafOrdersRows.value = []
|
||
leafOrdersTotal.value = 0
|
||
leafOrdersVisible.value = true
|
||
loadLeafOrders(1)
|
||
}
|
||
|
||
function openOrdersForDoctorChannel(row: CsDoctorChannelRow) {
|
||
if (!settlementMonth.value) {
|
||
ElMessage.warning('请选择结算月')
|
||
return
|
||
}
|
||
leafOrderDrill.value = {
|
||
mode: 'channel',
|
||
title: `业务订单 · ${row.appt_channel_name} · 医生 ${row.doctor_name}`,
|
||
doctor_id: row.doctor_id,
|
||
appt_channel_value: Number(row.appt_channel_value ?? 0),
|
||
}
|
||
leafOrdersBucket.value = 'all'
|
||
leafOrdersPage.value = 1
|
||
leafOrdersRows.value = []
|
||
leafOrdersTotal.value = 0
|
||
leafOrdersVisible.value = true
|
||
loadLeafOrders(1)
|
||
}
|
||
|
||
async function loadLeafOrders(page: number) {
|
||
if (!settlementMonth.value || !leafOrderDrill.value) return
|
||
leafOrdersLoading.value = true
|
||
leafOrdersPage.value = page
|
||
try {
|
||
const d = leafOrderDrill.value
|
||
const p: Record<string, any> = {
|
||
settlement_month: settlementMonth.value,
|
||
page,
|
||
page_size: leafOrdersPageSize.value,
|
||
}
|
||
if (d.mode === 'dept') {
|
||
Object.assign(p, buildQueryParams())
|
||
p.table_row_dept_id = d.dept_id
|
||
} else {
|
||
Object.assign(p, buildQueryParams())
|
||
p.appt_channel_value = d.appt_channel_value
|
||
if (d.assistant_id != null) {
|
||
p.assistant_id = d.assistant_id
|
||
}
|
||
if (d.doctor_id != null) {
|
||
p.doctor_id = d.doctor_id
|
||
}
|
||
}
|
||
if (leafOrdersBucket.value && leafOrdersBucket.value !== 'all') {
|
||
p.bucket = leafOrdersBucket.value
|
||
}
|
||
const res: any = await commissionSettlementOrderLines(p as any)
|
||
leafOrdersRows.value = res?.list || []
|
||
leafOrdersTotal.value = Number(res?.count ?? 0)
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载明细失败')
|
||
leafOrdersRows.value = []
|
||
leafOrdersTotal.value = 0
|
||
} finally {
|
||
leafOrdersLoading.value = false
|
||
}
|
||
}
|
||
|
||
function formatMoney(v: number | null | undefined) {
|
||
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
|
||
return `¥ ${Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||
}
|
||
|
||
async function loadDeptOptions() {
|
||
deptOptionsLoading.value = true
|
||
try {
|
||
const res: DeptOption[] = await commissionSettlementDeptOptions()
|
||
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 commissionSettlementChannelOptions()
|
||
channelGroups.value = res?.groups || []
|
||
} catch (_e) {
|
||
// ignore
|
||
} finally {
|
||
channelOptionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadOverview() {
|
||
const sm = settlementMonth.value
|
||
if (!sm) {
|
||
ElMessage.warning('请选择结算月')
|
||
return
|
||
}
|
||
const ot = orderTimeRange.value
|
||
if (!ot || !ot[0] || !ot[1]) {
|
||
ElMessage.warning('请设置订单创建时间范围')
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
const res: any = await commissionSettlementOverview(buildQueryParams() as any)
|
||
rows.value = res?.rows || []
|
||
assistantRows.value = res?.assistant_rows || []
|
||
doctorRows.value = res?.doctor_rows || []
|
||
assistantChannelRows.value = res?.assistant_channel_rows || []
|
||
doctorChannelRows.value = res?.doctor_channel_rows || []
|
||
totalAgg.value = res?.total || {}
|
||
meta.settlement_month = res?.settlement_month || sm
|
||
meta.order_month = res?.order_month || ''
|
||
meta.order_start_time = res?.order_start_time ? String(res.order_start_time) : ''
|
||
meta.order_end_time = res?.order_end_time ? String(res.order_end_time) : ''
|
||
meta.fulfillment_status =
|
||
res?.fulfillment_status != null ? Number(res.fulfillment_status) : 3
|
||
meta.explicit_order_time_window =
|
||
typeof res?.explicit_order_time_window === 'boolean'
|
||
? res.explicit_order_time_window
|
||
: !!(meta.order_start_time && meta.order_end_time)
|
||
meta.cutoff_end = res?.cutoff_end || ''
|
||
meta.rule_note = res?.rule_note || ''
|
||
meta.channel_name = res?.channel_name || ''
|
||
meta.require_system_auto_prescription =
|
||
typeof res?.require_system_auto_prescription === 'boolean'
|
||
? res.require_system_auto_prescription
|
||
: false
|
||
confirmInfo.value = res?.confirm ?? null
|
||
reconcileNote.value =
|
||
res?.confirm?.reconcile_note != null ? String(res.confirm.reconcile_note) : ''
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载失败')
|
||
rows.value = []
|
||
assistantRows.value = []
|
||
doctorRows.value = []
|
||
assistantChannelRows.value = []
|
||
doctorChannelRows.value = []
|
||
totalAgg.value = {}
|
||
confirmInfo.value = null
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function buildQueryParams(): Record<string, any> {
|
||
const sm = settlementMonth.value
|
||
const p: Record<string, any> = {
|
||
settlement_month: sm,
|
||
fulfillment_status: 3,
|
||
}
|
||
const ot = orderTimeRange.value
|
||
if (ot && ot[0] && ot[1]) {
|
||
p.start_time = ot[0]
|
||
p.end_time = ot[1]
|
||
}
|
||
if (requireSystemAutoOnly.value) {
|
||
p.require_system_auto_prescription = 1
|
||
}
|
||
if (selectedDeptIds.value.length > 0) {
|
||
p.dept_ids = selectedDeptIds.value.join(',')
|
||
}
|
||
if (selectedChannel.value) {
|
||
p.channel_code = selectedChannel.value
|
||
}
|
||
return p
|
||
}
|
||
|
||
function openReconcileDialog() {
|
||
if (!settlementMonth.value) {
|
||
ElMessage.warning('请选择结算月')
|
||
return
|
||
}
|
||
reconcileDialogVisible.value = true
|
||
}
|
||
|
||
function onReconcileDialogOpen() {
|
||
linesBucket.value = 'all'
|
||
loadOrderLines(1)
|
||
}
|
||
|
||
async function loadOrderLines(page: number) {
|
||
if (!settlementMonth.value) return
|
||
linesLoading.value = true
|
||
linesPage.value = page
|
||
try {
|
||
const p: Record<string, any> = {
|
||
...buildQueryParams(),
|
||
page,
|
||
page_size: linesPageSize.value,
|
||
}
|
||
if (linesBucket.value && linesBucket.value !== 'all') {
|
||
p.bucket = linesBucket.value
|
||
}
|
||
const res: any = await commissionSettlementOrderLines(p as any)
|
||
linesRows.value = res?.list || []
|
||
linesTotal.value = Number(res?.count ?? 0)
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '加载明细失败')
|
||
linesRows.value = []
|
||
linesTotal.value = 0
|
||
} finally {
|
||
linesLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSaveReconcile() {
|
||
if (!settlementMonth.value) {
|
||
ElMessage.warning('请选择结算月')
|
||
return
|
||
}
|
||
if (settlementConfirmedLocked.value) {
|
||
ElMessage.warning('已确定业绩不可修改备注;请先撤回确定')
|
||
return
|
||
}
|
||
reconcileSaving.value = true
|
||
try {
|
||
await commissionSettlementSaveReconcile({
|
||
...buildQueryParams(),
|
||
reconcile_note: reconcileNote.value,
|
||
})
|
||
ElMessage.success('已保存核对备注')
|
||
await loadOverview()
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '保存失败')
|
||
} finally {
|
||
reconcileSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function handleConfirmFinalize() {
|
||
if (!settlementMonth.value) {
|
||
ElMessage.warning('请选择结算月')
|
||
return
|
||
}
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'确定后将为「顺延下期」订单写入结转记录,下一结算月在相同渠道与部门筛选下自动并入统计。确定后仍可「撤回确定」以清除结转并重新核对(与财务对齐后再操作)。',
|
||
'确定本期业绩',
|
||
{
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
}
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
confirmSubmitting.value = true
|
||
try {
|
||
const out: any = await commissionSettlementConfirmFinalize({
|
||
...buildQueryParams(),
|
||
reconcile_note: reconcileNote.value,
|
||
})
|
||
ElMessage.success(
|
||
`已确定本期业绩;结转 ${Number(out?.deferred_carry_count ?? 0)} 单至 ${String(out?.next_settlement_month ?? '下期')}`
|
||
)
|
||
await loadOverview()
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '提交失败')
|
||
} finally {
|
||
confirmSubmitting.value = false
|
||
}
|
||
}
|
||
|
||
async function handleConfirmRevoke() {
|
||
if (!settlementMonth.value) {
|
||
ElMessage.warning('请选择结算月')
|
||
return
|
||
}
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'将撤回本次「确定业绩」状态,并删除已写入的顺延结转记录(下期将不再汇入这些单)。确定后继续?',
|
||
'撤回确定业绩',
|
||
{
|
||
confirmButtonText: '撤回',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
}
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
confirmRevoking.value = true
|
||
try {
|
||
await commissionSettlementConfirmRevoke(buildQueryParams() as any)
|
||
ElMessage.success('已撤回确定;可重新保存备注并再次确定本期业绩')
|
||
await loadOverview()
|
||
} catch (e: any) {
|
||
ElMessage.error(e?.msg || e?.message || '撤回失败')
|
||
} finally {
|
||
confirmRevoking.value = false
|
||
}
|
||
}
|
||
|
||
function handleReset() {
|
||
settlementMonth.value = defaultSettlementMonth()
|
||
orderTimeRange.value = defaultOrderTimeRangeForSettlementMonth(settlementMonth.value)
|
||
requireSystemAutoOnly.value = false
|
||
selectedDeptIds.value = []
|
||
selectedChannel.value = ''
|
||
loadOverview()
|
||
}
|
||
|
||
function getPersonSummaries(param: { columns: Array<{ property?: string }>; data: CsPersonMetricRow[] }) {
|
||
const { columns } = param
|
||
const t = totalAgg.value
|
||
const sums: string[] = []
|
||
columns.forEach((column, index: number) => {
|
||
if (index === 0) {
|
||
sums[index] = '合计'
|
||
return
|
||
}
|
||
const prop = column.property as string | undefined
|
||
if (!t || !prop) {
|
||
sums[index] = ''
|
||
return
|
||
}
|
||
if (prop === 'primary_dept_name' || prop === 'appt_channel_name') {
|
||
sums[index] = ''
|
||
return
|
||
}
|
||
if (prop === 'carry_in_order_count') {
|
||
sums[index] = String(t.carry_in_order_count ?? 0)
|
||
return
|
||
}
|
||
const map: Record<string, string> = {
|
||
completed_order_count: String(t.completed_order_count ?? 0),
|
||
completed_order_amount: formatMoney(t.completed_order_amount),
|
||
current_period_count: String(t.current_period_count ?? 0),
|
||
current_period_amount: formatMoney(t.current_period_amount),
|
||
deferred_period_count: String(t.deferred_period_count ?? 0),
|
||
deferred_period_amount: formatMoney(t.deferred_period_amount),
|
||
}
|
||
sums[index] = map[String(prop)] ?? ''
|
||
})
|
||
return sums
|
||
}
|
||
|
||
/**
|
||
* 合并「医助 / 归属中心」列:同一人的连续行只在首行显示一次。
|
||
* 列索引:0=医助/医生、1=归属中心 合并;其余列(含 2=挂号渠道)保持独立。
|
||
*/
|
||
function makeGroupedSpanMethod<K extends 'assistant_id' | 'doctor_id'>(
|
||
rowsRef: { value: Array<Record<string, any>> },
|
||
idKey: K,
|
||
) {
|
||
return (ctx: { row: any; rowIndex: number; column: any; columnIndex: number }):
|
||
| { rowspan: number; colspan: number }
|
||
| undefined => {
|
||
const { rowIndex, columnIndex } = ctx
|
||
if (columnIndex > 1) return undefined
|
||
const list = rowsRef.value
|
||
if (!list || list.length === 0) return undefined
|
||
const cur = list[rowIndex]
|
||
if (!cur) return undefined
|
||
const curId = cur[idKey]
|
||
const prev = rowIndex > 0 ? list[rowIndex - 1] : null
|
||
if (prev && prev[idKey] === curId) {
|
||
return { rowspan: 0, colspan: 0 }
|
||
}
|
||
let span = 1
|
||
for (let i = rowIndex + 1; i < list.length; i++) {
|
||
if (list[i][idKey] === curId) {
|
||
span++
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
return { rowspan: span, colspan: 1 }
|
||
}
|
||
}
|
||
|
||
const assistantSpanMethod = makeGroupedSpanMethod(assistantChannelRows as unknown as { value: Array<Record<string, any>> }, 'assistant_id')
|
||
const doctorSpanMethod = makeGroupedSpanMethod(doctorChannelRows as unknown as { value: Array<Record<string, any>> }, 'doctor_id')
|
||
|
||
/** 挂号渠道标签着色:复诊系列 -> teal;自媒体系列 -> indigo;转介绍 -> amber;未匹配 -> 灰 */
|
||
function channelTagClass(name: string | undefined | null): string {
|
||
const n = (name || '').trim()
|
||
if (!n) return 'cs-channel-tag--muted'
|
||
if (n.startsWith('复诊')) return 'cs-channel-tag--revisit'
|
||
if (n.startsWith('自媒体')) return 'cs-channel-tag--media'
|
||
if (n.includes('转介绍')) return 'cs-channel-tag--referral'
|
||
if (n.startsWith('未匹配')) return 'cs-channel-tag--muted'
|
||
return 'cs-channel-tag--neutral'
|
||
}
|
||
|
||
function getSummaries(param: { columns: Array<{ property?: string }>; data: CsRow[] }) {
|
||
const { columns } = param
|
||
const t = totalAgg.value
|
||
const sums: string[] = []
|
||
columns.forEach((column, index: number) => {
|
||
if (index === 0) {
|
||
sums[index] = '合计'
|
||
return
|
||
}
|
||
const prop = column.property as keyof CsRow | undefined
|
||
if (!t || !prop) {
|
||
sums[index] = ''
|
||
return
|
||
}
|
||
const map: Record<string, string> = {
|
||
completed_order_count: String(t.completed_order_count ?? 0),
|
||
completed_order_amount: formatMoney(t.completed_order_amount),
|
||
current_period_count: String(t.current_period_count ?? 0),
|
||
current_period_amount: formatMoney(t.current_period_amount),
|
||
deferred_period_count: String(t.deferred_period_count ?? 0),
|
||
deferred_period_amount: formatMoney(t.deferred_period_amount),
|
||
}
|
||
sums[index] = map[String(prop)] ?? ''
|
||
})
|
||
return sums
|
||
}
|
||
|
||
watch(settlementMonth, (v) => {
|
||
if (v) {
|
||
orderTimeRange.value = defaultOrderTimeRangeForSettlementMonth(v)
|
||
}
|
||
})
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
||
await loadOverview()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.cs-page {
|
||
min-width: 0;
|
||
|
||
/* Brand / accent */
|
||
--cs-brand: #0d9488;
|
||
--cs-brand-strong: #0f766e;
|
||
--cs-brand-soft: #f0fdfa;
|
||
--cs-brand-ring: rgba(13, 148, 136, 0.18);
|
||
--cs-accent: #7c3aed;
|
||
|
||
/* Semantic */
|
||
--cs-success: #10b981;
|
||
--cs-warning: #f59e0b;
|
||
--cs-warning-soft: #fffbeb;
|
||
--cs-danger: #ef4444;
|
||
|
||
/* Text */
|
||
--cs-ink: #0f172a;
|
||
--cs-text: #334155;
|
||
--cs-muted: #64748b;
|
||
--cs-faint: #94a3b8;
|
||
|
||
/* Surface */
|
||
--cs-line: #e2e8f0;
|
||
--cs-line-soft: #eef2f6;
|
||
--cs-surface: #ffffff;
|
||
--cs-surface-alt: #f8fafc;
|
||
--cs-page: #f5f7fa;
|
||
|
||
/* Effects */
|
||
--cs-shadow-sm: 0 1px 2px 0 rgba(15, 23, 42, 0.04);
|
||
--cs-shadow-md: 0 6px 20px -6px rgba(15, 23, 42, 0.12), 0 2px 6px -2px rgba(15, 23, 42, 0.06);
|
||
--cs-radius: 14px;
|
||
--cs-radius-sm: 10px;
|
||
--cs-radius-xs: 6px;
|
||
|
||
min-height: 100%;
|
||
padding: 20px 24px 32px;
|
||
color: var(--cs-text);
|
||
background: var(--cs-page);
|
||
font-feature-settings: 'tnum' 1;
|
||
}
|
||
|
||
/* ============ Masthead ============ */
|
||
.cs-masthead {
|
||
margin-bottom: 16px;
|
||
&__inner {
|
||
position: relative;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
padding: 22px 28px;
|
||
background:
|
||
radial-gradient(120% 160% at 0% 0%, var(--cs-brand-soft) 0%, rgba(255, 255, 255, 0) 55%),
|
||
radial-gradient(80% 120% at 100% 100%, #f5f3ff 0%, rgba(255, 255, 255, 0) 60%),
|
||
#ffffff;
|
||
border: 1px solid var(--cs-line);
|
||
border-radius: var(--cs-radius);
|
||
box-shadow: var(--cs-shadow-sm);
|
||
overflow: hidden;
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0 auto 0 0;
|
||
width: 3px;
|
||
background: linear-gradient(180deg, var(--cs-brand) 0%, var(--cs-accent) 100%);
|
||
}
|
||
}
|
||
&__main {
|
||
min-width: 0;
|
||
flex: 1 1 420px;
|
||
}
|
||
&__eyebrow {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 4px 10px 4px 8px;
|
||
margin-bottom: 10px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--cs-brand-strong);
|
||
background: var(--cs-brand-soft);
|
||
border: 1px solid #99f6e4;
|
||
border-radius: 999px;
|
||
}
|
||
&__dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--cs-brand);
|
||
box-shadow: 0 0 0 3px var(--cs-brand-ring);
|
||
}
|
||
&__title {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: var(--cs-ink);
|
||
line-height: 1.25;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
&__desc {
|
||
margin: 8px 0 0;
|
||
max-width: 880px;
|
||
font-size: 13px;
|
||
color: var(--cs-muted);
|
||
line-height: 1.65;
|
||
}
|
||
&__stats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0;
|
||
margin: 0;
|
||
padding: 4px 0;
|
||
background: rgba(255, 255, 255, 0.7);
|
||
border: 1px solid var(--cs-line);
|
||
border-radius: var(--cs-radius-sm);
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
}
|
||
|
||
.cs-stat {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: 8px 18px;
|
||
border-right: 1px dashed var(--cs-line);
|
||
&:last-child { border-right: none; }
|
||
&__label {
|
||
margin: 0;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--cs-muted);
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
&__value {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--cs-ink);
|
||
line-height: 1.2;
|
||
font-variant-numeric: tabular-nums;
|
||
&--xs { font-size: 13px; }
|
||
}
|
||
}
|
||
|
||
/* ============ Generic panel ============ */
|
||
.cs-panel {
|
||
margin-bottom: 14px;
|
||
border-radius: var(--cs-radius);
|
||
border: 1px solid var(--cs-line);
|
||
background: var(--cs-surface);
|
||
box-shadow: var(--cs-shadow-sm);
|
||
:deep(.el-card__body) { padding: 0; }
|
||
}
|
||
|
||
/* ============ Filter ============ */
|
||
.filter-card { overflow: hidden; }
|
||
.filter-surface {
|
||
padding: 16px 20px;
|
||
background: var(--cs-surface);
|
||
}
|
||
.filter-row--top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
gap: 14px 18px;
|
||
}
|
||
.filter-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
.lbl {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: var(--cs-muted);
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
}
|
||
}
|
||
.filter-field--wide {
|
||
flex: 1 1 280px;
|
||
min-width: 240px;
|
||
}
|
||
.filter-field--rangewrap {
|
||
flex: 1 1 440px;
|
||
min-width: 300px;
|
||
}
|
||
.filter-month { width: 168px; }
|
||
.filter-datetimerange {
|
||
width: 100%;
|
||
max-width: 520px;
|
||
}
|
||
.filter-sa-check {
|
||
margin-top: 6px;
|
||
font-weight: 500;
|
||
color: var(--cs-text);
|
||
}
|
||
.cs-tag-appt-muted {
|
||
margin-left: 6px;
|
||
vertical-align: middle;
|
||
}
|
||
.filter-select--channel { width: 220px; }
|
||
.filter-dept-tree-select { width: 100%; min-width: 240px; }
|
||
.filter-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-left: auto;
|
||
}
|
||
.filter-btn {
|
||
padding: 8px 18px;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
border-radius: var(--cs-radius-sm);
|
||
transition: all 0.18s ease;
|
||
&__icon {
|
||
margin-right: 4px;
|
||
vertical-align: -2px;
|
||
}
|
||
&--query {
|
||
background: var(--cs-brand);
|
||
color: #fff;
|
||
border: 1px solid var(--cs-brand);
|
||
box-shadow: 0 1px 2px rgba(15, 118, 110, 0.2);
|
||
&:hover,
|
||
&:focus {
|
||
background: var(--cs-brand-strong);
|
||
border-color: var(--cs-brand-strong);
|
||
}
|
||
}
|
||
&--reset {
|
||
background: var(--cs-surface);
|
||
border: 1px solid var(--cs-line);
|
||
color: var(--cs-text);
|
||
&:hover {
|
||
border-color: var(--cs-brand);
|
||
color: var(--cs-brand-strong);
|
||
background: var(--cs-brand-soft);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ============ Alert ============ */
|
||
.cs-alert {
|
||
margin-bottom: 14px;
|
||
border-radius: var(--cs-radius-sm);
|
||
border: 1px solid var(--cs-line);
|
||
background: var(--cs-surface);
|
||
padding: 12px 16px 12px 16px;
|
||
:deep(.el-alert__icon) {
|
||
color: var(--cs-brand);
|
||
font-size: 18px;
|
||
}
|
||
&__title {
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
color: var(--cs-ink);
|
||
}
|
||
&__body {
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
color: var(--cs-muted);
|
||
p { margin: 0 0 6px; }
|
||
p:last-child { margin-bottom: 0; }
|
||
strong { color: var(--cs-ink); font-weight: 600; }
|
||
}
|
||
&__meta {
|
||
margin-top: 10px !important;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed var(--cs-line);
|
||
font-size: 12px;
|
||
color: var(--cs-text);
|
||
strong {
|
||
color: var(--cs-brand-strong);
|
||
font-weight: 700;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ============ Reconcile ============ */
|
||
.cs-reconcile-card {
|
||
:deep(.el-card__body) { padding: 14px 20px 16px; }
|
||
}
|
||
.cs-reconcile {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
&__head {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
&__tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
:deep(.el-tag) {
|
||
border-radius: 999px;
|
||
font-weight: 600;
|
||
padding: 0 10px;
|
||
height: 24px;
|
||
line-height: 22px;
|
||
}
|
||
}
|
||
&__meta {
|
||
font-size: 12px;
|
||
color: var(--cs-muted);
|
||
}
|
||
&__toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
&__note {
|
||
width: 100%;
|
||
max-width: 720px;
|
||
}
|
||
&__carry {
|
||
margin: 0;
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
color: var(--cs-text);
|
||
line-height: 1.5;
|
||
background: var(--cs-brand-soft);
|
||
border-left: 3px solid var(--cs-brand);
|
||
border-radius: var(--cs-radius-xs);
|
||
strong { color: var(--cs-brand-strong); font-weight: 700; }
|
||
}
|
||
}
|
||
|
||
/* ============ Summary tabs ============ */
|
||
.cs-summary-card {
|
||
:deep(.el-tabs__header) {
|
||
margin: 0;
|
||
padding: 0 20px;
|
||
border-bottom: 1px solid var(--cs-line);
|
||
}
|
||
:deep(.el-tabs__nav-wrap::after) { height: 0; }
|
||
:deep(.el-tabs__item) {
|
||
height: 48px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--cs-muted);
|
||
padding: 0 16px;
|
||
transition: color 0.15s ease;
|
||
&:hover { color: var(--cs-ink); }
|
||
&.is-active { color: var(--cs-brand-strong); }
|
||
}
|
||
:deep(.el-tabs__active-bar) {
|
||
background-color: var(--cs-brand);
|
||
height: 2px;
|
||
border-radius: 2px;
|
||
}
|
||
:deep(.el-tabs__content) { padding: 0; }
|
||
}
|
||
|
||
/* ============ Table head ============ */
|
||
.cs-table-head {
|
||
padding: 14px 20px 12px;
|
||
background: var(--cs-surface);
|
||
border-bottom: 1px solid var(--cs-line);
|
||
&__row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
&__title {
|
||
position: relative;
|
||
padding-left: 12px;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--cs-ink);
|
||
letter-spacing: -0.005em;
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 3px;
|
||
height: 14px;
|
||
background: var(--cs-brand);
|
||
border-radius: 2px;
|
||
}
|
||
}
|
||
&__hint {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: var(--cs-muted);
|
||
line-height: 1.65;
|
||
strong { color: var(--cs-ink); font-weight: 600; }
|
||
}
|
||
}
|
||
|
||
/* ============ Drill state ============ */
|
||
.cs-drill-state {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 3px 4px 3px 10px;
|
||
background: var(--cs-brand-soft);
|
||
border: 1px solid #99f6e4;
|
||
border-radius: 999px;
|
||
&__tag {
|
||
max-width: 320px;
|
||
background: transparent !important;
|
||
border: none !important;
|
||
color: var(--cs-brand-strong) !important;
|
||
font-weight: 600 !important;
|
||
font-size: 12px !important;
|
||
padding: 0 !important;
|
||
height: auto !important;
|
||
:deep(.el-tag__content) {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
&__back {
|
||
display: inline-flex !important;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 4px 10px !important;
|
||
font-size: 12px !important;
|
||
font-weight: 600 !important;
|
||
color: var(--cs-brand-strong) !important;
|
||
background: #ffffff !important;
|
||
border: 1px solid #ccfbf1 !important;
|
||
border-radius: 999px !important;
|
||
&:hover {
|
||
color: #ffffff !important;
|
||
background: var(--cs-brand) !important;
|
||
border-color: var(--cs-brand) !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ============ Table ============ */
|
||
.cs-table {
|
||
width: 100%;
|
||
:deep(.el-table__inner-wrapper::before) { display: none; }
|
||
:deep(.el-table) {
|
||
--el-table-border-color: var(--cs-line);
|
||
--el-table-header-bg-color: var(--cs-surface-alt);
|
||
}
|
||
:deep(.el-table__header th.el-table__cell) {
|
||
background: var(--cs-surface-alt) !important;
|
||
font-weight: 600;
|
||
font-size: 11px;
|
||
color: var(--cs-muted);
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
border-bottom: 1px solid var(--cs-line) !important;
|
||
padding: 10px 0;
|
||
& > .cell { line-height: 1.4; }
|
||
}
|
||
:deep(.el-table__row > td.el-table__cell) {
|
||
font-size: 13px;
|
||
color: var(--cs-text);
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid var(--cs-line-soft) !important;
|
||
}
|
||
:deep(.el-table__footer-wrapper td.el-table__cell) {
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
color: var(--cs-ink);
|
||
background: linear-gradient(180deg, #fffbeb 0%, #fef3c7 100%) !important;
|
||
border-top: 1.5px solid var(--cs-warning) !important;
|
||
}
|
||
}
|
||
|
||
.cs-table--clickable {
|
||
:deep(.el-table__body tr) {
|
||
transition: background-color 0.15s ease;
|
||
}
|
||
:deep(.el-table__body tr:hover > td.el-table__cell) {
|
||
background-color: var(--cs-brand-soft) !important;
|
||
}
|
||
}
|
||
|
||
/* 分组表(合并单元格):合并后的左侧人名/部门列加浅底色让分组更显眼 */
|
||
.cs-table--grouped {
|
||
:deep(.el-table__body tr > td.el-table__cell:nth-child(1)),
|
||
:deep(.el-table__body tr > td.el-table__cell:nth-child(2)) {
|
||
background: linear-gradient(180deg, #ffffff 0%, #fafafa 100%);
|
||
font-weight: 600;
|
||
color: var(--cs-ink);
|
||
vertical-align: middle;
|
||
}
|
||
:deep(.el-table__body tr:hover > td.el-table__cell:nth-child(1)),
|
||
:deep(.el-table__body tr:hover > td.el-table__cell:nth-child(2)) {
|
||
background: var(--cs-brand-soft);
|
||
}
|
||
:deep(.el-table__body tr > td.el-table__cell:nth-child(3)) {
|
||
background: var(--cs-surface);
|
||
}
|
||
}
|
||
|
||
/* 挂号渠道彩色徽标 */
|
||
.cs-channel-tag {
|
||
border-radius: 999px !important;
|
||
font-weight: 600 !important;
|
||
padding: 0 10px !important;
|
||
height: 22px !important;
|
||
line-height: 20px !important;
|
||
border-width: 1px !important;
|
||
&--revisit {
|
||
background: #ecfeff !important;
|
||
border-color: #67e8f9 !important;
|
||
color: #0e7490 !important;
|
||
}
|
||
&--media {
|
||
background: #eef2ff !important;
|
||
border-color: #c7d2fe !important;
|
||
color: #4338ca !important;
|
||
}
|
||
&--referral {
|
||
background: #fff7ed !important;
|
||
border-color: #fdba74 !important;
|
||
color: #c2410c !important;
|
||
}
|
||
&--muted {
|
||
background: #f1f5f9 !important;
|
||
border-color: #cbd5e1 !important;
|
||
color: #64748b !important;
|
||
}
|
||
&--clickable {
|
||
cursor: pointer;
|
||
&:hover {
|
||
filter: brightness(0.96);
|
||
}
|
||
}
|
||
&--neutral {
|
||
background: #f0fdfa !important;
|
||
border-color: #99f6e4 !important;
|
||
color: var(--cs-brand-strong) !important;
|
||
}
|
||
}
|
||
|
||
/* ============ Dept link ============ */
|
||
.cs-dept-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
max-width: 100%;
|
||
font-weight: 600;
|
||
color: var(--cs-ink) !important;
|
||
transition: color 0.15s ease;
|
||
&__text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
&__icon {
|
||
flex: none;
|
||
width: 18px;
|
||
height: 18px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 11px;
|
||
color: var(--cs-faint);
|
||
background: var(--cs-surface-alt);
|
||
border-radius: 50%;
|
||
transition: all 0.18s ease;
|
||
}
|
||
&:hover {
|
||
color: var(--cs-brand-strong) !important;
|
||
}
|
||
&:hover &__icon {
|
||
color: #ffffff;
|
||
background: var(--cs-brand);
|
||
transform: translateX(1px);
|
||
}
|
||
}
|
||
|
||
/* ============ Dialog ============ */
|
||
.cs-dialog {
|
||
:deep(.el-dialog) {
|
||
border-radius: var(--cs-radius);
|
||
overflow: hidden;
|
||
box-shadow: var(--cs-shadow-md);
|
||
border: 1px solid var(--cs-line);
|
||
}
|
||
:deep(.el-dialog__header) {
|
||
padding: 14px 20px;
|
||
margin: 0;
|
||
border-bottom: 1px solid var(--cs-line);
|
||
background: var(--cs-surface-alt);
|
||
}
|
||
:deep(.el-dialog__title) {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--cs-ink);
|
||
}
|
||
:deep(.el-dialog__body) {
|
||
padding: 16px 20px;
|
||
}
|
||
}
|
||
.cs-dialog__bar {
|
||
margin-bottom: 12px;
|
||
&--split {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
}
|
||
.cs-dialog__hint {
|
||
font-size: 12px;
|
||
color: var(--cs-muted);
|
||
strong { color: var(--cs-ink); font-weight: 600; }
|
||
}
|
||
.cs-dialog__pager {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.cs-muted { color: var(--cs-muted); }
|
||
|
||
.cs-patient-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.cs-patient-cell__name {
|
||
color: var(--cs-ink, #0f172a);
|
||
}
|
||
|
||
.cs-patient-cell__meta {
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ============ Element Plus overrides (scoped via deep) ============ */
|
||
.cs-page {
|
||
:deep(.el-input__wrapper),
|
||
:deep(.el-select__wrapper),
|
||
:deep(.el-date-editor.el-input__wrapper) {
|
||
border-radius: var(--cs-radius-sm);
|
||
box-shadow: inset 0 0 0 1px var(--cs-line);
|
||
transition: box-shadow 0.15s ease;
|
||
&:hover {
|
||
box-shadow: inset 0 0 0 1px var(--cs-brand);
|
||
}
|
||
&.is-focus {
|
||
box-shadow: inset 0 0 0 1px var(--cs-brand), 0 0 0 3px var(--cs-brand-ring) !important;
|
||
}
|
||
}
|
||
:deep(.el-radio-button__inner) {
|
||
border-color: var(--cs-line);
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
}
|
||
:deep(.el-radio-button.is-active .el-radio-button__inner) {
|
||
background: var(--cs-brand) !important;
|
||
border-color: var(--cs-brand) !important;
|
||
color: #ffffff !important;
|
||
box-shadow: -1px 0 0 0 var(--cs-brand);
|
||
}
|
||
:deep(.el-pagination .btn-prev),
|
||
:deep(.el-pagination .btn-next),
|
||
:deep(.el-pagination .el-pager li) {
|
||
background: transparent;
|
||
font-weight: 600;
|
||
}
|
||
:deep(.el-pagination .el-pager li.is-active) {
|
||
background: var(--cs-brand);
|
||
color: #ffffff;
|
||
border-radius: var(--cs-radius-xs);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.cs-page { padding: 16px 14px 28px; }
|
||
.cs-masthead__inner { padding: 18px 20px; }
|
||
.cs-masthead__title { font-size: 19px; }
|
||
.filter-actions { width: 100%; margin-left: 0; }
|
||
}
|
||
</style>
|