更新
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
"Bash(python .claude/skills/ui-ux-pro-max/scripts/search.py \"finance admin dashboard SaaS form professional\" --design-system -p \"Finance Target Setting\" -f markdown)",
|
"Bash(python .claude/skills/ui-ux-pro-max/scripts/search.py \"finance admin dashboard SaaS form professional\" --design-system -p \"Finance Target Setting\" -f markdown)",
|
||||||
"Bash(python *)",
|
"Bash(python *)",
|
||||||
"Bash(php think *)",
|
"Bash(php think *)",
|
||||||
"Bash(python3 -c ' *)"
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(python3 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export function tcmDiagnosisDetail(params: any) {
|
|||||||
return request.get({ url: '/tcm.diagnosis/detail', params })
|
return request.get({ url: '/tcm.diagnosis/detail', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 诊单挂号 / 取消挂号 操作日志 */
|
||||||
|
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
|
||||||
|
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
||||||
|
}
|
||||||
|
|
||||||
// 检查手机号是否重复
|
// 检查手机号是否重复
|
||||||
export function checkPhone(params: any) {
|
export function checkPhone(params: any) {
|
||||||
return request.post({ url: '/tcm.diagnosis/checkPhone', params })
|
return request.post({ url: '/tcm.diagnosis/checkPhone', params })
|
||||||
@@ -335,6 +340,15 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 业务订单详情:仅修改关联处方的患者姓名与手机号 */
|
||||||
|
export function prescriptionOrderPatchPrescriptionPatient(params: {
|
||||||
|
id: number
|
||||||
|
patient_name: string
|
||||||
|
phone: string
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
|
||||||
|
}
|
||||||
|
|
||||||
export function prescriptionOrderAuditPrescription(params: {
|
export function prescriptionOrderAuditPrescription(params: {
|
||||||
id: number
|
id: number
|
||||||
action: 'approve' | 'reject'
|
action: 'approve' | 'reject'
|
||||||
|
|||||||
@@ -437,16 +437,12 @@
|
|||||||
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
|
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-stack"><div class="rx-bot-label">审方</div></div>
|
<div class="rx-bot-cell rx-bot-meta">
|
||||||
<div class="rx-bot-cell rx-bot-stack"><div class="rx-bot-label">配药</div></div>
|
|
||||||
<div class="rx-bot-cell rx-bot-stack"><div class="rx-bot-label">复核</div></div>
|
|
||||||
<div class="rx-bot-cell rx-bot-stack"><div class="rx-bot-label">发药</div></div>
|
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
|
||||||
<span class="rx-bot-meta-key">类型:</span>
|
<span class="rx-bot-meta-key">类型:</span>
|
||||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
<div class="rx-bot-cell rx-bot-meta">
|
||||||
<span class="rx-bot-meta-key">剂数:</span>
|
<span class="rx-bot-meta-key">天数:</span>
|
||||||
<span class="rx-bot-meta-val">{{ slipView.dose_count || '—' }}剂</span>
|
<span class="rx-bot-meta-val">{{ slipView.dose_count || '—' }}剂</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
<div class="rx-bot-cell rx-bot-meta">
|
||||||
@@ -454,32 +450,7 @@
|
|||||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-row rx-bot-row-2">
|
|
||||||
<div class="rx-bot-cell rx-fee">
|
|
||||||
<span class="rx-fee-key">医药费</span>
|
|
||||||
<span class="rx-fee-val">{{ rxFee.medicine }}元</span>
|
|
||||||
</div>
|
|
||||||
<div class="rx-bot-cell rx-fee">
|
|
||||||
<span class="rx-fee-key">诊金:</span>
|
|
||||||
<span class="rx-fee-val">{{ rxFee.consult }}元</span>
|
|
||||||
</div>
|
|
||||||
<div class="rx-bot-cell rx-fee">
|
|
||||||
<span class="rx-fee-key">制作费:</span>
|
|
||||||
<span class="rx-fee-val">{{ rxFee.making }}元</span>
|
|
||||||
</div>
|
|
||||||
<div class="rx-bot-cell rx-fee">
|
|
||||||
<span class="rx-fee-key">运费:</span>
|
|
||||||
<span class="rx-fee-val">{{ rxFee.shipping }}元</span>
|
|
||||||
</div>
|
|
||||||
<div class="rx-bot-cell rx-fee">
|
|
||||||
<span class="rx-fee-key">优惠:</span>
|
|
||||||
<span class="rx-fee-val">{{ rxFee.discount }}元</span>
|
|
||||||
</div>
|
|
||||||
<div class="rx-bot-cell rx-fee rx-fee-total">
|
|
||||||
<span class="rx-fee-key">合计:</span>
|
|
||||||
<span class="rx-fee-val">{{ rxFee.total }}元</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,20 @@
|
|||||||
<el-table-column label="收货人" min-width="100">
|
<el-table-column label="收货人" min-width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div>{{ row.recipient_name || '—' }}</div>
|
<div>{{ row.recipient_name || '—' }}</div>
|
||||||
<div class="text-xs text-gray-400">{{ row.recipient_phone || '' }}</div>
|
<div
|
||||||
|
class="text-xs"
|
||||||
|
:class="
|
||||||
|
recipientVsPrescriptionPhoneMismatch(row.recipient_phone, row.prescription_phone)
|
||||||
|
? 'text-red-600 font-semibold'
|
||||||
|
: 'text-gray-400'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ row.recipient_phone || '—' }}
|
||||||
|
<span
|
||||||
|
v-if="recipientVsPrescriptionPhoneMismatch(row.recipient_phone, row.prescription_phone)"
|
||||||
|
class="ml-1"
|
||||||
|
>(与处方手机不一致)</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="费用类别" width="96">
|
<el-table-column label="费用类别" width="96">
|
||||||
@@ -485,6 +498,29 @@
|
|||||||
<div v-else-if="detailData" class="po-detail-body px-6 pb-6 h-full overflow-y-auto">
|
<div v-else-if="detailData" class="po-detail-body px-6 pb-6 h-full overflow-y-auto">
|
||||||
|
|
||||||
<!-- 审核与履约可视化步骤条 -->
|
<!-- 审核与履约可视化步骤条 -->
|
||||||
|
<el-alert
|
||||||
|
v-if="detailRecipientRxPhoneMismatch"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-4 !items-start"
|
||||||
|
title="业务订单收货手机与处方登记手机不一致"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<div class="text-sm leading-relaxed">
|
||||||
|
<div>
|
||||||
|
处方手机:
|
||||||
|
<span class="font-mono font-semibold text-red-700">{{ detailPrescriptionPhoneDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
订单收货手机:
|
||||||
|
<span class="font-mono font-semibold text-red-700">{{ detailOrderPhoneDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600 mt-1">请核对是否代收货或录入错误,避免物流与回访联系错人。</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
<div class="bg-gray-50/80 border border-gray-100 rounded-lg p-5 mb-5 shadow-sm">
|
<div class="bg-gray-50/80 border border-gray-100 rounded-lg p-5 mb-5 shadow-sm">
|
||||||
<el-steps :active="workflowActiveStep" align-center finish-status="success">
|
<el-steps :active="workflowActiveStep" align-center finish-status="success">
|
||||||
<el-step title="业务订单创建" :description="formatTime(detailData.create_time)" />
|
<el-step title="业务订单创建" :description="formatTime(detailData.create_time)" />
|
||||||
@@ -577,6 +613,20 @@
|
|||||||
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
|
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
|
||||||
开方人:<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
|
开方人:<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<el-button
|
||||||
|
v-if="
|
||||||
|
detailData.prescription_id &&
|
||||||
|
detailPrescription &&
|
||||||
|
!String(detailData.prescription_detail_error || '').trim()
|
||||||
|
"
|
||||||
|
v-perms="['tcm.prescriptionOrder/patchPrescriptionPatient']"
|
||||||
|
type="default"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
@click="openPatchRxPatientDialog"
|
||||||
|
>
|
||||||
|
改患者信息
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="detailData.prescription_id && showPrescriptionAuditFilter"
|
v-if="detailData.prescription_id && showPrescriptionAuditFilter"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -616,7 +666,24 @@
|
|||||||
<el-descriptions-item label="性别 / 年龄">
|
<el-descriptions-item label="性别 / 年龄">
|
||||||
{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}岁
|
{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}岁
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="手机">{{ detailPrescription.phone || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="手机">
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
detailRecipientRxPhoneMismatch ? 'rx-phone-mismatch text-red-600 font-bold' : ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ detailPrescription.phone || '—' }}
|
||||||
|
</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="detailRecipientRxPhoneMismatch"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
effect="dark"
|
||||||
|
class="ml-2 align-middle"
|
||||||
|
>
|
||||||
|
与订单手机不一致
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
||||||
@@ -814,8 +881,55 @@
|
|||||||
<el-descriptions-item label="诊单ID">{{ detailData.diagnosis_id }}</el-descriptions-item>
|
<el-descriptions-item label="诊单ID">{{ detailData.diagnosis_id }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
|
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item label="手机">{{ detailData.recipient_phone }}</el-descriptions-item>
|
<el-descriptions-item label="挂号关联" :span="3">
|
||||||
|
<template v-if="detailLinkedAppointment">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]">
|
||||||
|
<span
|
||||||
|
>预约 <span class="font-mono text-primary">#{{ detailLinkedAppointment.id }}</span></span
|
||||||
|
>
|
||||||
|
<span class="text-gray-500"
|
||||||
|
>时间:{{ detailLinkedAppointment.appointment_date }}
|
||||||
|
{{ detailLinkedAppointment.period }}
|
||||||
|
{{ detailLinkedAppointment.appointment_time }}</span
|
||||||
|
>
|
||||||
|
<el-tag size="small" type="info">{{ detailLinkedAppointment.appointment_type_desc }}</el-tag>
|
||||||
|
<el-tag size="small">{{ detailLinkedAppointment.status_desc }}</el-tag>
|
||||||
|
<span v-if="detailLinkedAppointment.doctor_name"
|
||||||
|
>接诊:{{detailLinkedAppointment.doctor_name }}</span
|
||||||
|
>
|
||||||
|
<span v-if="detailLinkedAppointment.channel_source" class="text-gray-500"
|
||||||
|
>渠道:{{ detailLinkedAppointment.channel_source }}</span
|
||||||
|
>
|
||||||
|
<el-tag v-if="detailLinkedAppointmentResolvedFromTag" size="small" effect="plain">{{
|
||||||
|
detailLinkedAppointmentResolvedFromTag
|
||||||
|
}}</el-tag>
|
||||||
|
</div>
|
||||||
|
<p v-if="String(detailLinkedAppointment.remark || '').trim()" class="text-xs text-gray-500 mt-1">
|
||||||
|
备注:{{ detailLinkedAppointment.remark }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-gray-400">暂无挂号预约记录(未绑定或诊单下无预约)</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="收货手机">
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ detailData.recipient_phone || '—' }}
|
||||||
|
</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="detailRecipientRxPhoneMismatch"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
effect="dark"
|
||||||
|
class="ml-2 align-middle"
|
||||||
|
>
|
||||||
|
与处方手机不一致
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="收货地址" :span="2">{{ detailFullAddress }}</el-descriptions-item>
|
<el-descriptions-item label="收货地址" :span="2">{{ detailFullAddress }}</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
|
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
|
||||||
@@ -1611,6 +1725,47 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 修改关联处方:患者姓名 / 手机 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="patchRxPatientVisible"
|
||||||
|
title="修改处方患者信息"
|
||||||
|
width="440px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="resetPatchRxPatientForm"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="patchRxPatientFormRef"
|
||||||
|
:model="patchRxPatientForm"
|
||||||
|
:rules="patchRxPatientRules"
|
||||||
|
label-width="96px"
|
||||||
|
>
|
||||||
|
<el-form-item label="患者姓名" prop="patient_name">
|
||||||
|
<el-input
|
||||||
|
v-model="patchRxPatientForm.patient_name"
|
||||||
|
maxlength="50"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="与处方笺一致"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机" prop="phone">
|
||||||
|
<el-input
|
||||||
|
v-model="patchRxPatientForm.phone"
|
||||||
|
maxlength="20"
|
||||||
|
placeholder="处方登记手机号"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="patchRxPatientVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="patchRxPatientSaving" @click="submitPatchRxPatient">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 处方详情查看(处方单样式) -->
|
<!-- 处方详情查看(处方单样式) -->
|
||||||
<el-drawer
|
<el-drawer
|
||||||
v-model="prescriptionViewVisible"
|
v-model="prescriptionViewVisible"
|
||||||
@@ -1694,12 +1849,25 @@
|
|||||||
<span class="rx-notice-text">
|
<span class="rx-notice-text">
|
||||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
||||||
</span>
|
</span>
|
||||||
<span class="rx-notice-meta">
|
<div class="rx-notice-meta">
|
||||||
<span>日期:{{ rxDateText }}</span>
|
<div class="rx-meta-item">
|
||||||
<span>编号:{{ rxSerialText }}</span>
|
<span class="rx-meta-label">日期</span>
|
||||||
</span>
|
<span class="rx-meta-value">{{ rxDateText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rx-meta-item">
|
||||||
|
<span class="rx-meta-label">处方编号</span>
|
||||||
|
<span class="rx-meta-value">{{ rxPrescriptionSnText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rx-meta-item">
|
||||||
|
<span class="rx-meta-label">流转编号(挂号)</span>
|
||||||
|
<span class="rx-meta-value">{{ rxAppointmentFlowText }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rxBusinessOrderNoText" class="rx-meta-item">
|
||||||
|
<span class="rx-meta-label">业务单号</span>
|
||||||
|
<span class="rx-meta-value">{{ rxBusinessOrderNoText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rx-info">
|
<div class="rx-info">
|
||||||
<div class="rx-info-row">
|
<div class="rx-info-row">
|
||||||
<div class="rx-info-cell">
|
<div class="rx-info-cell">
|
||||||
@@ -1770,8 +1938,8 @@
|
|||||||
|
|
||||||
<div class="rx-bottom">
|
<div class="rx-bottom">
|
||||||
<div class="rx-bot-row rx-bot-row-1">
|
<div class="rx-bot-row rx-bot-row-1">
|
||||||
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
|
<div class="rx-bot-cell rx-bot-doctor">
|
||||||
<div class="rx-bot-label">医师</div>
|
<div class="rx-bot-label rx-bot-label--doctor">医师</div>
|
||||||
<div class="rx-bot-doctor-body">
|
<div class="rx-bot-doctor-body">
|
||||||
<img
|
<img
|
||||||
v-if="prescriptionViewData.doctor_signature"
|
v-if="prescriptionViewData.doctor_signature"
|
||||||
@@ -1779,7 +1947,7 @@
|
|||||||
alt="医师签名"
|
alt="医师签名"
|
||||||
class="rx-bot-sign-img"
|
class="rx-bot-sign-img"
|
||||||
/>
|
/>
|
||||||
<div class="rx-bot-doctor-name">{{ prescriptionViewData.doctor_name || '—' }}</div>
|
<!-- <span class="rx-bot-doctor-name">{{ prescriptionViewData.doctor_name || '—' }}</span> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1788,11 +1956,17 @@
|
|||||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
<div class="rx-bot-cell rx-bot-meta">
|
||||||
<span class="rx-bot-meta-key">剂数:</span>
|
<span class="rx-bot-meta-key">天数:</span>
|
||||||
<span class="rx-bot-meta-val">{{ prescriptionViewData.dose_count || '—' }}剂</span>
|
<span class="rx-bot-meta-val">
|
||||||
|
{{
|
||||||
|
detailData.medication_days != null &&
|
||||||
|
String(detailData.medication_days).trim() !== ''
|
||||||
|
? detailData.medication_days + ' 天'
|
||||||
|
: '—'
|
||||||
|
}} </span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
<div class="rx-bot-cell rx-bot-meta">
|
||||||
<span class="rx-bot-meta-key">单剂量:</span>
|
<span class="rx-bot-meta-key">剂量:</span>
|
||||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1980,6 +2154,7 @@ import {
|
|||||||
prescriptionOrderComplete,
|
prescriptionOrderComplete,
|
||||||
prescriptionOrderRevokeRxAudit,
|
prescriptionOrderRevokeRxAudit,
|
||||||
prescriptionOrderRevokePayAudit,
|
prescriptionOrderRevokePayAudit,
|
||||||
|
prescriptionOrderPatchPrescriptionPatient,
|
||||||
prescriptionOrderLinkPayOrder,
|
prescriptionOrderLinkPayOrder,
|
||||||
prescriptionOrderSubmitGancaoRecipel,
|
prescriptionOrderSubmitGancaoRecipel,
|
||||||
prescriptionOrderPreviewGancaoRecipel,
|
prescriptionOrderPreviewGancaoRecipel,
|
||||||
@@ -2703,6 +2878,47 @@ const detailPrescription = computed(() => {
|
|||||||
return p && typeof p === 'object' ? p : null
|
return p && typeof p === 'object' ? p : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function normalizeBizPhone(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
return String(v).replace(/\s/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
|
||||||
|
const a = normalizeBizPhone(recipient)
|
||||||
|
const b = normalizeBizPhone(rxPhone)
|
||||||
|
if (!a || !b) return false
|
||||||
|
return a !== b
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情抽屉:业务收货手机 vs 处方登记手机 */
|
||||||
|
const detailRecipientRxPhoneMismatch = computed(() => {
|
||||||
|
const rx = detailPrescription.value?.phone
|
||||||
|
const biz = detailData.value?.recipient_phone
|
||||||
|
return recipientVsPrescriptionPhoneMismatch(biz, rx)
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailPrescriptionPhoneDisplay = computed(() => {
|
||||||
|
const s = normalizeBizPhone(detailPrescription.value?.phone)
|
||||||
|
return s || '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailOrderPhoneDisplay = computed(() => {
|
||||||
|
const s = normalizeBizPhone(detailData.value?.recipient_phone)
|
||||||
|
return s || '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailLinkedAppointment = computed(() => {
|
||||||
|
const a = detailData.value?.linked_appointment
|
||||||
|
return a && typeof a === 'object' ? a : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailLinkedAppointmentResolvedFromTag = computed(() => {
|
||||||
|
const from = String(detailLinkedAppointment.value?.resolved_from || '')
|
||||||
|
if (from === 'prescription') return '处方绑定挂号'
|
||||||
|
if (from === 'diagnosis') return '诊单关联挂号'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
|
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
|
||||||
|
|
||||||
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
|
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
|
||||||
@@ -2790,7 +3006,11 @@ function logActionText(act: string) {
|
|||||||
fill_tracking: '填快递单',
|
fill_tracking: '填快递单',
|
||||||
ship: '确认发货',
|
ship: '确认发货',
|
||||||
withdraw: '撤销',
|
withdraw: '撤销',
|
||||||
add_pay_order: '补齐支付单',
|
link_pay_order: '关联支付单',
|
||||||
|
revoke_rx_audit: '撤回处方审核',
|
||||||
|
revoke_pay_audit: '撤回支付审核',
|
||||||
|
gancao_submit: '甘草下单',
|
||||||
|
patch_rx_patient: '处方患者信息',
|
||||||
complete: '完成订单'
|
complete: '完成订单'
|
||||||
}
|
}
|
||||||
return m[act] || act
|
return m[act] || act
|
||||||
@@ -2840,6 +3060,75 @@ async function fetchLogisticsTrace() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const patchRxPatientVisible = ref(false)
|
||||||
|
const patchRxPatientSaving = ref(false)
|
||||||
|
const patchRxPatientFormRef = ref<FormInstance>()
|
||||||
|
const patchRxPatientForm = reactive({
|
||||||
|
patient_name: '',
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
const patchRxPatientRules: FormRules = {
|
||||||
|
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
|
||||||
|
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPatchRxPatientDialog() {
|
||||||
|
const rx = detailPrescription.value
|
||||||
|
const ord = detailData.value
|
||||||
|
if (!rx || !ord?.id || !ord.prescription_id) {
|
||||||
|
feedback.msgWarning('无处方数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patchRxPatientForm.patient_name = String(rx.patient_name ?? '').trim()
|
||||||
|
patchRxPatientForm.phone = String(rx.phone ?? '').trim()
|
||||||
|
patchRxPatientVisible.value = true
|
||||||
|
nextTick(() => patchRxPatientFormRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPatchRxPatientForm() {
|
||||||
|
patchRxPatientForm.patient_name = ''
|
||||||
|
patchRxPatientForm.phone = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCurrentPrescriptionOrderDetail() {
|
||||||
|
const id = detailData.value?.id
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
const res: any = await prescriptionOrderDetail({ id })
|
||||||
|
const d = res?.data ?? res ?? null
|
||||||
|
if (d) detailData.value = d
|
||||||
|
} catch {
|
||||||
|
/* 静默失败,避免打断当前抽屉 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPatchRxPatient() {
|
||||||
|
const form = patchRxPatientFormRef.value
|
||||||
|
if (!form) return
|
||||||
|
try {
|
||||||
|
await form.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ordId = detailData.value?.id
|
||||||
|
if (!ordId) return
|
||||||
|
patchRxPatientSaving.value = true
|
||||||
|
try {
|
||||||
|
await prescriptionOrderPatchPrescriptionPatient({
|
||||||
|
id: ordId,
|
||||||
|
patient_name: patchRxPatientForm.patient_name.trim(),
|
||||||
|
phone: patchRxPatientForm.phone.trim()
|
||||||
|
})
|
||||||
|
patchRxPatientVisible.value = false
|
||||||
|
await refreshCurrentPrescriptionOrderDetail()
|
||||||
|
await fetchLogs(ordId)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
patchRxPatientSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openDetail(id: number) {
|
async function openDetail(id: number) {
|
||||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||||
detailData.value = null
|
detailData.value = null
|
||||||
@@ -3878,18 +4167,32 @@ const rxDateText = computed(() => {
|
|||||||
return v.prescription_date || '—'
|
return v.prescription_date || '—'
|
||||||
})
|
})
|
||||||
|
|
||||||
const rxSerialText = computed(() => {
|
/** 处方笺「处方编号」:处方 sn,缺省 visit_no */
|
||||||
|
const rxPrescriptionSnText = computed(() => {
|
||||||
const v = prescriptionViewData.value as any
|
const v = prescriptionViewData.value as any
|
||||||
if (!v) return '—'
|
if (!v) return '—'
|
||||||
return (
|
const sn = String(v.sn ?? '').trim()
|
||||||
v.order_no ||
|
if (sn) return sn
|
||||||
v.serial_no ||
|
const vn = String(v.visit_no ?? '').trim()
|
||||||
v.serial_number ||
|
if (vn) return vn
|
||||||
v.no ||
|
return '—'
|
||||||
v.prescription_no ||
|
})
|
||||||
v.visit_no ||
|
|
||||||
(v.id ? `G${v.id}` : '—')
|
/** 流转编号(挂号):预约/挂号主键 */
|
||||||
)
|
const rxAppointmentFlowText = computed(() => {
|
||||||
|
const v = prescriptionViewData.value as any
|
||||||
|
if (!v) return '—'
|
||||||
|
const aid = Number(v.appointment_id)
|
||||||
|
if (Number.isFinite(aid) && aid > 0) return String(aid)
|
||||||
|
return '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 业务订单号(从打开详情的订单行合并,便于笺面核对) */
|
||||||
|
const rxBusinessOrderNoText = computed(() => {
|
||||||
|
const v = prescriptionViewData.value as any
|
||||||
|
if (!v) return ''
|
||||||
|
const no = String(v.order_no ?? '').trim()
|
||||||
|
return no
|
||||||
})
|
})
|
||||||
|
|
||||||
const rxRecipientText = computed(() => {
|
const rxRecipientText = computed(() => {
|
||||||
@@ -4085,6 +4388,22 @@ async function openPrescriptionView(row: any) {
|
|||||||
if (row.recipient_phone && !merged.recipient_phone) merged.recipient_phone = row.recipient_phone
|
if (row.recipient_phone && !merged.recipient_phone) merged.recipient_phone = row.recipient_phone
|
||||||
if (row.shipping_address && !merged.shipping_address) merged.shipping_address = row.shipping_address
|
if (row.shipping_address && !merged.shipping_address) merged.shipping_address = row.shipping_address
|
||||||
if (row.amount != null && merged.order_amount == null) merged.order_amount = row.amount
|
if (row.amount != null && merged.order_amount == null) merged.order_amount = row.amount
|
||||||
|
/** 笺面剂数/单位与业务订单一致(甘草预览与订单详情同口径) */
|
||||||
|
if (row.dose_count != null && String(row.dose_count).trim() !== '' && Number(row.dose_count) > 0) {
|
||||||
|
merged.dose_count = Number(row.dose_count)
|
||||||
|
}
|
||||||
|
if (row.dose_unit != null && String(row.dose_unit).trim() !== '') {
|
||||||
|
merged.dose_unit = String(row.dose_unit)
|
||||||
|
}
|
||||||
|
const linkedAp = row.linked_appointment
|
||||||
|
if (
|
||||||
|
(!merged.appointment_id || Number(merged.appointment_id) <= 0) &&
|
||||||
|
linkedAp &&
|
||||||
|
typeof linkedAp === 'object' &&
|
||||||
|
Number(linkedAp.id) > 0
|
||||||
|
) {
|
||||||
|
merged.appointment_id = Number(linkedAp.id)
|
||||||
|
}
|
||||||
prescriptionViewData.value = merged
|
prescriptionViewData.value = merged
|
||||||
} else {
|
} else {
|
||||||
prescriptionViewData.value = null
|
prescriptionViewData.value = null
|
||||||
@@ -4389,25 +4708,84 @@ async function downloadPrescriptionSlipPdf() {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === 顶部告知 === */
|
/* === 顶部告知(上下分块,避免左右 flex 把左侧提示挤成竖条)=== */
|
||||||
.rx-notice {
|
.rx-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
padding: 16px 10px;
|
padding: 14px 12px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #1f1f1f;
|
color: #1f1f1f;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
|
||||||
.rx-notice-text { flex: 1; }
|
.rx-notice-text {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
line-height: 1.55;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.rx-notice-meta {
|
.rx-notice-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 14px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 8px 2px 2px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 2.35;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rx-notice-meta .rx-meta-item {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 24px;
|
flex: 0 0 auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
line-height: 2.35;
|
||||||
|
height:20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rx-notice-meta .rx-meta-item:not(:first-child) {
|
||||||
|
border-left: 1px solid #d1d5db;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin-left: 2px;
|
||||||
|
line-height: 2.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rx-notice-meta .rx-meta-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.35;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rx-notice-meta .rx-meta-label::after {
|
||||||
|
content: ':';
|
||||||
|
font-weight: 500;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rx-notice-meta .rx-meta-value {
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4663,21 +5041,39 @@ async function downloadPrescriptionSlipPdf() {
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rx-bot-label--doctor {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 6px;
|
||||||
|
line-height: 1.2;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
.rx-bot-doctor {
|
.rx-bot-doctor {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
.rx-bot-doctor-body {
|
.rx-bot-doctor-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: flex-start;
|
||||||
min-height: 36px;
|
flex-wrap: nowrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rx-bot-doctor-name {
|
.rx-bot-doctor-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #1f1f1f;
|
color: #1f1f1f;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rx-bot-sign-img {
|
.rx-bot-sign-img {
|
||||||
|
flex-shrink: 0;
|
||||||
max-height: 40px;
|
max-height: 40px;
|
||||||
max-width: 110px;
|
max-width: 110px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
|||||||
@@ -125,6 +125,57 @@
|
|||||||
:closable="false"
|
:closable="false"
|
||||||
class="filter-alert"
|
class="filter-alert"
|
||||||
/> -->
|
/> -->
|
||||||
|
<!-- ── 目标进度卡片 ── -->
|
||||||
|
<div
|
||||||
|
v-if="targetProgressCard || targetProgressLoading"
|
||||||
|
v-loading="targetProgressLoading"
|
||||||
|
class="tp-wrap"
|
||||||
|
>
|
||||||
|
<div v-if="targetProgressCard" class="tp-card">
|
||||||
|
<div class="tp-card__title">{{ targetProgressCard.title }}</div>
|
||||||
|
<div class="tp-card__body">
|
||||||
|
<div
|
||||||
|
v-for="sec in targetProgressCard.sections"
|
||||||
|
:key="sec.dept_id"
|
||||||
|
class="tp-section"
|
||||||
|
>
|
||||||
|
<div class="tp-section__head">
|
||||||
|
<span class="tp-section__name">{{ sec.dept_name }}目标</span>
|
||||||
|
<span class="tp-section__target">{{ formatMoneyW(sec.target_amount) }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="tp-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="tp-lbl">昨日诊金</td>
|
||||||
|
<td class="tp-val">{{ formatInt(sec.yesterday_amount) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="sec.douyin_amount !== null">
|
||||||
|
<td class="tp-lbl">抖音总诊金</td>
|
||||||
|
<td class="tp-val">{{ formatInt(sec.douyin_amount) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="sec.tuifen_amount !== null">
|
||||||
|
<td class="tp-lbl">推粉总诊金</td>
|
||||||
|
<td class="tp-val">{{ formatInt(sec.tuifen_amount) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="tp-lbl">总诊金</td>
|
||||||
|
<td class="tp-val tp-val--total">{{ formatInt(sec.total_amount) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="tp-lbl">目标进度</td>
|
||||||
|
<td class="tp-val">{{ formatMoney(sec.target_progress) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="tp-lbl">完成进度</td>
|
||||||
|
<td class="tp-val" :class="achievementClass(sec.completion_rate)">{{ formatAchievement(sec.completion_rate) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── 业绩表(4 张/N 张) ────────────────── -->
|
<!-- ── 业绩表(4 张/N 张) ────────────────── -->
|
||||||
<div v-loading="loading" class="tables-wrap">
|
<div v-loading="loading" class="tables-wrap">
|
||||||
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
||||||
@@ -349,6 +400,7 @@ import {
|
|||||||
yejiStatsMulti,
|
yejiStatsMulti,
|
||||||
yejiStatsOverview,
|
yejiStatsOverview,
|
||||||
} from '@/api/stats'
|
} from '@/api/stats'
|
||||||
|
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||||
|
|
||||||
interface DeptOption {
|
interface DeptOption {
|
||||||
id: number
|
id: number
|
||||||
@@ -440,6 +492,169 @@ const customRange = ref<[string, string] | null>(null)
|
|||||||
const tables = ref<YejiTable[]>([])
|
const tables = ref<YejiTable[]>([])
|
||||||
const leaderboardBlock = ref<LeaderboardPack | null>(null)
|
const leaderboardBlock = ref<LeaderboardPack | null>(null)
|
||||||
const leaderboardsLoading = ref(false)
|
const leaderboardsLoading = ref(false)
|
||||||
|
|
||||||
|
// ── 目标进度卡片 ──────────────────────────────
|
||||||
|
interface TargetProgressSection {
|
||||||
|
dept_id: number
|
||||||
|
dept_name: string
|
||||||
|
target_amount: number
|
||||||
|
total_amount: number
|
||||||
|
yesterday_amount: number
|
||||||
|
douyin_amount: number | null // 名称含「自媒体1」的子部门合计
|
||||||
|
tuifen_amount: number | null // 名称含「自媒体3」或「自媒体4」的子部门合计
|
||||||
|
target_progress: number // 按已过天数均摊的目标进度
|
||||||
|
completion_rate: number | null // 总诊金 / 目标金额
|
||||||
|
}
|
||||||
|
interface TargetProgressCard {
|
||||||
|
title: string
|
||||||
|
sections: TargetProgressSection[]
|
||||||
|
}
|
||||||
|
const targetProgressCard = ref<TargetProgressCard | null>(null)
|
||||||
|
const targetProgressLoading = ref(false)
|
||||||
|
|
||||||
|
function ymCurrent(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
function daysInMonth(ym: string): number {
|
||||||
|
const [y, m] = ym.split('-').map(Number)
|
||||||
|
return new Date(y, m, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTargetProgress() {
|
||||||
|
targetProgressLoading.value = true
|
||||||
|
targetProgressCard.value = null
|
||||||
|
try {
|
||||||
|
const ym = ymCurrent()
|
||||||
|
const targetRes: any = await deptPerformanceTargetMonthMatrix({ year_month: ym })
|
||||||
|
const treeRows: any[] = targetRes?.rows ?? []
|
||||||
|
if (!treeRows.length) return
|
||||||
|
|
||||||
|
// 从目标树平铺,找到 depth>=1 且目标>0 的节点作为卡片分区
|
||||||
|
function flatWithDepth(nodes: any[], depth = 0): Array<{ node: any; depth: number }> {
|
||||||
|
const r: Array<{ node: any; depth: number }> = []
|
||||||
|
for (const n of nodes ?? []) {
|
||||||
|
r.push({ node: n, depth })
|
||||||
|
r.push(...flatWithDepth(n.children ?? [], depth + 1))
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
const all = flatWithDepth(treeRows)
|
||||||
|
const cardDepts = all
|
||||||
|
.filter(x => x.depth >= 1 && Number(x.node.target_amount) > 0)
|
||||||
|
.map(x => x.node)
|
||||||
|
if (!cardDepts.length) return
|
||||||
|
|
||||||
|
// 用 deptOptions(完整部门列表,含 pid)做层级查找
|
||||||
|
// 目标树只包含有目标的节点,无法得到 0 目标的子部门(如自媒体1/3/4)
|
||||||
|
function descIdsFromOptions(deptId: number): number[] {
|
||||||
|
const result: number[] = []
|
||||||
|
const queue = [deptId]
|
||||||
|
while (queue.length) {
|
||||||
|
const pid = queue.shift()!
|
||||||
|
for (const d of deptOptions.value) {
|
||||||
|
if (Number(d.pid) === pid) {
|
||||||
|
result.push(d.id)
|
||||||
|
queue.push(d.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const allIds = new Set<number>()
|
||||||
|
const specs = cardDepts.map(dept => {
|
||||||
|
const deptId = Number(dept.dept_id)
|
||||||
|
const childIds = descIdsFromOptions(deptId)
|
||||||
|
const totalIds = childIds.length ? childIds : [deptId]
|
||||||
|
totalIds.forEach(id => allIds.add(id))
|
||||||
|
return {
|
||||||
|
dept_id: deptId,
|
||||||
|
dept_name: String(dept.dept_name),
|
||||||
|
target_amount: Number(dept.target_amount),
|
||||||
|
totalIds,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!allIds.size) return
|
||||||
|
|
||||||
|
// 从 channelGroups 找自媒体1/3/4 的 channel_code
|
||||||
|
const allChannels = channelGroups.value.flatMap(g => g.channels)
|
||||||
|
const douyinCode = allChannels.find(c => c.channel_name.includes('自媒体1'))?.channel_code
|
||||||
|
const t3Code = allChannels.find(c => c.channel_name.includes('自媒体3'))?.channel_code
|
||||||
|
const t4Code = allChannels.find(c => c.channel_name.includes('自媒体4'))?.channel_code
|
||||||
|
|
||||||
|
const deptIdsStr = Array.from(allIds).join(',')
|
||||||
|
|
||||||
|
// 并发拉取:无渠道(总诊金/昨日)+ 各渠道(completed_performance_amount)
|
||||||
|
const [perfRes, douyinRes, t3Res, t4Res] = await Promise.all([
|
||||||
|
yejiStatsMulti({ dept_ids: deptIdsStr }) as any,
|
||||||
|
douyinCode ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: douyinCode }) as any : Promise.resolve(null),
|
||||||
|
t3Code ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: t3Code }) as any : Promise.resolve(null),
|
||||||
|
t4Code ? yejiStatsMulti({ dept_ids: deptIdsStr, channel_code: t4Code }) as any : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
|
||||||
|
const getMonthTbl = (res: any) => (res?.tables ?? []).find((t: any) => String(t.label).includes('月')) ?? res?.tables?.[0]
|
||||||
|
const getYdayTbl = (res: any) => (res?.tables ?? []).find((t: any) => String(t.label).includes('昨')) ?? res?.tables?.[res?.tables?.length - 1]
|
||||||
|
|
||||||
|
function buildMap(tbl: any, field: string): Map<number, number> {
|
||||||
|
const m = new Map<number, number>()
|
||||||
|
for (const r of tbl?.rows ?? []) m.set(Number(r.dept_id), Number(r[field] ?? 0))
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthPerf = buildMap(getMonthTbl(perfRes), 'performance_amount')
|
||||||
|
const ydayPerf = buildMap(getYdayTbl(perfRes), 'performance_amount')
|
||||||
|
const douyinPerf = douyinRes ? buildMap(getMonthTbl(douyinRes), 'completed_performance_amount') : null
|
||||||
|
const t3Perf = t3Res ? buildMap(getMonthTbl(t3Res), 'completed_performance_amount') : null
|
||||||
|
const t4Perf = t4Res ? buildMap(getMonthTbl(t4Res), 'completed_performance_amount') : null
|
||||||
|
|
||||||
|
const sumIds = (m: Map<number, number>, ids: number[]) => ids.reduce((s, id) => s + (m.get(id) ?? 0), 0)
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const totalDays = daysInMonth(ym)
|
||||||
|
const elapsed = today.getDate()
|
||||||
|
|
||||||
|
const sections: TargetProgressSection[] = specs.map(spec => {
|
||||||
|
const total = sumIds(monthPerf, spec.totalIds)
|
||||||
|
const yesterday = sumIds(ydayPerf, spec.totalIds)
|
||||||
|
|
||||||
|
const douyinAmt = douyinPerf ? sumIds(douyinPerf, spec.totalIds) : null
|
||||||
|
const t3Amt = t3Perf ? sumIds(t3Perf, spec.totalIds) : null
|
||||||
|
const t4Amt = t4Perf ? sumIds(t4Perf, spec.totalIds) : null
|
||||||
|
// 推粉 = 自媒体3 + 自媒体4,有任一即显示
|
||||||
|
const tuifenAmt = (t3Amt !== null || t4Amt !== null) ? (t3Amt ?? 0) + (t4Amt ?? 0) : null
|
||||||
|
|
||||||
|
const targetProgress = Math.round(spec.target_amount * (elapsed / totalDays) * 100) / 100
|
||||||
|
const completion = spec.target_amount > 0 ? total / spec.target_amount : null
|
||||||
|
return {
|
||||||
|
dept_id: spec.dept_id,
|
||||||
|
dept_name: spec.dept_name,
|
||||||
|
target_amount: spec.target_amount,
|
||||||
|
total_amount: total,
|
||||||
|
yesterday_amount: yesterday,
|
||||||
|
douyin_amount: douyinAmt && douyinAmt > 0 ? douyinAmt : null,
|
||||||
|
tuifen_amount: tuifenAmt && tuifenAmt > 0 ? tuifenAmt : null,
|
||||||
|
target_progress: targetProgress,
|
||||||
|
completion_rate: completion,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 取根部门名作为卡片标题前缀
|
||||||
|
const rootName = treeRows[0]?.dept_name ?? ''
|
||||||
|
const m = today.getMonth() + 1
|
||||||
|
const d = today.getDate()
|
||||||
|
targetProgressCard.value = {
|
||||||
|
title: `${rootName}诊金-${m}.${d}`,
|
||||||
|
sections,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 静默失败,不影响主页面
|
||||||
|
} finally {
|
||||||
|
targetProgressLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const channelFilterNote = computed(() => {
|
const channelFilterNote = computed(() => {
|
||||||
for (const t of tables.value) {
|
for (const t of tables.value) {
|
||||||
if (t.channel_filter_note) return t.channel_filter_note
|
if (t.channel_filter_note) return t.channel_filter_note
|
||||||
@@ -675,6 +890,20 @@ function roiClass(v: any) {
|
|||||||
if (n >= 0.8) return 'roi-mid'
|
if (n >= 0.8) return 'roi-mid'
|
||||||
return 'roi-bad'
|
return 'roi-bad'
|
||||||
}
|
}
|
||||||
|
function formatAchievement(v: number | null): string {
|
||||||
|
if (v === null) return '—'
|
||||||
|
return (v * 100).toFixed(2) + '%'
|
||||||
|
}
|
||||||
|
function achievementClass(v: number | null): string {
|
||||||
|
if (v === null) return ''
|
||||||
|
if (v >= 1) return 'tp-good'
|
||||||
|
if (v >= 0.8) return 'tp-mid'
|
||||||
|
return 'tp-bad'
|
||||||
|
}
|
||||||
|
function formatMoneyW(v: number): string {
|
||||||
|
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
/** 本地日历日 yyyy-mm-dd(排行榜「今日」、日期快捷与日期控件一致) */
|
/** 本地日历日 yyyy-mm-dd(排行榜「今日」、日期快捷与日期控件一致) */
|
||||||
function formatLocalYmd(d: Date): string {
|
function formatLocalYmd(d: Date): string {
|
||||||
@@ -687,6 +916,7 @@ function formatLocalYmd(d: Date): string {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
||||||
loadData()
|
loadData()
|
||||||
|
loadTargetProgress()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -924,6 +1154,101 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 目标进度卡片 ── */
|
||||||
|
.tp-wrap {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-card {
|
||||||
|
display: inline-block;
|
||||||
|
border: 2px solid #e8b800;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
background: #ffe600;
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 20px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-section {
|
||||||
|
min-width: 200px;
|
||||||
|
border-right: 1px solid #e8d800;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
background: #fff8c0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid #e8d800;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__target {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
.tp-lbl {
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid #f0e860;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-val {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
border-bottom: 1px solid #f0e860;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #111;
|
||||||
|
|
||||||
|
&--total {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-good { color: #16a34a !important; font-weight: 700; }
|
||||||
|
.tp-mid { color: #d97706 !important; font-weight: 700; }
|
||||||
|
.tp-bad { color: #dc2626 !important; font-weight: 700; }
|
||||||
|
|
||||||
.leaderboard-sub {
|
.leaderboard-sub {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1025,6 +1350,7 @@ onMounted(async () => {
|
|||||||
background: #f0fdfa;
|
background: #f0fdfa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.total-row {
|
.total-row {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: #fff8d6;
|
background: #fff8d6;
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="group__header">
|
<div class="group__header">
|
||||||
<div class="group__accent" />
|
<div class="group__accent" />
|
||||||
<div class="group__name">{{ node.dept_name }}</div>
|
<div class="group__name">
|
||||||
|
<el-icon class="group__name-icon"><FolderOpened /></el-icon>
|
||||||
|
{{ node.dept_name }}
|
||||||
|
</div>
|
||||||
<div class="group__amount-wrap">
|
<div class="group__amount-wrap">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="node.target_amount"
|
v-model="node.target_amount"
|
||||||
@@ -25,7 +28,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="group__badge-wrap">
|
<div class="group__badge-wrap">
|
||||||
<el-tag v-if="node.target_id" size="small" type="success" effect="plain">已保存</el-tag>
|
<el-tag v-if="node.target_id" size="small" type="success" effect="plain" round>已保存</el-tag>
|
||||||
|
<span v-else class="group__unsaved-dot" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,22 +47,19 @@
|
|||||||
<!-- 子节点:表格行 -->
|
<!-- 子节点:表格行 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="row" :data-depth="String(Math.min(depth, 4))">
|
<div class="row" :data-depth="String(Math.min(depth, 4))">
|
||||||
<!-- 缩进空间 + 连接线 -->
|
|
||||||
<div class="row__indent" :style="{ width: `${(depth - 1) * 20 + 16}px` }">
|
<div class="row__indent" :style="{ width: `${(depth - 1) * 20 + 16}px` }">
|
||||||
<div class="row__connector" />
|
<div class="row__connector" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 部门名 -->
|
|
||||||
<div class="row__dept">
|
<div class="row__dept">
|
||||||
<div class="row__dot" />
|
<div class="row__dot" />
|
||||||
<div class="row__dept-text">
|
<div class="row__dept-text">
|
||||||
<span class="row__name">{{ node.dept_name }}</span>
|
<span class="row__name">{{ node.dept_name }}</span>
|
||||||
<span v-if="parentPath" class="row__path">{{ parentPath }}</span>
|
<span v-if="parentPath" class="row__path">{{ parentPath }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tag v-if="node.target_id" size="small" type="success" effect="plain" class="row__saved">已保存</el-tag>
|
<el-tag v-if="node.target_id" size="small" type="success" effect="plain" round class="row__saved">已保存</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 金额 -->
|
|
||||||
<div class="row__amount">
|
<div class="row__amount">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="node.target_amount"
|
v-model="node.target_amount"
|
||||||
@@ -71,7 +72,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 备注 -->
|
|
||||||
<div class="row__remark">
|
<div class="row__remark">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="node.remark"
|
v-model="node.remark"
|
||||||
@@ -82,7 +82,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 递归子节点 -->
|
|
||||||
<MubiaoDeptNode
|
<MubiaoDeptNode
|
||||||
v-for="c in node.children"
|
v-for="c in node.children"
|
||||||
:key="c.dept_id"
|
:key="c.dept_id"
|
||||||
@@ -94,6 +93,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { FolderOpened } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
defineOptions({ name: 'MubiaoDeptNode' })
|
defineOptions({ name: 'MubiaoDeptNode' })
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ const parentPath = computed(() =>
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* ── 根节点:分组区块 ── */
|
/* ── 根节点:分组区块 ── */
|
||||||
.group {
|
.group {
|
||||||
border-bottom: 2px solid var(--el-border-color-lighter);
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -135,14 +135,23 @@ const parentPath = computed(() =>
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-height: 52px;
|
min-height: 54px;
|
||||||
background: linear-gradient(90deg, var(--el-color-primary-light-9) 0%, var(--el-fill-color-extra-light) 60%);
|
background: linear-gradient(90deg,
|
||||||
border-bottom: 1px solid var(--el-color-primary-light-7);
|
color-mix(in srgb, var(--el-color-primary) 6%, var(--el-bg-color)) 0%,
|
||||||
|
color-mix(in srgb, var(--el-color-primary) 2%, var(--el-bg-color)) 50%,
|
||||||
|
var(--el-bg-color) 100%
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--el-color-primary) 12%, var(--el-border-color-lighter));
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
transition: background 0.15s;
|
cursor: default;
|
||||||
|
transition: background 0.18s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: linear-gradient(90deg, var(--el-color-primary-light-8) 0%, var(--el-fill-color-light) 60%);
|
background: linear-gradient(90deg,
|
||||||
|
color-mix(in srgb, var(--el-color-primary) 10%, var(--el-bg-color)) 0%,
|
||||||
|
color-mix(in srgb, var(--el-color-primary) 4%, var(--el-bg-color)) 50%,
|
||||||
|
var(--el-fill-color-extra-light) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +159,7 @@ const parentPath = computed(() =>
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
background: var(--el-color-primary);
|
background: linear-gradient(180deg, var(--el-color-primary), var(--el-color-primary-light-3));
|
||||||
border-radius: 0 2px 2px 0;
|
border-radius: 0 2px 2px 0;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
@@ -158,40 +167,70 @@ const parentPath = computed(() =>
|
|||||||
.group__name {
|
.group__name {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
font-size: 14px;
|
font-size: 13.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--el-color-primary-dark-2);
|
color: var(--el-color-primary-dark-2);
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__name-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group__amount-wrap {
|
.group__amount-wrap {
|
||||||
flex: 0 0 194px;
|
flex: 0 0 194px;
|
||||||
border-left: 1px solid var(--el-color-primary-light-6);
|
border-left: 1px solid color-mix(in srgb, var(--el-color-primary) 15%, var(--el-border-color-lighter));
|
||||||
padding: 8px 12px;
|
padding: 9px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group__amount-input {
|
.group__amount-input {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
|
|
||||||
:deep(.el-input__wrapper) {
|
:deep(.el-input__wrapper) {
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;
|
box-shadow: 0 0 0 1.5px var(--el-color-primary-light-5) inset,
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 0 1.5px var(--el-color-primary-light-3) inset,
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-focus {
|
||||||
|
box-shadow: 0 0 0 2px var(--el-color-primary-light-3) inset !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.group__remark-wrap {
|
.group__remark-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border-left: 1px solid var(--el-color-primary-light-6);
|
border-left: 1px solid color-mix(in srgb, var(--el-color-primary) 15%, var(--el-border-color-lighter));
|
||||||
padding: 8px 12px;
|
padding: 9px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group__badge-wrap {
|
.group__badge-wrap {
|
||||||
flex: 0 0 60px;
|
flex: 0 0 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__unsaved-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--el-border-color);
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group__body {
|
.group__body {
|
||||||
@@ -202,10 +241,11 @@ const parentPath = computed(() =>
|
|||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 48px;
|
min-height: 46px;
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
transition: background 0.12s;
|
transition: background 0.12s ease;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -213,13 +253,29 @@ const parentPath = computed(() =>
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--el-fill-color-extra-light);
|
background: var(--el-fill-color-extra-light);
|
||||||
|
|
||||||
|
.row__dot {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深度色阶 */
|
/* 深度色阶 */
|
||||||
&[data-depth='1'] { --dot: #67c23a; }
|
&[data-depth='1'] {
|
||||||
&[data-depth='2'] { --dot: #e6a23c; }
|
--dot: #67c23a;
|
||||||
&[data-depth='3'] { --dot: #909399; }
|
--connector: color-mix(in srgb, #67c23a 30%, var(--el-border-color));
|
||||||
&[data-depth='4'] { --dot: var(--el-border-color-darker); }
|
}
|
||||||
|
&[data-depth='2'] {
|
||||||
|
--dot: #e6a23c;
|
||||||
|
--connector: color-mix(in srgb, #e6a23c 30%, var(--el-border-color));
|
||||||
|
}
|
||||||
|
&[data-depth='3'] {
|
||||||
|
--dot: #909399;
|
||||||
|
--connector: var(--el-border-color);
|
||||||
|
}
|
||||||
|
&[data-depth='4'] {
|
||||||
|
--dot: var(--el-border-color-darker);
|
||||||
|
--connector: var(--el-border-color-light);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row__indent {
|
.row__indent {
|
||||||
@@ -235,8 +291,8 @@ const parentPath = computed(() =>
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 50%;
|
bottom: 50%;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
border-left: 1px dashed var(--el-border-color);
|
border-left: 1.5px dashed var(--connector, var(--el-border-color));
|
||||||
border-bottom: 1px dashed var(--el-border-color);
|
border-bottom: 1.5px dashed var(--connector, var(--el-border-color));
|
||||||
border-radius: 0 0 0 4px;
|
border-radius: 0 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +311,8 @@ const parentPath = computed(() =>
|
|||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--dot, #909399);
|
background: var(--dot, #909399);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dot, #909399) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row__dept-text {
|
.row__dept-text {
|
||||||
@@ -297,8 +355,14 @@ const parentPath = computed(() =>
|
|||||||
|
|
||||||
.row__amount-input {
|
.row__amount-input {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
|
|
||||||
:deep(.el-input__wrapper) {
|
:deep(.el-input__wrapper) {
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+200
-190
@@ -1,51 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mubiao-page">
|
<div class="mubiao-page">
|
||||||
|
|
||||||
<!-- ── 顶部 Header ── -->
|
<!-- ── 顶部工具栏 ── -->
|
||||||
<div class="header">
|
<div class="toolbar">
|
||||||
<!-- 左:图标 + 文字 -->
|
<div class="toolbar__left">
|
||||||
<div class="header__left">
|
<div class="toolbar__icon">
|
||||||
<div class="header__icon">
|
|
||||||
<el-icon><TrendCharts /></el-icon>
|
<el-icon><TrendCharts /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="header__text">
|
<div>
|
||||||
<span class="header__title">制定业绩目标</span>
|
<div class="toolbar__title">制定业绩目标</div>
|
||||||
<span class="header__desc">按部门层级设置每月销售目标,填 0 并保存将删除该记录</span>
|
<div class="toolbar__desc">按部门设置每月销售目标,填 0 并保存将删除该记录</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toolbar__center">
|
||||||
<!-- 右:KPI + 操作 -->
|
<div class="month-picker-wrap">
|
||||||
<div class="header__right">
|
<span class="month-picker-wrap__label">目标月份</span>
|
||||||
<!-- KPI 胶囊 -->
|
<el-date-picker
|
||||||
<div class="kpi-capsule">
|
v-model="yearMonth"
|
||||||
<div class="kpi-cell">
|
type="month"
|
||||||
<span class="kpi-cell__label">目标月份</span>
|
value-format="YYYY-MM"
|
||||||
<el-date-picker
|
placeholder="选择月份"
|
||||||
v-model="yearMonth"
|
:clearable="false"
|
||||||
type="month"
|
size="default"
|
||||||
value-format="YYYY-MM"
|
class="month-picker"
|
||||||
placeholder="选择月份"
|
@change="loadMatrix"
|
||||||
:clearable="false"
|
/>
|
||||||
size="small"
|
</div>
|
||||||
class="kpi-cell__picker"
|
</div>
|
||||||
@change="loadMatrix"
|
<div class="toolbar__right">
|
||||||
/>
|
<div class="summary-strip">
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-item__val">{{ deptCount }}</span>
|
||||||
|
<span class="summary-item__label">部门</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-sep" />
|
<div class="summary-divider" />
|
||||||
<div class="kpi-cell">
|
<div class="summary-item">
|
||||||
<span class="kpi-cell__label">部门数量</span>
|
<span class="summary-item__val primary">¥{{ formatMoneyShort(totalDraft) }}</span>
|
||||||
<span class="kpi-cell__val">{{ deptCount }}</span>
|
<span class="summary-item__label">目标合计</span>
|
||||||
</div>
|
|
||||||
<div class="kpi-sep" />
|
|
||||||
<div class="kpi-cell kpi-cell--primary">
|
|
||||||
<span class="kpi-cell__label">目标合计</span>
|
|
||||||
<span class="kpi-cell__val">¥ {{ formatMoney(totalDraft) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<el-button circle :loading="loading" @click="loadMatrix" title="刷新">
|
||||||
<!-- 操作按钮 -->
|
<el-icon><Refresh /></el-icon>
|
||||||
<el-button :loading="loading" @click="loadMatrix">
|
|
||||||
<el-icon><Refresh /></el-icon>刷新
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-perms="['finance.dept_performance_target/batchSave']"
|
v-perms="['finance.dept_performance_target/batchSave']"
|
||||||
@@ -59,10 +54,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── 主内容面板 ── -->
|
<!-- ── 看板区 ── -->
|
||||||
<div class="panel" v-loading="loading" element-loading-text="数据加载中…">
|
<div v-loading="loading" element-loading-text="数据加载中…" class="board-wrap">
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<div v-if="!loading && formRows.length === 0" class="empty-state">
|
<div v-if="!loading && formRows.length === 0" class="empty-state">
|
||||||
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
|
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
|
||||||
<p class="empty-state__title">暂无部门数据</p>
|
<p class="empty-state__title">暂无部门数据</p>
|
||||||
@@ -70,25 +64,30 @@
|
|||||||
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
|
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<div v-else class="board-grid">
|
||||||
<!-- 列头 - 严格对齐内容列 -->
|
<!-- 根级卡片 -->
|
||||||
<div class="col-head">
|
<TransitionGroup name="card" tag="div" class="board-grid__inner">
|
||||||
<div class="col-head__dept">部门</div>
|
<MubiaoDeptCard
|
||||||
<div class="col-head__amount">目标金额(元)</div>
|
|
||||||
<div class="col-head__remark">备注</div>
|
|
||||||
<div class="col-head__badge" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 数据区 -->
|
|
||||||
<div class="data-scroll">
|
|
||||||
<MubiaoDeptNode
|
|
||||||
v-for="r in formRows"
|
v-for="r in formRows"
|
||||||
:key="r.dept_id"
|
:key="'root-' + r.dept_id"
|
||||||
:node="r"
|
:node="r"
|
||||||
:depth="0"
|
:is-root="true"
|
||||||
|
:drilled-id="drilledId"
|
||||||
|
@drill-in="handleDrillIn"
|
||||||
/>
|
/>
|
||||||
</div>
|
<!-- 钻取卡片(顺序追加在根卡片后) -->
|
||||||
</template>
|
<MubiaoDeptCard
|
||||||
|
v-for="item in drillStack"
|
||||||
|
:key="'drill-' + item.dept_id"
|
||||||
|
:node="item"
|
||||||
|
:is-root="false"
|
||||||
|
:drilled-id="drilledId"
|
||||||
|
class="card--drilled"
|
||||||
|
@drill-in="handleDrillIn"
|
||||||
|
@close="handleClose(item.dept_id)"
|
||||||
|
/>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +96,7 @@
|
|||||||
<script lang="ts" setup name="financeMubiao">
|
<script lang="ts" setup name="financeMubiao">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { TrendCharts, Refresh, Check, OfficeBuilding } from '@element-plus/icons-vue'
|
import { TrendCharts, Refresh, Check, OfficeBuilding } from '@element-plus/icons-vue'
|
||||||
import MubiaoDeptNode from './mubiao-dept-node.vue'
|
import MubiaoDeptCard from './mubiao-dept-card.vue'
|
||||||
import {
|
import {
|
||||||
deptPerformanceTargetBatchSave,
|
deptPerformanceTargetBatchSave,
|
||||||
deptPerformanceTargetMonthMatrix,
|
deptPerformanceTargetMonthMatrix,
|
||||||
@@ -153,21 +152,50 @@ function ymNow(): string {
|
|||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMoneyShort(v: number): string {
|
||||||
|
if (v >= 10000) return (v / 10000).toFixed(1) + 'w'
|
||||||
|
return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
const yearMonth = ref<string>(ymNow())
|
const yearMonth = ref<string>(ymNow())
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const formRows = ref<Row[]>([])
|
const formRows = ref<Row[]>([])
|
||||||
|
|
||||||
|
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
|
||||||
|
const drillStack = ref<Row[]>([])
|
||||||
|
// 当前被钻取高亮的 dept_id(让来源卡片的那一行高亮)
|
||||||
|
const drilledId = ref<number | null>(null)
|
||||||
|
|
||||||
const totalDraft = computed(() => sumTree(formRows.value))
|
const totalDraft = computed(() => sumTree(formRows.value))
|
||||||
const deptCount = computed(() => countTree(formRows.value))
|
const deptCount = computed(() => countTree(formRows.value))
|
||||||
|
|
||||||
function formatMoney(v: number) {
|
function handleDrillIn(node: Row) {
|
||||||
return Number(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
// 已存在则不重复开
|
||||||
|
if (drillStack.value.some(d => d.dept_id === node.dept_id)) {
|
||||||
|
drilledId.value = node.dept_id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drillStack.value.push(node)
|
||||||
|
drilledId.value = node.dept_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(deptId: number) {
|
||||||
|
const idx = drillStack.value.findIndex(d => d.dept_id === deptId)
|
||||||
|
if (idx !== -1) {
|
||||||
|
// 关闭该卡及其之后所有钻取卡(子级)
|
||||||
|
drillStack.value.splice(idx)
|
||||||
|
}
|
||||||
|
drilledId.value = drillStack.value.length > 0
|
||||||
|
? drillStack.value[drillStack.value.length - 1].dept_id
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMatrix() {
|
async function loadMatrix() {
|
||||||
if (!yearMonth.value) return
|
if (!yearMonth.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
drillStack.value = []
|
||||||
|
drilledId.value = null
|
||||||
try {
|
try {
|
||||||
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
|
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
|
||||||
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
|
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
|
||||||
@@ -189,6 +217,8 @@ async function handleSave() {
|
|||||||
})
|
})
|
||||||
if (res?.rows) {
|
if (res?.rows) {
|
||||||
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
|
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
|
||||||
|
drillStack.value = []
|
||||||
|
drilledId.value = null
|
||||||
} else {
|
} else {
|
||||||
await loadMatrix()
|
await loadMatrix()
|
||||||
}
|
}
|
||||||
@@ -208,116 +238,79 @@ onMounted(loadMatrix)
|
|||||||
padding: 20px 20px 40px;
|
padding: 20px 20px 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════ Header ════════════════════ */
|
/* ── 工具栏 ── */
|
||||||
.header {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 16px;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__left {
|
.toolbar__left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__icon {
|
.toolbar__icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 38px;
|
width: 40px;
|
||||||
height: 38px;
|
height: 40px;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(135deg, var(--el-color-primary-light-7), var(--el-color-primary-light-9));
|
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-light-3));
|
||||||
color: var(--el-color-primary);
|
color: #fff;
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--el-color-primary) 20%, transparent);
|
box-shadow: 0 4px 10px color-mix(in srgb, var(--el-color-primary) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__text {
|
.toolbar__title {
|
||||||
display: flex;
|
font-size: 14px;
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header__title {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__desc {
|
.toolbar__desc {
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
line-height: 1.3;
|
line-height: 1.4;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__right {
|
.toolbar__center {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* KPI 胶囊 */
|
.month-picker-wrap {
|
||||||
.kpi-capsule {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 14px;
|
||||||
background: var(--el-fill-color-extra-light);
|
background: var(--el-fill-color-extra-light);
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-sep {
|
&__label {
|
||||||
width: 1px;
|
font-size: 12px;
|
||||||
background: var(--el-border-color-lighter);
|
color: var(--el-text-color-secondary);
|
||||||
align-self: stretch;
|
font-weight: 600;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
|
||||||
.kpi-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 8px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-cell--primary {
|
|
||||||
background: linear-gradient(135deg, var(--el-color-primary-light-9), var(--el-color-primary-light-8));
|
|
||||||
|
|
||||||
.kpi-cell__val {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-cell__label {
|
.month-picker {
|
||||||
font-size: 11px;
|
width: 130px;
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-cell__val {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-cell__picker {
|
|
||||||
width: 112px;
|
|
||||||
margin-top: 1px;
|
|
||||||
|
|
||||||
:deep(.el-input__wrapper) {
|
:deep(.el-input__wrapper) {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
@@ -327,7 +320,7 @@ onMounted(loadMatrix)
|
|||||||
|
|
||||||
:deep(.el-input__inner) {
|
:deep(.el-input__inner) {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -337,72 +330,89 @@ onMounted(loadMatrix)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════ Panel ════════════════════ */
|
.toolbar__right {
|
||||||
.panel {
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05);
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列头 —— 宽度需与子组件 .group__name/.row__dept (220px) 对齐 */
|
|
||||||
.col-head {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 38px;
|
gap: 10px;
|
||||||
background: var(--el-fill-color-light);
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
||||||
padding-right: 16px;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
/* 左侧 4px accent 占位 + 12px margin = 16px */
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 与 group/row 列宽严格对应 */
|
.summary-strip {
|
||||||
.col-head__dept {
|
display: flex;
|
||||||
flex: 0 0 220px;
|
align-items: center;
|
||||||
|
background: var(--el-fill-color-extra-light);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-head__amount {
|
.summary-item {
|
||||||
flex: 0 0 194px;
|
display: flex;
|
||||||
padding-left: 12px;
|
flex-direction: column;
|
||||||
border-left: 1px solid var(--el-border-color-lighter);
|
align-items: center;
|
||||||
}
|
padding: 6px 16px;
|
||||||
|
gap: 1px;
|
||||||
|
|
||||||
.col-head__remark {
|
&__val {
|
||||||
flex: 1;
|
font-size: 16px;
|
||||||
padding-left: 12px;
|
font-weight: 800;
|
||||||
border-left: 1px solid var(--el-border-color-lighter);
|
color: var(--el-text-color-primary);
|
||||||
}
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
|
||||||
.col-head__badge {
|
&.primary { color: var(--el-color-primary); }
|
||||||
flex: 0 0 60px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* 数据滚动区 */
|
&__label {
|
||||||
.data-scroll {
|
font-size: 10.5px;
|
||||||
max-height: min(72vh, 820px);
|
color: var(--el-text-color-placeholder);
|
||||||
overflow-y: auto;
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
&::-webkit-scrollbar { width: 5px; }
|
|
||||||
&::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--el-border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════ Empty ════════════════════ */
|
.summary-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 看板 ── */
|
||||||
|
.board-wrap {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-grid__inner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 钻取卡片入场动画 */
|
||||||
|
.card-enter-active {
|
||||||
|
animation: card-in 0.25s ease;
|
||||||
|
}
|
||||||
|
.card-leave-active {
|
||||||
|
animation: card-in 0.2s ease reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 钻取来源高亮标记 */
|
||||||
|
:deep(.card--drilled) {
|
||||||
|
--drill-glow: color-mix(in srgb, var(--el-color-primary) 18%, transparent);
|
||||||
|
box-shadow: 0 0 0 2px var(--el-color-primary-light-5), 0 4px 16px var(--drill-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 空状态 ── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -413,12 +423,12 @@ onMounted(loadMatrix)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state__icon {
|
.empty-state__icon {
|
||||||
width: 60px;
|
width: 64px;
|
||||||
height: 60px;
|
height: 64px;
|
||||||
border-radius: 18px;
|
border-radius: 20px;
|
||||||
background: var(--el-fill-color-light);
|
background: var(--el-fill-color-light);
|
||||||
color: var(--el-text-color-placeholder);
|
color: var(--el-text-color-placeholder);
|
||||||
font-size: 28px;
|
font-size: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -103,6 +103,15 @@
|
|||||||
>
|
>
|
||||||
批量指派医助
|
批量指派医助
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.diagnosis/assign']"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
@click="handleBatchCancelAssign"
|
||||||
|
:disabled="!canBatchCancelAssign"
|
||||||
|
>
|
||||||
|
批量取消指派
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} 条</div>
|
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} 条</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,9 +272,16 @@
|
|||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/assign'])" command="assign"><el-icon><User /></el-icon>指派</el-dropdown-item>
|
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/assign'])" command="assign"><el-icon><User /></el-icon>指派</el-dropdown-item>
|
||||||
|
<el-dropdown-item
|
||||||
|
v-if="hasPermission(['tcm.diagnosis/assign']) && hasAssignedAssistant(row)"
|
||||||
|
command="cancelAssign"
|
||||||
|
>
|
||||||
|
<el-icon><Remove /></el-icon>取消指派
|
||||||
|
</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
|
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
|
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="canCancelAppointmentRow(row) && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
|
<el-dropdown-item v-if="canCancelAppointmentRow(row) && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahaoLogList'])" command="guahaoLogs"><el-icon><List /></el-icon>挂号日志</el-dropdown-item>
|
||||||
<el-dropdown-item command="order" v-if="hasPermission(['tcm.diagnosis/order'])"><el-icon><Document /></el-icon>创建订单</el-dropdown-item>
|
<el-dropdown-item command="order" v-if="hasPermission(['tcm.diagnosis/order'])"><el-icon><Document /></el-icon>创建订单</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/delete'])" command="delete" divided><el-icon><Delete /></el-icon><span class="text-danger">删除</span></el-dropdown-item>
|
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/delete'])" command="delete" divided><el-icon><Delete /></el-icon><span class="text-danger">删除</span></el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
@@ -284,12 +300,37 @@
|
|||||||
<edit-popup ref="editRef" @success="refreshListAfterPopupSave" />
|
<edit-popup ref="editRef" @success="refreshListAfterPopupSave" />
|
||||||
<detail-popup ref="detailRef" />
|
<detail-popup ref="detailRef" />
|
||||||
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
|
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
|
||||||
<appointment-popup ref="appointmentRef" @success="refreshListAfterPopupSave" />
|
<appointment-popup ref="appointmentRef" @success="handleAppointmentSaved" />
|
||||||
<assistant-watch-call-dialog
|
<assistant-watch-call-dialog
|
||||||
v-model="watchCallVisible"
|
v-model="watchCallVisible"
|
||||||
:diagnosis-id="watchCallDiagnosisId"
|
:diagnosis-id="watchCallDiagnosisId"
|
||||||
@closed="getLists"
|
@closed="getLists"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<el-drawer v-model="guahaoLogDrawerVisible" title="挂号操作日志" size="460px" destroy-on-close @closed="resetGuahaoLogDrawer">
|
||||||
|
<div v-loading="guahaoLogLoading" class="min-h-[100px]">
|
||||||
|
<div v-if="guahaoLogContextRow" class="text-sm text-gray-600 mb-4 pb-3 border-b border-gray-100">
|
||||||
|
<span class="font-medium text-gray-800">{{ guahaoLogContextRow.patient_name }}</span>
|
||||||
|
<span class="mx-2 text-gray-300">|</span>
|
||||||
|
<span>诊单 #{{ guahaoLogContextRow.id }}</span>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!guahaoLogLoading && guahaoLogRows.length === 0" description="暂无挂号/取消挂号操作记录" />
|
||||||
|
<el-timeline v-else-if="guahaoLogRows.length > 0">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="(log, idx) in guahaoLogRows"
|
||||||
|
:key="log.id ?? idx"
|
||||||
|
:timestamp="log.create_time_text || '—'"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<el-tag size="small" type="info">{{ log.action_desc }}</el-tag>
|
||||||
|
<span class="text-sm font-medium text-gray-800">{{ formatGuahaoLogOperator(log) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1.5 leading-relaxed">{{ log.summary }}</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
<!-- 小程序二维码弹窗 -->
|
<!-- 小程序二维码弹窗 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -564,7 +605,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="tcmDiagnosis">
|
<script setup lang="ts" name="tcmDiagnosis">
|
||||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
|
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
|
||||||
import { orderCreate } from '@/api/order'
|
import { orderCreate } from '@/api/order'
|
||||||
import { cancelAppointment } from '@/api/doctor'
|
import { cancelAppointment } from '@/api/doctor'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
@@ -572,7 +613,7 @@ import { getDictData } from '@/api/app'
|
|||||||
import { getWeappConfig } from '@/api/channel/weapp'
|
import { getWeappConfig } from '@/api/channel/weapp'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose } from '@element-plus/icons-vue'
|
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
@@ -1116,6 +1157,19 @@ const handleSelectionChange = (selection: any[]) => {
|
|||||||
selectedIds.value = selection.map(item => item.id)
|
selectedIds.value = selection.map(item => item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前行是否已指派医助(用于取消指派入口) */
|
||||||
|
const hasAssignedAssistant = (row: any) => {
|
||||||
|
const id = Number(row?.assistant_id ?? row?.assistant ?? 0)
|
||||||
|
return id > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前页勾选记录中是否存在已指派医助的诊单(批量取消指派) */
|
||||||
|
const canBatchCancelAssign = computed(() => {
|
||||||
|
if (!selectedIds.value.length) return false
|
||||||
|
const set = new Set(selectedIds.value)
|
||||||
|
return pager.lists.some((r: any) => set.has(r.id) && hasAssignedAssistant(r))
|
||||||
|
})
|
||||||
|
|
||||||
// 单独指派
|
// 单独指派
|
||||||
const handleSingleAssign = (row: any) => {
|
const handleSingleAssign = (row: any) => {
|
||||||
assignMode.value = 'single'
|
assignMode.value = 'single'
|
||||||
@@ -1172,6 +1226,34 @@ const handleConfirmAssign = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取消指派:将 assistant_id 置为 0(与后端 assign 一致) */
|
||||||
|
const handleCancelAssign = async (row: any) => {
|
||||||
|
if (!row?.id || !hasAssignedAssistant(row)) {
|
||||||
|
feedback.msgWarning('该诊单未指派医助')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await feedback.confirm('确定取消该诊单的医助指派吗?')
|
||||||
|
await tcmDiagnosisAssign({ id: row.id, assistant_id: 0 })
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchCancelAssign = async () => {
|
||||||
|
if (!selectedIds.value.length) {
|
||||||
|
feedback.msgWarning('请先选择诊单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const set = new Set(selectedIds.value)
|
||||||
|
const rows = pager.lists.filter((r: any) => set.has(r.id) && hasAssignedAssistant(r))
|
||||||
|
if (!rows.length) {
|
||||||
|
feedback.msgWarning('所选诊单中没有已指派医助的记录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await feedback.confirm(`确定取消 ${rows.length} 条诊单的医助指派吗?`)
|
||||||
|
await Promise.all(rows.map((r: any) => tcmDiagnosisAssign({ id: r.id, assistant_id: 0 })))
|
||||||
|
selectedIds.value = []
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
editRef.value.open('add')
|
editRef.value.open('add')
|
||||||
}
|
}
|
||||||
@@ -1222,6 +1304,7 @@ const handleDelete = async (id: number) => {
|
|||||||
const handleRowAction = (cmd: string, row: any) => {
|
const handleRowAction = (cmd: string, row: any) => {
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case 'assign': handleSingleAssign(row); break
|
case 'assign': handleSingleAssign(row); break
|
||||||
|
case 'cancelAssign': handleCancelAssign(row); break
|
||||||
case 'videoQr':
|
case 'videoQr':
|
||||||
if (!isAppointmentActiveForVideo(row)) {
|
if (!isAppointmentActiveForVideo(row)) {
|
||||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||||
@@ -1231,6 +1314,7 @@ const handleRowAction = (cmd: string, row: any) => {
|
|||||||
break
|
break
|
||||||
case 'confirmQr': handleMiniProgramQRCode(row); break
|
case 'confirmQr': handleMiniProgramQRCode(row); break
|
||||||
case 'cancelApt': handleCancelAppointment(row); break
|
case 'cancelApt': handleCancelAppointment(row); break
|
||||||
|
case 'guahaoLogs': openGuahaoLogDrawer(row); break
|
||||||
case 'order': handleCreateOrder(row); break
|
case 'order': handleCreateOrder(row); break
|
||||||
case 'delete': handleDelete(row.id); break
|
case 'delete': handleDelete(row.id); break
|
||||||
}
|
}
|
||||||
@@ -1309,11 +1393,63 @@ const handleCancelAppointment = async (row: any) => {
|
|||||||
feedback.msgSuccess('取消挂号成功')
|
feedback.msgSuccess('取消挂号成功')
|
||||||
getLists()
|
getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
|
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id === row.id) {
|
||||||
|
await fetchGuahaoLogs(row.id)
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
|
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const guahaoLogDrawerVisible = ref(false)
|
||||||
|
const guahaoLogLoading = ref(false)
|
||||||
|
const guahaoLogRows = ref<any[]>([])
|
||||||
|
const guahaoLogContextRow = ref<any>(null)
|
||||||
|
|
||||||
|
function resetGuahaoLogDrawer() {
|
||||||
|
guahaoLogRows.value = []
|
||||||
|
guahaoLogContextRow.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGuahaoLogOperator(log: any) {
|
||||||
|
const name = String(log?.admin_name || '').trim()
|
||||||
|
const aid = Number(log?.admin_id)
|
||||||
|
if (name) return name
|
||||||
|
if (aid > 0) return `管理员 #${aid}`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGuahaoLogs(diagnosisId: number) {
|
||||||
|
guahaoLogLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await tcmDiagnosisGuahaoLogList({ id: diagnosisId })
|
||||||
|
guahaoLogRows.value = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []
|
||||||
|
} catch {
|
||||||
|
guahaoLogRows.value = []
|
||||||
|
} finally {
|
||||||
|
guahaoLogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGuahaoLogDrawer(row: any) {
|
||||||
|
if (!row?.id) {
|
||||||
|
feedback.msgWarning('诊单信息不完整')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guahaoLogContextRow.value = row
|
||||||
|
guahaoLogDrawerVisible.value = true
|
||||||
|
await fetchGuahaoLogs(Number(row.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预约抽屉保存成功后:静默刷新列表 + 顶部计数;若挂号日志抽屉打开则同步日志 */
|
||||||
|
async function handleAppointmentSaved() {
|
||||||
|
await getLists({ silent: true })
|
||||||
|
fetchDateCounts()
|
||||||
|
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id) {
|
||||||
|
await fetchGuahaoLogs(Number(guahaoLogContextRow.value.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 患者挂号
|
// 患者挂号
|
||||||
const handleAppointment = (row: any) => {
|
const handleAppointment = (row: any) => {
|
||||||
if (!row.patient_id) {
|
if (!row.patient_id) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class AppointmentController extends BaseAdminController
|
|||||||
{
|
{
|
||||||
$params = (new AppointmentValidate())->post()->goCheck('create');
|
$params = (new AppointmentValidate())->post()->goCheck('create');
|
||||||
$params['assistant_id'] = $this->adminId;
|
$params['assistant_id'] = $this->adminId;
|
||||||
$result = AppointmentLogic::create($params);
|
$result = AppointmentLogic::create($params, $this->adminId, $this->adminInfo);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return $this->fail(AppointmentLogic::getError());
|
return $this->fail(AppointmentLogic::getError());
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ class AppointmentController extends BaseAdminController
|
|||||||
public function cancel()
|
public function cancel()
|
||||||
{
|
{
|
||||||
$params = (new AppointmentValidate())->post()->goCheck('cancel');
|
$params = (new AppointmentValidate())->post()->goCheck('cancel');
|
||||||
$result = AppointmentLogic::cancel($params);
|
$result = AppointmentLogic::cancel($params, $this->adminId, $this->adminInfo);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return $this->fail(AppointmentLogic::getError());
|
return $this->fail(AppointmentLogic::getError());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ class DiagnosisController extends BaseAdminController
|
|||||||
$result = DiagnosisLogic::detail($params);
|
$result = DiagnosisLogic::detail($params);
|
||||||
return $this->data($result);
|
return $this->data($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
|
||||||
|
*/
|
||||||
|
public function guahaoLogList()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->get()->goCheck('guahaoLogList');
|
||||||
|
|
||||||
|
return $this->data(DiagnosisLogic::guahaoLogList((int) $params['id']));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 诊单详情(患者端)
|
* @notes 诊单详情(患者端)
|
||||||
@@ -191,13 +201,15 @@ class DiagnosisController extends BaseAdminController
|
|||||||
return $this->fail('诊单ID不能为空');
|
return $this->fail('诊单ID不能为空');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($params['assistant_id'])) {
|
if (!array_key_exists('assistant_id', $params)) {
|
||||||
return $this->fail('请选择医助');
|
return $this->fail('请选择医助或取消指派');
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = DiagnosisLogic::assign($params);
|
$result = DiagnosisLogic::assign($params);
|
||||||
if ($result) {
|
if ($result) {
|
||||||
return $this->success('指派成功', [], 1, 1);
|
$msg = (int) ($params['assistant_id'] ?? -1) === 0 ? '已取消指派' : '指派成功';
|
||||||
|
|
||||||
|
return $this->success($msg, [], 1, 1);
|
||||||
}
|
}
|
||||||
return $this->fail(DiagnosisLogic::getError());
|
return $this->fail(DiagnosisLogic::getError());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('保存成功', $result);
|
return $this->success('保存成功', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改关联处方的患者姓名与手机号(订单详情场景)
|
||||||
|
*/
|
||||||
|
public function patchPrescriptionPatient()
|
||||||
|
{
|
||||||
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('patchPrescriptionPatient');
|
||||||
|
$ok = PrescriptionOrderLogic::patchPrescriptionPatient(
|
||||||
|
(int) $params['id'],
|
||||||
|
(string) $params['patient_name'],
|
||||||
|
(string) $params['phone'],
|
||||||
|
$this->adminId,
|
||||||
|
$this->adminInfo
|
||||||
|
);
|
||||||
|
if (!$ok) {
|
||||||
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('保存成功');
|
||||||
|
}
|
||||||
|
|
||||||
public function auditPrescription()
|
public function auditPrescription()
|
||||||
{
|
{
|
||||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
if ($prescriptionIds !== []) {
|
if ($prescriptionIds !== []) {
|
||||||
$prescriptions = \app\common\model\tcm\Prescription::whereIn('id', $prescriptionIds)
|
$prescriptions = \app\common\model\tcm\Prescription::whereIn('id', $prescriptionIds)
|
||||||
->whereNull('delete_time')
|
->whereNull('delete_time')
|
||||||
->field(['id', 'creator_id', 'doctor_name'])
|
->field(['id', 'creator_id', 'doctor_name', 'phone'])
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
$doctorIds = [];
|
$doctorIds = [];
|
||||||
@@ -242,7 +242,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
if ($rxId > 0) {
|
if ($rxId > 0) {
|
||||||
$prescriptionCreators[$rxId] = [
|
$prescriptionCreators[$rxId] = [
|
||||||
'doctor_id' => $doctorId,
|
'doctor_id' => $doctorId,
|
||||||
'doctor_name' => $doctorName
|
'doctor_name' => $doctorName,
|
||||||
|
'phone' => (string) ($rx['phone'] ?? ''),
|
||||||
];
|
];
|
||||||
if ($doctorId > 0) {
|
if ($doctorId > 0) {
|
||||||
$doctorIds[] = $doctorId;
|
$doctorIds[] = $doctorId;
|
||||||
@@ -288,9 +289,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$creatorId = (int) ($item['creator_id'] ?? 0);
|
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||||
$item['creator_name'] = $creatorId > 0 ? ($creatorNames[$creatorId] ?? '') : '';
|
$item['creator_name'] = $creatorId > 0 ? ($creatorNames[$creatorId] ?? '') : '';
|
||||||
|
|
||||||
// 添加开方人姓名
|
// 添加开方人姓名、处方登记手机(与业务订单收货手机比对用)
|
||||||
$rxId = (int) ($item['prescription_id'] ?? 0);
|
$rxId = (int) ($item['prescription_id'] ?? 0);
|
||||||
$item['doctor_name'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['doctor_name'] ?? '') : '';
|
$item['doctor_name'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['doctor_name'] ?? '') : '';
|
||||||
|
$item['prescription_phone'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['phone'] ?? '') : '';
|
||||||
}
|
}
|
||||||
unset($item);
|
unset($item);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use app\common\model\doctor\Appointment;
|
|||||||
use app\common\model\doctor\Roster;
|
use app\common\model\doctor\Roster;
|
||||||
use app\common\service\doctor\RosterSegmentService;
|
use app\common\service\doctor\RosterSegmentService;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\model\tcm\DiagnosisGuahaoLog;
|
||||||
use app\common\model\auth\Admin;
|
use app\common\model\auth\Admin;
|
||||||
use app\api\logic\ChatNotifyLogic;
|
use app\api\logic\ChatNotifyLogic;
|
||||||
use app\adminapi\logic\tcm\PrescriptionLogic;
|
use app\adminapi\logic\tcm\PrescriptionLogic;
|
||||||
@@ -183,7 +184,7 @@ class AppointmentLogic extends BaseLogic
|
|||||||
* @param array $params
|
* @param array $params
|
||||||
* @return array|bool
|
* @return array|bool
|
||||||
*/
|
*/
|
||||||
public static function create(array $params)
|
public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
@@ -244,6 +245,25 @@ class AppointmentLogic extends BaseLogic
|
|||||||
$appointment = Appointment::create($data);
|
$appointment = Appointment::create($data);
|
||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
|
||||||
|
$doctorName = (string) (Admin::where('id', (int) $params['doctor_id'])->value('name') ?? '');
|
||||||
|
$hm = substr((string) $appointmentTime, 0, 5);
|
||||||
|
$summary = sprintf(
|
||||||
|
'挂号 #%d:医生「%s」,%s %s',
|
||||||
|
(int) $appointment->id,
|
||||||
|
$doctorName !== '' ? $doctorName : ('ID:' . (int) $params['doctor_id']),
|
||||||
|
(string) $params['appointment_date'],
|
||||||
|
$hm
|
||||||
|
);
|
||||||
|
self::appendDiagnosisGuahaoLog(
|
||||||
|
(int) $params['patient_id'],
|
||||||
|
(int) $appointment->id,
|
||||||
|
$operatorAdminId,
|
||||||
|
$operatorAdminInfo,
|
||||||
|
'create',
|
||||||
|
$summary
|
||||||
|
);
|
||||||
|
|
||||||
return ['id' => $appointment->id];
|
return ['id' => $appointment->id];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
@@ -257,7 +277,7 @@ class AppointmentLogic extends BaseLogic
|
|||||||
* @param array $params
|
* @param array $params
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function cancel(array $params)
|
public static function cancel(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$appointment = Appointment::findOrEmpty($params['id']);
|
$appointment = Appointment::findOrEmpty($params['id']);
|
||||||
@@ -279,10 +299,34 @@ class AppointmentLogic extends BaseLogic
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$diagId = (int) $appointment->patient_id;
|
||||||
|
$aptId = (int) $appointment->id;
|
||||||
|
$doctorId = (int) $appointment->doctor_id;
|
||||||
|
$aptDate = (string) ($appointment->appointment_date ?? '');
|
||||||
|
$aptTimeRaw = (string) ($appointment->appointment_time ?? '');
|
||||||
|
$hm = strlen($aptTimeRaw) >= 5 ? substr($aptTimeRaw, 0, 5) : $aptTimeRaw;
|
||||||
|
|
||||||
$appointment->status = 2; // 已取消
|
$appointment->status = 2; // 已取消
|
||||||
$appointment->update_time = time();
|
$appointment->update_time = time();
|
||||||
$appointment->save();
|
$appointment->save();
|
||||||
|
|
||||||
|
$doctorName = (string) (Admin::where('id', $doctorId)->value('name') ?? '');
|
||||||
|
$summary = sprintf(
|
||||||
|
'取消挂号 #%d:医生「%s」,%s %s',
|
||||||
|
$aptId,
|
||||||
|
$doctorName !== '' ? $doctorName : ('ID:' . $doctorId),
|
||||||
|
$aptDate,
|
||||||
|
$hm
|
||||||
|
);
|
||||||
|
self::appendDiagnosisGuahaoLog(
|
||||||
|
$diagId,
|
||||||
|
$aptId,
|
||||||
|
$operatorAdminId,
|
||||||
|
$operatorAdminInfo,
|
||||||
|
'cancel',
|
||||||
|
$summary
|
||||||
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -444,4 +488,37 @@ class AppointmentLogic extends BaseLogic
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单列表维度:记录谁在后台发起挂号 / 取消挂号(写入失败不影响主流程)
|
||||||
|
*/
|
||||||
|
private static function appendDiagnosisGuahaoLog(
|
||||||
|
int $diagnosisId,
|
||||||
|
int $appointmentId,
|
||||||
|
int $adminId,
|
||||||
|
array $adminInfo,
|
||||||
|
string $action,
|
||||||
|
string $summary
|
||||||
|
): void {
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$adminName = (string) ($adminInfo['name'] ?? '');
|
||||||
|
if ($adminName === '' && $adminId > 0) {
|
||||||
|
$adminName = (string) (Admin::where('id', $adminId)->value('name') ?? '');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$log = new DiagnosisGuahaoLog();
|
||||||
|
$log->diagnosis_id = $diagnosisId;
|
||||||
|
$log->appointment_id = $appointmentId;
|
||||||
|
$log->admin_id = $adminId;
|
||||||
|
$log->admin_name = mb_substr($adminName, 0, 64);
|
||||||
|
$log->action = mb_substr($action, 0, 32);
|
||||||
|
$log->summary = mb_substr($summary, 0, 500);
|
||||||
|
$log->create_time = time();
|
||||||
|
$log->save();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('诊单挂号日志写入失败: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use think\facade\Db;
|
|||||||
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心展示部门在选定渠道下计 0**
|
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心展示部门在选定渠道下计 0**
|
||||||
* - 接诊诊单 = 已完成挂号 status=3,医助 role_id=2;按 TIMESTAMP(appointment_date, appointment_time) 落入统计区间(挂号条数,非订单)。
|
* - 接诊诊单 = 已完成挂号 status=3,医助 role_id=2;按 TIMESTAMP(appointment_date, appointment_time) 落入统计区间(挂号条数,非订单)。
|
||||||
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4;NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag/creator 两套口径均按订单计,非挂号条数。
|
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4;NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag/creator 两套口径均按订单计,非挂号条数。
|
||||||
* - 合计业绩 = 区间内 zyt_tcm_prescription_order.amount 求和(排除已取消(4) 与软删),按 creator_id 归属到「中心」展示部门;
|
* - 合计业绩 = 区间内 zyt_tcm_prescription_order.amount 求和(排除已取消(4) 与软删),按 dg.assistant_id 归属到「中心」展示部门;
|
||||||
* 无法归属到任一展示部门的金额单列「未归属中心」行;合计行 = 与列表同条件的全表 SUM(不限部门)
|
* 无法归属到任一展示部门的金额单列「未归属中心」行;合计行 = 与列表同条件的全表 SUM(不限部门)
|
||||||
* · 时间窗与业绩条件与列表 stats_order_amount_performance 一致;列表若另有患者/单号等筛选或账号数据域收窄,金额可能仍不同
|
* · 时间窗与业绩条件与列表 stats_order_amount_performance 一致;列表若另有患者/单号等筛选或账号数据域收窄,金额可能仍不同
|
||||||
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子**
|
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子**
|
||||||
@@ -348,7 +348,7 @@ class YejiStatsLogic
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 医助排行榜:按展示部门分表,每表内按诊金降序排名。
|
* 医助排行榜:按展示部门分表,每表内按诊金降序排名。
|
||||||
* 诊金口径与 overview 部门表一致(无渠道=合计业绩/creator;有渠道={渠道}业绩/dict 诊单医助或 tag/creator;二中心在选渠道下注 0)。
|
* 诊金口径:无渠道=诊单 dg.assistant_id 业绩(与列表 assistant_id 筛选一致);有渠道={渠道}业绩/dict 诊单医助;二中心在选渠道下注 0。
|
||||||
* 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。
|
* 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。
|
||||||
*
|
*
|
||||||
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
|
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
|
||||||
@@ -431,7 +431,7 @@ class YejiStatsLogic
|
|||||||
$rangeNote = $channelFilterActive
|
$rangeNote = $channelFilterActive
|
||||||
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
||||||
. '「二中心」医助在该渠道下诊金与进线计 0。')
|
. '「二中心」医助在该渠道下诊金与进线计 0。')
|
||||||
: '排行诊金 = 合计业绩口径(creator,与部门表「合计业绩」一致)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。';
|
: '排行诊金 = 处方订单列表 assistant_id 口径(诊单医助业绩,与列表按医助筛选一致)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。';
|
||||||
|
|
||||||
$leaderboards = [];
|
$leaderboards = [];
|
||||||
foreach ($primaryDeptIds as $deptId) {
|
foreach ($primaryDeptIds as $deptId) {
|
||||||
@@ -1084,7 +1084,7 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 业绩按 creator_id 聚合到 admin(与 aggregatePerformance 同源 WHERE)。
|
* 业绩按 dg.assistant_id 聚合到 admin(与处方订单列表 assistant_id 筛选口径一致)。
|
||||||
*
|
*
|
||||||
* @return array<int, float>
|
* @return array<int, float>
|
||||||
*/
|
*/
|
||||||
@@ -1100,19 +1100,23 @@ class YejiStatsLogic
|
|||||||
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
|
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||||
$query = Db::name('tcm_prescription_order')
|
$query = Db::name('tcm_prescription_order')
|
||||||
->alias('o')
|
->alias('o')
|
||||||
|
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||||
->whereNull('o.delete_time')
|
->whereNull('o.delete_time')
|
||||||
->where('o.create_time', 'between', [$startTs, $endTs]);
|
->where('o.create_time', 'between', [$startTs, $endTs])
|
||||||
|
->where('o.diagnosis_id', '>', 0)
|
||||||
|
->where('dg.assistant_id', '>', 0);
|
||||||
self::applyPrescriptionOrderNotCancelledForPerformance($query);
|
self::applyPrescriptionOrderNotCancelledForPerformance($query);
|
||||||
$query->field(['o.creator_id', 'SUM(o.amount) AS amt'])
|
$query->field(['dg.assistant_id', 'SUM(o.amount) AS amt'])
|
||||||
->group('o.creator_id');
|
->group('dg.assistant_id');
|
||||||
|
|
||||||
if ($tagDiagIds !== null) {
|
if ($tagDiagIds !== null) {
|
||||||
$query->whereIn('o.diagnosis_id', $tagDiagIds);
|
$query->whereIn('o.diagnosis_id', $tagDiagIds);
|
||||||
}
|
}
|
||||||
if ($tagAssistantIds !== null) {
|
if ($tagAssistantIds !== null) {
|
||||||
$query->whereIn('o.creator_id', array_keys($tagAssistantIds));
|
$query->whereIn('dg.assistant_id', array_keys($tagAssistantIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $query->select()->toArray();
|
$rows = $query->select()->toArray();
|
||||||
@@ -1121,11 +1125,11 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
$byAdmin = [];
|
$byAdmin = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$cid = (int) ($r['creator_id'] ?? 0);
|
$aid = (int) ($r['assistant_id'] ?? 0);
|
||||||
if ($cid <= 0) {
|
if ($aid <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$byAdmin[$cid] = ($byAdmin[$cid] ?? 0) + (float) $r['amt'];
|
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $byAdmin;
|
return $byAdmin;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ namespace app\adminapi\logic\tcm;
|
|||||||
|
|
||||||
use app\common\logic\BaseLogic;
|
use app\common\logic\BaseLogic;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\model\tcm\DiagnosisGuahaoLog;
|
||||||
use app\common\model\tcm\DiagnosisAssignLog;
|
use app\common\model\tcm\DiagnosisAssignLog;
|
||||||
use app\common\model\tcm\ImChatMessage;
|
use app\common\model\tcm\ImChatMessage;
|
||||||
use app\common\model\DiagnosisViewRecord;
|
use app\common\model\DiagnosisViewRecord;
|
||||||
@@ -3296,4 +3297,36 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 诊单挂号 / 取消挂号 操作日志(后台)
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function guahaoLogList(int $diagnosisId): array
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
$exists = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find();
|
||||||
|
if (!$exists) {
|
||||||
|
self::setError('诊单不存在');
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = DiagnosisGuahaoLog::where('diagnosis_id', $diagnosisId)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->limit(200)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($logs as &$r) {
|
||||||
|
$t = (int) ($r['create_time'] ?? 0);
|
||||||
|
$r['create_time_text'] = $t > 0 ? date('Y-m-d H:i:s', $t) : '';
|
||||||
|
$act = (string) ($r['action'] ?? '');
|
||||||
|
$r['action_desc'] = $act === 'cancel' ? '取消挂号' : ($act === 'create' ? '挂号' : $act);
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
|
||||||
|
return $logs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,10 +382,13 @@ class PrescriptionLogic
|
|||||||
'dietary_taboo' => is_array($params['dietary_taboo'] ?? null) ? implode(',', $params['dietary_taboo']) : ($params['dietary_taboo'] ?? $prescription->dietary_taboo),
|
'dietary_taboo' => is_array($params['dietary_taboo'] ?? null) ? implode(',', $params['dietary_taboo']) : ($params['dietary_taboo'] ?? $prescription->dietary_taboo),
|
||||||
'usage_notes' => $params['usage_notes'] ?? $prescription->usage_notes,
|
'usage_notes' => $params['usage_notes'] ?? $prescription->usage_notes,
|
||||||
'doctor_name' => $params['doctor_name'] ?? $prescription->doctor_name,
|
'doctor_name' => $params['doctor_name'] ?? $prescription->doctor_name,
|
||||||
|
'doctor_signature' => $params['doctor_signature'] ?? $prescription->doctor_signature,
|
||||||
'is_shared' => (int)($params['is_shared'] ?? $prescription->is_shared),
|
'is_shared' => (int)($params['is_shared'] ?? $prescription->is_shared),
|
||||||
'visible_role_ids' => array_key_exists('visible_role_ids', $params)
|
'visible_role_ids' => array_key_exists('visible_role_ids', $params)
|
||||||
? self::normalizeVisibleRoleIds($params['visible_role_ids'])
|
? self::normalizeVisibleRoleIds($params['visible_role_ids'])
|
||||||
: (string) ($prescription->visible_role_ids ?? ''),
|
: (string) ($prescription->visible_role_ids ?? ''),
|
||||||
|
// 后台编辑保存后视为手工处方(原系统代开标记清除)
|
||||||
|
'is_system_auto' => 0,
|
||||||
// 开方医生修改后重新进入待审核
|
// 开方医生修改后重新进入待审核
|
||||||
'audit_status' => 0,
|
'audit_status' => 0,
|
||||||
'audit_time' => null,
|
'audit_time' => null,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use app\common\model\tcm\Prescription;
|
|||||||
use app\common\model\tcm\PrescriptionOrder;
|
use app\common\model\tcm\PrescriptionOrder;
|
||||||
use app\common\model\tcm\PrescriptionOrderLog;
|
use app\common\model\tcm\PrescriptionOrderLog;
|
||||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||||
|
use app\common\model\doctor\Appointment;
|
||||||
use app\common\model\auth\Admin;
|
use app\common\model\auth\Admin;
|
||||||
use app\common\service\ExpressTrackService;
|
use app\common\service\ExpressTrackService;
|
||||||
use app\common\service\gancao\GancaoScmRecipelService;
|
use app\common\service\gancao\GancaoScmRecipelService;
|
||||||
@@ -706,9 +707,176 @@ class PrescriptionOrderLogic
|
|||||||
}
|
}
|
||||||
$arr['diagnosis_creator_dept_path'] = $deptPath;
|
$arr['diagnosis_creator_dept_path'] = $deptPath;
|
||||||
|
|
||||||
|
// 挂号预约摘要:优先处方 appointment_id;否则按诊单 id 匹配预约表 patient_id(诊单)
|
||||||
|
$arr['linked_appointment'] = null;
|
||||||
|
$rxApptId = 0;
|
||||||
|
if (isset($arr['prescription']) && is_array($arr['prescription'])) {
|
||||||
|
$rxApptId = (int) ($arr['prescription']['appointment_id'] ?? 0);
|
||||||
|
}
|
||||||
|
$resolvedApptId = $rxApptId;
|
||||||
|
if ($resolvedApptId <= 0 && $diagIdForMeta > 0) {
|
||||||
|
$apFallback = Appointment::where('patient_id', $diagIdForMeta)->order('id', 'desc')->find();
|
||||||
|
if ($apFallback !== null) {
|
||||||
|
$resolvedApptId = (int) $apFallback->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($resolvedApptId > 0) {
|
||||||
|
$brief = self::buildAppointmentBrief($resolvedApptId);
|
||||||
|
if ($brief !== null) {
|
||||||
|
$brief['resolved_from'] = $rxApptId === $resolvedApptId ? 'prescription' : 'diagnosis';
|
||||||
|
$arr['linked_appointment'] = $brief;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $arr;
|
return $arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务订单详情场景:仅更新关联处方的患者姓名与手机号(不重置消费者处方审核、不改变其它处方字段)
|
||||||
|
*/
|
||||||
|
public static function patchPrescriptionPatient(
|
||||||
|
int $prescriptionOrderId,
|
||||||
|
string $patientName,
|
||||||
|
string $phone,
|
||||||
|
int $adminId,
|
||||||
|
array $adminInfo
|
||||||
|
): bool {
|
||||||
|
self::$error = '';
|
||||||
|
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
|
||||||
|
if (!$order) {
|
||||||
|
self::setError('订单不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||||
|
self::setError('无权限操作');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$rxId = (int) ($order->prescription_id ?? 0);
|
||||||
|
if ($rxId <= 0) {
|
||||||
|
self::setError('该订单未关联处方');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
|
||||||
|
if (!$rx) {
|
||||||
|
self::setError('处方不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!PrescriptionLogic::canViewPrescription($rx, $adminId, $adminInfo)) {
|
||||||
|
self::setError('无权限修改此处方');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$patientName = trim($patientName);
|
||||||
|
$phone = trim($phone);
|
||||||
|
if ($patientName === '') {
|
||||||
|
self::setError('患者姓名不能为空');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mb_strlen($patientName) > 50) {
|
||||||
|
self::setError('患者姓名过长');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($phone === '') {
|
||||||
|
self::setError('手机号不能为空');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (strlen($phone) > 20) {
|
||||||
|
self::setError('手机号过长');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldName = trim((string) ($rx->patient_name ?? ''));
|
||||||
|
$oldPhone = trim((string) ($rx->phone ?? ''));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rx->save([
|
||||||
|
'patient_name' => $patientName,
|
||||||
|
'phone' => $phone,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nameFrom = $oldName === '' ? '—' : $oldName;
|
||||||
|
$phoneFrom = $oldPhone === '' ? '—' : $oldPhone;
|
||||||
|
$summary = sprintf(
|
||||||
|
'关联处方 #%d:患者姓名「%s」→「%s」,手机「%s」→「%s」',
|
||||||
|
$rxId,
|
||||||
|
$nameFrom,
|
||||||
|
$patientName,
|
||||||
|
$phoneFrom,
|
||||||
|
$phone
|
||||||
|
);
|
||||||
|
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_patient', $summary);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挂号预约简要信息(业务订单详情展示)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function buildAppointmentBrief(int $appointmentId): ?array
|
||||||
|
{
|
||||||
|
if ($appointmentId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$ap = Appointment::find($appointmentId);
|
||||||
|
if ($ap === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$row = $ap->toArray();
|
||||||
|
$doctorName = '';
|
||||||
|
$did = (int) ($row['doctor_id'] ?? 0);
|
||||||
|
if ($did > 0) {
|
||||||
|
$doctorName = (string) (Admin::where('id', $did)->value('name') ?? '');
|
||||||
|
}
|
||||||
|
$statusMap = [
|
||||||
|
1 => '已预约',
|
||||||
|
2 => '已取消',
|
||||||
|
3 => '已完成',
|
||||||
|
4 => '已过号',
|
||||||
|
];
|
||||||
|
$typeMap = [
|
||||||
|
'video' => '视频问诊',
|
||||||
|
'text' => '图文问诊',
|
||||||
|
'phone' => '电话问诊',
|
||||||
|
];
|
||||||
|
$periodRaw = (string) ($row['period'] ?? '');
|
||||||
|
$period = $periodRaw === 'afternoon' ? '下午' : ($periodRaw === 'morning' ? '上午' : $periodRaw);
|
||||||
|
$timePart = (string) ($row['appointment_time'] ?? '');
|
||||||
|
if (strlen($timePart) > 5) {
|
||||||
|
$timePart = substr($timePart, 0, 5);
|
||||||
|
}
|
||||||
|
$status = (int) ($row['status'] ?? 0);
|
||||||
|
$atype = (string) ($row['appointment_type'] ?? '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $appointmentId,
|
||||||
|
'appointment_date' => (string) ($row['appointment_date'] ?? ''),
|
||||||
|
'period' => $period,
|
||||||
|
'appointment_time' => $timePart,
|
||||||
|
'appointment_type' => $atype,
|
||||||
|
'appointment_type_desc' => $typeMap[$atype] ?? '未知',
|
||||||
|
'status' => $status,
|
||||||
|
'status_desc' => $statusMap[$status] ?? '未知',
|
||||||
|
'doctor_name' => $doctorName,
|
||||||
|
'channel_source' => (string) ($row['channel_source'] ?? ''),
|
||||||
|
'remark' => (string) ($row['remark'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 物流轨迹(顺丰/京东等,优先快递100;未配置密钥时返回官网链接)
|
* 物流轨迹(顺丰/京东等,优先快递100;未配置密钥时返回官网链接)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
->append('id_card', 'require|length:15,18');
|
->append('id_card', 'require|length:15,18');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sceneGuahaoLogList()
|
||||||
|
{
|
||||||
|
return $this->only(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
protected function checkDiagnosis($value)
|
protected function checkDiagnosis($value)
|
||||||
{
|
{
|
||||||
$diagnosis = Diagnosis::findOrEmpty($value);
|
$diagnosis = Diagnosis::findOrEmpty($value);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'action' => 'require|in:approve,reject',
|
'action' => 'require|in:approve,reject',
|
||||||
'remark' => 'max:500',
|
'remark' => 'max:500',
|
||||||
'fulfillment_status' => 'require|integer|in:3,7,8,9,10,11,12',
|
'fulfillment_status' => 'require|integer|in:3,7,8,9,10,11,12',
|
||||||
|
'patient_name' => 'require|max:50',
|
||||||
|
'phone' => 'require|max:20',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $message = [
|
protected $message = [
|
||||||
@@ -43,6 +45,8 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'fee_type.require' => '请选择费用类别',
|
'fee_type.require' => '请选择费用类别',
|
||||||
'amount.require' => '请输入订单金额',
|
'amount.require' => '请输入订单金额',
|
||||||
'action.require' => '请选择审核操作',
|
'action.require' => '请选择审核操作',
|
||||||
|
'patient_name.require' => '请输入患者姓名',
|
||||||
|
'phone.require' => '请输入手机号',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $scene = [
|
protected $scene = [
|
||||||
@@ -69,5 +73,6 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'complete' => ['id', 'fulfillment_status'],
|
'complete' => ['id', 'fulfillment_status'],
|
||||||
'submitGancaoRecipel' => ['id'],
|
'submitGancaoRecipel' => ['id'],
|
||||||
'previewGancaoRecipel' => ['id'],
|
'previewGancaoRecipel' => ['id'],
|
||||||
|
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user