更新
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user