This commit is contained in:
Your Name
2026-04-30 14:04:12 +08:00
parent eb782305e6
commit d3003ce0ac
19 changed files with 1560 additions and 313 deletions
+2 -1
View File
@@ -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 *)",
"Bash(php think *)",
"Bash(python3 -c ' *)"
"Bash(python3 -c ' *)",
"Bash(python3 *)"
]
}
}
+14
View File
@@ -30,6 +30,11 @@ export function tcmDiagnosisDetail(params: any) {
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) {
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 })
}
/** 业务订单详情:仅修改关联处方的患者姓名与手机号 */
export function prescriptionOrderPatchPrescriptionPatient(params: {
id: number
patient_name: string
phone: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
}
export function prescriptionOrderAuditPrescription(params: {
id: number
action: 'approve' | 'reject'
@@ -437,16 +437,12 @@
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</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-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">
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">类型:</span>
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
</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">{{ slipView.dose_count || '—' }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
@@ -454,32 +450,7 @@
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}</span>
</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>
@@ -238,7 +238,20 @@
<el-table-column label="收货人" min-width="100">
<template #default="{ row }">
<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>
</el-table-column>
<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">
<!-- 审核与履约可视化步骤条 -->
<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">
<el-steps :active="workflowActiveStep" align-center finish-status="success">
<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 class="text-primary font-medium">{{ detailData.doctor_name }}</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
v-if="detailData.prescription_id && showPrescriptionAuditFilter"
type="primary"
@@ -616,7 +666,24 @@
<el-descriptions-item label="性别 / 年龄">
{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}岁
</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.doctor_name || '—' }}</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="创建时间">{{ formatTime(detailData.create_time) }}</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="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
@@ -1611,6 +1725,47 @@
</template>
</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
v-model="prescriptionViewVisible"
@@ -1694,12 +1849,25 @@
<span class="rx-notice-text">
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
</span>
<span class="rx-notice-meta">
<span>日期{{ rxDateText }}</span>
<span>编号{{ rxSerialText }}</span>
</span>
<div class="rx-notice-meta">
<div class="rx-meta-item">
<span class="rx-meta-label">日期</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 class="rx-info">
<div class="rx-info-row">
<div class="rx-info-cell">
@@ -1770,8 +1938,8 @@
<div class="rx-bottom">
<div class="rx-bot-row rx-bot-row-1">
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
<div class="rx-bot-label">医师</div>
<div class="rx-bot-cell rx-bot-doctor">
<div class="rx-bot-label rx-bot-label--doctor">医师</div>
<div class="rx-bot-doctor-body">
<img
v-if="prescriptionViewData.doctor_signature"
@@ -1779,7 +1947,7 @@
alt="医师签名"
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>
@@ -1788,11 +1956,17 @@
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂数:</span>
<span class="rx-bot-meta-val">{{ prescriptionViewData.dose_count || '—' }}</span>
<span class="rx-bot-meta-key">天数:</span>
<span class="rx-bot-meta-val">
{{
detailData.medication_days != null &&
String(detailData.medication_days).trim() !== ''
? detailData.medication_days + ' 天'
: '—'
}} </span>
</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">{{ rxPerDoseAmount }}克</span>
</div>
</div>
@@ -1980,6 +2154,7 @@ import {
prescriptionOrderComplete,
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderLinkPayOrder,
prescriptionOrderSubmitGancaoRecipel,
prescriptionOrderPreviewGancaoRecipel,
@@ -2703,6 +2878,47 @@ const detailPrescription = computed(() => {
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))
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
@@ -2790,7 +3006,11 @@ function logActionText(act: string) {
fill_tracking: '填快递单',
ship: '确认发货',
withdraw: '撤销',
add_pay_order: '补齐支付单',
link_pay_order: '关联支付单',
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
complete: '完成订单'
}
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) {
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
detailData.value = null
@@ -3878,18 +4167,32 @@ const rxDateText = computed(() => {
return v.prescription_date || '—'
})
const rxSerialText = computed(() => {
/** 处方笺「处方编号」:处方 sn,缺省 visit_no */
const rxPrescriptionSnText = computed(() => {
const v = prescriptionViewData.value as any
if (!v) return '—'
return (
v.order_no ||
v.serial_no ||
v.serial_number ||
v.no ||
v.prescription_no ||
v.visit_no ||
(v.id ? `G${v.id}` : '—')
)
const sn = String(v.sn ?? '').trim()
if (sn) return sn
const vn = String(v.visit_no ?? '').trim()
if (vn) return vn
return '—'
})
/** 流转编号(挂号):预约/挂号主键 */
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(() => {
@@ -4085,6 +4388,22 @@ async function openPrescriptionView(row: any) {
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.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
} else {
prescriptionViewData.value = null
@@ -4389,25 +4708,84 @@ async function downloadPrescriptionSlipPdf() {
overflow: visible;
}
/* === 顶部告知 === */
/* === 顶部告知上下分块避免左右 flex 把左侧提示挤成竖条=== */
.rx-notice {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
align-items: stretch;
gap: 10px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
padding: 16px 10px;
padding: 14px 12px;
border-radius: 2px;
font-size: 12px;
color: #1f1f1f;
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 {
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;
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;
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;
}
.rx-bot-label--doctor {
flex-shrink: 0;
margin-bottom: 0;
margin-right: 6px;
line-height: 1.2;
align-self: center;
}
.rx-bot-doctor {
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
.rx-bot-doctor-body {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
min-height: 36px;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 8px;
min-height: 0;
}
.rx-bot-doctor-name {
font-size: 13px;
color: #1f1f1f;
line-height: 1.2;
white-space: nowrap;
}
.rx-bot-sign-img {
flex-shrink: 0;
max-height: 40px;
max-width: 110px;
object-fit: contain;
+326
View File
@@ -125,6 +125,57 @@
:closable="false"
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 -->
<div v-loading="loading" class="tables-wrap">
<div v-if="!loading && tables.length === 0" class="empty-tip">
@@ -349,6 +400,7 @@ import {
yejiStatsMulti,
yejiStatsOverview,
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
interface DeptOption {
id: number
@@ -440,6 +492,169 @@ const customRange = ref<[string, string] | null>(null)
const tables = ref<YejiTable[]>([])
const leaderboardBlock = ref<LeaderboardPack | null>(null)
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(() => {
for (const t of tables.value) {
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'
return 'roi-bad'
}
function formatAchievement(v: number | null): string {
if (v === null) return '—'
return (v * 100).toFixed(2) + '%'
}
function achievementClass(v: number | null): string {
if (v === null) return ''
if (v >= 1) return 'tp-good'
if (v >= 0.8) return 'tp-mid'
return 'tp-bad'
}
function formatMoneyW(v: number): string {
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return String(v)
}
/** 本地日历日 yyyy-mm-dd(排行榜「今日」、日期快捷与日期控件一致) */
function formatLocalYmd(d: Date): string {
@@ -687,6 +916,7 @@ function formatLocalYmd(d: Date): string {
onMounted(async () => {
await Promise.all([loadDeptOptions(), loadChannelOptions()])
loadData()
loadTargetProgress()
})
</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 {
font-size: 13px;
font-weight: 500;
@@ -1025,6 +1350,7 @@ onMounted(async () => {
background: #f0fdfa;
}
.total-row {
font-weight: 700;
background: #fff8d6;
+95 -31
View File
@@ -4,7 +4,10 @@
<div class="group">
<div class="group__header">
<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">
<el-input-number
v-model="node.target_amount"
@@ -25,7 +28,8 @@
/>
</div>
<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>
@@ -43,22 +47,19 @@
<!-- 子节点表格行 -->
<template v-else>
<div class="row" :data-depth="String(Math.min(depth, 4))">
<!-- 缩进空间 + 连接线 -->
<div class="row__indent" :style="{ width: `${(depth - 1) * 20 + 16}px` }">
<div class="row__connector" />
</div>
<!-- 部门名 -->
<div class="row__dept">
<div class="row__dot" />
<div class="row__dept-text">
<span class="row__name">{{ node.dept_name }}</span>
<span v-if="parentPath" class="row__path">{{ parentPath }}</span>
</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 class="row__amount">
<el-input-number
v-model="node.target_amount"
@@ -71,7 +72,6 @@
/>
</div>
<!-- 备注 -->
<div class="row__remark">
<el-input
v-model="node.remark"
@@ -82,7 +82,6 @@
</div>
</div>
<!-- 递归子节点 -->
<MubiaoDeptNode
v-for="c in node.children"
:key="c.dept_id"
@@ -94,6 +93,7 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { FolderOpened } from '@element-plus/icons-vue'
defineOptions({ name: 'MubiaoDeptNode' })
@@ -124,7 +124,7 @@ const parentPath = computed(() =>
<style lang="scss" scoped>
/* ── 根节点:分组区块 ── */
.group {
border-bottom: 2px solid var(--el-border-color-lighter);
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
@@ -135,14 +135,23 @@ const parentPath = computed(() =>
display: flex;
align-items: center;
gap: 0;
min-height: 52px;
background: linear-gradient(90deg, var(--el-color-primary-light-9) 0%, var(--el-fill-color-extra-light) 60%);
border-bottom: 1px solid var(--el-color-primary-light-7);
min-height: 54px;
background: linear-gradient(90deg,
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;
transition: background 0.15s;
cursor: default;
transition: background 0.18s ease;
&: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;
width: 4px;
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;
margin-right: 12px;
}
@@ -158,40 +167,70 @@ const parentPath = computed(() =>
.group__name {
flex: 0 0 auto;
width: 220px;
font-size: 14px;
font-size: 13.5px;
font-weight: 700;
color: var(--el-color-primary-dark-2);
padding-right: 12px;
white-space: nowrap;
overflow: hidden;
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 {
flex: 0 0 194px;
border-left: 1px solid var(--el-color-primary-light-6);
padding: 8px 12px;
border-left: 1px solid color-mix(in srgb, var(--el-color-primary) 15%, var(--el-border-color-lighter));
padding: 9px 12px;
}
.group__amount-input {
width: 170px;
:deep(.el-input__wrapper) {
border-radius: 6px;
border-radius: 7px;
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 {
flex: 1;
border-left: 1px solid var(--el-color-primary-light-6);
padding: 8px 12px;
border-left: 1px solid color-mix(in srgb, var(--el-color-primary) 15%, var(--el-border-color-lighter));
padding: 9px 12px;
}
.group__badge-wrap {
flex: 0 0 60px;
display: flex;
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 {
@@ -202,10 +241,11 @@ const parentPath = computed(() =>
.row {
display: flex;
align-items: center;
min-height: 48px;
min-height: 46px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background 0.12s;
transition: background 0.12s ease;
padding-right: 16px;
cursor: default;
&:last-child {
border-bottom: none;
@@ -213,13 +253,29 @@ const parentPath = computed(() =>
&:hover {
background: var(--el-fill-color-extra-light);
.row__dot {
transform: scale(1.3);
}
}
/* 深度色阶 */
&[data-depth='1'] { --dot: #67c23a; }
&[data-depth='2'] { --dot: #e6a23c; }
&[data-depth='3'] { --dot: #909399; }
&[data-depth='4'] { --dot: var(--el-border-color-darker); }
&[data-depth='1'] {
--dot: #67c23a;
--connector: color-mix(in srgb, #67c23a 30%, var(--el-border-color));
}
&[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 {
@@ -235,8 +291,8 @@ const parentPath = computed(() =>
top: 0;
bottom: 50%;
width: 10px;
border-left: 1px dashed var(--el-border-color);
border-bottom: 1px dashed var(--el-border-color);
border-left: 1.5px dashed var(--connector, var(--el-border-color));
border-bottom: 1.5px dashed var(--connector, var(--el-border-color));
border-radius: 0 0 0 4px;
}
@@ -255,6 +311,8 @@ const parentPath = computed(() =>
height: 7px;
border-radius: 50%;
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 {
@@ -297,8 +355,14 @@ const parentPath = computed(() =>
.row__amount-input {
width: 170px;
: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
View File
@@ -1,51 +1,46 @@
<template>
<div class="mubiao-page">
<!-- 顶部 Header -->
<div class="header">
<!-- 图标 + 文字 -->
<div class="header__left">
<div class="header__icon">
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar__left">
<div class="toolbar__icon">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="header__text">
<span class="header__title">制定业绩目标</span>
<span class="header__desc">按部门层级设置每月销售目标 0 并保存将删除该记录</span>
<div>
<div class="toolbar__title">制定业绩目标</div>
<div class="toolbar__desc">按部门设置每月销售目标 0 并保存将删除该记录</div>
</div>
</div>
<!-- KPI + 操作 -->
<div class="header__right">
<!-- KPI 胶囊 -->
<div class="kpi-capsule">
<div class="kpi-cell">
<span class="kpi-cell__label">目标月份</span>
<el-date-picker
v-model="yearMonth"
type="month"
value-format="YYYY-MM"
placeholder="选择月份"
:clearable="false"
size="small"
class="kpi-cell__picker"
@change="loadMatrix"
/>
<div class="toolbar__center">
<div class="month-picker-wrap">
<span class="month-picker-wrap__label">目标月份</span>
<el-date-picker
v-model="yearMonth"
type="month"
value-format="YYYY-MM"
placeholder="选择月份"
:clearable="false"
size="default"
class="month-picker"
@change="loadMatrix"
/>
</div>
</div>
<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 class="kpi-sep" />
<div class="kpi-cell">
<span class="kpi-cell__label">部门数量</span>
<span class="kpi-cell__val">{{ deptCount }}</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 class="summary-divider" />
<div class="summary-item">
<span class="summary-item__val primary">¥{{ formatMoneyShort(totalDraft) }}</span>
<span class="summary-item__label">目标合计</span>
</div>
</div>
<!-- 操作按钮 -->
<el-button :loading="loading" @click="loadMatrix">
<el-icon><Refresh /></el-icon>刷新
<el-button circle :loading="loading" @click="loadMatrix" title="刷新">
<el-icon><Refresh /></el-icon>
</el-button>
<el-button
v-perms="['finance.dept_performance_target/batchSave']"
@@ -59,10 +54,9 @@
</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 class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
<p class="empty-state__title">暂无部门数据</p>
@@ -70,25 +64,30 @@
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
</div>
<template v-else>
<!-- 列头 - 严格对齐内容列 -->
<div class="col-head">
<div class="col-head__dept">部门</div>
<div class="col-head__amount">目标金额</div>
<div class="col-head__remark">备注</div>
<div class="col-head__badge" />
</div>
<!-- 数据区 -->
<div class="data-scroll">
<MubiaoDeptNode
<div v-else class="board-grid">
<!-- 根级卡片 -->
<TransitionGroup name="card" tag="div" class="board-grid__inner">
<MubiaoDeptCard
v-for="r in formRows"
:key="r.dept_id"
:key="'root-' + r.dept_id"
: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>
@@ -97,7 +96,7 @@
<script lang="ts" setup name="financeMubiao">
import { computed, onMounted, ref } from '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 {
deptPerformanceTargetBatchSave,
deptPerformanceTargetMonthMatrix,
@@ -153,21 +152,50 @@ function ymNow(): string {
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 loading = ref(false)
const saving = ref(false)
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 deptCount = computed(() => countTree(formRows.value))
function formatMoney(v: number) {
return Number(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function handleDrillIn(node: Row) {
// 已存在则不重复开
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() {
if (!yearMonth.value) return
loading.value = true
drillStack.value = []
drilledId.value = null
try {
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
@@ -189,6 +217,8 @@ async function handleSave() {
})
if (res?.rows) {
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
drillStack.value = []
drilledId.value = null
} else {
await loadMatrix()
}
@@ -208,116 +238,79 @@ onMounted(loadMatrix)
padding: 20px 20px 40px;
}
/* ════════════════════ Header ════════════════════ */
.header {
/* ── 工具栏 ── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
gap: 16px;
padding: 14px 20px;
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);
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
flex-wrap: wrap;
}
.header__left {
.toolbar__left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.header__icon {
.toolbar__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, var(--el-color-primary-light-7), var(--el-color-primary-light-9));
color: var(--el-color-primary);
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-light-3));
color: #fff;
font-size: 19px;
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 {
display: flex;
flex-direction: column;
gap: 2px;
}
.header__title {
font-size: 15px;
.toolbar__title {
font-size: 14px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.3;
}
.header__desc {
font-size: 12px;
.toolbar__desc {
font-size: 11.5px;
color: var(--el-text-color-placeholder);
line-height: 1.3;
line-height: 1.4;
margin-top: 2px;
}
.header__right {
.toolbar__center {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* KPI 胶囊 */
.kpi-capsule {
.month-picker-wrap {
display: flex;
align-items: stretch;
align-items: center;
gap: 8px;
padding: 7px 14px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
overflow: hidden;
}
.kpi-sep {
width: 1px;
background: var(--el-border-color-lighter);
align-self: stretch;
}
.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;
&__label {
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 600;
white-space: nowrap;
}
}
.kpi-cell__label {
font-size: 11px;
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;
.month-picker {
width: 130px;
:deep(.el-input__wrapper) {
box-shadow: none !important;
@@ -327,7 +320,7 @@ onMounted(loadMatrix)
:deep(.el-input__inner) {
font-size: 15px;
font-weight: 600;
font-weight: 700;
color: var(--el-text-color-primary);
cursor: pointer;
}
@@ -337,72 +330,89 @@ onMounted(loadMatrix)
}
}
/* ════════════════════ Panel ════════════════════ */
.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 {
.toolbar__right {
display: flex;
align-items: center;
height: 38px;
background: var(--el-fill-color-light);
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;
gap: 10px;
flex-shrink: 0;
}
/* 与 group/row 列宽严格对应 */
.col-head__dept {
flex: 0 0 220px;
.summary-strip {
display: flex;
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 {
flex: 0 0 194px;
padding-left: 12px;
border-left: 1px solid var(--el-border-color-lighter);
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 16px;
gap: 1px;
.col-head__remark {
flex: 1;
padding-left: 12px;
border-left: 1px solid var(--el-border-color-lighter);
}
&__val {
font-size: 16px;
font-weight: 800;
color: var(--el-text-color-primary);
line-height: 1.2;
letter-spacing: -0.02em;
.col-head__badge {
flex: 0 0 60px;
}
&.primary { color: var(--el-color-primary); }
}
/* 数据滚动区 */
.data-scroll {
max-height: min(72vh, 820px);
overflow-y: auto;
&::-webkit-scrollbar { width: 5px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 3px;
&__label {
font-size: 10.5px;
color: var(--el-text-color-placeholder);
letter-spacing: 0.04em;
}
}
/* ════════════════════ 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 {
display: flex;
flex-direction: column;
@@ -413,12 +423,12 @@ onMounted(loadMatrix)
}
.empty-state__icon {
width: 60px;
height: 60px;
border-radius: 18px;
width: 64px;
height: 64px;
border-radius: 20px;
background: var(--el-fill-color-light);
color: var(--el-text-color-placeholder);
font-size: 28px;
font-size: 30px;
display: flex;
align-items: center;
justify-content: center;
+139 -3
View File
@@ -103,6 +103,15 @@
>
批量指派医助
</el-button>
<el-button
v-perms="['tcm.diagnosis/assign']"
type="warning"
plain
@click="handleBatchCancelAssign"
:disabled="!canBatchCancelAssign"
>
批量取消指派
</el-button>
</div>
<div v-if="selectedIds.length > 0" class="list-selected">已选 {{ selectedIds.length }} </div>
</div>
@@ -263,9 +272,16 @@
<template #dropdown>
<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']) && 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="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="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 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>
@@ -284,12 +300,37 @@
<edit-popup ref="editRef" @success="refreshListAfterPopupSave" />
<detail-popup ref="detailRef" />
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
<appointment-popup ref="appointmentRef" @success="refreshListAfterPopupSave" />
<appointment-popup ref="appointmentRef" @success="handleAppointmentSaved" />
<assistant-watch-call-dialog
v-model="watchCallVisible"
:diagnosis-id="watchCallDiagnosisId"
@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
@@ -564,7 +605,7 @@
</template>
<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 { cancelAppointment } from '@/api/doctor'
import { usePaging } from '@/hooks/usePaging'
@@ -572,7 +613,7 @@ import { getDictData } from '@/api/app'
import { getWeappConfig } from '@/api/channel/weapp'
import feedback from '@/utils/feedback'
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 { useRoute } from 'vue-router'
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
@@ -1116,6 +1157,19 @@ const handleSelectionChange = (selection: any[]) => {
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) => {
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 = () => {
editRef.value.open('add')
}
@@ -1222,6 +1304,7 @@ const handleDelete = async (id: number) => {
const handleRowAction = (cmd: string, row: any) => {
switch (cmd) {
case 'assign': handleSingleAssign(row); break
case 'cancelAssign': handleCancelAssign(row); break
case 'videoQr':
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
@@ -1231,6 +1314,7 @@ const handleRowAction = (cmd: string, row: any) => {
break
case 'confirmQr': handleMiniProgramQRCode(row); break
case 'cancelApt': handleCancelAppointment(row); break
case 'guahaoLogs': openGuahaoLogDrawer(row); break
case 'order': handleCreateOrder(row); break
case 'delete': handleDelete(row.id); break
}
@@ -1309,11 +1393,63 @@ const handleCancelAppointment = async (row: any) => {
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
if (guahaoLogDrawerVisible.value && guahaoLogContextRow.value?.id === row.id) {
await fetchGuahaoLogs(row.id)
}
} catch (e: any) {
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) => {
if (!row.patient_id) {
@@ -33,7 +33,7 @@ class AppointmentController extends BaseAdminController
{
$params = (new AppointmentValidate())->post()->goCheck('create');
$params['assistant_id'] = $this->adminId;
$result = AppointmentLogic::create($params);
$result = AppointmentLogic::create($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AppointmentLogic::getError());
}
@@ -47,7 +47,7 @@ class AppointmentController extends BaseAdminController
public function cancel()
{
$params = (new AppointmentValidate())->post()->goCheck('cancel');
$result = AppointmentLogic::cancel($params);
$result = AppointmentLogic::cancel($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AppointmentLogic::getError());
}
@@ -118,6 +118,16 @@ class DiagnosisController extends BaseAdminController
$result = DiagnosisLogic::detail($params);
return $this->data($result);
}
/**
* @notes 诊单挂号 / 取消挂号 操作日志(谁在何时操作)
*/
public function guahaoLogList()
{
$params = (new DiagnosisValidate())->get()->goCheck('guahaoLogList');
return $this->data(DiagnosisLogic::guahaoLogList((int) $params['id']));
}
/**
* @notes 诊单详情(患者端)
@@ -191,13 +201,15 @@ class DiagnosisController extends BaseAdminController
return $this->fail('诊单ID不能为空');
}
if (!isset($params['assistant_id'])) {
return $this->fail('请选择医助');
if (!array_key_exists('assistant_id', $params)) {
return $this->fail('请选择医助或取消指派');
}
$result = DiagnosisLogic::assign($params);
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());
}
@@ -103,6 +103,26 @@ class PrescriptionOrderController extends BaseAdminController
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()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
@@ -231,7 +231,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($prescriptionIds !== []) {
$prescriptions = \app\common\model\tcm\Prescription::whereIn('id', $prescriptionIds)
->whereNull('delete_time')
->field(['id', 'creator_id', 'doctor_name'])
->field(['id', 'creator_id', 'doctor_name', 'phone'])
->select()
->toArray();
$doctorIds = [];
@@ -242,7 +242,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($rxId > 0) {
$prescriptionCreators[$rxId] = [
'doctor_id' => $doctorId,
'doctor_name' => $doctorName
'doctor_name' => $doctorName,
'phone' => (string) ($rx['phone'] ?? ''),
];
if ($doctorId > 0) {
$doctorIds[] = $doctorId;
@@ -288,9 +289,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$creatorId = (int) ($item['creator_id'] ?? 0);
$item['creator_name'] = $creatorId > 0 ? ($creatorNames[$creatorId] ?? '') : '';
// 添加开方人姓名
// 添加开方人姓名、处方登记手机(与业务订单收货手机比对用)
$rxId = (int) ($item['prescription_id'] ?? 0);
$item['doctor_name'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['doctor_name'] ?? '') : '';
$item['prescription_phone'] = $rxId > 0 ? ($prescriptionCreators[$rxId]['phone'] ?? '') : '';
}
unset($item);
@@ -7,6 +7,7 @@ use app\common\model\doctor\Appointment;
use app\common\model\doctor\Roster;
use app\common\service\doctor\RosterSegmentService;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisGuahaoLog;
use app\common\model\auth\Admin;
use app\api\logic\ChatNotifyLogic;
use app\adminapi\logic\tcm\PrescriptionLogic;
@@ -183,7 +184,7 @@ class AppointmentLogic extends BaseLogic
* @param array $params
* @return array|bool
*/
public static function create(array $params)
public static function create(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
{
try {
Db::startTrans();
@@ -244,6 +245,25 @@ class AppointmentLogic extends BaseLogic
$appointment = Appointment::create($data);
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];
} catch (\Exception $e) {
Db::rollback();
@@ -257,7 +277,7 @@ class AppointmentLogic extends BaseLogic
* @param array $params
* @return bool
*/
public static function cancel(array $params)
public static function cancel(array $params, int $operatorAdminId = 0, array $operatorAdminInfo = [])
{
try {
$appointment = Appointment::findOrEmpty($params['id']);
@@ -279,10 +299,34 @@ class AppointmentLogic extends BaseLogic
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->update_time = time();
$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;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -444,4 +488,37 @@ class AppointmentLogic extends BaseLogic
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**
* - 接诊诊单 = 已完成挂号 status=3,医助 role_id=2;按 TIMESTAMP(appointment_date, appointment_time) 落入统计区间(挂号条数,非订单)。
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag/creator 两套口径均按订单计,非挂号条数。
* - 合计业绩 = 区间内 zyt_tcm_prescription_order.amount 求和(排除已取消(4) 与软删),按 creator_id 归属到「中心」展示部门;
* - 合计业绩 = 区间内 zyt_tcm_prescription_order.amount 求和(排除已取消(4) 与软删),按 dg.assistant_id 归属到「中心」展示部门;
* 无法归属到任一展示部门的金额单列「未归属中心」行;合计行 = 与列表同条件的全表 SUM(不限部门)
* · 时间窗与业绩条件与列表 stats_order_amount_performance 一致;列表若另有患者/单号等筛选或账号数据域收窄,金额可能仍不同
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子**
@@ -348,7 +348,7 @@ class YejiStatsLogic
/**
* 医助排行榜:按展示部门分表,每表内按诊金降序排名。
* 诊金口径与 overview 部门表一致(无渠道=合计业绩/creator;有渠道={渠道}业绩/dict 诊单医助或 tag/creator;二中心在选渠道下注 0
* 诊金口径:无渠道=诊单 dg.assistant_id 业绩(与列表 assistant_id 筛选一致);有渠道={渠道}业绩/dict 诊单医助;二中心在选渠道下注 0。
* 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。
*
* @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
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」医助在该渠道下诊金与进线计 0。')
: '排行诊金 = 合计业绩口径(creator,与部门表「合计业绩」一致)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。';
: '排行诊金 = 处方订单列表 assistant_id 口径(诊单医助业绩,与列表按医助筛选一致)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。';
$leaderboards = [];
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>
*/
@@ -1100,19 +1100,23 @@ class YejiStatsLogic
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->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);
$query->field(['o.creator_id', 'SUM(o.amount) AS amt'])
->group('o.creator_id');
$query->field(['dg.assistant_id', 'SUM(o.amount) AS amt'])
->group('dg.assistant_id');
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('o.creator_id', array_keys($tagAssistantIds));
$query->whereIn('dg.assistant_id', array_keys($tagAssistantIds));
}
$rows = $query->select()->toArray();
@@ -1121,11 +1125,11 @@ class YejiStatsLogic
}
$byAdmin = [];
foreach ($rows as $r) {
$cid = (int) ($r['creator_id'] ?? 0);
if ($cid <= 0) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$cid] = ($byAdmin[$cid] ?? 0) + (float) $r['amt'];
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt'];
}
return $byAdmin;
@@ -16,6 +16,7 @@ namespace app\adminapi\logic\tcm;
use app\common\logic\BaseLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisGuahaoLog;
use app\common\model\tcm\DiagnosisAssignLog;
use app\common\model\tcm\ImChatMessage;
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),
'usage_notes' => $params['usage_notes'] ?? $prescription->usage_notes,
'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),
'visible_role_ids' => array_key_exists('visible_role_ids', $params)
? self::normalizeVisibleRoleIds($params['visible_role_ids'])
: (string) ($prescription->visible_role_ids ?? ''),
// 后台编辑保存后视为手工处方(原系统代开标记清除)
'is_system_auto' => 0,
// 开方医生修改后重新进入待审核
'audit_status' => 0,
'audit_time' => null,
@@ -13,6 +13,7 @@ use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\service\ExpressTrackService;
use app\common\service\gancao\GancaoScmRecipelService;
@@ -706,9 +707,176 @@ class PrescriptionOrderLogic
}
$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;
}
/**
* 业务订单详情场景:仅更新关联处方的患者姓名与手机号(不重置消费者处方审核、不改变其它处方字段)
*/
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;未配置密钥时返回官网链接)
*
@@ -93,6 +93,11 @@ class DiagnosisValidate extends BaseValidate
->append('id_card', 'require|length:15,18');
}
public function sceneGuahaoLogList()
{
return $this->only(['id']);
}
protected function checkDiagnosis($value)
{
$diagnosis = Diagnosis::findOrEmpty($value);
@@ -32,6 +32,8 @@ class PrescriptionOrderValidate extends BaseValidate
'action' => 'require|in:approve,reject',
'remark' => 'max:500',
'fulfillment_status' => 'require|integer|in:3,7,8,9,10,11,12',
'patient_name' => 'require|max:50',
'phone' => 'require|max:20',
];
protected $message = [
@@ -43,6 +45,8 @@ class PrescriptionOrderValidate extends BaseValidate
'fee_type.require' => '请选择费用类别',
'amount.require' => '请输入订单金额',
'action.require' => '请选择审核操作',
'patient_name.require' => '请输入患者姓名',
'phone.require' => '请输入手机号',
];
protected $scene = [
@@ -69,5 +73,6 @@ class PrescriptionOrderValidate extends BaseValidate
'complete' => ['id', 'fulfillment_status'],
'submitGancaoRecipel' => ['id'],
'previewGancaoRecipel' => ['id'],
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
];
}