Files
zyt/admin/src/views/fans/commission-settlement.vue
T
2026-06-01 16:30:25 +08:00

2067 lines
78 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="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_timeend_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-0204-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>