Compare commits

...
Author SHA1 Message Date
longandCursor 679e61057b fix(stats/self_input): 唯一索引含 delete_time,软删后可重新录入
去掉「同日+同渠道」全局唯一,回到按录入人判重;唯一索引补 delete_time 避免软删占键导致 1062;错误提示带冲突记录 ID,迁移脚本兼容多次执行。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 17:01:58 +08:00
long 003810114b debug 2026-05-22 16:45:28 +08:00
longandCursor e4f181e4fe fix(stats/self_input): 渠道字典、财务脱敏与同日同渠道全局唯一
自媒体来源改推广渠道字典;非白名单隐藏账户消耗/ROI;部门展示完整路径;业绩与消耗按日期+渠道全局去重。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 12:02:12 +08:00
longandCursor bba989c0af fix(stats/self_input): 修复保存不入库/路由 404,补全部门与自媒体来源下拉
- 模型去掉 defaultSoftDelete=0,避免 ThinkPHP 软删过滤导致新增记录"隐形"
- 菜单 paths 改相对路径并新增「自录数据统计」目录,迁移已有菜单
- Lists/Overview 补部门关联、支持按部门筛选
- 数据权限改由 self_input_stats_view_all_roles 白名单 + DataScope 控制,
  编辑/删除完全交给按钮权限校验(移除"仅本人可改"硬限制)
