更新
This commit is contained in:
@@ -9,6 +9,7 @@ import useAppStore from './stores/modules/app'
|
||||
import useSettingStore from './stores/modules/setting'
|
||||
import useUserStore from './stores/modules/user'
|
||||
import ChatNotifyToast from './components/chat-notify-toast/index.vue'
|
||||
import RevisitDueToast from './components/revisit-due-toast/index.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -58,6 +59,8 @@ watch(
|
||||
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
|
||||
<router-view />
|
||||
<ChatNotifyToast v-if="showChatNotifyToast" />
|
||||
<!-- 复诊到期弹窗(右下角):与会话通知并列挂载,复用同一条件避免未绑企微时无意义轮询 -->
|
||||
<RevisitDueToast v-if="showChatNotifyToast" />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 复诊到期弹窗(需求 4)接口:/adminapi/tcm.revisitDue/<action>
|
||||
* 契约见 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md §3.3。
|
||||
* 写接口后端 show=1,成功提示由 utils/request 统一弹出,调用方不要重复 ElMessage。
|
||||
*/
|
||||
|
||||
/** 弹窗条目(RevisitDueLogic::formatItem) */
|
||||
export interface RevisitDueToastItem {
|
||||
id: number
|
||||
diagnosis_id: number
|
||||
assistant_id: number
|
||||
patient_name: string
|
||||
current_slot: number
|
||||
next_slot: number
|
||||
medication_days: number
|
||||
/** 'YYYY-MM-DD' 或 '' */
|
||||
due_date: string
|
||||
overdue_days: number | null
|
||||
/** overdue | missed(弹窗只含这两态) */
|
||||
state: string
|
||||
state_text: string
|
||||
severe: 0 | 1
|
||||
missed_count: number
|
||||
snooze_count: number
|
||||
/** 0~3,3 = 居中模态 */
|
||||
escalation_level: number
|
||||
contact_count: number
|
||||
promised_date: string
|
||||
next_remind_at: number
|
||||
next_remind_at_text: string
|
||||
/** 下一次点「延迟」会顺延的秒数(按钮文案用) */
|
||||
next_snooze_seconds: number
|
||||
appointment_date: string
|
||||
closed_state: number
|
||||
}
|
||||
|
||||
/** 汇总计数(RevisitDueLogic::buildSummary) */
|
||||
export interface RevisitDueToastSummary {
|
||||
enabled: boolean
|
||||
due_total: number
|
||||
max_overdue_days: number
|
||||
warning_total: number
|
||||
promise_max_days: number
|
||||
contact_max_per_cycle: number
|
||||
work_hours: [string, string]
|
||||
}
|
||||
|
||||
export interface RevisitDuePendingResult {
|
||||
items: RevisitDueToastItem[]
|
||||
summary: RevisitDueToastSummary
|
||||
}
|
||||
|
||||
/** 写接口返回处理后的当前行(幂等:被他人处理时也返回当前行) */
|
||||
export type RevisitDueWriteResult = RevisitDueToastItem
|
||||
|
||||
export interface RevisitDueSnoozeAllResult {
|
||||
items: RevisitDueToastItem[]
|
||||
snoozed: number
|
||||
skipped: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 轮询接口不走统一 code 处理:权限不足 / 开关关闭时不要每分钟弹一次错误提示 */
|
||||
const POLL_OPTIONS = {
|
||||
isTransformResponse: false,
|
||||
ignoreCancelToken: true,
|
||||
isOpenRetry: false,
|
||||
retryCount: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗待办(60s 轮询):本人到期未挂号 / 过号且已到提醒时刻的项,每次返回全集。
|
||||
* 非 code=1 一律 reject(调用方静默处理),避免轮询刷错误提示。
|
||||
*/
|
||||
export async function revisitDuePending(): Promise<RevisitDuePendingResult> {
|
||||
const res = await request.get({ url: '/tcm.revisitDue/pending' }, POLL_OPTIONS)
|
||||
if (res && typeof res === 'object' && Number(res.code) === 1) {
|
||||
return res.data as RevisitDuePendingResult
|
||||
}
|
||||
return Promise.reject(res?.msg ?? 'revisit due pending failed')
|
||||
}
|
||||
|
||||
/** 仅计数,不更新弹出时间 */
|
||||
export function revisitDueSummary(): Promise<RevisitDueToastSummary> {
|
||||
return request.get({ url: '/tcm.revisitDue/summary' })
|
||||
}
|
||||
|
||||
/** 延迟一次(幂等) */
|
||||
export function revisitDueSnooze(params: { id: number }): Promise<RevisitDueWriteResult> {
|
||||
return request.post({ url: '/tcm.revisitDue/snooze', params })
|
||||
}
|
||||
|
||||
/** 批量延迟(仅 escalation_level<=1 的项) */
|
||||
export function revisitDueSnoozeAll(params: {
|
||||
ids: number[]
|
||||
}): Promise<RevisitDueSnoozeAllResult> {
|
||||
return request.post({ url: '/tcm.revisitDue/snoozeAll', params })
|
||||
}
|
||||
|
||||
/** 已联系:记录患者承诺复诊日 */
|
||||
export function revisitDueContact(params: {
|
||||
id: number
|
||||
promised_date: string
|
||||
remark?: string
|
||||
}): Promise<RevisitDueWriteResult> {
|
||||
return request.post({ url: '/tcm.revisitDue/contact', params })
|
||||
}
|
||||
|
||||
/** 人工关闭(reason lost|other,remark 必填 ≤200) */
|
||||
export function revisitDueClose(params: {
|
||||
id: number
|
||||
reason: 'lost' | 'other'
|
||||
remark: string
|
||||
}): Promise<RevisitDueWriteResult> {
|
||||
return request.post({ url: '/tcm.revisitDue/close', params })
|
||||
}
|
||||
|
||||
/** 主管视图列表行(RevisitDueLists,按角色 data_scope 收窄) */
|
||||
export interface RevisitDueListRow {
|
||||
id: number
|
||||
diagnosis_id: number
|
||||
patient_name: string
|
||||
assistant_id: number
|
||||
assistant_name: string
|
||||
dept_name: string
|
||||
current_slot: number
|
||||
next_slot: number
|
||||
medication_days: number
|
||||
/** 14/21/28/30 或 0=其它 */
|
||||
rule_days: number
|
||||
anchor_date: string
|
||||
due_date: string
|
||||
overdue_days: number | null
|
||||
days_left: number | null
|
||||
state: string
|
||||
state_text: string
|
||||
severe: 0 | 1
|
||||
appointment_state: number
|
||||
appointment_date: string
|
||||
snooze_count: number
|
||||
escalation_level: number
|
||||
contact_count: number
|
||||
promised_date: string
|
||||
missed_count: number
|
||||
closed_state: number
|
||||
closed_state_text: string
|
||||
handle_reason: string
|
||||
handle_remark: string
|
||||
first_shown_at_text: string
|
||||
last_snoozed_at_text: string
|
||||
computed_at_text: string
|
||||
}
|
||||
|
||||
/** 主管视图筛选参数(state:due|warning|booked|visited|contacted|no_days|pending|closed;days_bucket:14|21|28|30|other) */
|
||||
export interface RevisitDueListsParams {
|
||||
assistant_id?: number | ''
|
||||
dept_id?: number | ''
|
||||
state?: string
|
||||
days_bucket?: string
|
||||
min_snooze?: number | ''
|
||||
keyword?: string
|
||||
page_no?: number
|
||||
page_size?: number
|
||||
/** 1 = 导出(export-data 组件追加) */
|
||||
export?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/** 主管视图:复诊到期跟进分页列表(lists/count/page_no/page_size/extend 包络,供 usePaging / export-data 消费) */
|
||||
export function revisitDueLists(params: RevisitDueListsParams) {
|
||||
return request.get({ url: '/tcm.revisitDue/lists', params })
|
||||
}
|
||||
|
||||
/** 事件流水行(RevisitDueLogic::logs) */
|
||||
export interface RevisitDueLogRow {
|
||||
id: number
|
||||
/** 1 延迟 2 已联系 3 人工关闭 4 自动关闭-已挂号 5 自动关闭-已面诊 6 周期重置 7 重新打开 8 企微推送 9 医助变更 10 形态升级 11 首次弹出 */
|
||||
event: number
|
||||
event_text: string
|
||||
snooze_count_after: number
|
||||
level_after: number
|
||||
overdue_days: number
|
||||
/** payload JSON 解析后的对象(next_remind_at / promised_date / appointment_id / order_id / remark / from_assistant 等) */
|
||||
payload_arr: Record<string, any>
|
||||
operator_id: number
|
||||
operator_name: string
|
||||
create_time_text: string
|
||||
}
|
||||
|
||||
/** 主管视图:某条提醒的事件流水(按 due_id,时间倒序由后端决定) */
|
||||
export function revisitDueLogs(params: { due_id: number }) {
|
||||
return request.get({ url: '/tcm.revisitDue/logs', params })
|
||||
}
|
||||
+50
-4
@@ -204,8 +204,26 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
||||
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
||||
/**
|
||||
* 复诊接诊率口径:
|
||||
* - slot(默认,后端 config project.revisit_rate.default_inherit_mode):继承按继承时诊次计入每列独立分母(visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数)
|
||||
* - exclude:继承不计分母(旧口径,assigned_count / visit{N}_count / visit{N}_rate 与 2026-10 前一致)
|
||||
*/
|
||||
export type RevisitRateInheritMode = 'exclude' | 'slot'
|
||||
|
||||
/** 被指派明细行类型:new 新指派 / inherit 继承 / all 两者(默认) */
|
||||
export type RevisitRateAssignKind = 'new' | 'inherit' | 'all'
|
||||
|
||||
/**
|
||||
* 复诊接诊率(按月):部门 → 医助分组 + 合计。
|
||||
* 返回 inherit_mode、slots,行含 assigned_count(新指派)、inherit_count、assigned_total_count、
|
||||
* visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(旧口径二诊率)
|
||||
*/
|
||||
export function revisitRateOverview(params: {
|
||||
month?: string
|
||||
dept_ids?: string
|
||||
inherit_mode?: RevisitRateInheritMode
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||
}
|
||||
|
||||
@@ -214,23 +232,29 @@ export function revisitRateDeptOptions() {
|
||||
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
||||
/**
|
||||
* 复诊接诊率:「新指派数 / 继承数 / 被指派合计」点击下钻(按诊单聚合明细)。
|
||||
* 行含 assign_type(new|inherit)、inherit_visit_slot(继承时诊次 k)、entry_slot(起算诊次)
|
||||
*/
|
||||
export function revisitRateAssignLines(params: {
|
||||
month?: string
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
inherit_mode?: RevisitRateInheritMode
|
||||
kind?: RevisitRateAssignKind
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细);slot=0 为全部 N≥2(复诊合计单数),行含 visit_slot */
|
||||
export function revisitRateVisitOrderLines(params: {
|
||||
month?: string
|
||||
slot: number
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
inherit_mode?: RevisitRateInheritMode
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||
}
|
||||
@@ -253,3 +277,25 @@ export function assistantPerformanceOverview(params: {
|
||||
}) {
|
||||
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||
}
|
||||
|
||||
/** 「我的复诊接诊率」卡片返回(RevisitRateLogic::assistantOverview,仅本人行) */
|
||||
export interface AssistantRevisitRateResult {
|
||||
month: string
|
||||
inherit_mode: RevisitRateInheritMode
|
||||
/** 诊次列范围(2~N) */
|
||||
slots: number[]
|
||||
/** false = 本人不属于二中心子树(复诊接诊率仅统计二中心医助) */
|
||||
in_scope: boolean
|
||||
/** 本人行:assigned_count / inherit_count / assigned_total_count / visit{N}_count|_den|_rate / visit_all_*;无数据为 null */
|
||||
row: Record<string, any> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助个人业绩:我的复诊接诊率(按月,仅本人行;口径与复诊接诊率页一致,
|
||||
* 继承患者按继承时诊次从第 k+1 诊起计入分母)
|
||||
*/
|
||||
export function assistantPerformanceRevisitRate(params: {
|
||||
month?: string
|
||||
}): Promise<AssistantRevisitRateResult> {
|
||||
return request.get({ url: '/stats.assistantPerformance/revisitRate', params })
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 复诊节点(revisit_due)前端纯函数集合
|
||||
*
|
||||
* 数据契约与后端 `RevisitDueService::buildListPayload` 输出一致(方案 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md 契约 C):
|
||||
* 所有日期为 'Y-m-d' 字符串或 '';无快照且无法计算时后端返回 state='none' 的对象,前端显示「—」。
|
||||
*
|
||||
* 本文件只做 state → 文案 / 颜色 class / el-tag type 的映射与日期、天数文案拼装,不含任何请求或响应式逻辑,
|
||||
* 供 PC 列表、H5 卡片、只读页三处共用(RevisitNodeCell.vue + readonly.vue)。
|
||||
*/
|
||||
|
||||
export type RevisitDueState =
|
||||
| 'visited'
|
||||
| 'booked'
|
||||
| 'missed'
|
||||
| 'contacted'
|
||||
| 'overdue'
|
||||
| 'warning'
|
||||
| 'pending'
|
||||
| 'no_days'
|
||||
| 'none'
|
||||
|
||||
/** 列表行 `revisit_due` 对象(契约 C) */
|
||||
export interface RevisitDue {
|
||||
current_slot: number
|
||||
next_slot: number
|
||||
valid_order_count: number
|
||||
medication_days: number
|
||||
/** 0=缺失 1=订单 2=处方 */
|
||||
days_src: 0 | 1 | 2
|
||||
/** 14/21/28/30 或 0=其它 */
|
||||
rule_days: number
|
||||
/** 0=无 1=签收 2=发货 3=下单 */
|
||||
anchor_src: 0 | 1 | 2 | 3
|
||||
anchor_date: string
|
||||
order_date: string
|
||||
due_date: string
|
||||
days_left: number | null
|
||||
overdue_days: number | null
|
||||
state: RevisitDueState | string
|
||||
state_text: string
|
||||
severe: 0 | 1
|
||||
/** 0=无 1=已约 3=已面诊 4=过号 */
|
||||
appointment_state: number
|
||||
appointment_date: string
|
||||
snooze_count: number
|
||||
escalation_level: number
|
||||
promised_date: string
|
||||
missed_count: number
|
||||
first_touch_date: string
|
||||
in_care_days: number | null
|
||||
total_medication_days: number
|
||||
closed_state: number
|
||||
}
|
||||
|
||||
/** 「复诊状态」筛选下拉选项(请求参数 revisit_state;due = overdue ∪ missed) */
|
||||
export const REVISIT_STATE_FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: '到期未挂号 / 过号', value: 'due' },
|
||||
{ label: '即将到期', value: 'warning' },
|
||||
{ label: '已约待面诊', value: 'booked' },
|
||||
{ label: '已面诊', value: 'visited' },
|
||||
{ label: '已联系', value: 'contacted' },
|
||||
{ label: '缺开药天数', value: 'no_days' },
|
||||
{ label: '未到期', value: 'pending' }
|
||||
]
|
||||
|
||||
/** 「开药天数档」筛选下拉选项(请求参数 revisit_days_bucket) */
|
||||
export const REVISIT_DAYS_BUCKET_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: '14 天', value: '14' },
|
||||
{ label: '21 天', value: '21' },
|
||||
{ label: '28 天', value: '28' },
|
||||
{ label: '30 天', value: '30' },
|
||||
{ label: '其它天数', value: 'other' }
|
||||
]
|
||||
|
||||
/** state → 默认文案(后端 state_text 缺失时兜底) */
|
||||
const STATE_LABEL: Record<string, string> = {
|
||||
visited: '已面诊',
|
||||
booked: '已约',
|
||||
missed: '过号',
|
||||
contacted: '已联系',
|
||||
overdue: '超期',
|
||||
warning: '即将到期',
|
||||
pending: '未到期',
|
||||
no_days: '缺开药天数',
|
||||
none: '—'
|
||||
}
|
||||
|
||||
const toInt = (v: unknown, fallback = 0): number => {
|
||||
if (v === null || v === undefined || v === '') return fallback
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||
}
|
||||
|
||||
const toNullableInt = (v: unknown): number | null => {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : null
|
||||
}
|
||||
|
||||
const toDateStr = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return ''
|
||||
const s = String(v).trim()
|
||||
if (s === '' || s === '0000-00-00') return ''
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 把列表行 / 详情里的 `revisit_due` 原始值规整为 RevisitDue;
|
||||
* 字段缺失(旧接口)或非对象时返回 null,调用方据此不渲染。
|
||||
*/
|
||||
export function normalizeRevisitDue(raw: unknown): RevisitDue | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const state = String(r.state ?? 'none').trim() || 'none'
|
||||
const daysSrc = toInt(r.days_src)
|
||||
const anchorSrc = toInt(r.anchor_src)
|
||||
return {
|
||||
current_slot: toInt(r.current_slot),
|
||||
next_slot: toInt(r.next_slot),
|
||||
valid_order_count: toInt(r.valid_order_count),
|
||||
medication_days: toInt(r.medication_days),
|
||||
days_src: (daysSrc === 1 || daysSrc === 2 ? daysSrc : 0) as 0 | 1 | 2,
|
||||
rule_days: toInt(r.rule_days),
|
||||
anchor_src: (anchorSrc >= 1 && anchorSrc <= 3 ? anchorSrc : 0) as 0 | 1 | 2 | 3,
|
||||
anchor_date: toDateStr(r.anchor_date),
|
||||
order_date: toDateStr(r.order_date),
|
||||
due_date: toDateStr(r.due_date),
|
||||
days_left: toNullableInt(r.days_left),
|
||||
overdue_days: toNullableInt(r.overdue_days),
|
||||
state,
|
||||
state_text: String(r.state_text ?? '').trim(),
|
||||
severe: toInt(r.severe) === 1 ? 1 : 0,
|
||||
appointment_state: toInt(r.appointment_state),
|
||||
appointment_date: toDateStr(r.appointment_date),
|
||||
snooze_count: toInt(r.snooze_count),
|
||||
escalation_level: toInt(r.escalation_level),
|
||||
promised_date: toDateStr(r.promised_date),
|
||||
missed_count: toInt(r.missed_count),
|
||||
first_touch_date: toDateStr(r.first_touch_date),
|
||||
in_care_days: toNullableInt(r.in_care_days),
|
||||
total_medication_days: toInt(r.total_medication_days),
|
||||
closed_state: toInt(r.closed_state)
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否有可展示的复诊节点(state !== 'none') */
|
||||
export function hasRevisitDue(due: RevisitDue | null | undefined): due is RevisitDue {
|
||||
return !!due && due.state !== 'none' && due.state !== ''
|
||||
}
|
||||
|
||||
/** 行级标红:到期未挂号 / 过号未面诊;人工关闭或已自动关闭(closed_state!==0)的行不标红,与「复诊到期」chip / 筛选口径一致 */
|
||||
export function isRevisitDueHighlight(due: RevisitDue | null | undefined): boolean {
|
||||
return (
|
||||
hasRevisitDue(due) &&
|
||||
toInt(due.closed_state) === 0 &&
|
||||
(due.state === 'overdue' || due.state === 'missed')
|
||||
)
|
||||
}
|
||||
|
||||
/** 深红:超期达到 severe_overdue_days(后端已算好 severe=1) */
|
||||
export function isRevisitDueSevere(due: RevisitDue | null | undefined): boolean {
|
||||
return isRevisitDueHighlight(due) && Number(due?.severe) === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* el-table :row-class-name 片段:
|
||||
* '' | 'row-revisit-overdue' | 'row-revisit-overdue row-revisit-overdue-deep'
|
||||
*/
|
||||
export function revisitRowClassName(due: RevisitDue | null | undefined): string {
|
||||
if (!isRevisitDueHighlight(due)) return ''
|
||||
return isRevisitDueSevere(due) ? 'row-revisit-overdue row-revisit-overdue-deep' : 'row-revisit-overdue'
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 'MM-DD';非法/空值返回 '' */
|
||||
export function formatRevisitMonthDay(date: string | null | undefined): string {
|
||||
const s = toDateStr(date)
|
||||
if (!s) return ''
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||
if (m) return `${m[2]}-${m[3]}`
|
||||
return s
|
||||
}
|
||||
|
||||
/** 用药起算锚点来源文案(行 2 末尾) */
|
||||
export function revisitAnchorSrcText(src: number | null | undefined): string {
|
||||
const n = toInt(src)
|
||||
if (n === 1) return '签收起'
|
||||
if (n === 2) return '发货起'
|
||||
if (n === 3) return '下单起'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 开药天数来源文案(tooltip 用) */
|
||||
export function revisitDaysSrcText(src: number | null | undefined): string {
|
||||
const n = toInt(src)
|
||||
if (n === 1) return '订单'
|
||||
if (n === 2) return '处方'
|
||||
return '缺失'
|
||||
}
|
||||
|
||||
/** 是否缺开药天数(days_src=0 或 medication_days<=0) */
|
||||
export function isRevisitDaysMissing(due: RevisitDue | null | undefined): boolean {
|
||||
if (!hasRevisitDue(due)) return false
|
||||
return toInt(due.days_src) === 0 || toInt(due.medication_days) <= 0
|
||||
}
|
||||
|
||||
/** 「第 N 诊」文案 */
|
||||
export function revisitSlotText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const slot = toInt(due.current_slot)
|
||||
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||
}
|
||||
|
||||
/** 「开药 X 天」或「缺开药天数」 */
|
||||
export function revisitMedicationDaysText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
if (isRevisitDaysMissing(due)) return '缺开药天数'
|
||||
return `开药 ${toInt(due.medication_days)} 天`
|
||||
}
|
||||
|
||||
/** 「药尽 MM-DD」;缺天数无 due_date 时返回 '' */
|
||||
export function revisitDueDateText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const md = formatRevisitMonthDay(due.due_date)
|
||||
return md ? `药尽 ${md}` : ''
|
||||
}
|
||||
|
||||
/** state 兜底文案(后端 state_text 为空时使用) */
|
||||
export function revisitStateLabel(state: string | null | undefined): string {
|
||||
const key = String(state ?? '').trim()
|
||||
return STATE_LABEL[key] ?? (key || '—')
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态 chip 文案:优先后端 state_text;为空时按 state + 数字自行拼装,
|
||||
* 保证旧接口 / 字段缺失时也可读。
|
||||
*/
|
||||
export function revisitStateChipText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
if (due.state_text) return due.state_text
|
||||
const state = String(due.state)
|
||||
switch (state) {
|
||||
case 'overdue': {
|
||||
const n = toInt(due.overdue_days)
|
||||
return n <= 0 ? '今日应挂号' : `超期 ${n} 天`
|
||||
}
|
||||
case 'missed': {
|
||||
const md = formatRevisitMonthDay(due.appointment_date)
|
||||
return md ? `过号 ${md}` : '过号'
|
||||
}
|
||||
case 'booked': {
|
||||
const md = formatRevisitMonthDay(due.appointment_date)
|
||||
return md ? `已约 ${md}` : '已约'
|
||||
}
|
||||
case 'visited': {
|
||||
const md = formatRevisitMonthDay(due.appointment_date)
|
||||
return md ? `已面诊 ${md}` : '已面诊'
|
||||
}
|
||||
case 'contacted': {
|
||||
const md = formatRevisitMonthDay(due.promised_date)
|
||||
return md ? `已联系·约 ${md}` : '已联系'
|
||||
}
|
||||
case 'warning':
|
||||
case 'pending': {
|
||||
const n = due.days_left
|
||||
return n === null ? revisitStateLabel(state) : `剩 ${n} 天`
|
||||
}
|
||||
default:
|
||||
return revisitStateLabel(state)
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态 chip 的 el-tag type */
|
||||
export function revisitStateTagType(
|
||||
state: string | null | undefined
|
||||
): 'danger' | 'warning' | 'primary' | 'success' | 'info' {
|
||||
switch (String(state ?? '')) {
|
||||
case 'overdue':
|
||||
case 'missed':
|
||||
return 'danger'
|
||||
case 'warning':
|
||||
case 'no_days':
|
||||
return 'warning'
|
||||
case 'booked':
|
||||
case 'contacted':
|
||||
return 'primary'
|
||||
case 'visited':
|
||||
return 'success'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态 chip 的 el-tag effect:红为 dark;描边态(已联系 / 缺天数)为 plain;其余 light */
|
||||
export function revisitStateTagEffect(state: string | null | undefined): 'dark' | 'light' | 'plain' {
|
||||
switch (String(state ?? '')) {
|
||||
case 'overdue':
|
||||
case 'missed':
|
||||
return 'dark'
|
||||
case 'contacted':
|
||||
case 'no_days':
|
||||
return 'plain'
|
||||
default:
|
||||
return 'light'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态 → 徽标颜色 class(复用列表 / 只读页既有的 unserved-* 色板):
|
||||
* overdue/missed → unserved-red;warning/no_days → unserved-orange;
|
||||
* visited → unserved-green;booked/contacted → unserved-blue;其余 → unserved-muted
|
||||
*/
|
||||
export function revisitStateColorClass(
|
||||
state: string | null | undefined
|
||||
): 'unserved-red' | 'unserved-orange' | 'unserved-green' | 'unserved-blue' | 'unserved-muted' {
|
||||
switch (String(state ?? '')) {
|
||||
case 'overdue':
|
||||
case 'missed':
|
||||
return 'unserved-red'
|
||||
case 'warning':
|
||||
case 'no_days':
|
||||
return 'unserved-orange'
|
||||
case 'visited':
|
||||
return 'unserved-green'
|
||||
case 'booked':
|
||||
case 'contacted':
|
||||
return 'unserved-blue'
|
||||
default:
|
||||
return 'unserved-muted'
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态 chip 附加 class(组件内样式钩子):rv-chip rv-chip--<state> [rv-chip--severe] */
|
||||
export function revisitStateChipClass(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return 'rv-chip'
|
||||
const cls = ['rv-chip', `rv-chip--${String(due.state)}`]
|
||||
if (isRevisitDueSevere(due)) cls.push('rv-chip--severe')
|
||||
return cls.join(' ')
|
||||
}
|
||||
|
||||
/** 行 4:「已延迟 N 次」 */
|
||||
export function revisitSnoozeText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const n = toInt(due.snooze_count)
|
||||
return n > 0 ? `已延迟 ${n} 次` : ''
|
||||
}
|
||||
|
||||
/** 行 4:「已联系 约 MM-DD」 */
|
||||
export function revisitContactText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const md = formatRevisitMonthDay(due.promised_date)
|
||||
return md ? `已联系 约 ${md}` : ''
|
||||
}
|
||||
|
||||
/** 行 4 合并文案:仅 snooze_count>0 或 promised_date 有值时非空 */
|
||||
export function revisitFollowLineText(due: RevisitDue | null | undefined): string {
|
||||
const parts = [revisitSnoozeText(due), revisitContactText(due)].filter((s) => s !== '')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** 行 2:「MM-DD 开药 · 开药 X 天 · 签收起」 */
|
||||
export function revisitOrderLineText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const parts: string[] = []
|
||||
const md = formatRevisitMonthDay(due.order_date)
|
||||
if (md) parts.push(`${md} 开药`)
|
||||
parts.push(revisitMedicationDaysText(due))
|
||||
if (!isRevisitDaysMissing(due)) {
|
||||
const anchor = revisitAnchorSrcText(due.anchor_src)
|
||||
if (anchor) parts.push(anchor)
|
||||
}
|
||||
return parts.filter((s) => s !== '').join(' · ')
|
||||
}
|
||||
|
||||
/** 诊次 tag tooltip 行 */
|
||||
export function revisitTooltipLines(due: RevisitDue | null | undefined): string[] {
|
||||
if (!hasRevisitDue(due)) return []
|
||||
const lines: string[] = []
|
||||
const offset = Math.max(0, toInt(due.current_slot) - toInt(due.valid_order_count))
|
||||
lines.push(`有效订单 ${toInt(due.valid_order_count)} 笔 · 诊次偏移 +${offset}`)
|
||||
if (toInt(due.next_slot) > 0) lines.push(`待复诊:第 ${toInt(due.next_slot)} 诊`)
|
||||
const first = formatRevisitMonthDay(due.first_touch_date)
|
||||
if (first) {
|
||||
const care = due.in_care_days
|
||||
lines.push(care === null ? `首次触达 ${first}` : `首次触达 ${first} · 在管 ${care} 天`)
|
||||
}
|
||||
if (toInt(due.total_medication_days) > 0) lines.push(`累计开药 ${toInt(due.total_medication_days)} 天`)
|
||||
const anchorMd = formatRevisitMonthDay(due.anchor_date)
|
||||
const anchorSrc = revisitAnchorSrcText(due.anchor_src)
|
||||
if (anchorMd && anchorSrc) lines.push(`用药起算 ${anchorMd}(${anchorSrc.replace(/起$/, '')})`)
|
||||
if (!isRevisitDaysMissing(due)) lines.push(`开药天数来源:${revisitDaysSrcText(due.days_src)}`)
|
||||
if (toInt(due.missed_count) > 0) lines.push(`曾过号 ${toInt(due.missed_count)} 次`)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读页徽标文案:「第 N 诊 · 药尽 MM-DD · 超期 N 天」
|
||||
* 无 due_date 时省略中段;尾段为状态 chip 文案。
|
||||
*/
|
||||
export function revisitBadgeText(due: RevisitDue | null | undefined): string {
|
||||
if (!hasRevisitDue(due)) return ''
|
||||
const parts = [revisitSlotText(due), revisitDueDateText(due), revisitStateChipText(due)]
|
||||
return parts.filter((s) => s !== '').join(' · ')
|
||||
}
|
||||
@@ -56,6 +56,70 @@
|
||||
/>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</el-card>
|
||||
|
||||
<!-- 我的复诊接诊率(方案 §10 D1-h):仅本人行,口径与「复诊接诊率」报表一致 -->
|
||||
<el-card v-if="canViewRevisitRate" class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header-left">
|
||||
<span class="card-title">我的复诊接诊率</span>
|
||||
<el-date-picker
|
||||
v-model="revisitMonth"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择月份"
|
||||
:clearable="false"
|
||||
:disabled-date="disableFutureMonth"
|
||||
size="small"
|
||||
style="width: 130px"
|
||||
@change="fetchRevisitRate"
|
||||
/>
|
||||
</div>
|
||||
<span class="card-hint">{{ revisitMonthText }} · 与「复诊接诊率」报表同口径</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="revisitLoading" class="revisit-rate-body">
|
||||
<template v-if="revisitRow">
|
||||
<div class="revisit-kpi-grid">
|
||||
<div class="revisit-kpi-card">
|
||||
<div class="stats-kpi-label">新指派数</div>
|
||||
<div class="revisit-kpi-value">
|
||||
{{ Number(revisitRow.assigned_count ?? 0) }}
|
||||
</div>
|
||||
<div class="revisit-kpi-sub">当月新指派给我的患者</div>
|
||||
</div>
|
||||
<div class="revisit-kpi-card">
|
||||
<div class="stats-kpi-label">继承数</div>
|
||||
<div class="revisit-kpi-value">
|
||||
{{ Number(revisitRow.inherit_count ?? 0) }}
|
||||
</div>
|
||||
<div class="revisit-kpi-sub">当月继承给我的患者</div>
|
||||
</div>
|
||||
<div class="revisit-kpi-card is-primary">
|
||||
<div class="stats-kpi-label">复诊合计接诊率</div>
|
||||
<div class="revisit-kpi-value">
|
||||
{{ formatRate(revisitRow.visit_all_rate) }}
|
||||
</div>
|
||||
<div class="revisit-kpi-sub">{{ visitAllFractionText }}</div>
|
||||
</div>
|
||||
<div v-for="slot in revisitSlots" :key="slot" class="revisit-kpi-card">
|
||||
<div class="stats-kpi-label">{{ slotLabel(slot) }}接诊率</div>
|
||||
<div class="revisit-kpi-value">
|
||||
{{ formatRate(slotMetric(slot, 'rate')) }}
|
||||
</div>
|
||||
<div class="revisit-kpi-sub">{{ slotFractionText(slot) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else :description="revisitEmptyText" :image-size="72" />
|
||||
<div class="revisit-caliber">
|
||||
口径:N 诊接诊率 = 当月 N 诊成交单数 ÷ N 诊分母(新指派数 + 起算诊次 ≤ N 的继承数),分母为 0 显示「—」;
|
||||
<template v-if="revisitInheritMode === 'exclude'">当前为旧口径,继承患者不计入分母;</template>
|
||||
<template v-else>继承患者按继承时诊次从第 k+1 诊起计入;</template>
|
||||
复诊合计 = 各诊次成交单数之和 ÷ 被指派合计(新指派 ∪ 继承),可超过 100%。
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -63,7 +127,12 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { assistantPerformanceOverview } from '@/api/stats'
|
||||
import {
|
||||
assistantPerformanceOverview,
|
||||
assistantPerformanceRevisitRate,
|
||||
type AssistantRevisitRateResult
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
@@ -191,8 +260,78 @@ const handleReset = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// ── 我的复诊接诊率(方案 §10 D1-h)────────────────────────
|
||||
/** 权限点由 server/sql/1.9.20261012/10_add_assistant_revisit_rate_menu.sql 登记并自动授予已有个人业绩页的角色 */
|
||||
const canViewRevisitRate = hasPermission(['stats.assistantPerformance/revisitRate'])
|
||||
|
||||
const initNow = new Date()
|
||||
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||
const revisitMonth = ref<string>(currentMonth)
|
||||
const revisitLoading = ref(false)
|
||||
const revisitResult = ref<AssistantRevisitRateResult | null>(null)
|
||||
|
||||
const revisitRow = computed(() => revisitResult.value?.row ?? null)
|
||||
const revisitInheritMode = computed(() => revisitResult.value?.inherit_mode ?? 'slot')
|
||||
const revisitSlots = computed(() =>
|
||||
(revisitResult.value?.slots ?? []).map((s) => Number(s)).filter((s) => s >= 2)
|
||||
)
|
||||
const revisitMonthText = computed(() => {
|
||||
const [y, m] = (revisitResult.value?.month || revisitMonth.value).split('-')
|
||||
return y && m ? `${y} 年 ${Number(m)} 月` : revisitMonth.value
|
||||
})
|
||||
/** row=null:非二中心医助(in_scope=false)与「当月无被指派患者」文案区分 */
|
||||
const revisitEmptyText = computed(() => {
|
||||
if (revisitResult.value && revisitResult.value.in_scope === false) {
|
||||
return '暂无数据(仅二中心医助统计)'
|
||||
}
|
||||
return revisitMonth.value === currentMonth ? '本月暂无被指派患者' : '该月暂无被指派患者'
|
||||
})
|
||||
|
||||
const disableFutureMonth = (date: Date) => {
|
||||
const now = new Date()
|
||||
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||
}
|
||||
|
||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
const slotLabel = (slot: number) => {
|
||||
if (slot >= 2 && slot <= 10) return `${cnDigits[slot]}诊`
|
||||
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
|
||||
return `${slot}诊`
|
||||
}
|
||||
|
||||
const formatRate = (rate: unknown) =>
|
||||
rate === null || rate === undefined || rate === '' ? '—' : `${Number(rate)}%`
|
||||
|
||||
/** 读取本人行的 visit{N}_count / visit{N}_den / visit{N}_rate */
|
||||
const slotMetric = (slot: number, kind: 'count' | 'den' | 'rate') =>
|
||||
revisitRow.value?.[`visit${slot}_${kind}`]
|
||||
|
||||
/** 「成交 N 单 / 分母 M」 */
|
||||
const fractionText = (count: unknown, den: unknown) =>
|
||||
`成交 ${Number(count ?? 0)} 单 / 分母 ${Number(den ?? 0)}`
|
||||
|
||||
const slotFractionText = (slot: number) =>
|
||||
fractionText(slotMetric(slot, 'count'), slotMetric(slot, 'den'))
|
||||
|
||||
const visitAllFractionText = computed(() =>
|
||||
fractionText(revisitRow.value?.visit_all_count, revisitRow.value?.visit_all_den)
|
||||
)
|
||||
|
||||
const fetchRevisitRate = async () => {
|
||||
if (!canViewRevisitRate) return
|
||||
revisitLoading.value = true
|
||||
try {
|
||||
revisitResult.value = await assistantPerformanceRevisitRate({ month: revisitMonth.value })
|
||||
} catch {
|
||||
// 错误提示由 utils/request 统一弹出;失败时保留上一次结果
|
||||
} finally {
|
||||
revisitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchRevisitRate()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -270,4 +409,57 @@ onMounted(() => {
|
||||
.stats-chart {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.card-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.revisit-rate-body {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.revisit-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.revisit-kpi-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f7f9ff);
|
||||
border: 1px solid #ebf1ff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.revisit-kpi-card.is-primary {
|
||||
background: linear-gradient(145deg, #ffffff, #eef3ff);
|
||||
border-color: #d9e4ff;
|
||||
}
|
||||
|
||||
.revisit-kpi-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.revisit-kpi-card.is-primary .revisit-kpi-value {
|
||||
color: #4a78ff;
|
||||
}
|
||||
|
||||
.revisit-kpi-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.revisit-caliber {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,7 +67,10 @@
|
||||
</el-form>
|
||||
<div class="log-hint">
|
||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
|
||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志(原医助重新接手按「继承」记录)。
|
||||
<br />
|
||||
口径版本:<b>v1 旧口径</b> = 分母仅新指派(visit2_rate_new_assign);<b>v2 新口径</b> = 每列独立分母,继承按继承时诊次计入(visit2_rate)。
|
||||
每条日志同时记录两套率并标注本批次分档所用口径(配置 project.revisit_rate.auto_assign_tier_field);2026-10 前的历史行仅有旧口径率。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -137,7 +140,7 @@
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.assistant_id) > 0">
|
||||
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
||||
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
|
||||
<div class="cell-sub">上月二诊接诊率 {{ formatRate(tierRate(row)) }}</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
@@ -145,6 +148,29 @@
|
||||
<el-table-column label="档位" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分档口径" min-width="150">
|
||||
<template #header>
|
||||
<span>分档口径</span>
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
effect="dark"
|
||||
content="本批次分档所用口径;下方为两套口径的上月二诊接诊率(新口径 = 每列独立分母 / 旧口径 = 分母仅新指派)"
|
||||
>
|
||||
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.action) === 1">
|
||||
<el-tag :type="row.rate_version === 'v2' ? 'primary' : 'info'" size="small">
|
||||
{{ row.rate_version_text || row.rate_version || 'v1' }}
|
||||
</el-tag>
|
||||
<div class="cell-sub">
|
||||
新口径 {{ formatRate(row.visit2_rate) }} · 旧口径 {{ formatRate(row.visit2_rate_new_assign) }}
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="cell-sub">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="轮次" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
|
||||
@@ -186,6 +212,7 @@
|
||||
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
@@ -241,6 +268,14 @@ const handleReset = () => {
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
|
||||
/** 「医助」列显示的接诊率 = 本批次分档所用口径的率(v1 旧口径取 visit2_rate_new_assign,历史行无该列时回落 visit2_rate) */
|
||||
const tierRate = (row: any): number | null | undefined => {
|
||||
if (row?.rate_version === 'v1' && row?.visit2_rate_new_assign !== null && row?.visit2_rate_new_assign !== undefined) {
|
||||
return row.visit2_rate_new_assign
|
||||
}
|
||||
return row?.visit2_rate
|
||||
}
|
||||
|
||||
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
|
||||
|
||||
const onSelectionChange = (rows: any[]) => {
|
||||
@@ -315,5 +350,12 @@ onMounted(() => getLists())
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.col-hint {
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
<template>
|
||||
<div class="revisit-due-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="due-filter-form">
|
||||
<el-form-item label="医助">
|
||||
<el-select
|
||||
v-model="queryParams.assistant_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部"
|
||||
class="w-[160px]"
|
||||
@change="doSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in assistantOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="queryParams.dept_id"
|
||||
:data="deptTreeOptions"
|
||||
placeholder="全部(二中心)"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:props="deptTreeProps"
|
||||
class="w-[200px]"
|
||||
@change="doSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="queryParams.state"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
class="w-[170px]"
|
||||
@change="doSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in stateOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="开药天数">
|
||||
<el-select
|
||||
v-model="queryParams.days_bucket"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
class="w-[120px]"
|
||||
@change="doSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in REVISIT_DAYS_BUCKET_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="延迟次数 ≥">
|
||||
<el-input-number
|
||||
v-model="minSnooze"
|
||||
:min="0"
|
||||
:max="999"
|
||||
:step="1"
|
||||
step-strictly
|
||||
controls-position="right"
|
||||
placeholder="不限"
|
||||
class="!w-[120px]"
|
||||
@change="onMinSnoozeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="患者">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="患者姓名"
|
||||
clearable
|
||||
class="w-[160px]"
|
||||
@keyup.enter="doSearch"
|
||||
@clear="doSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<export-data
|
||||
class="ml-2.5"
|
||||
:fetch-fun="revisitDueLists"
|
||||
:params="queryParams"
|
||||
:page-size="pager.size"
|
||||
export-hint="导出范围与当前筛选一致(医助 / 部门 / 状态 / 开药天数 / 延迟次数 / 患者)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="due-hint">
|
||||
每位患者(诊单)一行,取最新有效业务订单为本轮周期:预计复诊日 = 用药起算日(签收 → 发货 → 下单)+ 开药天数;
|
||||
到期仍无有效挂号 / 过号未面诊的行标红,持有医助登录后台会收到右下角弹窗。
|
||||
「延迟次数」为医助在弹窗上点「延迟」的累计次数(越多说明越未跟进),「已联系」为填写患者承诺复诊日的次数,
|
||||
「人工关闭」由医助填原因关闭、在此标记供主管复核。数据范围按角色数据权限收窄;状态按今天实时推导。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-3" shadow="never">
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
v-loading="pager.loading"
|
||||
size="default"
|
||||
stripe
|
||||
row-key="id"
|
||||
:row-class-name="rowClassName"
|
||||
>
|
||||
<el-table-column label="患者" min-width="120" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-main">{{ row.patient_name || '—' }}</div>
|
||||
<div class="cell-sub">诊单 #{{ row.diagnosis_id }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="医助" min-width="100">
|
||||
<template #default="{ row }">{{ assistantText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="部门" min-width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.dept_name || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="诊次" width="110" align="center">
|
||||
<template #header>
|
||||
<span>诊次</span>
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
effect="dark"
|
||||
content="患者当前已完成诊次 = 有效业务订单数 + 诊单诊次偏移,与复诊接诊率同口径;下一行为待复诊诊次"
|
||||
>
|
||||
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="cell-main">{{ slotText(row) }}</div>
|
||||
<div v-if="Number(row.next_slot) > 0" class="cell-sub">
|
||||
待复诊第 {{ row.next_slot }} 诊
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开药天数" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.medication_days) > 0">
|
||||
<span>{{ row.medication_days }} 天</span>
|
||||
<el-tag
|
||||
v-if="Number(row.rule_days) > 0"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
class="ml-1"
|
||||
>
|
||||
{{ row.rule_days }} 档
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-missing">缺开药天数</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预计复诊" width="110">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-main">{{ row.due_date || '—' }}</div>
|
||||
<div v-if="row.anchor_date" class="cell-sub">
|
||||
起算 {{ formatRevisitMonthDay(row.anchor_date) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="超期天数" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['overdue-cell', overdueCell(row).cls]">
|
||||
{{ overdueCell(row).text }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="revisitStateTagType(row.state)"
|
||||
:effect="revisitStateTagEffect(row.state)"
|
||||
size="small"
|
||||
:class="revisitStateChipClass(dueOf(row))"
|
||||
>
|
||||
{{ chipText(row) }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="Number(row.closed_state) === 4"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
class="ml-1"
|
||||
>
|
||||
人工关闭
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="延迟次数" width="100" align="center">
|
||||
<template #header>
|
||||
<span>延迟次数</span>
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
effect="dark"
|
||||
content="医助在到期弹窗上点「延迟」的累计次数;1~2 次 L1、3~4 次 L2、≥5 次 L3(居中强制模态)。换医助后清零"
|
||||
>
|
||||
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span :class="['snooze-cell', snoozeClass(row)]">
|
||||
{{ Number(row.snooze_count ?? 0) }}
|
||||
</span>
|
||||
<div v-if="Number(row.escalation_level) > 0" class="cell-sub">
|
||||
等级 L{{ row.escalation_level }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已联系" width="80" align="center">
|
||||
<template #default="{ row }">{{ Number(row.contact_count ?? 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="承诺日" width="100">
|
||||
<template #default="{ row }">{{ row.promised_date || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过号" width="70" align="center">
|
||||
<template #default="{ row }">{{ Number(row.missed_count ?? 0) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关闭原因" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.closed_state) === 4">
|
||||
<span>{{ handleReasonText(row.handle_reason) }}</span>
|
||||
<span v-if="row.handle_remark" class="cell-sub">
|
||||
:{{ row.handle_remark }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="Number(row.closed_state) > 0" class="cell-sub">
|
||||
{{ row.closed_state_text || '—' }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['tcm.revisitDue/logs']"
|
||||
link
|
||||
type="primary"
|
||||
@click="openLogs(row)"
|
||||
>
|
||||
流水
|
||||
</el-button>
|
||||
<el-button link type="primary" @click="goDiagnosis(row)">去诊单</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="logVisible"
|
||||
:title="logTitle"
|
||||
direction="rtl"
|
||||
size="540px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="logLoading" class="log-drawer-body">
|
||||
<div v-if="logRow" class="log-summary">
|
||||
<div class="log-summary-row">
|
||||
<span>医助:{{ assistantText(logRow) }}</span>
|
||||
<span>{{ slotText(logRow) }}</span>
|
||||
<span v-if="logRow.due_date">预计复诊 {{ logRow.due_date }}</span>
|
||||
</div>
|
||||
<div class="log-summary-row">
|
||||
<el-tag
|
||||
:type="revisitStateTagType(logRow.state)"
|
||||
:effect="revisitStateTagEffect(logRow.state)"
|
||||
size="small"
|
||||
:class="revisitStateChipClass(dueOf(logRow))"
|
||||
>
|
||||
{{ chipText(logRow) }}
|
||||
</el-tag>
|
||||
<span class="cell-sub">{{ followCountText(logRow) }}</span>
|
||||
</div>
|
||||
<div v-if="hasShownMeta(logRow)" class="log-summary-row cell-sub">
|
||||
<span v-if="logRow.first_shown_at_text">
|
||||
首次弹出 {{ logRow.first_shown_at_text }}
|
||||
</span>
|
||||
<span v-if="logRow.last_snoozed_at_text">
|
||||
最近延迟 {{ logRow.last_snoozed_at_text }}
|
||||
</span>
|
||||
<span v-if="logRow.computed_at_text">
|
||||
重算于 {{ logRow.computed_at_text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-timeline v-if="logRows.length" class="mt-3">
|
||||
<el-timeline-item
|
||||
v-for="item in logRows"
|
||||
:key="item.id"
|
||||
:timestamp="item.create_time_text"
|
||||
placement="top"
|
||||
:type="eventTimelineType(item.event)"
|
||||
:hollow="!isUserEvent(item.event)"
|
||||
>
|
||||
<div class="log-event">
|
||||
<span class="log-event-title">
|
||||
{{ item.event_text || eventLabel(item.event) }}
|
||||
</span>
|
||||
<span class="log-event-operator">{{ operatorText(item) }}</span>
|
||||
</div>
|
||||
<div class="log-event-meta">
|
||||
<span>延迟 {{ Number(item.snooze_count_after ?? 0) }} 次</span>
|
||||
<span>等级 L{{ Number(item.level_after ?? 0) }}</span>
|
||||
<span>{{ logOverdueText(item.overdue_days) }}</span>
|
||||
</div>
|
||||
<div v-if="payloadEntries(item).length" class="log-event-payload">
|
||||
<span
|
||||
v-for="p in payloadEntries(item)"
|
||||
:key="p.key"
|
||||
class="log-payload-chip"
|
||||
>
|
||||
{{ p.label }}:{{ p.value }}
|
||||
</span>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else-if="!logLoading" description="暂无流水" :image-size="80" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="statsRevisitDue">
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
revisitDueLists,
|
||||
revisitDueLogs,
|
||||
type RevisitDueListRow,
|
||||
type RevisitDueListsParams,
|
||||
type RevisitDueLogRow
|
||||
} from '@/api/revisitDue'
|
||||
import { revisitRateDeptOptions } from '@/api/stats'
|
||||
import { getAssistants } from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import {
|
||||
REVISIT_DAYS_BUCKET_OPTIONS,
|
||||
REVISIT_STATE_FILTER_OPTIONS,
|
||||
formatRevisitMonthDay,
|
||||
normalizeRevisitDue,
|
||||
revisitRowClassName,
|
||||
revisitStateChipClass,
|
||||
revisitStateChipText,
|
||||
revisitStateTagEffect,
|
||||
revisitStateTagType,
|
||||
type RevisitDue
|
||||
} from '@/utils/revisitDue'
|
||||
|
||||
interface DeptTreeNode {
|
||||
id: number
|
||||
name: string
|
||||
pid: number
|
||||
children?: DeptTreeNode[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 状态筛选:诊单列表的 7 个状态 + 主管视图专有「人工关闭」 */
|
||||
const stateOptions = [...REVISIT_STATE_FILTER_OPTIONS, { label: '人工关闭', value: 'closed' }]
|
||||
|
||||
const assistantOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
|
||||
const queryParams = reactive<RevisitDueListsParams>({
|
||||
assistant_id: '',
|
||||
dept_id: '',
|
||||
state: '',
|
||||
days_bucket: '',
|
||||
min_snooze: '',
|
||||
keyword: ''
|
||||
})
|
||||
/** el-input-number 清空时为 null/undefined,单独持有后再写回 queryParams 的 '' */
|
||||
const minSnooze = ref<number | undefined>(undefined)
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: revisitDueLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const doSearch = () => resetPage()
|
||||
|
||||
const onMinSnoozeChange = (val: number | undefined | null) => {
|
||||
queryParams.min_snooze = val === null || val === undefined ? '' : Number(val)
|
||||
doSearch()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.assistant_id = ''
|
||||
queryParams.dept_id = ''
|
||||
queryParams.state = ''
|
||||
queryParams.days_bucket = ''
|
||||
queryParams.min_snooze = ''
|
||||
queryParams.keyword = ''
|
||||
minSnooze.value = undefined
|
||||
doSearch()
|
||||
}
|
||||
|
||||
// ── 单元格 ──────────────────────────────────────────
|
||||
/** 列表行 → 复诊节点对象(复用 utils/revisitDue 的 chip 映射;缺失字段按默认值补齐) */
|
||||
const dueOf = (row: RevisitDueListRow | Record<string, any>): RevisitDue | null =>
|
||||
normalizeRevisitDue(row)
|
||||
|
||||
const rowClassName = ({ row }: { row: RevisitDueListRow }) => revisitRowClassName(dueOf(row))
|
||||
|
||||
const assistantText = (row: RevisitDueListRow) =>
|
||||
row.assistant_name || (Number(row.assistant_id) > 0 ? `#${row.assistant_id}` : '—')
|
||||
|
||||
const slotText = (row: RevisitDueListRow) => {
|
||||
const slot = Number(row.current_slot ?? 0)
|
||||
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||
}
|
||||
|
||||
const chipText = (row: RevisitDueListRow) =>
|
||||
revisitStateChipText(dueOf(row)) || row.state_text || '—'
|
||||
|
||||
/** 抽屉摘要:「延迟 N 次 · 已联系 N 次 · 过号 N 次」 */
|
||||
const followCountText = (row: RevisitDueListRow) => {
|
||||
const snooze = Number(row.snooze_count ?? 0)
|
||||
const contact = Number(row.contact_count ?? 0)
|
||||
const missed = Number(row.missed_count ?? 0)
|
||||
return `延迟 ${snooze} 次 · 已联系 ${contact} 次 · 过号 ${missed} 次`
|
||||
}
|
||||
|
||||
const hasShownMeta = (row: RevisitDueListRow) =>
|
||||
!!(row.first_shown_at_text || row.last_snoozed_at_text || row.computed_at_text)
|
||||
|
||||
const toNullableNumber = (v: unknown): number | null => {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** 超期天数列:到期/过号红字(深红 = severe),其它状态显示剩余天数或历史超期 */
|
||||
const overdueCell = (row: RevisitDueListRow): { text: string; cls: string } => {
|
||||
const od = toNullableNumber(row.overdue_days)
|
||||
const dl = toNullableNumber(row.days_left)
|
||||
const isDue = row.state === 'overdue' || row.state === 'missed'
|
||||
if (isDue && od !== null && od >= 0) {
|
||||
return {
|
||||
text: od === 0 ? '今日到期' : `${od} 天`,
|
||||
cls: Number(row.severe) === 1 ? 'is-severe' : 'is-overdue'
|
||||
}
|
||||
}
|
||||
if (od !== null && od > 0) {
|
||||
return { text: `${od} 天`, cls: 'is-muted' }
|
||||
}
|
||||
if (dl !== null && dl >= 0) {
|
||||
return { text: `剩 ${dl} 天`, cls: 'is-muted' }
|
||||
}
|
||||
return { text: '—', cls: '' }
|
||||
}
|
||||
|
||||
const snoozeClass = (row: RevisitDueListRow) => {
|
||||
const level = Number(row.escalation_level ?? 0)
|
||||
if (level >= 3) return 'is-level-3'
|
||||
if (level === 2) return 'is-level-2'
|
||||
if (level === 1) return 'is-level-1'
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleReasonText = (reason: string | null | undefined) => {
|
||||
const key = String(reason ?? '').trim()
|
||||
if (key === 'lost') return '患者流失'
|
||||
if (key === 'other') return '其它'
|
||||
return key || '人工关闭'
|
||||
}
|
||||
|
||||
// ── 流水抽屉 ──────────────────────────────────────────
|
||||
const logVisible = ref(false)
|
||||
const logLoading = ref(false)
|
||||
const logRow = ref<RevisitDueListRow | null>(null)
|
||||
const logRows = ref<RevisitDueLogRow[]>([])
|
||||
|
||||
const logTitle = computed(() =>
|
||||
logRow.value ? `${logRow.value.patient_name || '患者'} · 复诊到期流水` : '复诊到期流水'
|
||||
)
|
||||
|
||||
const EVENT_LABEL: Record<number, string> = {
|
||||
1: '延迟',
|
||||
2: '已联系',
|
||||
3: '人工关闭',
|
||||
4: '自动关闭 · 已挂号',
|
||||
5: '自动关闭 · 已面诊',
|
||||
6: '周期重置(新订单)',
|
||||
7: '重新打开(挂号取消 / 过号)',
|
||||
8: '企微推送',
|
||||
9: '医助变更',
|
||||
10: '形态升级',
|
||||
11: '首次弹出'
|
||||
}
|
||||
|
||||
const eventLabel = (event: number) => EVENT_LABEL[Number(event)] ?? `事件 ${event}`
|
||||
|
||||
/** 1 延迟 / 2 已联系 / 3 人工关闭 由医助操作,实心节点;其余系统事件空心 */
|
||||
const isUserEvent = (event: number) => [1, 2, 3].includes(Number(event))
|
||||
|
||||
type TimelineType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||
|
||||
const eventTimelineType = (event: number): TimelineType => {
|
||||
switch (Number(event)) {
|
||||
case 1:
|
||||
return 'warning'
|
||||
case 2:
|
||||
case 6:
|
||||
return 'primary'
|
||||
case 4:
|
||||
case 5:
|
||||
return 'success'
|
||||
case 7:
|
||||
case 10:
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const operatorText = (item: RevisitDueLogRow) => {
|
||||
if (item.operator_name) return item.operator_name
|
||||
return Number(item.operator_id) > 0 ? `#${item.operator_id}` : '系统'
|
||||
}
|
||||
|
||||
const logOverdueText = (days: number | null | undefined) => {
|
||||
const n = toNullableNumber(days)
|
||||
if (n === null) return ''
|
||||
if (n > 0) return `超期 ${n} 天`
|
||||
if (n === 0) return '到期当天'
|
||||
return `未到期(剩 ${-n} 天)`
|
||||
}
|
||||
|
||||
const PAYLOAD_LABEL: Record<string, string> = {
|
||||
next_remind_at: '下次提醒',
|
||||
next_remind_at_text: '下次提醒',
|
||||
promised_date: '承诺日',
|
||||
appointment_id: '挂号单',
|
||||
appointment_date: '预约日',
|
||||
order_id: '订单',
|
||||
source_order_id: '周期订单',
|
||||
remark: '备注',
|
||||
reason: '原因',
|
||||
from_assistant: '原医助',
|
||||
from_assistant_id: '原医助',
|
||||
to_assistant: '新医助',
|
||||
to_assistant_id: '新医助',
|
||||
interval: '间隔(秒)',
|
||||
level: '等级',
|
||||
channel: '渠道',
|
||||
message: '说明'
|
||||
}
|
||||
|
||||
const formatPayloadValue = (key: string, value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') return ''
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
// 秒级时间戳(next_remind_at 等)转本地时间
|
||||
if (/_at$/.test(key) && typeof value === 'number' && value > 1e9) {
|
||||
const d = new Date(value * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
interface PayloadEntry {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** payload_arr → [{key,label,value}],有 *_text 版本时隐藏对应原始时间戳 */
|
||||
const payloadEntries = (item: RevisitDueLogRow): PayloadEntry[] => {
|
||||
const payload = item.payload_arr
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []
|
||||
const out: PayloadEntry[] = []
|
||||
for (const [key, raw] of Object.entries(payload)) {
|
||||
if (`${key}_text` in payload) continue
|
||||
const value = formatPayloadValue(key, raw)
|
||||
if (value === '') continue
|
||||
out.push({ key, label: PAYLOAD_LABEL[key] ?? key, value })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const openLogs = async (row: RevisitDueListRow) => {
|
||||
logRow.value = row
|
||||
logVisible.value = true
|
||||
logLoading.value = true
|
||||
logRows.value = []
|
||||
try {
|
||||
// 契约「返回 list」:兼容直接返回数组或 { list } / { lists } 包络
|
||||
const res: any = await revisitDueLogs({ due_id: Number(row.id) })
|
||||
const list = Array.isArray(res) ? res : (res?.list ?? res?.lists ?? [])
|
||||
logRows.value = Array.isArray(list) ? list : []
|
||||
} finally {
|
||||
logLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goDiagnosis = (row: RevisitDueListRow) => {
|
||||
const id = Number(row.diagnosis_id ?? 0)
|
||||
if (id <= 0) return
|
||||
router.push({ path: '/tcm/diagnosis', query: { id: String(id) } })
|
||||
}
|
||||
|
||||
// ── 下拉数据 ──────────────────────────────────────────
|
||||
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||
const map = new Map<number, DeptTreeNode>()
|
||||
for (const d of flat) {
|
||||
map.set(Number(d.id), { id: Number(d.id), pid: Number(d.pid), name: d.name, children: [] })
|
||||
}
|
||||
const roots: DeptTreeNode[] = []
|
||||
for (const d of flat) {
|
||||
const node = map.get(Number(d.id))!
|
||||
if (Number(d.pid) > 0 && map.has(Number(d.pid))) {
|
||||
map.get(Number(d.pid))!.children!.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
const strip = (n: DeptTreeNode) => {
|
||||
if (n.children?.length) {
|
||||
n.children.forEach(strip)
|
||||
} else {
|
||||
delete n.children
|
||||
}
|
||||
}
|
||||
roots.forEach(strip)
|
||||
return roots
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
const [assistants, depts] = await Promise.allSettled([
|
||||
getAssistants(),
|
||||
revisitRateDeptOptions()
|
||||
])
|
||||
if (assistants.status === 'fulfilled') {
|
||||
assistantOptions.value = Array.isArray(assistants.value) ? assistants.value : []
|
||||
}
|
||||
if (depts.status === 'fulfilled') {
|
||||
deptTreeOptions.value = buildDeptTree(depts.value?.rows ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 下拉与列表并行加载,不阻塞首屏
|
||||
void loadOptions()
|
||||
getLists()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revisit-due-page {
|
||||
.due-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.due-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cell-main {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cell-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.col-hint {
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.text-missing {
|
||||
color: #ea580c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.overdue-cell.is-overdue {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overdue-cell.is-severe {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overdue-cell.is-muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.snooze-cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.snooze-cell.is-level-1 {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.snooze-cell.is-level-2 {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.snooze-cell.is-level-3 {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 行级标红:与诊单列表一致(utils/revisitDue.ts revisitRowClassName) */
|
||||
:deep(.el-table__row.row-revisit-overdue) {
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__row.row-revisit-overdue:hover > td) {
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
|
||||
border-left-color: #b91c1c;
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
|
||||
}
|
||||
|
||||
/* 状态 chip 配色:与 RevisitNodeCell 一致 */
|
||||
:deep(.rv-chip) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.rv-chip--overdue),
|
||||
:deep(.rv-chip--missed) {
|
||||
--el-tag-bg-color: #dc2626;
|
||||
--el-tag-border-color: #dc2626;
|
||||
}
|
||||
|
||||
:deep(.rv-chip--severe) {
|
||||
font-weight: 700;
|
||||
--el-tag-bg-color: #b91c1c;
|
||||
--el-tag-border-color: #b91c1c;
|
||||
}
|
||||
|
||||
:deep(.rv-chip--warning) {
|
||||
--el-tag-text-color: #ea580c;
|
||||
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
|
||||
--el-tag-border-color: rgba(234, 88, 12, 0.3);
|
||||
}
|
||||
|
||||
:deep(.rv-chip--no_days) {
|
||||
--el-tag-text-color: #ea580c;
|
||||
--el-tag-border-color: #ea580c;
|
||||
}
|
||||
|
||||
:deep(.rv-chip--visited) {
|
||||
--el-tag-text-color: #16a34a;
|
||||
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
|
||||
--el-tag-border-color: rgba(22, 163, 74, 0.3);
|
||||
}
|
||||
|
||||
:deep(.rv-chip--booked) {
|
||||
--el-tag-text-color: #4a5dff;
|
||||
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
|
||||
--el-tag-border-color: rgba(74, 93, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.log-drawer-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.log-summary {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 14px;
|
||||
}
|
||||
|
||||
.log-event {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.log-event-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.log-event-operator {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-event-meta {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.log-event-payload {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.log-payload-chip {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -29,12 +29,18 @@
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="口径">
|
||||
<el-radio-group v-model="inheritMode" size="small" @change="loadData">
|
||||
<el-radio-button value="exclude">原口径(继承不计分母)</el-radio-button>
|
||||
<el-radio-button value="slot">新口径(继承按继承时诊次计入)</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
||||
<el-popover placement="bottom-start" :width="560" trigger="hover">
|
||||
<template #reference>
|
||||
<span class="rate-caliber-trigger">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
@@ -43,16 +49,29 @@
|
||||
</template>
|
||||
<div class="rate-caliber">
|
||||
<p>
|
||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
||||
<b>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>不含勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。与业绩看板「被指派数」同口径。
|
||||
</p>
|
||||
<template v-if="isSlotMode">
|
||||
<p>
|
||||
<b>继承数</b>:当月勾选「继承」转接给该医助的诊单数(同一诊单多次继承取最早;同月既新指派又继承给同一医助只算新指派;历史「回退自动分配」行按继承处理;指派给自己的记录忽略)。<b>继承时诊次 k</b> = 继承时刻之前患者已完成的诊次(有效实单数 + 偏移),<b>起算诊次 = max(2, k+1)</b>:继承者只对第 k+1 诊及以后负责。
|
||||
</p>
|
||||
<p>
|
||||
<b>N 诊分母</b> = 新指派数 + 起算诊次 ≤ N 的继承数(每列独立);<b>N 诊接诊率</b> = N 诊单数 ÷ N 诊分母,分母为 0 显示「—」。<b>复诊合计</b>:单数 = 各诊次单数之和,分母 = 新指派 ∪ 继承(被指派合计),比率可超过 100%(人均复诊成交单数)。
|
||||
</p>
|
||||
<p class="rate-caliber-note">
|
||||
继承分母依赖指派记录的「继承」标记(2026-06 起写入),之前的转接无法回溯;切换为「旧口径」可对照上线前数字。
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
<b>旧口径</b>:继承指派不计入分母,N 诊分母 = 新指派数(对所有 N 相同),新指派数 / N 诊单数 / N 诊接诊率与 2026-10 之前完全一致;继承数恒为 0。切换为「新口径」后继承按继承时诊次计入每列独立分母。
|
||||
</p>
|
||||
</template>
|
||||
<p>
|
||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||
</p>
|
||||
<p>
|
||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;继承会转移持有人)。往月指派、当月成交会推高分子,比率可能超过 100%。
|
||||
</p>
|
||||
<p>
|
||||
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||
@@ -81,7 +100,9 @@
|
||||
<template #header>
|
||||
<div class="rate-card-header">
|
||||
<span>部门 · 医助明细({{ monthText }})</span>
|
||||
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
||||
<span class="rate-card-hint">
|
||||
点击「新指派数 / 继承数 / 被指派合计 / N诊单数 / 复诊合计单数」可查看具体诊单与订单;接诊率下方灰字为「单数 / 分母」;底栏合计按诊单去重
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
@@ -104,19 +125,88 @@
|
||||
<span v-else>{{ row.assistant_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
||||
<el-table-column
|
||||
prop="assigned_count"
|
||||
:label="isSlotMode ? '新指派数' : '当月被指派数'"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
content="当月非继承指派的「医助 × 诊单」去重数(剔除名下有拒收/退款订单的诊单),与业绩看板「被指派数」同口径"
|
||||
>
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row)"
|
||||
@click="openAssignDetail(row, 'new')"
|
||||
>
|
||||
{{ row.assigned_count }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.assigned_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isSlotMode"
|
||||
prop="inherit_count"
|
||||
label="继承数"
|
||||
min-width="88"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>继承数</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
content="当月勾选「继承」转接给该医助的诊单数(按继承时诊次 k 从第 k+1 诊起计入分母;点击查看每条的继承时诊次与起算诊次)"
|
||||
>
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row.inherit_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row, 'inherit')"
|
||||
>
|
||||
{{ row.inherit_count }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.inherit_count ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isSlotMode"
|
||||
prop="assigned_total_count"
|
||||
label="被指派合计"
|
||||
min-width="100"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>被指派合计</span>
|
||||
<el-tooltip effect="dark" placement="top" content="新指派 ∪ 继承(按诊单去重),即「复诊合计接诊率」的分母">
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row.assigned_total_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row, 'all')"
|
||||
>
|
||||
{{ row.assigned_total_count }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.assigned_total_count ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template v-for="slot in visitSlots" :key="slot">
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_count`"
|
||||
@@ -149,12 +239,64 @@
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_rate`"
|
||||
:label="`${slotLabel(slot)}接诊率`"
|
||||
min-width="96"
|
||||
min-width="104"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
||||
<template #header>
|
||||
<span>{{ slotLabel(slot) }}接诊率</span>
|
||||
<el-tooltip effect="dark" placement="top" :content="rateHeaderTip(slot)">
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="rate-cell">
|
||||
<span class="rate-cell-main">{{ formatRate(row[`visit${slot}_rate`]) }}</span>
|
||||
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, `visit${slot}`) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
<el-table-column prop="visit_all_count" label="复诊合计单数" min-width="110" align="right">
|
||||
<template #header>
|
||||
<span>复诊合计单数</span>
|
||||
<el-tooltip effect="dark" placement="top" content="当月内下单、诊次 ≥ 2 的订单数之和(= 各 N 诊单数之和)">
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderLines && row.visit_all_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderDetail(0, row)"
|
||||
>
|
||||
{{ row.visit_all_count }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="visit_all_rate" label="复诊合计接诊率" min-width="118" align="right">
|
||||
<template #header>
|
||||
<span>复诊合计接诊率</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="
|
||||
isSlotMode
|
||||
? '复诊合计单数 ÷ 被指派合计(新指派 ∪ 继承);可超过 100%,语义为人均复诊成交单数'
|
||||
: '复诊合计单数 ÷ 新指派数(旧口径继承不计分母);可超过 100%'
|
||||
"
|
||||
>
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<div class="rate-cell">
|
||||
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span>
|
||||
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
@@ -162,7 +304,7 @@
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="detailTitle"
|
||||
width="980px"
|
||||
width="1080px"
|
||||
top="6vh"
|
||||
destroy-on-close
|
||||
>
|
||||
@@ -193,6 +335,33 @@
|
||||
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column v-if="isSlotMode" prop="assign_type" label="指派类型" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.assign_type === 'inherit' ? 'warning' : 'success'" size="small">
|
||||
{{ row.assign_type_text || (row.assign_type === 'inherit' ? '继承' : '新指派') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isSlotMode" prop="inherit_visit_slot" label="继承时诊次" width="100" align="center">
|
||||
<template #header>
|
||||
<span>继承时诊次</span>
|
||||
<el-tooltip effect="dark" placement="top" content="k:继承时刻之前患者已完成的诊次(有效实单数 + 偏移);新指派无此值">
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
{{ row.assign_type === 'inherit' ? `第 ${Number(row.inherit_visit_slot ?? 0)} 诊` : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="entry_slot" label="起算诊次" width="96" align="center">
|
||||
<template #header>
|
||||
<span>起算诊次</span>
|
||||
<el-tooltip effect="dark" placement="top" content="从该诊次列起计入分母:继承 = max(2, k+1);新指派 = 2(二诊)">
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">{{ entrySlotText(row.entry_slot) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||
</template>
|
||||
@@ -210,6 +379,9 @@
|
||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="detailSlot === 0" prop="visit_slot" label="诊次" width="80" align="center">
|
||||
<template #default="{ row }">{{ slotLabel(Number(row.visit_slot)) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -251,7 +423,9 @@ import {
|
||||
revisitRateAssignLines,
|
||||
revisitRateDeptOptions,
|
||||
revisitRateOverview,
|
||||
revisitRateVisitOrderLines
|
||||
revisitRateVisitOrderLines,
|
||||
type RevisitRateAssignKind,
|
||||
type RevisitRateInheritMode
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
@@ -263,7 +437,14 @@ const OrderDetailDrawer = defineAsyncComponent(
|
||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
interface MetricPack extends Record<string, any> {
|
||||
/** 新指派数(语义不变) */
|
||||
assigned_count: number
|
||||
inherit_count?: number
|
||||
assigned_total_count?: number
|
||||
visit_all_count?: number
|
||||
visit_all_den?: number
|
||||
visit_all_rate?: number | null
|
||||
visit2_rate_new_assign?: number | null
|
||||
}
|
||||
|
||||
interface StatsRow extends MetricPack {
|
||||
@@ -289,11 +470,14 @@ const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||
|
||||
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
||||
/** 接口动态返回的诊次分档(最少 2~4,按分子最大诊次 / 最大起算诊次扩展到五诊、六诊…) */
|
||||
const visitSlots = ref<number[]>([2, 3, 4])
|
||||
|
||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
const slotLabel = (slot: number) => {
|
||||
if (slot === 0) {
|
||||
return '复诊合计'
|
||||
}
|
||||
if (slot >= 2 && slot <= 10) {
|
||||
return `${cnDigits[slot]}诊`
|
||||
}
|
||||
@@ -307,6 +491,8 @@ const initNow = new Date()
|
||||
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||
const month = ref<string>(currentMonth)
|
||||
const deptId = ref<number | undefined>(undefined)
|
||||
/** 口径:首次加载不传、取后端默认(config default_inherit_mode),之后以用户选择为准 */
|
||||
const inheritMode = ref<RevisitRateInheritMode | ''>('')
|
||||
const loading = ref(false)
|
||||
const rows = ref<StatsRow[]>([])
|
||||
const total = ref<MetricPack | null>(null)
|
||||
@@ -314,6 +500,8 @@ const total = ref<MetricPack | null>(null)
|
||||
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
|
||||
const isSlotMode = computed(() => inheritMode.value !== 'exclude')
|
||||
|
||||
const monthText = computed(() => {
|
||||
const [y, m] = month.value.split('-')
|
||||
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||
@@ -322,9 +510,30 @@ const monthText = computed(() => {
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
|
||||
/** 接诊率单元格下方灰字:单数 / 分母 */
|
||||
const denText = (row: Record<string, any>, prefix: string) => {
|
||||
const cnt = Number(row[`${prefix}_count`] ?? 0)
|
||||
const den = row[`${prefix}_den`]
|
||||
return `${cnt} / ${den === null || den === undefined ? '—' : den}`
|
||||
}
|
||||
|
||||
const rateHeaderTip = (slot: number) =>
|
||||
isSlotMode.value
|
||||
? `${slotLabel(slot)}单数 ÷ ${slotLabel(slot)}分母;分母 = 当月新指派数 + 起算诊次 ≤ ${slot} 的继承数;分母为 0 显示「—」`
|
||||
: `${slotLabel(slot)}单数 ÷ 当月新指派数(旧口径:继承不计分母);分母为 0 显示「—」`
|
||||
|
||||
const entrySlotText = (entry: unknown) => {
|
||||
const n = Number(entry ?? 0)
|
||||
if (n <= 0) {
|
||||
return '—'
|
||||
}
|
||||
return n > 50 ? `${n}(仅计合计)` : slotLabel(n)
|
||||
}
|
||||
|
||||
const baseQuery = () => ({
|
||||
month: month.value,
|
||||
dept_ids: deptId.value ? String(deptId.value) : ''
|
||||
dept_ids: deptId.value ? String(deptId.value) : '',
|
||||
inherit_mode: (inheritMode.value || undefined) as RevisitRateInheritMode | undefined
|
||||
})
|
||||
|
||||
interface SummaryCard {
|
||||
@@ -341,11 +550,11 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
||||
const cards: SummaryCard[] = [
|
||||
{
|
||||
key: 'assigned',
|
||||
label: '当月被指派总数',
|
||||
label: isSlotMode.value ? '当月新指派数' : '当月被指派数',
|
||||
value: t ? String(t.assigned_count) : '—',
|
||||
sub: '',
|
||||
sub: t && isSlotMode.value ? `继承 ${t.inherit_count ?? 0} · 被指派合计 ${t.assigned_total_count ?? 0}` : '',
|
||||
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||
onClick: () => openAssignDetail(null)
|
||||
onClick: () => openAssignDetail(null, 'new')
|
||||
}
|
||||
]
|
||||
for (const slot of visitSlots.value) {
|
||||
@@ -355,11 +564,19 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
||||
key: `visit${slot}`,
|
||||
label: `当月${slotLabel(slot)}接诊率`,
|
||||
value: t ? formatRate(rate) : '—',
|
||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}` : '',
|
||||
clickable: !!(canViewOrderLines && cnt > 0),
|
||||
onClick: () => openOrderDetail(slot, null)
|
||||
})
|
||||
}
|
||||
cards.push({
|
||||
key: 'visit_all',
|
||||
label: '当月复诊合计接诊率',
|
||||
value: t ? formatRate(t.visit_all_rate) : '—',
|
||||
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 被指派合计 ${t.visit_all_den ?? '—'}` : '',
|
||||
clickable: !!(canViewOrderLines && t && Number(t.visit_all_count) > 0),
|
||||
onClick: () => openOrderDetail(0, null)
|
||||
})
|
||||
return cards
|
||||
})
|
||||
|
||||
@@ -374,15 +591,16 @@ const getSummaries = ({ columns }: { columns: any[] }) => {
|
||||
return ''
|
||||
}
|
||||
const prop = String(col.property || '')
|
||||
if (prop === 'assigned_count') {
|
||||
return String(t.assigned_count)
|
||||
if (prop === 'assigned_count' || prop === 'inherit_count' || prop === 'assigned_total_count') {
|
||||
return String(t[prop] ?? 0)
|
||||
}
|
||||
if (/^visit\d+_count$/.test(prop)) {
|
||||
const m = /^visit(\d+|all)_(count|den|rate)$/.exec(prop)
|
||||
if (m) {
|
||||
if (m[2] === 'rate') {
|
||||
return `${formatRate(t[prop])}(${denText(t, `visit${m[1]}`)})`
|
||||
}
|
||||
return String(t[prop] ?? '')
|
||||
}
|
||||
if (/^visit\d+_rate$/.test(prop)) {
|
||||
return formatRate(t[prop])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
}
|
||||
@@ -404,6 +622,9 @@ const loadData = async () => {
|
||||
if (res?.month) {
|
||||
month.value = res.month
|
||||
}
|
||||
if (res?.inherit_mode === 'slot' || res?.inherit_mode === 'exclude') {
|
||||
inheritMode.value = res.inherit_mode
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -449,6 +670,8 @@ const detailType = ref<'assign' | 'orders'>('assign')
|
||||
const detailTitle = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const detailRows = ref<any[]>([])
|
||||
/** 订单明细的诊次(0 = 复诊合计,表格多显示「诊次」列) */
|
||||
const detailSlot = ref(2)
|
||||
|
||||
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||
@@ -461,13 +684,19 @@ const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: numb
|
||||
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||
}
|
||||
|
||||
const openAssignDetail = async (row: StatsRow | null) => {
|
||||
const assignKindLabel: Record<RevisitRateAssignKind, string> = {
|
||||
new: '新指派明细',
|
||||
inherit: '继承明细',
|
||||
all: '被指派合计明细'
|
||||
}
|
||||
|
||||
const openAssignDetail = async (row: StatsRow | null, kind: RevisitRateAssignKind = 'all') => {
|
||||
if (!canViewAssignLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'assign'
|
||||
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
||||
detailTitle.value = `${scope.label} · ${assignKindLabel[kind]}(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailRows.value = []
|
||||
@@ -475,7 +704,8 @@ const openAssignDetail = async (row: StatsRow | null) => {
|
||||
const res = await revisitRateAssignLines({
|
||||
...baseQuery(),
|
||||
assistant_id: scope.assistant_id,
|
||||
dept_id: scope.dept_id
|
||||
dept_id: scope.dept_id,
|
||||
kind
|
||||
})
|
||||
detailRows.value = res?.rows ?? []
|
||||
} finally {
|
||||
@@ -483,12 +713,14 @@ const openAssignDetail = async (row: StatsRow | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** slot=0 表示全部诊次 ≥ 2(复诊合计单数) */
|
||||
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||
if (!canViewOrderLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'orders'
|
||||
detailSlot.value = slot
|
||||
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
@@ -529,6 +761,7 @@ const openPatientDetail = (diagnosisId: number) => {
|
||||
const handleReset = () => {
|
||||
month.value = currentMonth
|
||||
deptId.value = undefined
|
||||
inheritMode.value = ''
|
||||
loadData()
|
||||
}
|
||||
|
||||
@@ -623,6 +856,22 @@ onMounted(() => {
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.rate-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.3;
|
||||
|
||||
.rate-cell-main {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rate-cell-sub {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rate-detail-footer {
|
||||
@@ -640,5 +889,10 @@ onMounted(() => {
|
||||
p + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.rate-caliber-note {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,9 +12,21 @@
|
||||
{{ assistantLogCellText(row, 'to') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="继承" width="72" align="center">
|
||||
<el-table-column label="继承" width="118" align="center">
|
||||
<template #header>
|
||||
<span>继承</span>
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="继承时第 k 诊:继承时刻之前患者已完成的诊次(有效实单数 + 偏移),复诊接诊率从第 k+1 诊起计入新医助分母;2026-10 前的继承记录无此快照"
|
||||
>
|
||||
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="Number(row.is_inherit) === 1" type="success" size="small">是</el-tag>
|
||||
<template v-if="Number(row.is_inherit) === 1">
|
||||
<el-tag type="success" size="small">是</el-tag>
|
||||
<div v-if="inheritSlotText(row)" class="assign-log-inherit-slot">{{ inheritSlotText(row) }}</div>
|
||||
</template>
|
||||
<span v-else class="text-gray-400">否</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -85,6 +97,20 @@ function relatedPoCreateTimeCell(row: Record<string, unknown>): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
/** 对应日志表 inherit_visit_slot / inherit_visit_slot_calc_time:后端已拼好「继承时第 k 诊」文案,缺失时按快照列兜底 */
|
||||
function inheritSlotText(row: Record<string, unknown>): string {
|
||||
const text = row.inherit_visit_slot_text
|
||||
if (text != null && String(text).trim() !== '') {
|
||||
return String(text)
|
||||
}
|
||||
const calcTime = Number(row.inherit_visit_slot_calc_time)
|
||||
if (!Number.isFinite(calcTime) || calcTime <= 0) {
|
||||
return ''
|
||||
}
|
||||
const k = Number(row.inherit_visit_slot)
|
||||
return Number.isFinite(k) && k > 0 ? `继承时第 ${k} 诊` : '继承时未成交'
|
||||
}
|
||||
|
||||
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
|
||||
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
|
||||
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
|
||||
@@ -132,4 +158,11 @@ defineExpose({ refresh: load })
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.assign-log-inherit-slot {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div :class="['rv-cell', { 'rv-cell--compact': compact }]">
|
||||
<!-- 行 1:[第 N 诊] tag + 已开方/未开方 + 复诊医生 -->
|
||||
<div class="rv-line rv-line--head">
|
||||
<el-tooltip
|
||||
v-if="showDue && tooltipLines.length"
|
||||
placement="top"
|
||||
:show-after="300"
|
||||
:hide-after="0"
|
||||
>
|
||||
<template #content>
|
||||
<div class="rv-tooltip">
|
||||
<div v-for="line in tooltipLines" :key="line">{{ line }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-tag size="small" :type="slotTagType" effect="light" class="rv-slot-tag">
|
||||
{{ slotText }}
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag
|
||||
v-else-if="showDue"
|
||||
size="small"
|
||||
:type="slotTagType"
|
||||
effect="light"
|
||||
class="rv-slot-tag"
|
||||
>
|
||||
{{ slotText }}
|
||||
</el-tag>
|
||||
<span
|
||||
v-if="prescribedLabel"
|
||||
:class="['rv-prescribed', hasPrescription ? 'rv-prescribed--yes' : 'rv-prescribed--no']"
|
||||
>
|
||||
{{ prescribedLabel }}
|
||||
</span>
|
||||
<span v-if="followupDoctorName" class="rv-doctor">
|
||||
<span v-if="prescribedLabel" class="rv-dot">·</span>{{ followupDoctorName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="showDue">
|
||||
<!-- 行 2:最新开药日 · 开药 X 天 · 锚点来源;缺天数时橙色 -->
|
||||
<div class="rv-line rv-line--order">
|
||||
<span v-if="orderDateText" class="rv-order-date">{{ orderDateText }} 开药</span>
|
||||
<span v-if="orderDateText" class="rv-dot">·</span>
|
||||
<span :class="['rv-days', { 'rv-days--missing': daysMissing }]">{{ medicationText }}</span>
|
||||
<template v-if="!daysMissing && anchorText">
|
||||
<span class="rv-dot">·</span>
|
||||
<span class="rv-anchor">{{ anchorText }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 行 3:药尽 MM-DD + 状态 chip -->
|
||||
<div class="rv-line rv-line--due">
|
||||
<span v-if="dueDateText" class="rv-due-date">{{ dueDateText }}</span>
|
||||
<el-tag
|
||||
v-if="chipText"
|
||||
size="small"
|
||||
:type="chipType"
|
||||
:effect="chipEffect"
|
||||
:class="chipClass"
|
||||
>
|
||||
{{ chipText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 行 4:已延迟 N 次 · 已联系 约 MM-DD(有值才显示) -->
|
||||
<div v-if="followLineText" class="rv-line rv-line--follow">{{ followLineText }}</div>
|
||||
</template>
|
||||
<span v-else class="rv-none">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 复诊节点单元格(PC 列表 / H5 卡片紧凑 / 只读页 三处共用)
|
||||
*
|
||||
* 只负责展示,不发请求;数据来自列表行 `revisit_due`(契约 C),由父组件用
|
||||
* `normalizeRevisitDue` 规整后传入。state='none' 或 due 为 null 时仅显示行 1 + 「—」。
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
formatRevisitMonthDay,
|
||||
hasRevisitDue,
|
||||
isRevisitDaysMissing,
|
||||
revisitAnchorSrcText,
|
||||
revisitDueDateText,
|
||||
revisitFollowLineText,
|
||||
revisitMedicationDaysText,
|
||||
revisitSlotText,
|
||||
revisitStateChipClass,
|
||||
revisitStateChipText,
|
||||
revisitStateTagEffect,
|
||||
revisitStateTagType,
|
||||
revisitTooltipLines,
|
||||
type RevisitDue
|
||||
} from '@/utils/revisitDue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 规整后的复诊节点对象;null / state='none' 显示「—」 */
|
||||
due: RevisitDue | null
|
||||
/** H5 紧凑模式:更小字号、更紧行距 */
|
||||
compact?: boolean
|
||||
/** 是否已开方(决定「已开方/未开方」默认文案与颜色) */
|
||||
hasPrescription?: boolean
|
||||
/** 复诊医生(原列表 followup_doctor_name) */
|
||||
followupDoctorName?: string
|
||||
/** 自定义「已开方/未开方」文案;传 '' 隐藏该段(H5 卡片头已有标签时用) */
|
||||
prescribedText?: string
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
hasPrescription: false,
|
||||
followupDoctorName: ''
|
||||
}
|
||||
)
|
||||
|
||||
const showDue = computed(() => hasRevisitDue(props.due))
|
||||
|
||||
const prescribedLabel = computed(() => {
|
||||
if (props.prescribedText !== undefined) return props.prescribedText
|
||||
return props.hasPrescription ? '已开方' : '未开方'
|
||||
})
|
||||
|
||||
const slotText = computed(() => revisitSlotText(props.due))
|
||||
const slotTagType = computed<'primary' | 'info'>(() =>
|
||||
showDue.value && Number(props.due?.current_slot) > 0 ? 'primary' : 'info'
|
||||
)
|
||||
const tooltipLines = computed(() => revisitTooltipLines(props.due))
|
||||
|
||||
const orderDateText = computed(() => (showDue.value ? formatRevisitMonthDay(props.due?.order_date) : ''))
|
||||
const daysMissing = computed(() => isRevisitDaysMissing(props.due))
|
||||
const medicationText = computed(() => revisitMedicationDaysText(props.due))
|
||||
const anchorText = computed(() => (showDue.value ? revisitAnchorSrcText(props.due?.anchor_src) : ''))
|
||||
|
||||
const dueDateText = computed(() => revisitDueDateText(props.due))
|
||||
const chipText = computed(() => revisitStateChipText(props.due))
|
||||
const chipType = computed(() => revisitStateTagType(props.due?.state))
|
||||
const chipEffect = computed(() => revisitStateTagEffect(props.due?.state))
|
||||
const chipClass = computed(() => revisitStateChipClass(props.due))
|
||||
|
||||
const followLineText = computed(() => revisitFollowLineText(props.due))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rv-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
line-height: 1.35;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rv-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rv-line--head {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rv-slot-tag {
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rv-prescribed {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rv-prescribed--yes {
|
||||
color: #4a5dff;
|
||||
}
|
||||
|
||||
.rv-prescribed--no {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.rv-doctor {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rv-dot {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rv-line--order {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rv-days--missing {
|
||||
color: #ea580c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rv-anchor {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.rv-line--due {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rv-due-date {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 状态 chip:el-tag 基础色由 type/effect 决定,这里只补深红加粗与描边细节。
|
||||
前缀 .rv-cell 提高特异性,压过 Element 的 .el-tag--dark.el-tag--danger 变量定义 */
|
||||
.rv-cell .rv-chip {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rv-cell .rv-chip--overdue,
|
||||
.rv-cell .rv-chip--missed {
|
||||
--el-tag-bg-color: #dc2626;
|
||||
--el-tag-border-color: #dc2626;
|
||||
}
|
||||
|
||||
.rv-cell .rv-chip--severe,
|
||||
.rv-cell .rv-chip--overdue.rv-chip--severe,
|
||||
.rv-cell .rv-chip--missed.rv-chip--severe {
|
||||
font-weight: 700;
|
||||
--el-tag-bg-color: #b91c1c;
|
||||
--el-tag-border-color: #b91c1c;
|
||||
}
|
||||
|
||||
.rv-cell .rv-chip--warning {
|
||||
--el-tag-text-color: #ea580c;
|
||||
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
|
||||
--el-tag-border-color: rgba(234, 88, 12, 0.3);
|
||||
}
|
||||
|
||||
.rv-cell .rv-chip--no_days {
|
||||
--el-tag-text-color: #ea580c;
|
||||
--el-tag-border-color: #ea580c;
|
||||
}
|
||||
|
||||
.rv-cell .rv-chip--visited {
|
||||
--el-tag-text-color: #16a34a;
|
||||
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
|
||||
--el-tag-border-color: rgba(22, 163, 74, 0.3);
|
||||
}
|
||||
|
||||
.rv-cell .rv-chip--booked {
|
||||
--el-tag-text-color: #4a5dff;
|
||||
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
|
||||
--el-tag-border-color: rgba(74, 93, 255, 0.3);
|
||||
}
|
||||
|
||||
.rv-cell .rv-chip--contacted {
|
||||
--el-tag-text-color: #4a5dff;
|
||||
--el-tag-border-color: #4a5dff;
|
||||
}
|
||||
|
||||
.rv-line--follow {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rv-none {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.rv-tooltip {
|
||||
line-height: 1.6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* H5 紧凑模式 */
|
||||
.rv-cell--compact {
|
||||
gap: 2px;
|
||||
|
||||
.rv-line--head,
|
||||
.rv-line--due,
|
||||
.rv-prescribed {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rv-line--order,
|
||||
.rv-line--follow,
|
||||
.rv-doctor {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rv-none {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@
|
||||
formData.pending_assign !== '1' &&
|
||||
formData.pending_booking !== '1' &&
|
||||
formData.completed_appointment !== '1' &&
|
||||
formData.revisit_state === '' &&
|
||||
!hasLatestAppointmentFilter() &&
|
||||
formData.appointment_date === tab.value
|
||||
}
|
||||
@@ -30,6 +31,14 @@
|
||||
待预约
|
||||
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
||||
</span>
|
||||
<!-- 复诊到期:到期未挂号 ∪ 过号未面诊(revisit_state=due),点击默认按药尽日升序 -->
|
||||
<span
|
||||
:class="['chip', 'chip-revisit-due', { active: formData.revisit_state === 'due' }]"
|
||||
@click="handleRevisitDueTabClick"
|
||||
>
|
||||
复诊到期
|
||||
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
|
||||
</span>
|
||||
<span
|
||||
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
|
||||
@click="handleCompletedVisitTabClick"
|
||||
@@ -147,6 +156,36 @@
|
||||
:value="String(item.value)"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="formData.revisit_state"
|
||||
placeholder="复诊状态"
|
||||
clearable
|
||||
size="small"
|
||||
class="revisit-state-select"
|
||||
@change="handleRevisitFilterChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in revisitStateFilterOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="formData.revisit_days_bucket"
|
||||
placeholder="开药天数档"
|
||||
clearable
|
||||
size="small"
|
||||
class="revisit-days-bucket-select"
|
||||
@change="doSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in revisitDaysBucketOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button size="small" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -278,22 +317,20 @@
|
||||
<span v-else class="status-unconfirmed">未确认</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="复诊" min-width="120">
|
||||
<!-- 复诊节点:吞掉原「复诊」「开方」两列;排序走后端 sort_revisit_due(药尽日,NULL 置后) -->
|
||||
<el-table-column
|
||||
label="复诊节点"
|
||||
prop="revisit_due_date"
|
||||
min-width="180"
|
||||
sortable="custom"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.has_prescription" class="followup-cell">
|
||||
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
|
||||
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
|
||||
<el-tag
|
||||
v-if="row.followup_rx_voided"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="followup-void-tag"
|
||||
>
|
||||
处方已作废
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="followup-none">无</span>
|
||||
<revisit-node-cell
|
||||
:due="revisitDueOf(row)"
|
||||
:has-prescription="!!row.has_prescription"
|
||||
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||
:prescribed-text="prescribedTextOf(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
||||
@@ -301,12 +338,6 @@
|
||||
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开方" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.has_prescription" class="status-prescribed">已开方</span>
|
||||
<span v-else class="status-unprescribed">未开方</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="未服务天数"
|
||||
prop="unserved_days"
|
||||
@@ -519,7 +550,6 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="继承">
|
||||
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
||||
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
||||
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
||||
@@ -737,7 +767,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="tcmDiagnosis">
|
||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
|
||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
|
||||
import { orderCreate } from '@/api/order'
|
||||
import { cancelAppointment } from '@/api/doctor'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
@@ -752,6 +782,15 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import RevisitNodeCell from './components/RevisitNodeCell.vue'
|
||||
import { formatGender } from '@/utils/diag-display'
|
||||
import {
|
||||
normalizeRevisitDue,
|
||||
REVISIT_DAYS_BUCKET_OPTIONS,
|
||||
REVISIT_STATE_FILTER_OPTIONS,
|
||||
revisitRowClassName,
|
||||
type RevisitDue
|
||||
} from '@/utils/revisitDue'
|
||||
|
||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||
@@ -798,6 +837,12 @@ const formData = reactive({
|
||||
latest_assign_end_date: '' as string,
|
||||
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
||||
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
||||
/** 复诊状态筛选:due(到期未挂号∪过号)|warning|booked|visited|contacted|no_days|pending,空=不限 */
|
||||
revisit_state: '' as string,
|
||||
/** 开药天数档筛选:14|21|28|30|other,空=不限 */
|
||||
revisit_days_bucket: '' as string,
|
||||
/** 复诊节点(药尽日)排序:asc=最早到期在前(NULL 置后) desc 反之;与 sort_unserved_days 互斥 */
|
||||
sort_revisit_due: '' as '' | 'asc' | 'desc',
|
||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||
appointment_date: '' as string,
|
||||
has_appointment: '' as '' | '0' | '1',
|
||||
@@ -898,11 +943,13 @@ function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
|
||||
latest_appointment_end_date: formData.latest_appointment_end_date,
|
||||
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
||||
latest_assign_start_date: formData.latest_assign_start_date,
|
||||
latest_assign_end_date: formData.latest_assign_end_date
|
||||
latest_assign_end_date: formData.latest_assign_end_date,
|
||||
revisit_state: formData.revisit_state,
|
||||
revisit_days_bucket: formData.revisit_days_bucket
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
|
||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件(revisit_state 视为 Tab 条件,角标间互不影响) */
|
||||
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
page_no: 1,
|
||||
@@ -915,6 +962,8 @@ function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}):
|
||||
pending_assign_order_month: '',
|
||||
pending_assign_keyword: '',
|
||||
sort_unserved_days: '',
|
||||
revisit_state: '',
|
||||
sort_revisit_due: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -960,6 +1009,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
||||
formData.latest_assign_start_date = ''
|
||||
formData.latest_assign_end_date = ''
|
||||
formData.sort_unserved_days = ''
|
||||
formData.revisit_state = ''
|
||||
formData.revisit_days_bucket = ''
|
||||
formData.sort_revisit_due = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
activeTab.value = 'all'
|
||||
if (kw1 !== '') {
|
||||
@@ -996,6 +1048,7 @@ const handleDateTabClick = async (value: string) => {
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
clearRevisitTabFilters()
|
||||
clearLatestAppointmentFilters()
|
||||
formData.appointment_date = value
|
||||
pager.page = 1
|
||||
@@ -1012,6 +1065,7 @@ const handlePendingBookingTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = '0'
|
||||
clearRevisitTabFilters()
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
@@ -1027,6 +1081,7 @@ const handleCompletedVisitTabClick = async () => {
|
||||
formData.completed_appointment = '1'
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
clearRevisitTabFilters()
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
@@ -1039,6 +1094,7 @@ const handlePendingAssignTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
clearRevisitTabFilters()
|
||||
clearLatestAppointmentFilters()
|
||||
if (!formData.pending_assign_order_month) {
|
||||
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
||||
@@ -1057,10 +1113,66 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量
|
||||
/** 复诊状态 / 开药天数档 下拉选项(文案与请求值映射在 utils/revisitDue.ts) */
|
||||
const revisitStateFilterOptions = REVISIT_STATE_FILTER_OPTIONS
|
||||
const revisitDaysBucketOptions = REVISIT_DAYS_BUCKET_OPTIONS
|
||||
|
||||
/** 顶部「复诊到期」角标:revisit_state=due 的 count */
|
||||
const revisitDueCount = ref<number | undefined>(undefined)
|
||||
|
||||
/** 切换其它顶部 Tab 时清掉复诊 chip 状态与其排序(revisit_days_bucket 属「更多筛选」,保留) */
|
||||
const clearRevisitTabFilters = () => {
|
||||
formData.revisit_state = ''
|
||||
formData.sort_revisit_due = ''
|
||||
}
|
||||
|
||||
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊,默认按药尽日升序(超期最多在前) */
|
||||
const handleRevisitDueTabClick = async () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
formData.pending_assign_keyword = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = ''
|
||||
clearLatestAppointmentFilters()
|
||||
formData.revisit_state = 'due'
|
||||
formData.sort_revisit_due = 'asc'
|
||||
formData.sort_unserved_days = ''
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
/** 「更多筛选 → 复诊状态」下拉:与顶部日期 Tab 互斥(同 chip 语义),选中后不再默认当天挂号 */
|
||||
const handleRevisitFilterChange = () => {
|
||||
if (formData.revisit_state !== '') {
|
||||
formData.appointment_date = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
const noSortYet = formData.sort_revisit_due === '' && formData.sort_unserved_days === ''
|
||||
if (formData.revisit_state === 'due' && noSortYet) {
|
||||
formData.sort_revisit_due = 'asc'
|
||||
}
|
||||
} else {
|
||||
formData.sort_revisit_due = ''
|
||||
}
|
||||
doSearch()
|
||||
}
|
||||
|
||||
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,单元格显示「—」) */
|
||||
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
|
||||
|
||||
/** 「复诊节点」行 1 的开方文案:已开方 / 已开方(处方已作废) / 未开方 */
|
||||
const prescribedTextOf = (row: any): string => {
|
||||
if (!row?.has_prescription) return '未开方'
|
||||
return row.followup_rx_voided ? '已开方(处方已作废)' : '已开方'
|
||||
}
|
||||
|
||||
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量 + 复诊到期数量
|
||||
const fetchDateCounts = async () => {
|
||||
try {
|
||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
@@ -1081,7 +1193,10 @@ const fetchDateCounts = async () => {
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ revisit_state: 'due', has_appointment: '' }) as any
|
||||
)
|
||||
])
|
||||
dateCounts.value = {
|
||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||
@@ -1094,6 +1209,7 @@ const fetchDateCounts = async () => {
|
||||
pendingBookingCount.value = noApt?.count ?? 0
|
||||
completedVisitCount.value = doneVisit?.count ?? 0
|
||||
pendingAssignCount.value = pending?.count ?? 0
|
||||
revisitDueCount.value = revisitDue?.count ?? 0
|
||||
} catch (e) {
|
||||
console.error('获取日期数量失败', e)
|
||||
}
|
||||
@@ -1252,12 +1368,11 @@ const handleTableSortChange = ({
|
||||
prop: string
|
||||
order: 'ascending' | 'descending' | null
|
||||
}) => {
|
||||
if (prop === 'unserved_days') {
|
||||
formData.sort_unserved_days =
|
||||
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||
} else {
|
||||
formData.sort_unserved_days = ''
|
||||
}
|
||||
const dir: '' | 'asc' | 'desc' =
|
||||
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||
// 按列 prop 分派;两种排序后端互斥,切换列时清掉另一列
|
||||
formData.sort_unserved_days = prop === 'unserved_days' ? dir : ''
|
||||
formData.sort_revisit_due = prop === 'revisit_due_date' ? dir : ''
|
||||
pager.page = 1
|
||||
getLists()
|
||||
}
|
||||
@@ -1311,6 +1426,9 @@ const handleReset = () => {
|
||||
formData.latest_assign_start_date = ''
|
||||
formData.latest_assign_end_date = ''
|
||||
formData.sort_unserved_days = ''
|
||||
formData.revisit_state = ''
|
||||
formData.revisit_days_bucket = ''
|
||||
formData.sort_revisit_due = ''
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = ''
|
||||
@@ -1343,8 +1461,10 @@ const unservedDaysClass = (n: unknown) => {
|
||||
return 'unserved-green'
|
||||
}
|
||||
|
||||
// 行高亮:未确认诊单优先,其次未挂号
|
||||
// 行高亮优先级(方案 D3-c 默认 overdue_first):复诊超期/过号 > 未确认 > 未挂号
|
||||
const getRowClassName = ({ row }: { row: any }) => {
|
||||
const revisitCls = revisitRowClassName(revisitDueOf(row))
|
||||
if (revisitCls) return revisitCls
|
||||
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
||||
if (!row.has_appointment) return 'row-no-appointment'
|
||||
return ''
|
||||
@@ -1356,6 +1476,8 @@ const prescriptionRef = ref()
|
||||
const appointmentRef = ref()
|
||||
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
|
||||
const pendingRouteDiagnosisId = ref<number | null>(null)
|
||||
/** 路由 ?id=&action=appointment 深链(复诊到期弹窗「去挂号」):挂号弹窗同为异步组件,先暂存行再 open */
|
||||
const pendingRouteAppointmentRow = ref<Record<string, any> | null>(null)
|
||||
const userStore = useUserStore()
|
||||
|
||||
watch(
|
||||
@@ -1370,6 +1492,53 @@ watch(
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
[appointmentRef, pendingRouteAppointmentRow],
|
||||
() => {
|
||||
const row = pendingRouteAppointmentRow.value
|
||||
const opener = appointmentRef.value as { open?: (row: any) => void } | undefined
|
||||
if (!row || !opener?.open) return
|
||||
pendingRouteAppointmentRow.value = null
|
||||
handleAppointment(row)
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
/**
|
||||
* 深链打开挂号弹窗:优先用当前列表里的行(字段最全);不在本页时回退只读详情接口拼最小行
|
||||
* (appointment.vue open() 只读 patient_id / patient_name / gender_desc / age)。
|
||||
*/
|
||||
const openAppointmentFromRoute = async (diagnosisId: number) => {
|
||||
// 深链打开挂号弹窗前先校验挂号权限(与行内「预约」按钮 v-perms 同口径)
|
||||
if (!hasPermission(['tcm.diagnosis/guahao'])) {
|
||||
feedback.msgWarning('无挂号权限')
|
||||
return
|
||||
}
|
||||
const inList = (pager.lists as any[]).find((r) => Number(r?.id) === diagnosisId)
|
||||
if (inList) {
|
||||
pendingRouteAppointmentRow.value = inList
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await diagnosisReadonlyDetail({ id: diagnosisId })
|
||||
const diag = res?.diagnosis
|
||||
if (!diag || !diag.patient_id) {
|
||||
feedback.msgWarning('未找到该诊单或患者信息不完整,无法打开挂号')
|
||||
return
|
||||
}
|
||||
pendingRouteAppointmentRow.value = {
|
||||
...diag,
|
||||
id: diagnosisId,
|
||||
patient_id: diag.patient_id,
|
||||
patient_name: diag.patient_name || '',
|
||||
gender_desc: formatGender(diag.gender),
|
||||
age: diag.age ?? 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgWarning(e?.msg || e?.message || '加载诊单失败,无法打开挂号')
|
||||
}
|
||||
}
|
||||
|
||||
const watchCallVisible = ref(false)
|
||||
const watchCallDiagnosisId = ref(0)
|
||||
|
||||
@@ -1614,13 +1783,13 @@ const handleConfirmAssign = async () => {
|
||||
feedback.msgWarning('请选择医助')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
assignLoading.value = true
|
||||
try {
|
||||
if (assignMode.value === 'single') {
|
||||
// 单独指派
|
||||
await tcmDiagnosisAssign({
|
||||
id: currentRow.value.id,
|
||||
await tcmDiagnosisAssign({
|
||||
id: currentRow.value.id,
|
||||
assistant_id: assignForm.value.assistant_id,
|
||||
is_inherit: assignForm.value.is_inherit ? 1 : 0
|
||||
})
|
||||
@@ -2220,22 +2389,57 @@ const goReadonly = (row: any) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 默认展示当天挂号,突出重点
|
||||
formData.appointment_date = todayStr.value
|
||||
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
||||
getDictOptions()
|
||||
getLists()
|
||||
scheduleDeferredDateCounts()
|
||||
if (String(route.query.revisit_state ?? '') === 'due') {
|
||||
// 复诊到期弹窗「查看全部」深链:直接激活「复诊到期」chip(内部会 getLists + fetchDateCounts)
|
||||
void handleRevisitDueTabClick()
|
||||
} else {
|
||||
// 默认展示当天挂号,突出重点
|
||||
formData.appointment_date = todayStr.value
|
||||
getLists()
|
||||
scheduleDeferredDateCounts()
|
||||
}
|
||||
|
||||
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
|
||||
|
||||
const raw = route.query.id
|
||||
if (raw != null && raw !== '') {
|
||||
const num = Number(raw)
|
||||
if (!Number.isNaN(num)) pendingRouteDiagnosisId.value = num
|
||||
if (!Number.isNaN(num) && num > 0) {
|
||||
if (String(route.query.action ?? '') === 'appointment') {
|
||||
// 复诊到期弹窗「去挂号」深链:等首屏列表回来后再找行,找不到走详情接口
|
||||
void getListsPromiseForRoute().then(() => openAppointmentFromRoute(num))
|
||||
} else {
|
||||
pendingRouteDiagnosisId.value = num
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** onMounted 里 getLists() 已发起;等待首屏列表 loading 结束(最多 8s)再找行,失败/超时不阻断 */
|
||||
const getListsPromiseForRoute = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (!pager.loading) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
let settled = false
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
stop()
|
||||
resolve()
|
||||
}
|
||||
const stop = watch(
|
||||
() => pager.loading,
|
||||
(loading) => {
|
||||
if (!loading) finish()
|
||||
}
|
||||
)
|
||||
setTimeout(finish, 8000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (silentListPollTimer != null) {
|
||||
clearInterval(silentListPollTimer)
|
||||
@@ -2313,6 +2517,21 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.chip-revisit-due {
|
||||
margin-left: 8px;
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-danger-light-8);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.chip-completed-visit {
|
||||
margin-left: 8px;
|
||||
color: var(--el-color-success);
|
||||
@@ -2451,6 +2670,14 @@ onUnmounted(() => {
|
||||
.latest-appointment-channel {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.revisit-state-select {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.revisit-days-bucket-select {
|
||||
width: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-card {
|
||||
@@ -2641,17 +2868,6 @@ onUnmounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-prescribed {
|
||||
font-size: 13px;
|
||||
color: #4A5DFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-unprescribed {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
||||
.unserved-days {
|
||||
display: inline-block;
|
||||
@@ -2696,33 +2912,6 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.followup-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
line-height: 1.35;
|
||||
|
||||
.followup-time {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.followup-doctor {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.followup-void-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.followup-none {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.assistant-cell {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
@@ -2782,6 +2971,22 @@ onUnmounted(() => {
|
||||
:deep(.el-table__row.row-no-appointment:hover > td) {
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
|
||||
}
|
||||
/* 复诊到期未挂号 / 过号未面诊:红色左边条(同 row-unconfirmed 结构,warning → danger) */
|
||||
:deep(.el-table__row.row-revisit-overdue) {
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
|
||||
}
|
||||
:deep(.el-table__row.row-revisit-overdue:hover > td) {
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||
}
|
||||
/* 超期 ≥ severe_overdue_days:底色加深到 10%,悬停 14% */
|
||||
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
|
||||
border-left-color: #b91c1c;
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||
}
|
||||
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
|
||||
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.record-list {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
formData.pending_assign !== '1' &&
|
||||
formData.pending_booking !== '1' &&
|
||||
formData.completed_appointment !== '1' &&
|
||||
formData.revisit_state === '' &&
|
||||
formData.appointment_date === tab.value
|
||||
}
|
||||
]"
|
||||
@@ -40,6 +41,13 @@
|
||||
待预约
|
||||
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
||||
</span>
|
||||
<span
|
||||
:class="['dh5-chip', 'dh5-chip--revisit', { active: formData.revisit_state === 'due' }]"
|
||||
@click="handleRevisitDueTabClick"
|
||||
>
|
||||
复诊到期
|
||||
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
|
||||
</span>
|
||||
<span
|
||||
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
|
||||
@click="handleCompletedVisitTabClick"
|
||||
@@ -170,13 +178,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 复诊块(仅已开方时显示) -->
|
||||
<div v-if="row.has_prescription" class="dh5-row dh5-row--followup">
|
||||
<!-- 复诊节点块(已开方或有复诊节点数据时显示;卡片头已有「已开方」标签,此处隐藏开方文案) -->
|
||||
<div v-if="row.has_prescription || hasRevisitDue(revisitDueOf(row))" class="dh5-row dh5-row--followup">
|
||||
<span class="dh5-row-key">复诊</span>
|
||||
<div class="dh5-row-val">
|
||||
<span>{{ row.followup_time_text || '—' }}</span>
|
||||
<span class="dh5-meta-doctor">{{ row.followup_doctor_name || '—' }}</span>
|
||||
<span v-if="row.followup_rx_voided" class="dh5-tag dh5-tag--gray">处方作废</span>
|
||||
<div class="dh5-row-val dh5-row-val--revisit">
|
||||
<revisit-node-cell
|
||||
compact
|
||||
:due="revisitDueOf(row)"
|
||||
:has-prescription="!!row.has_prescription"
|
||||
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||
:prescribed-text="row.followup_rx_voided ? '处方作废' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -625,6 +637,13 @@ import {
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import RevisitNodeCell from './components/RevisitNodeCell.vue'
|
||||
import {
|
||||
hasRevisitDue,
|
||||
isRevisitDueHighlight,
|
||||
normalizeRevisitDue,
|
||||
type RevisitDue
|
||||
} from '@/utils/revisitDue'
|
||||
|
||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||
@@ -685,7 +704,9 @@ const formData = reactive({
|
||||
has_appointment: '' as '' | '0' | '1',
|
||||
pending_assign: '' as '' | '1',
|
||||
pending_booking: '' as '' | '1',
|
||||
completed_appointment: '' as '' | '1'
|
||||
completed_appointment: '' as '' | '1',
|
||||
/** 顶部「复诊到期」chip:due = 到期未挂号 ∪ 过号未面诊(后端 revisit_state) */
|
||||
revisit_state: '' as '' | 'due'
|
||||
})
|
||||
|
||||
const showMoreFilter = ref(false)
|
||||
@@ -740,6 +761,7 @@ const handleReset = () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.revisit_state = ''
|
||||
pager.page = 1
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
@@ -767,6 +789,7 @@ const handleDateTabClick = async (value: string) => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.revisit_state = ''
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = value
|
||||
pager.page = 1
|
||||
@@ -778,6 +801,7 @@ const handlePendingBookingTabClick = async () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_booking = '1'
|
||||
formData.completed_appointment = ''
|
||||
formData.revisit_state = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = '0'
|
||||
pager.page = 1
|
||||
@@ -789,6 +813,7 @@ const handleCompletedVisitTabClick = async () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = '1'
|
||||
formData.revisit_state = ''
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
pager.page = 1
|
||||
@@ -800,6 +825,7 @@ const handlePendingAssignTabClick = async () => {
|
||||
formData.pending_assign = '1'
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.revisit_state = ''
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
pager.page = 1
|
||||
@@ -807,9 +833,27 @@ const handlePendingAssignTabClick = async () => {
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊;后端按 revisit_state=due 过滤,排序默认药尽日升序 */
|
||||
const handleRevisitDueTabClick = async () => {
|
||||
formData.pending_assign = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
formData.revisit_state = 'due'
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
}
|
||||
|
||||
const revisitDueCount = ref<number | undefined>(undefined)
|
||||
|
||||
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,不渲染复诊块) */
|
||||
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
|
||||
|
||||
const fetchDateCounts = async () => {
|
||||
try {
|
||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
|
||||
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
||||
@@ -818,7 +862,8 @@ const fetchDateCounts = async () => {
|
||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
|
||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ revisit_state: 'due', page_no: 1, page_size: 1 })
|
||||
])
|
||||
dateCounts.value = {
|
||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||
@@ -831,6 +876,7 @@ const fetchDateCounts = async () => {
|
||||
pendingBookingCount.value = noApt?.count ?? 0
|
||||
completedVisitCount.value = doneVisit?.count ?? 0
|
||||
pendingAssignCount.value = pending?.count ?? 0
|
||||
revisitDueCount.value = revisitDue?.count ?? 0
|
||||
} catch (e) {
|
||||
console.error('获取日期数量失败', e)
|
||||
}
|
||||
@@ -898,7 +944,9 @@ const isDiagnosisConfirmed = (row: any) => {
|
||||
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
||||
}
|
||||
|
||||
// 卡片左边条优先级(同 PC 行高亮):复诊超期/过号 > 未确认 > 未挂号
|
||||
const getCardClass = (row: any) => {
|
||||
if (isRevisitDueHighlight(revisitDueOf(row))) return 'dh5-card--overdue'
|
||||
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
|
||||
if (!row.has_appointment) return 'dh5-card--noapt'
|
||||
return ''
|
||||
@@ -1582,6 +1630,8 @@ $dh5-card-bg: #ffffff;
|
||||
.dh5-chip--booking.active { background: $dh5-warn; }
|
||||
.dh5-chip--done.active { background: $dh5-success; }
|
||||
.dh5-chip--pending.active { background: $dh5-danger; }
|
||||
.dh5-chip--revisit { color: #b91c1c; background: rgba(239, 68, 68, 0.10); }
|
||||
.dh5-chip--revisit.active { color: #fff; background: $dh5-danger; }
|
||||
|
||||
.dh5-quick {
|
||||
display: flex;
|
||||
@@ -1675,6 +1725,12 @@ $dh5-card-bg: #ffffff;
|
||||
|
||||
.dh5-card--unconfirmed::before { background: $dh5-danger; }
|
||||
.dh5-card--noapt::before { background: $dh5-warn; }
|
||||
/* 复诊到期未挂号 / 过号未面诊:左边条同 danger,外框与底色轻微染红以区分「未确认」 */
|
||||
.dh5-card--overdue::before { background: $dh5-danger; }
|
||||
.dh5-card--overdue {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, $dh5-card-bg 60%);
|
||||
}
|
||||
|
||||
.dh5-card-head {
|
||||
display: flex;
|
||||
@@ -1800,6 +1856,11 @@ $dh5-card-bg: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 复诊节点块:单元格组件自带多行布局,取消外层 flex 换行间距 */
|
||||
.dh5-row-val--revisit {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 挂号块 - 状态色 */
|
||||
.dh5-row--apt {
|
||||
background: #fafbfc;
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
|
||||
<!-- 复诊节点徽标:第 N 诊 · 药尽 MM-DD · 超期 N 天(接口无 revisit_due 字段时不渲染) -->
|
||||
<el-tooltip
|
||||
v-if="revisitBadgeLabel"
|
||||
placement="bottom"
|
||||
:disabled="!revisitTooltipText"
|
||||
:content="revisitTooltipText"
|
||||
>
|
||||
<span :class="['unserved-badge', 'revisit-badge', revisitBadgeColorClass]">
|
||||
{{ revisitBadgeLabel }}
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,6 +167,12 @@ import { diagnosisReadonlyDetail } from '@/api/tcm'
|
||||
import { getDoctorNotes } from '@/api/patient'
|
||||
import { formatGender } from '@/utils/diag-display'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import {
|
||||
normalizeRevisitDue,
|
||||
revisitBadgeText,
|
||||
revisitStateColorClass,
|
||||
revisitTooltipLines
|
||||
} from '@/utils/revisitDue'
|
||||
import PatientInfoCard from './components/PatientInfoCard.vue'
|
||||
import PatientCaseCard from './components/PatientCaseCard.vue'
|
||||
import DailyMatrix from './components/DailyMatrix.vue'
|
||||
@@ -193,6 +210,17 @@ const unservedDays = computed<number | null>(() => {
|
||||
})
|
||||
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
|
||||
|
||||
/** 复诊节点:readonlyDetail 顶层 revisit_due(兼容挂在 diagnosis 下);字段不存在 → null → 不渲染徽标 */
|
||||
const revisitDue = computed(() =>
|
||||
normalizeRevisitDue(detail.value?.revisit_due ?? detail.value?.diagnosis?.revisit_due)
|
||||
)
|
||||
const revisitBadgeLabel = computed(() => revisitBadgeText(revisitDue.value))
|
||||
const revisitBadgeColorClass = computed(() => {
|
||||
const due = revisitDue.value
|
||||
return due ? revisitStateColorClass(due.state) : 'unserved-muted'
|
||||
})
|
||||
const revisitTooltipText = computed(() => revisitTooltipLines(revisitDue.value).join(';'))
|
||||
|
||||
/**
|
||||
* 「未服务天数」着色规则(与 T3 列表一致):
|
||||
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
||||
@@ -352,6 +380,13 @@ onMounted(fetchDetail)
|
||||
border-color: #fecaca;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
|
||||
/* 复诊节点徽标专用:已约 / 已联系 */
|
||||
&.unserved-blue {
|
||||
color: #4a5dff;
|
||||
border-color: #c7d2fe;
|
||||
background: rgba(74, 93, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
|
||||
Reference in New Issue
Block a user