- 新增 /stats.self_input/mediaSourceOptions 接口,统计页/账户消耗页/录入弹窗
  共用 useMediaSourceOptions + MediaSourceSelect,去重动态加载

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 11:47:15 +08:00
longandCursor 183b6a1acf feat(stats): 新增自录转化统计模块
支持后台用户手动录入个人业绩与账户消耗,独立计算转化指标,接入 DataScope 数据权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:17:16 +08:00
Your Name 49c8c95966 更新 2026-05-21 14:41:43 +08:00
Your Name c7c264497e 修复bug 2026-05-21 14:38:31 +08:00
Your Name ba1c067dce Merge branch 'master' into bug-5-20 2026-05-20 17:15:51 +08:00
Your Name 5e96fda88f 更新 2026-05-20 17:14:27 +08:00
Your Name 93e6d02ce4 更新 2026-05-20 16:53:19 +08:00
Your Name 752eddb0ba 更新 2026-05-20 16:51:12 +08:00
Your Name 1005859404 新增 2026-05-20 15:43:47 +08:00
Your Name ea6c73c063 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-20 15:40:36 +08:00
Your Name 3e4039efb0 更新 2026-05-20 15:40:01 +08:00
long c9ad4ae2ed Update order_list.vue
增加 用法计量 数据权限
2026-05-20 14:29:22 +08:00
Your Name abf8026d45 更新 2026-05-20 10:15:26 +08:00
long 26b442b5da debug
完善自媒体渠道修改
2026-05-19 16:39:29 +08:00
long 1cb00e9fbe add assistant_performance
新增医助个人业绩
执行:add_assistant_performance_menu.sql
2026-05-19 14:41:14 +08:00
Your Name 9208e7eaaa 更新 2026-05-19 11:40:56 +08:00
Your Name a1392f9491 更新 2026-05-19 11:18:23 +08:00
Your Name 2ea0e1d54b 更新 2026-05-19 11:11:37 +08:00
Your Name d96fa6f4a1 更新 2026-05-19 10:51:44 +08:00
Your Name 0fb51e12d1 更新 2026-05-19 10:18:00 +08:00
Your Name 4f9494db0b Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 18:12:23 +08:00
Your Name 4e87b080a0 更新 2026-05-18 18:12:04 +08:00
long 2f3038aba1 Merge branch '20260518-long' 2026-05-18 18:08:46 +08:00
long 131fb7d9c7 Update edit.vue
增加:账户消耗列表-编辑可以修改自媒体
2026-05-18 18:08:35 +08:00
Your Name 762a724204 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 18:02:15 +08:00
long 8090e0aab3 Merge branch '20260518-long' 2026-05-18 17:43:25 +08:00
long 9df6330d41 update
修改未服务天数判断条件
2026-05-18 17:42:24 +08:00
Your Name fd63e23eaf 更新 2026-05-18 16:56:00 +08:00
gr c1143f4629 geng
Merge branch 'master' of https://gitee.com/v_cms/zyt
2026-05-18 16:07:03 +08:00
Your Name 149fdffd49 更新 2026-05-18 15:59:22 +08:00
Your Name 439d5a3ec9 更新 2026-05-18 15:57:09 +08:00
long 9e24d49210 Merge branch '20260518-long' 2026-05-18 14:24:52 +08:00
long 90ac43fc82 update
更新自媒体渠道标签
2026-05-18 14:24:41 +08:00
gr 8b6b709987 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-18 11:36:21 +08:00
Your Name fb6ca9ce85 更新 2026-05-18 11:23:01 +08:00
Your Name 08290b329e 更新 2026-05-18 11:20:24 +08:00
long 1d66f84bd3 Update PatientOrderList.vue
业务订单增加:服务套餐
2026-05-18 10:16:31 +08:00
Your Name a2d9c1a03f Merge branch 'chufan-2026-5-16' 2026-05-18 09:43:48 +08:00
Your Name d79db88349 更新 2026-05-18 09:42:11 +08:00
Your Name 9f5fb650af 新增功能 2026-05-16 16:08:54 +08:00
Your Name 15b4339c90 更新 2026-05-16 16:05:29 +08:00
gr 5ce331394d Merge branch 'master' of https://gitee.com/v_cms/zyt
你好
2026-05-14 16:27:49 +08:00
gr 800c6af7f1 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 11:31:40 +08:00
gr 876ef0f8a0 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-14 11:02:29 +08:00
gr fdcfa30810 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 18:15:10 +08:00
gr 4caa98af3a Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 11:27:37 +08:00
gr bb87e4fb4c Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-05-12 10:59:56 +08:00
gr 039105580c Mergt branch 'master' of https://gitee.com/v_cms/zyt 2026-05-11 17:49:51 +08:00
gr f47f431bdf 更新 2026-05-11 17:49:38 +08:00
5914 changed files with 7490 additions and 1103 deletions
+50
View File
@@ -0,0 +1,50 @@
import request from '@/utils/request'
export function getSelfInputOverview(params: any) {
return request.get({ url: '/stats.self_input/overview', params })
}
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
export function getSelfInputMediaSourceOptions() {
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
}
export function personalYejiLists(params: any) {
return request.get({ url: '/stats.personal_yeji/lists', params })
}
export function personalYejiAdd(data: any) {
return request.post({ url: '/stats.personal_yeji/add', data })
}
export function personalYejiEdit(data: any) {
return request.post({ url: '/stats.personal_yeji/edit', data })
}
export function personalYejiDetail(params: any) {
return request.get({ url: '/stats.personal_yeji/detail', params })
}
export function personalYejiDelete(params: any) {
return request.post({ url: '/stats.personal_yeji/delete', params })
}
export function personalAccountCostLists(params: any) {
return request.get({ url: '/stats.personal_account_cost/lists', params })
}
export function personalAccountCostAdd(data: any) {
return request.post({ url: '/stats.personal_account_cost/add', data })
}
export function personalAccountCostEdit(data: any) {
return request.post({ url: '/stats.personal_account_cost/edit', data })
}
export function personalAccountCostDetail(params: any) {
return request.get({ url: '/stats.personal_account_cost/detail', params })
}
export function personalAccountCostDelete(params: any) {
return request.post({ url: '/stats.personal_account_cost/delete', params })
}
+9
View File
@@ -179,3 +179,12 @@ export function commissionSettlementConfirmFinalize(params: Record<string, any>)
export function commissionSettlementConfirmRevoke(params: Record<string, any>) { export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params }) return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
} }
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
start_date?: string
end_date?: string
}) {
return request.get({ url: '/stats.assistantPerformance/overview', params })
}
+27 -1
View File
@@ -319,6 +319,16 @@ export function prescriptionEdit(params: any) {
return request.post({ url: '/tcm.prescription/edit', params }) return request.post({ url: '/tcm.prescription/edit', params })
} }
/** 仅修正处方笺患者姓名、手机号与性别(不改审核状态),写入业务订单日志表 */
export function prescriptionPatchPatient(params: {
id: number
patient_name: string
phone: string
gender: number
}) {
return request.post({ url: '/tcm.prescription/patchPatient', params })
}
// 删除处方 // 删除处方
export function prescriptionDelete(params: { id: number }) { export function prescriptionDelete(params: { id: number }) {
return request.post({ url: '/tcm.prescription/delete', params }) return request.post({ url: '/tcm.prescription/delete', params })
@@ -452,15 +462,26 @@ export function prescriptionOrderAddPayOrder(params: {
order_type: number order_type: number
pay_amount: number pay_amount: number
pay_remark?: string pay_remark?: string
completion_request?: number
}) { }) {
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params }) return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
} }
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */ /** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) { export function prescriptionOrderLinkPayOrder(params: {
id: number
pay_order_id?: number
pay_order_ids?: number[]
completion_request?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params }) return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
} }
/** 已发货/已签收:仅提交完单申请(不新增/关联支付单) */
export function prescriptionOrderRequestCompletion(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
}
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */ /** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) { export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/tcm.prescriptionOrder/complete', params }) return request.post({ url: '/tcm.prescriptionOrder/complete', params })
@@ -490,6 +511,11 @@ export function prescriptionOrderUpdateAmount(params: { id: number; amount: numb
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params }) return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
} }
/** 设置发货类型:gancao 甘草药房 / direct 洛阳药房 */
export function prescriptionOrderSetShipMode(params: { id: number; ship_mode: 'gancao' | 'direct' }) {
return request.post({ url: '/tcm.prescriptionOrder/setShipMode', params })
}
// ========== 处方库 ========== // ========== 处方库 ==========
// 处方库列表 // 处方库列表
File diff suppressed because it is too large Load Diff
+582 -95
View File
@@ -216,6 +216,15 @@
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']"> <el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
查看 查看
</el-button> </el-button>
<el-button
v-if="Number(row.void_status) !== 1"
type="primary"
link
v-perms="['tcm.prescription/patchPatient']"
@click="openPatchPatientDialog(row)"
>
改姓名手机
</el-button>
<el-button <el-button
v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1" v-if="!Number(row.has_prescription_order) && Number(row.void_status) !== 1"
type="success" type="success"
@@ -398,20 +407,35 @@
</div> </div>
<div class="rx-herbs"> <div class="rx-herbs">
<div <template v-if="slipMainHerbs.length">
v-for="(h, i) in slipHerbsList" <div class="rx-herb-section-label">主方</div>
:key="i" <div
class="rx-herb-cell" v-for="(h, i) in slipMainHerbs"
> :key="'main-' + i"
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span> class="rx-herb-cell"
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span> >
</div> <span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
<template v-if="slipAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in slipAuxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
</div> </div>
</div> </div>
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 --> <!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
<div class="rx-text"> <div class="rx-text">
<p>服法{{ rxUsageText }}</p> <p>主方服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">辅方服法{{ rxAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p> <p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p> <p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn"> <p v-if="rxPharmacyRemarkText" class="rx-text-warn">
@@ -640,53 +664,87 @@
<el-form-item label="药材配方" prop="herbs" required> <el-form-item label="药材配方" prop="herbs" required>
<div class="w-full"> <div class="w-full">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<el-button type="primary" size="small" @click="addHerb"> <el-button
添加药材 type="success"
</el-button> size="small"
<el-button type="success" size="small" @click="openLibraryDialog"> v-perms="['cf.prescription/add', 'cf.prescription/edit', 'tcm.prescriptionLibrary/lists']"
@click="openLibraryDialog"
>
从处方库导入 从处方库导入
</el-button> </el-button>
</div>
<div class="mt-2">
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog"> <el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
导入药方 导入药方
</el-button> </el-button>
<span class="text-xs text-gray-500 ml-2 align-middle"> <span class="text-xs text-gray-500">
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入 粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
</span> </span>
</div> </div>
<el-table :data="editForm.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="row.name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row, $index }">
<el-input-number
v-model="row.dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="removeHerb($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table> <div class="herb-formula-block mt-3">
<div class="herb-formula-block__head">
<el-tag type="primary" size="small">主方</el-tag>
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
</div>
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="editForm.herbs[row.index].dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="herb-formula-block mt-4">
<div class="herb-formula-block__head">
<el-tag type="warning" size="small">辅方</el-tag>
<el-button type="primary" size="small" plain @click="addHerb('辅方')">
添加辅方药材
</el-button>
</div>
<el-table :data="auxHerbRows" class="mt-2" border empty-text="暂无辅方药材">
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="editForm.herbs[row.index].dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-alert <el-alert
v-if="editMode === 'edit'" v-if="editMode === 'edit'"
type="warning" type="warning"
@@ -769,8 +827,10 @@
<el-option label="汤剂" value="汤剂" /> <el-option label="汤剂" value="汤剂" />
</el-select> </el-select>
</el-form-item> </el-col> </el-form-item> </el-col>
</el-row>
<!-- 用量字段 --> <div class="usage-formula-title usage-formula-title--main">主方用法</div>
<el-row :gutter="20">
<el-col :span="8"> <el-col :span="8">
<el-form-item label="用量" prop="dosage_amount"> <el-form-item label="用量" prop="dosage_amount">
<!-- 浓缩水丸:1-10g 下拉选择 --> <!-- 浓缩水丸:1-10g 下拉选择 -->
@@ -873,6 +933,97 @@
</el-col> </el-col>
</el-row> </el-row>
<template v-if="auxHerbRows.length > 0">
<div class="usage-formula-title usage-formula-title--aux">辅方用法</div>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="用量">
<el-select
v-if="editForm.prescription_type === '浓缩水丸'"
v-model="editForm.aux_usage.dosage_amount"
placeholder="请选择用量"
class="w-full"
>
<el-option v-for="n in 10" :key="n" :label="n + 'g'" :value="n" />
</el-select>
<el-select
v-else-if="editForm.prescription_type === '饮片'"
v-model="editForm.aux_usage.dosage_amount"
placeholder="请选择用量"
class="w-full"
>
<el-option label="50ml" :value="50" />
<el-option label="100ml" :value="100" />
<el-option label="120ml" :value="120" />
<el-option label="150ml" :value="150" />
<el-option label="180ml" :value="180" />
<el-option label="200ml" :value="200" />
<el-option label="250ml" :value="250" />
</el-select>
<el-input-number
v-else
v-model="editForm.aux_usage.dosage_amount"
:min="0"
:precision="2"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
<el-form-item label=" " label-width="12px">
<el-select
v-model="editForm.aux_usage.dosage_bag_count"
placeholder="请选择袋数"
class="w-[120px]"
>
<el-option label="1袋" :value="1" />
<el-option label="2袋" :value="2" />
<el-option label="3袋" :value="3" />
<el-option label="4袋" :value="4" />
<el-option label="5袋" :value="5" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
<el-form-item label="是否代煎">
<el-radio-group v-model="editForm.aux_usage.need_decoction">
<el-radio :label="true">代煎</el-radio>
<el-radio :label="false">不代煎</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
<el-form-item label="每贴出包数">
<el-select v-model="editForm.aux_usage.bags_per_dose" placeholder="请选择" class="w-full">
<el-option v-for="n in 9" :key="n" :label="n + '包'" :value="n" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="每天几次">
<el-input-number
v-model="editForm.aux_usage.times_per_day"
:min="1"
:max="6"
class="!w-full"
placeholder="每天服用次数"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用天数">
<el-input-number
v-model="editForm.aux_usage.usage_days"
:min="1"
:max="365"
placeholder="服用天数"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
</template>
<el-form-item label="用法" prop="usage_instruction"> <el-form-item label="用法" prop="usage_instruction">
<el-input <el-input
v-model="editForm.usage_instruction" v-model="editForm.usage_instruction"
@@ -980,6 +1131,48 @@
</template> </template>
</el-drawer> </el-drawer>
<!-- 修正处方笺姓名 / 手机号(zyt_tcm_prescription,写入订单日志) -->
<el-dialog
v-model="patchPatientVisible"
title="修正姓名、性别与手机号"
width="440px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetPatchPatientForm"
>
<el-form
ref="patchPatientFormRef"
:model="patchPatientForm"
:rules="patchPatientRules"
label-width="88px"
>
<el-form-item label="处方编号">
<span class="text-gray-700">#{{ patchPatientForm.id || '—' }}</span>
</el-form-item>
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="patchPatientForm.patient_name" maxlength="50" show-word-limit placeholder="处方笺展示姓名" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="patchPatientForm.gender">
<el-radio :label="1">男</el-radio>
<el-radio :label="0">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="patchPatientForm.phone" maxlength="20" placeholder="处方笺展示手机号" />
</el-form-item>
</el-form>
<p class="text-xs text-gray-500 -mt-2 mb-2">
仅更新处方表 patient_name、gender、phone,不改变审核状态;记录写入业务订单操作日志(有关联订单时可在此订单日志中查看)。
</p>
<template #footer>
<el-button @click="patchPatientVisible = false">取消</el-button>
<el-button type="primary" :loading="patchPatientSubmitLoading" @click="submitPatchPatient">
保存
</el-button>
</template>
</el-dialog>
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) --> <!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
<el-dialog <el-dialog
v-model="createOrderVisible" v-model="createOrderVisible"
@@ -1314,16 +1507,17 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 从处方库导入(与诊间 tcm-prescription 一致,按当前处方开方医师 creator_id 筛选) --> <!-- 从处方库导入:当前开方医师的全部模板 + 所有人可见的公共模板 -->
<el-dialog <el-dialog
v-model="showLibraryDialog" v-model="showLibraryDialog"
title="从处方库导入" title="从处方库导入"
width="800px" width="800px"
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<div class="mb-4"> <div class="mb-4 flex gap-3">
<el-input <el-input
v-model="librarySearchName" v-model="librarySearchName"
class="flex-1"
placeholder="请输入处方名称搜索" placeholder="请输入处方名称搜索"
clearable clearable
@keyup.enter="searchLibrary" @keyup.enter="searchLibrary"
@@ -1333,6 +1527,24 @@
<el-button :icon="Search" @click="searchLibrary" /> <el-button :icon="Search" @click="searchLibrary" />
</template> </template>
</el-input> </el-input>
<el-select
v-model="librarySearchFormulaType"
placeholder="处方类型"
clearable
class="w-[140px]"
@change="searchLibrary"
>
<el-option label="全部" value="" />
<el-option label="主方" value="主方" />
<el-option label="辅方" value="辅方" />
</el-select>
</div>
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
<el-radio-group v-model="libraryImportMode">
<el-radio label="replace">覆盖现有药材</el-radio>
<el-radio label="append">追加到末尾</el-radio>
</el-radio-group>
</div> </div>
<el-table <el-table
@@ -1343,6 +1555,13 @@
@row-click="handleSelectLibraryRow" @row-click="handleSelectLibraryRow"
> >
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip /> <el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
<el-table-column label="处方类型" width="90">
<template #default="{ row }">
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
{{ row.formula_type || '主方' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="药材数量" width="100"> <el-table-column label="药材数量" width="100">
<template #default="{ row }"> <template #default="{ row }">
{{ row.herbs?.length || 0 }}味 {{ row.herbs?.length || 0 }}味
@@ -1356,6 +1575,13 @@
<span v-else class="text-gray-400">暂无药材</span> <span v-else class="text-gray-400">暂无药材</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="是否公开" width="110">
<template #default="{ row }">
<el-tag :type="row.is_public ? 'success' : 'info'" size="small">
{{ row.is_public ? '所有人可见' : '仅自己可见' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" width="100" /> <el-table-column label="创建人" prop="creator_name" width="100" />
<el-table-column label="操作" width="100" fixed="right"> <el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
@@ -1444,6 +1670,7 @@ import {
prescriptionLists, prescriptionLists,
prescriptionAdd, prescriptionAdd,
prescriptionEdit, prescriptionEdit,
prescriptionPatchPatient,
prescriptionDelete, prescriptionDelete,
prescriptionAudit, prescriptionAudit,
prescriptionDetail, prescriptionDetail,
@@ -1470,6 +1697,119 @@ import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue')) const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方'
type HerbRow = { name: string; dosage: number; formula_type: FormulaType }
type AuxUsageForm = {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
}
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
function normalizeAuxUsageForm(raw: unknown, prescriptionType: string): AuxUsageForm {
const base = defaultAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
}
}
function buildUsageSegmentText(
usage: {
prescription_type?: string
dosage_amount?: number | null
dosage_unit?: string
dosage_bag_count?: number
times_per_day?: number
usage_way?: string
usage_time?: string
},
fallbackWay?: string,
fallbackTime?: string
): string {
const pt = usage.prescription_type || '浓缩水丸'
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
const usageWay = usage.usage_way || fallbackWay || '温水送服'
const usageTime = usage.usage_time || fallbackTime || ''
const seg: string[] = []
seg.push(`每天${times}次`)
if (pt === '浓缩水丸') {
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
seg.push(`一次${bags}袋`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
}
function normalizeFormulaType(v: unknown): FormulaType {
return v === '辅方' ? '辅方' : '主方'
}
function normalizeHerbRow(raw: any): HerbRow {
return {
name: String(raw?.name ?? '').trim(),
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
}
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => normalizeHerbRow(x))
}
return []
}
/** 与 server/config/project.php prescription_audit_roles 保持一致 */ /** 与 server/config/project.php prescription_audit_roles 保持一致 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3] const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
@@ -1531,6 +1871,66 @@ const auditTargetId = ref(0)
const auditRemark = ref('') const auditRemark = ref('')
const auditLoading = ref(false) const auditLoading = ref(false)
/** 修正处方笺显示用姓名 / 手机号 */
const patchPatientVisible = ref(false)
const patchPatientSubmitLoading = ref(false)
const patchPatientFormRef = ref<FormInstance>()
const patchPatientForm = reactive({
id: 0,
patient_name: '',
gender: 1 as 0 | 1,
phone: ''
})
const patchPatientRules: FormRules = {
patient_name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
function openPatchPatientDialog(row: Record<string, unknown>) {
patchPatientForm.id = Number(row.id) || 0
patchPatientForm.patient_name = String(row.patient_name ?? '').trim()
const g = Number(row.gender)
patchPatientForm.gender = g === 0 || g === 1 ? (g as 0 | 1) : 1
patchPatientForm.phone = String(row.phone ?? '').trim()
patchPatientVisible.value = true
}
function resetPatchPatientForm() {
patchPatientForm.id = 0
patchPatientForm.patient_name = ''
patchPatientForm.gender = 1
patchPatientForm.phone = ''
patchPatientFormRef.value?.clearValidate()
}
async function submitPatchPatient() {
const form = patchPatientFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
patchPatientSubmitLoading.value = true
try {
await prescriptionPatchPatient({
id: patchPatientForm.id,
patient_name: patchPatientForm.patient_name.trim(),
phone: patchPatientForm.phone.trim(),
gender: patchPatientForm.gender
})
feedback.msgSuccess('已保存')
patchPatientVisible.value = false
getLists()
} catch (e: unknown) {
const msg = e && typeof e === 'object' && 'msg' in e ? String((e as { msg?: string }).msg) : ''
feedback.msgError(msg || '保存失败')
} finally {
patchPatientSubmitLoading.value = false
}
}
/** 从消费者处方创建订单 */ /** 从消费者处方创建订单 */
const createOrderVisible = ref(false) const createOrderVisible = ref(false)
/** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */ /** 创建订单分步向导:0 患者与收货 / 1 服务与支付单 / 2 金额与确认 */
@@ -1811,7 +2211,13 @@ const createOrderHerbSummary = computed(() => {
const row = createOrderPrescription.value const row = createOrderPrescription.value
const herbs = normalizeSlipHerbs(row?.herbs) const herbs = normalizeSlipHerbs(row?.herbs)
if (!herbs.length) return '暂无药材明细' if (!herbs.length) return '暂无药材明细'
const s = herbs.map((h) => `${h.name} ${h.dosage}g`).join('、') const main = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
const aux = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
const fmt = (list: HerbRow[]) => list.map((h) => `${h.name} ${h.dosage}g`).join('、')
const parts: string[] = []
if (main.length) parts.push(`主方:${fmt(main)}`)
if (aux.length) parts.push(`辅方:${fmt(aux)}`)
const s = parts.join('')
return s.length > 120 ? `${s.slice(0, 120)}…` : s return s.length > 120 ? `${s.slice(0, 120)}…` : s
}) })
@@ -2051,9 +2457,27 @@ const editReviveHint = computed(() => {
const slipHerbsList = computed(() => { const slipHerbsList = computed(() => {
const h = slipView.value?.herbs const h = slipView.value?.herbs
return Array.isArray(h) ? h : [] return Array.isArray(h) ? (h as HerbRow[]) : []
}) })
const slipMainHerbs = computed(() =>
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
)
const slipAuxHerbs = computed(() =>
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
)
const mainHerbRows = computed(() =>
editForm.herbs
.map((herb, index) => ({ herb, index }))
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '主方')
)
const auxHerbRows = computed(() =>
editForm.herbs
.map((herb, index) => ({ herb, index }))
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
)
const slipDietaryText = computed(() => { const slipDietaryText = computed(() => {
const d = slipView.value?.dietary_taboo const d = slipView.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、') if (Array.isArray(d)) return d.filter(Boolean).join('、')
@@ -2139,23 +2563,26 @@ const rxUsageText = computed(() => {
const v = slipView.value as any const v = slipView.value as any
if (!v) return '—' if (!v) return '—'
if (v.usage_text) return v.usage_text if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3 return buildUsageSegmentText(v)
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10 })
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服' const rxAuxUsageText = computed(() => {
const usageTime = v.usage_time || '' const v = slipView.value as any
const seg: string[] = [] if (!v || !slipAuxHerbs.value.length) return ''
seg.push(`每天${times}次`) const aux = normalizeAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') { return buildUsageSegmentText(
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1 {
seg.push(`一次${bags}袋`) prescription_type: v.prescription_type,
seg.push(`每袋${amount}${unit}`) dosage_amount: aux.dosage_amount,
} else { dosage_unit: v.dosage_unit,
seg.push(`一次${amount}${unit}`) dosage_bag_count: aux.dosage_bag_count,
} times_per_day: aux.times_per_day,
seg.push(usageWay) usage_way: v.usage_way,
if (usageTime) seg.push(usageTime) usage_time: v.usage_time
return seg.join(', ') },
v.usage_way,
v.usage_time
)
}) })
const rxAdviceText = computed(() => { const rxAdviceText = computed(() => {
@@ -2350,7 +2777,7 @@ const editForm = reactive({
pulse: '', pulse: '',
pulse_condition: '', pulse_condition: '',
clinical_diagnosis: '', clinical_diagnosis: '',
herbs: [] as Array<{ name: string; dosage: number }>, herbs: [] as HerbRow[],
dose_count: 7, dose_count: 7,
dose_unit: '剂', dose_unit: '剂',
usage_days: 7, usage_days: 7,
@@ -2376,7 +2803,8 @@ const editForm = reactive({
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */ /** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
business_prescription_audit_rejected: 0, business_prescription_audit_rejected: 0,
business_prescription_audit_remark: '', business_prescription_audit_remark: '',
times_per_day: 2 times_per_day: 2,
aux_usage: defaultAuxUsage('浓缩水丸')
}) })
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */ /** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
@@ -2467,6 +2895,7 @@ watch(() => editForm.prescription_type, (newType) => {
editForm.need_decoction = false editForm.need_decoction = false
editForm.bags_per_dose = 1 editForm.bags_per_dose = 1
} }
Object.assign(editForm.aux_usage, defaultAuxUsage(newType))
}) })
// 表单验证规则 // 表单验证规则
@@ -2695,15 +3124,15 @@ function slipAgeText(age: unknown) {
return `${age}岁` return `${age}岁`
} }
function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> { function herbValidationLabel(globalIndex: number): string {
if (!raw) return [] const herb = editForm.herbs[globalIndex]
if (Array.isArray(raw)) { if (!herb) return `第${globalIndex + 1}味`
return raw.map((x: any) => ({ const ft = normalizeFormulaType(herb.formula_type)
name: String(x?.name ?? '').trim(), let n = 0
dosage: Number(x?.dosage) || 0 for (let i = 0; i <= globalIndex; i++) {
})) if (normalizeFormulaType(editForm.herbs[i]?.formula_type) === ft) n++
} }
return [] return `${ft}第${n}味`
} }
function listRxOrderWarnings(row: any): string[] { function listRxOrderWarnings(row: any): string[] {
@@ -2926,6 +3355,8 @@ const showLibraryDialog = ref(false)
const libraryLoading = ref(false) const libraryLoading = ref(false)
const libraryList = ref<any[]>([]) const libraryList = ref<any[]>([])
const librarySearchName = ref('') const librarySearchName = ref('')
const librarySearchFormulaType = ref('')
const libraryImportMode = ref<'replace' | 'append'>('replace')
const libraryPage = ref(1) const libraryPage = ref(1)
const libraryPageSize = ref(15) const libraryPageSize = ref(15)
const libraryTotal = ref(0) const libraryTotal = ref(0)
@@ -2970,8 +3401,8 @@ const loadLibraryList = async () => {
page_no: libraryPage.value, page_no: libraryPage.value,
page_size: libraryPageSize.value, page_size: libraryPageSize.value,
prescription_name: librarySearchName.value, prescription_name: librarySearchName.value,
is_public: '', formula_type: librarySearchFormulaType.value,
creator_id: doctorId prescribing_creator_id: doctorId
}) })
libraryList.value = res?.lists || [] libraryList.value = res?.lists || []
libraryTotal.value = res?.count || 0 libraryTotal.value = res?.count || 0
@@ -3006,18 +3437,33 @@ const handleImportLibrary = (row: any) => {
feedback.msgWarning('该处方没有药材信息') feedback.msgWarning('该处方没有药材信息')
return return
} }
const imported = JSON.parse(JSON.stringify(row.herbs)) as Array<{ name: string; dosage: number }> const formulaType = normalizeFormulaType(row.formula_type)
editForm.herbs = imported const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
...normalizeHerbRow(h),
formula_type: formulaType
}))
if (libraryImportMode.value === 'replace') {
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
editForm.herbs = [...kept, ...imported]
} else {
editForm.herbs.push(...imported)
}
const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : ''
if (findDuplicateHerbNamesLocal().length) { if (findDuplicateHerbNamesLocal().length) {
feedback.msgWarning('导入的处方中存在重复药名,请合并剂量或删除多余行') feedback.msgWarning(
`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材。存在重复药名,请合并剂量或删除多余行`
)
} else {
feedback.msgSuccess(`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材`)
} }
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${imported.length}味药材`)
showLibraryDialog.value = false showLibraryDialog.value = false
} }
watch(showLibraryDialog, (open) => { watch(showLibraryDialog, (open) => {
if (open) { if (open) {
librarySearchName.value = '' librarySearchName.value = ''
librarySearchFormulaType.value = ''
libraryImportMode.value = 'replace'
libraryPage.value = 1 libraryPage.value = 1
loadLibraryList() loadLibraryList()
} }
@@ -3149,7 +3595,7 @@ async function handlePasteRecipeImport() {
} }
pasteRecipeImportLoading.value = true pasteRecipeImportLoading.value = true
try { try {
const resolved: Array<{ name: string; dosage: number }> = [] const resolved: HerbRow[] = []
const skippedNames: string[] = [] const skippedNames: string[] = []
for (const row of parsed) { for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(row.name) const name = await resolvePasteHerbNameFromLibrary(row.name)
@@ -3157,7 +3603,7 @@ async function handlePasteRecipeImport() {
skippedNames.push(row.name.trim()) skippedNames.push(row.name.trim())
continue continue
} }
resolved.push({ name, dosage: row.dosage }) resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
} }
const skippedUnique = [...new Set(skippedNames.filter(Boolean))] const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) { if (resolved.length === 0) {
@@ -3169,7 +3615,8 @@ async function handlePasteRecipeImport() {
return return
} }
if (pasteRecipeImportMode.value === 'replace') { if (pasteRecipeImportMode.value === 'replace') {
editForm.herbs = resolved const auxKept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
editForm.herbs = [...auxKept, ...resolved]
} else { } else {
editForm.herbs.push(...resolved) editForm.herbs.push(...resolved)
} }
@@ -3194,11 +3641,12 @@ async function handlePasteRecipeImport() {
} }
} }
// 添加药材 // 添加药材(主方 / 辅方)
const addHerb = () => { const addHerb = (formulaType: FormulaType = '主方') => {
editForm.herbs.push({ editForm.herbs.push({
name: '', name: '',
dosage: 0 dosage: 0,
formula_type: formulaType
}) })
} }
@@ -3248,6 +3696,8 @@ const resetForm = () => {
editForm.is_system_auto = 0 editForm.is_system_auto = 0
editForm.business_prescription_audit_rejected = 0 editForm.business_prescription_audit_rejected = 0
editForm.business_prescription_audit_remark = '' editForm.business_prescription_audit_remark = ''
editForm.times_per_day = 2
Object.assign(editForm.aux_usage, defaultAuxUsage('浓缩水丸'))
clearRxLinkedOrderHint() clearRxLinkedOrderHint()
} }
@@ -3346,7 +3796,7 @@ const handleEdit = async (row: any) => {
editForm.pulse = src.pulse || '' editForm.pulse = src.pulse || ''
editForm.pulse_condition = src.pulse_condition || '' editForm.pulse_condition = src.pulse_condition || ''
editForm.clinical_diagnosis = src.clinical_diagnosis || '' editForm.clinical_diagnosis = src.clinical_diagnosis || ''
editForm.herbs = src.herbs ? JSON.parse(JSON.stringify(src.herbs)) : [] editForm.herbs = src.herbs ? (src.herbs as any[]).map((h) => normalizeHerbRow(h)) : []
editForm.dose_count = src.dose_count ?? 7 editForm.dose_count = src.dose_count ?? 7
editForm.dose_unit = src.dose_unit || '剂' editForm.dose_unit = src.dose_unit || '剂'
editForm.usage_days = src.usage_days ?? 7 editForm.usage_days = src.usage_days ?? 7
@@ -3371,6 +3821,10 @@ const handleEdit = async (row: any) => {
editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0 editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0
editForm.business_prescription_audit_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0 editForm.business_prescription_audit_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '') editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
Object.assign(
editForm.aux_usage,
normalizeAuxUsageForm(src.aux_usage, editForm.prescription_type)
)
// 数据加载完成,重新启用watch // 数据加载完成,重新启用watch
// 使用 setTimeout 确保所有数据都已经渲染完成 // 使用 setTimeout 确保所有数据都已经渲染完成
@@ -3405,12 +3859,13 @@ const handleSubmit = async () => {
for (let i = 0; i < editForm.herbs.length; i++) { for (let i = 0; i < editForm.herbs.length; i++) {
const herb = editForm.herbs[i] const herb = editForm.herbs[i]
const label = herbValidationLabel(i)
if (!herb.name || !herb.name.trim()) { if (!herb.name || !herb.name.trim()) {
feedback.msgError(`${i + 1}味药材名称不能为空`) feedback.msgError(`${label}药材名称不能为空`)
return return
} }
if (!herb.dosage || herb.dosage <= 0) { if (!herb.dosage || herb.dosage <= 0) {
feedback.msgError(`${i + 1}味药材剂量必须大于0`) feedback.msgError(`${label}药材剂量必须大于0`)
return return
} }
} }
@@ -3834,6 +4289,38 @@ onMounted(async () => {
box-sizing: border-box; box-sizing: border-box;
} }
.rx-herb-section-label {
grid-column: 1 / -1;
font-size: 12px;
font-weight: 600;
color: #409eff;
line-height: 1.4;
}
.rx-herb-section-label--aux {
color: #e6a23c;
margin-top: 4px;
}
.herb-formula-block__head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.usage-formula-title {
font-size: 13px;
font-weight: 600;
margin: 8px 0 4px;
color: #409eff;
}
.usage-formula-title--aux {
color: #e6a23c;
margin-top: 12px;
}
.rx-herb-cell { .rx-herb-cell {
display: grid; display: grid;
grid-template-columns: 1fr 64px; grid-template-columns: 1fr 64px;
@@ -11,6 +11,13 @@
@keyup.enter="resetPage" @keyup.enter="resetPage"
/> />
</el-form-item> </el-form-item>
<el-form-item class="w-[200px]" label="处方类型">
<el-select v-model="formData.formula_type" placeholder="全部" clearable>
<el-option label="全部" :value="''" />
<el-option label="主方" value="主方" />
<el-option label="辅方" value="辅方" />
</el-select>
</el-form-item>
<el-form-item class="w-[200px]" label="是否公开"> <el-form-item class="w-[200px]" label="是否公开">
<el-select v-model="formData.is_public" placeholder="全部" clearable> <el-select v-model="formData.is_public" placeholder="全部" clearable>
<el-option label="全部" :value="''" /> <el-option label="全部" :value="''" />
@@ -37,6 +44,13 @@
<el-table :data="pager.lists" size="large"> <el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" /> <el-table-column label="ID" prop="id" min-width="60" />
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip /> <el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
<el-table-column label="处方类型" min-width="100">
<template #default="{ row }">
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
{{ row.formula_type || '主方' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="药材数量" min-width="100"> <el-table-column label="药材数量" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
{{ row.herbs?.length || 0 }} {{ row.herbs?.length || 0 }}
@@ -115,6 +129,13 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="处方类型" prop="formula_type">
<el-radio-group v-model="editForm.formula_type">
<el-radio value="主方">主方</el-radio>
<el-radio value="辅方">辅方</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="药材配方" prop="herbs" required> <el-form-item label="药材配方" prop="herbs" required>
<div class="w-full"> <div class="w-full">
<el-button <el-button
@@ -200,6 +221,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
// 表单数据 // 表单数据
const formData = reactive({ const formData = reactive({
prescription_name: '', prescription_name: '',
formula_type: '',
is_public: '' is_public: ''
}) })
@@ -212,6 +234,7 @@ const formRef = ref<FormInstance>()
const editForm = reactive({ const editForm = reactive({
id: 0, id: 0,
prescription_name: '', prescription_name: '',
formula_type: '主方',
herbs: [] as Array<{ name: string; dosage: number }>, herbs: [] as Array<{ name: string; dosage: number }>,
is_public: 0 is_public: 0
}) })
@@ -267,6 +290,7 @@ const removeHerb = (index: number) => {
const resetForm = () => { const resetForm = () => {
editForm.id = 0 editForm.id = 0
editForm.prescription_name = '' editForm.prescription_name = ''
editForm.formula_type = '主方'
editForm.herbs = [] editForm.herbs = []
editForm.is_public = 0 editForm.is_public = 0
} }
@@ -283,6 +307,7 @@ const handleView = (row: any) => {
editMode.value = 'view' editMode.value = 'view'
editForm.id = row.id editForm.id = row.id
editForm.prescription_name = row.prescription_name || '' editForm.prescription_name = row.prescription_name || ''
editForm.formula_type = row.formula_type || '主方'
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : [] editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0 editForm.is_public = row.is_public ?? 0
showEdit.value = true showEdit.value = true
@@ -293,6 +318,7 @@ const handleEdit = (row: any) => {
editMode.value = 'edit' editMode.value = 'edit'
editForm.id = row.id editForm.id = row.id
editForm.prescription_name = row.prescription_name || '' editForm.prescription_name = row.prescription_name || ''
editForm.formula_type = row.formula_type || '主方'
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : [] editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0 editForm.is_public = row.is_public ?? 0
showEdit.value = true showEdit.value = true
@@ -252,7 +252,7 @@
v-model="queryParams.assistant_id" v-model="queryParams.assistant_id"
clearable clearable
filterable filterable
placeholder="诊单医助" placeholder="订单创建人"
class="!w-full" class="!w-full"
> >
<el-option <el-option
@@ -277,7 +277,7 @@
:fetch-fun="prescriptionOrderExport" :fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams" :params="prescriptionOrderExportParams"
:page-size="pager.size" :page-size="pager.size"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:按诊单关联患者取最近一条挂号的渠道字典与细分。" export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。"
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -562,6 +562,28 @@
round round
class="ml-2" class="ml-2"
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag> >甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
<div v-if="detailData" class="flex items-center gap-2 ml-4 shrink-0">
<span class="text-sm text-gray-500">发货类型</span>
<el-radio-group
v-perms="['tcm.prescriptionOrder/setShipMode']"
:model-value="normalizeShipMode(detailData.ship_mode)"
size="small"
:disabled="shipModeSaving || !canEditShipMode(detailData)"
@change="onDetailShipModeChange"
>
<el-radio-button label="gancao">甘草药房</el-radio-button>
<el-radio-button
label="direct"
:disabled="isShipModeLockedToGancao(detailData)"
>洛阳药房</el-radio-button>
</el-radio-group>
<el-tag
v-if="!hasSetShipModePerm"
:type="normalizeShipMode(detailData.ship_mode) === 'direct' ? 'warning' : 'success'"
effect="plain"
round
>{{ shipModeLabel(detailData.ship_mode) }}</el-tag>
</div>
</div> </div>
<div v-if="detailData" class="flex items-center gap-2 mr-6"> <div v-if="detailData" class="flex items-center gap-2 mr-6">
<el-button <el-button
@@ -1214,6 +1236,7 @@
<template #header> <template #header>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-[15px]">物流轨迹</span> <span class="font-medium text-[15px]">物流轨迹</span>
<span class="text-xs text-gray-400 font-mono">{{ detailData.tracking_number }}</span>
<el-tag <el-tag
v-if="detailLogisticsUrgent.show" v-if="detailLogisticsUrgent.show"
type="danger" type="danger"
@@ -1925,10 +1948,12 @@
/> />
<el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip"> <el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip">
<el-form-item label="发货方式"> <el-form-item label="发货方式">
<el-radio-group v-model="shipForm.ship_mode"> <el-tag
<el-radio label="gancao">甘草药方发</el-radio> :type="normalizeShipMode(shipForm.ship_mode) === 'direct' ? 'warning' : 'success'"
<el-radio label="direct">药房直发</el-radio> effect="plain"
</el-radio-group> size="default"
>{{ shipDialogModeDisplay }}</el-tag>
<span class="text-xs text-gray-400 ml-2">请在订单详情顶部发货类型中设置此处不可修改</span>
</el-form-item> </el-form-item>
<el-form-item label="承运商"> <el-form-item label="承运商">
<el-select v-model="shipForm.express_company" class="w-full"> <el-select v-model="shipForm.express_company" class="w-full">
@@ -2028,7 +2053,7 @@
@closed="addPayOrderFormRef?.clearValidate()" @closed="addPayOrderFormRef?.clearValidate()"
> >
<el-alert <el-alert
title="可以手动创建新支付单,或关联已存在但未绑定的支付单。" :title="addPayOrderAlertTitle"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
@@ -2046,6 +2071,7 @@
<el-radio-group v-model="addPayOrderForm.add_mode"> <el-radio-group v-model="addPayOrderForm.add_mode">
<el-radio value="create">手动创建</el-radio> <el-radio value="create">手动创建</el-radio>
<el-radio value="link">关联已有</el-radio> <el-radio value="link">关联已有</el-radio>
<el-radio value="completion_only">直接完单申请</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
@@ -2066,11 +2092,11 @@
<el-form-item label="支付金额" prop="pay_amount"> <el-form-item label="支付金额" prop="pay_amount">
<el-input-number <el-input-number
v-model="addPayOrderForm.pay_amount" v-model="addPayOrderForm.pay_amount"
:min="0.01" :min="0"
:step="0.01" :step="0.01"
:precision="2" :precision="2"
class="w-full" class="w-full"
placeholder="请输入支付金额(元)" placeholder="请输入支付金额(元,可为 0"
/> />
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="pay_remark"> <el-form-item label="备注" prop="pay_remark">
@@ -2085,6 +2111,20 @@
</el-form-item> </el-form-item>
</template> </template>
<!-- 直接完单申请不创建/关联支付单 -->
<template v-else-if="addPayOrderForm.add_mode === 'completion_only'">
<el-alert
type="warning"
:closable="false"
show-icon
class="!mb-0"
>
<template #title>
不新增或关联支付单仅向审核人员提交完成订单申请提交后支付审核将变为待审核审核通过后将自动结案
</template>
</el-alert>
</template>
<!-- 关联已有模式 --> <!-- 关联已有模式 -->
<template v-else> <template v-else>
<el-form-item label="选择支付单" prop="link_pay_order_id"> <el-form-item label="选择支付单" prop="link_pay_order_id">
@@ -2115,8 +2155,8 @@
</el-form-item> </el-form-item>
</template> </template>
<!-- 完单申请选项 --> <!-- 完单申请选项创建/关联时可选 -->
<el-form-item label="完单申请"> <el-form-item v-if="addPayOrderForm.add_mode !== 'completion_only'" label="完单申请">
<el-radio-group v-model="addPayOrderForm.completion_request"> <el-radio-group v-model="addPayOrderForm.completion_request">
<el-radio :value="0">不申请</el-radio> <el-radio :value="0">不申请</el-radio>
<el-radio :value="1">申请完成订单</el-radio> <el-radio :value="1">申请完成订单</el-radio>
@@ -2128,7 +2168,13 @@
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="addPayOrderVisible = false">取消</el-button> <el-button @click="addPayOrderVisible = false">取消</el-button>
<el-button type="primary" :loading="addPayOrderSaving" @click="submitAddPayOrder">确认新增</el-button> <el-button
type="primary"
:loading="addPayOrderSaving"
@click="submitAddPayOrder"
>
{{ addPayOrderForm.add_mode === 'completion_only' ? '提交申请' : '确认新增' }}
</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -2330,23 +2376,49 @@
</div> </div>
</div> </div>
<div class="rx-herbs"> <div class="rx-herbs">
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell"> <template v-if="prescriptionTabType === 'internal'">
<span class="rx-herb-name" v-if="prescriptionTabType === 'internal'">{{ h.name }} ({{ h.dosage }})</span> <template v-if="slipMainHerbs.length">
<span class="rx-herb-name" v-else>{{ h.name }}</span> <div class="rx-herb-section-label">主方</div>
<span class="rx-herb-total" v-if="prescriptionTabType === 'internal'">{{ rxHerbTotal(h.dosage) }}</span> <div
<span class="rx-herb-total" v-else>{{ h.dosage }}</span> v-for="(h, i) in slipMainHerbs"
</div> :key="'main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
<template v-if="slipAuxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in slipAuxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}</span>
</div>
</template>
</template>
<template v-else>
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
</div> </div>
</div> </div>
<div class="rx-text"> <div class="rx-text">
<p>服法{{ rxUsageText }}</p> <p v-if="prescriptionTabType === 'internal'">主方服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="prescriptionTabType === 'internal' && rxAuxUsageText">辅方服法{{ rxAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p> <p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p> <p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn"> <p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }} 药房备注{{ rxPharmacyRemarkText }}
</p> </p>
<p v-if="rxOutPelletText" class="rx-text-warn"> <p v-if="rxOutPelletText && prescriptionViewData?.prescription_type !== '饮片'" class="rx-text-warn">
出丸{{ rxOutPelletText }} 出丸{{ rxOutPelletText }}
</p> </p>
</div> </div>
@@ -2571,7 +2643,9 @@ import {
prescriptionOrderShip, prescriptionOrderShip,
prescriptionOrderLogs, prescriptionOrderLogs,
prescriptionOrderUpdateAmount, prescriptionOrderUpdateAmount,
prescriptionOrderSetShipMode,
prescriptionOrderAddPayOrder, prescriptionOrderAddPayOrder,
prescriptionOrderRequestCompletion,
prescriptionOrderComplete, prescriptionOrderComplete,
prescriptionOrderRevokeRxAudit, prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit, prescriptionOrderRevokePayAudit,
@@ -2635,6 +2709,8 @@ const prescriptionOrderDetailButtonText = computed(() =>
/** 与 server/config/project.php prescription_order_finance_roles 保持一致 */ /** 与 server/config/project.php prescription_order_finance_roles 保持一致 */
const FINANCE_ROLE_IDS = [0, 3, 6] const FINANCE_ROLE_IDS = [0, 3, 6]
/** 与 server/config/project.php order_edit_all_roles 一致:可绕过「双审通过后创建人禁编」 */
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
/** 判断当前用户是否有查看财务字段(内部成本)的权限 */ /** 判断当前用户是否有查看财务字段(内部成本)的权限 */
const canViewFinanceFields = () => { const canViewFinanceFields = () => {
@@ -2840,9 +2916,9 @@ const queryParams = reactive({
express_company: '' as '' | 'sf' | 'jd', express_company: '' as '' | 'sf' | 'jd',
/** 开方医生(后台:关联处方 creator_id */ /** 开方医生(后台:关联处方 creator_id */
doctor_id: '' as number | '', doctor_id: '' as number | '',
/** 诊单医助(后台:关联诊单 assistant_id */ /** 订单创建人(后台:业务订单 creator_id */
assistant_id: '' as number | '', assistant_id: '' as number | '',
/** 诊单医助所属部门(后台:assistant_dept_idla_admin_dept */ /** 创建人所属部门(后台:assistant_dept_idla_admin_dept */
assistant_dept_id: '' as number | '', assistant_dept_id: '' as number | '',
fulfillment_status: '' as number | '', fulfillment_status: '' as number | '',
prescription_audit_status: '' as number | '', prescription_audit_status: '' as number | '',
@@ -3289,8 +3365,32 @@ function formatTime(v: unknown) {
return String(v) return String(v)
} }
function canBypassCreatorDualAuditEditLock(): boolean {
const u = userStore.userInfo
if (!u) return false
if (Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
return ids.some((id) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
}
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
const uid = Number(userStore.userInfo?.id)
return uid > 0 && Number(row.creator_id) === uid
}
/** 处方审核 + 支付单审核均已通过 */
function isDualAuditPassed(row: {
prescription_audit_status?: number
payment_slip_audit_status?: number
}) {
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
}
function canEditRow(row: { function canEditRow(row: {
creator_id?: number
fulfillment_status?: number fulfillment_status?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
gancao_reciperl_order_no?: string | null gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null gancao_submit_time?: number | null
}) { }) {
@@ -3298,6 +3398,15 @@ function canEditRow(row: {
// (3)(4) // (3)(4)
if (fs === 3 || fs === 4) return false if (fs === 3 || fs === 4) return false
// /
if (
isCurrentUserOrderCreator(row) &&
isDualAuditPassed(row) &&
!canBypassCreatorDualAuditEditLock()
) {
return false
}
const gcNo = String(row.gancao_reciperl_order_no || '').trim() const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0) const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0 const gcLocked = gcNo !== '' || gcTime > 0
@@ -3364,6 +3473,70 @@ function canShipRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 2 return Number(row.fulfillment_status) === 2
} }
type ShipMode = 'gancao' | 'direct'
const hasSetShipModePerm = computed(() => {
const p = userStore.perms || []
return p.includes('*') || p.includes('tcm.prescriptionOrder/setShipMode')
})
function normalizeShipMode(v: unknown): ShipMode {
return String(v || '').toLowerCase() === 'direct' ? 'direct' : 'gancao'
}
function shipModeLabel(v: unknown): string {
return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房'
}
function isShipModeLockedToGancao(row: { gancao_reciperl_order_no?: string | null }) {
return String(row.gancao_reciperl_order_no || '').trim() !== ''
}
function canEditShipMode(row: { fulfillment_status?: number }) {
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
const shipModeSaving = ref(false)
function isSamePrescriptionOrderId(a: unknown, b: unknown) {
const na = Number(a)
const nb = Number(b)
return na > 0 && na === nb
}
const shipDialogModeDisplay = computed(() => {
return normalizeShipMode(shipForm.ship_mode) === 'direct' ? '洛阳药房直发' : '甘草药房直发'
})
async function onDetailShipModeChange(mode: string | number | boolean | undefined) {
const row = detailData.value
if (!row?.id) return
const next = normalizeShipMode(mode)
const prev = normalizeShipMode(row.ship_mode)
if (next === prev) return
if (!canEditShipMode(row)) {
feedback.msgWarning('当前订单状态不可修改发货类型')
return
}
shipModeSaving.value = true
try {
await prescriptionOrderSetShipMode({ id: row.id, ship_mode: next })
if (detailData.value) detailData.value.ship_mode = next
if (shipVisible.value && isSamePrescriptionOrderId(shipRowId.value, row.id)) {
shipForm.ship_mode = next
}
await refreshCurrentPrescriptionOrderDetail()
feedback.msgSuccess('发货类型已保存')
getLists()
void fetchLogs(row.id)
} catch {
/* 拦截器已提示 */
} finally {
shipModeSaving.value = false
}
}
function canAddPayOrderRow(row: { fulfillment_status?: number }) { function canAddPayOrderRow(row: { fulfillment_status?: number }) {
// (5) / (6) // (5) / (6)
const fs = Number(row.fulfillment_status) const fs = Number(row.fulfillment_status)
@@ -3666,6 +3839,40 @@ const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null) const logisticsTracePayload = ref<Record<string, any> | null>(null)
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */ /** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
const logisticsTracePhoneTail = ref('') const logisticsTracePhoneTail = ref('')
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
let logisticsTraceRequestSeq = 0
type LogisticsTraceRequestContext = {
requestSeq: number
orderId: number
trackingNumber: string
}
function bumpLogisticsTraceRequestToken() {
logisticsTraceRequestSeq += 1
logisticsTracePayload.value = null
}
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
if (Number(detailData.value?.id) !== ctx.orderId) return false
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
}
function parseLogisticsTracePayload(
res: unknown,
expectedTrackingNumber: string,
expectedOrderId: number
): Record<string, any> | null {
const raw = (res as { data?: unknown })?.data ?? res
if (!raw || typeof raw !== 'object') return null
const payload = raw as Record<string, any>
const respNum = String(payload.tracking_number || '').trim()
if (respNum && respNum !== expectedTrackingNumber) return null
const respOrderId = Number(payload.order_id)
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
return payload
}
const detailLogs = ref<any[]>([]) const detailLogs = ref<any[]>([])
const updateAmountVisible = ref(false) const updateAmountVisible = ref(false)
@@ -3722,6 +3929,8 @@ function logActionText(act: string) {
ship: '确认发货', ship: '确认发货',
withdraw: '撤销', withdraw: '撤销',
link_pay_order: '关联支付单', link_pay_order: '关联支付单',
completion_request: '完单申请',
auto_complete: '自动完成',
revoke_rx_audit: '撤回处方审核', revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', gancao_submit: '甘草下单',
@@ -3829,24 +4038,33 @@ function expressCompanyLabel(v: unknown) {
} }
async function fetchLogisticsTrace() { async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id) const orderId = Number(detailData.value?.id)
if (!id) return const trackingNumber = String(detailData.value?.tracking_number || '').trim()
if (!orderId || !trackingNumber) return
const requestSeq = ++logisticsTraceRequestSeq
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
logisticsTraceLoading.value = true logisticsTraceLoading.value = true
try { try {
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '') const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = { const params: { id: number; express_company?: string; phone_tail?: string } = {
id, id: orderId,
express_company: detailLogisticsExpress.value express_company: detailLogisticsExpress.value
} }
if (digits.length >= 4) { if (digits.length >= 4) {
params.phone_tail = digits params.phone_tail = digits
} }
const res: any = await prescriptionOrderLogisticsTrace(params) const res: any = await prescriptionOrderLogisticsTrace(params)
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any> if (!isCurrentLogisticsTraceContext(ctx)) return
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
} catch { } catch {
if (!isCurrentLogisticsTraceContext(ctx)) return
logisticsTracePayload.value = null logisticsTracePayload.value = null
} finally { } finally {
logisticsTraceLoading.value = false if (isCurrentLogisticsTraceContext(ctx)) {
logisticsTraceLoading.value = false
}
} }
} }
@@ -3972,7 +4190,7 @@ async function openDetail(id: number) {
// Bug // Bug
detailData.value = null detailData.value = null
detailUnlinkedPayOrders.value = [] detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null bumpLogisticsTraceRequestToken()
detailLogisticsExpress.value = 'auto' detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = '' logisticsTracePhoneTail.value = ''
detailLogs.value = [] detailLogs.value = []
@@ -4224,7 +4442,25 @@ async function goEditOrderNextStep() {
} }
} }
async function openEdit(row: { id: number }) { async function openEdit(row: {
id: number
creator_id?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
fulfillment_status?: number
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
if (!canEditRow(row)) {
if (
isCurrentUserOrderCreator(row) &&
isDualAuditPassed(row) &&
!canBypassCreatorDualAuditEditLock()
) {
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
}
return
}
editOrderStep.value = 0 editOrderStep.value = 0
editOrderPrescription.value = null editOrderPrescription.value = null
editVisible.value = true editVisible.value = true
@@ -4745,14 +4981,31 @@ const shipForm = reactive({
tracking_number: '' tracking_number: ''
}) })
function resolveShipModeForRow(row: { id: number; ship_mode?: unknown }) {
if (isSamePrescriptionOrderId(detailData.value?.id, row.id)) {
return normalizeShipMode(detailData.value!.ship_mode)
}
return normalizeShipMode(row.ship_mode)
}
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) { function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
shipRowId.value = row.id shipRowId.value = Number(row.id)
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao' shipForm.ship_mode = resolveShipModeForRow(row)
shipForm.express_company = String(row.express_company || 'auto') || 'auto' shipForm.express_company = String(row.express_company || 'auto') || 'auto'
shipForm.tracking_number = String(row.tracking_number || '') shipForm.tracking_number = String(row.tracking_number || '')
shipVisible.value = true shipVisible.value = true
} }
watch(
() => detailData.value?.ship_mode,
(v) => {
if (!shipVisible.value || !shipRowId.value || !isSamePrescriptionOrderId(detailData.value?.id, shipRowId.value)) {
return
}
shipForm.ship_mode = normalizeShipMode(v)
}
)
async function submitShip() { async function submitShip() {
if (!shipForm.tracking_number.trim()) { if (!shipForm.tracking_number.trim()) {
feedback.msgError('请先填写快递单号再确认发货') feedback.msgError('请先填写快递单号再确认发货')
@@ -4762,7 +5015,7 @@ async function submitShip() {
try { try {
await prescriptionOrderShip({ await prescriptionOrderShip({
id: shipRowId.value, id: shipRowId.value,
ship_mode: shipForm.ship_mode === 'direct' ? 'direct' : 'gancao', ship_mode: normalizeShipMode(shipForm.ship_mode),
express_company: shipForm.express_company || 'auto', express_company: shipForm.express_company || 'auto',
tracking_number: shipForm.tracking_number.trim() tracking_number: shipForm.tracking_number.trim()
}) })
@@ -4798,35 +5051,47 @@ const completeOrderDialogVisible = ref(false)
const completeOrderId = ref(0) const completeOrderId = ref(0)
const completeFulfillmentStatus = ref<number>(3) const completeFulfillmentStatus = ref<number>(3)
const completeOrderSubmitting = ref(false) const completeOrderSubmitting = ref(false)
let completeOrderLogisticsRequestSeq = 0
function onCompleteOrderDialogClosed() { function onCompleteOrderDialogClosed() {
completeOrderId.value = 0 completeOrderId.value = 0
completeOrderLogisticsRequestSeq += 1
completeOrderLogisticsPayload.value = null completeOrderLogisticsPayload.value = null
completeOrderLogisticsLoading.value = false completeOrderLogisticsLoading.value = false
} }
async function fetchCompleteOrderLogisticsForDialog(row: { async function fetchCompleteOrderLogisticsForDialog(row: {
id: number id: number
tracking_number?: unknown
express_company?: unknown express_company?: unknown
recipient_phone?: unknown recipient_phone?: unknown
}) { }) {
const orderId = Number(row.id)
const trackingNumber = String(row.tracking_number || '').trim()
if (!orderId || !trackingNumber) return
const requestSeq = ++completeOrderLogisticsRequestSeq
completeOrderLogisticsLoading.value = true completeOrderLogisticsLoading.value = true
completeOrderLogisticsPayload.value = null completeOrderLogisticsPayload.value = null
try { try {
const digits = String(row.recipient_phone || '').replace(/\D/g, '') const digits = String(row.recipient_phone || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = { const params: { id: number; express_company?: string; phone_tail?: string } = {
id: row.id, id: orderId,
express_company: String(row.express_company || 'auto') || 'auto' express_company: String(row.express_company || 'auto') || 'auto'
} }
if (digits.length >= 4) { if (digits.length >= 4) {
params.phone_tail = digits params.phone_tail = digits
} }
const res: any = await prescriptionOrderLogisticsTrace(params) const res: any = await prescriptionOrderLogisticsTrace(params)
completeOrderLogisticsPayload.value = (res?.data ?? res) as Record<string, any> if (requestSeq !== completeOrderLogisticsRequestSeq || completeOrderId.value !== orderId) return
completeOrderLogisticsPayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
} catch { } catch {
if (requestSeq !== completeOrderLogisticsRequestSeq || completeOrderId.value !== orderId) return
completeOrderLogisticsPayload.value = null completeOrderLogisticsPayload.value = null
} finally { } finally {
completeOrderLogisticsLoading.value = false if (requestSeq === completeOrderLogisticsRequestSeq && completeOrderId.value === orderId) {
completeOrderLogisticsLoading.value = false
}
} }
} }
@@ -4860,8 +5125,15 @@ const addPayOrderRowId = ref(0)
const addPayOrderFormRef = ref<FormInstance>() const addPayOrderFormRef = ref<FormInstance>()
const addPayOrderAvailableLoading = ref(false) const addPayOrderAvailableLoading = ref(false)
const addPayOrderAvailableList = ref<any[]>([]) const addPayOrderAvailableList = ref<any[]>([])
const addPayOrderAlertTitle = computed(() => {
if (addPayOrderForm.add_mode === 'completion_only') {
return '不创建或关联支付单,仅提交完单申请,由审核人员在支付审核时处理。'
}
return '可以手动创建新支付单,或关联已存在但未绑定的支付单。'
})
const addPayOrderForm = reactive({ const addPayOrderForm = reactive({
add_mode: 'create' as 'create' | 'link', add_mode: 'create' as 'create' | 'link' | 'completion_only',
order_type: 3, order_type: 3,
pay_amount: undefined as number | undefined, pay_amount: undefined as number | undefined,
pay_remark: '', pay_remark: '',
@@ -4869,38 +5141,47 @@ const addPayOrderForm = reactive({
completion_request: 0 completion_request: 0
}) })
const addPayOrderRules: FormRules = { const addPayOrderRules = computed<FormRules>(() => {
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }], const rules: FormRules = {
order_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }], add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }]
pay_amount: [ }
{ required: true, message: '请输入支付金额', trigger: 'blur' }, if (addPayOrderForm.add_mode === 'create') {
{ rules.order_type = [{ required: true, message: '请选择费用类别', trigger: 'change' }]
validator: (_rule, v, cb) => { rules.pay_amount = [
const n = Number(v) {
if (!Number.isFinite(n) || n <= 0) { validator: (_rule, v, cb) => {
cb(new Error('金额须大于 0')) if (v === undefined || v === null || v === '') {
} else { cb(new Error('请输入支付金额'))
cb() return
} }
}, const n = Number(v)
trigger: 'blur' if (!Number.isFinite(n) || n < 0) {
} cb(new Error('金额不能小于 0'))
], } else {
link_pay_order_ids: [ cb()
{ }
required: true, },
message: '请至少选择一个支付单', trigger: 'blur'
trigger: 'change', }
validator: (_rule, v, cb) => { ]
if (!Array.isArray(v) || v.length === 0) { } else if (addPayOrderForm.add_mode === 'link') {
cb(new Error('请至少选择一个支付单')) rules.link_pay_order_ids = [
} else { {
cb() required: true,
message: '请至少选择一个支付单',
trigger: 'change',
validator: (_rule, v, cb) => {
if (!Array.isArray(v) || v.length === 0) {
cb(new Error('请至少选择一个支付单'))
} else {
cb()
}
} }
} }
} ]
] }
} return rules
})
async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: number[] = []) { async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: number[] = []) {
if (!diagnosisId) { if (!diagnosisId) {
@@ -4948,7 +5229,10 @@ async function submitAddPayOrder() {
await addPayOrderFormRef.value.validate() await addPayOrderFormRef.value.validate()
addPayOrderSaving.value = true addPayOrderSaving.value = true
try { try {
if (addPayOrderForm.add_mode === 'create') { if (addPayOrderForm.add_mode === 'completion_only') {
await prescriptionOrderRequestCompletion({ id: addPayOrderRowId.value })
feedback.msgSuccess('完单申请已提交,请等待支付审核')
} else if (addPayOrderForm.add_mode === 'create') {
// //
await prescriptionOrderAddPayOrder({ await prescriptionOrderAddPayOrder({
id: addPayOrderRowId.value, id: addPayOrderRowId.value,
@@ -5070,12 +5354,116 @@ const prescriptionTabType = ref('internal')
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null) const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
const prescriptionSlipExporting = ref(false) const prescriptionSlipExporting = ref(false)
type SlipFormulaType = '主方' | '辅方'
type SlipAuxUsageForm = {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
}
function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
return v === '辅方' ? '辅方' : '主方'
}
function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
const base = defaultSlipAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
}
}
function buildSlipUsageSegmentText(
usage: {
prescription_type?: string
dosage_amount?: number | null
dosage_unit?: string
dosage_bag_count?: number
times_per_day?: number
usage_way?: string
usage_time?: string
},
fallbackWay?: string,
fallbackTime?: string
): string {
const pt = usage.prescription_type || '浓缩水丸'
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
const usageWay = usage.usage_way || fallbackWay || '温水送服'
const usageTime = usage.usage_time || fallbackTime || ''
const seg: string[] = []
seg.push(`每天${times}`)
if (pt === '浓缩水丸') {
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
seg.push(`一次${bags}`)
seg.push(`每袋${amount}${unit}`)
} else {
seg.push(`一次${amount}${unit}`)
}
seg.push(usageWay)
if (usageTime) seg.push(usageTime)
return seg.join(', ')
}
// //
const slipHerbsList = computed(() => { const slipHerbsList = computed(() => {
const h = prescriptionViewData.value?.herbs const h = prescriptionViewData.value?.herbs
return Array.isArray(h) ? h : [] return Array.isArray(h) ? h : []
}) })
const slipMainHerbs = computed(() =>
slipHerbsList.value.filter((h: any) => normalizeSlipFormulaType(h?.formula_type) === '主方')
)
const slipAuxHerbs = computed(() =>
slipHerbsList.value.filter((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
)
const slipDietaryText = computed(() => { const slipDietaryText = computed(() => {
const d = prescriptionViewData.value?.dietary_taboo const d = prescriptionViewData.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、') if (Array.isArray(d)) return d.filter(Boolean).join('、')
@@ -5193,6 +5581,8 @@ const rxTypeText = computed(() => {
const v = prescriptionViewData.value as any const v = prescriptionViewData.value as any
if (!v) return '—' if (!v) return '—'
const t = v.prescription_type || '浓缩水丸' const t = v.prescription_type || '浓缩水丸'
/** 饮片不匹配「丸|散|膏|片」,避免误显示「浓缩丸-饮片」;类型字段直接展示饮片 */
if (t === '饮片') return '饮片'
return /丸|散|膏|片/.test(t) ? `浓缩丸-${t}` : t return /丸|散|膏|片/.test(t) ? `浓缩丸-${t}` : t
}) })
@@ -5200,23 +5590,26 @@ const rxUsageText = computed(() => {
const v = prescriptionViewData.value as any const v = prescriptionViewData.value as any
if (!v) return '—' if (!v) return '—'
if (v.usage_text) return v.usage_text if (v.usage_text) return v.usage_text
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3 return buildSlipUsageSegmentText(v)
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10 })
const unit = v.dosage_unit || 'g'
const usageWay = v.usage_way || '温水送服' const rxAuxUsageText = computed(() => {
const usageTime = v.usage_time || '' const v = prescriptionViewData.value as any
const seg: string[] = [] if (!v || !slipAuxHerbs.value.length) return ''
seg.push(`每天${times}`) const aux = normalizeSlipAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') { return buildSlipUsageSegmentText(
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1 {
seg.push(`一次${bags}`) prescription_type: v.prescription_type,
seg.push(`每袋${amount}${unit}`) dosage_amount: aux.dosage_amount,
} else { dosage_unit: v.dosage_unit,
seg.push(`一次${amount}${unit}`) dosage_bag_count: aux.dosage_bag_count,
} times_per_day: aux.times_per_day,
seg.push(usageWay) usage_way: v.usage_way,
if (usageTime) seg.push(usageTime) usage_time: v.usage_time
return seg.join(', ') },
v.usage_way,
v.usage_time
)
}) })
/** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */ /** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
@@ -6019,6 +6412,19 @@ async function downloadPrescriptionSlipPdf() {
box-sizing: border-box; box-sizing: border-box;
} }
.rx-herb-section-label {
grid-column: 1 / -1;
font-size: 12px;
font-weight: 600;
color: #409eff;
line-height: 1.4;
}
.rx-herb-section-label--aux {
color: #e6a23c;
margin-top: 4px;
}
.rx-herb-cell { .rx-herb-cell {
display: grid; display: grid;
grid-template-columns: 1fr 64px; grid-template-columns: 1fr 64px;
@@ -3047,14 +3047,48 @@ function formatTime(v: unknown) {
return String(v) return String(v)
} }
/** 与 server/config/project.php order_edit_all_roles 一致 */
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
function canBypassCreatorDualAuditEditLock(): boolean {
const u = userStore.userInfo
if (!u) return false
if (Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
return ids.some((id) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
}
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
const uid = Number(userStore.userInfo?.id)
return uid > 0 && Number(row.creator_id) === uid
}
function isDualAuditPassed(row: {
prescription_audit_status?: number
payment_slip_audit_status?: number
}) {
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
}
function canEditRow(row: { function canEditRow(row: {
creator_id?: number
fulfillment_status?: number fulfillment_status?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
gancao_reciperl_order_no?: string | null gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null gancao_submit_time?: number | null
}) { }) {
const fs = Number(row.fulfillment_status) const fs = Number(row.fulfillment_status)
if (fs === 3 || fs === 4) return false if (fs === 3 || fs === 4) return false
if (
isCurrentUserOrderCreator(row) &&
isDualAuditPassed(row) &&
!canBypassCreatorDualAuditEditLock()
) {
return false
}
const gcNo = String(row.gancao_reciperl_order_no || '').trim() const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0) const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0 const gcLocked = gcNo !== '' || gcTime > 0
@@ -3406,6 +3440,40 @@ const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null) const logisticsTracePayload = ref<Record<string, any> | null>(null)
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */ /** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
const logisticsTracePhoneTail = ref('') const logisticsTracePhoneTail = ref('')
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
let logisticsTraceRequestSeq = 0
type LogisticsTraceRequestContext = {
requestSeq: number
orderId: number
trackingNumber: string
}
function bumpLogisticsTraceRequestToken() {
logisticsTraceRequestSeq += 1
logisticsTracePayload.value = null
}
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
if (Number(detailData.value?.id) !== ctx.orderId) return false
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
}
function parseLogisticsTracePayload(
res: unknown,
expectedTrackingNumber: string,
expectedOrderId: number
): Record<string, any> | null {
const raw = (res as { data?: unknown })?.data ?? res
if (!raw || typeof raw !== 'object') return null
const payload = raw as Record<string, any>
const respNum = String(payload.tracking_number || '').trim()
if (respNum && respNum !== expectedTrackingNumber) return null
const respOrderId = Number(payload.order_id)
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
return payload
}
const detailLogs = ref<any[]>([]) const detailLogs = ref<any[]>([])
const updateAmountVisible = ref(false) const updateAmountVisible = ref(false)
@@ -3500,24 +3568,33 @@ function expressCompanyLabel(v: unknown) {
} }
async function fetchLogisticsTrace() { async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id) const orderId = Number(detailData.value?.id)
if (!id) return const trackingNumber = String(detailData.value?.tracking_number || '').trim()
if (!orderId || !trackingNumber) return
const requestSeq = ++logisticsTraceRequestSeq
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
logisticsTraceLoading.value = true logisticsTraceLoading.value = true
try { try {
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '') const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = { const params: { id: number; express_company?: string; phone_tail?: string } = {
id, id: orderId,
express_company: detailLogisticsExpress.value express_company: detailLogisticsExpress.value
} }
if (digits.length >= 4) { if (digits.length >= 4) {
params.phone_tail = digits params.phone_tail = digits
} }
const res: any = await prescriptionOrderLogisticsTrace(params) const res: any = await prescriptionOrderLogisticsTrace(params)
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any> if (!isCurrentLogisticsTraceContext(ctx)) return
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
} catch { } catch {
if (!isCurrentLogisticsTraceContext(ctx)) return
logisticsTracePayload.value = null logisticsTracePayload.value = null
} finally { } finally {
logisticsTraceLoading.value = false if (isCurrentLogisticsTraceContext(ctx)) {
logisticsTraceLoading.value = false
}
} }
} }
@@ -3643,7 +3720,7 @@ async function openDetail(id: number) {
// Bug // Bug
detailData.value = null detailData.value = null
detailUnlinkedPayOrders.value = [] detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null bumpLogisticsTraceRequestToken()
detailLogisticsExpress.value = 'auto' detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = '' logisticsTracePhoneTail.value = ''
detailLogs.value = [] detailLogs.value = []
@@ -3659,7 +3736,7 @@ async function openDetail(id: number) {
const dig = String(d.recipient_phone || '').replace(/\D/g, '') const dig = String(d.recipient_phone || '').replace(/\D/g, '')
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : '' logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
if (String(d.tracking_number || '').trim()) { if (String(d.tracking_number || '').trim()) {
fetchLogisticsTrace() void fetchLogisticsTrace()
} }
fetchLogs(id) fetchLogs(id)
@@ -3889,7 +3966,25 @@ async function goEditOrderNextStep() {
} }
} }
async function openEdit(row: { id: number }) { async function openEdit(row: {
id: number
creator_id?: number
prescription_audit_status?: number
payment_slip_audit_status?: number
fulfillment_status?: number
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
if (!canEditRow(row)) {
if (
isCurrentUserOrderCreator(row) &&
isDualAuditPassed(row) &&
!canBypassCreatorDualAuditEditLock()
) {
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
}
return
}
editOrderStep.value = 0 editOrderStep.value = 0
editOrderPrescription.value = null editOrderPrescription.value = null
editVisible.value = true editVisible.value = true
+39 -12
View File
@@ -22,16 +22,25 @@
<el-select <el-select
v-model="formData.media_channel_code" v-model="formData.media_channel_code"
placeholder="请选择自媒体渠道" placeholder="请选择自媒体渠道"
class="w-full" class="account-cost-channel-select w-full"
filterable filterable
:disabled="mode === 'edit'"
> >
<el-option <el-option-group
v-for="item in mediaChannelOptions" v-for="g in mediaChannelGroups"
:key="item.code" :key="g.group_name"
:label="item.name" :label="g.group_name"
:value="item.code" >
/> <el-option
v-for="ch in g.channels"
:key="ch.channel_code"
:label="ch.channel_name"
:value="ch.channel_code"
>
<div class="channel-opt-row">
<span class="opt-name">{{ ch.channel_name }}</span>
</div>
</el-option>
</el-option-group>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="部门" prop="dept_id"> <el-form-item label="部门" prop="dept_id">
@@ -78,9 +87,9 @@ import type { FormInstance } from 'element-plus'
import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance' import { accountCostAdd, accountCostDetail, accountCostEdit } from '@/api/finance'
import Popup from '@/components/popup/index.vue' import Popup from '@/components/popup/index.vue'
interface MediaChannelOption { interface MediaChannelGroup {
code: string group_name: string
name: string channels: { channel_code: string; channel_name: string }[]
} }
interface DeptOption { interface DeptOption {
@@ -90,7 +99,7 @@ interface DeptOption {
} }
const props = defineProps<{ const props = defineProps<{
mediaChannelOptions: MediaChannelOption[] mediaChannelGroups: MediaChannelGroup[]
deptOptions: DeptOption[] deptOptions: DeptOption[]
defaultMediaChannelCode: string defaultMediaChannelCode: string
}>() }>()
@@ -197,3 +206,21 @@ defineExpose({
getDetail, getDetail,
}) })
</script> </script>
<style scoped lang="scss">
.account-cost-channel-select {
:deep(.channel-opt-row) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
padding-right: 4px;
}
:deep(.opt-name) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
+83 -10
View File
@@ -27,14 +27,25 @@
placeholder="筛选全部渠道" placeholder="筛选全部渠道"
clearable clearable
filterable filterable
class="w-[220px]" class="account-cost-channel-select w-[220px]"
> >
<el-option <el-option label="全部" value="" />
v-for="item in mediaChannelOptions" <el-option-group
:key="item.code" v-for="g in mediaChannelGroups"
:label="item.name" :key="g.group_name"
:value="item.code" :label="g.group_name"
/> >
<el-option
v-for="ch in g.channels"
:key="ch.channel_code"
:label="ch.channel_name"
:value="ch.channel_code"
>
<div class="channel-opt-row">
<span class="opt-name">{{ ch.channel_name }}</span>
</div>
</el-option>
</el-option-group>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="部门"> <el-form-item label="部门">
@@ -78,7 +89,15 @@
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists"> <el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="日期" prop="cost_date" min-width="120" /> <el-table-column label="日期" prop="cost_date" min-width="120" />
<el-table-column label="自媒体渠道" prop="media_channel_name" min-width="120" /> <el-table-column label="自媒体渠道" min-width="160">
<template #default="{ row }">
<span v-if="getChannelGroupLabel(row.media_channel_code)">
<el-tag size="small" class="mr-1">{{ getChannelGroupLabel(row.media_channel_code) }}</el-tag>
{{ row.media_channel_name }}
</span>
<span v-else>{{ row.media_channel_name }}</span>
</template>
</el-table-column>
<el-table-column label="部门" prop="dept_name" min-width="140" /> <el-table-column label="部门" prop="dept_name" min-width="140" />
<el-table-column label="账户消耗" min-width="120"> <el-table-column label="账户消耗" min-width="120">
<template #default="{ row }">¥{{ row.amount }}</template> <template #default="{ row }">¥{{ row.amount }}</template>
@@ -117,7 +136,7 @@
<edit-popup <edit-popup
v-if="showEdit" v-if="showEdit"
ref="editRef" ref="editRef"
:media-channel-options="mediaChannelOptions" :media-channel-groups="mediaChannelGroups"
:dept-options="deptOptions" :dept-options="deptOptions"
:default-media-channel-code="defaultMediaChannelCode" :default-media-channel-code="defaultMediaChannelCode"
@success="getLists" @success="getLists"
@@ -133,6 +152,11 @@ import feedback from '@/utils/feedback'
import EditPopup from './edit.vue' import EditPopup from './edit.vue'
interface MediaChannelGroup {
group_name: string
channels: { channel_code: string; channel_name: string }[]
}
interface MediaChannelOption { interface MediaChannelOption {
code: string code: string
name: string name: string
@@ -160,7 +184,7 @@ const { pager, getLists, resetPage } = usePaging({
params: queryParams, params: queryParams,
}) })
const mediaChannelOptions = computed<MediaChannelOption[]>(() => { const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
const options = pager.extend.media_channel_options const options = pager.extend.media_channel_options
if (!Array.isArray(options)) return [] if (!Array.isArray(options)) return []
@@ -170,6 +194,26 @@ const mediaChannelOptions = computed<MediaChannelOption[]>(() => {
})) }))
}) })
const mediaChannelGroups = computed<MediaChannelGroup[]>(() => {
const groups = pager.extend.media_channel_groups
if (Array.isArray(groups) && groups.length > 0) {
return groups as MediaChannelGroup[]
}
const flat = mediaChannelOptionsFlat.value
if (!flat.length) {
return []
}
return [
{
group_name: '渠道',
channels: flat.map(item => ({
channel_code: item.code,
channel_name: item.name,
})),
},
]
})
const deptOptions = computed<DeptOption[]>(() => { const deptOptions = computed<DeptOption[]>(() => {
const options = pager.extend.dept_options const options = pager.extend.dept_options
return Array.isArray(options) ? options : [] return Array.isArray(options) ? options : []
@@ -177,6 +221,17 @@ const deptOptions = computed<DeptOption[]>(() => {
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || '')) const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
const getChannelGroupLabel = (channelCode: string): string => {
if (!channelCode) return ''
for (const group of mediaChannelGroups.value) {
const channel = group.channels.find(ch => ch.channel_code === channelCode)
if (channel) {
return group.group_name
}
}
return ''
}
const treeProps = { const treeProps = {
value: 'id', value: 'id',
label: 'name', label: 'name',
@@ -213,3 +268,21 @@ const handleReset = () => {
getLists() getLists()
</script> </script>
<style scoped lang="scss">
.account-cost-channel-select {
:deep(.channel-opt-row) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
padding-right: 4px;
}
:deep(.opt-name) {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
@@ -0,0 +1,273 @@
<template>
<div class="assistant-performance-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="stats-filter-form">
<el-form-item label="时间范围">
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">最近30天</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="queryParams.time_type === 'custom'">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
@change="handleCustomDateChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="fetchData">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="stats-kpi-grid">
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card" :class="card.cardClass">
<div class="stats-kpi-label">{{ card.label }}</div>
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
{{ formatValue(card.key, card.type) }}
</div>
</div>
</div>
<el-card class="!border-none mt-4" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">业绩趋势</span>
<span class="card-hint">{{ dateRangeText }} · 履约完成业绩</span>
</div>
</template>
<v-charts
v-if="chartHasData"
class="stats-chart"
:option="chartOption"
autoresize
/>
<el-empty v-else description="暂无数据" />
</el-card>
</div>
</template>
<script setup lang="ts" name="assistantPerformancePage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import vCharts from 'vue-echarts'
import { assistantPerformanceOverview } from '@/api/stats'
const loading = ref(false)
const dateRange = ref<string[]>([])
const queryParams = reactive({
time_type: 'month',
start_date: '',
end_date: ''
})
const overview = reactive<Record<string, any>>({
date_range: [],
summary: {
total_amount: 0,
total_count: 0
},
chart: {
dates: [],
amounts: [],
counts: []
}
})
const summaryCards = [
{ key: 'total_amount', label: '业绩', type: 'money', cardClass: '' },
{ key: 'total_count', label: '有效订单数', type: 'count', cardClass: '' }
]
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
})
const chartHasData = computed(() => {
return overview.chart.dates.length > 0 && overview.chart.amounts.some((v: number) => v > 0)
})
const chartOption = computed(() => ({
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const p = params[0]
return `${p.axisValue}<br/>${p.marker}${p.seriesName}: ¥${Number(p.value).toFixed(2)}`
}
},
grid: { left: 60, right: 24, top: 36, bottom: 36 },
xAxis: {
type: 'category',
data: overview.chart.dates,
axisLabel: {
interval: overview.chart.dates.length > 15 ? 'auto' : 0,
rotate: overview.chart.dates.length > 10 ? 30 : 0
}
},
yAxis: {
type: 'value',
name: '金额(元)',
axisLabel: {
formatter: (val: number) => val >= 10000 ? (val / 10000).toFixed(1) + '万' : String(val)
}
},
series: [
{
name: '业绩',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 6,
lineStyle: { width: 3, color: '#4a78ff' },
itemStyle: { color: '#4a78ff' },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(74, 120, 255, 0.25)' },
{ offset: 1, color: 'rgba(74, 120, 255, 0.02)' }
]
}
},
data: overview.chart.amounts
}
]
}))
const formatValue = (key: string, type: string) => {
const value = overview.summary?.[key] ?? 0
if (type === 'money') return `¥${Number(value).toFixed(2)}`
return String(value)
}
const fetchData = async () => {
loading.value = true
try {
const params: Record<string, any> = { time_type: queryParams.time_type }
if (queryParams.time_type === 'custom') {
params.start_date = dateRange.value[0] || ''
params.end_date = dateRange.value[1] || ''
}
const res = await assistantPerformanceOverview(params)
Object.assign(overview, res || {})
} catch (error: any) {
console.error('获取业绩数据失败:', error)
ElMessage.error(error?.msg || '获取业绩数据失败')
} finally {
loading.value = false
}
}
const handleTimeTypeChange = () => {
if (queryParams.time_type !== 'custom') {
dateRange.value = []
fetchData()
}
}
const handleCustomDateChange = () => {
if (dateRange.value?.length === 2) {
fetchData()
}
}
const handleReset = () => {
queryParams.time_type = 'month'
dateRange.value = []
fetchData()
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.assistant-performance-page {
padding: 20px;
}
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stats-kpi-card {
background: linear-gradient(145deg, #ffffff, #f5f8ff);
border: 1px solid #ebf1ff;
border-radius: 14px;
padding: 20px;
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
}
.stats-kpi-card.is-cancelled {
background: linear-gradient(145deg, #ffffff, #fff5f5);
border-color: #ffe0e0;
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.06);
}
.stats-kpi-label {
color: #6b7280;
font-size: 13px;
}
.stats-kpi-value {
margin-top: 12px;
font-size: 28px;
line-height: 1.1;
font-weight: 700;
color: #1f2937;
}
.stats-kpi-value.is-money {
font-size: 24px;
}
.stats-kpi-card.is-cancelled .stats-kpi-value {
color: #ef4444;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.card-hint {
color: #909399;
font-size: 12px;
}
.stats-chart {
height: 360px;
}
</style>
@@ -0,0 +1,82 @@
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
:clearable="clearable"
:filterable="filterable"
:allow-create="allowCreate"
:default-first-option="allowCreate"
:disabled="disabled"
:loading="loading"
:class="selectClass"
:style="selectStyle"
@update:model-value="emit('update:modelValue', $event)"
@change="emit('change', $event)"
@visible-change="onVisibleChange"
>
<el-option
v-for="item in normalizedOptions"
:key="item.name"
:label="item.name"
:value="item.name"
/>
<!-- 旧记录可能存在不在当前字典内的值保留显示避免编辑时反查不到 -->
<el-option
v-if="modelValue && !hasCurrentValue"
:key="`__legacy_${modelValue}`"
:label="`${modelValue}(已停用)`"
:value="modelValue"
/>
</el-select>
</template>
<script lang="ts" setup>
interface OptionItem {
name: string
value?: string
}
const props = withDefaults(
defineProps<{
modelValue?: string
options: Array<OptionItem | string>
loading?: boolean
placeholder?: string
clearable?: boolean
filterable?: boolean
allowCreate?: boolean
disabled?: boolean
selectClass?: string
selectStyle?: string | Record<string, string>
}>(),
{
modelValue: '',
loading: false,
placeholder: '请选择自媒体来源',
clearable: true,
filterable: true,
allowCreate: false,
disabled: false,
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [value: string]
'visible-change': [visible: boolean]
}>()
const normalizedOptions = computed<OptionItem[]>(() => {
return (props.options || []).map((item) =>
typeof item === 'string' ? { name: item } : { name: item.name, value: item.value }
)
})
const hasCurrentValue = computed(() => {
return normalizedOptions.value.some((item) => item.name === props.modelValue)
})
const onVisibleChange = (visible: boolean) => {
emit('visible-change', visible)
}
</script>
@@ -0,0 +1,254 @@
<template>
<div class="personal-account-cost-page">
<el-card class="!border-none mb-4" shadow="never">
<div class="flex flex-wrap">
<div class="w-1/2 md:w-1/3">
<div class="leading-10">当前筛选天数</div>
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
</div>
<div class="w-1/2 md:w-1/3">
<div class="leading-10">累计账户消耗 ()</div>
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
</div>
</div>
</el-card>
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="queryParams" inline>
<el-form-item label="日期范围">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
:data="deptOptions"
node-key="id"
:props="deptTreeProps"
:default-expand-all="true"
check-strictly
placeholder="全部部门"
clearable
filterable
class="w-[220px]"
@change="resetPage"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<media-source-select
v-model="queryParams.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="全部来源"
:allow-create="false"
select-class="w-[220px]"
@change="resetPage"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
<el-form-item class="w-[280px]" label="备注/录入人">
<el-input
v-model="queryParams.remark"
placeholder="备注 / 创建人"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<el-button v-perms="['stats.personal_account_cost/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
</div>
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="日期" prop="cost_date" min-width="120" />
<el-table-column label="自媒体来源" prop="media_source" min-width="160" show-overflow-tooltip />
<el-table-column label="账户消耗" min-width="120">
<template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="录入人" prop="creator_name" min-width="100" />
<el-table-column label="部门" prop="dept_name" min-width="140">
<template #default="{ row }">
<el-tooltip
v-if="row.dept_path"
:content="row.dept_path"
placement="top"
effect="dark"
>
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
</el-tooltip>
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else class="text-gray-400">未分配</span>
</template>
</el-table-column>
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
<el-table-column label="更新时间" prop="update_time" min-width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['stats.personal_account_cost/edit']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['stats.personal_account_cost/delete']"
type="danger"
link
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
<cost-edit-popup
v-if="showEdit"
ref="editRef"
:media-source-options="mediaSourceOptions"
:media-source-loading="mediaSourceLoading"
@success="handleCostSuccess"
@close="showEdit = false"
/>
</div>
</template>
<script lang="ts" setup name="personalAccountCost">
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
import { deptAll } from '@/api/org/department'
import { usePaging } from '@/hooks/usePaging'
import { getRoutePath } from '@/router'
import feedback from '@/utils/feedback'
import CostEditPopup from './cost-edit.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
const router = useRouter()
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
const showEdit = ref(false)
const deptOptions = ref<any[]>([])
const {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
} = useMediaSourceOptions()
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
const goSelfInput = () => {
if (selfInputPath.value) {
router.push(selfInputPath.value)
}
}
const queryParams = reactive({
start_date: '',
end_date: '',
media_source: '',
dept_id: undefined as number | undefined,
remark: '',
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: personalAccountCostLists,
params: queryParams,
})
const handleReset = () => {
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.remark = ''
resetPage()
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
if (visible) {
refreshMediaSourceOptions()
}
}
const handleCostSuccess = async () => {
await refreshMediaSourceOptions()
getLists()
}
const handleAdd = async () => {
await refreshMediaSourceOptions()
showEdit.value = true
await nextTick()
editRef.value?.open('add')
}
const handleEdit = async (row: Record<string, any>) => {
showEdit.value = true
await nextTick()
editRef.value?.open('edit')
editRef.value?.setFormData(row)
}
const handleDelete = async (id: number) => {
await feedback.confirm('确定删除该账户消耗记录?')
await personalAccountCostDelete({ id })
getLists()
}
const loadDeptOptions = async () => {
try {
const res = await deptAll({ apply_data_scope: 1 })
if (Array.isArray(res)) {
deptOptions.value = res
}
} catch (e) {
deptOptions.value = []
}
}
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
})
</script>
<style scoped lang="scss">
.personal-account-cost-page {
padding: 20px;
}
.dept-cell {
cursor: help;
border-bottom: 1px dashed #c0c4cc;
}
</style>
@@ -0,0 +1,142 @@
<template>
<div class="edit-popup">
<popup
ref="popupRef"
:title="popupTitle"
:async="true"
width="520px"
@confirm="handleSubmit"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
<el-form-item label="日期" prop="cost_date">
<el-date-picker
v-model="formData.cost_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
:disabled="mode === 'edit'"
class="w-full"
/>
</el-form-item>
<el-form-item label="自媒体来源" prop="media_source">
<media-source-select
v-model="formData.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="请选择自媒体来源"
:allow-create="false"
:disabled="mode === 'edit'"
select-class="w-full"
/>
</el-form-item>
<el-form-item label="账户消耗" prop="amount">
<el-input-number
v-model="formData.amount"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
maxlength="255"
show-word-limit
placeholder="选填"
/>
</el-form-item>
</el-form>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import {
personalAccountCostAdd,
personalAccountCostEdit,
} from '@/api/self_input_stats'
import Popup from '@/components/popup/index.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import type { MediaSourceOption } from './useMediaSourceOptions'
withDefaults(
defineProps<{
mediaSourceOptions?: MediaSourceOption[]
mediaSourceLoading?: boolean
}>(),
{
mediaSourceOptions: () => [],
mediaSourceLoading: false,
}
)
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const mode = ref<'add' | 'edit'>('add')
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
const formData = reactive({
id: '',
cost_date: '',
media_source: '',
amount: 0,
remark: '',
})
const formRules = {
cost_date: [{ required: true, message: '请选择日期', trigger: ['change'] }],
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
amount: [{ required: true, message: '请输入账户消耗金额', trigger: ['blur', 'change'] }],
}
const resetForm = () => {
formData.id = ''
formData.cost_date = ''
formData.media_source = ''
formData.amount = 0
formData.remark = ''
}
const handleSubmit = async () => {
await formRef.value?.validate()
if (mode.value === 'edit') {
await personalAccountCostEdit(formData)
} else {
await personalAccountCostAdd(formData)
}
popupRef.value?.close()
emit('success')
}
const open = (type: 'add' | 'edit' = 'add') => {
mode.value = type
resetForm()
popupRef.value?.open()
}
const setFormData = (data: Record<string, any>) => {
formData.id = data.id ?? ''
formData.cost_date = data.cost_date ?? ''
formData.media_source = data.media_source ?? ''
formData.amount = Number(data.amount ?? 0)
formData.remark = data.remark ?? ''
}
const handleClose = () => {
emit('close')
}
defineExpose({
open,
setFormData,
})
</script>
+499
View File
@@ -0,0 +1,499 @@
<template>
<div class="self-input-stats-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<el-form-item label="时间范围">
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">最近30天</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="queryParams.time_type === 'custom'">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
@change="handleCustomDateChange"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
:data="deptOptions"
node-key="id"
:props="deptTreeProps"
:default-expand-all="true"
check-strictly
placeholder="全部部门"
clearable
filterable
style="width: 220px"
@change="handleQuery"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<media-source-select
v-model="queryParams.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="全部来源"
:allow-create="false"
select-style="width: 220px"
@change="handleQuery"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="stats-kpi-grid">
<div v-for="card in visibleSummaryCards" :key="card.key" class="stats-kpi-card">
<div class="stats-kpi-label">{{ card.label }}</div>
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
{{ renderMetric(card.key, overview.summary, card.type) }}
</div>
</div>
</div>
<el-card class="!border-none mt-4 stats-detail-card" shadow="never">
<template #header>
<div class="card-header">
<div class="card-header-main">
<span class="card-title">业绩明细</span>
<span class="card-hint">{{ dateRangeText }}</span>
</div>
<div class="card-header-actions">
<el-button v-perms="['stats.personal_yeji/add']" type="primary" @click="handleAddYeji">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增业绩
</el-button>
<el-button
v-if="accountCostEntryPath"
v-perms="['stats.personal_account_cost/lists']"
@click="goAccountCostEntry"
>
账户消耗录入
</el-button>
</div>
</div>
</template>
<el-table
v-loading="pager.loading"
:data="pager.lists"
border
size="large"
max-height="640"
>
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
<el-table-column label="录入人" prop="creator_name" min-width="100" />
<el-table-column label="部门" prop="dept_name" min-width="140">
<template #default="{ row }">
<el-tooltip
v-if="row.dept_path"
:content="row.dept_path"
placement="top"
effect="dark"
>
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
</el-tooltip>
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else class="text-gray-400">未分配</span>
</template>
</el-table-column>
<el-table-column
v-for="col in visibleTableColumns"
:key="col.key"
:label="col.label"
:min-width="col.minWidth || 100"
align="right"
>
<template #default="{ row }">
{{ renderMetric(col.key, row, col.type) }}
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['stats.personal_yeji/edit']"
type="primary"
link
@click="handleEditYeji(row)"
>
编辑
</el-button>
<el-button
v-perms="['stats.personal_yeji/delete']"
type="danger"
link
@click="handleDeleteYeji(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="fetchOverview" />
</div>
</el-card>
<yeji-edit-popup
v-if="showYejiEdit"
ref="yejiEditRef"
:media-source-options="mediaSourceOptions"
:media-source-loading="mediaSourceLoading"
@success="handleYejiSuccess"
@close="showYejiEdit = false"
/>
</div>
</template>
<script lang="ts" setup name="selfInputStats">
import {
getSelfInputOverview,
personalYejiDelete,
} from '@/api/self_input_stats'
import { deptAll } from '@/api/org/department'
import { usePaging } from '@/hooks/usePaging'
import { getRoutePath } from '@/router'
import feedback from '@/utils/feedback'
import MediaSourceSelect from './MediaSourceSelect.vue'
import YejiEditPopup from './yeji-edit.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
interface MetricCard {
key: string
label: string
type: MetricType
}
interface MetricColumn {
key: string
label: string
type: MetricType
minWidth?: number
}
const router = useRouter()
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
const showYejiEdit = ref(false)
const dateRange = ref<string[]>([])
const deptOptions = ref<any[]>([])
const {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
} = useMediaSourceOptions()
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const overview = reactive<Record<string, any>>({
date_range: [],
summary: {},
can_view_finance: false,
})
const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi'])
const canViewFinance = computed(() => Boolean(overview.can_view_finance))
const queryParams = reactive({
media_source: '',
dept_id: undefined as number | undefined,
time_type: 'today',
start_date: '',
end_date: '',
})
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
const goAccountCostEntry = () => {
if (accountCostEntryPath.value) {
router.push(accountCostEntryPath.value)
}
}
const fetchOverviewList = async (params: Record<string, any>) => {
if (queryParams.time_type === 'custom') {
params.start_date = dateRange.value[0] || ''
params.end_date = dateRange.value[1] || ''
}
const res = await getSelfInputOverview(params)
Object.assign(overview, res?.extend || {})
return res
}
const loadDeptOptions = async () => {
try {
const res = await deptAll({ apply_data_scope: 1 })
if (Array.isArray(res)) {
deptOptions.value = res
}
} catch (e) {
deptOptions.value = []
}
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
if (visible) {
refreshMediaSourceOptions()
}
}
const handleYejiSuccess = async () => {
await refreshMediaSourceOptions()
await fetchOverview()
}
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
})
const { pager, getLists } = usePaging({
fetchFun: fetchOverviewList,
params: queryParams,
firstLoading: true,
})
const summaryCards: MetricCard[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count' },
{ key: 'unreplied_count', label: '未回复', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
{ key: 'interview_count', label: '面诊', type: 'count' },
{ key: 'order_amount', label: '订单金额', type: 'money' },
{ key: 'account_cost', label: '账户消耗', type: 'money' },
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
{ key: 'cash_cost', label: '现金成本', type: 'money' },
{ key: 'roi', label: 'ROI', type: 'ratio' },
]
const tableColumns: MetricColumn[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count' },
{ key: 'unreplied_count', label: '未回复', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
{ key: 'interview_count', label: '面诊', type: 'count' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 120 },
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', minWidth: 110 },
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 100 },
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 100 },
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
]
const visibleSummaryCards = computed(() =>
canViewFinance.value
? summaryCards
: summaryCards.filter((card) => !FINANCE_KEYS.has(card.key))
)
const visibleTableColumns = computed(() =>
canViewFinance.value
? tableColumns
: tableColumns.filter((col) => !FINANCE_KEYS.has(col.key))
)
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
})
const fetchOverview = async () => {
try {
await getLists()
} catch (error: any) {
console.error('获取自录转化统计失败:', error)
ElMessage.error(error?.msg || '获取统计数据失败')
}
}
const handleTimeTypeChange = () => {
if (queryParams.time_type !== 'custom') {
dateRange.value = []
pager.page = 1
fetchOverview()
}
}
const handleCustomDateChange = () => {
if (dateRange.value.length === 2) {
pager.page = 1
fetchOverview()
}
}
const handleQuery = () => {
pager.page = 1
fetchOverview()
}
const handleReset = () => {
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.time_type = 'today'
dateRange.value = []
pager.page = 1
pager.size = 15
fetchOverview()
}
const renderMetric = (key: string, source: Record<string, any>, type = 'count') => {
const value = source?.[key] ?? 0
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
if (type === 'ratio') return Number(value || 0).toFixed(2)
return String(value ?? 0)
}
const handleAddYeji = async () => {
await refreshMediaSourceOptions()
showYejiEdit.value = true
await nextTick()
yejiEditRef.value?.open('add')
}
const handleEditYeji = async (row: Record<string, any>) => {
showYejiEdit.value = true
await nextTick()
yejiEditRef.value?.open('edit')
yejiEditRef.value?.setFormData(row)
}
const handleDeleteYeji = async (id: number) => {
await feedback.confirm('确定删除该业绩记录?')
await personalYejiDelete({ id })
fetchOverview()
}
onMounted(async () => {
await fetchOverview()
})
</script>
<style scoped lang="scss">
.self-input-stats-page {
padding: 20px;
}
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stats-kpi-card {
background: linear-gradient(145deg, #ffffff, #f5f8ff);
border: 1px solid #ebf1ff;
border-radius: 14px;
padding: 18px 16px;
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
}
.dept-cell {
cursor: help;
border-bottom: 1px dashed #c0c4cc;
}
.stats-kpi-label {
color: #6b7280;
font-size: 13px;
}
.stats-kpi-value {
margin-top: 12px;
font-size: 28px;
line-height: 1.1;
font-weight: 700;
color: #1f2937;
}
.stats-kpi-value.is-money {
font-size: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.card-header-main {
display: flex;
align-items: baseline;
gap: 10px;
}
.card-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.card-hint {
color: #909399;
font-size: 12px;
}
.stats-detail-card :deep(.el-card__body) {
padding-top: 0;
}
</style>
@@ -0,0 +1,63 @@
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
/**
* 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。
* 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。
*/
export interface MediaSourceOption {
name: string
value: string
}
export function useMediaSourceOptions() {
const mediaSourceOptions = ref<MediaSourceOption[]>([])
const mediaSourceLoading = ref(false)
const normalize = (raw: any): MediaSourceOption[] => {
if (!Array.isArray(raw)) return []
const list: MediaSourceOption[] = []
const seen = new Set<string>()
for (const item of raw) {
if (typeof item === 'string') {
const name = item.trim()
if (name && !seen.has(name)) {
seen.add(name)
list.push({ name, value: '' })
}
continue
}
if (item && typeof item === 'object') {
const name = String(item.name ?? '').trim()
if (name && !seen.has(name)) {
seen.add(name)
list.push({ name, value: String(item.value ?? '') })
}
}
}
return list
}
const loadMediaSourceOptions = async (force = false) => {
if (!force && mediaSourceOptions.value.length > 0) {
return
}
mediaSourceLoading.value = true
try {
const res = await getSelfInputMediaSourceOptions()
mediaSourceOptions.value = normalize(res)
} catch {
mediaSourceOptions.value = []
} finally {
mediaSourceLoading.value = false
}
}
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
return {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
}
}
@@ -0,0 +1,234 @@
<template>
<div class="edit-popup">
<popup
ref="popupRef"
:title="popupTitle"
:async="true"
width="640px"
@confirm="handleSubmit"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
<el-form-item label="业绩日期" prop="yeji_date">
<el-date-picker
v-model="formData.yeji_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择日期"
:disabled="mode === 'edit'"
class="w-full"
/>
</el-form-item>
<el-form-item label="自媒体来源" prop="media_source">
<media-source-select
v-model="formData.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="请选择自媒体来源"
:allow-create="false"
:disabled="mode === 'edit'"
select-class="w-full"
/>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="加粉数" prop="add_fans_count">
<el-input-number
v-model="formData.add_fans_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总开口" prop="total_open_count">
<el-input-number
v-model="formData.total_open_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="未回复" prop="unreplied_count">
<el-input-number
v-model="formData.unreplied_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="付费挂号" prop="paid_appointment_count">
<el-input-number
v-model="formData.paid_appointment_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="免费挂号" prop="free_appointment_count">
<el-input-number
v-model="formData.free_appointment_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="面诊" prop="interview_count">
<el-input-number
v-model="formData.interview_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="订单金额" prop="order_amount">
<el-input-number
v-model="formData.order_amount"
:min="0"
:step="0.01"
:precision="2"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="接诊诊单" prop="completed_order_count">
<el-input-number
v-model="formData.completed_order_count"
:min="0"
:step="1"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
maxlength="255"
show-word-limit
placeholder="选填"
/>
</el-form-item>
</el-form>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
import Popup from '@/components/popup/index.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import type { MediaSourceOption } from './useMediaSourceOptions'
withDefaults(
defineProps<{
mediaSourceOptions?: MediaSourceOption[]
mediaSourceLoading?: boolean
}>(),
{
mediaSourceOptions: () => [],
mediaSourceLoading: false,
}
)
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const mode = ref<'add' | 'edit'>('add')
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑业绩' : '新增业绩'))
const formData = reactive({
id: '',
yeji_date: '',
media_source: '',
add_fans_count: 0,
total_open_count: 0,
unreplied_count: 0,
paid_appointment_count: 0,
free_appointment_count: 0,
interview_count: 0,
order_amount: 0,
completed_order_count: 0,
remark: '',
})
const formRules = {
yeji_date: [{ required: true, message: '请选择业绩日期', trigger: ['change'] }],
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
add_fans_count: [{ required: true, message: '请填写加粉数', trigger: ['blur', 'change'] }],
}
const resetForm = () => {
formData.id = ''
formData.yeji_date = ''
formData.media_source = ''
formData.add_fans_count = 0
formData.total_open_count = 0
formData.unreplied_count = 0
formData.paid_appointment_count = 0
formData.free_appointment_count = 0
formData.interview_count = 0
formData.order_amount = 0
formData.completed_order_count = 0
formData.remark = ''
}
const handleSubmit = async () => {
await formRef.value?.validate()
if (mode.value === 'edit') {
await personalYejiEdit(formData)
} else {
await personalYejiAdd(formData)
}
popupRef.value?.close()
emit('success')
}
const open = (type: 'add' | 'edit' = 'add') => {
mode.value = type
resetForm()
popupRef.value?.open()
}
const setFormData = (data: Record<string, any>) => {
formData.id = data.id ?? ''
formData.yeji_date = data.yeji_date ?? ''
formData.media_source = data.media_source ?? ''
formData.add_fans_count = Number(data.add_fans_count ?? 0)
formData.total_open_count = Number(data.total_open_count ?? 0)
formData.unreplied_count = Number(data.unreplied_count ?? 0)
formData.paid_appointment_count = Number(data.paid_appointment_count ?? 0)
formData.free_appointment_count = Number(data.free_appointment_count ?? 0)
formData.interview_count = Number(data.interview_count ?? 0)
formData.order_amount = Number(data.order_amount ?? 0)
formData.completed_order_count = Number(data.completed_order_count ?? 0)
formData.remark = data.remark ?? ''
}
const handleClose = () => {
emit('close')
}
defineExpose({
open,
setFormData,
})
</script>
@@ -131,7 +131,7 @@
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item> <el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item> <el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item> <el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
<el-descriptions-item label="剂数 / 用法" :span="2"> <el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '' }} {{ detailData.dose_unit || '' }} · {{ detailData.dose_count ?? detailPrescription.dose_count ?? '' }} {{ detailData.dose_unit || '' }} ·
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '' }} {{ detailPrescription.usage_instruction || detailPrescription.usage_method || '' }}
</el-descriptions-item> </el-descriptions-item>
@@ -274,6 +274,7 @@
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item> <el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item> <el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item> <el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item> <el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item> <el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item> <el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
@@ -332,7 +333,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch, ref } from 'vue' import { computed, watch, ref, onMounted } from 'vue'
import { usePaging } from '@/hooks/usePaging' import { usePaging } from '@/hooks/usePaging'
import { import {
prescriptionOrderLists, prescriptionOrderLists,
@@ -341,6 +342,7 @@ import {
prescriptionOrderLogisticsTrace, prescriptionOrderLogisticsTrace,
prescriptionOrderPaidPayOrders prescriptionOrderPaidPayOrders
} from '@/api/tcm' } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { hasPermission } from '@/utils/perm' import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback' import feedback from '@/utils/feedback'
import useUserStore from '@/stores/modules/user' import useUserStore from '@/stores/modules/user'
@@ -377,6 +379,22 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit' queryParams.scene = 'diagnosis_edit'
} }
/** 诊间医助角色(与 DiagnosisLists 等一致) */
const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
/**
* 是否展示「处方审核」相关字段
*/
const showPrescriptionAuditFilter = computed(() => {
const u = userStore.userInfo
if (!u || Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
})
// ─── 详情抽屉 ─── // ─── 详情抽屉 ───
const detailVisible = ref(false) const detailVisible = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
@@ -563,6 +581,39 @@ function feeTypeText(t: number | undefined) {
return m[Number(t)] ?? '—' return m[Number(t)] ?? '—'
} }
// 服务套餐字典选项(与 order_list.vue 数据源一致:dict 类型 server_order
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
async function loadServicePackageOptions() {
try {
const data: any = await getDictData({ type: 'server_order' })
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
servicePackageOptions.value = list.filter((item) => item.status !== 0)
} catch {
servicePackageOptions.value = []
}
}
function formatServicePackage(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
let packages: string[] = []
if (Array.isArray(value)) {
packages = value.map((v) => String(v)).filter((v) => v !== '')
} else if (typeof value === 'string') {
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
}
if (packages.length === 0) return '—'
const names = packages.map((val) => {
const opt = servicePackageOptions.value.find((o) => o.value === val)
return opt ? opt.name : val
})
return names.join('、')
}
onMounted(() => {
loadServicePackageOptions()
})
function expressCompanyLabel(v: unknown) { function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase() const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运' if (s === 'sf') return '顺丰速运'
+1 -1
View File
@@ -257,7 +257,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-tooltip <el-tooltip
v-if="row.last_blood_record_at" v-if="row.last_blood_record_at"
:content="`最近一次血糖打卡:${row.last_blood_record_at}`" :content="`最近一次健康打卡:${row.last_blood_record_at}`"
placement="top" placement="top"
> >
<span class="unserved-days" :class="unservedDaysClass(row.unserved_days)"> <span class="unserved-days" :class="unservedDaysClass(row.unserved_days)">
+2 -2
View File
@@ -17,14 +17,14 @@
</span> </span>
<el-tooltip <el-tooltip
v-if="lastBloodAt" v-if="lastBloodAt"
:content="`最近一次血糖打卡:${lastBloodAt}`" :content="`最近一次健康打卡:${lastBloodAt}`"
placement="bottom" placement="bottom"
> >
<span :class="['unserved-badge', unservedDaysClass(unservedDays)]"> <span :class="['unserved-badge', unservedDaysClass(unservedDays)]">
未服务 {{ unservedDays ?? '—' }} 未服务 {{ unservedDays ?? '—' }}
</span> </span>
</el-tooltip> </el-tooltip>
<span v-else class="unserved-badge unserved-muted">从未打卡血糖</span> <span v-else class="unserved-badge unserved-muted">从未打卡</span>
</div> </div>
</div> </div>
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\AssistantPerformanceLogic;
/**
* 医助个人业绩
*
* - GET stats.assistantPerformance/overview 个人业绩概览
*/
class AssistantPerformanceController extends BaseAdminController
{
public function overview()
{
$params = $this->request->get();
return $this->data(AssistantPerformanceLogic::overview($params, $this->adminId, $this->adminInfo));
}
}
View File
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\stats\PersonalAccountCostLists;
use app\adminapi\logic\stats\PersonalAccountCostLogic;
use app\adminapi\validate\stats\PersonalAccountCostValidate;
class PersonalAccountCostController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new PersonalAccountCostLists());
}
public function add()
{
$params = (new PersonalAccountCostValidate())->post()->goCheck('add');
$result = PersonalAccountCostLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
if ($result === false) {
return $this->fail(PersonalAccountCostLogic::getError());
}
return $this->success('添加成功', [], 1, 1);
}
public function edit()
{
$params = (new PersonalAccountCostValidate())->post()->goCheck('edit');
$result = PersonalAccountCostLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo);
if ($result === false) {
return $this->fail(PersonalAccountCostLogic::getError());
}
return $this->success('编辑成功', [], 1, 1);
}
public function detail()
{
$params = (new PersonalAccountCostValidate())->goCheck('detail');
$detail = PersonalAccountCostLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
if ($detail === []) {
return $this->fail('记录不存在或无权查看');
}
return $this->data($detail);
}
public function delete()
{
$params = (new PersonalAccountCostValidate())->post()->goCheck('delete');
$result = PersonalAccountCostLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PersonalAccountCostLogic::getError());
}
return $this->success('删除成功', [], 1, 1);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\stats\PersonalYejiLists;
use app\adminapi\logic\stats\PersonalYejiLogic;
use app\adminapi\validate\stats\PersonalYejiValidate;
class PersonalYejiController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new PersonalYejiLists());
}
public function add()
{
$params = (new PersonalYejiValidate())->post()->goCheck('add');
$result = PersonalYejiLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
if ($result === false) {
return $this->fail(PersonalYejiLogic::getError());
}
return $this->success('添加成功', [], 1, 1);
}
public function edit()
{
$params = (new PersonalYejiValidate())->post()->goCheck('edit');
$result = PersonalYejiLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo);
if ($result === false) {
return $this->fail(PersonalYejiLogic::getError());
}
return $this->success('编辑成功', [], 1, 1);
}
public function detail()
{
$params = (new PersonalYejiValidate())->goCheck('detail');
$detail = PersonalYejiLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
if ($detail === []) {
return $this->fail('记录不存在或无权查看');
}
return $this->data($detail);
}
public function delete()
{
$params = (new PersonalYejiValidate())->post()->goCheck('delete');
$result = PersonalYejiLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PersonalYejiLogic::getError());
}
return $this->success('删除成功', [], 1, 1);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\SelfInputLogic;
class SelfInputController extends BaseAdminController
{
public function overview()
{
$result = SelfInputLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
return $this->data($result);
}
/**
* 自媒体来源下拉:从已录入业绩/账户消耗去重提取(随录入动态变化)
*/
public function mediaSourceOptions()
{
$list = SelfInputLogic::mediaSourceOptions($this->adminId, $this->adminInfo);
return $this->data($list);
}
}
View File
View File
View File
View File
View File

Some files were not shown because too many files have changed in this diff Show More