Compare commits

..
61 Commits
Author SHA1 Message Date
Your Name 6ce58bcd85 更新 2026-08-07 14:54:05 +08:00
Your Name 58a7197d3e 新增功能 2026-08-07 14:32:35 +08:00
Your Name 16d301f302 更新 2026-08-07 09:16:15 +08:00
Your Name 8bbd6f7885 更新 2026-08-06 16:29:08 +08:00
Your Name a010483bdc 更新 2026-08-06 14:48:14 +08:00
Your Name d5b0ab4709 更新 2026-08-06 12:03:58 +08:00
Your Name 079e50006d 更新 2026-08-06 10:57:35 +08:00
Your Name 2c0b9c5afa 更新 2026-08-05 14:37:29 +08:00
Your Name dd28bba354 更新 2026-08-05 14:14:55 +08:00
Your Name a797743aa4 更新 2026-08-05 14:06:07 +08:00
Your Name abb4aced1c 更新 2026-08-05 11:55:10 +08:00
Your Name 4ee8a8e98b 更新 2026-08-05 11:54:24 +08:00
Your Name 7ddb4882e3 修复bug 2026-08-05 11:15:14 +08:00
Your Name c2b7018a22 更新 2026-08-05 11:08:02 +08:00
Your Name 57a7c415a1 Merge branch 'bug-08/03' 2026-08-03 11:27:55 +08:00
Your Name 582c7cf0c6 更新 2026-08-03 11:27:15 +08:00
Your Name 2425fb60d0 更新 2026-08-03 09:56:58 +08:00
Your Name 22a371a733 更新 2026-08-03 09:51:53 +08:00
long 81d6a38e26 build 2026-07-28 16:29:32 +08:00
long 64d760af1a debug 2026-07-28 16:28:36 +08:00
long 9ba53b6145 debug 2026-07-28 15:15:14 +08:00
long 30cf33c546 build 2026-07-28 11:04:45 +08:00
long e1fe818dce feat: integrate Luoyang pharmacy workflow 2026-07-22 18:00:17 +08:00
long 31312ae975 feat: add zyt medicine bootstrap 2026-07-22 14:50:34 +08:00
大哥大哥的大哥哥 8a6890767e fix(tongji): 恢复居家训练并统一识糖入口 2026-07-18 11:30:54 +08:00
大哥大哥的大哥哥 d57e60539e Merge remote-tracking branch 'origin/master' into feature/tongji-endless-game
# Conflicts:
#	TUICallKit-Vue3/tongji/endless-game/README.md
#	TUICallKit-Vue3/tongji/endless-game/index.vue
#	TUICallKit-Vue3/vite.config.ts
2026-07-18 11:24:59 +08:00
大哥大哥的大哥哥 78fd00fa05 fix(miniprogram): 优化分包并清理旧版小游戏 2026-07-18 11:21:12 +08:00
大哥大哥的大哥哥 fd7e6071a8 fix(tongji): 修复小游戏平台同步与并发问题 2026-07-17 21:21:43 +08:00
大哥大哥的大哥哥 7192c4db8c feat(tongji): 接入小游戏真实周榜与微信分享 2026-07-17 18:02:00 +08:00
Your Name b02c340348 更新 2026-07-17 14:47:00 +08:00
Your Name 0f21dd4451 更新 2026-07-17 14:12:15 +08:00
Your Name 1ed3e06fdf 更新 2026-07-17 12:50:11 +08:00
Your Name c608145023 i
Merge branch 'bug-0717'
2026-07-17 12:13:30 +08:00
Your Name 4f95d9182e 更新 2026-07-17 12:13:02 +08:00
long f662723a25 debug 2026-07-17 11:37:05 +08:00
long 32370df7fe feat(tongji): 新增控糖消消乐小游戏 2026-07-17 10:24:42 +08:00
大哥大哥的大哥哥 62467dc01c feat(tongji): 新增控糖消消乐小游戏 2026-07-17 09:30:22 +08:00
Your Name 97fefbae8f 更新 2026-07-16 09:43:07 +08:00
Your Name 541fe59ad1 Merge branch 'xuhao-20260715' 2026-07-16 09:37:30 +08:00
longandCursor b6c5b422c2 test: 空提交验证 Gitea SSH 推送
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 09:25:10 +08:00
Your Name 500038601c 更新 2026-07-16 09:04:40 +08:00
Your Name f6688a740d 更新 2026-07-10 14:19:01 +08:00
Your Name a347e0642c Merge branch 'AI-20260710' 2026-07-10 14:16:51 +08:00
Your Name 13adbac3fd 更新 2026-07-10 14:16:07 +08:00
Your Name 333a18c37e 更新 2026-07-10 14:10:48 +08:00
Your Name e3a91a91f7 i
Merge branch 'AI-20260710'
2026-07-10 14:08:47 +08:00
Your Name 5ca5f06255 更新 2026-07-10 14:08:27 +08:00
Your Name e01f3e7072 更新 2026-07-10 10:58:14 +08:00
Your Name ed78803015 更新 2026-07-10 10:52:36 +08:00
Your Name 546138d60e 更新 2026-07-10 10:28:21 +08:00
Your Name fdddfdb3dd AI 客服 2026-07-10 10:25:51 +08:00
Your Name f5a89b4310 更新 2026-07-09 17:58:02 +08:00
Your Name a650747fd3 更新 2026-07-09 11:27:20 +08:00
Your Name 46b62229a6 i
Merge branch '20260708'
2026-07-09 11:25:25 +08:00
Your Name 0f33f2ad28 更新 2026-07-09 11:24:18 +08:00
Your Name a677b69e97 更新 2026-07-08 15:00:28 +08:00
Your Name 6432e9990c 更新 2026-07-08 14:53:41 +08:00
Your Name 4d6125268a 更新 2026-07-08 14:46:39 +08:00
Your Name 7f93cf5480 更新 2026-07-07 11:06:19 +08:00
Your Name f8f1953a2a Merge branch 'bug-20260706' 2026-07-07 11:04:43 +08:00
Your Name bde396189a Please enter the commit message for your changes. Lines starting 2026-07-06 16:58:01 +08:00
780 changed files with 33185 additions and 6335 deletions
+2 -7
View File
@@ -1,7 +1,6 @@
<script setup>
// 2. 获取组件实例,通过 proxy 访问全局属性
import { ref} from "vue";
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
import { getCurrentInstance } from 'vue'
@@ -97,11 +96,7 @@ const video =async (data)=>{
},
}, false)
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
console.log('SDKAppID',SDKAppID)
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
@@ -115,4 +110,4 @@ const video =async (data)=>{
uni.setStorageSync('CallManager', userId)
await uni.CallManager.init(cc);
}
</script>
</script>
+1 -7
View File
@@ -239,7 +239,6 @@
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
@@ -561,17 +560,12 @@ const { proxy } = getCurrentInstance()
},
}, false)
const {userId } = res.data;
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
patient_data.value=res.data.data
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID;
getApp().globalData.secretKey = secretKey;
uni.setStorageSync('CallManager', userId)
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
}
@@ -186,16 +186,8 @@ const inlineItems = computed(() => {
return metro ? [metro, ...rest] : trainingItems
})
const isDevMode = (): boolean => {
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
try {
return process.env.NODE_ENV !== 'production'
} catch (_) {
return false
}
}
const visible = ref(isDevMode())
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
const visible = ref(true)
const expanded = ref(false)
const toggle = () => {
@@ -178,7 +178,6 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getCurrentInstance } from 'vue'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
let userId = ref("");
@@ -379,10 +378,11 @@ const loginHandler = async (patient_id) => {
data: { patient_id: patient_id },
}, false)
const { userId: patientUserId } = res.data
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
userID: patientUserId,
})
const {
userId: patientUserId,
userSig: patientUserSig,
sdkAppId: patientSDKAppID,
} = res.data
console.log('获取签名成功:', patientUserSig)
// 更新ref值
+1
View File
@@ -51,6 +51,7 @@
/* */
"mp-weixin" : {
"appid" : "wx79b9a0bfbfe7cbcd",
"lazyCodeLoading" : "requiredComponents",
"setting" : {
"urlCheck" : false,
"minified" : true
+19 -12
View File
@@ -26,14 +26,6 @@
"navigationBarTitleText": "甄养堂"
}
},
{
"path": "TUICallKit/src/Components/TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
},
{
"path": "pages/order/monad/monad",
"style": {
@@ -76,6 +68,19 @@
}
],
"subPackages": [
{
"root": "TUICallKit/src/Components",
"pages": [
{
"path": "TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
}
]
},
{
"root": "TUIKit",
"pages": [
@@ -216,12 +221,14 @@
}
},
{
"path": "pages/game",
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "糖分突袭",
"backgroundColor": "#c7d2fe",
"disableScroll": true
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
]
+2 -7
View File
@@ -62,7 +62,6 @@
import { ref,onMounted,getCurrentInstance} from "vue";
// 2. 获取组件实例,通过 proxy 访问全局属性
const { proxy } = getCurrentInstance()
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
let userID = ref("4");
@@ -140,13 +139,9 @@ const loginHandler = async (patient_id) => {
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
console.log('获取签名成功:',userSig);
getApp().globalData.userID = userId.value;
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID;
+1 -5
View File
@@ -52,7 +52,6 @@
<script setup>
import { ref, getCurrentInstance,onMounted } from "vue";
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
let userId = ref("");
@@ -126,10 +125,7 @@ const loginHandler = async (patient_id) => {
data: { patient_id: patient_id },
}, false)
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
console.log('获取签名成功:',userSig);
getApp().globalData.userID = userId;
+12 -17
View File
@@ -370,7 +370,6 @@
import { ref,onMounted,getCurrentInstance } from "vue";
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
// 2. 获取组件实例,通过 proxy 访问全局属性
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
@@ -667,25 +666,25 @@ const closePatientModal = async () => {
const granted = await requestAVPermissionsInModal();
if (!granted) return; // 未授权,停止跳转
const app = getApp();
// const userID = app.globalData.userID;
// const userSig = app.globalData.userSig;
// const SDKAppID = app.globalData.SDKAppID;
const doctor = 'doctor_' + doctorId.value;
await confirmDiagnosis(diagnosisId);
if(!diagnosisId.includes('patient')){
diagnosisId='patient_'+diagnosisId
}
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:diagnosisId,
});
const signatureRes = await proxy.apiUrl({
url: '/api/tcm/getPatientSignature',
method: 'GET',
data: { patient_id: diagnosisId },
}, false);
const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
if (!doctorId.value) {
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
return;
}
console.log(app.globalData, diagnosisId);
app.globalData.userID = userId;
app.globalData.userSig = userSig;
app.globalData.SDKAppID = SDKAppID;
console.log(app.globalData, userId);
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(diagnosisId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
showPatientModal.value = false;
uni.redirectTo({
@@ -707,11 +706,7 @@ const video =async (data)=>{
},
}, false)
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
@@ -1,142 +0,0 @@
<template>
<view
class="food-tile-icon"
:class="[`food-tile-icon--${size}`, { 'is-custom-img': hasCustomImg }]"
>
<text v-if="showEmojiFallback" class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
<image
v-if="src && !imageFailed"
class="food-tile-icon__img"
:src="src"
:mode="imageMode"
@error="onImageError"
/>
</view>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getFoodImgUrl } from '../data/gameFoodImg.js'
const props = defineProps({
food: { type: [Object, String], default: null },
size: { type: String, default: 'tile' }
})
const imageFailed = ref(false)
const fallbackIcon = computed(() => {
if (typeof props.food === 'object' && props.food?.icon) return props.food.icon
return '🍽'
})
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
const src = computed(() => {
if (typeof props.food === 'object' && props.food?.img) return props.food.img
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
return ''
})
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
watch(
() => src.value,
() => {
imageFailed.value = false
}
)
function onImageError() {
imageFailed.value = true
}
</script>
<style lang="scss" scoped>
.food-tile-icon {
position: relative;
box-sizing: border-box;
overflow: hidden;
}
.food-tile-icon--tile {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
font-size: calc(var(--tile-emoji-size, 48px) * 0.6);
}
.food-tile-icon--tile .food-tile-icon__emoji,
.food-tile-icon--tile .food-tile-icon__img {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 75%;
height: 75%;
z-index: 2;
}
.food-tile-icon--tile.is-custom-img .food-tile-icon__img {
width: 82%;
height: 82%;
}
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
.food-tile-icon--tile .food-tile-icon__img {
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
}
.food-tile-icon--goal {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.food-tile-icon--tooltip {
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
.food-tile-icon__emoji {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: inherit;
line-height: 1;
z-index: 1;
}
.food-tile-icon--goal .food-tile-icon__emoji,
.food-tile-icon--tooltip .food-tile-icon__emoji {
position: static;
transform: none;
font-size: 34rpx;
}
.food-tile-icon--goal.is-custom-img .food-tile-icon__img,
.food-tile-icon--tooltip.is-custom-img .food-tile-icon__img {
border-radius: 8rpx;
}
.food-tile-icon__img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 2;
display: block;
}
</style>
@@ -1,188 +0,0 @@
/**
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
*/
import { ref } from 'vue'
let fxSeq = 0
function nextFxId(prefix) {
fxSeq += 1
return `${prefix}-${fxSeq}-${Date.now()}`
}
// 开心消消乐式糖果色板(彩虹爆裂用)
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
// 星屑字形
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
export function useGameFx() {
const floatingTexts = ref([])
const burstParticles = ref([])
const ripples = ref([])
const comboBanner = ref({ show: false, text: '', tier: 1 })
const screenShaking = ref(false)
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
let comboBannerTimer = null
let shakeTimer = null
let flashTimer = null
let flashFadeTimer = null
function spawnFloatingText(payload) {
const id = nextFxId('ft')
floatingTexts.value.push({ id, ...payload })
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
setTimeout(() => {
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
}, duration)
return id
}
/**
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
* @param {number} cx,cy 爆裂中心(屏幕坐标)
* @param {string} color 基础颜色
* @param {number} count 粒子数量
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
*/
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
const {
star = true,
rainbow = false,
gravity = 64,
sizeMin = 8,
sizeMax = 16,
distMin = 36,
distMax = 110,
life = 850
} = opts
const dur = (life / 1000).toFixed(2)
for (let i = 0; i < count; i++) {
const id = nextFxId('p')
const isStar = star && Math.random() < 0.65
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
const angle = Math.random() * Math.PI * 2
const distance = Math.random() * (distMax - distMin) + distMin
const tx = Math.cos(angle) * distance
// 先上抛、再叠加重力下落,形成喷泉抛物线
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
const half = pSize / 2
const pColor = rainbow
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
: color
const rot = Math.random() * 720 - 360
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
const particle = {
id,
left: cx - half,
top: cy - half,
size: pSize,
color: pColor,
char: glyph,
opacity: 1,
transform: 'scale(0.4) rotate(0deg)',
transition: 'none'
}
burstParticles.value.push(particle)
setTimeout(() => {
particle.transition =
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
particle.left = cx - half + tx
particle.top = cy - half + ty
particle.opacity = 0
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
}, 16)
setTimeout(() => {
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
}, life + 80)
}
}
/**
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
* @param {object} opts { thickness, life }
*/
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
const id = nextFxId('rp')
const size = 36 + tier * 18
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
ripples.value.push({
id,
left: cx,
top: cy,
size,
color,
thickness,
opacity: 0.95
})
setTimeout(() => {
ripples.value = ripples.value.filter((r) => r.id !== id)
}, life)
}
/**
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
*/
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
if (flashFadeTimer) clearTimeout(flashFadeTimer)
if (flashTimer) clearTimeout(flashTimer)
flash.value = { active: true, color, opacity: intensity }
flashFadeTimer = setTimeout(() => {
flash.value = { ...flash.value, opacity: 0 }
}, 24)
flashTimer = setTimeout(() => {
flash.value = { active: false, color, opacity: 0 }
}, life)
}
function showComboBanner(chain, points) {
if (chain <= 1) return
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
comboBanner.value = {
show: true,
text: `连消 ×${chain} +${points}`,
tier
}
if (comboBannerTimer) clearTimeout(comboBannerTimer)
comboBannerTimer = setTimeout(() => {
comboBanner.value = { ...comboBanner.value, show: false }
}, 1100)
}
function triggerShake(intensity = 1) {
screenShaking.value = true
if (shakeTimer) clearTimeout(shakeTimer)
shakeTimer = setTimeout(() => {
screenShaking.value = false
}, intensity >= 2 ? 420 : 280)
}
function clearAll() {
floatingTexts.value = []
burstParticles.value = []
ripples.value = []
comboBanner.value = { show: false, text: '', tier: 1 }
screenShaking.value = false
flash.value = { active: false, color: '#ffffff', opacity: 0 }
}
return {
floatingTexts,
burstParticles,
ripples,
comboBanner,
screenShaking,
flash,
spawnFloatingText,
spawnBurst,
spawnRipple,
showComboBanner,
triggerShake,
triggerFlash,
clearAll
}
}
@@ -1,112 +0,0 @@
/**
* 游戏进度持久化:关卡、星级、连胜、每日打卡
*/
import { ref } from 'vue'
import { calcLevelStars } from '../data/gameLevels.js'
const STORAGE_KEY = 'tongji_game_progress_v1'
const DEFAULT = {
currentLevel: 1,
maxUnlocked: 1,
stars: {},
winStreak: 0,
dailyStreak: 0,
lastPlayDate: '',
totalWins: 0,
bestCombo: 0,
totalStars: 0
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function sumStars(starsMap) {
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
}
function loadRaw() {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
} catch (_) {}
return { ...DEFAULT }
}
function saveRaw(data) {
try {
uni.setStorageSync(STORAGE_KEY, data)
} catch (_) {}
}
export function useGameProgress() {
const progress = ref(loadRaw())
function persist() {
progress.value.totalStars = sumStars(progress.value.stars)
saveRaw(progress.value)
}
/** 进入游戏时更新每日打卡 */
function touchDailyPlay() {
const today = todayStr()
const last = progress.value.lastPlayDate
if (last === today) return progress.value.dailyStreak
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
if (last === yStr) {
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
} else {
progress.value.dailyStreak = 1
}
progress.value.lastPlayDate = today
persist()
return progress.value.dailyStreak
}
function getStars(levelId) {
return progress.value.stars[String(levelId)] || 0
}
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
const key = String(levelId)
const prev = progress.value.stars[key] || 0
if (stars > prev) progress.value.stars[key] = stars
progress.value.winStreak = (progress.value.winStreak || 0) + 1
progress.value.totalWins = (progress.value.totalWins || 0) + 1
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
const next = levelId + 1
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
progress.value.currentLevel = next
persist()
return { stars, newBest: stars > prev }
}
function recordLoss() {
progress.value.winStreak = 0
persist()
}
function setCurrentLevel(levelId) {
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
persist()
}
return {
progress,
touchDailyPlay,
getStars,
recordWin,
recordLoss,
setCurrentLevel,
persist
}
}
-654
View File
@@ -1,654 +0,0 @@
/**
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
*/
import { svgToDataUrl } from '../utils/svgDataUrl.js'
function gid(key, suffix) {
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
}
function wrap(key, body) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
}
function shadow(key) {
const id = gid(key, 'sh')
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
}
function shine(x, y, rx, ry) {
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
}
/** 圆形水果 */
function artRound(key, base, dark, light, extra = '') {
const g = gid(key, 'g')
return wrap(key, `
<defs>
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
<stop offset="0%" stop-color="${light}"/>
<stop offset="55%" stop-color="${base}"/>
<stop offset="100%" stop-color="${dark}"/>
</radialGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
${shine(24, 28, 7, 5)}
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
${extra}
</g>`)
}
/** 叶菜 */
function artLeafy(key, light, mid, dark) {
const g1 = gid(key, 'g1')
const g2 = gid(key, 'g2')
return wrap(key, `
<defs>
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
</linearGradient>
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
${shine(26, 24, 6, 8)}
</g>`)
}
/** 西兰花 */
function artBroccoli(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs>
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
</radialGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
${shine(28, 20, 5, 4)}
</g>`)
}
/** 长条(黄瓜/香蕉/玉米) */
function artLong(key, base, dark, light, type = 'cucumber') {
const g = gid(key, 'g')
if (type === 'banana') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
${shine(30, 26, 5, 3)}
</g>`)
}
if (type === 'corn') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
Array.from({ length: 4 }, (_, c) =>
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
).join('')
).join('')}</g>
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
${shine(26, 26, 5, 8)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
</g>`)
}
/** 茄子 */
function artEggplant(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
${shine(26, 32, 5, 7)}
</g>`)
}
/** 番茄 */
function artTomato(key) {
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
}
/** 甜椒 */
function artPepper(key, color, dark) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
${shine(28, 26, 5, 6)}
</g>`)
}
/** 洋葱 */
function artOnion(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
${shine(24, 30, 6, 5)}
</g>`)
}
/** 葡萄串 */
function artGrapes(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
${shine(28, 24, 3, 2)}
</g>`)
}
/** 樱桃 */
function artCherry(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
</g>`)
}
/** 草莓 */
function artStrawberry(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
${shine(26, 28, 4, 6)}
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
).join('')}</g>
</g>`)
}
/** 豆类 */
function artBeans(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
</g>`)
}
/** 饮品杯 */
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
const g = gid(key, 'g')
if (type === 'bottle') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
${shine(28, 30, 4, 10)}
</g>`)
}
if (type === 'wine') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
${shine(26, 32, 4, 8)}
</g>`)
}
/** 蘑菇 */
function artMushroom(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
${shine(24, 24, 6, 4)}
</g>`)
}
/** 芹菜 */
function artCelery(key) {
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
}
/** 四季豆 */
function artGreenBean(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
</g>`)
}
/** 饭碗 */
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
${shine(24, 34, 6, 3)}
</g>`)
}
/** 薯类 */
function artRoot(key, base, dark, light) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
${shine(24, 30, 5, 6)}
</g>`)
}
/** 肉类 */
function artMeat(key, base, dark, type = 'steak') {
const g = gid(key, 'g')
if (type === 'chicken') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
${shine(26, 32, 5, 6)}
</g>`)
}
if (type === 'fish') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
${shine(28, 30, 6, 4)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
${shine(26, 28, 6, 4)}
</g>`)
}
/** 虾/蟹 */
function artSeafood(key, type = 'shrimp') {
const g = gid(key, 'g')
if (type === 'crab') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
${shine(30, 28, 5, 4)}
</g>`)
}
/** 菠萝 */
function artPineapple(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
).join('')}</g>
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
${shine(26, 32, 5, 6)}
</g>`)
}
/** 奇异果 */
function artKiwi(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
${shine(26, 28, 4, 3)}
</g>`)
}
/** 蜜瓜 */
function artMelon(key) {
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
}
/** 蜂蜜 */
function artHoney(key) {
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
}
/** 面包类 */
function artBread(key, base, dark, type = 'loaf') {
const g = gid(key, 'g')
if (type === 'donut') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
${shine(24, 28, 5, 4)}
</g>`)
}
if (type === 'mantou') {
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
${shine(24, 32, 6, 4)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
${shine(22, 28, 8, 5)}
</g>`)
}
/** 面条 */
function artNoodle(key, soup = '#d7ccc8') {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
</g>`)
}
/** 南瓜 */
function artPumpkin(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
${shine(26, 28, 5, 6)}
</g>`)
}
/** 薯条 */
function artFries(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
</g>`)
}
/** 西瓜 */
function artWatermelon(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
${shine(24, 30, 4, 3)}
</g>`)
}
/** 糖果 */
function artCandy(key, base, dark, type = 'lollipop') {
const g = gid(key, 'g')
if (type === 'wrap') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
</g>`)
}
return wrap(key, `
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
${shine(26, 24, 4, 3)}
</g>`)
}
/** 糯米团 */
function artDango(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="22" cy="36" r="8" fill="#fff"/>
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
<circle cx="42" cy="36" r="8" fill="#fff"/>
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
</g>`)
}
/** 爆米花 */
function artPopcorn(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
</g>`)
}
const FOOD_ART = {
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
broccoli: () => artBroccoli('broccoli'),
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
eggplant: () => artEggplant('eggplant'),
tomato: () => artTomato('tomato'),
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
onion: () => artOnion('onion'),
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
cherry: () => artCherry('cherry'),
grape: () => artGrapes('grape'),
strawberry: () => artStrawberry('strawberry'),
soybean: () => artBeans('soybean'),
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
mushroom: () => artMushroom('mushroom'),
celery: () => artCelery('celery'),
greenBean: () => artGreenBean('greenBean'),
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
shrimp: () => artSeafood('shrimp', 'shrimp'),
crab: () => artSeafood('crab', 'crab'),
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
pineapple: () => artPineapple('pineapple'),
kiwi: () => artKiwi('kiwi'),
melon: () => artMelon('melon'),
honey: () => artHoney('honey'),
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
udon: () => artNoodle('udon', '#d9b88f'),
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
popcorn: () => artPopcorn('popcorn'),
ramen: () => artNoodle('ramen', '#e0a96d'),
pumpkin: () => artPumpkin('pumpkin'),
bakedPotato: () => artFries('bakedPotato'),
watermelon: () => artWatermelon('watermelon'),
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
stickyRice: () => artDango('stickyRice')
}
const imgCache = {}
/** 获取食材插画 data URL(带缓存) */
export function getFoodImg(key) {
if (imgCache[key]) return imgCache[key]
const builder = FOOD_ART[key]
if (!builder) return ''
imgCache[key] = svgToDataUrl(builder())
return imgCache[key]
}
export function warmUpFoodImgs(keys) {
keys.forEach((k) => getFoodImg(k))
}
@@ -1,50 +0,0 @@
/**
* 食材图标 URLTwemoji PNG,小程序 / H5 通用)
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
*/
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
/** emoji → Twemoji PNG 地址 */
export function emojiToTwemojiUrl(emoji) {
if (!emoji) return ''
const parts = []
for (let i = 0; i < emoji.length;) {
const cp = emoji.codePointAt(i)
if (!cp) break
parts.push(cp.toString(16))
i += cp > 0xffff ? 2 : 1
}
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
}
const imgCache = {}
export function getFoodImgUrl(emoji) {
if (!emoji) return ''
if (imgCache[emoji]) return imgCache[emoji]
const url = emojiToTwemojiUrl(emoji)
imgCache[emoji] = url
return url
}
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
export function warmUpFoodImgs(foods) {
const list = Array.isArray(foods) ? foods : []
list.forEach((f) => {
const icon = typeof f === 'string' ? f : f?.icon
// 优先预热自定义图片素材,缺省回退 emoji PNG
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
if (!url) return
// #ifdef MP-WEIXIN
try {
uni.getImageInfo({ src: url, fail: () => {} })
} catch (_) {}
// #endif
// #ifdef H5
try {
const img = new Image()
img.src = url
} catch (_) {}
// #endif
})
}
@@ -1,205 +0,0 @@
/**
* 全量食材库:key 用于消除匹配,img 为 Twemoji PNG(小程序可用)
*/
import { getFoodImgUrl } from './gameFoodImg.js'
export const GI_LABEL = { low: '低血糖', mid: '中血糖', high: '高血糖' }
/** 若实际食用时的升糖幅度(教育提示用,正值=升糖) */
export function getEatVal(food) {
if (food.eatVal != null) return food.eatVal
if (food.gi === 'high') return Math.abs(food.val)
if (food.gi === 'mid') return Math.max(1, food.val)
return Math.max(1, Math.abs(food.val))
}
/** 从餐盘消除该食物后的血糖变化(负值=控糖有益) */
export function getClearVal(food) {
if (food.clearVal != null) return food.clearVal
const eat = getEatVal(food)
if (food.gi === 'high') return -Math.max(8, Math.round(eat * 0.5))
if (food.gi === 'mid') return -1
return 0
}
/** 点击食材时的升糖说明文案 */
export function formatEatLabel(food) {
if (food.gi === 'low') return '升糖低'
return `若食用 +${getEatVal(food)}`
}
/** 含糖等级简称:低糖 / 中糖 / 高糖 */
export const GI_SHORT = { low: '低糖', mid: '中糖', high: '高糖' }
/** 含糖等级对应的提示主色(绿 / 橙 / 红) */
export const GI_COLOR = { low: '#16a34a', mid: '#ea580c', high: '#dc2626' }
/** 部分代表食物的专属科普文案(一句话,便于老人记忆) */
const FOOD_TIP_OVERRIDE = {
whiteRice: '精制主食,升糖快,建议小份',
stickyRice: '糯米黏软,升糖很快,要少吃',
whiteBread: '精制面食,升糖快',
mantou: '白面馒头,升糖快,配菜一起吃更稳',
youtiao: '油炸又高糖,少吃为好',
sugar: '纯糖,升糖最快,尽量不吃',
maltose: '糖分很高,升糖快',
soda: '含糖饮料,升糖很快,不建议喝',
orangeJuice: '果汁含糖高,吃整果更好',
watermelon: '很甜,升糖快,一次别吃太多',
donut: '又甜又油,升糖高',
banana: '偏甜水果,适量吃',
sweetPotato: '粗粮但有糖,可代替部分主食',
corn: '粗粮主食,适量吃',
honey: '本质是糖,别当健康品多吃',
broccoli: '新鲜蔬菜,升糖很慢,可多吃',
cucumber: '清爽蔬菜,几乎不升糖',
lettuce: '绿叶菜,升糖很低,可多吃',
celery: '高纤维蔬菜,升糖低',
apple: '带皮整果,升糖较慢',
milk: '低糖,含蛋白,适量喝好',
fish: '优质蛋白,升糖低',
beef: '蛋白为主,升糖低',
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
friedNoodles: '油多又是精面,升糖快',
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
sandwich: '夹心面包精制碳多,升糖快',
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
milletCongee: '小米粥熬软升糖快,糖友控量',
shandongPancake: '煎饼加油面,升糖中等偏快',
cake: '蛋糕又甜又油,升糖快',
biscuit: '饼干又甜又碎,升糖快',
wonton: '馄饨面皮精白,升糖中等,别多吃',
blackCoffee: '不加糖的黑咖啡,升糖很低'
}
/** 含糖等级通用科普文案 */
const GI_TIP_DEFAULT = {
low: '升糖慢,相对放心,可常吃',
mid: '升糖中等,注意分量',
high: '含糖高、升糖快,要少吃'
}
/** 点击食物时展示的一句话科普(认知教育用) */
export function getFoodTip(food) {
if (!food) return ''
if (food.tip) return food.tip
if (FOOD_TIP_OVERRIDE[food.key]) return FOOD_TIP_OVERRIDE[food.key]
return GI_TIP_DEFAULT[food.gi] || ''
}
/** 自定义图片素材目录(腾讯云 COS,优先使用真实图片而非 emoji) */
const GAMES_IMG_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games'
const RAW_FOODS = [
// 低 GI
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
{ key: 'broccoli', icon: '🥦', name: '西兰花', gi: 'low', val: -3, color: '#4E8A3A' },
{ key: 'apple', icon: '🍎', name: '苹果', gi: 'low', val: -2, color: '#E53935' },
{ key: 'cucumber', icon: '🥒', name: '黄瓜', gi: 'low', val: -4, color: '#66A82F' },
{ key: 'eggplant', icon: '🍆', name: '茄子', gi: 'low', val: -3, color: '#7E57C2' },
{ key: 'tomato', icon: '🍅', name: '番茄', gi: 'low', val: -2, color: '#EF5350' },
{ key: 'pepper', icon: '🫑', name: '青椒', gi: 'low', val: -3, color: '#43A047' },
{ key: 'onion', icon: '🧅', name: '洋葱', gi: 'low', val: -2, color: '#CDA06B' },
{ key: 'pear', icon: '🍐', name: '雪梨', gi: 'low', val: -2, color: '#C0CA33' },
{ key: 'orange', icon: '🍊', name: '橙子', gi: 'low', val: -2, color: '#FB8C00' },
{ key: 'peach', icon: '🍑', name: '桃子', gi: 'low', val: -2, color: '#FF8A65' },
{ key: 'cherry', icon: '🍒', name: '樱桃', gi: 'low', val: -2, color: '#C2185B' },
{ key: 'grape', icon: '🍇', name: '葡萄', gi: 'low', val: -1, color: '#8E24AA' },
{ key: 'strawberry', icon: '🍓', name: '草莓', gi: 'low', val: -2, color: '#EC407A' },
{ key: 'soybean', icon: '🫘', name: '黄豆', gi: 'low', val: -3, color: '#C9A063' },
{ key: 'milk', icon: '🥛', name: '牛奶', gi: 'low', val: -2, color: '#B0BEC5' },
{ key: 'tea', icon: '🍵', name: '红茶', gi: 'low', val: -1, color: '#A1573B' },
{ key: 'mushroom', icon: '🍄', name: '香菇', gi: 'low', val: -3, color: '#8D6E63' },
{ key: 'celery', icon: '🌿', name: '芹菜', gi: 'low', val: -4, color: '#7CB342' },
{ key: 'greenBean', icon: '🫛', name: '四季豆', gi: 'low', val: -3, color: '#689F38' },
// 中 GI
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39', img: `${GAMES_IMG_BASE}/%E7%95%AA%E8%96%AF.png` },
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
{ key: 'beef', icon: '🥩', name: '牛肉', gi: 'mid', val: 3, color: '#C0504D' },
{ key: 'leanMeat', icon: '🍖', name: '瘦肉', gi: 'mid', val: 3, color: '#B5654D' },
{ key: 'shrimp', icon: '🦐', name: '虾仁', gi: 'mid', val: 2, color: '#FF7043' },
{ key: 'crab', icon: '🦀', name: '螃蟹', gi: 'mid', val: 2, color: '#F4623A' },
{ key: 'banana', icon: '🍌', name: '香蕉', gi: 'mid', val: 6, color: '#FDD835' },
{ key: 'mango', icon: '🥭', name: '芒果', gi: 'mid', val: 6, color: '#FFB300' },
{ key: 'pineapple', icon: '🍍', name: '菠萝', gi: 'mid', val: 6, color: '#FBC02D' },
{ key: 'kiwi', icon: '🥝', name: '奇异果', gi: 'mid', val: 4, color: '#9CCC65' },
{ key: 'melon', icon: '🍈', name: '哈密瓜', gi: 'mid', val: 6, color: '#AED581' },
{ key: 'honey', icon: '🍯', name: '蜂蜜', gi: 'mid', val: 6, color: '#F9A825' },
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
{ key: 'blackCoffee', icon: '☕', name: '黑咖啡', gi: 'low', val: -1, color: '#4E342E', img: `${GAMES_IMG_BASE}/%E9%BB%91%E5%92%96%E5%95%A1.png` },
{ key: 'wonton', icon: '🥟', name: '馄饨', gi: 'mid', val: 5, color: '#E8DCC8', img: `${GAMES_IMG_BASE}/%E9%A6%84%E9%A5%A8.png` },
{ key: 'milletCongee', icon: '🥣', name: '小米粥', gi: 'mid', val: 5, color: '#F5E6B8', img: `${GAMES_IMG_BASE}/%E5%B0%8F%E7%B1%B3%E7%B2%A5.png` },
{ key: 'shandongPancake', icon: '🥞', name: '山东煎饼', gi: 'high', val: 16, color: '#D4A574', img: `${GAMES_IMG_BASE}/%E5%B1%B1%E4%B8%9C%E7%85%8E%E9%A5%BC.png` },
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
// 高 GI
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0', img: `${GAMES_IMG_BASE}/%E7%B1%B3%E9%A5%AD.png` },
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F', img: `${GAMES_IMG_BASE}/%E6%B2%B9%E6%9D%A1.png` },
{ key: 'sandwich', icon: '🥪', name: '三明治', gi: 'high', val: 16, color: '#D9B88F', img: `${GAMES_IMG_BASE}/%E4%B8%89%E6%98%8E%E6%B2%BB.png` },
{ key: 'biscuit', icon: '🍪', name: '饼干', gi: 'high', val: 18, color: '#C9A063', img: `${GAMES_IMG_BASE}/%E9%A5%BC%E5%B9%B2.png` },
{ key: 'cake', icon: '🎂', name: '蛋糕', gi: 'high', val: 22, color: '#F48FB1', img: `${GAMES_IMG_BASE}/%E8%9B%8B%E7%B3%95.png` },
{ key: 'eightTreasureCongee', icon: '🥣', name: '八宝粥', gi: 'high', val: 17, color: '#E8C4A0', img: `${GAMES_IMG_BASE}/%E5%85%AB%E5%AE%9D%E7%B2%A5.png` },
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
{ key: 'pumpkin', icon: '🎃', name: '南瓜', gi: 'high', val: 15, color: '#EF6C00' },
{ key: 'bakedPotato', icon: '🍟', name: '焗薯', gi: 'high', val: 20, color: '#F2C14E' },
{ key: 'watermelon', icon: '🍉', name: '西瓜', gi: 'high', val: 16, color: '#F0506A' },
{ key: 'sugar', icon: '🍭', name: '砂糖', gi: 'high', val: 25, color: '#FF79B0' },
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
]
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
const eatVal = getEatVal(f)
const clearVal = getClearVal({ ...f, eatVal })
return {
id: i + 1,
...f,
eatVal,
clearVal,
giLabel: GI_LABEL[f.gi],
get img() {
// 优先使用自定义图片素材,缺省回退到 emoji PNG
return f.img || getFoodImgUrl(f.icon)
}
}
})
/** 按 key 查找食材(目标栏等用) */
export function getFoodByKey(key) {
return FOOD_LIBRARY.find((f) => f.key === key) || null
}
/** 为棋盘 tile 使用的 plain 对象(img 预计算,避免 getter 在响应式里反复触发) */
export function cloneFoodType(food) {
return {
id: food.id,
key: food.key,
icon: food.icon,
name: food.name,
gi: food.gi,
giLabel: food.giLabel,
val: food.val,
color: food.color,
img: food.img || getFoodImgUrl(food.icon)
}
}
-60
View File
@@ -1,60 +0,0 @@
/**
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
*/
/**
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
* 第一关固定为这 16 种带真实图片素材的食物。
*/
const FIXED_LEVEL_FOODS = {
1: [
'sandwich',
'eightTreasureCongee',
'milkOats',
'milletCongee',
'shandongPancake',
'grainMantou',
'riceNoodleSoup',
'youtiao',
'friedNoodles',
'sweetPotato',
'centuryEggCongee',
'whiteRice',
'cake',
'biscuit',
'wonton',
'blackCoffee'
]
}
export function getLevelConfig(levelId) {
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
const tier = Math.floor((lv - 1) / 5)
return {
id: lv,
// 步数充裕:仅作展示,认全目标即可通关
moves: Math.max(40, 60 - tier * 2),
// 认识目标平缓增长,避免给老人压力
goalTargets: [6 + tier, 8 + tier],
scoreTarget: 2000 + lv * 300,
startGlucose: 50,
// 固定食材(可选):存在时本关只用这些食物
foods: FIXED_LEVEL_FOODS[lv] || null,
boosters: {
insulin: 3,
fiber: 1,
meal: 5
}
}
}
/** 根据本局表现计算 1~3 星:完成即得星,步数越省星越多(不依赖血糖) */
export function calcLevelStars({ movesLeft, levelConfig }) {
const totalMoves = levelConfig?.moves || 1
const ratio = totalMoves > 0 ? movesLeft / totalMoves : 0
let stars = 1
if (ratio >= 0.3) stars = 2
if (ratio >= 0.55) stars = 3
return stars
}
@@ -0,0 +1,40 @@
# 识糖小课堂平台接入说明
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期、性别自动分配最多 7 人的同行组。
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
## 接口
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key``learned_count``score``ended`
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`
## 部署顺序
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php``TcmController.php`
3. 重新构建并上传小程序前端。
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
## 上线前检查
- 用男女各两个测试账号进入,确认被分入对应性别组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
- 周一验证新周重新分组,旧周成绩不带入新周。
@@ -0,0 +1,43 @@
# 识糖小课堂独立功能包
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
## 目录内容
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
- `game-endless.scss`:完整页面与动画样式。
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
## 迁移步骤
1. 复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录,并确保目标项目同时提供 `tongji/components/TongjiIcon.vue``tongji/utils/svgDataUrl.js`
2. 在目标项目 `pages.json``tongji` 分包 `pages` 数组中加入:
```json
{
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
```
3. 使用 `/tongji/endless-game/index` 打开游戏。
4. 微信小程序后台需要将 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com` 配置为合法的音频与图片下载域名。
## 说明
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`
- 本功能包依赖 uni-app Vue 3、`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
- 食物风险等级和科普文案集中在 `index.vue``FOODS` 配置中,正式上线前仍需由医院医生审核。
@@ -0,0 +1,71 @@
/**
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
*/
export function useGameAuth(proxy) {
let loginPromise = null
function hasToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearToken() {
uni.removeStorageSync('token')
}
function wxLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
fail: reject
})
})
}
async function loginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res?.code !== 1 || !res.data?.token) {
clearToken()
throw new Error(res?.msg || '登录失败')
}
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return true
}
async function verifyOrLogin() {
if (hasToken()) {
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res?.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
if (res?.code !== -1) return true
} catch (_) {
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
return true
}
clearToken()
}
const code = await wxLoginCode()
return loginWithCode(code)
}
async function ensureLoggedIn() {
if (loginPromise) return loginPromise
loginPromise = verifyOrLogin()
.then(ok => !!ok)
.catch(() => false)
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
.finally(() => { loginPromise = null })
return loginPromise
}
return { ensureLoggedIn }
}
@@ -0,0 +1,251 @@
import { ref } from 'vue'
const EMPTY_BOARD = {
week_start: '',
week_end: '',
sex_label: '同行',
group_size: 7,
member_count: 0,
players: [],
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
invite_code: ''
}
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
function createSessionKey() {
const random = Math.random().toString(36).slice(2, 12)
return `game_${Date.now().toString(36)}_${random}`
}
function readPendingPayloads() {
try {
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
if (!Array.isArray(stored)) return []
return stored.filter(item => (
item
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
&& Number(item.learned_count) >= 0
)).slice(-8)
} catch (_) {
return []
}
}
/**
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
*/
export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
let sessionKey = createSessionKey()
let syncTimer = null
let syncing = false
let queuedPayloads = readPendingPayloads()
let connectPromise = null
function persistPendingPayloads() {
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
}
async function api(request) {
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
return proxy.apiUrl(request, false)
}
function applyLeaderboard(data, confirmedForSession = '') {
if (!data || !Array.isArray(data.players)) return
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
if (!inactiveSessionResponse) {
leaderboard.value = {
...EMPTY_BOARD,
...data,
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
players: data.players
}
}
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
}
connected.value = true
syncStatus.value = 'synced'
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET'
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data)
return res.data
}
async function acceptShare(inviteCode) {
const code = String(inviteCode || '').trim().toUpperCase()
if (!code) return
try {
await api({
url: '/api/tcm/gameAcceptShare',
method: 'POST',
data: { invite_code: code }
})
} catch (_) {
// 分享关系是辅助能力,不阻断进入游戏。
}
}
async function connect(inviteCode = '') {
if (connectPromise) return connectPromise
connectPromise = (async () => {
loading.value = true
try {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('登录失败')
await acceptShare(inviteCode)
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
return false
} finally {
loading.value = false
connectPromise = null
}
})()
return connectPromise
}
function beginSession() {
if (syncTimer) clearTimeout(syncTimer)
sessionKey = createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
learned_count: Math.max(0, Number(learnedCount) || 0),
score: Math.max(0, Number(score) || 0),
ended: ended ? 1 : 0
}
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
if (existingIndex >= 0) {
const existing = queuedPayloads[existingIndex]
queuedPayloads[existingIndex] = {
...nextPayload,
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
score: Math.max(existing.score, nextPayload.score),
ended: Math.max(existing.ended, nextPayload.ended)
}
} else {
queuedPayloads.push(nextPayload)
}
persistPendingPayloads()
syncStatus.value = 'pending'
if (syncTimer) clearTimeout(syncTimer)
if (ended) {
flushProgress()
} else {
syncTimer = setTimeout(flushProgress, 1000)
}
}
async function flushProgress() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
if (syncing || !queuedPayloads.length) return
const payload = queuedPayloads[0]
syncing = true
syncStatus.value = 'syncing'
try {
if (!connected.value) {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('未登录')
}
const res = await api({
url: '/api/tcm/gameSubmitProgress',
method: 'POST',
data: payload
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '成绩保存失败')
}
applyLeaderboard(res.data, payload.session_key)
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
queuedPayloads = queuedPayloads.filter(item => (
item.session_key !== payload.session_key
|| Number(item.learned_count) > Number(payload.learned_count)
|| Number(item.score) > Number(payload.score)
|| Number(item.ended) > Number(payload.ended)
))
persistPendingPayloads()
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
syncing = false
if (queuedPayloads.length && connected.value) {
setTimeout(flushProgress, 80)
}
}
}
async function syncAndRefresh(learnedCount, score) {
queueProgress(learnedCount, score, false)
await flushProgress()
if (!connected.value) {
await connect()
if (queuedPayloads.length) await flushProgress()
} else {
try { await refreshLeaderboard() } catch (_) {}
}
}
async function recordShare() {
try {
if (!connected.value && !(await connect())) return
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
if (res?.code === 1 && res.data?.invite_code) {
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
}
} catch (_) {
// 分享本身仍可进行,统计失败不影响用户。
}
}
function dispose() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
persistPendingPayloads()
}
return {
connected,
loading,
syncStatus,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
queueProgress,
flushProgress,
syncAndRefresh,
refreshLeaderboard,
recordShare,
dispose
}
}
@@ -25,6 +25,13 @@ const SRC = {
* rate 不同可在同一素材上听出明显差异
*/
const SFX = {
// —— 基础素材别名(供分层音效直接调用)——
tap: { src: SRC.tap, volume: 0.32, rate: 1, pool: 3 },
pop: { src: SRC.pop, volume: 0.3, rate: 1, pool: 4 },
fanfare: { src: SRC.fanfare, volume: 0.56, rate: 1, pool: 2 },
blast: { src: SRC.blast, volume: 0.62, rate: 1, pool: 2 },
sparkle: { src: SRC.sparkle, volume: 0.5, rate: 1, pool: 2 },
// —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
@@ -77,6 +84,43 @@ const SFX = {
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
}
const WARMUP_SFX = [
'select', 'swap', 'swapFail', 'drop', 'dropLight',
'match3', 'match4', 'match5', 'combo2', 'combo3', 'comboMega',
'insulin', 'sparkle', 'reshuffle', 'lose'
]
function createAudioContext() {
if (typeof Audio !== 'undefined') {
const audio = new Audio()
audio.preload = 'auto'
return {
get src() { return audio.src },
set src(value) { audio.src = value },
get volume() { return audio.volume },
set volume(value) { audio.volume = value },
get playbackRate() { return audio.playbackRate },
set playbackRate(value) { audio.playbackRate = value },
play() {
const promise = audio.play()
if (promise?.catch) promise.catch(() => {})
},
stop() {
audio.pause()
try { audio.currentTime = 0 } catch (_) {}
},
seek(time) {
try { audio.currentTime = time } catch (_) {}
},
destroy() {
audio.pause()
audio.removeAttribute('src')
}
}
}
return uni.createInnerAudioContext()
}
class SfxPool {
constructor(src, size, volume, rate = 1) {
this.src = src
@@ -88,40 +132,42 @@ class SfxPool {
this.warmedUp = false
}
createOne() {
const ctx = createAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.volume
try {
ctx.playbackRate = this.rate
} catch (_) {}
this.list.push(ctx)
return ctx
}
create() {
for (let i = 0; i < this.size; i++) {
const ctx = uni.createInnerAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.volume
try {
ctx.playbackRate = this.rate
} catch (_) {}
this.list.push(ctx)
}
while (this.list.length < this.size) this.createOne()
}
warmUp() {
if (this.warmedUp) return
if (!this.list.length) this.create()
this.list.forEach((ctx) => {
try {
ctx.volume = 0
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = this.volume
} catch (_) {}
}, 60)
} catch (_) {}
})
const ctx = this.list[0] || this.createOne()
try {
ctx.volume = 0
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = this.volume
} catch (_) {}
}, 60)
} catch (_) {}
if (this.size > 1) this.cursor = 1
this.warmedUp = true
}
play(scale = 1, rateMul = 1) {
if (!this.list.length) this.create()
if (this.list.length < this.size) this.create()
const ctx = this.list[this.cursor % this.list.length]
this.cursor += 1
const vol = Math.min(1, this.volume * scale)
@@ -204,7 +250,8 @@ export function useGameSfx() {
function warmUp() {
if (!enabled.value) return
ensureAudioOption()
Object.keys(SFX).forEach((name) => {
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
names.forEach((name) => {
getPool(name)?.warmUp()
})
}
@@ -0,0 +1,570 @@
.eg-page {
min-height: 100vh;
color: #173b32;
background: linear-gradient(180deg, #eefbf4 0%, #f9f3dd 56%, #fffaf1 100%);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.eg-confetti-layer { position: fixed; z-index: 80; inset: 0; overflow: hidden; pointer-events: none; }
.eg-confetti-piece {
position: absolute;
top: -70rpx;
display: block;
border-radius: 3rpx;
box-shadow: 0 2rpx 4rpx rgba(0,0,0,.12);
animation-name: egConfettiFall;
animation-timing-function: cubic-bezier(.18,.72,.35,1);
animation-fill-mode: both;
}
.eg-confetti-piece.is-emoji { width: auto !important; height: auto !important; background: transparent !important; box-shadow: none; font-size: 42rpx; }
.eg-nav {
position: relative;
display: grid;
box-sizing: border-box;
grid-template-columns: 156rpx minmax(0, 1fr) 156rpx;
align-items: center;
column-gap: 12rpx;
padding-right: 28rpx;
padding-bottom: 16rpx;
padding-left: 28rpx;
}
.eg-nav-leading { display: flex; width: 156rpx; justify-content: flex-start; }
.eg-nav-btn {
display: flex;
width: 72rpx;
height: 72rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(21, 94, 75, .12);
border-radius: 24rpx;
background: rgba(255,255,255,.82);
}
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
.eg-content { padding: 0 16rpx 48rpx; }
.eg-score-card {
display: flex;
align-items: center;
margin-bottom: 16rpx;
padding: 18rpx 24rpx;
border: 2rpx solid rgba(21, 94, 75, .1);
border-radius: 28rpx;
background: rgba(255,255,255,.9);
box-shadow: 0 10rpx 28rpx rgba(32, 78, 43, .08);
}
.eg-stat { display: flex; flex: 1; flex-direction: column; align-items: center; }
.eg-stat--main { border-right: 2rpx solid #e2eee8; border-left: 2rpx solid #e2eee8; }
.eg-stat-label { color: #71847d; font-size: 24rpx; }
.eg-stat-value { margin-top: 4rpx; color: #204e2b; font-size: 38rpx; font-weight: 800; }
.eg-stat-score { margin-top: 2rpx; color: #e16f24; font-size: 46rpx; font-weight: 900; }
.eg-stat--rank { position: relative; cursor: pointer; }
.eg-stat--rank.is-locked { opacity: .52; }
.eg-stat-rank-value { margin-top: 1rpx; color: #176b52; font-size: 35rpx; font-weight: 1000; line-height: 1.08; }
.eg-stat-rank-hint { margin-top: 2rpx; color: #df7427; font-size: 18rpx; font-weight: 800; }
.eg-task-card {
margin-bottom: 16rpx;
padding: 18rpx 22rpx;
border-radius: 24rpx;
background: rgba(255, 249, 223, .94);
box-shadow: 0 8rpx 22rpx rgba(118, 89, 25, .08);
}
.eg-task-card { position: relative; overflow: visible; }
.eg-task-reward-flight {
position: absolute;
z-index: 72;
top: 4rpx;
left: 42%;
display: flex;
width: 112rpx;
height: 92rpx;
align-items: center;
justify-content: center;
border: 4rpx solid rgba(255,255,255,.96);
border-radius: 24rpx;
background: #fff7e8;
box-shadow: 0 10rpx 24rpx rgba(159,94,23,.28);
animation: egTaskRewardFlight 1.12s cubic-bezier(.18,.78,.22,1) both;
pointer-events: none;
}
.eg-task-reward-flight > image { position: relative; z-index: 2; width: 78rpx; height: 66rpx; }
.eg-task-reward-flight > text { position: absolute; z-index: 3; top: -14rpx; right: -16rpx; display: flex; width: 48rpx; height: 48rpx; align-items: center; justify-content: center; border: 4rpx solid #fff; border-radius: 50%; color: #fff; background: #ed7625; box-shadow: 0 5rpx 12rpx rgba(191,75,19,.3); font-size: 25rpx; font-weight: 1000; }
.eg-task-reward-glow { position: absolute; z-index: 1; inset: -15rpx; border-radius: 32rpx; background: radial-gradient(circle, rgba(255,210,67,.52), rgba(255,210,67,0) 68%); animation: egRewardGlow .42s ease-in-out infinite alternate; }
.eg-task-head { display: flex; align-items: center; justify-content: space-between; color: #7c5b1b; font-size: 27rpx; font-weight: 700; }
.eg-task-name { display: flex; align-items: center; gap: 10rpx; }
.eg-task-count { color: #9a6718; font-size: 29rpx; }
.eg-progress { height: 14rpx; margin-top: 12rpx; overflow: hidden; border-radius: 999rpx; background: #eadfbd; }
.eg-progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #f0a128, #f4c34f); transition: width .25s; }
.eg-board-shell {
position: relative;
padding: 14rpx;
border: 2rpx solid rgba(34, 103, 79, .16);
border-radius: 34rpx;
background: rgba(255,255,255,.92);
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
}
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
.eg-risk-dots { display: flex; gap: 5rpx; }
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
.eg-risk-dot.on { background: #e96b3b; box-shadow: 0 0 0 3rpx rgba(233,107,59,.12); }
.eg-board { display: flex; flex-direction: column; gap: 8rpx; opacity: 1; transition: opacity .15s; }
.eg-board.is-busy { opacity: .82; }
.eg-row { display: flex; gap: 8rpx; }
.eg-cell {
position: relative;
display: flex;
height: 166rpx;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
border: 5rpx solid transparent;
border-radius: 26rpx;
box-shadow: inset 0 -5rpx 0 rgba(0,0,0,.05), 0 5rpx 12rpx rgba(31, 66, 53, .08);
transition: transform .16s cubic-bezier(.2,.8,.2,1), border-color .15s, opacity .16s, filter .16s;
will-change: transform;
}
.eg-cell.is-dragging { transition: transform .055s linear, border-color .15s; }
.eg-cell.is-selected { z-index: 2; border-color: #167d61; transform: scale(1.06); box-shadow: 0 0 0 6rpx rgba(22,125,97,.14); }
.eg-cell.is-camel-target { border-color: #f59e0b; animation: egPulse 1s infinite; }
.eg-replace-target { position: absolute; z-index: 7; right: 7rpx; bottom: 6rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; background: #1570a6; box-shadow: 0 3rpx 9rpx rgba(20,91,135,.28); font-size: 20rpx; font-weight: 900; }
.eg-cell.is-clearing {
z-index: 8;
border-color: #fff;
animation: egHit .22s cubic-bezier(.2,.9,.3,1) forwards;
filter: brightness(1.28) saturate(1.25);
}
.eg-cell.is-yellow,
.eg-cell.is-cream,
.eg-cell.is-red,
.eg-cell.is-purple { background: #fffaf0; }
.eg-cell.is-level-low { background: #f4faf6; }
.eg-cell.is-level-mid { background: #fff9eb; }
.eg-cell.is-orange,
.eg-cell.is-level-high { background: #fff0e4; }
.eg-cell.is-milk { background: #edf7ff; }
.eg-cell.is-high { border-color: #ef7b45; }
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
.eg-sugar-tag.is-mid { color: #735a15; border-color: rgba(255,255,255,.9); background: #f7e7a8; box-shadow: 0 2rpx 6rpx rgba(137,91,0,.15); }
.eg-sugar-tag.is-high { background: #d9431f; box-shadow: 0 4rpx 10rpx rgba(163,45,21,.32); }
.eg-sugar-tag.is-prop { background: #2563a8; }
.eg-special-mark { position: absolute; z-index: 12; bottom: 5rpx; left: 6rpx; display: flex; width: 40rpx; height: 40rpx; align-items: center; justify-content: center; border: 3rpx solid rgba(255,255,255,.96); border-radius: 50%; color: #fff; background: #6d28d9; box-shadow: 0 4rpx 11rpx rgba(73,31,130,.3); font-size: 23rpx; font-weight: 900; }
.eg-special-mark.is-row { width: 58rpx; height: 40rpx; border-radius: 999rpx; background: linear-gradient(135deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
.eg-special-mark.is-col { width: 40rpx; height: 58rpx; border-radius: 999rpx; background: linear-gradient(180deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
.eg-special-mark.is-burst { width: 48rpx; height: 48rpx; border-color: #fff3a8; border-radius: 15rpx 50% 50%; background: linear-gradient(145deg, #7c3aed 5%, #dd3b70 48%, #ff8a18 100%); box-shadow: 0 4rpx 13rpx rgba(108,35,155,.38), 0 0 12rpx rgba(255,179,38,.3); animation: egLReady 1.25s ease-in-out infinite alternate; }
.eg-mini-flame { position: relative; width: 25rpx; height: 30rpx; border-radius: 70% 32% 68% 40%; background: linear-gradient(135deg, #fff8a8 8%, #ffc21f 42%, #f04420 84%); box-shadow: 0 0 14rpx rgba(255,230,92,.95); transform: rotate(43deg); }
.eg-mini-flame-core { position: absolute; right: 4rpx; bottom: 3rpx; width: 10rpx; height: 15rpx; border-radius: 70% 35% 70% 42%; background: #fffbd1; box-shadow: 0 0 7rpx rgba(255,255,210,.95); }
.eg-l-special { position: relative; width: 29rpx; height: 29rpx; filter: drop-shadow(0 1rpx 2rpx rgba(73,22,105,.3)); }
.eg-l-arm { position: absolute; left: 3rpx; bottom: 3rpx; border-radius: 999rpx; background: #fff9b8; box-shadow: 0 0 6rpx rgba(255,249,184,.92); }
.eg-l-arm.is-vertical { width: 8rpx; height: 25rpx; }
.eg-l-arm.is-horizontal { width: 25rpx; height: 8rpx; }
.eg-l-core { position: absolute; left: 1rpx; bottom: 1rpx; width: 12rpx; height: 12rpx; border: 2rpx solid #fff; border-radius: 50%; background: #ff5b25; }
.eg-cell.is-fire-clearing { border-color: #ff7a1a; box-shadow: inset 0 0 25rpx rgba(255,102,18,.36), 0 0 18rpx rgba(255,105,20,.48); filter: brightness(1.35) saturate(1.42); }
.eg-cell.is-clearing.is-fire-clearing { animation: egFireCellHit .36s cubic-bezier(.2,.78,.22,1) forwards; }
.eg-fire-clear { position: absolute; z-index: 14; inset: 2rpx; overflow: hidden; border: 3rpx solid rgba(255,210,71,.92); border-radius: 22rpx; opacity: .96; background: linear-gradient(180deg, rgba(255,230,86,.12), rgba(239,63,27,.22)); pointer-events: none; }
.eg-fire-glow { position: absolute; inset: 6%; border-radius: 18rpx; background: radial-gradient(circle, rgba(255,246,177,.74), rgba(255,131,22,.32) 47%, rgba(222,47,23,0) 75%); animation: egFireGlow .34s ease-out both; }
.eg-fire-sweep { position: absolute; top: 23%; left: -48%; width: 168%; height: 55%; border-radius: 60% 45% 55% 42%; background: linear-gradient(90deg, rgba(255,184,38,0), rgba(255,238,124,.94) 28%, rgba(255,133,22,.92) 53%, rgba(226,54,25,.82) 72%, rgba(255,184,38,0)); box-shadow: 0 0 27rpx rgba(255,91,17,.72); animation: egFireSweep .34s cubic-bezier(.2,.78,.22,1) both; }
.eg-fire-flame { position: absolute; bottom: 13%; width: 25rpx; height: 39rpx; border-radius: 72% 35% 68% 42%; background: linear-gradient(145deg, #fff7a6 4%, #ffc329 39%, #ff7419 66%, #dc321f 100%); box-shadow: 0 0 15rpx rgba(255,124,21,.88); transform: rotate(42deg); animation: egFireFlame .34s ease-out both; }
.eg-fire-flame > view { position: absolute; right: 5rpx; bottom: 4rpx; width: 10rpx; height: 18rpx; border-radius: 70% 35% 70% 42%; background: #fffbd6; }
.eg-fire-flame.is-left { left: 16%; animation-delay: .01s; }
.eg-fire-flame.is-center { left: 43%; bottom: 19%; transform: rotate(42deg) scale(1.18); animation-delay: .035s; }
.eg-fire-flame.is-right { right: 15%; animation-delay: .065s; }
.eg-fire-spark { position: absolute; width: 10rpx; height: 17rpx; border-radius: 70% 35% 70% 42%; background: #ffe45e; box-shadow: 0 0 11rpx rgba(255,139,28,.9); animation: egFireSpark .34s ease-out both; }
.eg-fire-spark.is-one { top: 46%; left: 25%; }
.eg-fire-spark.is-two { top: 34%; left: 62%; animation-delay: .035s; }
.eg-fire-spark.is-three { top: 52%; left: 78%; animation-delay: .07s; }
.eg-board-tip { display: flex; align-items: center; justify-content: center; gap: 8rpx; min-height: 58rpx; margin-top: 12rpx; color: #684817; font-size: 26rpx; font-weight: 700; text-align: center; }
.eg-impact-text {
position: absolute;
z-index: 20;
top: 46%;
left: 50%;
padding: 10rpx 24rpx;
border: 4rpx solid rgba(255,255,255,.9);
border-radius: 999rpx;
color: #fff;
background: linear-gradient(135deg, #f59e0b, #e4572e);
box-shadow: 0 10rpx 28rpx rgba(190, 70, 25, .35);
font-size: 34rpx;
font-weight: 900;
transform: translate(-50%, -50%);
animation: egImpact .52s cubic-bezier(.16,.82,.3,1) forwards;
pointer-events: none;
}
.eg-impact-text.is-level-2 { font-size: 40rpx; background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.eg-impact-text.is-level-3 { font-size: 46rpx; background: linear-gradient(135deg, #ef4444, #f59e0b); box-shadow: 0 12rpx 36rpx rgba(239,68,68,.42); }
.eg-jackpot {
position: absolute;
z-index: 30;
top: 48%;
left: 50%;
width: 540rpx;
padding: 24rpx 22rpx 20rpx;
overflow: hidden;
border: 7rpx solid #ffd83d;
border-radius: 36rpx;
color: #fff;
background: radial-gradient(circle at 50% 0%, #ef4444, #9f1239 65%, #64102a);
box-shadow: 0 0 0 7rpx #fff2a8, 0 22rpx 52rpx rgba(100,16,42,.5);
text-align: center;
transform: translate(-50%, -50%);
animation: egJackpotIn 1.35s cubic-bezier(.16,.85,.25,1) forwards;
pointer-events: none;
}
.eg-jackpot.is-match-4 { background: radial-gradient(circle at 50% 0%, #8b5cf6, #5b21b6 68%, #35106d); }
.eg-jackpot.is-match-5 { border-color: #fff36a; background: radial-gradient(circle at 50% 0%, #ff6b24, #d7193f 58%, #760c2a); animation-duration: 1.9s; }
.eg-jackpot.is-match-3 {
width: 414rpx;
padding: 20rpx 22rpx 18rpx;
border-width: 4rpx;
border-color: #f1c663;
color: #31574b;
background: linear-gradient(155deg, #f7fff9, #fff5d9);
box-shadow: 0 0 0 4rpx rgba(255,255,255,.84), 0 14rpx 34rpx rgba(98,82,39,.2);
animation: egTripleIn 1.12s cubic-bezier(.2,.82,.28,1) forwards;
}
.eg-jackpot.is-match-3 .eg-jackpot-lights { display: none; }
.eg-jackpot.is-match-3 .eg-jackpot-kicker { color: #5b8c73; font-size: 21rpx; letter-spacing: 3rpx; text-shadow: none; }
.eg-jackpot.is-match-3 .eg-jackpot-title { color: #365b4e; font-size: 31rpx; text-shadow: none; }
.eg-jackpot.is-match-3 .eg-jackpot-reward { color: #ad7021; font-size: 25rpx; }
.eg-jackpot.is-match-3 .eg-jackpot-note { color: #829087; }
.eg-triple-cans { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 12rpx 0 10rpx; }
.eg-triple-can { display: flex; width: 78rpx; height: 76rpx; align-items: center; justify-content: center; border: 3rpx solid #f4d996; border-radius: 19rpx; background: rgba(255,255,255,.9); box-shadow: 0 5rpx 12rpx rgba(128,92,30,.12); animation: egTripleCan .42s cubic-bezier(.2,.86,.26,1) both; }
.eg-triple-can:nth-child(2) { animation-delay: .07s; }
.eg-triple-can:nth-child(3) { animation-delay: .14s; }
.eg-triple-can > image { width: 62rpx; height: 58rpx; }
.eg-jackpot.is-match-4 {
width: 548rpx;
padding: 25rpx 22rpx 22rpx;
border-color: #ffd95b;
background: radial-gradient(circle at 50% -10%, #a97cff, #6031b5 58%, #341066);
box-shadow: 0 0 0 6rpx #fff0a9, 0 0 36rpx rgba(255,210,76,.5), 0 24rpx 58rpx rgba(53,16,109,.48);
animation: egFourIn 1.88s cubic-bezier(.16,.84,.24,1) forwards;
}
.eg-jackpot.is-match-4 .eg-jackpot-kicker { color: #ffef8d; font-size: 24rpx; letter-spacing: 3rpx; }
.eg-jackpot.is-match-4 .eg-jackpot-title { margin-top: 9rpx; color: #fff; font-size: 39rpx; }
.eg-jackpot.is-match-4 .eg-jackpot-reward { color: #fff2a8; }
.eg-four-teaser { position: relative; margin-top: 13rpx; }
.eg-four-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.eg-four-can { display: flex; width: 76rpx; height: 92rpx; align-items: center; justify-content: center; border: 4rpx solid #ffe078; border-radius: 18rpx; background: linear-gradient(180deg, #fffef7, #ffe9a5); box-shadow: inset 0 -5rpx 0 rgba(171,106,17,.12), 0 7rpx 13rpx rgba(34,12,67,.28); animation: egFourCan .48s cubic-bezier(.18,.9,.25,1.2) both; }
.eg-four-can:nth-child(2) { animation-delay: .07s; }
.eg-four-can:nth-child(3) { animation-delay: .14s; }
.eg-four-can:nth-child(4) { animation-delay: .21s; }
.eg-four-can > image { width: 64rpx; height: 70rpx; }
.eg-four-can.is-locked { border-style: dashed; border-color: rgba(255,241,161,.86); color: #fff6ae; background: rgba(48,18,91,.54); box-shadow: inset 0 0 16rpx rgba(255,225,91,.18), 0 0 18rpx rgba(255,222,91,.36); font-size: 48rpx; font-weight: 1000; animation: egLockedCan .68s .34s ease-in-out infinite alternate; }
.eg-four-promise { display: inline-flex; margin-top: 15rpx; padding: 8rpx 18rpx; border: 2rpx solid rgba(255,247,184,.72); border-radius: 999rpx; color: #3d176f; background: linear-gradient(90deg, #fff3a3, #ffd85b); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); font-size: 25rpx; font-weight: 1000; animation: egPromisePulse .72s ease-in-out infinite alternate; }
.eg-five-celebration { position: relative; margin: 15rpx 0 10rpx; }
.eg-five-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.eg-five-can { display: flex; width: 78rpx; height: 93rpx; align-items: center; justify-content: center; border: 4rpx solid #fff17b; border-radius: 18rpx; background: linear-gradient(180deg, #fff, #fff2ac); box-shadow: inset 0 -5rpx 0 rgba(184,91,12,.13), 0 7rpx 0 #bd5b13, 0 0 16rpx rgba(255,240,101,.5); animation: egFiveCan .56s cubic-bezier(.16,.92,.24,1.2) both; }
.eg-five-can:nth-child(2) { animation-delay: .07s; }
.eg-five-can:nth-child(3) { animation-delay: .14s; }
.eg-five-can:nth-child(4) { animation-delay: .21s; }
.eg-five-can:nth-child(5) { animation-delay: .28s; }
.eg-five-can > image { width: 65rpx; height: 72rpx; }
.eg-five-sevens { display: flex; justify-content: center; gap: 8rpx; margin-top: 13rpx; }
.eg-five-sevens > text { display: flex; width: 58rpx; height: 55rpx; align-items: center; justify-content: center; border: 3rpx solid #fff5a7; border-radius: 13rpx; color: #fff36a; background: linear-gradient(155deg, #d7193f, #9f1239); box-shadow: 0 5rpx 0 #70102b, 0 0 13rpx rgba(255,243,106,.45); font-size: 43rpx; font-weight: 1000; line-height: 1; text-shadow: 0 3rpx 0 #8f1733; }
.eg-jackpot-lights { position: absolute; inset: 8rpx; border: 4rpx dotted rgba(255,255,255,.88); border-radius: 25rpx; animation: egLights .24s steps(2) infinite; }
.eg-jackpot-kicker { position: relative; display: block; color: #fff36a; font-size: 28rpx; font-weight: 900; letter-spacing: 6rpx; text-shadow: 0 3rpx 0 rgba(83,19,25,.45); }
.eg-jackpot-reels { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 13rpx 0; }
.eg-jackpot-reel { display: flex; width: 112rpx; height: 126rpx; flex-direction: column; align-items: center; justify-content: center; border: 6rpx solid #ffcf31; border-radius: 20rpx; background: linear-gradient(180deg, #fff 0%, #fff7cf 48%, #ffd55e 50%, #fff 53%, #fff8dc 100%); box-shadow: inset 0 0 18rpx rgba(137,78,10,.25), 0 7rpx 0 #a85a0a; animation: egReelStop .52s cubic-bezier(.2,.9,.25,1) both; }
.eg-jackpot-reel:nth-child(2) { animation-delay: .09s; }
.eg-jackpot-reel:nth-child(3) { animation-delay: .18s; }
.eg-jackpot-seven { color: #e11d48; font-size: 61rpx; font-weight: 1000; line-height: .85; text-shadow: 0 3rpx 0 #ffd1d8; }
.eg-jackpot-milk { width: 54rpx; height: 36rpx; margin-top: 3rpx; border-radius: 7rpx; }
.eg-jackpot-title { position: relative; display: block; font-size: 37rpx; font-weight: 900; text-shadow: 0 4rpx 0 rgba(73,10,30,.5); }
.eg-jackpot-points { position: relative; display: block; margin-top: 2rpx; color: #fff36a; font-size: 66rpx; font-weight: 1000; line-height: 1.05; letter-spacing: 2rpx; text-shadow: 0 5rpx 0 #9e2813, 0 0 18rpx rgba(255,243,106,.85); animation: egPointsPop .62s .42s cubic-bezier(.16,.9,.25,1.25) both; }
.eg-jackpot-reward { position: relative; display: block; margin-top: 7rpx; color: #fff5a5; font-size: 27rpx; font-weight: 800; line-height: 1.35; }
.eg-jackpot-note { position: relative; display: block; margin-top: 8rpx; color: rgba(255,255,255,.8); font-size: 22rpx; }
.eg-tools { margin-top: 16rpx; }
.eg-tool { display: flex; align-items: center; padding: 16rpx 18rpx; border: 3rpx solid #c9e0d6; border-radius: 26rpx; background: rgba(255,255,255,.92); box-shadow: 0 7rpx 18rpx rgba(24, 84, 63, .07); }
.eg-tool.active { border-color: #f59e0b; background: #fffbeb; }
.eg-tool.disabled { opacity: .45; }
.eg-tool-icon { position: relative; display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; overflow: visible; border-radius: 24rpx; font-size: 46rpx; background: linear-gradient(145deg, #e8f7ff, #d4ecff); box-shadow: inset 0 -4rpx 0 rgba(33,104,151,.08); }
.eg-tool-food-image { width: 76rpx; height: 58rpx; border-radius: 14rpx; }
.eg-replace-badge { position: absolute; right: -12rpx; bottom: -8rpx; display: flex; min-width: 78rpx; height: 38rpx; align-items: center; justify-content: center; gap: 2rpx; padding: 0 8rpx; border: 3rpx solid #fff; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #2389b9, #145d91); box-shadow: 0 5rpx 12rpx rgba(20,93,145,.3); }
.eg-replace-badge .tj-icon-wrap { width: 22rpx; height: 22rpx; }
.eg-replace-badge > text { font-size: 20rpx; font-weight: 900; line-height: 1; }
.eg-tool-copy { display: flex; flex: 1; flex-direction: column; margin-left: 16rpx; }
.eg-tool-name { color: #244a3d; font-size: 29rpx; font-weight: 800; }
.eg-tool-desc { margin-top: 5rpx; color: #6c8179; font-size: 25rpx; }
.eg-tool-count { color: #155e4b; font-size: 32rpx; font-weight: 900; }
.eg-legend { display: flex; justify-content: center; gap: 18rpx; margin-top: 18rpx; color: #53685f; font-size: 25rpx; font-weight: 700; }
.eg-dot--safe { color: #4cae7b; }
.eg-dot--mid { color: #e2aa19; }
.eg-dot--high { color: #e4572e; }
.eg-weekly-overlay { position: fixed; z-index: 90; inset: 0; display: flex; align-items: center; justify-content: center; padding: 28rpx; background: rgba(16, 55, 43, .66); backdrop-filter: blur(5px); }
.eg-weekly-card { width: 100%; max-width: 664rpx; max-height: calc(100vh - 56rpx); overflow-y: auto; padding: 28rpx 26rpx 22rpx; border: 5rpx solid rgba(255,255,255,.96); border-radius: 38rpx; background: linear-gradient(160deg, #f5fff9 0%, #fffdf3 56%, #fff4db 100%); box-shadow: 0 30rpx 80rpx rgba(5,45,32,.38); animation: egWeeklyIn .36s cubic-bezier(.18,.88,.27,1.08) both; }
.eg-weekly-head { display: flex; align-items: center; justify-content: space-between; }
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
.eg-weekly-progress-label { display: block; color: #657b73; font-size: 22rpx; font-weight: 700; }
.eg-weekly-progress-value { display: block; margin-top: -2rpx; color: #e06b24; font-size: 43rpx; font-weight: 1000; }
.eg-weekly-progress-copy { display: flex; flex-direction: column; align-items: flex-end; color: #235b49; font-size: 24rpx; font-weight: 900; line-height: 1.55; }
.eg-weekly-progress-copy text:last-child { color: #b86820; }
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
.eg-weekly-avatar.is-tone-5 { background: #a673bd; }
.eg-weekly-avatar.is-tone-6 { background: #448e9c; }
.eg-weekly-avatar.is-tone-7 { background: #c27b49; }
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
.eg-cheer-top { display: flex; align-items: center; justify-content: space-between; }
.eg-cheer-source { color: #8e5c18; font-size: 21rpx; font-weight: 900; }
.eg-cheer-card.is-family .eg-cheer-source { color: #77539c; }
.eg-cheer-change { padding: 5rpx 12rpx; border-radius: 999rpx; color: #176b52; background: rgba(255,255,255,.75); font-size: 19rpx; font-weight: 900; }
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-weekly-share::after { border: 0; }
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
.eg-stage-card { width: 100%; max-width: 620rpx; padding: 34rpx 28rpx 30rpx; border: 6rpx solid #fff; border-radius: 38rpx; background: linear-gradient(155deg, #f5fff9, #fff8da); box-shadow: 0 26rpx 70rpx rgba(10,56,41,.3); text-align: center; animation: egStageIn 1.8s cubic-bezier(.16,.86,.28,1) both; }
.eg-stage-kicker { display: block; color: #16815f; font-size: 23rpx; font-weight: 900; letter-spacing: 3rpx; }
.eg-stage-number { display: block; margin-top: 5rpx; color: #e47722; font-size: 48rpx; font-weight: 1000; line-height: 1.1; }
.eg-stage-title { display: block; margin-top: 7rpx; color: #194e3d; font-size: 38rpx; font-weight: 900; }
.eg-stage-foods { display: flex; justify-content: center; gap: 9rpx; margin-top: 22rpx; }
.eg-stage-food { display: flex; width: 92rpx; min-height: 110rpx; flex-direction: column; align-items: center; justify-content: center; border: 3rpx solid #dbece4; border-radius: 20rpx; color: #315b4e; background: #fff; font-size: 20rpx; font-weight: 800; }
.eg-stage-food > image { width: 72rpx; height: 52rpx; border-radius: 12rpx; }
.eg-stage-food > text { margin-top: 8rpx; }
.eg-stage-food.is-danger { border-color: #ef8b52; color: #a43c20; background: #fff1e6; box-shadow: 0 0 0 4rpx rgba(239,139,82,.12); }
.eg-stage-tip { display: block; margin-top: 20rpx; color: #6e776d; font-size: 21rpx; font-weight: 700; }
.eg-reshuffle-overlay { position: fixed; z-index: 65; inset: 0; display: flex; align-items: center; justify-content: center; padding: 46rpx; background: rgba(19,55,45,.5); }
.eg-reshuffle-card { width: 100%; max-width: 540rpx; padding: 38rpx 30rpx; border: 5rpx solid rgba(255,255,255,.95); border-radius: 36rpx; background: linear-gradient(155deg, #f4fff9, #e6f6ef); box-shadow: 0 24rpx 64rpx rgba(10,48,37,.3); text-align: center; }
.eg-reshuffle-icon { display: flex; width: 94rpx; height: 94rpx; align-items: center; justify-content: center; margin: 0 auto 18rpx; border-radius: 50%; background: linear-gradient(135deg, #2aa87b, #126a50); box-shadow: 0 10rpx 24rpx rgba(18,106,80,.28); animation: egReshuffleSpin .85s linear infinite; }
.eg-reshuffle-title { display: block; color: #174e3d; font-size: 36rpx; font-weight: 900; }
.eg-reshuffle-desc { display: block; margin-top: 10rpx; color: #5b746b; font-size: 24rpx; font-weight: 700; }
.eg-overlay { position: fixed; z-index: 50; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; padding: 36rpx; background: rgba(20, 48, 40, .62); }
.eg-modal { width: 100%; max-width: 650rpx; padding: 34rpx 28rpx 28rpx; border-radius: 38rpx; background: #fffdf8; box-shadow: 0 28rpx 70rpx rgba(0,0,0,.22); }
.eg-modal-icon { display: block; font-size: 68rpx; text-align: center; }
.eg-modal-title { display: block; margin-top: 6rpx; color: #204e3d; font-size: 38rpx; font-weight: 900; text-align: center; }
.eg-modal-sub { display: block; margin: 8rpx 0 22rpx; color: #718078; font-size: 24rpx; text-align: center; }
.eg-result-warning { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; margin: 0 auto; border-radius: 50%; color: #fff; background: #e4572e; font-size: 60rpx; font-weight: 900; }
.eg-result-food { display: block; margin: 12rpx 0 18rpx; color: #c34b27; font-size: 28rpx; font-weight: 800; text-align: center; }
.eg-knowledge-card { padding: 22rpx; border-radius: 24rpx; background: #fff3e7; }
.eg-knowledge-title { display: block; color: #8d4427; font-size: 26rpx; font-weight: 800; }
.eg-knowledge-copy { display: block; margin-top: 9rpx; color: #5f514a; font-size: 23rpx; line-height: 1.65; }
.eg-knowledge-review { display: block; margin-top: 12rpx; color: #9b7768; font-size: 19rpx; }
.eg-result-score { display: flex; align-items: center; justify-content: space-between; margin: 20rpx 6rpx; color: #466258; font-size: 25rpx; }
.eg-result-score text:last-child { color: #e16f24; font-size: 36rpx; font-weight: 900; }
.eg-result-best { display: flex; align-items: center; justify-content: space-between; margin: -12rpx 6rpx 20rpx; color: #75867f; font-size: 22rpx; }
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-share-btn::after { border: 0; }
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
@keyframes egPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
50% { box-shadow: 0 0 0 8rpx rgba(245,158,11,.12); }
}
@keyframes egWeeklyIn {
0% { opacity: 0; transform: translateY(34rpx) scale(.91); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes egHit {
0% { opacity: 1; transform: scale(1); }
42% { opacity: 1; transform: scale(1.18) rotate(-3deg); }
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
}
@keyframes egTaskRewardFlight {
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
72% { opacity: 1; transform: translate3d(270rpx, 900rpx, 0) scale(.68) rotate(8deg); }
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
}
@keyframes egRewardGlow {
from { opacity: .4; transform: scale(.86); }
to { opacity: .9; transform: scale(1.12); }
}
@keyframes egFireSweep {
0% { opacity: 0; transform: translate3d(-38%, 20rpx, 0) scaleX(.6); }
28% { opacity: 1; }
72% { opacity: .96; }
100% { opacity: 0; transform: translate3d(58%, -15rpx, 0) scaleX(1.08); }
}
@keyframes egFireSpark {
0% { opacity: 0; transform: translateY(14rpx) scale(.5) rotate(35deg); }
38% { opacity: 1; }
100% { opacity: 0; transform: translateY(-38rpx) scale(1.05) rotate(58deg); }
}
@keyframes egFireGlow {
0% { opacity: 0; transform: scale(.72); }
38% { opacity: 1; transform: scale(1.08); }
100% { opacity: 0; transform: scale(1.28); }
}
@keyframes egFireCellHit {
0% { opacity: 1; transform: scale(1); }
24% { opacity: 1; transform: scale(1.09); }
72% { opacity: 1; transform: scale(1.02); }
100% { opacity: 0; transform: scale(.48); }
}
@keyframes egFireFlame {
0% { opacity: 0; transform: translateY(20rpx) rotate(42deg) scale(.55); }
38% { opacity: 1; transform: translateY(0) rotate(42deg) scale(1.05); }
100% { opacity: 0; transform: translateY(-31rpx) rotate(48deg) scale(.86); }
}
@keyframes egFlameReady {
from { transform: scale(.94); filter: brightness(.96); }
to { transform: scale(1.08); filter: brightness(1.18); }
}
@keyframes egLReady {
from { transform: scale(.94) rotate(-2deg); filter: brightness(.96); }
to { transform: scale(1.06) rotate(2deg); filter: brightness(1.12); }
}
@keyframes egImpact {
0% { opacity: 0; transform: translate(-50%, -42%) scale(.55); }
35% { opacity: 1; transform: translate(-50%, -50%) scale(1.16); }
72% { opacity: 1; transform: translate(-50%, -54%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -76%) scale(.92); }
}
@keyframes egJackpotIn {
0% { opacity: 0; transform: translate(-50%, -45%) scale(.48) rotate(-4deg); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.09) rotate(2deg); }
31% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
78% { opacity: 1; transform: translate(-50%, -53%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -68%) scale(.92); }
}
@keyframes egTripleIn {
0% { opacity: 0; transform: translate(-50%, -44%) scale(.82); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
74% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -60%) scale(.96); }
}
@keyframes egTripleCan {
0% { opacity: 0; transform: translateY(18rpx) scale(.72); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes egFourIn {
0% { opacity: 0; transform: translate(-50%, -44%) scale(.62) rotate(-3deg); }
18% { opacity: 1; transform: translate(-50%, -50%) scale(1.08) rotate(1deg); }
29% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
78% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -65%) scale(.94); }
}
@keyframes egFourCan {
0% { opacity: 0; transform: translateY(-22rpx) rotate(-7deg) scale(.65); }
75% { opacity: 1; transform: translateY(5rpx) rotate(2deg) scale(1.05); }
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
}
@keyframes egFiveCan {
0% { opacity: 0; transform: translateY(-40rpx) scale(.52) rotate(-8deg); filter: brightness(1.5); }
70% { opacity: 1; transform: translateY(7rpx) scale(1.08) rotate(2deg); }
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0); filter: brightness(1); }
}
@keyframes egLockedCan {
from { opacity: .62; transform: scale(.92); filter: brightness(.9); }
to { opacity: 1; transform: scale(1.06); filter: brightness(1.2); }
}
@keyframes egPromisePulse {
from { transform: scale(.97); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); }
to { transform: scale(1.03); box-shadow: 0 7rpx 21rpx rgba(255,221,89,.38); }
}
@keyframes egReelStop {
0% { opacity: .2; transform: translateY(-48rpx) scaleY(1.4); filter: blur(5rpx); }
70% { opacity: 1; transform: translateY(7rpx) scaleY(.94); filter: blur(0); }
100% { transform: translateY(0) scaleY(1); }
}
@keyframes egLights {
0% { opacity: .35; }
100% { opacity: 1; }
}
@keyframes egConfettiFall {
0% { opacity: 0; transform: translate3d(0, -8vh, 0) rotate(0deg) scale(.7); }
9% { opacity: 1; }
48% { transform: translate3d(42rpx, 48vh, 0) rotate(420deg) scale(1); }
100% { opacity: .92; transform: translate3d(-28rpx, 112vh, 0) rotate(920deg) scale(.86); }
}
@keyframes egPointsPop {
0% { opacity: 0; transform: scale(.35) rotate(-7deg); }
55% { opacity: 1; transform: scale(1.28) rotate(3deg); }
78% { transform: scale(.92) rotate(0); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes egStageIn {
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
18% { opacity: 1; transform: scale(1.06) translateY(0); }
30%, 76% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.96) translateY(-22rpx); }
}
@keyframes egReshuffleSpin {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
@media screen and (max-height: 760px) {
.eg-task-card { padding-top: 13rpx; padding-bottom: 13rpx; }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -10
View File
@@ -98,15 +98,17 @@
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据累计记录满 7 天后显示您的真实趋势</text>
</view>
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
<view class="st-game-entry-list">
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
<view class="st-game-entry-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">识糖小课堂</text>
<text class="st-game-entry-sub">三消认食物学习哪些食物含糖高</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">识糖小课堂</text>
<text class="st-game-entry-sub">认一认哪些食物含糖高</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<view class="st-training-section">
@@ -1512,15 +1514,19 @@ async function goMorePage() {
uni.navigateTo({ url: buildMorePageUrl() })
}
function goGamePage() {
function openGamePage(url) {
uni.navigateTo({
url: '/tongji/pages/game',
url,
fail() {
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
}
})
}
function goEndlessGamePage() {
openGamePage('/tongji/endless-game/index')
}
function navToUser() {
uni.redirectTo({ url: '/pages/user/user' })
}
File diff suppressed because it is too large Load Diff
@@ -177,7 +177,7 @@
transform: scale(0.98);
}
/* 糖分突袭游戏入口 */
/* 游戏入口 */
.weekly-page .st-game-entry {
margin-top: 24rpx;
@@ -388,8 +388,15 @@
}
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
.weekly-page .st-chart-card .st-game-entry-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin: 12rpx 20rpx 0;
}
.weekly-page .st-chart-card .st-game-entry--in-card {
margin-top: 12rpx;
margin-top: 0;
padding: 16rpx 20rpx;
gap: 16rpx;
border-radius: 24rpx;
+13 -1
View File
@@ -1,10 +1,22 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import Optimization from '@uni-ku/bundle-optimizer'
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
// 编译产物默认输出到 ./dist/<mode>/<platform>
export default defineConfig({
plugins: [uni()],
plugins: [
uni(),
Optimization({
enable: {
optimization: true,
'async-import': false,
'async-component': false,
},
dts: false,
logger: false,
}),
],
css: {
preprocessorOptions: {
scss: {
+283
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+281
View File
@@ -0,0 +1,281 @@
import request from '@/utils/request'
export interface MyPatientListParams {
page_no: number
page_size: number
keyword?: string
status_filter?: '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
start_date?: string
end_date?: string
}
export function myPatientLists(params: MyPatientListParams) {
return request.get({ url: '/firstvisit.myPatient/lists', params })
}
export interface MyPatientOrderListParams {
page_no: number
page_size: number
keyword?: string
prescription_audit_status?: '' | number
payment_slip_audit_status?: '' | number
fulfillment_status?: '' | number
start_date?: string
end_date?: string
}
export function myPatientOrderLists(params: MyPatientOrderListParams) {
return request.get({ url: '/firstvisit.myPatient/orders', params })
}
export function myPatientOrderDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.myPatient/orderDetail', params })
}
export function myPatientOrderEdit(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.myPatient/orderEdit', params })
}
export function myPatientOrderAuditPrescription(params: {
id: number
action: 'approve' | 'reject'
remark?: string
}) {
return request.post({ url: '/firstvisit.myPatient/orderAuditPrescription', params })
}
export function myPatientOrderRevokeRxAudit(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/orderRevokeRxAudit', params })
}
export function myPatientOrderAuditPayment(params: {
id: number
action: 'approve' | 'reject'
remark?: string
}) {
return request.post({ url: '/firstvisit.myPatient/orderAuditPayment', params })
}
export function myPatientOrderRevokePayAudit(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/orderRevokePayAudit', params })
}
export function myPatientOrderDdcode(params: {
id: number
express_company: string
tracking_number: string
}) {
return request.post({ url: '/firstvisit.myPatient/orderDdcode', params })
}
export function myPatientOrderShip(params: {
id: number
ship_mode?: 'gancao' | 'direct'
express_company: string
tracking_number: string
}) {
return request.post({ url: '/firstvisit.myPatient/orderShip', params })
}
export function myPatientOrderAddPayOrder(params: {
id: number
order_type: number
pay_amount: number
pay_remark?: string
completion_request?: number
pay_create_type?: 'fubei' | 'express_cod'
}) {
return request.post({ url: '/firstvisit.myPatient/orderAddPayOrder', params })
}
export function myPatientOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/firstvisit.myPatient/orderComplete', params })
}
export function myPatientOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
return request.post({ url: '/firstvisit.myPatient/orderRefund', params })
}
export function myPatientOrderWithdraw(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/orderWithdraw', params })
}
export function myPatientOrderUploadToPharmacy(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/orderUploadToPharmacy', params })
}
export interface MyPatientProgressListParams {
page_no: number
page_size: number
keyword?: string
status?: '' | 1 | 3 | 4
start_date?: string
end_date?: string
}
export function myPatientProgressLists(params: MyPatientProgressListParams) {
return request.get({ url: '/firstvisit.myPatient/progress', params })
}
export function myPatientCreateAppointment(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.myPatient/createAppointment', params })
}
export function myPatientCancelAppointment(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/cancelAppointment', params })
}
export function myPatientAssistants() {
return request.get({ url: '/firstvisit.myPatient/assistants' })
}
export function myPatientAssign(params: { id: number; assistant_id: number; is_inherit?: 0 | 1 }) {
return request.post({ url: '/firstvisit.myPatient/assign', params })
}
export function myPatientFillIdCard(params: { id: number; id_card: string }) {
return request.post({ url: '/firstvisit.myPatient/fillIdCard', params })
}
export interface FirstVisitConversionParams {
time_type: 'today' | 'yesterday' | 'week' | 'month' | 'quarter' | 'year'
dept_id?: number
assistant_id?: number
media_channel_code?: string
}
/** 一诊综合数据转化:服务端按当前角色 DataScope 与所选部门/员工取交集。 */
export function firstVisitConversionOverview(params: FirstVisitConversionParams) {
return request.get(
{ url: '/firstvisit.conversion/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export interface FirstVisitRegistrationStatsParams {
time_type: 'today' | 'week' | 'month'
dept_id?: number
assistant_id?: number
}
/** 一诊挂号统计:部门和员工参数只会在服务端 DataScope 权限范围内继续收窄。 */
export function firstVisitRegistrationStatsOverview(params: FirstVisitRegistrationStatsParams) {
return request.get(
{ url: '/firstvisit.registrationStats/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export interface FirstVisitDoctorDashboardParams {
time_type: 'today' | 'week' | 'month'
dept_id?: number
doctor_id?: number
active_only?: 0 | 1
alert_threshold?: number
}
/** 一诊医生看板:医生与经手医助范围均由服务端根据当前角色和部门权限计算。 */
export function firstVisitDoctorDashboardOverview(params: FirstVisitDoctorDashboardParams) {
return request.get(
{ url: '/firstvisit.doctorDashboard/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export function wecomPromotionOverview() {
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
}
export function wecomPromotionDeletePool(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
}
export function wecomPromotionDeleteRemoteLink(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deleteRemoteLink', params })
}
export function wecomPromotionToggleLink(params: { id: number; status: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/toggleLink', params })
}
export function wecomPromotionDeleteLink(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
}
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
export interface WecomPromotionCustomerStatsParams {
page_no: number
page_size: number
promotion_link_id?: number
userid?: string
chat_status?: 0 | 1 | 2
}
export interface WecomPromotionCustomerStatsSummary {
customer_count: number
messaged_customer_count: number
message_customer_rate: number
received_message_count: number
message_count_known_count: number
}
export interface WecomPromotionCustomerStatRow {
id?: number
external_userid_masked?: string
customer_id_masked?: string
customer_name_masked?: string
link_id?: number | string
link_name?: string
member_id?: number
member_name?: string
department_name?: string
dept_name?: string
has_messaged?: boolean | number
chat_status?: 0 | 1 | 2
message_count_known?: boolean | number
received_message_count?: number
last_synced_at?: string
last_message_at?: string
}
export interface WecomPromotionCustomerStatsResult {
summary?: Partial<WecomPromotionCustomerStatsSummary>
lists?: WecomPromotionCustomerStatRow[]
total?: number
link_options?: Array<{ id: number | string; name: string }>
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
meta?: { last_synced_at?: string }
}
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
return request.get(
{ url: '/firstvisit.wecomPromotion/customerStatistics', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export function wecomPromotionSyncCustomers(params: { promotion_link_id?: number } = {}) {
return request.post({ url: '/firstvisit.wecomPromotion/syncCustomers', params, timeout: 120000 })
}
+87
View File
@@ -0,0 +1,87 @@
import request from '@/utils/request'
export interface MedicineMappingQuery {
page_no: number
page_size: number
local_name?: string
remote_keyword?: string
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
}
export interface MedicineMappingRow {
local_medicine_id: number
local_name: string
local_unit: string
local_status: number
mapping_id: number | null
mapping_status: number
medicine_code: string | null
remote_name: string | null
remote_brand: string | null
remote_unit: string | null
settlement_price: string | number | null
retail_price: string | number | null
catalog_version: number | null
remote_status: number | null
remote_deleted: number | null
operator_name: string | null
mapping_update_time: number | null
}
export interface CatalogOption {
medicine_code: string
name: string
brand: string
unit: string
settlement_price: string | number
retail_price: string | number
catalog_version: number
status: number
}
export interface PharmacySyncStatus {
sync_enabled: boolean
cursor: number
last_success_time: number
last_failure_time: number
last_error_summary: string
is_syncing: boolean
catalog_total: number
catalog_active: number
unmapped_local: number
}
export interface PharmacySyncResult {
pages: number
pulled: number
received: number
created: number
updated: number
unchanged: number
deactivated: number
cursor: number
}
export function medicineMappingLists(params: MedicineMappingQuery) {
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
}
export function medicineMappingStatus() {
return request.get({ url: '/pharmacy.medicineMapping/status' })
}
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
}
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
return request.post({ url: '/pharmacy.medicineMapping/save', params })
}
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
}
export function medicineCatalogSync() {
return request.post({ url: '/pharmacy.medicineMapping/sync' })
}
+18
View File
@@ -1,5 +1,13 @@
import request from '@/utils/request'
/** 角色数据驾驶舱:服务端统一按当前管理员的数据范围聚合。 */
export function performanceDashboardOverview(params?: { ranking_dept_id?: number; _t?: number }) {
return request.get(
{ url: '/stats.performanceDashboard/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export function getConversionStatsOverview(params: any) {
return request.get({ url: '/stats.conversion/overview', params })
}
@@ -227,6 +235,16 @@ export function revisitRateVisitOrderLines(params: {
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
}
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
export function autoAssignLogLists(params: Record<string, any>) {
return request.get({ url: '/stats.autoAssignLog/lists', params })
}
/** 批量回退自动分配:将诊单医助撤回到自动分配前的原医助 */
export function autoAssignLogRollback(data: { ids: number[] }) {
return request.post({ url: '/stats.autoAssignLog/rollback', data })
}
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
+43
View File
@@ -61,6 +61,14 @@ export function tcmDiagnosisDetail(params: any) {
return request.get({ url: '/tcm.diagnosis/detail', params })
}
/** 设置复诊接诊率统计起始偏移(统计诊次=实单序号+偏移;1=二诊起,2=三诊起) */
export function tcmDiagnosisSetRevisitSlotStartOffset(params: {
id: number
revisit_slot_start_offset: number
}) {
return request.post({ url: '/tcm.diagnosis/setRevisitSlotStartOffset', params })
}
/** 诊单挂号 / 取消挂号 操作日志 */
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
@@ -416,6 +424,15 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
}
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
export function prescriptionOrderDdcode(params: {
id: number
express_company: string
tracking_number: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/ddcode', params })
}
/** 业务订单详情:仅修改关联处方的患者姓名与手机号 */
export function prescriptionOrderPatchPrescriptionPatient(params: {
id: number
@@ -425,6 +442,17 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
}
export function prescriptionOrderPatchPrescriptionUsage(params: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
}
export function prescriptionOrderAuditPrescription(params: {
id: number
action: 'approve' | 'reject'
@@ -509,6 +537,21 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
}
/** 按业务订单 ship_mode 上传:gancao 走甘草,direct 走洛阳药房 ERP */
export function prescriptionOrderUploadToPharmacy(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/uploadToPharmacy', params })
}
/** 人工核对甘草不确定提交结果。 */
export function prescriptionOrderConfirmGancaoSubmission(params: {
id: number
resolution: 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED'
remote_order_no?: string
note: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/confirmGancaoSubmission', params })
}
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
export function prescriptionOrderPreviewGancaoRecipel(params: {
id: number
@@ -2,7 +2,7 @@
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '' }}</span>
<el-select
v-else
:model-value="modelValue"
:model-value="selectedMedicineId"
class="medicine-name-select w-full"
filterable
remote
@@ -14,7 +14,14 @@
@visible-change="onVisibleChange"
@update:model-value="onUpdate"
>
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.name" />
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
<div class="medicine-name-option">
<span>{{ item.name }}</span>
<span class="medicine-name-option__meta">
{{ item.id > 0 ? [item.supplier, item.unit, `ID ${item.id}`].filter(Boolean).join(' · ') : '历史名称,请重新选择' }}
</span>
</div>
</el-option>
</el-select>
</template>
@@ -24,6 +31,7 @@ import { medicineLists } from '@/api/medicine'
const props = withDefaults(
defineProps<{
modelValue: string
medicineId?: number | null
disabled?: boolean
}>(),
{ disabled: false }
@@ -31,18 +39,30 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:medicineId': [value: number | undefined]
}>()
const loading = ref(false)
const options = ref<{ id: number; name: string }[]>([])
type MedicineOption = { id: number; name: string; supplier?: string; unit?: string }
const options = ref<MedicineOption[]>([])
const selectedMedicineId = computed<number | ''>(() => {
const id = Number(props.medicineId)
if (Number.isInteger(id) && id > 0) {
return id
}
return (props.modelValue || '').trim() ? 0 : ''
})
function ensureCurrentInOptions() {
const v = (props.modelValue || '').trim()
if (!v) {
return
}
if (!options.value.some((o) => o.name === v)) {
options.value = [{ id: 0, name: v }, ...options.value]
const id = Number(props.medicineId)
const currentId = Number.isInteger(id) && id > 0 ? id : 0
if (!options.value.some((o) => o.id === currentId)) {
options.value = [{ id: currentId, name: v }, ...options.value]
}
}
@@ -56,7 +76,7 @@ const remoteMethod = async (query: string) => {
page_size: 100,
status: 1
})
options.value = res.lists || []
options.value = (res.lists || []) as MedicineOption[]
ensureCurrentInOptions()
} catch (e) {
console.error(e)
@@ -72,13 +92,19 @@ const onVisibleChange = (open: boolean) => {
}
}
const onUpdate = (val: string) => {
emit('update:modelValue', val || '')
const onUpdate = (val: number | string) => {
const id = Number(val)
const selected = id > 0 ? options.value.find((item) => item.id === id) : undefined
emit('update:modelValue', selected?.name || '')
emit('update:medicineId', selected?.id)
}
watch(
() => props.modelValue,
() => [props.modelValue, props.medicineId],
() => {
if (!(props.modelValue || '').trim() && Number(props.medicineId) > 0) {
emit('update:medicineId', undefined)
}
ensureCurrentInOptions()
},
{ immediate: true }
@@ -93,4 +119,23 @@ watch(
.medicine-name-select.w-full {
width: 100%;
}
.medicine-name-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 60%);
align-items: center;
gap: 16px;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.medicine-name-option__meta {
color: var(--el-text-color-secondary);
font-size: 12px;
text-align: right;
}
</style>
@@ -139,7 +139,12 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<MedicineNameSelect
v-model="formData.herbs[row.index].name"
v-model:medicine-id="formData.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="herb-editor-card__select"
/>
<div
v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -176,7 +181,12 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">辅方</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<MedicineNameSelect
v-model="formData.herbs[row.index].name"
v-model:medicine-id="formData.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="herb-editor-card__select"
/>
<div
v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -1045,6 +1055,7 @@ import { Search } from '@element-plus/icons-vue'
type FormulaType = '主方' | '辅方'
interface Herb {
medicine_id?: number
name: string
dosage: number
formula_type?: FormulaType
@@ -1071,6 +1082,10 @@ function normalizeHerbRow(raw: any): Herb {
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
if (Number.isInteger(medicineId) && medicineId > 0) {
row.medicine_id = medicineId
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
@@ -1578,8 +1593,8 @@ function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
return herbs
}
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测 */
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
/** 仅有一条完全同名记录时返回稳定药材身份,否则不猜测 */
async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
const q = rawName.trim()
if (!q) return null
try {
@@ -1590,8 +1605,10 @@ async function resolveHerbNameFromLibrary(rawName: string): Promise<string | nul
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
return exact.length === 1
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
: null
} catch {
return null
}
@@ -1612,12 +1629,12 @@ async function handlePasteRecipeImport() {
const resolved: Herb[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolveHerbNameFromLibrary(row.name)
if (!name) {
const medicine = await resolveHerbFromLibrary(row.name)
if (!medicine) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
@@ -0,0 +1,129 @@
<template>
<template v-if="needsReconcile">
<el-button
v-perms="['tcm.prescriptionOrder/confirmGancaoSubmission']"
type="danger"
size="small"
plain
@click="openDialog"
>核对甘草提交</el-button
>
<el-dialog
v-model="visible"
title="人工核对甘草提交"
width="min(92vw, 520px)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-alert
title="请先在甘草后台核对。本操作会写入不可变更的审计记录。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="100px" @submit.prevent="submit">
<el-form-item label="核对结果" required>
<el-radio-group v-model="form.resolution">
<el-radio label="CONFIRM_SUCCESS">确认已创建</el-radio>
<el-radio label="CONFIRM_NOT_CREATED">确认未创建</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.resolution === 'CONFIRM_SUCCESS'"
label="甘草单号"
required
>
<el-input v-model="form.remote_order_no" maxlength="64" clearable />
</el-form-item>
<el-form-item label="核对依据" required>
<el-input
v-model="form.note"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="例如:核对时间、甘草后台查询条件及结果"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit"
>确认并记录</el-button
>
</template>
</el-dialog>
</template>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { prescriptionOrderConfirmGancaoSubmission } from '@/api/tcm'
import feedback from '@/utils/feedback'
const props = defineProps<{ order: Record<string, any> | null | undefined }>()
const emit = defineEmits<{ resolved: [] }>()
const needsReconcile = computed(() => {
const target = String(props.order?.pharmacy_claim_target || '')
.trim()
.toLowerCase()
const status = String(props.order?.pharmacy_claim_status || '')
.trim()
.toUpperCase()
const leaseExpiresAt = Number(props.order?.pharmacy_claim_lease_expires_at || 0)
const expiredPending =
status === 'PENDING' &&
leaseExpiresAt > 0 &&
leaseExpiresAt <= Math.floor(Date.now() / 1000)
return (
target === 'gancao' && (expiredPending || ['UNKNOWN', 'PENDING_RECONCILE'].includes(status))
)
})
const visible = ref(false)
const submitting = ref(false)
const form = reactive({
resolution: 'CONFIRM_SUCCESS' as 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED',
remote_order_no: '',
note: ''
})
function openDialog() {
form.resolution = 'CONFIRM_SUCCESS'
form.remote_order_no = ''
form.note = ''
visible.value = true
}
async function submit() {
const id = Number(props.order?.id)
if (!id) return
if (form.resolution === 'CONFIRM_SUCCESS' && !form.remote_order_no.trim()) {
feedback.msgError('请填写甘草药方单号')
return
}
if (!form.note.trim()) {
feedback.msgError('请填写甘草后台核对依据')
return
}
submitting.value = true
try {
await prescriptionOrderConfirmGancaoSubmission({
id,
resolution: form.resolution,
remote_order_no:
form.resolution === 'CONFIRM_SUCCESS' ? form.remote_order_no.trim() : '',
note: form.note.trim()
})
feedback.msgSuccess('甘草提交核对已记录')
visible.value = false
emit('resolved')
} catch {
// Request interceptor presents the server-side reconciliation error.
} finally {
submitting.value = false
}
}
</script>
@@ -30,6 +30,13 @@
round
class="ml-2"
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
<el-tag
v-if="detailData?.ej_pharmacy_order_no"
type="warning"
effect="plain"
round
class="ml-2"
>洛阳 {{ detailData.ej_pharmacy_order_no }}</el-tag>
<!-- 完整版发货类型切换等 -->
<slot v-if="detailData" name="header-extra" :detail="detailData" />
</div>
@@ -327,7 +334,27 @@
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="服用方式">
<el-descriptions-item>
<template #label>
<div class="flex items-center gap-1">
<span>服用方式</span>
<el-button
v-if="
!readonly &&
detailData.prescription_id &&
detailPrescription &&
!String(detailData.prescription_detail_error || '').trim()
"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
@click="openPatchUsageDialog"
>
修改
</el-button>
</div>
</template>
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<template v-if="detailHasAuxHerbs">
<div>
@@ -655,7 +682,7 @@
<!-- 物流轨迹 -->
<el-card
v-if="String(detailData.tracking_number || '').trim()"
v-if="loadRelatedData && String(detailData.tracking_number || '').trim()"
shadow="never"
class="po-panel po-panel-logistics"
:class="
@@ -775,6 +802,7 @@
<!-- 操作日志 -->
<el-card
v-if="loadRelatedData"
v-perms="['tcm.prescriptionOrder/logs']"
shadow="never"
class="po-panel border-gray-100 mt-4"
@@ -885,12 +913,98 @@
<el-button type="primary" :loading="addLogSaving" @click="submitAddLog">保存</el-button>
</template>
</el-dialog>
<!-- 修改服用参数主方 / 辅方 / 订单设置 -->
<el-dialog
v-model="patchUsageVisible"
title="修改服用参数"
width="480px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetPatchUsageForm"
>
<el-form
ref="patchUsageFormRef"
:model="patchUsageForm"
:rules="patchUsageRules"
label-width="108px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="aux_usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.aux_usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</template>
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
<el-form-item label="服用天数" prop="medication_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.medication_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="patchUsageVisible = false">取消</el-button>
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
</template>
</el-dialog>
</div>
</el-drawer>
</template>
<script lang="ts" setup name="PrescriptionOrderDetailDrawer">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Refresh, Loading, Search, Van } from '@element-plus/icons-vue'
import {
@@ -899,7 +1013,8 @@ import {
prescriptionOrderAddLog,
prescriptionOrderLogisticsTrace,
prescriptionOrderLogisticsJdUpdate,
prescriptionOrderPaidPayOrders
prescriptionOrderPaidPayOrders,
prescriptionOrderPatchPrescriptionUsage
} from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
@@ -943,11 +1058,17 @@ const props = withDefaults(
gancaoPreviewLoading?: boolean
/** 嵌套在其他抽屉内时需要 append-to-body */
appendToBody?: boolean
/** 自定义详情请求;用于在有独立行级权限边界的页面安全复用抽屉。 */
detailLoader?: (params: { id: number }) => Promise<any>
/** 是否加载原订单页的物流、日志和未关联支付单等附加接口。 */
loadRelatedData?: boolean
}>(),
{
readonly: false,
gancaoPreviewLoading: false,
appendToBody: false
appendToBody: false,
detailLoader: undefined,
loadRelatedData: true
}
)
@@ -1183,11 +1304,15 @@ const detailFullAddress = computed(() => {
const servicePackageOptions = ref<ServicePackageOption[]>([])
async function loadServicePackageOptions() {
if (servicePackageOptions.value.length > 0) return
try {
const data: any = await getDictData({ type: 'server_order' })
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
const opts = normalizeServicePackageOptions(data?.server_order)
if (opts.length > 0) {
servicePackageOptions.value = opts
}
} catch {
servicePackageOptions.value = []
/* 请求被同参数请求取消或失败时保留现值,open() 时会重试 */
}
}
@@ -1257,6 +1382,100 @@ const addLogRules: FormRules = {
summary: [{ required: true, message: '请填写日志内容', trigger: 'blur' }]
}
const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
const rules: FormRules = {
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
}
return rules
})
function openPatchUsageDialog() {
const rx = detailPrescription.value
const ord = detailData.value
if (!rx || !ord?.id || !ord.prescription_id) {
feedback.msgWarning('无处方数据')
return
}
const aux = detailAuxUsage.value
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageForm.aux_times_per_day =
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.medication_days = undefined
}
async function submitPatchUsage() {
const form = patchUsageFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
const ordId = detailData.value?.id
if (!ordId) return
patchUsageSaving.value = true
try {
const payload: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
patchUsageVisible.value = false
await refresh()
emit('detail-changed')
} catch {
/* 拦截器已提示 */
} finally {
patchUsageSaving.value = false
}
}
function resetAddLogForm() {
addLogForm.summary = ''
addLogForm.prescription_audit_status = ''
@@ -1437,7 +1656,13 @@ async function updateJdLogistics() {
}
// /
function requestDetail(id: number) {
return props.detailLoader ? props.detailLoader({ id }) : prescriptionOrderDetail({ id })
}
async function open(id: number) {
// axios
void loadServicePackageOptions()
//
detailData.value = null
detailUnlinkedPayOrders.value = []
@@ -1449,22 +1674,22 @@ async function open(id: number) {
detailVisible.value = true
detailLoading.value = true
try {
const res: any = await prescriptionOrderDetail({ id })
const res: any = await requestDetail(id)
const d = res?.data ?? res ?? null
detailData.value = d
if (d) {
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
if (String(d.tracking_number || '').trim()) {
if (props.loadRelatedData && String(d.tracking_number || '').trim()) {
fetchLogisticsTrace()
}
fetchLogs(id)
if (props.loadRelatedData) fetchLogs(id)
//
const diagId = d.diagnosis_id
const linkedIds = d.pay_order_ids || []
if (diagId) {
if (props.loadRelatedData && diagId) {
void loadDetailUnlinkedPayOrders(diagId, id, linkedIds)
}
}
@@ -1481,20 +1706,20 @@ async function refresh() {
const id = Number(detailData.value?.id)
if (!id) return
try {
const res: any = await prescriptionOrderDetail({ id })
const res: any = await requestDetail(id)
const d = res?.data ?? res ?? null
if (!d) return
const prevTracking = String(detailData.value?.tracking_number || '').trim()
detailData.value = d
const nextTracking = String(d.tracking_number || '').trim()
if (nextTracking && nextTracking !== prevTracking) {
if (props.loadRelatedData && nextTracking && nextTracking !== prevTracking) {
bumpLogisticsTraceRequestToken()
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
void fetchLogisticsTrace()
}
void fetchLogs(id)
if (props.loadRelatedData) void fetchLogs(id)
const diagId = d.diagnosis_id
if (diagId) {
if (props.loadRelatedData && diagId) {
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
}
} catch {
@@ -11,6 +11,45 @@ export const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
if (!row) return false
if (String(row.gancao_reciperl_order_no || '').trim()) return true
if (String(row.ej_pharmacy_order_no || '').trim()) return true
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
return true
}
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
String(row.pharmacy_claim_status || '').toUpperCase()
)
}
export type SupplyMode = 'gancao' | 'direct' | 'self'
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
if (
String(row?.ship_mode || '')
.trim()
.toLowerCase() === 'direct'
)
return 'direct'
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
return 'self'
}
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
const mode = supplyModeKey(row)
if (mode === 'direct') return '洛阳直发'
return mode === 'gancao' ? '甘草' : '自营'
}
export function supplyModeTagType(
row: Record<string, unknown> | null | undefined
): 'success' | 'warning' | 'info' {
const mode = supplyModeKey(row)
if (mode === 'direct') return 'warning'
return mode === 'gancao' ? 'success' : 'info'
}
export function formatTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
@@ -134,7 +173,10 @@ export function logActionText(act: string) {
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款',
@@ -696,7 +696,12 @@
<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" :disabled="herbsLocked" class="w-full" />
<MedicineNameSelect
v-model="editForm.herbs[row.index].name"
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -732,7 +737,12 @@
<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" :disabled="herbsLocked" class="w-full" />
<MedicineNameSelect
v-model="editForm.herbs[row.index].name"
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -1294,6 +1304,14 @@
<div v-show="createOrderStep === 1" class="create-order-step-panel">
<el-row :gutter="20">
<el-col v-if="canSelectShipMode" :span="24">
<el-form-item label="发货药房" prop="ship_mode">
<el-radio-group v-model="createOrderForm.ship_mode">
<el-radio-button label="gancao">甘草药房</el-radio-button>
<el-radio-button label="direct">洛阳药房</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="复诊">
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
@@ -1697,6 +1715,7 @@ import { roleAll } from '@/api/perms/role'
import { usePaging } from '@/hooks/usePaging'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import type { FormInstance, FormRules } from 'element-plus'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
@@ -1709,7 +1728,7 @@ import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方'
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type HerbRow = { medicine_id?: number; name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type AuxUsageForm = {
dosage_amount?: number
@@ -1814,6 +1833,10 @@ function normalizeHerbRow(raw: any): HerbRow {
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
if (Number.isInteger(medicineId) && medicineId > 0) {
row.medicine_id = medicineId
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
@@ -2006,6 +2029,7 @@ const createOrderForm = reactive({
service_package: [] as string[],
express_company: 'auto',
tracking_number: '',
ship_mode: 'gancao' as 'gancao' | 'direct',
fee_type: 3,
amount: 0,
internal_cost: undefined as number | undefined,
@@ -2014,6 +2038,10 @@ const createOrderForm = reactive({
pay_order_ids: [] as number[]
})
//
//
const canSelectShipMode = computed(() => hasPermission(['tcm.prescriptionOrder/setShipMode']))
// API 使
const regionOptions = ref([])
@@ -2252,6 +2280,7 @@ function resetCreateOrderForm() {
createOrderForm.service_package = []
createOrderForm.express_company = 'auto'
createOrderForm.tracking_number = ''
createOrderForm.ship_mode = 'gancao'
createOrderForm.fee_type = 3
createOrderForm.amount = 0
createOrderForm.internal_cost = undefined
@@ -2425,6 +2454,7 @@ async function submitCreateOrderFromPrescription() {
: '',
express_company: createOrderForm.express_company || 'auto',
tracking_number: createOrderForm.tracking_number || '',
ship_mode: createOrderForm.ship_mode,
fee_type: createOrderForm.fee_type,
amount: createOrderForm.amount,
remark_extra: createOrderForm.remark_extra || '',
@@ -3601,7 +3631,7 @@ function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }
return herbs
}
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
const q = rawName.trim()
if (!q) return null
try {
@@ -3612,8 +3642,10 @@ async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
return exact.length === 1
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
: null
} catch {
return null
}
@@ -3634,12 +3666,12 @@ async function handlePasteRecipeImport() {
const resolved: HerbRow[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(row.name)
if (!name) {
const medicine = await resolvePasteHerbFromLibrary(row.name)
if (!medicine) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
@@ -157,7 +157,11 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="row.name" :disabled="editMode === 'view'" />
<MedicineNameSelect
v-model="row.name"
v-model:medicine-id="row.medicine_id"
:disabled="editMode === 'view'"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="150">
@@ -257,7 +261,7 @@ const editForm = reactive({
id: 0,
prescription_name: '',
formula_type: '主方',
herbs: [] as Array<{ name: string; dosage: number }>,
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
is_public: 0,
disable_edit: 0
})
@@ -168,7 +168,7 @@
{{ listStats.periodLine }}
</p>
<!-- <p class="relative mt-1.5 text-xs text-slate-400 leading-relaxed">
业绩 = 除业务订单已取消(4)关联支付金额下方为合计与已取消明细
业绩 = 除业务订单已取消(4)拒收(9)退款(10)后的关联支付金额下方为合计与排除项明细
</p> -->
</div>
</div>
@@ -298,7 +298,7 @@
:fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams"
:page-size="pager.size"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「处方」导出主方/辅方药材明细;「主方/辅方服用方式、天数」与详情侧栏、处方笺同口径(天数优先取订单 medication_days,缺省回退处方 usage_days / 辅方 aux_usage)。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「处方」导出主方/辅方药材明细;「主方/辅方服用方式、天数」与详情侧栏同口径(主方/辅方天数分别取处方 usage_days辅方 aux_usage.usage_days;「天数」列为订单 medication_days)。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
/>
</el-form-item>
</el-form>
@@ -373,9 +373,9 @@
<el-tag
size="small"
effect="plain"
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
:type="supplyModeTagType(row)"
>
{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}
{{ supplyModeLabel(row) }}
</el-tag>
<span class="text-gray-400">#{{ row.id }}</span>
</div>
@@ -594,13 +594,13 @@
@click="confirmWithdraw(row)"
>撤回</el-button>
<el-button
v-if="canUploadGancaoRow(row)"
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
v-if="canUploadPharmacyRow(row)"
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
type="warning"
link
:loading="gancaoSubmitId === row.id"
@click="confirmSubmitGancaoRecipel(row)"
>上传药</el-button>
:loading="pharmacySubmitId === row.id"
@click="confirmUploadToPharmacy(row)"
>上传药</el-button>
</div>
</template>
</el-table-column>
@@ -638,7 +638,7 @@
<el-radio-button label="gancao">甘草药房</el-radio-button>
<el-radio-button
label="direct"
:disabled="isShipModeLockedToGancao(detail)"
:disabled="isShipModeLocked(detail)"
>洛阳药房</el-radio-button>
</el-radio-group>
<el-tag
@@ -650,6 +650,19 @@
</div>
</template>
<template #header-actions="{ detail }">
<gancao-submission-reconcile-button
:order="detail"
@resolved="handleGancaoSubmissionResolved"
/>
<el-button
v-if="canUploadPharmacyRow(detail)"
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
type="warning"
size="small"
plain
:loading="pharmacySubmitId === detail.id"
@click="confirmUploadToPharmacy(detail)"
>上传药房</el-button>
<el-button
v-if="canRxAudit(detail)"
v-perms="['tcm.prescriptionOrder/auditPrescription']"
@@ -2205,6 +2218,7 @@ import { useRoute } from 'vue-router'
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import {
TCM_ASSISTANT_ROLE_ID,
PRESCRIPTION_AUDIT_ROLE_IDS,
@@ -2231,13 +2245,17 @@ import {
type ServicePackageOption,
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions
mergeServicePackageSelectOptions,
isRemoteSnapshotLocked,
supplyModeLabel,
supplyModeTagType
} from './components/prescription-order-utils'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
prescriptionOrderAuditPayment,
prescriptionOrderAuditPrescription,
prescriptionOrderDetail,
prescriptionOrderDdcode,
prescriptionOrderEdit,
prescriptionOrderLists,
prescriptionOrderExport,
@@ -2256,7 +2274,7 @@ import {
prescriptionOrderBatchAssignAssistant,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderLinkPayOrder,
prescriptionOrderSubmitGancaoRecipel,
prescriptionOrderUploadToPharmacy,
prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail,
prescriptionLibraryLists,
@@ -2576,12 +2594,13 @@ function handleFulfillmentStatusTabClick(value: number | '') {
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
const supplyModeTabs = [
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
{ label: '甘草', value: 'gancao' as const },
{ label: '洛阳直发', value: 'direct' as const },
{ label: '自营', value: 'self' as const }
]
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
queryParams.supply_mode = value
resetPage()
}
@@ -2615,8 +2634,8 @@ const queryParams = reactive({
fulfillment_status: '' as number | '',
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
supply_mode: '' as '' | 'gancao' | 'self',
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0' */
service_channel: '' as '' | '0',
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
@@ -2901,7 +2920,7 @@ const listStats = computed(() => {
const oC = hasOrderSplit ? n(ex?.stats_order_amount_cancelled) : 0
const pNc = hasPaySplit ? n(ex?.stats_linked_pay_amount_not_cancelled) : n(pay)
const pC = hasPaySplit ? n(ex?.stats_linked_pay_amount_cancelled) : 0
// = (fulfillment_status=4) not_cancelled
// = (4)(9)退(10) not_cancelled
const oPerf = ex?.stats_order_amount_performance !== undefined
? n(ex?.stats_order_amount_performance)
: oNc
@@ -2941,7 +2960,7 @@ const listStats = computed(() => {
payHeading: `${headPrefix}业绩(关联实付)`,
periodLine,
scopeHint,
orderSplitHint: '业绩 = 除履约已取消(4)外全部订单金额;下方为合计与已取消明细'
orderSplitHint: '业绩 = 除履约已取消(4)、拒收(9)、退款(10)后的订单金额;下方为合计与排除项明细'
}
})
@@ -3071,14 +3090,7 @@ function canEditRow(row: {
return false
}
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0
// /// 6 edit
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
// (1)(2)
return fs === 1 || fs === 2
@@ -3106,6 +3118,7 @@ function canRevokeRxAudit(row: {
payment_slip_audit_status?: number
fulfillment_status?: number
}) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const fs = Number(row.fulfillment_status)
if (fs === 3 || fs === 4 || fs === 6) return false
const rxStatus = Number(row.prescription_audit_status)
@@ -3128,6 +3141,7 @@ function canRevokePayAudit(row: {
}
function canWithdrawRow(row: { fulfillment_status?: number }) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
//
return Number(row.fulfillment_status) === 1
}
@@ -3152,13 +3166,13 @@ 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 isShipModeLocked(row: { gancao_reciperl_order_no?: string | null; ej_pharmacy_order_no?: string | null }) {
return isRemoteSnapshotLocked(row as Record<string, unknown>)
}
function canEditShipMode(row: { fulfillment_status?: number }) {
function canEditShipMode(row: { fulfillment_status?: number; gancao_reciperl_order_no?: string; ej_pharmacy_order_no?: string }) {
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
return fs !== 3 && fs !== 4 && !isShipModeLocked(row)
}
const shipModeSaving = ref(false)
@@ -3200,6 +3214,11 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
}
}
async function handleGancaoSubmissionResolved() {
await refreshCurrentPrescriptionOrderDetail()
getLists()
}
function canAddPayOrderRow(row: {
fulfillment_status?: number
amount?: number | string
@@ -3225,19 +3244,31 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
// (5) (6)
return Number(row.fulfillment_status) === 5
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
return true
}
function canUploadGancaoRow(row: {
function canUploadPharmacyRow(row: {
prescription_audit_status?: number
gancao_reciperl_order_no?: string
fulfillment_status?: number
ship_mode?: string
gancao_reciperl_order_no?: string
ej_pharmacy_order_no?: string
ej_pharmacy_status?: string
ej_pharmacy_review_status?: string
can_upload_pharmacy?: boolean
}) {
// (1)
if (row.can_upload_pharmacy === false) return false
const rxStatus = Number(row.prescription_audit_status)
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
return rxStatus === 1 && gancaoOrderNo === ''
const fs = Number(row.fulfillment_status)
const ejRejected = ['REJECTED'].includes(String(row.ej_pharmacy_status || '').toUpperCase())
|| ['REJECTED'].includes(String(row.ej_pharmacy_review_status || '').toUpperCase())
if (rxStatus !== 1 || ([3, 4, 8, 9, 10, 11, 12].includes(fs) && !(fs === 9 && ejRejected))) return false
if (normalizeShipMode(row.ship_mode) === 'direct') {
return (String(row.ej_pharmacy_order_no || '').trim() === '' || ejRejected)
&& String(row.gancao_reciperl_order_no || '').trim() === ''
}
return String(row.gancao_reciperl_order_no || '').trim() === '' && String(row.ej_pharmacy_order_no || '').trim() === ''
}
// PrescriptionOrderDetailDrawer
@@ -3863,7 +3894,7 @@ async function confirmWithdraw(row: { id: number }) {
}
}
const gancaoSubmitId = ref(0)
const pharmacySubmitId = ref(0)
const gancaoPreviewDrawerVisible = ref(false)
const gancaoPreviewLoading = ref(false)
const gancaoPreviewData = ref<any>(null)
@@ -4002,18 +4033,24 @@ async function testGancaoPreviewFromDetail() {
}
}
async function confirmSubmitGancaoRecipel(row: { id: number }) {
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
const isLuoyang = normalizeShipMode(row.ship_mode) === 'direct'
const pharmacyName = isLuoyang ? '洛阳药房' : '甘草药房'
try {
await ElMessageBox.confirm(
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html 。确定继续?',
'上传甘草药方',
`将把本单关联处方提交至${pharmacyName},提交成功后不可切换发货药房。确定继续?`,
'上传药房',
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
)
gancaoSubmitId.value = row.id
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
pharmacySubmitId.value = row.id
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
const d = res?.data ?? res
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
const no = d?.pharmacy_order_no != null
? String(d.pharmacy_order_no)
: d?.recipel_order_no != null
? String(d.recipel_order_no)
: ''
feedback.msgSuccess(no ? `上传成功,${pharmacyName}单号:${no}` : '上传成功')
getLists()
await detailDrawerRef.value?.refreshIfCurrent(row.id)
} catch (e: any) {
@@ -4021,7 +4058,7 @@ async function confirmSubmitGancaoRecipel(row: { id: number }) {
/* 拦截器已提示 */
}
} finally {
gancaoSubmitId.value = 0
pharmacySubmitId.value = 0
}
}
@@ -4057,33 +4094,8 @@ async function submitQuickTrack() {
}
quickTrackSaving.value = true
try {
//
const res: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
const d = res?.data ?? res
if (!d) {
feedback.msgError('加载订单数据失败')
return
}
await prescriptionOrderEdit({
id: d.id,
recipient_name: d.recipient_name || '',
recipient_phone: d.recipient_phone || '',
shipping_province: d.shipping_province || '',
shipping_city: d.shipping_city || '',
shipping_district: d.shipping_district || '',
shipping_address: d.shipping_address || '',
is_follow_up: d.is_follow_up ? 1 : 0,
medication_days: (d.medication_days != null && String(d.medication_days).trim() !== '') ? d.medication_days : '',
prev_staff: d.prev_staff || '',
service_channel: d.service_channel || '',
service_package: d.service_package || '',
fee_type: Number(d.fee_type) || 3,
amount: Number(d.amount) || 0,
remark_extra: d.remark_extra || '',
remark_assistant: d.remark_assistant || '',
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
//
await prescriptionOrderDdcode({
id: quickTrackRowId.value,
express_company: quickTrackForm.express_company || 'auto',
tracking_number: quickTrackForm.tracking_number.trim()
})
@@ -4092,17 +4104,6 @@ async function submitQuickTrack() {
getLists()
// Drawer
await detailDrawerRef.value?.refreshIfCurrent(quickTrackRowId.value)
// (2)(5)/(6)
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
try {
await ElMessageBox.confirm(
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
'确认发货',
{ type: 'success', confirmButtonText: '确认发货', cancelButtonText: '稍后再说' }
)
openShip({ id: quickTrackRowId.value, tracking_number: quickTrackForm.tracking_number, express_company: quickTrackForm.express_company })
} catch { /* 用户点了「稍后」 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
@@ -365,8 +365,8 @@
<el-tag
size="small"
effect="plain"
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
>{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}</el-tag>
:type="supplyModeTagType(row)"
>{{ supplyModeLabel(row) }}</el-tag>
<span class="po-card__id">#{{ row.id }}</span>
</div>
</div>
@@ -500,7 +500,11 @@
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
<span class="text-red-500">退款</span>
</el-dropdown-item>
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item>
<el-dropdown-item
v-if="canUploadPharmacyRow(row)"
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
command="uploadPharmacy"
>上传药房</el-dropdown-item>
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
<span class="text-red-500">撤回订单</span>
</el-dropdown-item>
@@ -544,6 +548,10 @@
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
</div>
<div v-if="detailData" class="po-detail-drawer-actions flex flex-wrap items-center gap-2">
<gancao-submission-reconcile-button
:order="detailData"
@resolved="handleGancaoSubmissionResolved"
/>
<el-button
v-if="canRxAudit(detailData)"
v-perms="['tcm.prescriptionOrder/auditPrescription']"
@@ -858,31 +866,94 @@
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
<div class="flex flex-col gap-1 text-sm leading-relaxed">
<div>
<span v-if="detailHasAuxHerbs" class="text-gray-500 mr-1">主方</span>
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</div>
<div v-if="detailHasAuxHerbs && detailAuxUsage">
<span class="text-gray-500 mr-1">辅方</span>
<template v-if="detailAuxUsage.dosage_amount != null && detailAuxUsage.dosage_amount !== 0">
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="服用方式">
<el-descriptions-item>
<template #label>
<div class="flex items-center gap-1 flex-wrap">
<span>服用方式</span>
<el-button
v-if="
detailData.prescription_id &&
detailPrescription &&
!String(detailData.prescription_detail_error || '').trim()
"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
@click="openPatchUsageDialog"
>
修改
</el-button>
</div>
</template>
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
? detailPrescription.usage_days + ' 天'
: '—'
}}
</div>
<template v-if="detailHasAuxHerbs">
<div>
<span class="text-gray-500 mr-1">主方</span>
每天
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '— 次' }}
· 处方开立
{{
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
? detailPrescription.usage_days + ' 天'
: '—'
}}
</div>
<div v-if="detailAuxUsage">
<span class="text-gray-500 mr-1">辅方</span>
每天
{{ detailAuxUsage.times_per_day ? detailAuxUsage.times_per_day + ' 次' : '— 次' }}
· 处方开立
{{
detailAuxUsage.usage_days != null && Number(detailAuxUsage.usage_days) > 0
? detailAuxUsage.usage_days + ' 天'
: '— 天'
}}
</div>
</template>
<template v-else>
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
? detailPrescription.usage_days + ' 天'
: '—'
}}
</div>
</template>
<div>
<span class="text-gray-500">订单设置</span>
{{
@@ -1822,10 +1893,12 @@
/>
<el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip">
<el-form-item label="发货方式">
<el-radio-group v-model="shipForm.ship_mode">
<el-radio label="gancao">甘草药方发</el-radio>
<el-radio label="direct">药房直发</el-radio>
</el-radio-group>
<el-tag
:type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'"
effect="plain"
size="default"
>{{ shipDialogModeDisplay }}</el-tag>
<span class="text-xs text-gray-400 ml-2">请在订单详情顶部发货类型中设置此处不可修改</span>
</el-form-item>
<el-form-item label="承运商">
<el-select v-model="shipForm.express_company" class="w-full">
@@ -2152,6 +2225,93 @@
</template>
</el-dialog>
<!-- 修改服用参数主方 / 辅方 / 订单设置 -->
<el-dialog
v-model="patchUsageVisible"
title="修改服用参数"
width="92%"
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="resetPatchUsageForm"
>
<el-form
ref="patchUsageFormRef"
:model="patchUsageForm"
:rules="patchUsageRules"
label-width="96px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="aux_usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.aux_usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</template>
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
<el-form-item label="服用天数" prop="medication_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.medication_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="patchUsageVisible = false">取消</el-button>
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
</template>
</el-dialog>
<!-- 处方详情查看处方单样式 -->
<el-drawer
v-model="prescriptionViewVisible"
@@ -2547,11 +2707,13 @@ import {
User
} from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
prescriptionOrderAuditPayment,
prescriptionOrderAuditPrescription,
prescriptionOrderDetail,
prescriptionOrderDdcode,
prescriptionOrderEdit,
prescriptionOrderLists,
prescriptionOrderPaidPayOrders,
@@ -2566,9 +2728,10 @@ import {
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderPatchPrescriptionUsage,
prescriptionOrderLinkPayOrder,
prescriptionOrderRequestCompletion,
prescriptionOrderSubmitGancaoRecipel,
prescriptionOrderUploadToPharmacy,
prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail,
getDoctors,
@@ -2580,7 +2743,12 @@ import {
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions,
formatServicePackageLabels
formatServicePackageLabels,
normalizeSlipAuxUsageForm,
prescriptionHasAuxFormula,
isRemoteSnapshotLocked,
supplyModeLabel,
supplyModeTagType
} from './components/prescription-order-utils'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
@@ -2831,12 +2999,13 @@ function handleFulfillmentStatusTabClick(value: number | '') {
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
const supplyModeTabs = [
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
{ label: '甘草', value: 'gancao' as const },
{ label: '洛阳直发', value: 'direct' as const },
{ label: '自营', value: 'self' as const }
]
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
queryParams.supply_mode = value
resetPage()
}
@@ -2870,8 +3039,8 @@ const queryParams = reactive({
fulfillment_status: '' as number | '',
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
supply_mode: '' as '' | 'gancao' | 'self',
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
/** 下单人(关联操作日志 audit_rx_* / audit_pay_* */
audit_admin_id: '' as number | ''
})
@@ -3306,13 +3475,7 @@ function canEditRow(row: {
return false
}
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
return fs === 1 || fs === 2
}
@@ -3339,6 +3502,7 @@ function canRevokeRxAudit(row: {
payment_slip_audit_status?: number
fulfillment_status?: number
}) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const fs = Number(row.fulfillment_status)
if (fs === 3 || fs === 4 || fs === 6) return false
const rxStatus = Number(row.prescription_audit_status)
@@ -3361,6 +3525,7 @@ function canRevokePayAudit(row: {
}
function canWithdrawRow(row: { fulfillment_status?: number }) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
//
return Number(row.fulfillment_status) === 1
}
@@ -3387,18 +3552,30 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 5
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
return true
}
function canUploadGancaoRow(row: {
function canUploadPharmacyRow(row: {
prescription_audit_status?: number
gancao_reciperl_order_no?: string
fulfillment_status?: number
gancao_reciperl_order_no?: string
ej_pharmacy_order_no?: string
ej_pharmacy_status?: string
ej_pharmacy_review_status?: string
can_upload_pharmacy?: boolean
}) {
// (1)
if (row.can_upload_pharmacy === false) return false
const rxStatus = Number(row.prescription_audit_status)
const fulfillmentStatus = Number(row.fulfillment_status)
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
return rxStatus === 1 && gancaoOrderNo === ''
const ejOrderNo = String(row.ej_pharmacy_order_no || '').trim()
const ejRejected = String(row.ej_pharmacy_status || '').toUpperCase() === 'REJECTED'
|| String(row.ej_pharmacy_review_status || '').toUpperCase() === 'REJECTED'
return rxStatus === 1
&& (![3, 4, 8, 9, 10, 11, 12].includes(fulfillmentStatus) || (fulfillmentStatus === 9 && ejRejected))
&& gancaoOrderNo === ''
&& (ejOrderNo === '' || ejRejected)
}
function orderStatusText(s: number | undefined) {
@@ -3436,7 +3613,7 @@ const h5ActiveFilterCount = computed(() => {
// H5:
function hasMoreCardActions(row: Record<string, any>) {
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadPharmacyRow(row) || canWithdrawRow(row)
}
// H5:
@@ -3444,7 +3621,7 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
else if (cmd === 'complete') confirmComplete(row as any)
else if (cmd === 'refund') openRefundOrder(row as any)
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any)
else if (cmd === 'uploadPharmacy') confirmUploadToPharmacy(row as any)
else if (cmd === 'withdraw') confirmWithdraw(row as any)
}
@@ -3618,6 +3795,16 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
const detailHasAuxHerbs = computed(() => prescriptionHasAuxFormula(detailPrescription.value as any))
const detailAuxUsage = computed(() => {
const rx = detailPrescription.value as any
if (!rx || !prescriptionHasAuxFormula(rx)) return null
const raw = rx.aux_usage
if (raw == null || raw === '' || (Array.isArray(raw) && raw.length === 0)) return null
return normalizeSlipAuxUsageForm(raw, rx.prescription_type || '浓缩水丸')
})
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
@@ -3766,6 +3953,8 @@ function logActionText(act: string) {
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单',
@@ -3844,6 +4033,101 @@ const patchRxPatientRules: FormRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
const rules: FormRules = {
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
}
return rules
})
function openPatchUsageDialog() {
const rx = detailPrescription.value
const ord = detailData.value
if (!rx || !ord?.id || !ord.prescription_id) {
feedback.msgWarning('无处方数据')
return
}
const aux = detailAuxUsage.value
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageForm.aux_times_per_day =
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.medication_days = undefined
}
async function submitPatchUsage() {
const form = patchUsageFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
const ordId = detailData.value?.id
if (!ordId) return
patchUsageSaving.value = true
try {
const payload: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
patchUsageVisible.value = false
await refreshCurrentPrescriptionOrderDetail()
await fetchLogs(ordId)
getLists()
} catch {
/* 拦截器已提示 */
} finally {
patchUsageSaving.value = false
}
}
function openPatchRxPatientDialog() {
const rx = detailPrescription.value
const ord = detailData.value
@@ -4463,7 +4747,7 @@ async function confirmWithdraw(row: { id: number }) {
}
}
const gancaoSubmitId = ref(0)
const pharmacySubmitId = ref(0)
const gancaoPreviewDrawerVisible = ref(false)
const gancaoPreviewLoading = ref(false)
const gancaoPreviewData = ref<any>(null)
@@ -4555,18 +4839,19 @@ async function testGancaoPreviewFromDetail() {
}
}
async function confirmSubmitGancaoRecipel(row: { id: number }) {
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
try {
const target = String(row.ship_mode || 'gancao') === 'direct' ? '洛阳药房' : '甘草药房'
await ElMessageBox.confirm(
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
'上传甘草药方',
`确认将本单关联处方上传至${target}`,
'上传药房',
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
)
gancaoSubmitId.value = row.id
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
pharmacySubmitId.value = row.id
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
const d = res?.data ?? res
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
const no = String(d?.pharmacy_order_no || d?.recipel_order_no || '').trim()
feedback.msgSuccess(no ? `上传成功,药房单号:${no}` : '上传成功')
getLists()
if (detailVisible.value && Number(detailData.value?.id) === row.id) {
try {
@@ -4582,7 +4867,7 @@ async function confirmSubmitGancaoRecipel(row: { id: number }) {
/* 拦截器已提示 */
}
} finally {
gancaoSubmitId.value = 0
pharmacySubmitId.value = 0
}
}
@@ -4616,33 +4901,8 @@ async function submitQuickTrack() {
}
quickTrackSaving.value = true
try {
//
const res: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
const d = res?.data ?? res
if (!d) {
feedback.msgError('加载订单数据失败')
return
}
await prescriptionOrderEdit({
id: d.id,
recipient_name: d.recipient_name || '',
recipient_phone: d.recipient_phone || '',
shipping_province: d.shipping_province || '',
shipping_city: d.shipping_city || '',
shipping_district: d.shipping_district || '',
shipping_address: d.shipping_address || '',
is_follow_up: d.is_follow_up ? 1 : 0,
medication_days: (d.medication_days != null && String(d.medication_days).trim() !== '') ? d.medication_days : '',
prev_staff: d.prev_staff || '',
service_channel: d.service_channel || '',
service_package: d.service_package || '',
fee_type: Number(d.fee_type) || 3,
amount: Number(d.amount) || 0,
remark_extra: d.remark_extra || '',
remark_assistant: d.remark_assistant || '',
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
//
await prescriptionOrderDdcode({
id: quickTrackRowId.value,
express_company: quickTrackForm.express_company || 'auto',
tracking_number: quickTrackForm.tracking_number.trim()
})
@@ -4655,19 +4915,9 @@ async function submitQuickTrack() {
const r: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(quickTrackRowId.value)
} catch { /* 静默 */ }
}
// (2)(5)/(6)
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
try {
await ElMessageBox.confirm(
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
'确认发货',
{ type: 'success', confirmButtonText: '确认发货', cancelButtonText: '稍后再说' }
)
openShip({ id: quickTrackRowId.value, tracking_number: quickTrackForm.tracking_number, express_company: quickTrackForm.express_company })
} catch { /* 用户点了「稍后」 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
@@ -4685,6 +4935,10 @@ const shipForm = reactive({
tracking_number: ''
})
const shipDialogModeDisplay = computed(() =>
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
)
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
shipRowId.value = row.id
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
@@ -4724,6 +4978,13 @@ async function submitShip() {
}
}
async function handleGancaoSubmissionResolved() {
getLists()
if (detailData.value?.id) {
await openDetail(Number(detailData.value.id))
}
}
const completeOrderStatusOptions = [
{ value: 3, label: '已完成' },
{ value: 7, label: '进行中' },
@@ -0,0 +1,528 @@
<template>
<div class="conversion-page" v-loading="loading" element-loading-text="正在汇总权限范围内的数据">
<header class="page-heading">
<div>
<h1>综合数据转化</h1>
<p>{{ scopeDescription }}</p>
</div>
<div class="heading-meta">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
<span v-if="dashboard.meta.generated_at">更新于 {{ dashboard.meta.generated_at }}</span>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
</div>
</header>
<section class="filter-strip">
<div class="filter-item filter-item--time">
<span class="filter-label">时间范围</span>
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
</div>
<div class="filter-item">
<span class="filter-label">搜索员工</span>
<el-select
v-model="query.assistant_id"
clearable
filterable
placeholder="全部员工"
class="employee-select"
@change="loadDashboard"
>
<el-option
v-for="item in dashboard.filters.assistants"
:key="item.id"
:label="item.name"
:value="Number(item.id)"
/>
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">部门</span>
<el-tree-select
v-model="query.dept_id"
:data="dashboard.filters.departments"
:props="deptTreeProps"
node-key="id"
clearable
filterable
check-strictly
default-expand-all
placeholder="全部可见部门"
class="dept-select"
@change="handleDeptChange"
/>
</div>
<div class="filter-item">
<span class="filter-label">渠道</span>
<el-select
v-model="query.media_channel_code"
clearable
filterable
placeholder="全部渠道"
class="channel-select"
@change="loadDashboard"
>
<el-option
v-for="item in dashboard.filters.media_channels"
:key="item.code"
:label="item.name"
:value="item.code"
/>
</el-select>
</div>
<span class="range-text">{{ dashboard.meta.start_date }} {{ dashboard.meta.end_date }}</span>
</section>
<section class="metric-grid" aria-label="综合转化指标">
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
<small>{{ metric.hint }}</small>
</article>
</section>
<section class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门订单量占比</h2>
<p>按订单创建人归属排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="item in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门金额占比</h2>
<p>仅统计未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="item in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
</article>
</section>
<section class="panel detail-panel">
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/预约接诊率=接诊诊单/加粉</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
<el-table
:data="dashboard.rows"
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
class="detail-table"
>
<el-table-column prop="name" label="部门 / 人员" min-width="250" fixed="left">
<template #default="{ row }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
{{ row.name }}
</strong>
<el-tag v-if="row.type === 'member' && row.role" size="small" type="info" effect="plain" class="role-tag">
{{ row.role }}{{ row.is_leader ? ' · 组长' : '' }}
</el-tag>
<el-tag v-else-if="row.type === 'unbound'" size="small" type="warning" effect="plain" class="role-tag">
未绑定账号
</el-tag>
</template>
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
<el-table-column label="接诊金额" min-width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column>
<el-table-column label="开口率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column>
<el-table-column label="挂号率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
<el-table-column label="ROI" min-width="86" align="right">
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
</el-table-column>
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
</el-table>
</section>
<section class="panel target-panel">
<div class="panel-heading">
<div>
<h2>一诊诊金目标追踪</h2>
<p>{{ dashboard.target.year }} · 按当前角色部门及员工筛选范围计算</p>
</div>
<span>{{ dashboard.target.department_count }} 个目标部门</span>
</div>
<div class="target-layout">
<div class="target-progress-list">
<div class="target-summary">
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
</div>
<div class="progress-block">
<div class="progress-copy">
<span>年度范围目标</span>
<b>{{ nullablePercent(dashboard.target.completion_rate) }}</b>
</div>
<el-progress
:percentage="progressValue(dashboard.target.completion_rate)"
:show-text="false"
:stroke-width="12"
color="#0f9185"
/>
<small>已完成 {{ formatMoney(dashboard.target.actual_amount) }} / 目标 {{ formatMoney(dashboard.target.target_amount) }}</small>
</div>
<div class="progress-block progress-block--month">
<div class="progress-copy">
<span>本月范围目标</span>
<b>{{ nullablePercent(dashboard.target.current_month_rate) }}</b>
</div>
<el-progress
:percentage="progressValue(dashboard.target.current_month_rate)"
:show-text="false"
:stroke-width="12"
color="#2f78df"
/>
<small>已完成 {{ formatMoney(dashboard.target.current_month_actual) }} / 目标 {{ formatMoney(dashboard.target.current_month_target) }}</small>
</div>
<div v-if="Number(dashboard.target.target_amount) <= 0" class="target-empty-note">
当前可见部门尚未维护本年度月度目标实际诊单金额仍会正常统计
</div>
</div>
<div class="trend-wrap">
<div class="trend-title">
<span>年度累计趋势</span>
<div><i class="legend-line is-actual" />实际完成 <i class="legend-line is-target" />目标值</div>
</div>
<v-charts class="target-chart" :option="targetChartOption" autoresize />
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts" name="firstVisitConversionPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Lock, Refresh } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
type MetricType = 'count' | 'money' | 'ratio'
const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
},
filters: {
departments: [] as any[],
assistants: [] as Array<{ id: number; name: string }>,
media_channels: [] as Array<{ code: string; name: string }>
},
summary: {} as Record<string, any>,
rankings: { orders: [] as any[], amounts: [] as any[] },
rows: [] as any[],
target: {
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0, completion_rate: null as number | null,
current_month_target: 0, current_month_actual: 0, current_month_rate: null as number | null,
department_count: 0, months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
}
})
const dashboard = reactive(emptyDashboard())
const loading = ref(false)
const query = reactive<FirstVisitConversionParams>({
time_type: 'today',
dept_id: undefined,
assistant_id: undefined,
media_channel_code: ''
})
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '昨天', value: 'yesterday' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '本季度', value: 'quarter' },
{ label: '本年', value: 'year' }
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
{ key: 'avg_unit_price', label: '平均客单价', type: 'money', hint: '诊单金额 / 接诊诊单' },
{ key: 'account_cost', label: '现金成本', type: 'money', hint: '当前范围内实际投放成本' },
{ key: 'roi', label: 'ROI', type: 'ratio', hint: '诊单金额 / 投放成本' }
]
const scopeDescription = computed(() => {
const parts = [`${dashboard.meta.time_label || '当前区间'}数据`, dashboard.meta.scope_label || '当前权限范围']
if (dashboard.meta.selected_dept_name) parts.push(dashboard.meta.selected_dept_name)
if (dashboard.meta.selected_assistant_name) parts.push(dashboard.meta.selected_assistant_name)
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
return parts.join(' · ')
})
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
const targetChartOption = computed(() => ({
animationDuration: 450,
color: ['#0f9185', '#2f78df'],
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
grid: { left: 62, right: 24, top: 28, bottom: 36 },
xAxis: { type: 'category', boundaryGap: false, data: dashboard.target.months, axisLine: { lineStyle: { color: '#d9e1e9' } }, axisLabel: { color: '#718096' } },
yAxis: { type: 'value', axisLabel: { color: '#718096', formatter: (value: number) => compactNumber(value) }, splitLine: { lineStyle: { color: '#edf1f5' } } },
series: [
{ name: '实际完成', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 3 }, areaStyle: { color: 'rgba(15,145,133,.08)' }, data: dashboard.target.actual_cumulative },
{ name: '目标值', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 2, type: 'dashed' }, data: dashboard.target.target_cumulative }
]
}))
async function loadDashboard() {
loading.value = true
try {
const result: any = await firstVisitConversionOverview(query)
Object.assign(dashboard, emptyDashboard(), result || {})
} catch (error: any) {
ElMessage.error(error?.message || '综合数据加载失败')
} finally {
loading.value = false
}
}
function handleDeptChange() {
query.assistant_id = undefined
loadDashboard()
}
function formatMetric(key: string, type: MetricType) {
const value = dashboard.summary[key]
if (type === 'money') return formatMoney(value)
if (type === 'ratio') return formatRatio(value)
return formatNumber(value)
}
function formatNumber(value: any) {
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
}
function formatMoney(value: any) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPercent(value: any) {
return `${Number(value || 0).toFixed(1)}%`
}
function nullablePercent(value: any) {
return value === null || value === undefined ? '未设置' : formatPercent(value)
}
function formatRatio(value: any) {
return Number(value || 0).toFixed(2)
}
function compactNumber(value: number) {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(0)}`
return String(Math.round(value))
}
function barWidth(value: any, maximum: number) {
if (maximum <= 0) return '0%'
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%`
}
function progressValue(value: any) {
return Math.max(0, Math.min(100, Number(value || 0)))
}
onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.conversion-page {
display: grid;
gap: 14px;
min-height: 640px;
padding: 16px;
color: #172033;
background: #f4f6f8;
}
.page-heading,
.filter-strip,
.panel,
.metric-card {
border: 1px solid #dfe5ec;
background: #fff;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 15px 18px;
border-radius: 10px;
h1 { margin: 0; font-size: 20px; font-weight: 750; }
p { margin: 5px 0 0; color: #8590a2; font-size: 12px; }
}
.heading-meta {
display: flex;
align-items: center;
gap: 12px;
color: #8994a5;
font-size: 12px;
}
.scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
color: #0d8077;
}
.filter-strip {
display: flex;
align-items: center;
gap: 22px;
padding: 12px 16px;
border-radius: 9px;
}
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-label, .range-text { color: #748094; font-size: 12px; white-space: nowrap; }
.range-text { margin-left: auto; }
.employee-select { width: 190px; }
.dept-select { width: 220px; }
.channel-select { width: 180px; }
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
min-height: 100px;
padding: 15px 17px;
border-radius: 10px;
span, small { display: block; color: #748094; font-size: 12px; }
strong { display: block; margin: 9px 0 7px; color: #111b2f; font-size: 25px; line-height: 1; font-variant-numeric: tabular-nums; }
small { color: #a0a9b6; font-size: 11px; }
}
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { padding: 16px; border-radius: 10px; }
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
h2 { margin: 0; font-size: 15px; font-weight: 700; }
p { margin: 4px 0 0; color: #929dac; font-size: 11px; }
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
}
.bar-list { display: grid; gap: 13px; }
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
.bar-track i.is-teal { background: #15998d; }
.bar-track i.is-blue { background: #307bdf; }
.detail-panel { padding-bottom: 10px; }
.detail-table {
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
strong.is-parent { color: #172033; font-weight: 700; }
strong.is-member { color: #314158; font-weight: 600; }
.role-tag { margin-left: 7px; vertical-align: middle; }
}
.target-panel { padding-bottom: 18px; }
.target-layout { display: grid; grid-template-columns: minmax(390px, .82fr) minmax(500px, 1.18fr); gap: 22px; }
.target-progress-list { padding-right: 20px; border-right: 1px solid #e4e9ef; }
.target-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
.target-summary div { display: grid; gap: 5px; }
.target-summary span { color: #8590a2; font-size: 11px; }
.target-summary strong { font-size: 18px; font-variant-numeric: tabular-nums; }
.target-summary strong.is-teal { color: #0f9185; }
.progress-block + .progress-block { margin-top: 18px; }
.progress-copy { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.progress-copy b { color: #263246; font-size: 13px; }
.progress-block small { display: block; margin-top: 7px; color: #8a95a5; font-size: 11px; }
.target-empty-note { margin-top: 16px; padding: 9px 11px; border-radius: 7px; color: #a36b1e; background: #fff7e8; font-size: 11px; }
.trend-title { display: flex; align-items: center; justify-content: space-between; color: #2e3a4d; font-size: 12px; font-weight: 600; }
.trend-title > div { display: flex; align-items: center; gap: 7px; color: #7f8a9b; font-size: 10px; font-weight: 400; }
.legend-line { width: 18px; border-top: 2px solid; }
.legend-line.is-actual { border-color: #0f9185; }
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
.target-chart { width: 100%; height: 260px; }
@media (max-width: 1180px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.range-text { margin-left: 0; }
.target-layout { grid-template-columns: 1fr; }
.target-progress-list { padding-right: 0; padding-bottom: 18px; border-right: 0; border-bottom: 1px solid #e4e9ef; }
}
@media (max-width: 820px) {
.conversion-page { padding: 10px; }
.page-heading, .heading-meta { align-items: flex-start; flex-direction: column; }
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
.target-summary { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,709 @@
<template>
<div class="doctor-dashboard" v-loading="loading" element-loading-text="正在汇总医生经营数据">
<header class="page-heading">
<div class="heading-copy">
<span class="heading-mark"><el-icon><DataLine /></el-icon></span>
<div>
<h1>医生看板</h1>
<p>从挂号预约面诊到接诊成交统一观察医生经营表现</p>
</div>
</div>
<div class="heading-actions">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
<el-button :icon="Download" @click="exportRows">导出</el-button>
</div>
</header>
<section class="filter-strip">
<div class="filter-item filter-item--time">
<span>时间范围</span>
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
</div>
<div v-if="dashboard.filters.can_filter_department" class="filter-item">
<span>所属部门</span>
<el-tree-select
v-model="query.dept_id"
:data="dashboard.filters.departments"
:props="deptTreeProps"
node-key="id"
clearable
filterable
check-strictly
default-expand-all
placeholder="全部可见部门"
class="dept-select"
@change="handleDepartmentChange"
/>
</div>
<div class="filter-item">
<span>医生</span>
<el-select
v-model="query.doctor_id"
clearable
filterable
placeholder="全部医生"
class="doctor-select"
@change="loadDashboard"
>
<el-option
v-for="doctor in dashboard.filters.doctors"
:key="doctor.id"
:value="Number(doctor.id)"
:label="`${doctor.name}${Number(doctor.disable) === 1 ? '(停用)' : ''}`"
/>
</el-select>
</div>
<div class="filter-item filter-item--status">
<span>医生范围</span>
<el-segmented v-model="query.active_only" :options="doctorStatusOptions" @change="handleActiveChange" />
</div>
<div class="view-switch">
<button :class="{ active: viewMode === 'overview' }" @click="viewMode = 'overview'">诊断总览</button>
<button :class="{ active: viewMode === 'detail' }" @click="viewMode = 'detail'">医生明细</button>
</div>
</section>
<section class="metric-grid" aria-label="医生经营核心指标">
<article class="metric-card metric-card--teal">
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
<div>
<span>总挂号</span>
<strong>{{ formatNumber(dashboard.summary.registration_total) }}</strong>
<small>已支付且实收金额大于 0低于 10 元的订单</small>
</div>
</article>
<article class="metric-card metric-card--green">
<div class="metric-icon"><el-icon><User /></el-icon></div>
<div>
<span>总面诊</span>
<strong>{{ formatNumber(dashboard.summary.interview_count) }}</strong>
<small>过号 {{ formatNumber(dashboard.summary.missed_count) }} · 取消 {{ formatNumber(dashboard.summary.cancelled_count) }}</small>
</div>
</article>
<article class="metric-card metric-card--indigo">
<div class="metric-icon"><el-icon><Tickets /></el-icon></div>
<div>
<span>总诊单</span>
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
<small>接诊转化率 {{ formatPercent(dashboard.summary.receive_conversion_rate) }}</small>
</div>
</article>
<article class="metric-card metric-card--blue">
<div class="metric-icon"><el-icon><Money /></el-icon></div>
<div>
<span>总成交金额</span>
<strong>{{ formatMoney(dashboard.summary.deal_amount) }}</strong>
<small>客单价 {{ nullableMoney(dashboard.summary.avg_order_amount) }}</small>
</div>
</article>
<article class="metric-card metric-card--orange">
<div class="metric-icon"><el-icon><DataLine /></el-icon></div>
<div>
<span>总接诊率</span>
<strong>{{ formatPercent(dashboard.summary.receive_conversion_rate) }}</strong>
<small>总接诊 {{ formatNumber(dashboard.summary.order_count) }} ÷ 总面诊 {{ formatNumber(dashboard.summary.interview_count) }}</small>
</div>
</article>
<article class="metric-card metric-card--cyan">
<div class="metric-icon"><el-icon><CircleCheck /></el-icon></div>
<div>
<span>预约完成率</span>
<strong>{{ formatPercent(dashboard.summary.appointment_completion_rate) }}</strong>
<small>总预约 {{ formatNumber(dashboard.summary.appointment_total) }} · {{ dashboard.meta.doctor_count }} 位有数据医生</small>
</div>
</article>
</section>
<template v-if="viewMode === 'overview'">
<section class="two-column-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>成交金额 TOP</h2><p>按有效诊单金额从高到低</p></div>
<span>{{ dashboard.meta.time_label }}</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.doctor_id}`" class="bar-row">
<b>{{ index + 1 }}</b>
<span :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmount) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="52" description="当前范围暂无成交金额" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>接诊转化率 TOP</h2><p>有效诊单数 ÷ 完成面诊数</p></div>
<span>{{ dashboard.meta.time_label }}</span>
</div>
<div v-if="dashboard.rankings.conversion.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.conversion" :key="`rate-${item.doctor_id}`" class="bar-row">
<b>{{ index + 1 }}</b>
<span :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: percentageWidth(item.value) }" /></div>
<strong>{{ formatPercent(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="52" description="当前范围暂无转化数据" />
</article>
</section>
<section class="analytics-grid">
<article class="panel trend-panel">
<div class="panel-heading">
<div><h2> 30 天成交金额趋势</h2><p>{{ dashboard.trend.start_date }} {{ dashboard.trend.end_date }}</p></div>
<span>单位</span>
</div>
<v-charts class="trend-chart" :option="trendChartOption" autoresize />
</article>
</section>
<section class="panel alert-panel" :class="{ 'has-alerts': dashboard.alerts.length }">
<div class="panel-heading alert-heading">
<div>
<h2><el-icon><WarningFilled /></el-icon>需关注医生</h2>
<p>仅对完成过面诊的医生计算接诊转化预警</p>
</div>
<div class="threshold-control">
<span>接诊转化率低于</span>
<el-select v-model="query.alert_threshold" class="threshold-select" @change="loadDashboard">
<el-option v-for="value in thresholdOptions" :key="value" :label="`${value}%`" :value="value" />
</el-select>
</div>
</div>
<div v-if="dashboard.alerts.length" class="alert-list">
<div v-for="item in dashboard.alerts" :key="item.doctor_id" class="alert-row">
<span class="alert-icon">!</span>
<div class="alert-doctor"><strong>{{ item.doctor_name }}</strong><small>{{ item.department_name }}</small></div>
<span class="severity" :class="`is-${item.severity}`">{{ item.severity === 'high' ? '重点关注' : '低于阈值' }}</span>
<span class="alert-data">面诊 {{ item.interview_count }} / 接诊 {{ item.order_count }}</span>
<p>{{ item.suggestion }}</p>
<strong class="alert-rate">{{ formatPercent(item.rate) }}</strong>
</div>
</div>
<div v-else class="alert-empty">
<el-icon><CircleCheck /></el-icon>
当前范围内暂无低于 {{ query.alert_threshold }}% 的医生
</div>
</section>
</template>
<section v-else class="panel detail-panel">
<div class="panel-heading">
<div><h2>医生明细 · 按成交金额排序</h2><p>低于接诊率预警线的医生整行标红过号与取消分别展示</p></div>
<span>{{ businessRows.length }} 位有数据医生</span>
</div>
<el-table
:data="visibleDetailRows"
class="detail-table"
:default-sort="{ prop: 'deal_amount', order: 'descending' }"
:row-class-name="detailRowClassName"
>
<el-table-column prop="doctor_name" label="医生" min-width="130" fixed="left" sortable>
<template #default="{ row }"><strong>{{ row.doctor_name }}</strong></template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="90" sortable>
<template #default="{ row }">
<span class="status-pill" :class="{ disabled: row.status === 'disabled' }">
{{ row.status === 'disabled' ? '停用' : '活跃' }}
</span>
</template>
</el-table-column>
<el-table-column prop="appointment_total" label="预约" min-width="90" sortable />
<el-table-column prop="interview_count" label="面诊" min-width="90" sortable />
<el-table-column prop="order_count" label="接诊" min-width="90" sortable />
<el-table-column prop="receive_conversion_rate" label="接诊率" min-width="110" sortable>
<template #default="{ row }">
<span :class="conversionClass(row.receive_conversion_rate)">
<span v-if="isLowConversion(row)" class="warning-mark"></span>{{ formatPercent(row.receive_conversion_rate) }}
</span>
</template>
</el-table-column>
<el-table-column prop="deal_amount" label="成交金额" min-width="135" sortable>
<template #default="{ row }"><strong class="money-text">{{ formatDetailMoney(row.deal_amount) }}</strong></template>
</el-table-column>
<el-table-column label="过号/取消" min-width="110">
<template #default="{ row }">{{ formatNumber(row.appointment_missed) }}/{{ formatNumber(row.appointment_cancelled) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<el-button class="detail-action" type="primary" link @click="openDoctorDetail(row)">详情</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="当前权限和筛选范围内暂无医生数据" /></template>
</el-table>
<button
v-if="!query.doctor_id && zeroDataRows.length"
type="button"
class="zero-doctor-toggle"
@click="showZeroRows = !showZeroRows"
>
<span>{{ showZeroRows ? '▾' : '▸' }}</span>
{{ showZeroRows ? '收起' : '已隐藏' }} {{ zeroDataRows.length }} 位无业务数据医生{{ showZeroRows ? '' : '(点击展开)' }}
</button>
</section>
<footer class="data-note">
<el-icon><InfoFilled /></el-icon>
<span>{{ dashboard.meta.registration_rule }}{{ dashboard.meta.appointment_rule }}{{ dashboard.meta.performance_rule }}</span>
</footer>
</div>
</template>
<script setup lang="ts" name="firstVisitDoctorDashboardPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
Calendar,
CircleCheck,
DataLine,
Download,
InfoFilled,
Lock,
Money,
Refresh,
Tickets,
User,
WarningFilled
} from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import {
firstVisitDoctorDashboardOverview,
type FirstVisitDoctorDashboardParams
} from '@/api/first_visit'
const emptyDashboard = () => ({
meta: {
time_type: 'month', time_label: '本月', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', scope_kind: '', selected_dept_name: '', selected_doctor_name: '',
doctor_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
},
filters: {
departments: [] as any[], doctors: [] as Array<{ id: number; name: string; disable: number }>,
can_filter_department: true
},
summary: {
registration_total: 0, appointment_total: 0, interview_count: 0, order_count: 0, deal_amount: 0,
avg_order_amount: null as number | null, appointment_completion_rate: null as number | null,
receive_conversion_rate: null as number | null, missed_count: 0, cancelled_count: 0
},
rankings: { amounts: [] as any[], conversion: [] as any[] },
trend: { start_date: '', end_date: '', dates: [] as string[], labels: [] as string[], amounts: [] as number[] },
alerts: [] as any[],
alert_threshold: 15,
rows: [] as any[]
})
const dashboard = reactive(emptyDashboard())
const loading = ref(false)
const viewMode = ref<'overview' | 'detail'>('overview')
const showZeroRows = ref(false)
const query = reactive<FirstVisitDoctorDashboardParams>({
time_type: 'month',
active_only: 1,
alert_threshold: 15
})
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' }
]
const doctorStatusOptions = [
{ label: '仅活跃医生', value: 1 },
{ label: '全部医生', value: 0 }
]
const thresholdOptions = [10, 15, 20, 30]
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
const maxAmount = computed(() => Math.max(0, ...dashboard.rankings.amounts.map((item: any) => Number(item.value) || 0)))
const businessRows = computed(() => dashboard.rows.filter((row: any) => hasBusinessData(row)))
const zeroDataRows = computed(() => dashboard.rows.filter((row: any) => !hasBusinessData(row)))
const visibleDetailRows = computed(() => {
if (query.doctor_id || showZeroRows.value) return dashboard.rows
return businessRows.value
})
const trendChartOption = computed(() => ({
animationDuration: 450,
grid: { left: 20, right: 20, top: 18, bottom: 18, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
xAxis: {
type: 'category', boundaryGap: false, data: dashboard.trend.labels,
axisLine: { lineStyle: { color: '#dfe5eb' } }, axisTick: { show: false },
axisLabel: { color: '#7e8a9b', fontSize: 10, interval: 4 }
},
yAxis: {
type: 'value', splitNumber: 4,
axisLabel: { color: '#8793a2', fontSize: 10, formatter: (value: number) => compactMoney(value) },
splitLine: { lineStyle: { color: '#eef2f5' } }
},
series: [{
name: '成交金额', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
data: dashboard.trend.amounts,
lineStyle: { color: '#139a8c', width: 3 },
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
areaStyle: { color: 'rgba(19,154,140,.08)' }
}]
}))
async function loadDashboard() {
loading.value = true
try {
const params: FirstVisitDoctorDashboardParams = {
time_type: query.time_type,
active_only: query.active_only ?? 1,
alert_threshold: Number(query.alert_threshold || 15)
}
if (query.dept_id) params.dept_id = Number(query.dept_id)
if (query.doctor_id) params.doctor_id = Number(query.doctor_id)
const result: any = await firstVisitDoctorDashboardOverview(params)
Object.assign(dashboard, emptyDashboard(), result || {})
} catch (error: any) {
ElMessage.error(error?.message || '医生看板加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function handleDepartmentChange() {
delete query.doctor_id
loadDashboard()
}
function handleActiveChange() {
delete query.doctor_id
loadDashboard()
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
}
function formatMoney(value: unknown) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatDetailMoney(value: unknown) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
function nullableMoney(value: unknown) {
return value === null || value === undefined ? '—' : formatMoney(value)
}
function formatPercent(value: unknown) {
return value === null || value === undefined ? '—' : `${Number(value).toFixed(1)}%`
}
function compactMoney(value: number) {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}`
return `${Math.round(value)}`
}
function barWidth(value: unknown, max: number) {
if (max <= 0) return '0%'
return `${Math.max(3, Math.min(100, Number(value || 0) / max * 100))}%`
}
function percentageWidth(value: unknown) {
return `${Math.max(0, Math.min(100, Number(value) || 0))}%`
}
function conversionClass(value: unknown) {
if (value === null || value === undefined) return 'conversion-rate is-neutral'
return Number(value) < Number(query.alert_threshold || 15) ? 'conversion-rate is-low' : 'conversion-rate is-good'
}
function hasBusinessData(row: any) {
return Number(row.appointment_total || 0) > 0
|| Number(row.interview_count || 0) > 0
|| Number(row.order_count || 0) > 0
|| Number(row.deal_amount || 0) > 0
|| Number(row.appointment_missed || 0) > 0
|| Number(row.appointment_cancelled || 0) > 0
}
function isLowConversion(row: any) {
return Number(row.interview_count || 0) > 0
&& Number(row.receive_conversion_rate || 0) < Number(query.alert_threshold || 15)
}
function detailRowClassName({ row }: { row: any }) {
if (isLowConversion(row)) return 'is-conversion-warning'
if (!hasBusinessData(row)) return 'is-zero-data'
return ''
}
async function openDoctorDetail(row: any) {
query.doctor_id = Number(row.doctor_id)
viewMode.value = 'overview'
showZeroRows.value = false
await loadDashboard()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function csvCell(value: unknown) {
return `"${String(value ?? '').replace(/"/g, '""')}"`
}
function exportRows() {
if (!dashboard.rows.length) {
ElMessage.warning('当前范围暂无可导出的医生数据')
return
}
const headers = ['医生', '状态', '预约', '面诊', '接诊', '接诊率', '成交金额', '过号', '取消']
const lines = dashboard.rows.map((row: any) => [
row.doctor_name, row.status === 'disabled' ? '停用' : '活跃', row.appointment_total,
row.interview_count, row.order_count, formatPercent(row.receive_conversion_rate), row.deal_amount,
row.appointment_missed, row.appointment_cancelled
].map(csvCell).join(','))
const blob = new Blob([`\uFEFF${headers.map(csvCell).join(',')}\n${lines.join('\n')}`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `医生看板_${dashboard.meta.start_date}_${dashboard.meta.end_date}.csv`
link.click()
URL.revokeObjectURL(url)
}
onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.doctor-dashboard {
--ink: #17243a;
--muted: #768497;
--line: #e2e8ee;
--canvas: #f4f6f8;
--teal: #139a8c;
--blue: #3d78e7;
min-height: 100%;
padding: 18px;
color: var(--ink);
background: var(--canvas);
}
.page-heading,
.filter-strip,
.metric-card,
.panel { border: 1px solid var(--line); background: #fff; }
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 76px;
padding: 14px 18px;
border-radius: 13px;
}
.heading-copy, .heading-actions, .filter-item, .threshold-control { display: flex; align-items: center; }
.heading-copy { gap: 12px; }
.heading-mark {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 11px;
color: #fff;
background: var(--teal);
box-shadow: 0 8px 20px rgba(19, 154, 140, .16);
font-size: 20px;
}
h1, h2, p { margin: 0; }
h1 { font-size: 20px; line-height: 1.3; }
h2 { font-size: 15px; line-height: 1.4; }
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
.heading-actions { gap: 10px; color: var(--muted); font-size: 12px; }
.scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 9px;
border: 1px solid #cce8e3;
border-radius: 7px;
color: #117f75;
background: #f2faf8;
}
.filter-strip {
display: flex;
align-items: center;
gap: 20px;
margin-top: 14px;
padding: 11px 14px;
border-radius: 11px;
}
.filter-item { gap: 8px; color: #68778a; font-size: 12px; }
.dept-select { width: 190px; }
.doctor-select { width: 160px; }
.view-switch { display: flex; margin-left: auto; padding-left: 12px; border-left: 1px solid #e6ebef; }
.view-switch button {
min-width: 82px;
padding: 9px 12px;
border: 0;
border-bottom: 2px solid transparent;
color: #657488;
background: transparent;
cursor: pointer;
}
.view-switch button.active { border-bottom-color: var(--teal); color: #0e8277; font-weight: 700; }
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.metric-card {
display: flex;
align-items: center;
gap: 12px;
min-height: 105px;
padding: 16px;
border-radius: 11px;
}
.metric-icon {
display: grid;
width: 40px;
height: 40px;
flex: 0 0 40px;
place-items: center;
border-radius: 10px;
font-size: 20px;
}
.metric-card--teal .metric-icon { color: #0b887c; background: #e8f7f4; }
.metric-card--green .metric-icon { color: #37a465; background: #eef8f1; }
.metric-card--indigo .metric-icon { color: #556dde; background: #eef0fd; }
.metric-card--blue .metric-icon { color: #3976e6; background: #edf3ff; }
.metric-card--orange .metric-icon { color: #d77a2c; background: #fff4e9; }
.metric-card--cyan .metric-icon { color: #138da1; background: #eaf7f8; }
.metric-card span { display: block; color: #748296; font-size: 12px; }
.metric-card strong { display: block; margin: 4px 0 3px; font-size: 24px; line-height: 1.1; }
.metric-card small { color: #8995a4; font-size: 10px; }
.two-column-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 16px 11px;
}
.panel-heading > span { color: #8a96a5; font-size: 11px; }
.ranking-panel { min-height: 284px; }
.bar-list { padding: 0 16px 15px; }
.bar-row {
display: grid;
grid-template-columns: 24px minmax(80px, .35fr) minmax(140px, 1fr) 110px;
align-items: center;
gap: 9px;
min-height: 34px;
}
.bar-row > b { color: #8793a2; text-align: center; font-size: 10px; }
.bar-row > span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-row > strong { text-align: right; font-size: 12px; }
.bar-track { height: 9px; overflow: hidden; border-radius: 6px; background: #edf1f4; }
.bar-track i { display: block; height: 100%; border-radius: inherit; }
.bar-track .is-blue { background: var(--blue); }
.bar-track .is-teal { background: var(--teal); }
.analytics-grid { display: grid; grid-template-columns: minmax(0, 1fr); }
.analytics-grid .panel { min-height: 300px; }
.trend-chart { width: 100%; height: 238px; }
.alert-panel { border-color: #dfe8e7; }
.alert-panel.has-alerts { border-color: #efc4c4; }
.alert-heading h2 { display: flex; align-items: center; gap: 6px; }
.alert-heading h2 .el-icon { color: #ee5b5b; }
.threshold-control { gap: 8px; color: #768497; font-size: 11px; }
.threshold-select { width: 88px; }
.alert-list { padding: 0 14px 14px; }
.alert-row {
display: grid;
grid-template-columns: 30px minmax(120px, .45fr) 82px 135px minmax(220px, 1fr) 65px;
align-items: center;
gap: 10px;
min-height: 54px;
margin-top: 8px;
padding: 0 12px;
border: 1px solid #f0dddd;
border-radius: 9px;
background: #fffafa;
}
.alert-icon { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 50%; color: #fff; background: #ef5a5a; font-weight: 700; }
.alert-doctor strong, .alert-doctor small { display: block; }
.alert-doctor strong { font-size: 12px; }
.alert-doctor small { margin-top: 2px; color: #8d98a6; font-size: 10px; }
.severity { padding: 4px 7px; border-radius: 5px; text-align: center; font-size: 10px; }
.severity.is-high { color: #d84444; background: #ffe9e9; }
.severity.is-medium { color: #c77726; background: #fff1df; }
.alert-data { color: #67768a; font-size: 11px; }
.alert-row p { color: #7b8797; font-size: 11px; }
.alert-rate { color: #e34949; text-align: right; font-size: 15px; }
.alert-empty { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 94px; color: #4f8f77; font-size: 12px; }
.alert-empty .el-icon { font-size: 20px; }
.detail-panel { min-height: 360px; }
.detail-table { border-top: 1px solid #edf1f4; --el-table-header-bg-color: #f7f9fb; }
.detail-table :deep(th.el-table__cell) { height: 43px; color: #68778a; font-weight: 500; }
.detail-table :deep(td.el-table__cell) { height: 47px; }
.detail-table :deep(.is-conversion-warning > td.el-table__cell) { background: #fff0f0 !important; }
.detail-table :deep(.is-zero-data > td.el-table__cell) { color: #98a2af; background: #fafbfc !important; }
.money-text { color: #246bd3; }
.conversion-rate.is-low { color: #e04d4d; font-weight: 700; }
.conversion-rate.is-good { color: #168f72; }
.conversion-rate.is-neutral { color: #919cab; }
.warning-mark { margin-right: 3px; color: #ec4e4e; font-size: 10px; }
.status-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 9px;
border-radius: 11px;
color: #16895f;
background: #eaf8ef;
font-size: 11px;
}
.status-pill.disabled { color: #9a6570; background: #f4ecee; }
.detail-action { font-size: 12px; }
.zero-doctor-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 10px 16px 15px;
padding: 4px 9px;
border: 0;
border-radius: 11px;
color: #6e7d90;
background: #eef3f6;
cursor: pointer;
font-size: 11px;
}
.zero-doctor-toggle:hover { color: #0e8277; background: #e7f4f1; }
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
@media (max-width: 1280px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.view-switch { margin-left: 0; }
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.doctor-dashboard { padding: 10px; }
.page-heading { align-items: flex-start; flex-direction: column; }
.heading-actions { flex-wrap: wrap; }
.update-time { display: none; }
.metric-grid, .two-column-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 92px; }
.filter-item { width: 100%; justify-content: space-between; }
.dept-select, .doctor-select { width: calc(100% - 78px); }
.filter-item--time, .filter-item--status { justify-content: flex-start; }
.view-switch { width: 100%; justify-content: flex-end; border-left: 0; }
.alert-row { grid-template-columns: 30px 1fr 80px 65px; padding: 10px; }
.alert-data, .alert-row p { grid-column: 2 / -1; }
}
</style>
@@ -0,0 +1,684 @@
<template>
<prescription-order-detail-drawer
ref="detailDrawerRef"
readonly
append-to-body
:detail-loader="myPatientOrderDetail"
:load-related-data="false"
>
<template #header-actions="{ detail }">
<el-dropdown
v-if="myPatientOrderActions(detail).length"
trigger="click"
@command="(command) => openAction(detail, command as MyPatientOrderAction)"
>
<el-button type="primary" size="small" plain>
订单操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="action in myPatientOrderActions(detail)"
:key="action.key"
:command="action.key"
:class="{ 'danger-menu-item': action.danger }"
>
{{ action.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</prescription-order-detail-drawer>
<el-dialog v-model="auditVisible" :title="auditTitle" width="480px" append-to-body destroy-on-close>
<el-alert
title="通过时审核意见可不填;驳回时必须填写明确原因。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-input
v-model="auditRemark"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入审核意见"
/>
<template #footer>
<el-button @click="auditVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitAudit('approve')">同意通过</el-button>
<el-button type="danger" :loading="submitting" @click="submitAudit('reject')">驳回</el-button>
</template>
</el-dialog>
<el-dialog v-model="trackVisible" title="修改快递单号" width="440px" append-to-body destroy-on-close>
<el-form label-width="92px" @submit.prevent="submitTrack">
<el-form-item label="承运商">
<el-select v-model="trackForm.express_company" class="w-full">
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="快递单号" required>
<el-input v-model="trackForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="trackVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitTrack">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="shipVisible" title="确认发货" width="460px" append-to-body destroy-on-close>
<el-alert
title="确认后订单将进入“已发货”,请先核对发货方式和快递信息。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="92px" @submit.prevent="submitShip">
<el-form-item label="发货方式">
<el-tag :type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'" effect="plain">
{{ shipForm.ship_mode === 'direct' ? '洛阳药房' : '甘草药房' }}
</el-tag>
</el-form-item>
<el-form-item label="承运商">
<el-select v-model="shipForm.express_company" class="w-full">
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="快递单号" required>
<el-input v-model="shipForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="shipVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button>
</template>
</el-dialog>
<el-dialog v-model="editVisible" title="编辑订单" width="680px" append-to-body destroy-on-close>
<el-form v-loading="editLoading" label-width="104px" class="order-edit-form">
<div class="form-grid">
<el-form-item label="收货人" required>
<el-input v-model="editForm.recipient_name" maxlength="50" />
</el-form-item>
<el-form-item label="收货手机" required>
<el-input v-model="editForm.recipient_phone" maxlength="20" />
</el-form-item>
<el-form-item label="费用类别" required>
<el-select v-model="editForm.fee_type" class="w-full">
<el-option v-for="item in feeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="订单金额" required>
<el-input-number v-model="editForm.amount" :min="0" :precision="2" :step="10" class="w-full" />
</el-form-item>
</div>
<el-form-item label="收货地址" required>
<el-input v-model="editForm.shipping_address" maxlength="500" />
</el-form-item>
<div class="form-grid">
<el-form-item label="承运商">
<el-select v-model="editForm.express_company" class="w-full">
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="快递单号">
<el-input v-model="editForm.tracking_number" maxlength="80" clearable />
</el-form-item>
</div>
<el-form-item label="医助备注">
<el-input v-model="editForm.remark_assistant" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item label="药房备注">
<el-input v-model="editForm.remark_extra" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
<el-alert
title="保存后会沿用原订单编辑规则;非已发货订单的支付审核将重新变为待审核。"
type="info"
:closable="false"
show-icon
/>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="editLoading" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="payVisible" title="补齐支付单" width="520px" append-to-body destroy-on-close>
<el-alert
:title="`订单金额 ¥${money(actionRow.amount)},已关联支付 ¥${money(actionRow.linked_pay_paid_total)}`"
type="info"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="108px">
<el-form-item label="支付单类型">
<el-select v-model="payForm.order_type" class="w-full">
<el-option v-for="item in payTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建方式">
<el-radio-group v-model="payForm.pay_create_type">
<el-radio value="fubei">付呗支付</el-radio>
<el-radio value="express_cod">快递代收</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="补齐金额" required>
<el-input-number v-model="payForm.pay_amount" :min="0" :precision="2" :step="10" class="w-full" />
</el-form-item>
<el-form-item label="支付备注">
<el-input v-model="payForm.pay_remark" type="textarea" :rows="3" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="完单申请">
<el-switch v-model="payForm.completion_request" :active-value="1" :inactive-value="0" />
<span class="form-tip">同时提交完单申请</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="payVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitPayOrder">确认新增</el-button>
</template>
</el-dialog>
<el-dialog v-model="completeVisible" title="完成订单" width="480px" append-to-body destroy-on-close>
<el-alert
title="请选择真实履约结果。退款必须使用单独的“退款”操作。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="100px">
<el-form-item label="结案状态" required>
<el-select v-model="completeStatus" class="w-full">
<el-option v-for="item in completeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="completeVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitComplete">确认完成</el-button>
</template>
</el-dialog>
<el-dialog v-model="refundVisible" title="订单退款" width="500px" append-to-body destroy-on-close>
<el-alert
title="退款会同步更新关联支付单和订单金额;退款金额留空时按系统计算的最大可退金额处理。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="100px">
<el-form-item label="退款原因" required>
<el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item label="退款金额">
<el-input-number
v-model="refundForm.refund_amount"
:min="0"
:precision="2"
:step="10"
class="w-full"
placeholder="留空则退最大可退金额"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refundVisible = false">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitRefund">确认退款</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import feedback from '@/utils/feedback'
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
import {
myPatientOrderAddPayOrder,
myPatientOrderAuditPayment,
myPatientOrderAuditPrescription,
myPatientOrderComplete,
myPatientOrderDdcode,
myPatientOrderDetail,
myPatientOrderEdit,
myPatientOrderRefund,
myPatientOrderRevokePayAudit,
myPatientOrderRevokeRxAudit,
myPatientOrderShip,
myPatientOrderUploadToPharmacy,
myPatientOrderWithdraw
} from '@/api/first_visit'
import { myPatientOrderActions, type MyPatientOrderAction } from './order-actions'
const emit = defineEmits<{ changed: [] }>()
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const actionRow = ref<Record<string, any>>({})
const submitting = ref(false)
const expressOptions = [
{ label: '自动识别', value: 'auto' },
{ label: '顺丰速运', value: 'sf' },
{ label: '京东快递', value: 'jd' },
{ label: '极兔速递', value: 'jt' }
]
const feeTypeOptions = [
{ label: '挂号', value: 1 },
{ label: '问诊', value: 2 },
{ label: '药品', value: 3 },
{ label: '首付', value: 4 },
{ label: '尾款', value: 5 },
{ label: '其他', value: 6 },
{ label: '全部', value: 7 },
{ label: '代收', value: 8 }
]
const payTypeOptions = [
{ label: '药品', value: 3 },
{ label: '尾款', value: 5 },
{ label: '其他', value: 6 }
]
const completeOptions = [
{ label: '已完成', value: 3 },
{ label: '进行中', value: 7 },
{ label: '暂不制药', value: 8 },
{ label: '拒收', value: 9 },
{ label: '保留药方', value: 11 },
{ label: '制药缓发', value: 12 }
]
const auditVisible = ref(false)
const auditKind = ref<'prescription' | 'payment'>('prescription')
const auditRemark = ref('')
const auditTitle = computed(() => auditKind.value === 'prescription' ? '处方业务审核' : '关联支付单审核')
const trackVisible = ref(false)
const trackForm = reactive({ express_company: 'auto', tracking_number: '' })
const shipVisible = ref(false)
const shipForm = reactive<{ ship_mode: 'gancao' | 'direct'; express_company: string; tracking_number: string }>({
ship_mode: 'gancao',
express_company: 'auto',
tracking_number: ''
})
const editVisible = ref(false)
const editLoading = ref(false)
const editSource = ref<Record<string, any>>({})
const editForm = reactive({
recipient_name: '',
recipient_phone: '',
shipping_address: '',
fee_type: 3,
amount: 0,
express_company: 'auto',
tracking_number: '',
remark_assistant: '',
remark_extra: ''
})
const payVisible = ref(false)
const payForm = reactive<{
order_type: number
pay_amount: number
pay_remark: string
completion_request: number
pay_create_type: 'fubei' | 'express_cod'
}>({ order_type: 3, pay_amount: 0, pay_remark: '', completion_request: 0, pay_create_type: 'fubei' })
const completeVisible = ref(false)
const completeStatus = ref(3)
const refundVisible = ref(false)
const refundForm = reactive<{ reason: string; refund_amount: number | undefined }>({
reason: '',
refund_amount: undefined
})
function money(value: unknown) {
return Number(value || 0).toFixed(2)
}
function normalizeShipMode(value: unknown): 'gancao' | 'direct' {
return String(value || '').toLowerCase() === 'direct' ? 'direct' : 'gancao'
}
function openDetail(row: Record<string, any>) {
detailDrawerRef.value?.open(Number(row.id))
}
async function fetchDetail(id: number) {
const response: any = await myPatientOrderDetail({ id })
return response?.data ?? response ?? null
}
async function openEdit(row: Record<string, any>) {
actionRow.value = row
editVisible.value = true
editLoading.value = true
try {
const detail = await fetchDetail(Number(row.id))
if (!detail) throw new Error('订单详情加载失败')
editSource.value = detail
editForm.recipient_name = String(detail.recipient_name || '')
editForm.recipient_phone = String(detail.recipient_phone || '')
editForm.shipping_address = String(detail.shipping_address || '')
editForm.fee_type = Number(detail.fee_type || 3)
editForm.amount = Number(detail.amount || 0)
editForm.express_company = String(detail.express_company || 'auto') || 'auto'
editForm.tracking_number = String(detail.tracking_number || '')
editForm.remark_assistant = String(detail.remark_assistant || '')
editForm.remark_extra = String(detail.remark_extra || '')
} catch (error: any) {
feedback.msgError(error?.message || '订单详情加载失败')
editVisible.value = false
} finally {
editLoading.value = false
}
}
function openAction(row: Record<string, any>, action: MyPatientOrderAction) {
actionRow.value = row
switch (action) {
case 'edit':
void openEdit(row)
break
case 'audit_prescription':
case 'audit_payment':
auditKind.value = action === 'audit_prescription' ? 'prescription' : 'payment'
auditRemark.value = ''
auditVisible.value = true
break
case 'revoke_rx_audit':
void confirmRevokeAudit('prescription')
break
case 'revoke_pay_audit':
void confirmRevokeAudit('payment')
break
case 'ddcode':
trackForm.express_company = String(row.express_company || 'auto') || 'auto'
trackForm.tracking_number = String(row.tracking_number || '')
trackVisible.value = true
break
case 'ship':
shipForm.ship_mode = normalizeShipMode(row.ship_mode)
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
shipForm.tracking_number = String(row.tracking_number || '')
shipVisible.value = true
break
case 'add_pay_order': {
const diff = Math.max(0, Number(row.amount || 0) - Number(row.linked_pay_paid_total || 0))
payForm.order_type = 3
payForm.pay_amount = Number(diff.toFixed(2))
payForm.pay_remark = ''
payForm.completion_request = 0
payForm.pay_create_type = 'fubei'
payVisible.value = true
break
}
case 'complete':
completeStatus.value = 3
completeVisible.value = true
break
case 'refund':
refundForm.reason = ''
refundForm.refund_amount = undefined
refundVisible.value = true
break
case 'withdraw':
void confirmWithdraw()
break
case 'upload_pharmacy':
void confirmUploadPharmacy()
break
}
}
async function changed(message: string) {
feedback.msgSuccess(message)
emit('changed')
await detailDrawerRef.value?.refreshIfCurrent(actionRow.value.id)
}
async function submitAudit(action: 'approve' | 'reject') {
if (action === 'reject' && !auditRemark.value.trim()) {
feedback.msgError('驳回时必须填写审核意见')
return
}
submitting.value = true
try {
const params = { id: Number(actionRow.value.id), action, remark: auditRemark.value.trim() }
if (auditKind.value === 'prescription') await myPatientOrderAuditPrescription(params)
else await myPatientOrderAuditPayment(params)
auditVisible.value = false
await changed(action === 'approve' ? '审核通过成功' : '订单已驳回')
} catch {
//
} finally {
submitting.value = false
}
}
async function confirmRevokeAudit(kind: 'prescription' | 'payment') {
try {
await ElMessageBox.confirm(
`确认撤回${kind === 'prescription' ? '处方' : '支付单'}审核并恢复为待审核吗?`,
'撤回审核',
{ type: 'warning', confirmButtonText: '确认撤回', cancelButtonText: '取消' }
)
if (kind === 'prescription') await myPatientOrderRevokeRxAudit({ id: Number(actionRow.value.id) })
else await myPatientOrderRevokePayAudit({ id: Number(actionRow.value.id) })
await changed('审核已撤回')
} catch {
//
}
}
async function submitTrack() {
if (!trackForm.tracking_number.trim()) {
feedback.msgError('请填写快递单号')
return
}
submitting.value = true
try {
await myPatientOrderDdcode({
id: Number(actionRow.value.id),
express_company: trackForm.express_company || 'auto',
tracking_number: trackForm.tracking_number.trim()
})
trackVisible.value = false
await changed('快递单号已保存')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitShip() {
if (!shipForm.tracking_number.trim()) {
feedback.msgError('请填写快递单号')
return
}
submitting.value = true
try {
await myPatientOrderShip({
id: Number(actionRow.value.id),
ship_mode: shipForm.ship_mode,
express_company: shipForm.express_company || 'auto',
tracking_number: shipForm.tracking_number.trim()
})
shipVisible.value = false
await changed('确认发货成功')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitEdit() {
if (!editForm.recipient_name.trim() || !editForm.recipient_phone.trim() || !editForm.shipping_address.trim()) {
feedback.msgError('请完整填写收货人、收货手机和收货地址')
return
}
if (Number(editForm.amount) < 0) {
feedback.msgError('订单金额不能为负数')
return
}
const source = editSource.value
submitting.value = true
try {
await myPatientOrderEdit({
id: Number(actionRow.value.id),
recipient_name: editForm.recipient_name.trim(),
recipient_phone: editForm.recipient_phone.trim(),
shipping_province: source.shipping_province || '',
shipping_city: source.shipping_city || '',
shipping_district: source.shipping_district || '',
shipping_address: editForm.shipping_address.trim(),
is_follow_up: Number(source.is_follow_up || 0),
medication_days: source.medication_days,
dose_unit: source.dose_unit || '剂',
dose_count: Number(source.dose_count || 1),
prev_staff: source.prev_staff || '',
service_channel: source.service_channel || '',
service_package: source.service_package || '',
tracking_number: editForm.tracking_number.trim(),
express_company: editForm.express_company || 'auto',
fee_type: Number(editForm.fee_type),
amount: Number(editForm.amount),
remark_extra: editForm.remark_extra.trim(),
remark_assistant: editForm.remark_assistant.trim(),
pay_order_ids: Array.isArray(source.pay_order_ids) ? source.pay_order_ids : []
})
editVisible.value = false
await changed('订单保存成功')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitPayOrder() {
if (!(Number(payForm.pay_amount) > 0)) {
feedback.msgError('补齐金额必须大于 0')
return
}
submitting.value = true
try {
await myPatientOrderAddPayOrder({
id: Number(actionRow.value.id),
order_type: Number(payForm.order_type),
pay_amount: Number(payForm.pay_amount),
pay_remark: payForm.pay_remark.trim(),
completion_request: Number(payForm.completion_request),
pay_create_type: payForm.pay_create_type
})
payVisible.value = false
await changed('支付单已新增,等待支付审核')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitComplete() {
submitting.value = true
try {
await myPatientOrderComplete({ id: Number(actionRow.value.id), fulfillment_status: Number(completeStatus.value) })
completeVisible.value = false
await changed('订单状态已更新')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitRefund() {
if (!refundForm.reason.trim()) {
feedback.msgError('请填写退款原因')
return
}
submitting.value = true
try {
await myPatientOrderRefund({
id: Number(actionRow.value.id),
reason: refundForm.reason.trim(),
refund_amount: refundForm.refund_amount === undefined ? undefined : Number(refundForm.refund_amount)
})
refundVisible.value = false
await changed('退款成功')
} catch {
//
} finally {
submitting.value = false
}
}
async function confirmWithdraw() {
try {
await ElMessageBox.confirm('确认撤回该订单吗?撤回后当前订单将结束。', '撤回订单', {
type: 'warning',
confirmButtonText: '确认撤回',
cancelButtonText: '取消'
})
await myPatientOrderWithdraw({ id: Number(actionRow.value.id) })
await changed('订单已撤回')
} catch {
//
}
}
async function confirmUploadPharmacy() {
try {
await ElMessageBox.confirm('确认将该订单上传到订单设定的药房吗?', '上传药房', {
type: 'warning',
confirmButtonText: '确认上传',
cancelButtonText: '取消'
})
await myPatientOrderUploadToPharmacy({ id: Number(actionRow.value.id) })
await changed('药方上传成功')
} catch {
//
}
}
defineExpose({ openDetail, openAction })
</script>
<style scoped lang="scss">
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.form-tip {
margin-left: 10px;
color: #8a95a6;
font-size: 12px;
}
:global(.danger-menu-item) {
color: var(--el-color-danger) !important;
}
@media (max-width: 720px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,720 @@
<template>
<section class="embedded-panel">
<div class="panel-toolbar">
<div>
<h2>订单管理</h2>
<p>展示当前患者范围内的处方业务订单订单创建人不参与数据归属判断</p>
</div>
<div class="toolbar-actions">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
<el-button :icon="Refresh" :loading="pager.loading" @click="refreshPanel">刷新</el-button>
</div>
</div>
<div class="filter-panel">
<div class="filter-heading">
<div>
<h3>订单检索</h3>
<p>审核与履约状态直接展示点击后立即筛选</p>
</div>
<el-button link :disabled="!hasActiveFilters" @click="resetFilters">清空全部条件</el-button>
</div>
<div class="status-filter-list">
<div class="status-filter-row">
<span class="filter-label">处方审核</span>
<el-radio-group v-model="formData.prescription_audit_status" @change="handleSearch">
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="status-filter-row">
<span class="filter-label">支付单审核</span>
<el-radio-group v-model="formData.payment_slip_audit_status" @change="handleSearch">
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="status-filter-row status-filter-row--fulfillment">
<span class="filter-label">履约状态</span>
<el-radio-group v-model="formData.fulfillment_status" @change="handleSearch">
<el-radio-button v-for="item in fulfillmentFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="search-filter-row">
<label class="filter-field filter-field--keyword">
<span>关键词</span>
<el-input
v-model="formData.keyword"
clearable
:prefix-icon="Search"
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</label>
<label class="filter-field filter-field--date">
<span>创建时间</span>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
@change="handleDateChange"
/>
</label>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>订单数量</span>
<strong>{{ summary.orders }}</strong>
<small></small>
</div>
<div class="metric-card">
<span>有效订单金额</span>
<strong>¥{{ money(summary.amount) }}</strong>
<small>排除取消拒收及退款</small>
</div>
<div class="metric-card metric-warning">
<span>待审核</span>
<strong>{{ summary.pending }}</strong>
<small>任一审核待处理</small>
</div>
<div class="metric-card metric-success">
<span>已完成 / 签收</span>
<strong>{{ summary.completed }}</strong>
<small></small>
</div>
<button
class="metric-card metric-card--interactive metric-rejected"
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
type="button"
@click="toggleRejectedFilter"
>
<span>拒收订单</span>
<strong>{{ summary.rejected }}</strong>
<small> · 点击查看明细</small>
</button>
<button
class="metric-card metric-card--interactive metric-rejected-rate"
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
type="button"
@click="toggleRejectedFilter"
>
<span>拒收率</span>
<strong>{{ percent(summary.rejectionRate) }}</strong>
<small>拒收订单 ÷ 同检索范围订单</small>
</button>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
class="embedded-table"
:row-class-name="orderRowClassName"
>
<el-table-column label="订单" min-width="174" fixed="left">
<template #default="{ row }">
<div class="primary-cell">
<strong>{{ row.order_no || '—' }}</strong>
<span>#{{ row.id }} · {{ row.fee_type_text }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="患者" min-width="150">
<template #default="{ row }">
<div class="primary-cell">
<strong>{{ row.patient_name || row.recipient_name || '—' }}</strong>
<span>{{ row.patient_phone_masked || row.recipient_phone_masked || '无手机号' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="处方 / 诊单" min-width="118">
<template #default="{ row }">
<div class="id-stack">
<span>处方 #{{ row.prescription_id || '—' }}</span>
<span>诊单 #{{ row.diagnosis_id || '—' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="有效金额" width="118" align="right">
<template #default="{ row }">
<strong v-if="row.amount_included" class="amount">¥{{ money(row.effective_amount) }}</strong>
<span v-else class="amount-excluded">{{ row.amount_exclusion_text || '不计入' }}</span>
</template>
</el-table-column>
<el-table-column label="处方审核" width="104" align="center">
<template #default="{ row }">
<el-tag size="small" :type="auditTagType(row.prescription_audit_status)" effect="light">
{{ row.prescription_audit_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="支付单审核" width="112" align="center">
<template #default="{ row }">
<el-tag size="small" :type="auditTagType(row.payment_slip_audit_status)" effect="light">
{{ row.payment_slip_audit_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="履约状态" width="112" align="center">
<template #default="{ row }">
<el-tag size="small" :type="fulfillmentTagType(row.fulfillment_status)" effect="light">
{{ row.fulfillment_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="关联支付单" width="100" align="center">
<template #default="{ row }">{{ Number(row.linked_pay_order_count) > 0 ? `${row.linked_pay_order_count}` : '—' }}</template>
</el-table-column>
<el-table-column prop="assistant_name" label="归属助理" min-width="100" show-overflow-tooltip />
<el-table-column prop="doctor_name" label="开方人" min-width="90" show-overflow-tooltip />
<el-table-column prop="creator_name" label="创建人" min-width="90" show-overflow-tooltip />
<el-table-column prop="create_time_text" label="创建时间" min-width="145" />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<div class="order-actions">
<el-button type="primary" link @click="emit('openDiagnosis', row)">诊单</el-button>
<el-button
v-if="canViewMyPatientOrderDetail()"
type="primary"
link
@click="orderActionHostRef?.openDetail(row)"
>详情</el-button>
<el-dropdown
v-if="myPatientOrderActions(row).length"
trigger="click"
@command="(command) => openOrderAction(row, command as MyPatientOrderAction)"
>
<el-button type="primary" link>
操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="action in myPatientOrderActions(row)"
:key="action.key"
:command="action.key"
:class="{ 'danger-menu-item': action.danger }"
>
{{ action.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="当前范围内暂无订单" /></template>
</el-table>
<div class="pagination-wrap">
<pagination v-model="pager" @change="getLists" />
</div>
<order-action-host ref="orderActionHostRef" @changed="getLists" />
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ArrowDown, Lock, Refresh, Search } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { myPatientOrderLists } from '@/api/first_visit'
import OrderActionHost from './OrderActionHost.vue'
import {
canViewMyPatientOrderDetail,
myPatientOrderActions,
type MyPatientOrderAction
} from './order-actions'
type SelectValue = '' | number
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
const orderActionHostRef = ref<InstanceType<typeof OrderActionHost>>()
const dateRange = ref<string[]>([])
const formData = reactive({
keyword: '',
prescription_audit_status: '' as SelectValue,
payment_slip_audit_status: '' as SelectValue,
fulfillment_status: '' as SelectValue,
start_date: '',
end_date: ''
})
const auditFilterOptions: Array<{ label: string; value: SelectValue }> = [
{ label: '全部', value: '' },
{ label: '待审核', value: 0 },
{ label: '已通过', value: 1 },
{ label: '已驳回', value: 2 }
]
const fulfillmentFilterOptions: Array<{ label: string; value: SelectValue }> = [
{ label: '全部', value: '' },
{ label: '待双审通过', value: 1 },
{ label: '待发货', value: 2 },
{ label: '已完成', value: 3 },
{ label: '已取消', value: 4 },
{ label: '已发货', value: 5 },
{ label: '已签收', value: 6 },
{ label: '进行中', value: 7 },
{ label: '暂不制药', value: 8 },
{ label: '拒收', value: 9 },
{ label: '退款', value: 10 },
{ label: '保留药方', value: 11 },
{ label: '制药缓发', value: 12 }
]
const { pager, getLists, resetPage } = usePaging({
fetchFun: myPatientOrderLists as any,
params: formData,
size: 15,
firstLoading: true
})
const summary = computed(() => ({
orders: Number(pager.extend?.summary?.orders || 0),
amount: Number(pager.extend?.summary?.amount || 0),
pending: Number(pager.extend?.summary?.pending || 0),
completed: Number(pager.extend?.summary?.completed || 0),
rejected: Number(pager.extend?.summary?.rejected || 0),
rejectionRate: Number(pager.extend?.summary?.rejection_rate || 0)
}))
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
const hasActiveFilters = computed(() => Boolean(
formData.keyword.trim()
|| formData.prescription_audit_status !== ''
|| formData.payment_slip_audit_status !== ''
|| formData.fulfillment_status !== ''
|| formData.start_date
|| formData.end_date
))
function handleSearch() {
resetPage()
}
function refreshPanel() {
return getLists()
}
function handleDateChange(value: string[] | null) {
formData.start_date = value?.[0] || ''
formData.end_date = value?.[1] || value?.[0] || ''
resetPage()
}
function resetFilters() {
formData.keyword = ''
formData.prescription_audit_status = ''
formData.payment_slip_audit_status = ''
formData.fulfillment_status = ''
formData.start_date = ''
formData.end_date = ''
dateRange.value = []
resetPage()
}
function toggleRejectedFilter() {
formData.fulfillment_status = Number(formData.fulfillment_status) === 9 ? '' : 9
resetPage()
}
function money(value: number | string) {
return Number(value || 0).toFixed(2)
}
function percent(value: number | string) {
const number = Number(value || 0)
return `${Number.isInteger(number) ? number.toFixed(0) : number.toFixed(2)}%`
}
function auditTagType(status: number): 'success' | 'warning' | 'danger' | 'info' {
if (Number(status) === 1) return 'success'
if (Number(status) === 2) return 'danger'
return 'warning'
}
function fulfillmentTagType(status: number): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
const value = Number(status)
if ([3, 6].includes(value)) return 'success'
if ([4, 9, 10].includes(value)) return 'danger'
if ([2, 5, 7].includes(value)) return 'primary'
return 'warning'
}
function orderRowClassName({ row }: { row: any }) {
if ([2].includes(Number(row.prescription_audit_status)) || [2].includes(Number(row.payment_slip_audit_status))) {
return 'order-row-risk'
}
if ([3, 6].includes(Number(row.fulfillment_status))) return 'order-row-done'
return ''
}
function openOrderAction(row: Record<string, any>, action: MyPatientOrderAction) {
orderActionHostRef.value?.openAction(row, action)
}
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
onMounted(getLists)
</script>
<style scoped lang="scss">
.embedded-panel {
min-height: 420px;
}
.panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 17px;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 12px;
}
}
.toolbar-actions,
.scope-chip {
display: flex;
align-items: center;
gap: 8px;
}
.scope-chip {
color: #0f766e;
font-size: 12px;
}
.filter-panel {
padding: 16px;
border: 1px solid #e3e8ef;
border-radius: 10px;
background: #fbfcfd;
}
.filter-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e8edf2;
h3 {
margin: 0;
color: #273244;
font-size: 14px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 12px;
}
}
.status-filter-list {
display: grid;
gap: 10px;
padding: 13px 0;
border-bottom: 1px solid #e8edf2;
}
.status-filter-row {
display: flex;
align-items: flex-start;
gap: 12px;
:deep(.el-radio-group) {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 6px;
}
:deep(.el-radio-button__inner) {
min-width: 72px;
padding: 7px 12px;
color: #5f6b7d;
border: 1px solid #dfe5ec;
border-radius: 6px;
box-shadow: none;
background: #fff;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
:deep(.el-radio-button:first-child .el-radio-button__inner),
:deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 6px;
}
:deep(.el-radio-button.is-active .el-radio-button__inner) {
color: #fff;
border-color: #0f9185;
background: #0f9185;
box-shadow: none;
}
}
.filter-label {
flex: 0 0 76px;
padding-top: 7px;
color: #475467;
font-size: 12px;
font-weight: 600;
}
.search-filter-row {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(300px, 360px) auto;
align-items: end;
gap: 12px;
padding-top: 13px;
}
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
> span {
color: #667085;
font-size: 12px;
font-weight: 500;
}
:deep(.el-date-editor) {
width: 100%;
}
}
.filter-actions {
display: flex;
align-items: center;
:deep(.el-button + .el-button) {
margin-left: 8px;
}
}
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
margin: 14px 0;
}
.metric-card {
min-height: 82px;
padding: 14px 16px;
text-align: left;
font: inherit;
border: 1px solid #e3e8ef;
border-radius: 10px;
background: #fff;
span,
small {
color: #8a95a6;
font-size: 12px;
}
strong {
display: block;
margin: 7px 0 3px;
color: #172033;
font-size: 22px;
line-height: 1;
}
}
.metric-card--interactive {
color: inherit;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
&:hover,
&:focus-visible {
border-color: #e5a9a3;
box-shadow: 0 7px 18px rgba(132, 45, 40, 0.08);
transform: translateY(-1px);
outline: none;
}
&:active {
transform: translateY(0);
}
&.is-active {
border-color: #d9685f;
box-shadow: 0 0 0 2px rgba(217, 104, 95, 0.11);
}
}
.metric-rejected,
.metric-rejected-rate {
border-color: #f0cbc7;
background: #fff9f8;
strong {
color: #c64f47;
}
}
.metric-warning {
border-color: #f3d8aa;
background: #fffcf5;
}
.metric-success {
border-color: #b9e2dc;
background: #f7fcfb;
}
.embedded-table {
width: 100%;
border: 1px solid #e7ebf0;
border-radius: 9px;
overflow: hidden;
:deep(th.el-table__cell) {
height: 44px;
color: #5f6b7d;
background: #f7f9fb;
font-weight: 600;
}
:deep(.order-row-risk > td.el-table__cell) {
background: #fff8f7;
}
:deep(.order-row-done > td.el-table__cell) {
background: #f8fcfb;
}
}
.primary-cell,
.id-stack {
display: flex;
flex-direction: column;
gap: 3px;
strong {
color: #202939;
font-size: 13px;
}
span {
color: #8b96a8;
font-size: 12px;
}
}
.amount {
color: #d04f3f;
font-variant-numeric: tabular-nums;
}
.amount-excluded {
color: #9aa4b2;
font-size: 11px;
}
.order-actions {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
:global(.danger-menu-item) {
color: var(--el-color-danger) !important;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
@media (max-width: 1400px) {
.metric-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.search-filter-row {
grid-template-columns: 1fr 1fr;
}
.filter-actions {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.panel-toolbar {
align-items: flex-start;
flex-direction: column;
}
.metric-grid {
grid-template-columns: 1fr;
}
.status-filter-row,
.search-filter-row {
display: flex;
align-items: stretch;
flex-direction: column;
}
.filter-label {
padding-top: 0;
}
.filter-actions {
display: grid;
grid-template-columns: 1fr 1fr;
:deep(.el-button) {
width: 100%;
}
}
}
</style>
@@ -0,0 +1,834 @@
<template>
<div class="progress-board">
<section class="board-section overview-section">
<div class="section-heading">
<div class="heading-copy">
<h2>今日面诊概览</h2>
<span class="heading-badge">{{ isOwnershipMode ? '按本人归属' : '与排班合并' }}</span>
</div>
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
</div>
<div class="overview-grid">
<article class="overview-card">
<span>{{ isOwnershipMode ? '今日本人面诊' : '今日面诊总号源' }}</span>
<strong>{{ todayOverview.totalVisits }}</strong>
<small>{{ todayOverview.doctorCount }} {{ isOwnershipMode ? '接诊' : '排班' }}医生</small>
</article>
<article class="overview-card">
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
<strong>{{ todayOverview.booked }}</strong>
<small>{{ isOwnershipMode ? '本人归属患者的有效挂号' : '按有效挂号占用号源' }}</small>
</article>
<article :class="['overview-card', isOwnershipMode ? 'overview-card-completed' : 'overview-card-empty']">
<span>{{ isOwnershipMode ? '已完成' : '空号' }}</span>
<strong>{{ isOwnershipMode ? todayOverview.completed : todayOverview.emptySlots }}</strong>
<small>{{ isOwnershipMode ? `已过号 ${todayOverview.missed}` : '未被有效挂号占用' }}</small>
</article>
</div>
</section>
<section class="board-section schedule-section">
<div class="section-heading">
<div class="heading-copy">
<h2>{{ isOwnershipMode ? '近一周面诊安排' : '近一周排班' }}</h2>
<span class="heading-badge">{{ isOwnershipMode ? '本人患者' : '今日起 7 天' }}</span>
</div>
<span class="schedule-range">{{ scheduleRange }}</span>
</div>
<div class="schedule-grid">
<button
v-for="day in weekSchedule"
:key="day.date"
type="button"
class="schedule-card"
:class="{
'is-today': day.date === today,
'is-selected': day.date === selectedScheduleDate
}"
:aria-pressed="day.date === selectedScheduleDate"
@click="selectScheduleDay(day.date)"
>
<span class="schedule-date">{{ day.date_text }} {{ day.weekday }}</span>
<strong>{{ isOwnershipMode ? day.total_appointments : day.total_slots }}</strong>
<span class="schedule-card-stats">
<span>{{ isOwnershipMode ? '待诊' : '已约' }} {{ isOwnershipMode ? day.waiting_appointments : day.booked_slots }}</span>
<i>/</i>
<span :class="{ 'is-completed': isOwnershipMode }">{{ isOwnershipMode ? '完成' : '空' }} {{ isOwnershipMode ? day.completed_appointments : day.empty_slots }}</span>
</span>
<span class="schedule-card-action">
<span>{{ day.doctor_count }} 位医生</span>
<span>{{ day.date === selectedScheduleDate ? '正在查看' : '查看明细' }} </span>
</span>
</button>
</div>
<div class="schedule-drilldown">
<div class="drilldown-heading">
<div>
<h3>{{ selectedScheduleLabel }}{{ isOwnershipMode ? '面诊安排' : '排班明细' }}</h3>
<p> {{ selectedScheduleDay.doctor_count }} {{ isOwnershipMode ? '接诊' : '排班' }}医生点击上方日期可切换</p>
</div>
<div :class="['drilldown-summary', { 'is-ownership': isOwnershipMode }]">
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.total_appointments : selectedScheduleDay.total_slots }}</strong></span>
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.waiting_appointments : selectedScheduleDay.booked_slots }}</strong></span>
<span>{{ isOwnershipMode ? '已完成' : '空号' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.completed_appointments : selectedScheduleDay.empty_slots }}</strong></span>
<span v-if="isOwnershipMode">已过号 <strong>{{ selectedScheduleDay.missed_appointments }}</strong></span>
</div>
</div>
<div v-if="selectedScheduleDoctors.length" class="doctor-schedule-list">
<article
v-for="(doctor, index) in selectedScheduleDoctors"
:key="doctor.doctor_id"
class="doctor-schedule-row"
>
<div class="doctor-identity">
<span class="doctor-index">{{ index + 1 }}</span>
<div>
<strong>{{ doctor.doctor_name }}</strong>
<small>{{ isOwnershipMode ? '本人患者接诊医生' : '医生排班' }}</small>
</div>
</div>
<div class="doctor-windows">
<span>{{ isOwnershipMode ? '预约时刻' : '排班时段' }}</span>
<strong>{{ doctorWindows(doctor) }}</strong>
</div>
<div class="doctor-metric">
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }}</span>
<strong>{{ isOwnershipMode ? doctor.total_appointments : doctor.total_slots }}</strong>
</div>
<div class="doctor-metric doctor-metric-booked">
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
<strong>{{ isOwnershipMode ? doctor.waiting_appointments : doctor.booked_slots }}</strong>
</div>
<div :class="['doctor-metric', isOwnershipMode ? 'doctor-metric-completed' : 'doctor-metric-empty']">
<span>{{ isOwnershipMode ? '完成 / 过号' : '空号' }}</span>
<strong>{{ isOwnershipMode ? `${doctor.completed_appointments} / ${doctor.missed_appointments}` : doctor.empty_slots }}</strong>
</div>
</article>
</div>
<el-empty
v-else
:image-size="56"
:description="isOwnershipMode ? '该日期暂无本人归属患者的有效挂号' : '该日期暂无当前权限范围内的医生排班'"
/>
</div>
</section>
<section class="board-section queue-section">
<div class="section-heading queue-heading">
<div class="heading-copy">
<h2>候诊列表</h2>
<span class="heading-badge">按医生排队</span>
<span class="queue-count"> {{ pager.count }} </span>
</div>
<span class="refresh-time"> 15 秒自动刷新</span>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
class="queue-table"
:row-class-name="tableRowClassName"
@row-dblclick="openDiagnosis"
>
<el-table-column label="排队" width="92">
<template #default="{ row }">
<span class="queue-number">{{ row.queue_no || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="患者" min-width="170">
<template #default="{ row }">
<div class="patient-cell">
<strong>{{ row.patient_name || '未命名患者' }}</strong>
<span>{{ row.phone_masked || '无手机号' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="doctor_name" label="医生" min-width="130" show-overflow-tooltip />
<el-table-column label="预约时间" min-width="135">
<template #default="{ row }">
<span class="appointment-clock">{{ appointmentClock(row) }}</span>
</template>
</el-table-column>
<el-table-column label="前方等待" min-width="180">
<template #default="{ row }">
<span :class="['waiting-text', `is-${row.queue_status || 'waiting'}`]">
{{ waitingText(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<el-tag size="small" round effect="light" :type="queueTagType(row.queue_status)">
{{ row.queue_status_text || '等待中' }}
</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty description="当前权限范围内今日暂无候诊患者" />
</template>
</el-table>
<div v-if="pager.count > pager.size" class="pagination-wrap">
<pagination v-model="pager" @change="getLists" />
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import { Lock } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { myPatientProgressLists } from '@/api/first_visit'
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
const today = dayjs().format('YYYY-MM-DD')
const selectedScheduleDate = ref(today)
const formData = reactive({
keyword: '',
status: 1 as const,
start_date: today,
end_date: today
})
const { pager, getLists } = usePaging({
fetchFun: myPatientProgressLists as any,
params: formData,
size: 15,
firstLoading: true
})
const todayOverview = computed(() => ({
totalVisits: Number(pager.extend?.today_overview?.total_visits || 0),
booked: Number(pager.extend?.today_overview?.booked || 0),
completed: Number(pager.extend?.today_overview?.completed || 0),
missed: Number(pager.extend?.today_overview?.missed || 0),
emptySlots: Number(pager.extend?.today_overview?.empty_slots || 0),
doctorCount: Number(pager.extend?.today_overview?.doctor_count || 0)
}))
const isOwnershipMode = computed(() => pager.extend?.schedule_mode === 'ownership')
const weekSchedule = computed(() => {
const rows = Array.isArray(pager.extend?.week_schedule) ? pager.extend.week_schedule : []
if (rows.length) return rows
return Array.from({ length: 7 }, (_, index) => {
const date = dayjs().add(index, 'day')
return {
date: date.format('YYYY-MM-DD'),
date_text: date.format('MM-DD'),
weekday: `${'日一二三四五六'[date.day()]}`,
total_slots: 0,
booked_slots: 0,
empty_slots: 0,
doctor_count: 0,
doctors: [],
total_appointments: 0,
waiting_appointments: 0,
completed_appointments: 0,
missed_appointments: 0
}
})
})
const selectedScheduleDay = computed(() => {
return weekSchedule.value.find((day: any) => day.date === selectedScheduleDate.value) || weekSchedule.value[0] || {
date: today,
date_text: dayjs().format('MM-DD'),
weekday: `${'日一二三四五六'[dayjs().day()]}`,
total_slots: 0,
booked_slots: 0,
empty_slots: 0,
doctor_count: 0,
doctors: [],
total_appointments: 0,
waiting_appointments: 0,
completed_appointments: 0,
missed_appointments: 0
}
})
const selectedScheduleDoctors = computed(() => {
return Array.isArray(selectedScheduleDay.value?.doctors) ? selectedScheduleDay.value.doctors : []
})
const selectedScheduleLabel = computed(() => {
const day = selectedScheduleDay.value
return `${day.date_text || ''} ${day.weekday || ''} `
})
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
const scheduleRange = computed(() => {
const rows = weekSchedule.value
if (!rows.length) return ''
return `${rows[0].date_text}${rows[rows.length - 1].date_text}`
})
let refreshTimer: ReturnType<typeof setInterval> | null = null
function refreshPanel(options?: { silent?: boolean }) {
return getLists(options)
}
function selectScheduleDay(date: string) {
selectedScheduleDate.value = date
}
function doctorWindows(doctor: Record<string, any>) {
const source = isOwnershipMode.value ? doctor.appointment_times : doctor.schedule_windows
const windows = Array.isArray(source) ? source.filter(Boolean) : []
if (windows.length) return windows.join('、')
return isOwnershipMode.value ? '暂无预约时刻' : '未设置具体时段'
}
function appointmentClock(row: Record<string, any>) {
const time = String(row.appointment_time || '').slice(0, 5)
return time || '—'
}
function waitingText(row: Record<string, any>) {
if (row.queue_status === 'consulting') return '0(进行中)'
if (row.queue_status === 'next') return '0(待接诊)'
const ahead = Math.max(0, Number(row.ahead_count || 0))
if (ahead === 0) return '0 位'
return `${ahead} 位 · 约 ${Number(row.estimated_wait_minutes || ahead * 15)} 分钟`
}
function queueTagType(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (status === 'consulting') return 'success'
if (status === 'next') return 'warning'
if (status === 'completed') return 'success'
if (status === 'missed') return 'danger'
return 'info'
}
function tableRowClassName({ row }: { row: Record<string, any> }) {
return Number(row.is_self_patient) === 1 ? 'queue-row-self' : ''
}
function openDiagnosis(row: Record<string, any>) {
emit('openDiagnosis', row)
}
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
onMounted(() => {
getLists()
refreshTimer = setInterval(() => getLists({ silent: true }), 15_000)
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<style scoped lang="scss">
.progress-board {
display: grid;
gap: 14px;
color: #172033;
}
.board-section {
padding: 16px;
border: 1px solid #e1e7ee;
border-radius: 12px;
background: #fff;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.heading-copy,
.scope-chip {
display: flex;
align-items: center;
}
.heading-copy {
gap: 8px;
min-width: 0;
h2 {
margin: 0;
color: #172033;
font-size: 15px;
font-weight: 700;
}
}
.heading-badge {
padding: 3px 7px;
border-radius: 5px;
color: #758195;
background: #eef2f6;
font-size: 11px;
white-space: nowrap;
}
.scope-chip {
gap: 5px;
color: #0f766e;
font-size: 12px;
white-space: nowrap;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.overview-card {
min-height: 78px;
padding: 14px 16px;
border: 1px solid #dfe5ed;
border-radius: 10px;
background: #fff;
> span,
> small {
display: block;
color: #778398;
font-size: 12px;
}
> strong {
display: block;
margin: 7px 0 5px;
color: #111a2c;
font-size: 24px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
> small {
color: #a0a9b7;
font-size: 11px;
}
}
.overview-card-empty > strong {
color: #ee4d55;
}
.overview-card-completed > strong {
color: #07886d;
}
.schedule-range,
.refresh-time,
.queue-count {
color: #929dac;
font-size: 12px;
}
.schedule-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
}
.schedule-card {
min-width: 0;
padding: 11px 8px;
text-align: center;
border: 1px solid #dfe5ed;
border-radius: 9px;
background: #fbfcfe;
color: inherit;
font: inherit;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
&:hover {
border-color: #7ac9c2;
background: #f7fcfb;
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 3px solid rgba(15, 145, 133, 0.18);
outline-offset: 2px;
}
&.is-today {
border-color: #8fd3cd;
background: #f4fbfa;
}
&.is-selected {
border-color: #0f9185;
background: #effaf8;
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1), inset 0 -3px 0 #0f9185;
}
> strong {
display: block;
margin: 7px 0 5px;
color: #131c2d;
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
}
.schedule-date {
display: block;
overflow: hidden;
color: #70809a;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-card-stats {
display: flex;
justify-content: center;
gap: 4px;
color: #0c9967;
font-size: 11px;
white-space: nowrap;
i {
color: #a5afbd;
font-style: normal;
}
span:last-child {
color: #d8862b;
}
span.is-completed {
color: #07886d;
}
}
.schedule-card-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin: 10px 2px 0;
padding-top: 8px;
border-top: 1px solid #e7ecef;
color: #7b8799;
font-size: 10px;
span:last-child {
color: #0f8077;
font-weight: 600;
}
}
.schedule-drilldown {
margin-top: 14px;
padding: 15px;
border-radius: 10px;
background: #f7f9fb;
}
.drilldown-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 11px;
h3 {
margin: 0;
color: #1e293b;
font-size: 14px;
font-weight: 700;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 11px;
}
}
.drilldown-summary {
display: flex;
align-items: center;
gap: 18px;
color: #7a8698;
font-size: 11px;
white-space: nowrap;
strong {
margin-left: 3px;
color: #243044;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
span:last-child strong {
color: #d17820;
}
}
.doctor-schedule-list {
display: grid;
gap: 7px;
}
.doctor-schedule-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(3, minmax(64px, 0.55fr));
align-items: center;
gap: 14px;
min-height: 58px;
padding: 8px 13px;
border: 1px solid #e4e9ef;
border-radius: 8px;
background: #fff;
}
.doctor-identity {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
> div {
min-width: 0;
}
strong,
small {
display: block;
}
strong {
overflow: hidden;
color: #202b3d;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
margin-top: 2px;
color: #9aa4b2;
font-size: 10px;
}
}
.doctor-index {
display: grid;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 7px;
color: #0f766e;
background: #e7f6f4;
font-size: 12px;
font-weight: 700;
}
.doctor-windows,
.doctor-metric {
min-width: 0;
span,
strong {
display: block;
}
span {
color: #98a2b3;
font-size: 10px;
}
strong {
margin-top: 3px;
color: #344054;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
.doctor-windows strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doctor-metric {
text-align: right;
strong {
font-size: 15px;
}
}
.doctor-metric-booked strong {
color: #07886d;
}
.doctor-metric-empty strong {
color: #d17820;
}
.doctor-metric-completed strong {
color: #07886d;
}
.queue-heading {
margin-bottom: 10px;
}
.queue-table {
width: 100%;
border-radius: 8px;
overflow: hidden;
:deep(th.el-table__cell) {
height: 40px;
color: #667085;
background: #f7f9fb;
font-size: 12px;
font-weight: 600;
}
:deep(td.el-table__cell) {
height: 46px;
padding: 6px 0;
color: #253044;
font-size: 13px;
}
:deep(.queue-row-self > td.el-table__cell) {
background: #fff8e8 !important;
}
:deep(.queue-row-self > td.el-table__cell:first-child) {
border-left: 3px solid #f08332;
}
}
.queue-number {
display: inline-grid;
min-width: 26px;
height: 26px;
padding: 0 7px;
place-items: center;
border-radius: 8px;
color: #0f766e;
background: #e7f6f4;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.patient-cell {
display: flex;
flex-direction: column;
gap: 2px;
strong {
color: #1f2937;
font-size: 13px;
}
span {
color: #98a2b3;
font-size: 11px;
}
}
.appointment-clock {
color: #263246;
font-variant-numeric: tabular-nums;
}
.waiting-text {
color: #667085;
font-size: 12px;
&.is-consulting {
color: #07886d;
font-weight: 600;
}
&.is-next {
color: #d17820;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 14px;
}
@media (max-width: 1180px) {
.schedule-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.doctor-schedule-row {
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) repeat(3, minmax(56px, 0.5fr));
gap: 10px;
}
}
@media (max-width: 760px) {
.board-section {
padding: 12px;
}
.overview-grid,
.schedule-grid {
grid-template-columns: 1fr;
}
.section-heading {
align-items: flex-start;
flex-direction: column;
gap: 7px;
}
.heading-copy {
flex-wrap: wrap;
}
.drilldown-heading,
.drilldown-summary {
align-items: flex-start;
flex-direction: column;
}
.drilldown-summary {
gap: 5px;
}
.doctor-schedule-row {
grid-template-columns: repeat(3, 1fr);
}
.doctor-identity,
.doctor-windows {
grid-column: 1 / -1;
}
.doctor-metric {
text-align: left;
}
}
</style>
@@ -0,0 +1,125 @@
import { hasPermission } from '@/utils/perm'
import { isRemoteSnapshotLocked } from '@/views/consumer/prescription/components/prescription-order-utils'
export type MyPatientOrderAction =
| 'edit'
| 'audit_prescription'
| 'revoke_rx_audit'
| 'audit_payment'
| 'revoke_pay_audit'
| 'ddcode'
| 'ship'
| 'add_pay_order'
| 'complete'
| 'refund'
| 'withdraw'
| 'upload_pharmacy'
export interface MyPatientOrderActionItem {
key: MyPatientOrderAction
label: string
danger?: boolean
}
function permitted(permission: string) {
return hasPermission([permission])
}
function remoteLocked(row: Record<string, any>) {
return isRemoteSnapshotLocked(row as Record<string, unknown>)
}
export function canViewMyPatientOrderDetail() {
return permitted('tcm.prescriptionOrder/detail')
}
export function myPatientOrderActions(row: Record<string, any>): MyPatientOrderActionItem[] {
const actions: MyPatientOrderActionItem[] = []
const fulfillment = Number(row.fulfillment_status)
const prescriptionAudit = Number(row.prescription_audit_status)
const paymentAudit = Number(row.payment_slip_audit_status)
const locked = remoteLocked(row)
if (
canViewMyPatientOrderDetail()
&& permitted('tcm.prescriptionOrder/edit')
&& fulfillment === 1
&& !locked
) {
actions.push({ key: 'edit', label: '编辑订单' })
}
if (
permitted('tcm.prescriptionOrder/auditPrescription')
&& prescriptionAudit === 0
&& ![3, 4, 6].includes(fulfillment)
) {
actions.push({ key: 'audit_prescription', label: '处方审核' })
}
if (
permitted('tcm.prescriptionOrder/auditPrescription')
&& [1, 2].includes(prescriptionAudit)
&& paymentAudit === 0
&& ![3, 4, 6].includes(fulfillment)
&& !locked
) {
actions.push({ key: 'revoke_rx_audit', label: '撤回处方审核' })
}
if (
permitted('tcm.prescriptionOrder/auditPayment')
&& prescriptionAudit === 1
&& paymentAudit === 0
&& ![3, 4].includes(fulfillment)
) {
actions.push({ key: 'audit_payment', label: '支付单审核' })
}
if (
permitted('tcm.prescriptionOrder/auditPayment')
&& prescriptionAudit === 1
&& [1, 2].includes(paymentAudit)
&& ![3, 4, 6].includes(fulfillment)
) {
actions.push({ key: 'revoke_pay_audit', label: '撤回支付审核' })
}
if (permitted('tcm.prescriptionOrder/ddcode')) {
actions.push({ key: 'ddcode', label: '修改快递单号' })
}
if (permitted('tcm.prescriptionOrder/ship') && fulfillment === 2) {
actions.push({ key: 'ship', label: '确认发货' })
}
const amount = Math.round((Number(row.amount) || 0) * 100) / 100
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
if (
permitted('tcm.prescriptionOrder/addPayOrder')
&& [5, 6].includes(fulfillment)
&& paidTotal < amount
) {
actions.push({ key: 'add_pay_order', label: '补齐支付单' })
}
if (
permitted('tcm.prescriptionOrder/complete')
&& [5, 6].includes(fulfillment)
&& paymentAudit === 1
) {
actions.push({ key: 'complete', label: '完成订单' })
}
if (
permitted('tcm.prescriptionOrder/refund')
&& [3, 5, 6, 9].includes(fulfillment)
&& paymentAudit === 1
) {
actions.push({ key: 'refund', label: '退款', danger: true })
}
if (permitted('tcm.prescriptionOrder/withdraw') && fulfillment === 1 && !locked) {
actions.push({ key: 'withdraw', label: '撤回订单', danger: true })
}
if (
permitted('tcm.prescriptionOrder/uploadToPharmacy')
&& row.can_upload_pharmacy !== false
&& prescriptionAudit === 1
&& ![3, 4, 8, 10, 11, 12].includes(fulfillment)
) {
actions.push({ key: 'upload_pharmacy', label: '上传药房' })
}
return actions
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,646 @@
<template>
<div
class="registration-stats"
v-loading="loading"
element-loading-text="正在汇总权限范围内的挂号与预约数据"
>
<header class="page-heading">
<div class="heading-copy">
<span class="heading-mark"><el-icon><Histogram /></el-icon></span>
<div>
<h1>挂号统计</h1>
<p>挂号预约诊单与目标数据按当前角色和部门权限实时汇总</p>
</div>
</div>
<div class="heading-actions">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
</div>
</header>
<section class="filter-strip">
<div class="filter-item">
<span>部门</span>
<el-tree-select
v-model="query.dept_id"
:data="dashboard.filters.departments"
:props="deptTreeProps"
node-key="id"
clearable
filterable
check-strictly
default-expand-all
placeholder="全部可见部门"
class="dept-select"
@change="handleDepartmentChange"
/>
</div>
<div class="filter-item filter-item--time">
<span>时间</span>
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
</div>
<div class="filter-item">
<span>员工</span>
<el-select
v-model="query.assistant_id"
clearable
filterable
placeholder="全部可见员工"
class="employee-select"
@change="loadDashboard"
>
<el-option
v-for="item in dashboard.filters.assistants"
:key="item.id"
:label="item.name"
:value="Number(item.id)"
/>
</el-select>
</div>
<div class="filter-summary">
<strong>{{ dashboard.meta.start_date || '—' }}</strong>
<span> {{ dashboard.meta.end_date || '—' }} · {{ dashboard.meta.member_count || 0 }} 位员工</span>
</div>
</section>
<section class="metric-grid" aria-label="挂号统计核心指标">
<article class="metric-card metric-card--teal">
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总挂号</span>
<strong>{{ formatNumber(dashboard.summary.registration_count) }}</strong>
<small :class="compareClass(dashboard.summary.registration_compare_rate)">
{{ compareText(dashboard.summary.registration_compare_rate) }}
</small>
</div>
</article>
<article class="metric-card metric-card--blue">
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总预约</span>
<strong>{{ formatNumber(dashboard.summary.appointment_count) }}</strong>
<small :class="compareClass(dashboard.summary.appointment_compare_rate)">
{{ compareText(dashboard.summary.appointment_compare_rate) }}
</small>
</div>
</article>
<article class="metric-card metric-card--violet">
<div class="metric-icon"><el-icon><DocumentChecked /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总诊单</span>
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
<small>排除取消拒收和退款订单</small>
</div>
</article>
<article class="metric-card metric-card--amber">
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总业绩</span>
<strong>{{ formatMoney(dashboard.summary.order_amount) }}</strong>
<small>按业务订单创建人归属</small>
</div>
</article>
</section>
<section class="panel employee-panel">
<div class="panel-heading">
<div>
<h2>本组员工挂号与预约统计</h2>
<p>挂号按已支付且实收低于 10 元的支付订单统计预约按预约记录统计</p>
</div>
<span class="panel-badge">{{ dashboard.meta.time_label || '当前范围' }}</span>
</div>
<el-table
:data="dashboard.employee_rows"
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
class="stats-table"
>
<el-table-column prop="name" label="部门 / 员工" min-width="250" fixed="left">
<template #default="{ row }">
<div class="name-cell" :class="`is-${row.row_type}`">
<span v-if="row.row_type === 'department'" class="dept-dot" />
<el-avatar v-else :size="26">{{ avatarText(row.name) }}</el-avatar>
<strong>{{ row.name }}</strong>
<em v-if="row.row_type === 'department'">{{ row.member_count }} </em>
</div>
</template>
</el-table-column>
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
<el-table-column prop="day_after_count" label="后日预约" min-width="105" align="right" sortable />
<el-table-column prop="order_count" label="诊单" min-width="86" align="right" sortable />
<el-table-column label="业绩" min-width="128" align="right" sortable :sort-method="sortAmount">
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
</el-table-column>
<el-table-column label="挂号较上期" min-width="112" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
{{ compactCompare(row.registration_compare_rate) }}
</span>
</template>
</el-table-column>
<el-table-column label="预约较上期" min-width="112" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
{{ compactCompare(row.appointment_compare_rate) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="92" align="center">
<template #default><span class="status-pill"><i />正常</span></template>
</el-table-column>
<template #empty><el-empty description="当前权限与筛选范围内暂无医助数据" /></template>
</el-table>
</section>
<section class="panel target-panel">
<div class="panel-heading">
<div>
<h2>一诊诊金目标追踪</h2>
<p>{{ dashboard.target.year }} · {{ dashboard.target.scope_note }}</p>
</div>
<span class="panel-badge">{{ dashboard.target.department_count || 0 }} 个目标部门</span>
</div>
<div class="target-layout">
<div class="target-summary">
<div class="target-numbers">
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
</div>
<el-progress
:percentage="progressValue(dashboard.target.completion_rate)"
:show-text="false"
:stroke-width="13"
color="#139a8c"
/>
<p v-if="Number(dashboard.target.target_amount) > 0">
尚差 {{ formatMoney(Math.max(0, Number(dashboard.target.target_amount) - Number(dashboard.target.actual_amount))) }} 达成年度目标
</p>
<p v-else class="target-empty">当前范围未维护可用目标实际业绩仍按统一口径正常统计</p>
</div>
<div class="target-chart-wrap">
<div class="chart-legend"><i class="actual" />累计完成 <i class="target" />累计目标</div>
<v-charts class="target-chart" :option="targetChartOption" autoresize />
</div>
</div>
</section>
<section class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>{{ dashboard.meta.time_label || '今日' }}业绩 TOP</h2><p>按业务订单创建人排序</p></div>
<span class="panel-badge">金额</span>
</div>
<div v-if="dashboard.rankings.performance.length" class="ranking-list">
<div v-for="(item, index) in dashboard.rankings.performance" :key="`performance-${item.admin_id}`" class="ranking-row">
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
<span>{{ item.name }}<small>{{ item.count }} </small></span>
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxPerformance) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="52" description="暂无业绩数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>{{ dashboard.meta.time_label || '今日' }}挂号 TOP</h2><p>按低于 10 元的已支付订单笔数排序</p></div>
<span class="panel-badge">挂号</span>
</div>
<div v-if="dashboard.rankings.registrations.length" class="ranking-list ranking-list--blue">
<div v-for="(item, index) in dashboard.rankings.registrations" :key="`registration-${item.admin_id}`" class="ranking-row">
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
<span>{{ item.name }}<small>已支付小额订单</small></span>
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxRegistrations) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="52" description="暂无挂号数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>{{ dashboard.meta.time_label || '今日' }}预约 TOP</h2><p>按有效预约记录数量排序</p></div>
<span class="panel-badge">预约</span>
</div>
<div v-if="dashboard.rankings.appointments.length" class="ranking-list ranking-list--blue">
<div v-for="(item, index) in dashboard.rankings.appointments" :key="`appointment-${item.admin_id}`" class="ranking-row">
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
<span>{{ item.name }}<small>有效预约</small></span>
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxAppointments) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="52" description="暂无预约数据" />
</article>
</section>
<section class="panel department-panel">
<div class="panel-heading">
<div><h2>按部门统计</h2><p>员工按最深层有效归属部门唯一计入避免多部门重复累计</p></div>
<span class="panel-badge">{{ dashboard.departments.length }} 个部门</span>
</div>
<el-table :data="dashboard.departments" class="stats-table department-table">
<el-table-column prop="name" label="部门" min-width="220">
<template #default="{ row }"><strong>{{ row.name }}</strong></template>
</el-table-column>
<el-table-column prop="member_count" label="人数" min-width="90" align="right" sortable />
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
<el-table-column prop="order_count" label="诊单" min-width="90" align="right" sortable />
<el-table-column label="业绩" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
</el-table-column>
<el-table-column label="挂号环比" min-width="110" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
{{ compactCompare(row.registration_compare_rate) }}
</span>
</template>
</el-table-column>
<el-table-column label="预约环比" min-width="110" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
{{ compactCompare(row.appointment_compare_rate) }}
</span>
</template>
</el-table-column>
<template #empty><el-empty description="当前范围暂无部门汇总" /></template>
</el-table>
</section>
<footer class="data-note">
<el-icon><InfoFilled /></el-icon>
<span>{{ dashboard.meta.registration_rule }}{{ dashboard.meta.appointment_rule }}{{ dashboard.meta.performance_rule }}</span>
</footer>
</div>
</template>
<script setup lang="ts" name="firstVisitRegistrationStatsPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
Calendar,
DocumentChecked,
Histogram,
InfoFilled,
Lock,
Refresh,
Wallet
} from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import {
firstVisitRegistrationStatsOverview,
type FirstVisitRegistrationStatsParams
} from '@/api/first_visit'
const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
member_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
},
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
summary: {
registration_count: 0, registration_compare_count: 0, registration_compare_rate: null as number | null,
appointment_count: 0, appointment_compare_count: 0, appointment_compare_rate: null as number | null,
order_count: 0, order_amount: 0, range_label: '今日'
},
employee_rows: [] as any[],
rankings: { performance: [] as any[], registrations: [] as any[], appointments: [] as any[] },
departments: [] as any[],
target: {
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0,
completion_rate: null as number | null, department_count: 0, scope_note: '',
months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
}
})
const loading = ref(false)
const dashboard = reactive(emptyDashboard())
const query = reactive<FirstVisitRegistrationStatsParams>({ time_type: 'today' })
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' }
]
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
const maxPerformance = computed(() => Math.max(0, ...dashboard.rankings.performance.map((item: any) => Number(item.value) || 0)))
const maxRegistrations = computed(() => Math.max(0, ...dashboard.rankings.registrations.map((item: any) => Number(item.value) || 0)))
const maxAppointments = computed(() => Math.max(0, ...dashboard.rankings.appointments.map((item: any) => Number(item.value) || 0)))
const targetChartOption = computed(() => ({
animationDuration: 450,
grid: { left: 18, right: 18, top: 18, bottom: 12, containLabel: true },
tooltip: {
trigger: 'axis',
valueFormatter: (value: number) => formatMoney(value)
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dashboard.target.months,
axisLine: { lineStyle: { color: '#d9e1e8' } },
axisTick: { show: false },
axisLabel: { color: '#7d8b9c', fontSize: 11 }
},
yAxis: {
type: 'value',
splitNumber: 3,
axisLabel: { color: '#8b98a8', formatter: (value: number) => compactMoney(value) },
splitLine: { lineStyle: { color: '#eef2f5' } }
},
series: [
{
name: '累计完成', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
data: dashboard.target.actual_cumulative,
lineStyle: { color: '#139a8c', width: 3 },
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
areaStyle: { color: 'rgba(19,154,140,.08)' }
},
{
name: '累计目标', type: 'line', smooth: true, showSymbol: false,
data: dashboard.target.target_cumulative,
lineStyle: { color: '#5f86e8', width: 2, type: 'dashed' }
}
]
}))
async function loadDashboard() {
loading.value = true
try {
const params: FirstVisitRegistrationStatsParams = { time_type: query.time_type }
if (query.dept_id) params.dept_id = Number(query.dept_id)
if (query.assistant_id) params.assistant_id = Number(query.assistant_id)
const result: any = await firstVisitRegistrationStatsOverview(params)
Object.assign(dashboard, emptyDashboard(), result || {})
} catch (error: any) {
ElMessage.error(error?.message || '挂号统计加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function handleDepartmentChange() {
delete query.assistant_id
loadDashboard()
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
}
function formatMoney(value: unknown) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function compactMoney(value: number) {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}`
return `${Math.round(value)}`
}
function nullablePercent(value: unknown) {
return value === null || value === undefined ? '未设置' : `${Number(value).toFixed(1)}%`
}
function progressValue(value: unknown) {
return Math.min(100, Math.max(0, Number(value) || 0))
}
function compareText(value: unknown) {
if (value === null || value === undefined) return '上期无数据,暂不计算环比'
const number = Number(value)
if (number === 0) return '与上期持平'
return `${number > 0 ? '较上期增长' : '较上期下降'} ${Math.abs(number).toFixed(1)}%`
}
function compactCompare(value: unknown) {
if (value === null || value === undefined) return '—'
const number = Number(value)
return `${number > 0 ? '+' : ''}${number.toFixed(1)}%`
}
function compareClass(value: unknown) {
if (value === null || value === undefined || Number(value) === 0) return 'is-neutral'
return Number(value) > 0 ? 'is-up' : 'is-down'
}
function rankWidth(value: unknown, max: number) {
if (max <= 0) return '0%'
return `${Math.max(4, Math.min(100, Number(value || 0) / max * 100))}%`
}
function avatarText(name: unknown) {
const text = String(name || '员').trim()
return text.slice(-1)
}
function sortAmount(left: any, right: any) {
return Number(left?.order_amount || 0) - Number(right?.order_amount || 0)
}
onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.registration-stats {
--ink: #17243a;
--muted: #778598;
--line: #e3e9ef;
--canvas: #f4f6f8;
--teal: #139a8c;
--blue: #4f78e5;
min-height: 100%;
padding: 18px;
color: var(--ink);
background: var(--canvas);
}
.page-heading,
.filter-strip,
.panel,
.metric-card {
border: 1px solid var(--line);
background: #fff;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 76px;
padding: 14px 18px;
border-radius: 13px;
}
.heading-copy,
.heading-actions,
.filter-item,
.name-cell,
.chart-legend {
display: flex;
align-items: center;
}
.heading-copy { gap: 12px; }
.heading-mark {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 11px;
color: #fff;
background: var(--teal);
box-shadow: 0 8px 20px rgba(19, 154, 140, .18);
font-size: 20px;
}
h1, h2, p { margin: 0; }
h1 { font-size: 20px; line-height: 1.3; letter-spacing: .01em; }
h2 { font-size: 15px; line-height: 1.4; }
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
.heading-actions { gap: 12px; color: var(--muted); font-size: 12px; }
.scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 9px;
border: 1px solid #cbe8e3;
border-radius: 7px;
color: #127f75;
background: #f1faf8;
}
.filter-strip {
display: flex;
align-items: center;
gap: 22px;
margin-top: 14px;
padding: 11px 14px;
border-radius: 11px;
}
.filter-item { gap: 9px; color: #68778b; font-size: 13px; }
.dept-select { width: 220px; }
.employee-select { width: 180px; }
.filter-summary { margin-left: auto; text-align: right; }
.filter-summary strong { display: block; font-size: 13px; }
.filter-summary span { color: var(--muted); font-size: 11px; }
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.metric-card {
display: flex;
align-items: center;
gap: 13px;
min-height: 114px;
padding: 18px;
border-radius: 12px;
}
.metric-icon {
display: grid;
width: 42px;
height: 42px;
flex: 0 0 42px;
place-items: center;
border-radius: 11px;
font-size: 20px;
}
.metric-card--teal .metric-icon { color: #107f75; background: #e9f7f5; }
.metric-card--blue .metric-icon { color: #416bd7; background: #edf1ff; }
.metric-card--violet .metric-icon { color: #7257c7; background: #f2efff; }
.metric-card--amber .metric-icon { color: #bc7428; background: #fff4e7; }
.metric-card span { display: block; color: #748296; font-size: 13px; }
.metric-card strong { display: block; margin: 4px 0 2px; font-size: 27px; line-height: 1.15; }
.metric-card small { color: #8b98a8; font-size: 11px; }
.metric-card small.is-up, .rate-text.is-up { color: #17956f; }
.metric-card small.is-down, .rate-text.is-down { color: #e25858; }
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 15px 16px 13px;
}
.panel-badge {
padding: 4px 8px;
border-radius: 6px;
color: #758497;
background: #f2f5f7;
font-size: 11px;
}
.stats-table { --el-table-header-bg-color: #f7f9fb; --el-table-border-color: #e7ecf1; }
.stats-table :deep(th.el-table__cell) { height: 42px; color: #68778b; font-weight: 500; }
.stats-table :deep(td.el-table__cell) { height: 47px; }
.stats-table :deep(.el-table__row--level-0) { background: #fafcfd; }
.name-cell { gap: 9px; }
.name-cell em { color: #8d99a7; font-size: 11px; font-style: normal; font-weight: 400; }
.name-cell.is-department strong { font-weight: 700; }
.name-cell :deep(.el-avatar) { color: #167e75; background: #e8f5f3; font-size: 11px; }
.dept-dot { width: 8px; height: 8px; border-radius: 3px; background: var(--teal); }
.status-pill { display: inline-flex; align-items: center; gap: 5px; color: #218d71; font-size: 12px; }
.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: #34b38d; }
.rate-text { font-size: 12px; }
.rate-text.is-neutral { color: #8b98a8; }
.target-layout { display: grid; grid-template-columns: minmax(340px, .8fr) minmax(480px, 1.2fr); border-top: 1px solid #edf1f4; }
.target-summary { display: flex; flex-direction: column; justify-content: center; padding: 24px 22px; border-right: 1px solid #edf1f4; }
.target-numbers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-bottom: 22px; }
.target-numbers span { display: block; color: var(--muted); font-size: 12px; }
.target-numbers strong { display: block; margin-top: 5px; font-size: 20px; }
.target-numbers .is-teal { color: var(--teal); }
.target-summary p { margin-top: 9px; color: var(--muted); font-size: 11px; }
.target-summary .target-empty { color: #b17832; }
.target-chart-wrap { position: relative; min-height: 238px; padding: 10px 14px 4px; }
.target-chart { width: 100%; height: 225px; }
.chart-legend { position: absolute; z-index: 2; top: 11px; right: 18px; gap: 6px; color: #778598; font-size: 11px; }
.chart-legend i { width: 18px; height: 3px; margin-left: 8px; border-radius: 2px; }
.chart-legend .actual { background: var(--teal); }
.chart-legend .target { background: repeating-linear-gradient(90deg, var(--blue) 0 5px, transparent 5px 8px); }
.ranking-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.ranking-panel { min-height: 286px; }
.ranking-list { padding: 2px 16px 16px; }
.ranking-row { display: grid; grid-template-columns: 30px minmax(86px, .8fr) minmax(64px, 1fr) auto; align-items: center; gap: 10px; min-height: 43px; border-top: 1px solid #eff2f5; }
.ranking-row > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 7px; color: #8491a1; background: #f1f4f6; font-size: 11px; }
.ranking-row > b.is-top { color: #fff; background: var(--teal); }
.ranking-list--blue .ranking-row > b.is-top { background: var(--blue); }
.ranking-row > span { overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.ranking-row small { margin-left: 6px; color: #939eab; font-size: 10px; font-weight: 400; }
.ranking-row > strong { text-align: right; font-size: 12px; }
.rank-track { height: 7px; overflow: hidden; border-radius: 5px; background: #edf1f4; }
.rank-track i { display: block; height: 100%; border-radius: inherit; background: var(--teal); }
.ranking-list--blue .rank-track i { background: var(--blue); }
.department-table { border-top: 1px solid #edf1f4; }
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
@media (max-width: 1180px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.filter-summary { margin-left: 0; }
.target-layout { grid-template-columns: 1fr; }
.target-summary { border-right: 0; border-bottom: 1px solid #edf1f4; }
}
@media (max-width: 820px) {
.registration-stats { padding: 10px; }
.page-heading { align-items: flex-start; flex-direction: column; }
.heading-actions { flex-wrap: wrap; }
.update-time { display: none; }
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item { width: 100%; justify-content: space-between; }
.dept-select, .employee-select { width: calc(100% - 52px); }
.filter-item--time { justify-content: flex-start; }
.target-numbers { grid-template-columns: 1fr; }
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,686 @@
<template>
<div class="mapping-page">
<header class="page-header">
<h1>洛阳药房药材映射</h1>
<el-button
v-if="status.sync_enabled"
v-perms="['pharmacy.medicineMapping/sync']"
type="primary"
:icon="Refresh"
:loading="syncing"
@click="handleSync"
>
增量同步
</el-button>
</header>
<section class="status-strip" v-loading="statusLoading">
<div class="status-item">
<span>目录</span>
<strong>{{ status.catalog_active }} / {{ status.catalog_total }}</strong>
<small>启用 / 总数</small>
</div>
<div class="status-item warning">
<span>未映射本地药材</span>
<strong>{{ status.unmapped_local }}</strong>
<small>仅统计启用药材</small>
</div>
<div class="status-item">
<span>同步游标</span>
<strong>{{ status.cursor }}</strong>
<small>{{ formatTime(status.last_success_time) || '尚未成功同步' }}</small>
</div>
<div class="status-item" :class="{ danger: !!status.last_error_summary }">
<span>最近同步</span>
<strong>{{
status.is_syncing ? '进行中' : status.last_error_summary ? '失败' : '正常'
}}</strong>
<small :title="status.last_error_summary">
{{
status.last_error_summary ||
formatTime(status.last_failure_time) ||
'无失败记录'
}}
</small>
</div>
</section>
<el-form class="filter-bar" inline @submit.prevent>
<el-form-item label="本地药材">
<el-input
v-model="query.local_name"
clearable
placeholder="名称"
:prefix-icon="Search"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="远端目录">
<el-input
v-model="query.remote_keyword"
clearable
placeholder="名称或编码"
:prefix-icon="Search"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="映射状态">
<el-select
v-model="query.mapping_status"
clearable
placeholder="全部"
style="width: 150px"
>
<el-option label="已映射" value="mapped" />
<el-option label="未映射" value="unmapped" />
<el-option label="远端失效" value="invalid" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="table-wrap">
<el-table v-loading="loading" :data="rows" border stripe table-layout="fixed">
<el-table-column label="本地药材" min-width="190" fixed="left">
<template #default="{ row }">
<div class="medicine-name">{{ row.local_name }}</div>
<div class="subline">
ID {{ row.local_medicine_id }} · {{ row.local_unit || '-' }}
</div>
</template>
</el-table-column>
<el-table-column label="本地状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.local_status === 1 ? 'success' : 'info'" size="small">
{{ row.local_status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="映射状态" width="105" align="center">
<template #default="{ row }">
<el-tag :type="mappingTag(row.mapping_status)" size="small">
{{ mappingText(row.mapping_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="洛阳药房目录" min-width="250">
<template #default="{ row }">
<template v-if="row.medicine_code">
<div class="medicine-name">{{ row.remote_name || '目录项不可用' }}</div>
<div class="subline code">{{ row.medicine_code }}</div>
</template>
<template v-else>
<div class="medicine-name">-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
{{ row.mapping_status === 0 ? '-' : row.remote_brand || '-' }}
</template>
</el-table-column>
<el-table-column label="单位" width="80" align="center">
<template #default="{ row }">{{ row.remote_unit || '-' }}</template>
</el-table-column>
<el-table-column label="价格" width="155" align="right">
<template #default="{ row }">
<template v-if="row.mapping_status !== 0">
<div>结算 ¥{{ formatPrice(row.settlement_price) }}</div>
<div class="subline">零售 ¥{{ formatPrice(row.retail_price) }}</div>
</template>
<template v-else>
<div>-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="版本 / 状态" width="130" align="center">
<template #default="{ row }">
<template v-if="row.medicine_code">
<div>v{{ row.catalog_version || 0 }}</div>
<div class="subline">
{{
row.remote_status === 1 && row.remote_deleted !== 1
? '远端启用'
: '远端停用'
}}
</div>
</template>
<template v-else>
<div>-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-perms="['pharmacy.medicineMapping/save']"
type="primary"
link
:icon="Link"
:disabled="row.local_status !== 1"
@click="openMapping(row)"
>
{{ row.mapping_status === 1 ? '更换' : '映射' }}
</el-button>
<el-button
v-if="row.mapping_status === 1 || row.mapping_status === 2"
v-perms="['pharmacy.medicineMapping/unlink']"
type="danger"
link
:icon="CloseBold"
@click="handleUnlink(row)"
>
解除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="query.page_no"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadRows"
@current-change="loadRows"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="`${editingRow?.mapping_status === 1 ? '更换' : '建立'}药材映射`"
width="min(560px, calc(100vw - 32px))"
destroy-on-close
>
<div class="local-summary">
<span>本地药材</span>
<strong>{{ editingRow?.local_name }}</strong>
<small
>ID {{ editingRow?.local_medicine_id }} ·
{{ editingRow?.local_unit || '-' }}</small
>
</div>
<el-form label-position="top" class="mapping-form">
<el-form-item label="洛阳药房药材">
<el-select
v-model="selectedCode"
filterable
remote
reserve-keyword
clearable
:remote-method="searchCatalog"
:loading="catalogLoading"
placeholder="输入药材名称或编码检索"
style="width: 100%"
>
<el-option
v-for="option in catalogOptions"
:key="option.medicine_code"
:label="`${option.name} · ${option.medicine_code}`"
:value="option.medicine_code"
>
<div class="option-row">
<span>{{ option.name }}</span>
<small
>{{ option.medicine_code }} · {{ option.brand || '无品牌' }} ·
{{ option.unit }}</small
>
</div>
</el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="!selectedCode"
@click="saveMapping"
>
保存映射
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="pharmacyMedicineMapping">
import { onMounted, reactive, ref } from 'vue'
import { CloseBold, Link, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import feedback from '@/utils/feedback'
import { createLatestRequestGuard } from './latest-request.mjs'
import {
medicineCatalogOptions,
medicineCatalogSync,
medicineMappingLists,
medicineMappingSave,
medicineMappingStatus,
medicineMappingUnlink,
type CatalogOption,
type MedicineMappingQuery,
type MedicineMappingRow,
type PharmacySyncResult,
type PharmacySyncStatus
} from '@/api/pharmacy'
const emptyStatus = (): PharmacySyncStatus => ({
sync_enabled: false,
cursor: 0,
last_success_time: 0,
last_failure_time: 0,
last_error_summary: '',
is_syncing: false,
catalog_total: 0,
catalog_active: 0,
unmapped_local: 0
})
const loading = ref(false)
const statusLoading = ref(false)
const syncing = ref(false)
const saving = ref(false)
const catalogLoading = ref(false)
const rows = ref<MedicineMappingRow[]>([])
const total = ref(0)
const status = ref<PharmacySyncStatus>(emptyStatus())
const query = reactive<MedicineMappingQuery>({
page_no: 1,
page_size: 20,
local_name: '',
remote_keyword: '',
mapping_status: ''
})
const dialogVisible = ref(false)
const editingRow = ref<MedicineMappingRow | null>(null)
const selectedCode = ref('')
const catalogOptions = ref<CatalogOption[]>([])
let catalogTimer: number | undefined
const listRequests = createLatestRequestGuard<MedicineMappingQuery>()
const catalogRequests = createLatestRequestGuard<{ keyword: string; localMedicineId: number }>()
const statusRequests = createLatestRequestGuard()
const formatPrice = (value: string | number | null | undefined) => {
const number = Number(value || 0)
return Number.isFinite(number) ? number.toFixed(2) : '0.00'
}
const formatTime = (timestamp: number) => {
if (!timestamp) return ''
return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false })
}
const mappingText = (value: number) => {
if (value === 1) return '已映射'
if (value === 2) return '远端失效'
return '未映射'
}
const mappingTag = (value: number): 'success' | 'warning' | 'info' => {
if (value === 1) return 'success'
if (value === 2) return 'warning'
return 'info'
}
const loadRows = async () => {
const ticket = listRequests.next({ ...query })
loading.value = true
try {
const response = await medicineMappingLists(ticket.snapshot)
if (listRequests.isLatest(ticket)) {
rows.value = response.lists || []
total.value = response.count || 0
}
} finally {
if (listRequests.isLatest(ticket)) {
loading.value = false
}
}
}
const loadStatus = async () => {
const ticket = statusRequests.next(undefined)
statusLoading.value = true
try {
const nextStatus = await medicineMappingStatus()
if (statusRequests.isLatest(ticket)) {
status.value = nextStatus
}
} finally {
if (statusRequests.isLatest(ticket)) {
statusLoading.value = false
}
}
}
const search = () => {
query.page_no = 1
void loadRows()
}
const resetFilters = () => {
query.local_name = ''
query.remote_keyword = ''
query.mapping_status = ''
search()
}
const handleSync = async () => {
syncing.value = true
try {
const result = (await medicineCatalogSync()) as PharmacySyncResult
feedback.msgSuccess(
`同步完成:拉取 ${result.pulled},新增 ${result.created},更新 ${result.updated},停用 ${result.deactivated}`
)
await Promise.all([loadRows(), loadStatus()])
} finally {
syncing.value = false
}
}
const searchCatalogNow = async (
keyword: string,
localMedicineId = Number(editingRow.value?.local_medicine_id || 0)
) => {
const ticket = catalogRequests.next({ keyword: keyword.trim(), localMedicineId })
catalogLoading.value = true
try {
const options = await medicineCatalogOptions({ keyword: ticket.snapshot.keyword, limit: 30 })
if (
catalogRequests.isLatest(ticket) &&
Number(editingRow.value?.local_medicine_id || 0) === ticket.snapshot.localMedicineId
) {
catalogOptions.value = options
}
} finally {
if (catalogRequests.isLatest(ticket)) {
catalogLoading.value = false
}
}
return ticket
}
const searchCatalog = (keyword: string) => {
if (catalogTimer) window.clearTimeout(catalogTimer)
catalogRequests.invalidate()
catalogTimer = window.setTimeout(() => void searchCatalogNow(keyword), 250)
}
const openMapping = async (row: MedicineMappingRow) => {
if (catalogTimer) window.clearTimeout(catalogTimer)
catalogRequests.invalidate()
const rowSnapshot = { ...row }
const localMedicineId = Number(rowSnapshot.local_medicine_id)
editingRow.value = rowSnapshot
selectedCode.value = rowSnapshot.mapping_status === 1 ? rowSnapshot.medicine_code || '' : ''
catalogOptions.value = []
dialogVisible.value = true
const initialTicket = await searchCatalogNow(rowSnapshot.local_name, localMedicineId)
if (
!catalogRequests.isLatest(initialTicket) ||
Number(editingRow.value?.local_medicine_id || 0) !== localMedicineId
)
return
if (
selectedCode.value &&
!catalogOptions.value.some((item) => item.medicine_code === selectedCode.value)
) {
await searchCatalogNow(selectedCode.value, localMedicineId)
}
}
const saveMapping = async () => {
if (!editingRow.value || !selectedCode.value) return
saving.value = true
try {
await medicineMappingSave({
local_medicine_id: editingRow.value.local_medicine_id,
medicine_code: selectedCode.value
})
dialogVisible.value = false
await Promise.all([loadRows(), loadStatus()])
} finally {
saving.value = false
}
}
const handleUnlink = async (row: MedicineMappingRow) => {
await ElMessageBox.confirm(
`确认解除“${row.local_name}”与 ${row.medicine_code} 的映射?`,
'解除映射',
{
type: 'warning',
confirmButtonText: '解除',
cancelButtonText: '取消'
}
)
await medicineMappingUnlink({ local_medicine_id: row.local_medicine_id })
await Promise.all([loadRows(), loadStatus()])
}
onMounted(() => {
void Promise.all([loadRows(), loadStatus()])
})
</script>
<style scoped>
.mapping-page {
min-width: 0;
padding: 16px;
color: var(--el-text-color-primary);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.page-header h1 {
margin: 0;
font-size: 20px;
line-height: 28px;
letter-spacing: 0;
}
.status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
min-height: 88px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-light);
background: var(--el-bg-color);
}
.status-item {
min-width: 0;
padding: 14px 16px;
border-right: 1px solid var(--el-border-color-lighter);
}
.status-item:last-child {
border-right: 0;
}
.status-item span,
.status-item small {
display: block;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-item strong {
display: block;
margin: 5px 0 2px;
font-size: 18px;
line-height: 24px;
}
.status-item.warning strong {
color: var(--el-color-warning-dark-2);
}
.status-item.danger strong,
.status-item.danger small {
color: var(--el-color-danger);
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0 8px;
padding: 14px 16px 0;
border: 1px solid var(--el-border-color-light);
border-bottom: 0;
background: var(--el-fill-color-extra-light);
}
.filter-bar :deep(.el-input) {
width: 190px;
}
.table-wrap {
min-width: 0;
overflow-x: auto;
}
.table-wrap :deep(.el-table) {
min-width: 1220px;
}
.medicine-name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.subline,
.muted {
margin-top: 3px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
overflow-x: auto;
padding-top: 16px;
}
.local-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
padding: 12px 14px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
}
.local-summary span,
.local-summary small {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.local-summary small {
grid-column: 2;
}
.mapping-form {
margin-top: 18px;
}
.option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.option-row small {
overflow: hidden;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.status-item:nth-child(2) {
border-right: 0;
}
.status-item:nth-child(-n + 2) {
border-bottom: 1px solid var(--el-border-color-lighter);
}
}
@media (max-width: 600px) {
.mapping-page {
padding: 12px;
}
.page-header {
align-items: stretch;
flex-direction: column;
}
.page-header .el-button {
width: 100%;
}
.status-strip {
grid-template-columns: 1fr;
}
.status-item,
.status-item:nth-child(2) {
border-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.status-item:last-child {
border-bottom: 0;
}
.filter-bar {
display: block;
}
.filter-bar :deep(.el-form-item),
.filter-bar :deep(.el-input),
.filter-bar :deep(.el-select) {
width: 100% !important;
}
.pagination-wrap {
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,12 @@
export interface LatestRequestTicket<T> {
readonly generation: number
readonly snapshot: T
}
export interface LatestRequestGuard<T> {
next(snapshot: T): LatestRequestTicket<T>
invalidate(): number
isLatest(ticket: LatestRequestTicket<T>): boolean
}
export function createLatestRequestGuard<T>(): LatestRequestGuard<T>
@@ -0,0 +1,22 @@
export function createLatestRequestGuard() {
let generation = 0
return {
next(snapshot) {
generation += 1
const stableSnapshot = Array.isArray(snapshot)
? [...snapshot]
: snapshot && typeof snapshot === 'object'
? { ...snapshot }
: snapshot
return Object.freeze({ generation, snapshot: stableSnapshot })
},
invalidate() {
generation += 1
return generation
},
isLatest(ticket) {
return ticket?.generation === generation
}
}
}
@@ -0,0 +1,319 @@
<template>
<div class="auto-assign-log-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="log-filter-form">
<el-form-item label="记录时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
@change="doSearch"
/>
</el-form-item>
<el-form-item label="执行日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
@change="doSearch"
/>
</el-form-item>
<el-form-item label="结果">
<el-select
v-model="queryParams.action"
clearable
placeholder="全部"
class="w-[120px]"
@change="doSearch"
>
<el-option label="已分配" value="1" />
<el-option label="未分配" value="0" />
</el-select>
</el-form-item>
<el-form-item label="回退">
<el-select
v-model="queryParams.is_rollback"
clearable
placeholder="全部"
class="w-[120px]"
@change="doSearch"
>
<el-option label="未回退" value="0" />
<el-option label="已回退" value="1" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="患者/手机号/医助/诊单ID"
clearable
class="w-[220px]"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="log-hint">
由每日定时任务待分配诊单自动指派写入按上月二诊复诊接诊率分档轮询分配&gt;70% 每人3条/60%~70%
每人2条/50%~60% 每人1条/&lt;50% 不分回退可将已分配医助撤回到自动分配前的原医助通常为一中心并写入指派日志
</div>
</el-card>
<el-card class="!border-none mt-3" shadow="never">
<div class="toolbar mb-3">
<el-button
v-perms="['stats.autoAssignLog/rollback']"
type="warning"
:disabled="selectedRows.length === 0"
:loading="rollbackLoading"
@click="handleBatchRollback"
>
回退选中{{ selectedRows.length }}
</el-button>
<span v-if="selectedRows.length > 0" class="toolbar-tip">
已选 {{ selectedRows.length }} 条可回退记录
</span>
</div>
<el-table
ref="tableRef"
:data="pager.lists"
v-loading="pager.loading"
size="default"
stripe
row-key="id"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
width="48"
:selectable="rowSelectable"
reserve-selection
/>
<el-table-column label="记录时间" width="160">
<template #default="{ row }">{{ row.create_time_text || '—' }}</template>
</el-table-column>
<el-table-column label="诊单" width="90" prop="diagnosis_id" />
<el-table-column label="患者" min-width="110">
<template #default="{ row }">
<div>{{ row.patient_name || '—' }}</div>
<div class="cell-sub">{{ row.patient_phone || '' }}</div>
</template>
</el-table-column>
<el-table-column label="结果" width="90" align="center">
<template #default="{ row }">
<el-tag :type="Number(row.action) === 1 ? 'success' : 'info'" size="small">
{{ row.action_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="回退状态" width="140">
<template #default="{ row }">
<template v-if="Number(row.is_rollback) === 1">
<el-tag type="warning" size="small">已回退</el-tag>
<div class="cell-sub">{{ row.rollback_time_text }}</div>
<div v-if="row.rollback_admin_name" class="cell-sub">
{{ row.rollback_admin_name }}
</div>
</template>
<el-tag v-else-if="Number(row.can_rollback) === 1" type="success" size="small">
可回退
</el-tag>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="医助" min-width="110">
<template #default="{ row }">
<template v-if="Number(row.assistant_id) > 0">
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="档位" width="100" align="center">
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
</el-table-column>
<el-table-column label="轮次" width="70" align="center">
<template #default="{ row }">
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
</template>
</el-table-column>
<el-table-column label="原因" min-width="280" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason }}</template>
</el-table-column>
<el-table-column label="统计月" width="90" prop="stat_month" align="center" />
<el-table-column label="批次" width="160">
<template #default="{ row }">
<span class="cell-sub">{{ row.batch_no }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="Number(row.can_rollback) === 1"
v-perms="['stats.autoAssignLog/rollback']"
link
type="warning"
:loading="rollbackLoading"
@click="handleSingleRollback(row)"
>
回退
</el-button>
<span v-else class="cell-sub"></span>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
</div>
</template>
<script setup lang="ts" name="statsAutoAssignLog">
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import { onMounted, reactive, ref, watch } from 'vue'
const dateRange = ref<[string, string] | null>(null)
const timeRange = ref<[string, string] | null>(null)
const tableRef = ref()
const selectedRows = ref<any[]>([])
const rollbackLoading = ref(false)
const queryParams = reactive({
start_date: '',
end_date: '',
start_time: '',
end_time: '',
action: '' as '' | '0' | '1',
is_rollback: '' as '' | '0' | '1',
keyword: ''
})
watch(dateRange, (val) => {
queryParams.start_date = val?.[0] ?? ''
queryParams.end_date = val?.[1] ?? ''
})
watch(timeRange, (val) => {
queryParams.start_time = val?.[0] ?? ''
queryParams.end_time = val?.[1] ?? ''
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: autoAssignLogLists,
params: queryParams
})
const doSearch = () => {
selectedRows.value = []
tableRef.value?.clearSelection?.()
resetPage()
}
const handleReset = () => {
dateRange.value = null
timeRange.value = null
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.start_time = ''
queryParams.end_time = ''
queryParams.action = ''
queryParams.is_rollback = ''
queryParams.keyword = ''
doSearch()
}
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows.filter((r) => Number(r.can_rollback) === 1)
}
const runRollback = async (ids: number[]) => {
if (ids.length === 0) return
await feedback.confirm(
`确认回退选中的 ${ids.length} 条自动分配?将把诊单医助撤回到自动分配前的原医助,并写入指派日志。`
)
rollbackLoading.value = true
try {
const res: any = await autoAssignLogRollback({ ids })
const success = Number(res?.success ?? 0)
const failed = Number(res?.failed ?? 0)
if (failed > 0 && Array.isArray(res?.messages) && res.messages.length) {
feedback.msgWarning(
`回退成功 ${success} 条,跳过/失败 ${failed} 条:${res.messages.slice(0, 3).join('')}`
)
} else {
feedback.msgSuccess(`回退成功 ${success}`)
}
selectedRows.value = []
tableRef.value?.clearSelection?.()
await getLists()
} finally {
rollbackLoading.value = false
}
}
const handleBatchRollback = () => {
const ids = selectedRows.value.map((r) => Number(r.id)).filter((id) => id > 0)
return runRollback(ids)
}
const handleSingleRollback = (row: any) => {
const id = Number(row?.id ?? 0)
if (id <= 0) return
return runRollback([id])
}
onMounted(() => getLists())
</script>
<style lang="scss" scoped>
.auto-assign-log-page {
.log-filter-form {
:deep(.el-form-item) {
margin-bottom: 8px;
}
}
.log-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -18,7 +18,7 @@
<el-tree-select
v-model="deptId"
:data="deptTreeOptions"
placeholder="全部部门"
placeholder="二中心(全部)"
clearable
filterable
check-strictly
@@ -43,10 +43,10 @@
</template>
<div class="rate-caliber">
<p>
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重<b>再剔除</b>名下存在履约拒收 / 退款业务订单的诊单
</p>
<p>
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序的全局序号<b>跨月累计不重置</b> 5 月指派后旗下成交 4 单为二诊~五诊下月再成交即为六诊
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序编号为实单序号<b>统计诊次 = 实单序号 + 诊单偏移</b>默认偏移 0 1 笔实单为一诊偏移 1 1 笔实单为二诊偏移 2 1 笔实单为三诊5 笔实单等价七诊诊次<b>跨月累计不重置</b>诊单可在业务订单tab 配置偏移量
</p>
<p>
<b>当月 N 诊单数</b>当月内下单且诊次为 N 的订单数归属下单时点<b>持有该患者的医助</b>指派可在往月释放后不再归属继承指派会转移持有人但不计被指派数
@@ -55,7 +55,7 @@
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数往月指派当月成交会推高分子比率可能超过 100%医助当月无新指派但旗下有成交时被指派数为 0比率显示
</p>
<p>
医助按人事部门归组选定部门时含其组织下级
医助按人事部门归组<b>仅统计二中心及其组织下级</b>部门下拉与未选时的默认范围均限定在该子树选定部门时含其组织下级
</p>
</div>
</el-popover>
@@ -131,7 +131,7 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="row.name" />
<MedicineNameSelect v-model="row.name" v-model:medicine-id="row.medicine_id" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -321,7 +321,7 @@ const formData = reactive({
pulse: '',
pulse_condition: '',
clinical_diagnosis: '',
herbs: [] as Array<{ name: string; dosage: number }>,
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
dose_count: 7,
dose_unit: '剂',
usage_days: 7,
+45 -1
View File
@@ -83,6 +83,23 @@
<el-radio-button value="0">未确认</el-radio-button>
</el-radio-group>
</div>
<div class="filter-group">
<span class="filter-label">部门</span>
<el-tree-select
v-model="formData.assistant_dept_id"
:data="departmentTreeRaw"
class="filter-dept-select"
clearable
filterable
check-strictly
:default-expand-all="true"
node-key="id"
size="small"
:props="assistantDeptTreeProps"
placeholder="选父级含子级"
@change="handleAssistantDeptChange"
/>
</div>
</div>
<el-dialog
@@ -542,6 +559,7 @@
import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
import { deptAll } from '@/api/org/department'
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { addDoctorNote } from '@/api/patient'
@@ -603,10 +621,19 @@ const formData = reactive({
end_date: '',
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
diagnosis_confirmed: '' as '' | '0' | '1', // ''= 1= 0=
/** 接诊医生 / 诊单医助 / 挂号医助所属部门(选父级含子级) */
assistant_dept_id: '' as number | '',
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
include_status_counts: 0 as 0 | 1
})
const departmentTreeRaw = ref<unknown[]>([])
const assistantDeptTreeProps = {
value: 'id',
label: 'name',
children: 'children'
}
const activeTab = ref('1')
const dateCustomVisible = ref(false)
const statusCount = ref<Record<number, number>>({
@@ -721,6 +748,12 @@ const handleDiagnosisConfirmedChange = () => {
loadData()
}
//
const handleAssistantDeptChange = () => {
pager.page = 1
loadData()
}
//
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
const v = String(val || '')
@@ -770,6 +803,7 @@ const handleReset = () => {
formData.doctor_name = ''
formData.date_preset = 'today'
formData.diagnosis_confirmed = ''
formData.assistant_dept_id = ''
const t = new Date()
const p = (n: number) => String(n).padStart(2, '0')
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
@@ -1099,7 +1133,13 @@ formData.start_date = `${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${
formData.end_date = formData.start_date
formData.status = 1
onMounted(() => {
onMounted(async () => {
try {
const deptTree = await deptAll()
departmentTreeRaw.value = Array.isArray(deptTree) ? deptTree : []
} catch {
departmentTreeRaw.value = []
}
loadData()
listPollTimer = setInterval(() => {
loadData({ silent: true })
@@ -1228,6 +1268,10 @@ onUnmounted(() => {
padding: 6px 14px;
}
}
.filter-dept-select {
width: 200px;
}
}
}
+15 -1
View File
@@ -206,10 +206,20 @@ import isoWeek from 'dayjs/plugin/isoWeek'
import { getDoctors } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { getAvailableSlots, createAppointment, rosterLists, appointmentLists } from '@/api/doctor'
import { myPatientCreateAppointment } from '@/api/first_visit'
import feedback from '@/utils/feedback'
dayjs.extend(isoWeek)
const props = withDefaults(
defineProps<{
apiScene?: 'default' | 'my_patient'
}>(),
{
apiScene: 'default'
}
)
interface TimeSlot {
time: string
available: boolean
@@ -703,7 +713,11 @@ const handleConfirm = async () => {
console.log('提交预约参数:', params)
await createAppointment(params)
if (props.apiScene === 'my_patient') {
await myPatientCreateAppointment(params)
} else {
await createAppointment(params)
}
feedback.msgSuccess('预约成功')
visible.value = false
@@ -4,6 +4,43 @@
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
</div>
<template v-else>
<div v-if="diagnosisId > 0" class="po-revisit-offset-bar mb-3">
<div class="po-revisit-offset-bar__main">
<span class="text-sm text-gray-600">复诊统计起始偏移</span>
<el-tooltip placement="top">
<template #content>
<div class="max-w-xs leading-relaxed">
在实单诊次序号上叠加偏移量设为 0默认 1 笔实单计为一诊设为 1 1 笔实单计为二诊设为 2 1 笔实单计为三诊若有 5 笔实单且偏移 2则统计上相当于计至七诊5+2
</div>
</template>
<el-icon class="text-gray-400 align-middle ml-1"><QuestionFilled /></el-icon>
</el-tooltip>
<el-input-number
v-model="revisitSlotStartOffset"
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
:min="0"
:max="20"
:step="1"
controls-position="right"
class="w-[120px] ml-3"
:disabled="offsetSaving"
/>
<span class="text-xs text-gray-500 ml-2">
1 笔实单计为{{ visitSlotStartLabel }}
</span>
</div>
<el-button
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
type="primary"
size="small"
:loading="offsetSaving"
:disabled="!offsetDirty"
@click="saveRevisitSlotStartOffset"
>
保存
</el-button>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
@@ -12,6 +49,23 @@
empty-text="暂无业务订单"
>
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
<el-table-column label="全局诊次" width="96" align="center">
<template #default="{ row }">
<span v-if="row.global_visit_seq">{{ row.global_visit_seq }}</span>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="计入统计" width="96" align="center">
<template #default="{ row }">
<el-tag
v-if="row.counts_for_revisit_rate"
type="success"
size="small"
effect="plain"
></el-tag>
<el-tag v-else type="info" size="small" effect="plain"></el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
@@ -63,9 +117,11 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists } from '@/api/tcm'
import { prescriptionOrderLists, tcmDiagnosisDetail, tcmDiagnosisSetRevisitSlotStartOffset } from '@/api/tcm'
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
import feedback from '@/utils/feedback'
import {
formatTime,
fulfillmentText,
@@ -91,6 +147,26 @@ const { pager, getLists, resetPage } = usePaging({
size: 10
})
const revisitSlotStartOffset = ref(0)
const savedRevisitSlotStartOffset = ref(0)
const offsetSaving = ref(false)
const offsetLoading = ref(false)
const offsetDirty = computed(
() => Number(revisitSlotStartOffset.value) !== Number(savedRevisitSlotStartOffset.value)
)
const visitSlotStartLabel = computed(() => {
const raw = Number(revisitSlotStartOffset.value)
const offset = Number.isFinite(raw) ? raw : 0
const slot = offset + 1
const cn = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
if (slot >= 1 && slot <= 10) {
return cn[slot] + '诊'
}
return `${slot}`
})
const buildParams = () => {
Object.keys(queryParams).forEach((k) => delete queryParams[k])
if (props.diagnosisId > 0) {
@@ -102,6 +178,47 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit'
}
async function loadRevisitSlotStartOffset() {
if (props.diagnosisId <= 0) return
offsetLoading.value = true
try {
const res: any = await tcmDiagnosisDetail({ id: props.diagnosisId })
const d = res?.data ?? res ?? {}
const offset = Number(d.revisit_slot_start_offset)
const val = Number.isFinite(offset) && offset >= 0 && offset <= 20 ? offset : 0
revisitSlotStartOffset.value = val
savedRevisitSlotStartOffset.value = val
} catch {
revisitSlotStartOffset.value = 0
savedRevisitSlotStartOffset.value = 0
} finally {
offsetLoading.value = false
}
}
async function saveRevisitSlotStartOffset() {
if (props.diagnosisId <= 0) return
const offset = Number(revisitSlotStartOffset.value)
if (!Number.isFinite(offset) || offset < 0 || offset > 20) {
feedback.msgWarning('起始偏移须在 020 之间')
return
}
offsetSaving.value = true
try {
await tcmDiagnosisSetRevisitSlotStartOffset({
id: props.diagnosisId,
revisit_slot_start_offset: offset
})
savedRevisitSlotStartOffset.value = offset
feedback.msgSuccess('保存成功')
getLists()
} catch {
/* 拦截器已提示 */
} finally {
offsetSaving.value = false
}
}
// /
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
@@ -117,8 +234,13 @@ const formatAmount = (value: unknown) => {
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return }
if (!patientIdAvailable.value) {
pager.lists = []
pager.count = 0
return
}
buildParams()
void loadRevisitSlotStartOffset()
resetPage()
},
{ immediate: true }
@@ -134,4 +256,21 @@ defineExpose({ refresh: () => getLists() })
.po-empty-tip {
padding: 24px 0;
}
.po-revisit-offset-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.po-revisit-offset-bar__main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
</style>
File diff suppressed because one or more lines are too long
+17 -1
View File
@@ -1,5 +1,21 @@
APP_DEBUG = true
# 恩济药房 ERP:必须写在第一个 INI 分区之前
# 在 ej 后台创建商户后,将一次性返回的 app_key、app_secret、webhook_secret 填入下方
EJ_PHARMACY_ENABLED = false
EJ_PHARMACY_CATALOG_SYNC_ENABLED = false
EJ_PHARMACY_BASE_URL = "https://ej.example.com"
EJ_PHARMACY_APP_KEY = ""
EJ_PHARMACY_APP_SECRET = ""
EJ_PHARMACY_CALLBACK_SECRET = ""
# autoNSS cURL 自动使用 PHP OpenSSL;也可显式填写 openssl 或 curl。
EJ_PHARMACY_HTTP_TRANSPORT = "auto"
# CentOS/RHEL 7 可填写 /etc/pki/tls/certs/ca-bundle.crt;留空使用 PHP 默认 CA。
EJ_PHARMACY_CA_FILE = ""
EJ_PHARMACY_SUBMISSION_LEASE_SECONDS = 300
EJ_PHARMACY_CONNECT_TIMEOUT = 5
EJ_PHARMACY_REQUEST_TIMEOUT = 30
[APP]
DEFAULT_TIMEZONE = "Asia/Shanghai"
@@ -64,4 +80,4 @@ LOGISTICS_KUAIDI100_KEY =
; GANCAO_SCM_CALLBACK_URL = https://你的公网域名/gancao/recipel-notify
; GANCAO_SCM_CRADLE_STORE = 甄养堂互联网医院
; GANCAO_SCM_DF_ID = 101
; GANCAO_SCM_EXPRESS_TYPE = general
; GANCAO_SCM_EXPRESS_TYPE = general
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\firstvisit;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
class ConversionController extends BaseAdminController
{
private const PAGE_PERMISSION = 'firstvisit.conversion/overview';
public function overview()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法查看综合数据转化');
}
@set_time_limit(120);
return $this->data(FirstVisitConversionLogic::overview(
$this->request->get(),
$this->adminId,
$this->adminInfo
));
}
private function hasPagePermission(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\firstvisit;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\FirstVisitDoctorDashboardLogic;
class DoctorDashboardController extends BaseAdminController
{
private const PAGE_PERMISSION = 'firstvisit.doctorDashboard/overview';
public function overview()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法查看医生看板');
}
@set_time_limit(120);
return $this->data(FirstVisitDoctorDashboardLogic::overview(
$this->request->get(),
$this->adminId,
$this->adminInfo
));
}
private function hasPagePermission(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
}
@@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\firstvisit;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\firstvisit\MyPatientLists;
use app\adminapi\lists\firstvisit\MyPatientOrderLists;
use app\adminapi\lists\firstvisit\MyPatientProgressLists;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\adminapi\validate\doctor\AppointmentValidate;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\PrescriptionOrder;
class MyPatientController extends BaseAdminController
{
private const LIST_PERMISSION = 'firstvisit.myPatient/lists';
private string $orderGuardError = '订单不存在或无权操作';
public function lists()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法访问我的患者');
}
return $this->dataLists(new MyPatientLists());
}
/** 当前角色/部门患者范围内的处方业务订单。 */
public function orders()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法查看患者订单');
}
return $this->dataLists(new MyPatientOrderLists());
}
/** 当前角色/部门患者范围内的挂号面诊进度。 */
public function progress()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法查看面诊进度');
}
return $this->dataLists(new MyPatientProgressLists());
}
/** 当前账号数据范围内可被指派的医助。 */
public function assistants()
{
if (!$this->hasPagePermission() || !$this->hasOriginalPermission('tcm.diagnosis/assign')) {
return $this->fail('权限不足,无法获取医助列表');
}
return $this->data(DiagnosisLogic::getAssistants($this->adminId, $this->adminInfo));
}
/** 从“我的患者”指派医助,先校验患者行级数据范围和目标医助范围。 */
public function assign()
{
if (!$this->hasPagePermission() || !$this->hasOriginalPermission('tcm.diagnosis/assign')) {
return $this->fail('权限不足,无法指派患者');
}
$params = $this->request->post();
$diagnosisId = (int) ($params['id'] ?? 0);
$assistantId = (int) ($params['assistant_id'] ?? 0);
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $this->adminId, $this->adminInfo)) {
return $this->fail('患者不存在或无权操作');
}
if ($assistantId <= 0 || !$this->canAssignToAssistant($assistantId)) {
return $this->fail('所选医助不在当前可指派范围内');
}
$result = DiagnosisLogic::assign([
'id' => $diagnosisId,
'assistant_id' => $assistantId,
'is_inherit' => (int) ($params['is_inherit'] ?? 0) === 1 ? 1 : 0,
]);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('指派成功');
}
/** 从“我的患者”补全身份证,复用诊单身份证校验和年龄计算。 */
public function fillIdCard()
{
if (!$this->hasPagePermission() || !$this->hasOriginalPermission('tcm.diagnosis/edit')) {
return $this->fail('权限不足,无法补全身份证');
}
$params = (new DiagnosisValidate())->post()->goCheck('fillIdCard');
$diagnosisId = (int) ($params['id'] ?? 0);
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $this->adminId, $this->adminInfo)) {
return $this->fail('患者不存在或无权操作');
}
$duplicate = DiagnosisLogic::checkIdCard([
'id' => $diagnosisId,
'id_card' => trim((string) ($params['id_card'] ?? '')),
]);
if (!empty($duplicate['exists'])) {
return $this->fail((string) ($duplicate['message'] ?? '该身份证号已存在'));
}
$result = DiagnosisLogic::fillIdCard($params);
if ($result === false) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('补全成功,年龄已自动更新');
}
/** 当前患者范围内的订单详情;仍要求原订单详情权限。 */
public function orderDetail()
{
$params = (new PrescriptionOrderValidate())->get()->goCheck('detail');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/detail') === null) {
return $this->fail($this->orderGuardError);
}
$detail = PrescriptionOrderLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
if ($detail === null) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->data($detail);
}
/** 编辑当前患者范围内的订单,参数只允许原编辑表单支持的字段。 */
public function orderEdit()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('edit');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/edit') === null) {
return $this->fail($this->orderGuardError);
}
if ((float) ($params['amount'] ?? 0) < 0) {
return $this->fail('订单金额不能为负数');
}
$params = $this->onlyParams($params, [
'id', 'recipient_name', 'recipient_phone', 'shipping_province', 'shipping_city',
'shipping_district', 'shipping_address', 'is_follow_up', 'medication_days',
'dose_unit', 'dose_count', 'prev_staff', 'service_channel', 'service_package',
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra',
'remark_assistant', 'pay_order_ids', 'internal_cost',
]);
$result = PrescriptionOrderLogic::edit($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('保存成功', $result);
}
public function orderAuditPrescription()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPrescription') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::auditPrescription(
(int) $params['id'],
(string) $params['action'],
(string) ($params['remark'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('操作成功', $result);
}
public function orderRevokeRxAudit()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPrescription') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::revokeRxAudit((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('处方审核已撤回', $result);
}
public function orderAuditPayment()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPayment');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPayment') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::auditPaymentSlip(
(int) $params['id'],
(string) $params['action'],
(string) ($params['remark'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('操作成功', $result);
}
public function orderRevokePayAudit()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('detail');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/auditPayment') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::revokePayAudit((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('支付单审核已撤回', $result);
}
public function orderDdcode()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('ddcode');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/ddcode') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::ddcode(
(int) $params['id'],
(string) ($params['express_company'] ?? 'auto'),
(string) $params['tracking_number'],
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('快递单号已保存', $result);
}
public function orderShip()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('ship');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/ship') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::ship(
(int) $params['id'],
(string) ($params['express_company'] ?? 'auto'),
(string) ($params['tracking_number'] ?? ''),
(string) ($params['ship_mode'] ?? 'gancao'),
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('发货成功', $result);
}
public function orderAddPayOrder()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('addPayOrder');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/addPayOrder') === null) {
return $this->fail($this->orderGuardError);
}
$params = $this->onlyParams($params, [
'id', 'order_type', 'pay_amount', 'pay_remark', 'completion_request', 'pay_create_type',
]);
$result = PrescriptionOrderLogic::addPayOrder($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('新增支付单成功', $result);
}
public function orderComplete()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('complete');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/complete') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::complete(
(int) $params['id'],
$this->adminId,
$this->adminInfo,
(int) $params['fulfillment_status']
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('操作成功', $result);
}
public function orderRefund()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('refund');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/refund') === null) {
return $this->fail($this->orderGuardError);
}
$rawRefundAmount = $params['refund_amount'] ?? null;
$refundAmount = ($rawRefundAmount === null || $rawRefundAmount === '')
? null
: round((float) $rawRefundAmount, 2);
$result = PrescriptionOrderLogic::refund(
(int) $params['id'],
(string) ($params['reason'] ?? ''),
$this->adminId,
$this->adminInfo,
$refundAmount
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('退款成功', $result);
}
public function orderWithdraw()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('withdraw');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/withdraw') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::withdraw((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('已撤回', $result);
}
public function orderUploadToPharmacy()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('uploadToPharmacy');
if ($this->guardOrder((int) $params['id'], 'tcm.prescriptionOrder/uploadToPharmacy') === null) {
return $this->fail($this->orderGuardError);
}
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('药方上传成功', $result);
}
/** 从“我的患者”页面创建挂号,写操作复用原逻辑但先做患者行级校验。 */
public function createAppointment()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法创建挂号');
}
$params = (new AppointmentValidate())->post()->goCheck('create');
$diagnosisId = (int) ($params['patient_id'] ?? 0);
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $this->adminId, $this->adminInfo)) {
return $this->fail('患者不存在或无权操作');
}
$params['assistant_id'] = $this->adminId;
$result = AppointmentLogic::create($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AppointmentLogic::getError());
}
return $this->success('挂号成功', $result);
}
/** 从“我的患者”页面取消挂号,按挂号所属诊单再次校验数据范围。 */
public function cancelAppointment()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法取消挂号');
}
$params = (new AppointmentValidate())->post()->goCheck('cancel');
$appointment = Appointment::findOrEmpty((int) ($params['id'] ?? 0));
if ($appointment->isEmpty()) {
return $this->fail('挂号记录不存在');
}
if (!MyPatientLogic::canAccessDiagnosis((int) $appointment->patient_id, $this->adminId, $this->adminInfo)) {
return $this->fail('患者不存在或无权操作');
}
$result = AppointmentLogic::cancel($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AppointmentLogic::getError());
}
return $this->success('取消挂号成功');
}
private function hasPagePermission(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array(self::LIST_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
private function hasOriginalPermission(string $permission): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array($permission, AuthLogic::getAuthByAdminId($this->adminId), true);
}
private function guardOrder(int $orderId, string $permission): ?PrescriptionOrder
{
$this->orderGuardError = '订单不存在或无权操作';
if (!$this->hasPagePermission() || !$this->hasOriginalPermission($permission) || $orderId <= 0) {
return null;
}
$order = PrescriptionOrder::where('id', $orderId)->whereNull('delete_time')->find();
if ($order === null) {
return null;
}
if (!MyPatientLogic::canAccessDiagnosis((int) $order->diagnosis_id, $this->adminId, $this->adminInfo)) {
return null;
}
return $order;
}
private function canAssignToAssistant(int $assistantId): bool
{
foreach (DiagnosisLogic::getAssistants($this->adminId, $this->adminInfo) as $assistant) {
if ((int) ($assistant['id'] ?? 0) === $assistantId) {
return true;
}
}
return false;
}
/** @param array<string,mixed> $params @param array<int,string> $keys */
private function onlyParams(array $params, array $keys): array
{
return array_intersect_key($params, array_flip($keys));
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\firstvisit;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\FirstVisitRegistrationStatsLogic;
class RegistrationStatsController extends BaseAdminController
{
private const PAGE_PERMISSION = 'firstvisit.registrationStats/overview';
public function overview()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法查看挂号统计');
}
@set_time_limit(120);
return $this->data(FirstVisitRegistrationStatsLogic::overview(
$this->request->get(),
$this->adminId,
$this->adminInfo
));
}
private function hasPagePermission(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
}
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\firstvisit;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
class WecomPromotionController extends BaseAdminController
{
private const PAGE_PERMISSION = 'firstvisit.wecomPromotion/overview';
public function overview()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足,无法访问企业微信获客助手');
}
return $this->run(fn () => $this->data(WecomPromotionLogic::overview(
$this->adminId,
$this->adminInfo,
$this->request->domain()
)));
}
public function savePool()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('分流方案已保存', WecomPromotionLogic::savePool(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function deletePool()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
return $this->run(function () use ($id) {
WecomPromotionLogic::deletePool($id, $this->adminId, $this->adminInfo);
return $this->success('分流方案已删除');
});
}
public function saveLink()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('获客助手链接已保存', WecomPromotionLogic::saveLink(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function checkApiPermission()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('获客助手 API 权限验证通过', WecomPromotionLogic::checkApiPermission()));
}
public function syncRemoteLinks()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$poolId = (int) $this->request->post('pool_id', 0);
return $this->run(fn () => $this->success('企业微信获客链接同步完成', WecomPromotionLogic::syncRemoteLinks(
$poolId,
$this->adminId,
$this->adminInfo
)));
}
public function remoteLinkDetail()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->get('id', 0);
return $this->run(fn () => $this->data(WecomPromotionLogic::remoteLinkDetail(
$id,
$this->adminId,
$this->adminInfo
)));
}
public function deleteRemoteLink()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
return $this->run(function () use ($id) {
WecomPromotionLogic::deleteRemoteLink($id, $this->adminId, $this->adminInfo);
return $this->success('企业微信获客链接已永久删除,本地审计记录已保留');
});
}
public function syncCustomers()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('获客客户同步完成', WecomAcquisitionCustomerLogic::sync(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function customerStatistics()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->data(WecomAcquisitionCustomerLogic::statistics(
$this->request->get(),
$this->adminId,
$this->adminInfo
)));
}
public function toggleLink()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
$status = (int) $this->request->post('status', 0);
return $this->run(function () use ($id, $status) {
WecomPromotionLogic::toggleLink($id, $status, $this->adminId, $this->adminInfo);
return $this->success('状态已更新');
});
}
public function deleteLink()
{
if (!$this->hasPagePermission()) {
return $this->fail('权限不足');
}
$id = (int) $this->request->post('id', 0);
return $this->run(function () use ($id) {
WecomPromotionLogic::deleteLink($id, $this->adminId, $this->adminInfo);
return $this->success('获客助手链接已删除');
});
}
private function run(callable $callback)
{
try {
return $callback();
} catch (\Throwable $e) {
return $this->fail($e->getMessage());
}
}
private function hasPagePermission(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array(self::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\pharmacy;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\pharmacy\MedicineMappingLists;
use app\adminapi\logic\pharmacy\MedicineMappingLogic;
use app\adminapi\validate\pharmacy\MedicineMappingValidate;
class MedicineMappingController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new MedicineMappingLists());
}
public function status()
{
return $this->data(MedicineMappingLogic::status());
}
public function catalogOptions()
{
return $this->data(MedicineMappingLogic::catalogOptions(
(string) $this->request->get('keyword', ''),
(int) $this->request->get('limit', 30)
));
}
public function save()
{
$params = (new MedicineMappingValidate())->post()->goCheck('save');
$name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? '');
if (!MedicineMappingLogic::save($params, $this->adminId, $name)) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('映射已保存', [], 1, 1);
}
public function unlink()
{
$params = (new MedicineMappingValidate())->post()->goCheck('unlink');
$name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? '');
if (!MedicineMappingLogic::unlink((int) $params['local_medicine_id'], $this->adminId, $name)) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('映射已解除', [], 1, 1);
}
public function sync()
{
$result = MedicineMappingLogic::sync();
if ($result === false) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('目录同步完成', $result);
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\stats\AutoAssignLogLists;
use app\adminapi\logic\stats\AutoAssignLogLogic;
/**
* 待分配诊单自动指派日志
*
* - GET stats.autoAssignLog/lists 日志列表
* - POST stats.autoAssignLog/rollback 批量回退已自动分配的医助
*/
class AutoAssignLogController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new AutoAssignLogLists());
}
public function rollback()
{
$ids = $this->request->post('ids', []);
if (!is_array($ids)) {
$ids = [$ids];
}
$result = AutoAssignLogLogic::rollback($ids, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AutoAssignLogLogic::getError());
}
$msg = sprintf('回退成功 %d 条', (int) ($result['success'] ?? 0));
$failed = (int) ($result['failed'] ?? 0);
if ($failed > 0) {
$msg .= sprintf(',跳过/失败 %d 条', $failed);
}
// show=0:由前端按 success/failed 明细提示,避免与部分失败警告重复
return $this->success($msg, $result, 1, 0);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\PerformanceDashboardLogic;
/**
* 角色数据驾驶舱。
*
* 所有数据在服务端按当前管理员的数据范围聚合,前端不参与权限裁剪。
*/
class PerformanceDashboardController extends BaseAdminController
{
public function overview()
{
@set_time_limit(120);
$response = $this->data(PerformanceDashboardLogic::overview(
$this->adminId,
$this->adminInfo,
$this->request->get()
));
// 驾驶舱包含分钟级实时数据,禁止浏览器和中间代理缓存旧统计结果。
return $response->header([
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
}
}
@@ -94,6 +94,24 @@ class DiagnosisController extends BaseAdminController
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 设置复诊接诊率统计起始偏移(业务订单 tab
*/
public function setRevisitSlotStartOffset()
{
$params = (new DiagnosisValidate())->post()->goCheck('setRevisitSlotStartOffset');
$ok = DiagnosisLogic::setRevisitSlotStartOffset(
(int) $params['id'],
(int) $params['revisit_slot_start_offset'],
$this->adminInfo
);
if (!$ok) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('保存成功');
}
/**
* @notes 删除诊单
* @return \think\response\Json
@@ -131,6 +131,26 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('保存成功', $result);
}
/**
* 仅修改承运商与快递单号,不受订单履约状态或远端药房快照锁限制。
*/
public function ddcode()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('ddcode');
$result = PrescriptionOrderLogic::ddcode(
(int) $params['id'],
(string) ($params['express_company'] ?? 'auto'),
(string) $params['tracking_number'],
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('快递单号已保存', $result);
}
public function updateAmount()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('updateAmount');
@@ -161,6 +181,27 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('已保存', $result);
}
/**
* 人工核对甘草不确定提交:确认远端成功或确认未创建。
*/
public function confirmGancaoSubmission()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('confirmGancaoSubmission');
$result = PrescriptionOrderLogic::confirmGancaoSubmission(
(int) $params['id'],
(string) $params['resolution'],
(string) ($params['remote_order_no'] ?? ''),
(string) $params['note'],
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('甘草提交核对已记录', $result);
}
/**
* 修改关联处方的患者姓名与手机号(订单详情场景)
*/
@@ -181,6 +222,20 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('保存成功');
}
/**
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
*/
public function patchPrescriptionUsage()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('patchPrescriptionUsage');
$ok = PrescriptionOrderLogic::patchPrescriptionUsage($params, $this->adminId, $this->adminInfo);
if (!$ok) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('保存成功');
}
public function auditPrescription()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
@@ -437,7 +492,7 @@ class PrescriptionOrderController extends BaseAdminController
{
try {
$params = (new PrescriptionOrderValidate())->post()->goCheck('submitGancaoRecipel');
$result = PrescriptionOrderLogic::submitGancaoRecipel((int) $params['id'], $this->adminId, $this->adminInfo);
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
$error = PrescriptionOrderLogic::getError();
@@ -451,7 +506,7 @@ class PrescriptionOrderController extends BaseAdminController
return $this->fail('返回数据格式错误');
}
return $this->success('甘草药方上传成功', $result);
return $this->success('药方上传成功', $result);
} catch (\Throwable $e) {
\think\facade\Log::error('submitGancaoRecipel exception', [
'message' => $e->getMessage(),
@@ -463,6 +518,19 @@ class PrescriptionOrderController extends BaseAdminController
}
}
/**
* Unified pharmacy upload. The order ship_mode decides the target.
*/
public function uploadToPharmacy()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('uploadToPharmacy');
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('药方上传成功', $result);
}
/**
* 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单)
* 用于在编辑订单时测试价格和配置
@@ -17,6 +17,7 @@ declare (strict_types=1);
namespace app\adminapi\http\middleware;
use app\adminapi\logic\LoginLogic;
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
use app\common\{
cache\AdminAuthCache,
service\JsonService
@@ -74,7 +75,8 @@ class AuthMiddleware
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri)) {
if (!in_array($accessUri, $allUri, true)
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
return $next($request);
}
@@ -109,6 +111,10 @@ class AuthMiddleware
*/
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
{
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
return PharmacyUploadPermissionAlias::allows($accessUri, $adminUris);
}
if (in_array('tcm.diagnosis/dailyrecord', $adminUris, true)
&& in_array($accessUri, [
'tcm.diagnosistodo/lists',
@@ -3,6 +3,8 @@
namespace app\adminapi\lists\doctor;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Prescription;
@@ -75,6 +77,35 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
});
}
/**
* 按部门筛选:接诊医生、诊单医助或挂号医助所属部门命中子树即可(选父级含子级)
*
* @param mixed $query
*/
private function applyAssistantDeptIdFilter($query): void
{
if (!isset($this->params['assistant_dept_id']) || $this->params['assistant_dept_id'] === '' || (int) $this->params['assistant_dept_id'] <= 0) {
return;
}
$rootDeptId = (int) $this->params['assistant_dept_id'];
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
return $id > 0;
}));
if ($deptIds === []) {
$query->whereRaw('0 = 1');
return;
}
$inList = implode(',', $deptIds);
$adTbl = (new AdminDept())->getTable();
$query->whereRaw(
"(EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`doctor_id` AND ad.`dept_id` IN ({$inList}))"
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = u.`assistant_id` AND ad.`dept_id` IN ({$inList}))"
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`assistant_id` AND ad.`dept_id` IN ({$inList})))"
);
}
/**
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
*
@@ -191,6 +222,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyAssistantIdFilter($query);
$this->applyAssistantDeptIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter);
// 是否确认诊单:1=已确认 0=未确认
@@ -373,6 +406,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyAssistantIdFilter($query);
$this->applyAssistantDeptIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter);
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
@@ -0,0 +1,372 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\firstvisit;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\DiagnosisViewRecord;
use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Diagnosis;
use think\db\Query;
use think\facade\Db;
class MyPatientLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
public function setSearch(): array
{
return [];
}
public function lists(): array
{
$query = $this->buildQuery(true, true);
$appointmentTable = (new Appointment())->getTable();
$today = date('Y-m-d');
$upcomingSql = "SELECT MIN(CONCAT(sort_apt.appointment_date, ' ', IFNULL(NULLIF(TRIM(sort_apt.appointment_time), ''), '00:00:00')))"
. " FROM {$appointmentTable} sort_apt"
. ' WHERE sort_apt.patient_id = d.id'
. ' AND sort_apt.status IN (1,4)'
. " AND sort_apt.appointment_date >= '{$today}'";
$rows = $query
->field([
'd.id', 'd.patient_id', 'd.patient_name', 'd.phone', 'd.id_card', 'd.gender', 'd.age',
'd.diagnosis_date', 'd.diagnosis_type', 'd.syndrome_type', 'd.assistant_id',
'd.assign_read_at', 'd.create_time',
])
->orderRaw("CASE WHEN ({$upcomingSql}) IS NULL THEN 1 ELSE 0 END ASC")
->orderRaw("IFNULL(({$upcomingSql}), '9999-12-31 23:59:59') ASC")
->order('d.id', 'desc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
return $this->appendRelations($rows);
}
public function count(): int
{
return (int) $this->buildQuery(true, true)->count('d.id');
}
public function extend(): array
{
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$dayAfter = date('Y-m-d', strtotime('+2 days'));
return [
'summary' => [
'today' => $this->countByAppointmentDate($today),
'tomorrow' => $this->countByAppointmentDate($tomorrow),
'day_after' => $this->countByAppointmentDate($dayAfter),
],
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
'dates' => [
'today' => $today,
'tomorrow' => $tomorrow,
'day_after' => $dayAfter,
],
];
}
private function buildQuery(bool $applyStatusFilter, bool $applyDateFilter): Query
{
$diagnosisTable = (new Diagnosis())->getTable();
$query = Db::table($diagnosisTable)
->alias('d')
->whereNull('d.delete_time')
->where('d.status', 1);
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
$this->applyKeyword($query);
$statusFilter = $applyStatusFilter ? trim((string) ($this->params['status_filter'] ?? '')) : '';
$appointmentTable = (new Appointment())->getTable();
if ($statusFilter === 'unbooked') {
$query->whereNotExists(
"SELECT 1 FROM {$appointmentTable} unbooked_apt"
. ' WHERE unbooked_apt.patient_id = d.id'
. ' AND unbooked_apt.status IN (' . implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES) . ')'
);
}
$appointmentStatuses = self::EFFECTIVE_APPOINTMENT_STATUSES;
if (in_array($statusFilter, ['pending_interview', 'booked'], true)) {
$appointmentStatuses = [1];
} elseif ($statusFilter === 'completed') {
$appointmentStatuses = [3];
} elseif ($statusFilter === 'missed') {
$appointmentStatuses = [4];
}
$needsAppointmentFilter = in_array(
$statusFilter,
['pending_interview', 'booked', 'completed', 'missed'],
true
);
[$startDate, $endDate] = $applyDateFilter ? $this->dateRange() : ['', ''];
if ($startDate !== '' || $endDate !== '') {
$needsAppointmentFilter = true;
}
if ($needsAppointmentFilter) {
$conditions = [
'filter_apt.patient_id = d.id',
'filter_apt.status IN (' . implode(',', $appointmentStatuses) . ')',
];
if ($startDate !== '') {
$conditions[] = "filter_apt.appointment_date >= '{$startDate}'";
}
if ($endDate !== '') {
$conditions[] = "filter_apt.appointment_date <= '{$endDate}'";
}
$query->whereExists(
"SELECT 1 FROM {$appointmentTable} filter_apt WHERE " . implode(' AND ', $conditions)
);
}
return $query;
}
private function applyKeyword(Query $query): void
{
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword === '') {
return;
}
$needle = addslashes($keyword);
$adminTable = (new Admin())->getTable();
$appointmentTable = (new Appointment())->getTable();
$query->whereRaw(
"(d.patient_name LIKE '%{$needle}%'"
. " OR d.phone LIKE '%{$needle}%'"
. " OR EXISTS (SELECT 1 FROM {$adminTable} assistant_admin"
. ' WHERE assistant_admin.id = CAST(d.assistant_id AS UNSIGNED)'
. ' AND assistant_admin.delete_time IS NULL'
. " AND assistant_admin.name LIKE '%{$needle}%')"
. " OR EXISTS (SELECT 1 FROM {$appointmentTable} keyword_apt"
. " INNER JOIN {$adminTable} doctor_admin ON doctor_admin.id = keyword_apt.doctor_id"
. ' AND doctor_admin.delete_time IS NULL'
. ' WHERE keyword_apt.patient_id = d.id'
. ' AND keyword_apt.status IN (1,3,4)'
. " AND doctor_admin.name LIKE '%{$needle}%'))"
);
}
/** @return array{0:string,1:string} */
private function dateRange(): array
{
$startDate = $this->normalizeDate($this->params['start_date'] ?? '');
$endDate = $this->normalizeDate($this->params['end_date'] ?? '');
if ($startDate === '' && $endDate !== '') {
$startDate = $endDate;
}
if ($endDate === '' && $startDate !== '') {
$endDate = $startDate;
}
if ($startDate !== '' && $endDate !== '' && $startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
return [$startDate, $endDate];
}
private function normalizeDate($value): string
{
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
}
private function countByAppointmentDate(string $date): int
{
$query = $this->buildQuery(false, false);
$appointmentTable = (new Appointment())->getTable();
$query->whereExists(
"SELECT 1 FROM {$appointmentTable} summary_apt"
. ' WHERE summary_apt.patient_id = d.id'
. " AND summary_apt.appointment_date = '{$date}'"
. ' AND summary_apt.status IN (1,3,4)'
);
return (int) $query->count('d.id');
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function appendRelations(array $rows): array
{
if ($rows === []) {
return [];
}
$diagnosisIds = array_values(array_unique(array_map('intval', array_column($rows, 'id'))));
$assistantIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'assistant_id')))));
$appointmentTable = (new Appointment())->getTable();
$appointments = Db::table($appointmentTable)
->whereIn('patient_id', $diagnosisIds)
->whereIn('status', self::EFFECTIVE_APPOINTMENT_STATUSES)
->field(['id', 'patient_id', 'doctor_id', 'appointment_date', 'appointment_time', 'status'])
->order('appointment_date', 'asc')
->order('appointment_time', 'asc')
->order('id', 'asc')
->select()
->toArray();
$doctorIds = array_values(array_unique(array_filter(array_map('intval', array_column($appointments, 'doctor_id')))));
$adminIds = array_values(array_unique(array_merge($assistantIds, $doctorIds)));
$adminNames = $adminIds === [] ? [] : Admin::whereIn('id', $adminIds)->whereNull('delete_time')->column('name', 'id');
$appointmentMap = [];
foreach ($appointments as $appointment) {
$diagnosisId = (int) ($appointment['patient_id'] ?? 0);
if ($diagnosisId > 0) {
$appointmentMap[$diagnosisId][] = $appointment;
}
}
$viewTable = (new DiagnosisViewRecord())->getTable();
$confirmedIds = Db::table($viewTable)
->whereIn('diagnosis_id', $diagnosisIds)
->where('is_confirmed', 1)
->whereNull('delete_time')
->column('diagnosis_id');
$confirmedSet = array_fill_keys(array_map('intval', $confirmedIds), true);
[$rangeStart, $rangeEnd] = $this->dateRange();
$today = date('Y-m-d');
$statusFilter = trim((string) ($this->params['status_filter'] ?? ''));
$preferredStatuses = [
'pending_interview' => [1],
'booked' => [1],
'completed' => [3],
'missed' => [4],
][$statusFilter] ?? [];
foreach ($rows as &$row) {
$diagnosisId = (int) $row['id'];
$rowAppointments = $appointmentMap[$diagnosisId] ?? [];
$primary = $this->pickPrimaryAppointment(
$rowAppointments,
$rangeStart,
$rangeEnd,
$today,
$preferredStatuses
);
$completedCount = count(array_filter($rowAppointments, static function (array $appointment): bool {
return (int) ($appointment['status'] ?? 0) === 3;
}));
$assistantId = (int) ($row['assistant_id'] ?? 0);
$row['diagnosis_id'] = $diagnosisId;
$row['source_patient_id'] = (int) ($row['patient_id'] ?? 0);
$row['phone_masked'] = $this->maskPhone((string) ($row['phone'] ?? ''));
unset($row['phone']);
$row['has_id_card'] = trim((string) ($row['id_card'] ?? '')) !== '' ? 1 : 0;
unset($row['id_card']);
$row['gender_desc'] = (int) ($row['gender'] ?? 0) === 1 ? '男' : '女';
$row['diagnosis_date_text'] = $this->formatDiagnosisDate($row['diagnosis_date'] ?? '');
$row['assistant_name'] = (string) ($adminNames[$assistantId] ?? '未分配');
$row['confirmed'] = isset($confirmedSet[$diagnosisId]) ? 1 : 0;
$row['confirmation_text'] = $row['confirmed'] ? '已确认' : '待确认';
$row['visit_count'] = $completedCount;
$row['revisit_count'] = max(0, $completedCount - 1);
$row['appointment_id'] = $primary ? (int) $primary['id'] : 0;
$row['appointment_status'] = $primary ? (int) $primary['status'] : 0;
$row['appointment_status_text'] = $this->appointmentStatusText((int) ($primary['status'] ?? 0));
$row['appointment_doctor_id'] = $primary ? (int) $primary['doctor_id'] : 0;
$row['appointment_doctor_name'] = $primary
? (string) ($adminNames[(int) $primary['doctor_id']] ?? '未知医生')
: '未预约';
$row['appointment_time_text'] = $primary ? $this->appointmentTimeText($primary) : '';
$row['has_appointment'] = $primary !== null ? 1 : 0;
}
unset($row);
return $rows;
}
/**
* @param array<int, array<string, mixed>> $appointments
* @return array<string, mixed>|null
*/
private function pickPrimaryAppointment(
array $appointments,
string $rangeStart,
string $rangeEnd,
string $today,
array $preferredStatuses = []
): ?array
{
if ($appointments === []) {
return null;
}
$candidates = $appointments;
if ($preferredStatuses !== []) {
$candidates = array_values(array_filter($candidates, static function (array $appointment) use ($preferredStatuses): bool {
return in_array((int) ($appointment['status'] ?? 0), $preferredStatuses, true);
}));
}
if ($rangeStart !== '' || $rangeEnd !== '') {
$candidates = array_values(array_filter($candidates, static function (array $appointment) use ($rangeStart, $rangeEnd): bool {
$date = (string) ($appointment['appointment_date'] ?? '');
return ($rangeStart === '' || $date >= $rangeStart) && ($rangeEnd === '' || $date <= $rangeEnd);
}));
}
if ($candidates === []) {
$candidates = $appointments;
}
foreach ($candidates as $appointment) {
$status = (int) ($appointment['status'] ?? 0);
$date = (string) ($appointment['appointment_date'] ?? '');
if (in_array($status, [1, 4], true) && $date >= $today) {
return $appointment;
}
}
return $candidates[count($candidates) - 1] ?? null;
}
private function maskPhone(string $phone): string
{
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
}
private function formatDiagnosisDate($value): string
{
if (is_numeric($value)) {
return (int) $value > 0 ? date('Y-m-d', (int) $value) : '';
}
$text = trim((string) $value);
return $text === '' ? '' : substr($text, 0, 10);
}
private function appointmentTimeText(array $appointment): string
{
$time = trim((string) ($appointment['appointment_time'] ?? ''));
if (strlen($time) > 5) {
$time = substr($time, 0, 5);
}
return trim((string) ($appointment['appointment_date'] ?? '') . ' ' . $time);
}
private function appointmentStatusText(int $status): string
{
return [1 => '待面诊', 3 => '已完成', 4 => '已过号'][$status] ?? '未预约';
}
}
@@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\firstvisit;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\Order;
use app\common\model\auth\Admin;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use think\db\Query;
use think\facade\Db;
/**
* “我的患者”内嵌订单列表。
*
* 订单可见性始终锚定 diagnosis 别名 d,并复用 MyPatientLogic;订单创建人仅用于展示,
* 不能作为患者归属或数据范围条件。
*/
class MyPatientOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
public function setSearch(): array
{
return [];
}
public function lists(): array
{
$rows = $this->buildQuery()
->field([
'po.id', 'po.order_no', 'po.prescription_id', 'po.diagnosis_id', 'po.creator_id',
'po.recipient_name', 'po.recipient_phone', 'po.fee_type', 'po.amount',
'po.prescription_audit_status', 'po.payment_slip_audit_status',
'po.fulfillment_status', 'po.express_company', 'po.tracking_number', 'po.ship_mode',
'po.gancao_reciperl_order_no', 'po.ej_pharmacy_order_no',
'po.gancao_submit_time', 'po.ej_pharmacy_submit_time',
'po.ej_pharmacy_status', 'po.ej_pharmacy_review_status', 'po.refund_amount',
'po.create_time',
'd.patient_name', 'd.phone AS patient_phone', 'd.assistant_id',
])
->order('po.id', 'desc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
return $this->appendRelations($rows);
}
public function count(): int
{
return (int) $this->buildQuery()->count('po.id');
}
public function extend(): array
{
$query = $this->buildQuery();
$pendingQuery = clone $query;
$effectiveAmountQuery = clone $query;
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($effectiveAmountQuery, 'po');
// 拒收指标保留关键词、审核和日期条件,但不受当前履约状态按钮影响,
// 避免点击“拒收订单”后分母被收窄为拒收状态而固定显示 100%。
$rejectionScopeQuery = $this->buildQuery(true);
$rejectionScopeOrderCount = (int) (clone $rejectionScopeQuery)->count('po.id');
$rejectedCount = (int) (clone $rejectionScopeQuery)
->where('po.fulfillment_status', 9)
->count('po.id');
$orderCount = (int) (clone $query)->count('po.id');
return [
'summary' => [
'orders' => $orderCount,
'amount' => round((float) $effectiveAmountQuery->sum('po.amount'), 2),
'pending' => (int) $pendingQuery
->where(function ($q) {
$q->where('po.prescription_audit_status', 0)
->whereOr('po.payment_slip_audit_status', 0);
})
->count('po.id'),
'completed' => (int) (clone $query)->whereIn('po.fulfillment_status', [3, 6])->count('po.id'),
'rejected' => $rejectedCount,
'rejection_rate' => $rejectionScopeOrderCount > 0
? round($rejectedCount / $rejectionScopeOrderCount * 100, 2)
: 0.0,
],
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
];
}
private function buildQuery(bool $ignoreFulfillmentStatus = false): Query
{
$query = PrescriptionOrder::alias('po')
->join('tcm_diagnosis d', 'po.diagnosis_id = d.id')
->whereNull('po.delete_time')
->whereNull('d.delete_time')
->where('d.status', 1);
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
$this->applyKeyword($query);
$this->applyStatusFilters($query, $ignoreFulfillmentStatus);
$this->applyDateFilter($query);
return $query;
}
private function applyKeyword(Query $query): void
{
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword === '') {
return;
}
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->whereLike('po.order_no', $like)
->whereOr('d.patient_name', 'like', $like)
->whereOr('d.phone', 'like', $like)
->whereOr('po.recipient_name', 'like', $like)
->whereOr('po.recipient_phone', 'like', $like);
if (preg_match('/^\d+$/', $keyword)) {
$id = (int) $keyword;
if ($id > 0) {
$q->whereOr('po.id', $id)
->whereOr('po.prescription_id', $id)
->whereOr('po.diagnosis_id', $id);
}
}
});
}
private function applyStatusFilters(Query $query, bool $ignoreFulfillmentStatus = false): void
{
foreach (['prescription_audit_status', 'payment_slip_audit_status', 'fulfillment_status'] as $field) {
if ($ignoreFulfillmentStatus && $field === 'fulfillment_status') {
continue;
}
$raw = $this->params[$field] ?? '';
if ($raw === '' || $raw === null) {
continue;
}
$query->where('po.' . $field, (int) $raw);
}
}
private function applyDateFilter(Query $query): void
{
[$startDate, $endDate] = $this->dateRange();
if ($startDate !== '') {
$query->where('po.create_time', '>=', strtotime($startDate . ' 00:00:00'));
}
if ($endDate !== '') {
$query->where('po.create_time', '<=', strtotime($endDate . ' 23:59:59'));
}
}
/** @return array{0:string,1:string} */
private function dateRange(): array
{
$startDate = $this->normalizeDate($this->params['start_date'] ?? '');
$endDate = $this->normalizeDate($this->params['end_date'] ?? '');
if ($startDate === '' && $endDate !== '') {
$startDate = $endDate;
}
if ($endDate === '' && $startDate !== '') {
$endDate = $startDate;
}
if ($startDate !== '' && $endDate !== '' && $startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
return [$startDate, $endDate];
}
private function normalizeDate($value): string
{
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function appendRelations(array $rows): array
{
if ($rows === []) {
return [];
}
$orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'id')))));
$prescriptionIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'prescription_id')))));
$creatorIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'creator_id')))));
$assistantIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'assistant_id')))));
$prescriptionMap = [];
$doctorIds = [];
if ($prescriptionIds !== []) {
$prescriptions = Prescription::whereIn('id', $prescriptionIds)
->whereNull('delete_time')
->field(['id', 'creator_id', 'doctor_name'])
->select()
->toArray();
foreach ($prescriptions as $prescription) {
$prescriptionId = (int) ($prescription['id'] ?? 0);
$doctorId = (int) ($prescription['creator_id'] ?? 0);
if ($prescriptionId > 0) {
$prescriptionMap[$prescriptionId] = $prescription;
}
if ($doctorId > 0) {
$doctorIds[] = $doctorId;
}
}
}
$adminIds = array_values(array_unique(array_merge($creatorIds, $assistantIds, $doctorIds)));
$adminNames = $adminIds === []
? []
: Admin::whereIn('id', $adminIds)->whereNull('delete_time')->column('name', 'id');
$linkCounts = [];
$paidTotals = [];
if ($orderIds !== []) {
$linkRows = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $orderIds)
->field(['prescription_order_id', 'pay_order_id'])
->select()
->toArray();
$payOrderIds = array_values(array_unique(array_filter(array_map('intval', array_column($linkRows, 'pay_order_id')))));
$payOrders = $payOrderIds === []
? []
: Order::whereIn('id', $payOrderIds)
->whereNull('delete_time')
->field(['id', 'amount', 'status'])
->select()
->toArray();
$payOrderMap = [];
foreach ($payOrders as $payOrder) {
$payOrderMap[(int) ($payOrder['id'] ?? 0)] = $payOrder;
}
foreach ($linkRows as $linkRow) {
$orderId = (int) ($linkRow['prescription_order_id'] ?? 0);
if ($orderId > 0) {
$linkCounts[$orderId] = ($linkCounts[$orderId] ?? 0) + 1;
}
$payOrder = $payOrderMap[(int) ($linkRow['pay_order_id'] ?? 0)] ?? [];
if ($orderId > 0 && in_array((int) ($payOrder['status'] ?? 0), [2, 5], true)) {
$paidTotals[$orderId] = round(
(float) ($paidTotals[$orderId] ?? 0) + (float) ($payOrder['amount'] ?? 0),
2
);
}
}
}
$assistantByDiagnosis = [];
foreach ($rows as $row) {
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
if ($diagnosisId > 0) {
$assistantByDiagnosis[$diagnosisId] = (int) ($row['assistant_id'] ?? 0);
}
}
$claimByOrder = [];
if ($orderIds !== []) {
$claimRows = Db::name('pharmacy_submission_claim')
->whereIn('prescription_order_id', $orderIds)
->field(['prescription_order_id', 'target', 'status', 'lease_expires_at'])
->order('source_revision', 'desc')
->select()
->toArray();
foreach ($claimRows as $claimRow) {
$orderId = (int) ($claimRow['prescription_order_id'] ?? 0);
if ($orderId > 0 && !isset($claimByOrder[$orderId])) {
$claimByOrder[$orderId] = $claimRow;
}
}
}
foreach ($rows as &$row) {
$prescription = $prescriptionMap[(int) ($row['prescription_id'] ?? 0)] ?? [];
$doctorId = (int) ($prescription['creator_id'] ?? 0);
$doctorName = trim((string) ($prescription['doctor_name'] ?? ''));
$creatorId = (int) ($row['creator_id'] ?? 0);
$assistantId = (int) ($row['assistant_id'] ?? 0);
$row['patient_phone_masked'] = $this->maskPhone((string) ($row['patient_phone'] ?? ''));
$row['recipient_phone_masked'] = $this->maskPhone((string) ($row['recipient_phone'] ?? ''));
unset($row['patient_phone'], $row['recipient_phone']);
$row['creator_name'] = (string) ($adminNames[$creatorId] ?? '—');
$row['assistant_name'] = (string) ($adminNames[$assistantId] ?? '未分配');
$row['doctor_name'] = $doctorName !== '' ? $doctorName : (string) ($adminNames[$doctorId] ?? '—');
$row['linked_pay_order_count'] = (int) ($linkCounts[(int) $row['id']] ?? 0);
$row['linked_pay_paid_total'] = (float) ($paidTotals[(int) $row['id']] ?? 0);
$claim = $claimByOrder[(int) $row['id']] ?? [];
$row['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
$row['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
$row['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
$row['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
$row,
$this->adminId,
$this->adminInfo,
$assistantByDiagnosis
);
$row['create_time_text'] = $this->formatTimestamp($row['create_time'] ?? 0);
$row['fee_type_text'] = $this->feeTypeText((int) ($row['fee_type'] ?? 0));
$row['prescription_audit_text'] = $this->auditStatusText((int) ($row['prescription_audit_status'] ?? 0));
$row['payment_slip_audit_text'] = $this->auditStatusText((int) ($row['payment_slip_audit_status'] ?? 0));
$row['fulfillment_text'] = $this->fulfillmentStatusText((int) ($row['fulfillment_status'] ?? 0));
$fulfillmentStatus = (int) ($row['fulfillment_status'] ?? 0);
$refundAmount = round((float) ($row['refund_amount'] ?? 0), 2);
$amountIncluded = !in_array(
$fulfillmentStatus,
YejiStatsLogic::PRESCRIPTION_ORDER_FULFILLMENT_EXCLUDED_FROM_PERFORMANCE,
true
) && $refundAmount <= 0;
$row['amount_included'] = $amountIncluded;
$row['effective_amount'] = $amountIncluded ? round((float) ($row['amount'] ?? 0), 2) : 0.0;
$row['amount_exclusion_text'] = $amountIncluded
? ''
: ($refundAmount > 0 || $fulfillmentStatus === 10 ? '退款不计入' : $row['fulfillment_text'] . '不计入');
}
unset($row);
return $rows;
}
private function maskPhone(string $phone): string
{
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
}
private function formatTimestamp($value): string
{
return is_numeric($value) && (int) $value > 0 ? date('Y-m-d H:i', (int) $value) : '';
}
private function auditStatusText(int $status): string
{
return [0 => '待审核', 1 => '已通过', 2 => '已驳回'][$status] ?? '未知';
}
private function feeTypeText(int $type): string
{
return [1 => '挂号', 2 => '问诊', 3 => '药品', 4 => '首付', 5 => '尾款', 6 => '其他', 7 => '全部'][$type] ?? '其他';
}
private function fulfillmentStatusText(int $status): string
{
return [
1 => '待双审通过', 2 => '待发货', 3 => '已完成', 4 => '已取消',
5 => '已发货', 6 => '已签收', 7 => '进行中', 8 => '暂不制药',
9 => '拒收', 10 => '退款', 11 => '保留药方', 12 => '制药缓发',
][$status] ?? '未知';
}
}
@@ -0,0 +1,667 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\firstvisit;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\model\DiagnosisViewRecord;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Roster;
use app\common\model\tcm\Prescription;
use app\common\service\doctor\RosterSegmentService;
use think\db\Query;
use think\facade\Db;
/**
* “我的患者”内嵌面诊进度。
*
* 一条挂号一行;只返回脱敏患者信息,并严格复用 MyPatientLogic 的患者级范围。
*/
class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
private const EFFECTIVE_STATUSES = [1, 3, 4];
private const AVG_MINUTES_PER_VISIT = 15;
public function setSearch(): array
{
return [];
}
public function lists(): array
{
$rows = $this->buildQuery(true)
->field([
'a.id', 'a.patient_id AS diagnosis_id', 'a.doctor_id', 'a.appointment_date',
'a.appointment_time', 'a.appointment_type', 'a.status', 'a.create_time',
'd.patient_id AS source_patient_id', 'd.patient_name', 'd.phone', 'd.gender', 'd.age',
'd.assistant_id', 'doctor_admin.name AS doctor_name', 'assistant_admin.name AS assistant_name',
])
->order('a.appointment_date', 'asc')
->order('a.appointment_time', 'asc')
->order('a.id', 'asc')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
return $this->appendProgress($rows);
}
public function count(): int
{
return (int) $this->buildQuery(true)->count('a.id');
}
public function extend(): array
{
$query = $this->buildQuery(false);
[$startDate, $endDate] = $this->dateRange();
$summary = [
'total' => (int) (clone $query)->count('a.id'),
'booked' => (int) (clone $query)->where('a.status', 1)->count('a.id'),
'completed' => (int) (clone $query)->where('a.status', 3)->count('a.id'),
'missed' => (int) (clone $query)->where('a.status', 4)->count('a.id'),
];
$scheduleMode = $this->usesOwnershipSchedule() ? 'ownership' : 'roster';
$weekSchedule = $scheduleMode === 'ownership' ? $this->ownershipWeekSchedule() : $this->weekSchedule();
$todaySchedule = $weekSchedule[0] ?? $this->emptyScheduleDay(date('Y-m-d'));
$todayOverview = $scheduleMode === 'ownership'
? [
'total_visits' => (int) ($todaySchedule['total_appointments'] ?? 0),
'booked' => (int) ($todaySchedule['waiting_appointments'] ?? 0),
'completed' => (int) ($todaySchedule['completed_appointments'] ?? 0),
'missed' => (int) ($todaySchedule['missed_appointments'] ?? 0),
'empty_slots' => 0,
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
]
: [
'total_visits' => (int) ($todaySchedule['total_slots'] ?? 0),
'booked' => (int) ($todaySchedule['booked_slots'] ?? 0),
'completed' => 0,
'missed' => 0,
'empty_slots' => (int) ($todaySchedule['empty_slots'] ?? 0),
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
];
return [
'summary' => $summary,
'schedule_mode' => $scheduleMode,
'today_overview' => $todayOverview,
'week_schedule' => $weekSchedule,
'scope' => MyPatientLogic::scopeMeta($this->adminId, $this->adminInfo),
'dates' => ['start' => $startDate, 'end' => $endDate],
];
}
private function buildQuery(bool $applyStatus): Query
{
$query = Appointment::alias('a')
->join('tcm_diagnosis d', 'a.patient_id = d.id')
->leftJoin('admin doctor_admin', 'a.doctor_id = doctor_admin.id')
->leftJoin('admin assistant_admin', 'CAST(d.assistant_id AS UNSIGNED) = assistant_admin.id')
->whereNull('d.delete_time')
->where('d.status', 1);
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
$this->applyKeyword($query);
$this->applyDateFilter($query);
if ($applyStatus) {
$status = $this->params['status'] ?? '';
if ($status !== '' && $status !== null && in_array((int) $status, self::EFFECTIVE_STATUSES, true)) {
$query->where('a.status', (int) $status);
} else {
$query->whereIn('a.status', self::EFFECTIVE_STATUSES);
}
} else {
$query->whereIn('a.status', self::EFFECTIVE_STATUSES);
}
return $query;
}
private function applyKeyword(Query $query): void
{
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword === '') {
return;
}
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->whereLike('d.patient_name', $like)
->whereOr('d.phone', 'like', $like)
->whereOr('doctor_admin.name', 'like', $like)
->whereOr('assistant_admin.name', 'like', $like);
if (preg_match('/^\d+$/', $keyword)) {
$id = (int) $keyword;
if ($id > 0) {
$q->whereOr('a.id', $id)->whereOr('d.id', $id);
}
}
});
}
private function applyDateFilter(Query $query): void
{
[$startDate, $endDate] = $this->dateRange();
$query->whereBetween('a.appointment_date', [$startDate, $endDate]);
}
/** @return array{0:string,1:string} */
private function dateRange(): array
{
$today = date('Y-m-d');
$startDate = $this->normalizeDate($this->params['start_date'] ?? '') ?: $today;
$endDate = $this->normalizeDate($this->params['end_date'] ?? '') ?: $startDate;
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
$startTs = strtotime($startDate);
$endTs = strtotime($endDate);
if ($startTs !== false && $endTs !== false && $endTs - $startTs > 31 * 86400) {
$endDate = date('Y-m-d', $startTs + 31 * 86400);
}
return [$startDate, $endDate];
}
private function normalizeDate($value): string
{
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) ? $value : '';
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function appendProgress(array $rows): array
{
if ($rows === []) {
return [];
}
$diagnosisIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'diagnosis_id')))));
$appointmentIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'id')))));
$queuePositionMap = $this->queuePositionMap($rows);
$confirmedSet = [];
if ($diagnosisIds !== []) {
$viewTable = (new DiagnosisViewRecord())->getTable();
$confirmedIds = Db::table($viewTable)
->whereIn('diagnosis_id', $diagnosisIds)
->where('is_confirmed', 1)
->whereNull('delete_time')
->column('diagnosis_id');
$confirmedSet = array_fill_keys(array_map('intval', $confirmedIds), true);
}
$prescriptionMap = [];
if ($appointmentIds !== []) {
$prescriptions = Prescription::whereIn('appointment_id', $appointmentIds)
->whereNull('delete_time')
->where('void_status', 0)
->field(['id', 'appointment_id', 'audit_status', 'is_system_auto'])
->order('id', 'desc')
->select()
->toArray();
foreach ($prescriptions as $prescription) {
$appointmentId = (int) ($prescription['appointment_id'] ?? 0);
if ($appointmentId > 0 && !isset($prescriptionMap[$appointmentId])) {
$prescriptionMap[$appointmentId] = $prescription;
}
}
}
foreach ($rows as &$row) {
$appointmentId = (int) ($row['id'] ?? 0);
$diagnosisId = (int) ($row['diagnosis_id'] ?? 0);
$status = (int) ($row['status'] ?? 0);
$prescription = $prescriptionMap[$appointmentId] ?? [];
$confirmed = isset($confirmedSet[$diagnosisId]);
$prescribed = $prescription !== [];
$aheadCount = $status === 1 ? (int) ($queuePositionMap[$appointmentId] ?? 0) : 0;
$row['phone_masked'] = $this->maskPhone((string) ($row['phone'] ?? ''));
unset($row['phone']);
$row['gender_desc'] = (int) ($row['gender'] ?? 0) === 1 ? '男' : '女';
$row['assistant_name'] = trim((string) ($row['assistant_name'] ?? '')) ?: '未分配';
$row['doctor_name'] = trim((string) ($row['doctor_name'] ?? '')) ?: '未知医生';
$row['appointment_time_text'] = $this->appointmentTimeText($row);
$row['status_text'] = $this->appointmentStatusText($status);
$row['appointment_type_text'] = $this->appointmentTypeText((string) ($row['appointment_type'] ?? ''));
$row['registered'] = 1;
$row['diagnosis_confirmed'] = $confirmed ? 1 : 0;
$row['visit_completed'] = $status === 3 ? 1 : 0;
$row['has_prescription'] = $prescribed ? 1 : 0;
$row['prescription_id'] = (int) ($prescription['id'] ?? 0);
$row['prescription_audit_status'] = $prescribed ? (int) ($prescription['audit_status'] ?? 0) : -1;
$row['progress_text'] = $this->progressText($confirmed, $status === 3, $prescribed, $status);
$row['queue_no'] = $status === 1 ? $aheadCount + 1 : 0;
$row['ahead_count'] = $aheadCount;
$row['estimated_wait_minutes'] = $aheadCount * self::AVG_MINUTES_PER_VISIT;
$row['queue_status'] = $this->queueStatus($status, $confirmed, $aheadCount);
$row['queue_status_text'] = $this->queueStatusText((string) $row['queue_status']);
$row['is_self_patient'] = (
(int) ($row['assistant_id'] ?? 0) === $this->adminId
|| (int) ($row['doctor_id'] ?? 0) === $this->adminId
) ? 1 : 0;
}
unset($row);
return $rows;
}
/**
* 候诊位次按 progress.vue 的真实规则计算:同医生、同日、待就诊,按预约时刻和挂号 ID 升序。
* 队列计算读取完整医生队列,只向当前范围列表返回人数,不暴露范围外患者身份。
*
* @param array<int,array<string,mixed>> $rows
* @return array<int,int>
*/
private function queuePositionMap(array $rows): array
{
$doctorIds = array_values(array_unique(array_filter(array_map('intval', array_column($rows, 'doctor_id')))));
$dates = array_values(array_unique(array_filter(array_map('strval', array_column($rows, 'appointment_date')))));
if ($doctorIds === [] || $dates === []) {
return [];
}
$queueRows = Appointment::whereIn('doctor_id', $doctorIds)
->whereIn('appointment_date', $dates)
->where('status', 1)
->field(['id', 'doctor_id', 'appointment_date', 'appointment_time'])
->order('doctor_id', 'asc')
->order('appointment_date', 'asc')
->order('appointment_time', 'asc')
->order('id', 'asc')
->select()
->toArray();
$groupCounts = [];
$positions = [];
foreach ($queueRows as $queueRow) {
$group = (int) ($queueRow['doctor_id'] ?? 0) . '|' . (string) ($queueRow['appointment_date'] ?? '');
$positions[(int) ($queueRow['id'] ?? 0)] = (int) ($groupCounts[$group] ?? 0);
$groupCounts[$group] = (int) ($groupCounts[$group] ?? 0) + 1;
}
return $positions;
}
/**
* 未来七天号源:完全复用 paiban/availableSlots 的生成口径,按医生+日期+时刻去重。
*
* @return array<int,array<string,mixed>>
*/
private function weekSchedule(): array
{
$startDate = date('Y-m-d');
$endDate = date('Y-m-d', strtotime($startDate . ' +6 days'));
$days = [];
for ($offset = 0; $offset < 7; $offset++) {
$date = date('Y-m-d', strtotime($startDate . " +{$offset} days"));
$days[$date] = $this->emptyScheduleDay($date);
}
$doctorIds = $this->visibleDoctorIds($startDate, $endDate);
if ($doctorIds === []) {
return array_values($days);
}
$rosters = Roster::whereIn('doctor_id', $doctorIds)
->whereBetween('date', [$startDate, $endDate])
->where('status', 1)
->whereNull('delete_time')
->field(['doctor_id', 'date', 'period', 'start_time', 'end_time', 'slot_minutes', 'quota'])
->select()
->toArray();
$doctorNames = Admin::whereIn('id', $doctorIds)->column('name', 'id');
$doctorSlotSets = [];
$doctorWindowSets = [];
foreach ($rosters as $roster) {
$date = (string) ($roster['date'] ?? '');
$doctorId = (int) ($roster['doctor_id'] ?? 0);
$window = RosterSegmentService::resolveWindow($roster);
if (!isset($days[$date]) || $doctorId <= 0 || $window === null) {
continue;
}
[$startTime, $endTime] = $window;
$times = RosterSegmentService::generateSlotTimes(
$startTime,
$endTime,
RosterSegmentService::normalizeSlotMinutes($roster['slot_minutes'] ?? 15)
);
$times = RosterSegmentService::applyQuotaCap($times, (int) ($roster['quota'] ?? 0));
foreach ($times as $time) {
$doctorSlotSets[$date][$doctorId][$time] = true;
}
$doctorWindowSets[$date][$doctorId][$startTime . '-' . $endTime] = true;
}
$appointments = Appointment::whereIn('doctor_id', $doctorIds)
->whereBetween('appointment_date', [$startDate, $endDate])
->where('status', 1)
->field(['doctor_id', 'appointment_date', 'appointment_time'])
->select()
->toArray();
$doctorBookedSets = [];
foreach ($appointments as $appointment) {
$date = (string) ($appointment['appointment_date'] ?? '');
$doctorId = (int) ($appointment['doctor_id'] ?? 0);
$time = substr((string) ($appointment['appointment_time'] ?? ''), 0, 5);
if (isset($doctorSlotSets[$date][$doctorId][$time])) {
$doctorBookedSets[$date][$doctorId][$time] = true;
}
}
foreach ($days as $date => &$day) {
$doctorDetails = [];
$total = 0;
$booked = 0;
foreach ($doctorSlotSets[$date] ?? [] as $doctorId => $slotSet) {
$doctorTotal = count($slotSet);
$doctorBooked = count($doctorBookedSets[$date][$doctorId] ?? []);
$total += $doctorTotal;
$booked += $doctorBooked;
$scheduleWindows = array_values(array_keys($doctorWindowSets[$date][$doctorId] ?? []));
sort($scheduleWindows, SORT_STRING);
$doctorDetails[] = [
'doctor_id' => (int) $doctorId,
'doctor_name' => trim((string) ($doctorNames[$doctorId] ?? '')) ?: '未知医生',
'schedule_windows' => $scheduleWindows,
'total_slots' => $doctorTotal,
'booked_slots' => $doctorBooked,
'empty_slots' => max(0, $doctorTotal - $doctorBooked),
];
}
usort($doctorDetails, static function (array $left, array $right): int {
return $right['booked_slots'] <=> $left['booked_slots']
?: $right['total_slots'] <=> $left['total_slots']
?: strcmp((string) $left['doctor_name'], (string) $right['doctor_name']);
});
$day['total_slots'] = $total;
$day['booked_slots'] = $booked;
$day['empty_slots'] = max(0, $total - $booked);
$day['doctor_count'] = count($doctorDetails);
$day['doctors'] = $doctorDetails;
}
unset($day);
return array_values($days);
}
/**
* 医助“本人归属”只统计其患者的真实挂号,不再把历史接诊医生的整周号源算到本人名下。
*
* @return array<int,array<string,mixed>>
*/
private function ownershipWeekSchedule(): array
{
$startDate = date('Y-m-d');
$endDate = date('Y-m-d', strtotime($startDate . ' +6 days'));
$days = [];
for ($offset = 0; $offset < 7; $offset++) {
$date = date('Y-m-d', strtotime($startDate . " +{$offset} days"));
$days[$date] = $this->emptyScheduleDay($date);
}
$query = Appointment::alias('ownership_a')
->join('tcm_diagnosis d', 'ownership_a.patient_id = d.id')
->leftJoin('admin ownership_doctor', 'ownership_a.doctor_id = ownership_doctor.id')
->whereNull('d.delete_time')
->where('d.status', 1)
->whereBetween('ownership_a.appointment_date', [$startDate, $endDate])
->whereIn('ownership_a.status', self::EFFECTIVE_STATUSES);
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
$appointments = $query
->field([
'ownership_a.id', 'ownership_a.doctor_id', 'ownership_a.appointment_date',
'ownership_a.appointment_time', 'ownership_a.status',
'ownership_doctor.name AS doctor_name',
])
->order('ownership_a.appointment_date', 'asc')
->order('ownership_a.appointment_time', 'asc')
->order('ownership_a.id', 'asc')
->select()
->toArray();
$doctorDetails = [];
foreach ($appointments as $appointment) {
$date = (string) ($appointment['appointment_date'] ?? '');
$doctorId = (int) ($appointment['doctor_id'] ?? 0);
if (!isset($days[$date]) || $doctorId <= 0) {
continue;
}
if (!isset($doctorDetails[$date][$doctorId])) {
$doctorDetails[$date][$doctorId] = [
'doctor_id' => $doctorId,
'doctor_name' => trim((string) ($appointment['doctor_name'] ?? '')) ?: '未知医生',
'appointment_time_set' => [],
'total_appointments' => 0,
'waiting_appointments' => 0,
'completed_appointments' => 0,
'missed_appointments' => 0,
];
}
$time = substr((string) ($appointment['appointment_time'] ?? ''), 0, 5);
if ($time !== '') {
$doctorDetails[$date][$doctorId]['appointment_time_set'][$time] = true;
}
$status = (int) ($appointment['status'] ?? 0);
$doctorDetails[$date][$doctorId]['total_appointments']++;
if ($status === 1) {
$doctorDetails[$date][$doctorId]['waiting_appointments']++;
} elseif ($status === 3) {
$doctorDetails[$date][$doctorId]['completed_appointments']++;
} elseif ($status === 4) {
$doctorDetails[$date][$doctorId]['missed_appointments']++;
}
}
foreach ($days as $date => &$day) {
$rows = [];
foreach ($doctorDetails[$date] ?? [] as $doctor) {
$times = array_values(array_keys($doctor['appointment_time_set'] ?? []));
sort($times, SORT_STRING);
unset($doctor['appointment_time_set']);
$doctor['appointment_times'] = $times;
$rows[] = $doctor;
}
usort($rows, static function (array $left, array $right): int {
return $right['waiting_appointments'] <=> $left['waiting_appointments']
?: $right['total_appointments'] <=> $left['total_appointments']
?: strcmp((string) $left['doctor_name'], (string) $right['doctor_name']);
});
$day['total_appointments'] = array_sum(array_column($rows, 'total_appointments'));
$day['waiting_appointments'] = array_sum(array_column($rows, 'waiting_appointments'));
$day['completed_appointments'] = array_sum(array_column($rows, 'completed_appointments'));
$day['missed_appointments'] = array_sum(array_column($rows, 'missed_appointments'));
$day['doctor_count'] = count($rows);
$day['doctors'] = $rows;
}
unset($day);
return array_values($days);
}
private function usesOwnershipSchedule(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return false;
}
$roleIds = $this->currentRoleIds();
return in_array(2, $roleIds, true) && array_intersect($roleIds, [3, 7, 8]) === [];
}
/** @return int[] */
private function visibleDoctorIds(string $startDate, string $endDate): array
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
$doctorIds = array_values(array_unique(array_map('intval', Roster::whereBetween('date', [$startDate, $endDate])
->where('status', 1)
->whereNull('delete_time')
->column('doctor_id'))));
return $this->activeDoctorIds($doctorIds);
}
$roleIds = $this->currentRoleIds();
$isTeamRole = array_intersect($roleIds, [3, 7, 8]) !== [];
$isDoctor = in_array(1, $roleIds, true);
$isAssistant = in_array(2, $roleIds, true);
// 纯医生账号的概览只统计本人排班,避免同一患者曾由其他医生接诊时放大到其他医生。
if (!$isTeamRole && $isDoctor && !$isAssistant) {
return $this->activeDoctorIds([$this->adminId]);
}
$query = Appointment::alias('scope_a')
->join('tcm_diagnosis d', 'scope_a.patient_id = d.id')
->whereNull('d.delete_time')
->where('d.status', 1)
->whereIn('scope_a.status', self::EFFECTIVE_STATUSES)
->where('scope_a.doctor_id', '>', 0);
MyPatientLogic::applyScope($query, $this->adminId, $this->adminInfo);
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $query->distinct(true)->column('scope_a.doctor_id')))));
if (!$isTeamRole && $isDoctor) {
$doctorIds[] = $this->adminId;
}
return $this->activeDoctorIds(array_values(array_unique($doctorIds)));
}
/** @return int[] */
private function currentRoleIds(): array
{
return array_values(array_unique(array_filter(array_map('intval', AdminRole::where('admin_id', $this->adminId)->column('role_id')))));
}
/** @param int[] $doctorIds @return int[] */
private function activeDoctorIds(array $doctorIds): array
{
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $doctorIds))));
if ($doctorIds === []) {
return [];
}
$roleDoctorIds = array_values(array_unique(array_map('intval', AdminRole::whereIn('admin_id', $doctorIds)
->where('role_id', 1)
->column('admin_id'))));
if ($roleDoctorIds === []) {
return [];
}
$activeSet = array_fill_keys(array_map('intval', Admin::whereIn('id', $roleDoctorIds)
->where('disable', 0)
->column('id')), true);
return array_values(array_filter($doctorIds, static function (int $doctorId) use ($activeSet): bool {
return isset($activeSet[$doctorId]);
}));
}
/** @return array<string,mixed> */
private function emptyScheduleDay(string $date): array
{
$weekdayLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
$timestamp = strtotime($date) ?: time();
return [
'date' => $date,
'date_text' => date('m-d', $timestamp),
'weekday' => $weekdayLabels[(int) date('w', $timestamp)],
'total_slots' => 0,
'booked_slots' => 0,
'empty_slots' => 0,
'doctor_count' => 0,
'doctors' => [],
'total_appointments' => 0,
'waiting_appointments' => 0,
'completed_appointments' => 0,
'missed_appointments' => 0,
];
}
private function queueStatus(int $status, bool $confirmed, int $aheadCount): string
{
if ($status === 3) {
return 'completed';
}
if ($status === 4) {
return 'missed';
}
if ($confirmed) {
return 'consulting';
}
return $aheadCount === 0 ? 'next' : 'waiting';
}
private function queueStatusText(string $status): string
{
return [
'completed' => '已完成',
'missed' => '已过号',
'consulting' => '就诊中',
'next' => '待确认',
'waiting' => '等待中',
][$status] ?? '等待中';
}
private function maskPhone(string $phone): string
{
return preg_replace('/^(\d{3})\d{4}(\d{4})$/', '$1****$2', $phone) ?: $phone;
}
private function appointmentTimeText(array $row): string
{
$time = trim((string) ($row['appointment_time'] ?? ''));
if (strlen($time) > 5) {
$time = substr($time, 0, 5);
}
return trim((string) ($row['appointment_date'] ?? '') . ' ' . $time);
}
private function appointmentStatusText(int $status): string
{
return [1 => '已挂号', 3 => '已完成', 4 => '已过号'][$status] ?? '未知';
}
private function appointmentTypeText(string $type): string
{
return ['video' => '视频问诊', 'text' => '图文问诊', 'phone' => '电话问诊'][$type] ?? '面诊';
}
private function progressText(bool $confirmed, bool $completed, bool $prescribed, int $status): string
{
if ($status === 4) {
return '已过号';
}
if (!$confirmed) {
return '待确认诊单';
}
if (!$completed) {
return '待完诊';
}
return $prescribed ? '已开方' : '待开方';
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\pharmacy;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use think\facade\Db;
class MedicineMappingLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [];
}
public function lists(): array
{
$rows = $this->query()
->field($this->fields())
->limit($this->limitOffset, $this->limitLength)
->order('l.id', 'desc')
->select()
->toArray();
foreach ($rows as &$row) {
foreach ([
'local_medicine_id', 'local_status', 'mapping_id', 'mapping_status',
'operator_id', 'mapping_update_time', 'catalog_version', 'remote_status', 'remote_deleted',
] as $field) {
if ($row[$field] !== null) {
$row[$field] = (int) $row[$field];
}
}
}
unset($row);
return $rows;
}
public function count(): int
{
return (int) $this->query()->count('l.id');
}
private function query()
{
$query = Db::name('doctor_medicine')->alias('l')
->leftJoin(
'ej_medicine_mapping m',
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
)
->leftJoin('ej_medicine_catalog c', 'c.medicine_code = m.medicine_code')
->whereNull('l.delete_time');
$localName = trim((string) ($this->params['local_name'] ?? ''));
if ($localName !== '') {
$query->where('l.name', 'like', '%' . $localName . '%');
}
$remoteKeyword = trim((string) ($this->params['remote_keyword'] ?? ''));
if ($remoteKeyword !== '') {
$query->where(function ($nested) use ($remoteKeyword): void {
$nested->where('c.name', 'like', '%' . $remoteKeyword . '%')
->whereOr('c.medicine_code', 'like', '%' . $remoteKeyword . '%');
});
}
$mappingStatus = trim((string) ($this->params['mapping_status'] ?? ''));
if ($mappingStatus === 'mapped') {
$query->whereNotNull('m.id')->where('c.status', 1)->where('c.remote_deleted', 0);
} elseif ($mappingStatus === 'unmapped') {
$query->whereNull('m.id');
} elseif ($mappingStatus === 'invalid') {
$query->whereNotNull('m.id')
->where(function ($nested): void {
$nested->whereNull('c.id')
->whereOr('c.status', '<>', 1)
->whereOr('c.remote_deleted', 1);
});
}
return $query;
}
private function fields(): string
{
return implode(',', [
'l.id AS local_medicine_id',
'l.name AS local_name',
'l.unit AS local_unit',
'l.status AS local_status',
'm.id AS mapping_id',
'm.medicine_code',
'm.operator_id',
'm.operator_name',
'm.update_time AS mapping_update_time',
'c.name AS remote_name',
'c.brand AS remote_brand',
'c.unit AS remote_unit',
'c.settlement_price',
'c.retail_price',
'c.catalog_version',
'c.status AS remote_status',
'c.remote_deleted',
"CASE WHEN m.id IS NULL THEN 0 "
. "WHEN c.id IS NULL OR c.status <> 1 OR c.remote_deleted = 1 THEN 2 ELSE 1 END AS mapping_status",
]);
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\stats;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use think\facade\Db;
/**
* 待分配诊单自动指派日志列表
*
* 数据来源:tcm_diagnosis_auto_assign_log(定时命令 tcm:auto-assign-pending 写入)
* 筛选:run_date / create_time 区间 / action / keyword / rollback 状态
*/
class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterface
{
private const TIER_LABELS = [
'gt70' => '>70%',
'60_70' => '60%~70%',
'50_60' => '50%~60%',
];
/**
* @notes 设置搜索条件
*/
public function setSearch(): array
{
return [
'=' => ['run_date', 'action', 'assistant_id', 'batch_no', 'stat_month'],
];
}
/**
* @notes 获取列表
*/
public function lists(): array
{
$rows = $this->buildQuery()
->order(['id' => 'desc'])
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
foreach ($rows as &$row) {
$ct = (int) ($row['create_time'] ?? 0);
$row['create_time_text'] = $ct > 0 ? date('Y-m-d H:i:s', $ct) : '';
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
$rbTs = (int) ($row['rollback_time'] ?? 0);
$row['rollback_time_text'] = $rbTs > 0 ? date('Y-m-d H:i:s', $rbTs) : '';
$row['is_rollback'] = $rbTs > 0 ? 1 : 0;
$row['can_rollback'] = ((int) ($row['action'] ?? 0) === 1 && $rbTs === 0) ? 1 : 0;
if ($rbTs > 0) {
$row['rollback_status_text'] = '已回退'
. (($row['rollback_admin_name'] ?? '') !== '' ? (' · ' . $row['rollback_admin_name']) : '');
} else {
$row['rollback_status_text'] = ((int) ($row['action'] ?? 0) === 1) ? '可回退' : '—';
}
}
unset($row);
return $rows;
}
/**
* @notes 获取数量
*/
public function count(): int
{
return $this->buildQuery()->count();
}
/**
* @return \think\db\Query
*/
private function buildQuery()
{
$query = Db::name('tcm_diagnosis_auto_assign_log')->where($this->searchWhere);
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->whereLike('patient_name', '%' . $keyword . '%')
->whereOr('patient_phone', 'like', '%' . $keyword . '%')
->whereOr('assistant_name', 'like', '%' . $keyword . '%');
if (preg_match('/^\d+$/', $keyword) === 1 && (int) $keyword > 0) {
$q->whereOr('diagnosis_id', '=', (int) $keyword);
}
});
}
$startDate = trim((string) ($this->params['start_date'] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) === 1) {
$query->where('run_date', '>=', $startDate);
}
$endDate = trim((string) ($this->params['end_date'] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) === 1) {
$query->where('run_date', '<=', $endDate);
}
// 记录时间(create_time)区间:支持 Y-m-d 或 Y-m-d H:i:s
$startTs = $this->parseTimeParam((string) ($this->params['start_time'] ?? ''), false);
if ($startTs > 0) {
$query->where('create_time', '>=', $startTs);
}
$endTs = $this->parseTimeParam((string) ($this->params['end_time'] ?? ''), true);
if ($endTs > 0) {
$query->where('create_time', '<=', $endTs);
}
$rollback = trim((string) ($this->params['is_rollback'] ?? ''));
if ($rollback === '1') {
$query->where('rollback_time', '>', 0);
} elseif ($rollback === '0') {
$query->where('rollback_time', '=', 0);
}
return $query;
}
/**
* @param bool $endOfDay 仅日期时是否取当天结束
*/
private function parseTimeParam(string $raw, bool $endOfDay): int
{
$raw = trim($raw);
if ($raw === '') {
return 0;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) {
return (int) strtotime($raw . ($endOfDay ? ' 23:59:59' : ' 00:00:00'));
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $raw) === 1) {
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $raw) === 1) {
$raw .= $endOfDay ? ':59' : ':00';
}
return (int) strtotime($raw);
}
return 0;
}
}
@@ -24,6 +24,7 @@ use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\ExpressTracking;
use app\common\service\gancao\GancaoScmRecipelService;
use app\common\service\pharmacy\EjPharmacyClient;
use think\facade\Config;
use think\facade\Db;
use think\db\Query;
@@ -333,9 +334,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
/**
* 供货方式:甘草(已上传甘草药方单号)/ 自营(无甘草单号,走药房直发等)
* 供货方式:洛阳直发(ship_mode=direct,含尚未上传)/ 甘草(有甘草单号)/ 自营(其余)。
*
* 入参 supply_modegancao | self,空表示不限
* 入参 supply_modegancao | direct | self,空表示不限
*/
private function applySupplyModeFilter($query): void
{
@@ -343,13 +344,24 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($mode === '') {
return;
}
if ($mode === 'direct') {
$query->whereRaw("LOWER(TRIM(IFNULL(`ship_mode`,''))) = 'direct'");
return;
}
if ($mode === 'gancao') {
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''");
$query->whereRaw(
"LOWER(TRIM(IFNULL(`ship_mode`,''))) <> 'direct'"
. " AND TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''"
);
return;
}
if ($mode === 'self') {
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''");
$query->whereRaw(
"LOWER(TRIM(IFNULL(`ship_mode`,''))) <> 'direct'"
. " AND TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''"
);
return;
}
@@ -706,6 +718,22 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$assistantNames = $assistantIds !== []
? \app\common\model\auth\Admin::whereIn('id', $assistantIds)->column('name', 'id')
: [];
$orderIdsForClaims = array_values(array_unique(array_filter(array_map('intval', array_column($lists, 'id')))));
$claimByOrder = [];
if ($orderIdsForClaims !== []) {
$claimRows = Db::name('pharmacy_submission_claim')
->whereIn('prescription_order_id', $orderIdsForClaims)
->field(['prescription_order_id', 'target', 'status', 'lease_expires_at'])
->order('source_revision', 'desc')
->select()
->toArray();
foreach ($claimRows as $claimRow) {
$claimId = (int) $claimRow['prescription_order_id'];
if (!isset($claimByOrder[$claimId])) {
$claimByOrder[$claimId] = $claimRow;
}
}
}
foreach ($lists as &$item) {
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
@@ -713,12 +741,22 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$item['linked_pay_order_count'] = (int) ($linkCountByPo[$pid] ?? 0);
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
$item['deposit_min_amount'] = $depMin;
$claim = $claimByOrder[$pid] ?? [];
$item['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
$item['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
$item['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
$item['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
$item,
$this->adminId,
$this->adminInfo,
$assistantByDiag
);
$item['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
$item,
$this->adminId,
$this->adminInfo,
$assistantByDiag
);
// 添加创建人姓名
$creatorId = (int) ($item['creator_id'] ?? 0);
@@ -741,6 +779,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
unset($item);
if ($this->shouldBypassListVisibilityForDiagnosisEdit()) {
$this->appendDiagnosisEditVisitSeqFields($lists);
}
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
@@ -762,6 +804,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$base = [
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
'ej_pharmacy_enabled' => EjPharmacyClient::isConfigured(),
'stats_order_amount' => $s['order_amount'],
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
@@ -834,7 +877,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_agency_collect' => '代收金额',
'export_tracking_number' => '快递单号',
'export_sign_time' => '签收日期',
'export_supply_mode' => '甘草还是自营',
'export_supply_mode' => '供货方式',
'export_gancao_prescription_cost' => '处方成本',
'export_first_visit_assistant' => '初诊医助',
'export_rx_audit_time' => '审核时间',
@@ -1417,6 +1460,69 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
);
}
/**
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)
*
* @param array<int, array<string, mixed>> $lists
*/
private function appendDiagnosisEditVisitSeqFields(array &$lists): void
{
if ($lists === []) {
return;
}
$diagIds = [];
foreach ($lists as $row) {
$d = (int) ($row['diagnosis_id'] ?? 0);
if ($d > 0) {
$diagIds[$d] = true;
}
}
$contextDid = (int) ($this->params['context_diagnosis_id'] ?? 0);
if ($contextDid > 0) {
$diagIds[$contextDid] = true;
}
$diagIdList = array_keys($diagIds);
if ($diagIdList === []) {
return;
}
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
$offsetMap = [];
foreach ($offsetRows as $id => $offset) {
$offsetMap[(int) $id] = max(0, min(20, (int) $offset));
}
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
$seqByOrderId = [];
foreach ($diagIdList as $did) {
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
$orderIds = $q
->order(['create_time' => 'asc', 'id' => 'asc'])
->column('id');
$seq = 0;
foreach ($orderIds as $oid) {
$seq++;
$seqByOrderId[(int) $oid] = $seq;
}
}
foreach ($lists as &$row) {
$oid = (int) ($row['id'] ?? 0);
$did = (int) ($row['diagnosis_id'] ?? 0);
$seq = (int) ($seqByOrderId[$oid] ?? 0);
$offset = (int) ($offsetMap[$did] ?? 0);
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
$row['counts_for_revisit_rate'] = $effectiveSlot >= 2 ? 1 : 0;
}
unset($row);
}
/**
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
+32 -1
View File
@@ -151,6 +151,24 @@ class DeptLogic extends BaseLogic
* @return list<int>
*/
private static function findErCenterRootDeptIds(): array
{
return self::findCenterRootDeptIdsByNameKeyword('二中心');
}
/**
* 名称含「一中心」的部门 id。
*
* @return list<int>
*/
private static function findYiCenterRootDeptIds(): array
{
return self::findCenterRootDeptIdsByNameKeyword('一中心');
}
/**
* @return list<int>
*/
private static function findCenterRootDeptIdsByNameKeyword(string $keyword): array
{
$rows = Dept::whereNull('delete_time')
->field(['id', 'name'])
@@ -159,7 +177,7 @@ class DeptLogic extends BaseLogic
$out = [];
foreach ($rows as $r) {
$name = (string) ($r['name'] ?? '');
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
if ($name !== '' && mb_strpos($name, $keyword) !== false) {
$out[] = (int) $r['id'];
}
}
@@ -201,6 +219,19 @@ class DeptLogic extends BaseLogic
return array_fill_keys($subtreeIds, true);
}
/**
* 名称含「一中心」的部门及其全部下级 idmap)。
*
* @return array<int, true>
*/
public static function getYiCenterSubtreeDeptIdSet(): array
{
$yiRoots = self::findYiCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($yiRoots);
return array_fill_keys($subtreeIds, true);
}
/**
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
*
@@ -0,0 +1,702 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\ConversionLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\model\stats\PersonalYeji;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\facade\Db;
/**
* 一诊「综合数据转化」。
*
* 自动指标复用 ConversionLogic;开口数来自个人业绩录入。所有筛选先与 DataScope
* 可见管理员集合取交集,HTTP 参数不能扩大当前账号的数据范围。
*/
class FirstVisitConversionLogic
{
private const ASSISTANT_ROLE_ID = 2;
/** @return array<string,mixed> */
public static function overview(array $params, int $adminId, array $adminInfo): array
{
[$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange((string) ($params['time_type'] ?? 'today'));
$baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
$selectedMediaChannelCode = MediaChannelService::normalizeStatsCode(
trim((string) ($params['media_channel_code'] ?? ''))
);
$selectedMediaChannel = $selectedMediaChannelCode !== ''
? MediaChannelService::getChannelByCode($selectedMediaChannelCode)
: null;
$deptSelectionValid = $selectedDeptId <= 0
|| $allowedDeptSet === null
|| isset($allowedDeptSet[$selectedDeptId]);
$selectedDeptIds = [];
if ($selectedDeptId > 0 && $deptSelectionValid) {
$selectedDeptIds = array_values(array_unique(array_filter(array_map(
'intval',
DeptLogic::getSelfAndDescendantIds($selectedDeptId)
), static fn (int $id): bool => $id > 0)));
if ($allowedDeptSet !== null) {
$selectedDeptIds = array_values(array_filter(
$selectedDeptIds,
static fn (int $id): bool => isset($allowedDeptSet[$id])
));
}
}
$effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : [];
if ($selectedDeptId > 0 && $deptSelectionValid) {
$deptAdminIds = $selectedDeptIds === []
? []
: self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id'));
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
}
if ($selectedAssistantId > 0) {
$assistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : [];
}
$costAllocationAdminIds = self::costAllocationAdminIds(
$effectiveAdminIds,
$scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0
);
$conversionParams = [
'dimension' => 'dept',
'time_type' => 'custom',
'start_date' => $startDate,
'end_date' => $endDate,
'include_filters' => 1,
'include_members' => 1,
'exclude_cancelled_appointments' => 1,
'order_metric_mode' => 'performance',
'page_no' => 1,
'page_size' => 100,
];
if ($selectedDeptId > 0 && $deptSelectionValid) {
$conversionParams['dept_id'] = $selectedDeptId;
}
if ($selectedMediaChannelCode !== '') {
$conversionParams['media_channel_code'] = $selectedMediaChannelCode;
}
$conversion = ConversionLogic::overview(
$conversionParams,
$adminId,
$adminInfo,
$effectiveAdminIds,
$costAllocationAdminIds
);
$rows = is_array($conversion['lists'] ?? null) ? $conversion['lists'] : [];
$rowAllowedDeptIds = self::visibleRowDeptIds($effectiveAdminIds);
if ($rowAllowedDeptIds !== null) {
$rows = self::filterDeptRows($rows, array_fill_keys($rowAllowedDeptIds, true));
}
$rowDeptIdSet = [];
self::collectRowDeptIds($rows, $rowDeptIdSet);
$openCounts = self::loadOpenCounts(
$startDate,
$endDate,
$effectiveAdminIds,
array_fill_keys(array_keys($rowDeptIdSet), true),
self::personalYejiMediaSources($selectedMediaChannelCode, $selectedMediaChannel)
);
$openDirect = $openCounts['dept'];
self::applyOpenCounts($rows, $openDirect, $openCounts['admin']);
$summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : [];
$summary['total_open_count'] = array_sum($openDirect);
$summary['total_open_rate'] = self::percent(
(int) $summary['total_open_count'],
(int) ($summary['add_fans_count'] ?? 0)
);
$summary['open_appointment_rate'] = self::percent(
(int) ($summary['paid_appointment_count'] ?? 0),
(int) $summary['total_open_count']
);
$summary['open_receive_rate'] = self::percent(
(int) ($summary['completed_order_count'] ?? 0),
(int) $summary['total_open_count']
);
$rankingRows = self::rankingRows($rows);
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
? []
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
$selectedDeptName = $selectedDeptId > 0
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
: '';
$conversionFilters = is_array($conversion['extend']['filters'] ?? null)
? $conversion['extend']['filters']
: [];
return [
'meta' => [
'time_type' => $timeType,
'time_label' => $timeLabel,
'start_date' => $startDate,
'end_date' => $endDate,
'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'selected_media_channel_code' => $selectedMediaChannelCode,
'selected_media_channel_name' => $selectedMediaChannelName,
'open_count_source' => $selectedMediaChannelCode === ''
? '个人业绩录入'
: '个人业绩录入(按渠道名称匹配)',
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
],
'filters' => [
'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId),
'media_channels' => is_array($conversionFilters['media_channels'] ?? null)
? $conversionFilters['media_channels']
: [],
],
'summary' => $summary,
'rankings' => [
'orders' => self::topRows($rankingRows, 'completed_order_count'),
'amounts' => self::topRows($rankingRows, 'completed_order_amount'),
],
'rows' => $rows,
'target' => $target,
];
}
/** @return array{0:string,1:string,2:string,3:string} */
private static function resolveTimeRange(string $timeType): array
{
$today = date('Y-m-d');
$timeType = in_array($timeType, ['today', 'yesterday', 'week', 'month', 'quarter', 'year'], true)
? $timeType
: 'today';
if ($timeType === 'yesterday') {
$yesterday = date('Y-m-d', strtotime('-1 day'));
return [$yesterday, $yesterday, $timeType, '昨天'];
}
if ($timeType === 'week') {
return [date('Y-m-d', strtotime('monday this week')), $today, $timeType, '本周'];
}
if ($timeType === 'month') {
return [date('Y-m-01'), $today, $timeType, '本月'];
}
if ($timeType === 'quarter') {
$quarterMonth = ((int) floor(((int) date('n') - 1) / 3) * 3) + 1;
return [date('Y-' . str_pad((string) $quarterMonth, 2, '0', STR_PAD_LEFT) . '-01'), $today, $timeType, '本季度'];
}
if ($timeType === 'year') {
return [date('Y-01-01'), $today, $timeType, '本年'];
}
return [$today, $today, 'today', '今日'];
}
/** @param int[]|null $visibleIds @param int[] $candidateIds @return int[]|null */
private static function intersectVisibleIds(?array $visibleIds, array $candidateIds): ?array
{
if ($visibleIds === null) {
return $candidateIds;
}
return array_values(array_intersect($visibleIds, $candidateIds));
}
private static function isActiveAssistant(int $adminId): bool
{
if ($adminId <= 0) {
return false;
}
return Db::name('admin')
->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('a.id', $adminId)
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->where('a.disable', 0)
->whereNull('a.delete_time')
->count() > 0;
}
/** @param int[]|null $visibleAdminIds @return int[]|null */
private static function visibleRowDeptIds(?array $visibleAdminIds): ?array
{
if ($visibleAdminIds === null) {
return null;
}
if ($visibleAdminIds === []) {
return [];
}
return self::normalizeIds(AdminDept::whereIn('admin_id', $visibleAdminIds)->column('dept_id'));
}
/**
* 个人指标仍只查本人;成本按本人所在部门全员的加粉占比分摊。
*
* @param int[]|null $effectiveAdminIds
* @return int[]|null null 表示使用默认分摊范围
*/
private static function costAllocationAdminIds(?array $effectiveAdminIds, bool $personalScope): ?array
{
if (!$personalScope) {
return null;
}
if ($effectiveAdminIds === []) {
return [];
}
$deptIds = self::visibleRowDeptIds($effectiveAdminIds);
if ($deptIds === null || $deptIds === []) {
return $effectiveAdminIds ?? [];
}
$ids = self::normalizeIds(AdminDept::whereIn('dept_id', $deptIds)->column('admin_id'));
return $ids !== [] ? $ids : ($effectiveAdminIds ?? []);
}
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $allowedSet @return array<int,array<string,mixed>> */
private static function filterDeptRows(array $rows, array $allowedSet): array
{
if ($allowedSet === []) {
return [];
}
$out = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
if (in_array((string) ($row['type'] ?? ''), ['member', 'unbound'], true)) {
$out[] = $row;
continue;
}
$children = self::filterDeptRows(is_array($row['children'] ?? null) ? $row['children'] : [], $allowedSet);
$id = (int) ($row['id'] ?? 0);
if (isset($allowedSet[$id])) {
$row['children'] = $children;
if ($children === []) {
unset($row['children']);
}
$out[] = $row;
continue;
}
foreach ($children as $child) {
$out[] = $child;
}
}
return $out;
}
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $set */
private static function collectRowDeptIds(array $rows, array &$set): void
{
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id !== 0) {
$set[$id] = true;
}
self::collectRowDeptIds(is_array($row['children'] ?? null) ? $row['children'] : [], $set);
}
}
/**
* @param int[]|null $effectiveAdminIds
* @param array<int,true> $rowDeptSet
* @param string[]|null $mediaSources null=全部渠道;空数组=所选渠道没有可匹配的手工来源
* @return array{dept:array<int,int>,admin:array<int,int>}
*/
private static function loadOpenCounts(
string $startDate,
string $endDate,
?array $effectiveAdminIds,
array $rowDeptSet,
?array $mediaSources = null
): array
{
if ($effectiveAdminIds === [] || $rowDeptSet === [] || $mediaSources === []) {
return ['dept' => [], 'admin' => []];
}
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
if ($mediaSources !== null) {
$query->whereIn('media_source', $mediaSources);
}
$rows = $query
->fieldRaw('creator_id, SUM(total_open_count) AS open_count')
->group('creator_id')
->select()
->toArray();
if ($rows === []) {
return ['dept' => [], 'admin' => []];
}
$creatorIds = self::normalizeIds(array_column($rows, 'creator_id'));
$deptRows = $creatorIds === [] ? [] : AdminDept::whereIn('admin_id', $creatorIds)
->field('admin_id, dept_id')
->order('admin_id', 'asc')
->order('dept_id', 'asc')
->select()
->toArray();
$adminDeptMap = [];
foreach ($deptRows as $deptRow) {
$adminDeptMap[(int) $deptRow['admin_id']][] = (int) $deptRow['dept_id'];
}
$deptMetaRows = Db::name('dept')
->whereNull('delete_time')
->field('id, pid, sort')
->select()
->toArray();
$deptMeta = [];
foreach ($deptMetaRows as $deptMetaRow) {
$deptId = (int) ($deptMetaRow['id'] ?? 0);
if ($deptId > 0) {
$deptMeta[$deptId] = [
'pid' => (int) ($deptMetaRow['pid'] ?? 0),
'sort' => (int) ($deptMetaRow['sort'] ?? 0),
];
}
}
$depthCache = [];
$depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptMeta): int {
if ($deptId <= 0 || !isset($deptMeta[$deptId])) {
return 0;
}
if (isset($depthCache[$deptId])) {
return $depthCache[$deptId];
}
$parentId = (int) ($deptMeta[$deptId]['pid'] ?? 0);
if ($parentId <= 0 || $parentId === $deptId || !isset($deptMeta[$parentId])) {
return $depthCache[$deptId] = 0;
}
return $depthCache[$deptId] = $depthOf($parentId) + 1;
};
foreach ($adminDeptMap as &$deptIds) {
usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int {
$depthCompare = $depthOf($right) <=> $depthOf($left);
if ($depthCompare !== 0) {
return $depthCompare;
}
$sortCompare = (int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0);
if ($sortCompare !== 0) {
return $sortCompare;
}
return $left <=> $right;
});
}
unset($deptIds);
$direct = [];
$adminDirect = [];
foreach ($rows as $row) {
$adminId = (int) ($row['creator_id'] ?? 0);
$targetDeptId = 0;
foreach ($adminDeptMap[$adminId] ?? [] as $deptId) {
if (isset($rowDeptSet[$deptId])) {
$targetDeptId = $deptId;
break;
}
}
if ($targetDeptId === 0 && isset($rowDeptSet[-2])) {
$targetDeptId = -2;
}
if ($targetDeptId !== 0) {
$openCount = (int) ($row['open_count'] ?? 0);
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + $openCount;
$adminDirect[$adminId] = ($adminDirect[$adminId] ?? 0) + $openCount;
}
}
return ['dept' => $direct, 'admin' => $adminDirect];
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,int> $deptDirect
* @param array<int,int> $adminDirect
*/
private static function applyOpenCounts(array &$rows, array $deptDirect, array $adminDirect): int
{
$sum = 0;
foreach ($rows as &$row) {
$rowType = (string) ($row['type'] ?? '');
if (in_array($rowType, ['member', 'unbound'], true)) {
$count = $rowType === 'member'
? (int) ($adminDirect[(int) ($row['admin_id'] ?? 0)] ?? 0)
: 0;
$row['total_open_count'] = $count;
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
$row['open_appointment_rate'] = self::percent(
(int) ($row['paid_appointment_count'] ?? 0),
$count
);
$row['open_receive_rate'] = self::percent(
(int) ($row['completed_order_count'] ?? 0),
$count
);
continue;
}
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
$childTotal = self::applyOpenCounts($children, $deptDirect, $adminDirect);
if ($children !== []) {
$row['children'] = $children;
}
$directCount = (int) ($deptDirect[(int) ($row['id'] ?? 0)] ?? 0);
$count = $directCount + $childTotal;
$row['total_open_count'] = $count;
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
$row['open_appointment_rate'] = self::percent(
(int) ($row['paid_appointment_count'] ?? 0),
$count
);
$row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count);
$sum += $directCount + $childTotal;
}
unset($row);
return $sum;
}
/**
* 手工开口按 personal_yeji.media_source 保存;渠道筛选时仅匹配该渠道自身的稳定标识和名称。
* 不使用 source_group_name,避免同组多个渠道的开口数被重复计入每个渠道。
*
* @param array<string,mixed>|null $channel
* @return string[]|null
*/
private static function personalYejiMediaSources(string $channelCode, ?array $channel): ?array
{
if ($channelCode === '') {
return null;
}
if ($channel === null) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn ($value): string => trim((string) $value),
[
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
]
), static fn (string $value): bool => $value !== '')));
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows): array
{
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
}));
// 每个可见顶层分支只展示同一层级:有权限看到下级时展示直属子部门;没有可见
// 下级时保留当前部门。这样既能按角色/DataScope 展示子部门,也不会把父子汇总
// 同时放进占比图造成重复计算。
$chartRows = [];
foreach ($visibleRows as $row) {
$children = array_values(array_filter(
is_array($row['children'] ?? null) ? $row['children'] : [],
static fn (array $child): bool => (int) ($child['id'] ?? 0) > 0
&& !((bool) ($child['_virtual_bucket'] ?? false))
));
if ($children !== []) {
foreach ($children as $child) {
$chartRows[] = $child;
}
continue;
}
$chartRows[] = $row;
}
return $chartRows;
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function topRows(array $rows, string $metric): array
{
$rows = array_values(array_filter($rows, static fn (array $row): bool => (int) ($row['id'] ?? 0) > 0));
usort($rows, static function (array $left, array $right) use ($metric): int {
return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
});
return array_map(static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], array_slice($rows, 0, 6));
}
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
{
$query = Db::name('admin')
->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->where('a.disable', 0)
->whereNull('a.delete_time');
if ($baseVisibleAdminIds !== null) {
if ($baseVisibleAdminIds === []) {
return [];
}
$query->whereIn('a.id', $baseVisibleAdminIds);
}
if ($selectedDeptId > 0) {
if ($selectedDeptIds === []) {
return [];
}
$query->join('admin_dept ad', 'ad.admin_id = a.id')->whereIn('ad.dept_id', $selectedDeptIds);
}
return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray();
}
/** @param array<int,true>|null $allowedDeptSet @param int[] $selectedDeptIds @return int[]|null */
private static function resolveTargetDeptIds(?array $allowedDeptSet, array $selectedDeptIds, int $selectedDeptId): ?array
{
if ($selectedDeptId > 0) {
return $selectedDeptIds;
}
if ($allowedDeptSet === null) {
return null;
}
return array_map('intval', array_keys($allowedDeptSet));
}
/** @param int[]|null $effectiveAdminIds @param int[]|null $targetDeptIds @return array<string,mixed> */
private static function buildTargetProgress(int $year, ?array $effectiveAdminIds, ?array $targetDeptIds): array
{
$targetQuery = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
if ($targetDeptIds !== null) {
if ($targetDeptIds === []) {
$targetRows = [];
} else {
$targetRows = $targetQuery->whereIn('dept_id', $targetDeptIds)
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
->group('`year_month`')
->select()
->toArray();
}
} else {
$targetRows = $targetQuery
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
->group('`year_month`')
->select()
->toArray();
}
$actualRows = [];
if ($effectiveAdminIds !== []) {
$actualQuery = Db::name('tcm_prescription_order')
->alias('po')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [
strtotime($year . '-01-01 00:00:00'),
strtotime($year . '-12-31 23:59:59'),
]);
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($actualQuery, 'po');
if ($effectiveAdminIds !== null) {
$actualQuery->whereIn('po.creator_id', $effectiveAdminIds);
}
$actualRows = $actualQuery
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
->group('month_no')
->select()
->toArray();
}
$targets = array_fill(1, 12, 0.0);
$actuals = array_fill(1, 12, 0.0);
$deptCountSet = [];
foreach ($targetRows as $row) {
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
if ($month >= 1 && $month <= 12) {
$targets[$month] = round((float) ($row['target_amount'] ?? 0), 2);
$deptCountSet[$month] = (int) ($row['dept_count'] ?? 0);
}
}
foreach ($actualRows as $row) {
$month = (int) ($row['month_no'] ?? 0);
if ($month >= 1 && $month <= 12) {
$actuals[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
}
}
$targetCumulative = [];
$actualCumulative = [];
$targetRunning = 0.0;
$actualRunning = 0.0;
for ($month = 1; $month <= 12; $month++) {
$targetRunning = round($targetRunning + $targets[$month], 2);
$actualRunning = round($actualRunning + $actuals[$month], 2);
$targetCumulative[] = $targetRunning;
$actualCumulative[] = $actualRunning;
}
$currentMonth = (int) date('n');
return [
'year' => $year,
'target_amount' => $targetRunning,
'actual_amount' => $actualRunning,
'completion_rate' => $targetRunning > 0 ? round($actualRunning / $targetRunning * 100, 2) : null,
'current_month_target' => $targets[$currentMonth],
'current_month_actual' => $actuals[$currentMonth],
'current_month_rate' => $targets[$currentMonth] > 0
? round($actuals[$currentMonth] / $targets[$currentMonth] * 100, 2)
: null,
'department_count' => max($deptCountSet ?: [0]),
'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)),
'target_cumulative' => $targetCumulative,
'actual_cumulative' => $actualCumulative,
];
}
/** @param array<int|string,mixed> $ids @return int[] */
private static function normalizeIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
}
private static function percent(int $numerator, int $denominator): float
{
return $denominator > 0 ? round($numerator / $denominator * 100, 2) : 0.0;
}
}
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\AdminDept;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 一诊「医生看板」。
*
* 医生是最终展示维度;部门权限通过实际经手医助下推到预约、诊单与业绩:
* - 医生 SELF:只看本人医生数据,不限制经手医助;
* - 医助 SELF:只看本人经手患者关联的医生数据;
* - 组长/经理:只看数据范围内医助经手患者关联的医生数据;
* - 管理员/ALL:全部医生,可再选择部门收窄。
*/
class FirstVisitDoctorDashboardLogic
{
private const DOCTOR_ROLE_ID = 1;
private const ASSISTANT_ROLE_ID = 2;
private const TREND_DAYS = 30;
/** @return array<string,mixed> */
public static function overview(array $params, int $adminId, array $adminInfo): array
{
$range = self::resolveRange((string) ($params['time_type'] ?? 'month'));
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
$roleIds = self::normalizeIds(Db::name('admin_role')->where('admin_id', $adminId)->column('role_id'));
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
$doctorSelf = !$isRoot
&& $scopeValue === DataScopeService::SCOPE_SELF
&& in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
$activeOnly = (int) ($params['active_only'] ?? 1) !== 0;
$selectedDeptId = $doctorSelf ? 0 : max(0, (int) ($params['dept_id'] ?? 0));
$selectedDoctorId = max(0, (int) ($params['doctor_id'] ?? 0));
$threshold = min(100.0, max(1.0, (float) ($params['alert_threshold'] ?? 15)));
$allDoctorOptions = self::doctorOptions($activeOnly, $doctorSelf ? $adminId : 0);
$doctorIds = self::normalizeIds(array_column($allDoctorOptions, 'id'));
if ($selectedDoctorId > 0) {
$doctorIds = in_array($selectedDoctorId, $doctorIds, true) ? [$selectedDoctorId] : [];
}
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
$selectedDeptId,
$allowedDeptSet
);
$assistantIds = self::resolveAssistantScope(
$adminId,
$adminInfo,
$doctorSelf,
$selectedDeptId,
$selectedDeptIds,
$deptSelectionValid
);
$stats = DoctorDailyStatsLogic::overview(
[
'start_date' => $range['start'],
'end_date' => $range['end'],
],
$adminId,
$adminInfo,
$doctorIds,
$assistantIds
);
$doctorDeptNames = self::doctorDepartmentNames($doctorIds);
$doctorStatus = self::doctorStatusMap($doctorIds);
$rows = self::enrichRows(
is_array($stats['rows'] ?? null) ? $stats['rows'] : [],
$doctorDeptNames,
$doctorStatus
);
// 支付单没有医生字段,当前数据中的低额支付单也未关联患者;挂号只能按创建人及权限范围汇总,
// 不能为了医生排行而将医助创建的支付单虚构分摊给某位医生。
$registrationCreatorIds = $doctorSelf ? [$adminId] : $assistantIds;
$registrationTotal = self::loadRegistrationTotal(
$range['start'],
$range['end'],
$registrationCreatorIds
);
$summary = self::buildSummary($rows, $registrationTotal);
$trend = self::buildAmountTrend($doctorIds, $assistantIds);
$selectedDeptName = $selectedDeptId > 0
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedDoctorName = '';
if ($selectedDoctorId > 0) {
foreach ($allDoctorOptions as $doctor) {
if ((int) ($doctor['id'] ?? 0) === $selectedDoctorId) {
$selectedDoctorName = (string) ($doctor['name'] ?? '');
break;
}
}
}
return [
'meta' => [
'time_type' => $range['type'],
'time_label' => $range['label'],
'start_date' => $range['start'],
'end_date' => $range['end'],
'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue,
'scope_label' => $doctorSelf ? '医生本人' : DataScopeService::scopeLabel($scopeValue),
'scope_kind' => $doctorSelf ? 'doctor_self' : ($assistantIds === null ? 'all' : 'assistant_scope'),
'selected_dept_name' => $selectedDeptName,
'selected_doctor_name' => $selectedDoctorName,
'doctor_count' => count($rows),
'registration_rule' => '总挂号按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个,并按订单创建人及当前权限范围归属',
'appointment_rule' => '总预约包含已预约、已取消、已完成和已过号;面诊取状态为已完成的预约',
'performance_rule' => '诊单按订单创建时间统计,排除已取消、拒收、全额退款及部分退款,金额归属处方开方医生',
],
'filters' => [
'departments' => $doctorSelf ? [] : DeptLogic::getAllDataScoped($adminId, $adminInfo),
'doctors' => $allDoctorOptions,
'can_filter_department' => !$doctorSelf,
],
'summary' => $summary,
'rankings' => [
'amounts' => self::ranking($rows, 'deal_amount', 8),
'conversion' => self::ranking($rows, 'receive_conversion_rate', 8),
],
'funnel' => [
['key' => 'registration', 'label' => '挂号', 'value' => (int) $summary['registration_total']],
['key' => 'appointment', 'label' => '预约', 'value' => (int) $summary['appointment_total']],
['key' => 'interview', 'label' => '面诊', 'value' => (int) $summary['interview_count']],
['key' => 'receive', 'label' => '接诊', 'value' => (int) $summary['order_count']],
['key' => 'deal', 'label' => '成交', 'value' => (int) $summary['order_count']],
],
'trend' => $trend,
'alerts' => self::alertRows($rows, $threshold),
'alert_threshold' => $threshold,
'rows' => $rows,
];
}
/** @return array<string,string> */
private static function resolveRange(string $type): array
{
$today = date('Y-m-d');
if ($type === 'today') {
return ['type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today];
}
if ($type === 'week') {
return [
'type' => 'week', 'label' => '本周',
'start' => date('Y-m-d', strtotime('monday this week')), 'end' => $today,
];
}
return ['type' => 'month', 'label' => '本月', 'start' => date('Y-m-01'), 'end' => $today];
}
/** @return array<int,array{id:int,name:string,disable:int}> */
private static function doctorOptions(bool $activeOnly, int $selfDoctorId = 0): array
{
$query = Db::name('admin')->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', self::DOCTOR_ROLE_ID)
->whereNull('a.delete_time');
if ($activeOnly) {
$query->where('a.disable', 0);
}
if ($selfDoctorId > 0) {
$query->where('a.id', $selfDoctorId);
}
return $query->field('a.id, a.name, a.disable')
->distinct(true)
->order('a.disable', 'asc')
->order('a.name', 'asc')
->select()
->toArray();
}
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
{
if ($selectedDeptId <= 0) {
return [[], true];
}
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
if ($allowedSet !== null) {
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
}
return [$ids, $ids !== []];
}
/**
* null 表示医生本人或 ALL,不附加医助过滤;数组表示必须按这些医助经手的数据收窄。
*
* @param int[] $selectedDeptIds
* @return int[]|null
*/
private static function resolveAssistantScope(
int $adminId,
array $adminInfo,
bool $doctorSelf,
int $selectedDeptId,
array $selectedDeptIds,
bool $deptSelectionValid
): ?array {
if ($doctorSelf) {
return null;
}
if (!$deptSelectionValid) {
return [];
}
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$assistantIds = self::activeAssistantIds($visibleIds);
if ($selectedDeptId <= 0) {
return $visibleIds === null ? null : $assistantIds;
}
$deptAssistantIds = self::activeAssistantIdsByDepartment($selectedDeptIds);
if ($visibleIds === null) {
return $deptAssistantIds;
}
return array_values(array_intersect($assistantIds, $deptAssistantIds));
}
/** @param int[]|null $visibleIds @return int[] */
private static function activeAssistantIds(?array $visibleIds): array
{
if ($visibleIds === []) {
return [];
}
$query = Db::name('admin')->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->where('a.disable', 0)
->whereNull('a.delete_time');
if ($visibleIds !== null) {
$query->whereIn('a.id', $visibleIds);
}
return self::normalizeIds($query->column('a.id'));
}
/** @param int[] $deptIds @return int[] */
private static function activeAssistantIdsByDepartment(array $deptIds): array
{
if ($deptIds === []) {
return [];
}
return self::normalizeIds(Db::name('admin')->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->join('admin_dept ad', 'ad.admin_id = a.id')
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->whereIn('ad.dept_id', $deptIds)
->where('a.disable', 0)
->whereNull('a.delete_time')
->distinct(true)
->column('a.id'));
}
/** @param int[] $doctorIds @return array<int,string> */
private static function doctorDepartmentNames(array $doctorIds): array
{
if ($doctorIds === []) {
return [];
}
$rows = AdminDept::alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL')
->whereIn('ad.admin_id', $doctorIds)
->field('ad.admin_id, d.name')
->order('d.sort', 'desc')
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$id = (int) ($row['admin_id'] ?? 0);
$name = trim((string) ($row['name'] ?? ''));
if ($id > 0 && $name !== '' && !isset($out[$id])) {
$out[$id] = $name;
}
}
return $out;
}
/** @param int[] $doctorIds @return array<int,int> */
private static function doctorStatusMap(array $doctorIds): array
{
if ($doctorIds === []) {
return [];
}
$rows = Db::name('admin')->whereIn('id', $doctorIds)->field('id, disable')->select()->toArray();
$out = [];
foreach ($rows as $row) {
$out[(int) $row['id']] = (int) ($row['disable'] ?? 0);
}
return $out;
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function enrichRows(array $rows, array $deptNames, array $statusMap): array
{
$out = [];
foreach ($rows as $row) {
$id = (int) ($row['admin_id'] ?? 0);
$appointmentTotal = (int) ($row['appointment_total'] ?? 0);
$interviewCount = (int) ($row['appointment_completed'] ?? 0);
$orderCount = (int) ($row['deal_order_count'] ?? 0);
$out[] = array_merge($row, [
'doctor_id' => $id,
'department_name' => (string) ($deptNames[$id] ?? '未分配部门'),
'interview_count' => $interviewCount,
'order_count' => $orderCount,
'appointment_completion_rate' => $appointmentTotal > 0
? round($interviewCount / $appointmentTotal * 100, 2)
: null,
'receive_conversion_rate' => $interviewCount > 0
? round($orderCount / $interviewCount * 100, 2)
: null,
'status' => (int) ($statusMap[$id] ?? 0) === 0 ? 'active' : 'disabled',
]);
}
usort($out, static fn (array $a, array $b): int => (($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
return $out;
}
/** @param array<int,array<string,mixed>> $rows @return array<string,mixed> */
private static function buildSummary(array $rows, int $registrationTotal): array
{
$appointmentTotal = 0;
$interviewCount = 0;
$orderCount = 0;
$dealAmount = 0.0;
$missed = 0;
$cancelled = 0;
foreach ($rows as $row) {
$appointmentTotal += (int) ($row['appointment_total'] ?? 0);
$interviewCount += (int) ($row['interview_count'] ?? 0);
$orderCount += (int) ($row['order_count'] ?? 0);
$dealAmount += (float) ($row['deal_amount'] ?? 0);
$missed += (int) ($row['appointment_missed'] ?? 0);
$cancelled += (int) ($row['appointment_cancelled'] ?? 0);
}
return [
'registration_total' => $registrationTotal,
'appointment_total' => $appointmentTotal,
'interview_count' => $interviewCount,
'order_count' => $orderCount,
'deal_amount' => round($dealAmount, 2),
'avg_order_amount' => $orderCount > 0 ? round($dealAmount / $orderCount, 2) : null,
'appointment_completion_rate' => $appointmentTotal > 0
? round($interviewCount / $appointmentTotal * 100, 2)
: null,
'receive_conversion_rate' => $interviewCount > 0
? round($orderCount / $interviewCount * 100, 2)
: null,
'missed_count' => $missed,
'cancelled_count' => $cancelled,
];
}
/**
* 新挂号口径:支付时间位于筛选区间、状态为已支付、0 < 实收金额 < 10 元。
* null 表示全部创建人,空数组表示当前权限范围没有可统计创建人。
*
* @param int[]|null $creatorIds
*/
private static function loadRegistrationTotal(
string $startDate,
string $endDate,
?array $creatorIds
): int {
if ($creatorIds === []) {
return 0;
}
$query = Db::name('order')
->whereNull('delete_time')
->where('status', 2)
->where('amount', '>', 0)
->where('amount', '<', 10)
->whereNotNull('payment_time')
->where('payment_time', '<>', '')
->whereBetweenTime(
'payment_time',
$startDate . ' 00:00:00',
$endDate . ' 23:59:59'
);
if ($creatorIds !== null) {
$query->whereIn('creator_id', $creatorIds);
}
return (int) $query->count();
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function ranking(array $rows, string $field, int $limit): array
{
$ranked = $rows;
usort($ranked, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
$out = [];
foreach (array_slice($ranked, 0, $limit) as $row) {
$out[] = [
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
'name' => (string) ($row['doctor_name'] ?? ''),
'value' => round((float) ($row[$field] ?? 0), 2),
'interview_count' => (int) ($row['interview_count'] ?? 0),
'order_count' => (int) ($row['order_count'] ?? 0),
];
}
return $out;
}
/** @param int[] $doctorIds @param int[]|null $assistantIds @return array<string,mixed> */
private static function buildAmountTrend(array $doctorIds, ?array $assistantIds): array
{
$endDate = date('Y-m-d');
$startDate = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
$amountByDate = [];
if ($doctorIds !== [] && $assistantIds !== []) {
$query = Db::name('tcm_prescription_order')->alias('o')
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0)
->where('o.create_time', 'between', [
strtotime($startDate . ' 00:00:00'),
strtotime($endDate . ' 23:59:59'),
]);
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'o');
if ($assistantIds !== null) {
$query->whereIn('o.creator_id', $assistantIds);
}
$rows = $query
->fieldRaw("FROM_UNIXTIME(o.create_time, '%Y-%m-%d') AS date_label, SUM(o.amount) AS amount_sum")
->group('date_label')
->order('date_label', 'asc')
->select()
->toArray();
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date !== '') {
$amountByDate[$date] = round((float) ($row['amount_sum'] ?? 0), 2);
}
}
}
$dates = [];
$labels = [];
$amounts = [];
for ($offset = 0; $offset < self::TREND_DAYS; $offset++) {
$date = date('Y-m-d', strtotime($startDate . ' +' . $offset . ' days'));
$dates[] = $date;
$labels[] = date('m-d', strtotime($date));
$amounts[] = (float) ($amountByDate[$date] ?? 0);
}
return [
'start_date' => $startDate,
'end_date' => $endDate,
'dates' => $dates,
'labels' => $labels,
'amounts' => $amounts,
];
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function alertRows(array $rows, float $threshold): array
{
$alerts = array_values(array_filter($rows, static function (array $row) use ($threshold): bool {
$interviews = (int) ($row['interview_count'] ?? 0);
$rate = $row['receive_conversion_rate'] ?? null;
return $interviews > 0 && ($rate === null || (float) $rate < $threshold);
}));
usort($alerts, static fn (array $a, array $b): int => (($a['receive_conversion_rate'] ?? -1) <=> ($b['receive_conversion_rate'] ?? -1)) ?: (($b['interview_count'] ?? 0) <=> ($a['interview_count'] ?? 0)));
return array_map(static function (array $row) use ($threshold): array {
$rate = (float) ($row['receive_conversion_rate'] ?? 0);
return [
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
'doctor_name' => (string) ($row['doctor_name'] ?? ''),
'department_name' => (string) ($row['department_name'] ?? ''),
'interview_count' => (int) ($row['interview_count'] ?? 0),
'order_count' => (int) ($row['order_count'] ?? 0),
'rate' => round($rate, 2),
'severity' => $rate < $threshold / 2 ? 'high' : 'medium',
'suggestion' => (int) ($row['order_count'] ?? 0) === 0
? '当前有面诊但无接诊诊单,建议核对诊单及跟进记录'
: '接诊转化低于预警线,建议复盘患者需求与沟通记录',
];
}, $alerts);
}
/** @param array<int|string,mixed> $ids @return int[] */
private static function normalizeIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
}
}
@@ -0,0 +1,695 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\AdminDept;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 一诊「挂号统计」。
*
* 统计口径:
* - 挂号:order.payment_time,已支付且 0 < amount < 10,每笔支付订单计 1 个;
* 按支付订单 creator_id 归属员工。
* - 预约:doctor_appointment.appointment_date,状态 1/3/4,排除已取消 2
* 归属优先挂号医助 assistant_id,再回退诊单医助 assistant_id。
* - 诊单:tcm_prescription_order.create_time,归属订单 creator_id,排除履约 4/9/10
* - 所有部门和员工筛选都只能收窄 DataScope,不允许 HTTP 参数扩大当前账号范围。
*/
class FirstVisitRegistrationStatsLogic
{
private const ASSISTANT_ROLE_ID = 2;
/** @return array<string,mixed> */
public static function overview(array $params, int $adminId, array $adminInfo): array
{
$range = self::resolveRange((string) ($params['time_type'] ?? 'today'));
$baseVisibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
$selectedDeptId,
$allowedDeptSet
);
$assistants = $deptSelectionValid
? self::assistantOptions($baseVisibleIds, $selectedDeptIds, $selectedDeptId)
: [];
$assistantIds = self::normalizeIds(array_column($assistants, 'id'));
if ($selectedAssistantId > 0) {
$assistantIds = in_array($selectedAssistantId, $assistantIds, true)
? [$selectedAssistantId]
: [];
}
$departmentTree = DeptLogic::getAllDataScoped($adminId, $adminInfo);
$departmentIndex = [];
self::flattenDepartmentTree($departmentTree, $departmentIndex, 0);
$assignment = self::buildAssistantDepartmentMap(
$assistantIds,
$departmentIndex,
$selectedDeptIds,
$selectedDeptId
);
$appointmentDaily = self::loadAppointmentDaily(
min($range['compare_start'], $range['start']),
$range['day_after_tomorrow'],
$assistantIds
);
$registrationDaily = self::loadRegistrationDaily(
min($range['compare_start'], $range['start']),
$range['end'],
$assistantIds
);
$orderDaily = self::loadOrderDaily(
min($range['compare_start'], $range['start']),
$range['end'],
$assistantIds
);
$members = self::buildMemberRows(
$assistants,
$assistantIds,
$assignment,
$appointmentDaily,
$registrationDaily,
$orderDaily,
$range
);
$groups = self::buildDepartmentGroups($members, $departmentIndex);
$summary = self::buildSummary($members, $range);
$targetDeptIds = self::resolveTargetDeptIds(
$adminId,
$scopeValue,
$selectedAssistantId,
$selectedDeptIds,
$selectedDeptId
);
$target = self::buildTarget((int) date('Y'), $assistantIds, $targetDeptIds);
$selectedDeptName = $selectedDeptId > 0
? (string) ($departmentIndex[$selectedDeptId]['name'] ?? '')
: '';
$selectedAssistantName = '';
if ($selectedAssistantId > 0) {
foreach ($assistants as $assistant) {
if ((int) ($assistant['id'] ?? 0) === $selectedAssistantId) {
$selectedAssistantName = (string) ($assistant['name'] ?? '');
break;
}
}
}
return [
'meta' => [
'time_type' => $range['type'],
'time_label' => $range['label'],
'start_date' => $range['start'],
'end_date' => $range['end'],
'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'member_count' => count($assistantIds),
'registration_rule' => '支付时间在统计区间,状态为已支付且实收金额低于 10 元(大于 0 元),每笔支付订单计 1 个挂号',
'appointment_rule' => '预约日期在统计区间,状态为已预约、已完成或已过号,排除已取消',
'performance_rule' => '按订单创建时间和创建人统计,排除已取消、拒收和退款',
],
'filters' => [
'departments' => $departmentTree,
'assistants' => $assistants,
],
'summary' => $summary,
'employee_rows' => $groups,
'rankings' => [
'performance' => self::rankMembers($members, 'order_amount', 10),
'registrations' => self::rankMembers($members, 'registration_count', 10),
'appointments' => self::rankMembers($members, 'appointment_count', 10),
],
'departments' => self::departmentSummaryRows($groups),
'target' => $target,
];
}
/** @return array<string,string> */
private static function resolveRange(string $type): array
{
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$dayAfterTomorrow = date('Y-m-d', strtotime('+2 days'));
if ($type === 'week') {
$start = date('Y-m-d', strtotime('monday this week'));
return [
'type' => 'week', 'label' => '本周', 'start' => $start, 'end' => $today,
'compare_start' => date('Y-m-d', strtotime($start . ' -7 days')),
'compare_end' => date('Y-m-d', strtotime($today . ' -7 days')),
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
];
}
if ($type === 'month') {
$start = date('Y-m-01');
$previousStart = date('Y-m-01', strtotime('first day of previous month'));
$previousLastDay = (int) date('t', strtotime($previousStart));
$day = min((int) date('j'), $previousLastDay);
return [
'type' => 'month', 'label' => '本月', 'start' => $start, 'end' => $today,
'compare_start' => $previousStart,
'compare_end' => date('Y-m-d', strtotime($previousStart . ' +' . max(0, $day - 1) . ' days')),
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
];
}
return [
'type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today,
'compare_start' => date('Y-m-d', strtotime('-1 day')),
'compare_end' => date('Y-m-d', strtotime('-1 day')),
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
];
}
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
{
if ($selectedDeptId <= 0) {
return [[], true];
}
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
if ($allowedSet !== null) {
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
}
return [$ids, $ids !== []];
}
/** @param int[]|null $visibleIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
private static function assistantOptions(?array $visibleIds, array $selectedDeptIds, int $selectedDeptId): array
{
if ($visibleIds === []) {
return [];
}
$query = Db::name('admin')->alias('a')
->join('admin_role ar', 'ar.admin_id = a.id')
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
->where('a.disable', 0)
->whereNull('a.delete_time');
if ($visibleIds !== null) {
$query->whereIn('a.id', $visibleIds);
}
if ($selectedDeptId > 0) {
if ($selectedDeptIds === []) {
return [];
}
$query->join('admin_dept ad', 'ad.admin_id = a.id')
->whereIn('ad.dept_id', $selectedDeptIds);
}
return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray();
}
/** @param array<int,array<string,mixed>> $nodes @param array<int,array<string,mixed>> $index */
private static function flattenDepartmentTree(array $nodes, array &$index, int $depth): void
{
foreach ($nodes as $node) {
$id = (int) ($node['id'] ?? 0);
if ($id <= 0) {
continue;
}
$index[$id] = [
'id' => $id,
'pid' => (int) ($node['pid'] ?? 0),
'name' => (string) ($node['name'] ?? '未命名部门'),
'sort' => (int) ($node['sort'] ?? 0),
'depth' => $depth,
];
self::flattenDepartmentTree(
is_array($node['children'] ?? null) ? $node['children'] : [],
$index,
$depth + 1
);
}
}
/** @param int[] $assistantIds @param array<int,array<string,mixed>> $deptIndex @param int[] $selectedDeptIds @return array<int,int> */
private static function buildAssistantDepartmentMap(
array $assistantIds,
array $deptIndex,
array $selectedDeptIds,
int $selectedDeptId
): array {
if ($assistantIds === []) {
return [];
}
$allowed = $selectedDeptId > 0 ? array_fill_keys($selectedDeptIds, true) : null;
$rows = AdminDept::whereIn('admin_id', $assistantIds)
->field('admin_id, dept_id')
->select()
->toArray();
$candidates = [];
foreach ($rows as $row) {
$aid = (int) ($row['admin_id'] ?? 0);
$deptId = (int) ($row['dept_id'] ?? 0);
if (!isset($deptIndex[$deptId]) || ($allowed !== null && !isset($allowed[$deptId]))) {
continue;
}
$candidates[$aid][] = $deptId;
}
$out = [];
foreach ($assistantIds as $aid) {
$ids = $candidates[$aid] ?? [];
usort($ids, static function (int $left, int $right) use ($deptIndex): int {
$depthCompare = (int) ($deptIndex[$right]['depth'] ?? 0) <=> (int) ($deptIndex[$left]['depth'] ?? 0);
if ($depthCompare !== 0) {
return $depthCompare;
}
return (int) ($deptIndex[$right]['sort'] ?? 0) <=> (int) ($deptIndex[$left]['sort'] ?? 0);
});
$out[$aid] = (int) ($ids[0] ?? 0);
}
return $out;
}
/** @param int[] $assistantIds @return array<int,array<string,array{count:int}>> */
private static function loadAppointmentDaily(string $startDate, string $endDate, array $assistantIds): array
{
if ($assistantIds === []) {
return [];
}
$effective = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
$query = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', 'between', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
->whereRaw("({$effective}) IN (" . implode(',', $assistantIds) . ')');
$rows = $query
->field([
'a.appointment_date AS date_label',
Db::raw("({$effective}) AS assistant_id"),
Db::raw('COUNT(*) AS item_count'),
])
->group(['a.appointment_date', $effective])
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$aid = (int) ($row['assistant_id'] ?? 0);
$date = (string) ($row['date_label'] ?? '');
if ($aid > 0 && $date !== '') {
$out[$aid][$date] = ['count' => (int) ($row['item_count'] ?? 0)];
}
}
return $out;
}
/** @param int[] $assistantIds @return array<int,array<string,array{count:int}>> */
private static function loadRegistrationDaily(string $startDate, string $endDate, array $assistantIds): array
{
if ($assistantIds === []) {
return [];
}
$rows = Db::name('order')->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.amount', '>', 0)
->where('o.amount', '<', 10)
->whereBetweenTime(
'o.payment_time',
$startDate . ' 00:00:00',
$endDate . ' 23:59:59'
)
->whereIn('o.creator_id', $assistantIds)
->fieldRaw('o.creator_id AS assistant_id, DATE(o.payment_time) AS date_label, COUNT(*) AS item_count')
->group(['o.creator_id', 'date_label'])
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$aid = (int) ($row['assistant_id'] ?? 0);
$date = (string) ($row['date_label'] ?? '');
if ($aid > 0 && $date !== '') {
$out[$aid][$date] = ['count' => (int) ($row['item_count'] ?? 0)];
}
}
return $out;
}
/** @param int[] $assistantIds @return array<int,array<string,array{count:int,amount:float}>> */
private static function loadOrderDaily(string $startDate, string $endDate, array $assistantIds): array
{
if ($assistantIds === []) {
return [];
}
$query = Db::name('tcm_prescription_order')->alias('po')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [
strtotime($startDate . ' 00:00:00'),
strtotime($endDate . ' 23:59:59'),
])
->whereIn('po.creator_id', $assistantIds);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
$rows = $query
->fieldRaw("po.creator_id AS assistant_id, FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count, SUM(po.amount) AS amount_sum")
->group(['po.creator_id', 'date_label'])
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$aid = (int) ($row['assistant_id'] ?? 0);
$date = (string) ($row['date_label'] ?? '');
if ($aid > 0 && $date !== '') {
$out[$aid][$date] = [
'count' => (int) ($row['item_count'] ?? 0),
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
];
}
}
return $out;
}
/** @return array<int,array<string,mixed>> */
private static function buildMemberRows(
array $assistants,
array $assistantIds,
array $assignment,
array $appointmentDaily,
array $registrationDaily,
array $orderDaily,
array $range
): array {
$assistantIndex = [];
foreach ($assistants as $assistant) {
$assistantIndex[(int) ($assistant['id'] ?? 0)] = (string) ($assistant['name'] ?? '未命名员工');
}
$rows = [];
foreach ($assistantIds as $aid) {
$appointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$compareAppointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
$registrationCount = self::sumDaily($registrationDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$compareRegistrationCount = self::sumDaily($registrationDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
$orderCount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$orderAmount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'amount');
$rows[] = [
'id' => 'admin-' . $aid,
'admin_id' => $aid,
'dept_id' => (int) ($assignment[$aid] ?? 0),
'name' => (string) ($assistantIndex[$aid] ?? '未命名员工'),
'row_type' => 'employee',
'registration_count' => (int) $registrationCount,
'compare_registration_count' => (int) $compareRegistrationCount,
'registration_compare_rate' => self::relativeChange($registrationCount, $compareRegistrationCount),
'appointment_count' => (int) $appointmentCount,
'compare_appointment_count' => (int) $compareAppointmentCount,
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
'tomorrow_count' => (int) ($appointmentDaily[$aid][$range['tomorrow']]['count'] ?? 0),
'day_after_count' => (int) ($appointmentDaily[$aid][$range['day_after_tomorrow']]['count'] ?? 0),
'order_count' => (int) $orderCount,
'order_amount' => round((float) $orderAmount, 2),
'status' => 'normal',
];
}
usort($rows, static fn (array $a, array $b): int => ($b['registration_count'] <=> $a['registration_count']) ?: ($b['appointment_count'] <=> $a['appointment_count']) ?: ($b['order_amount'] <=> $a['order_amount']));
return $rows;
}
/** @param array<int,array<string,mixed>> $members @param array<int,array<string,mixed>> $deptIndex @return array<int,array<string,mixed>> */
private static function buildDepartmentGroups(array $members, array $deptIndex): array
{
$groups = [];
foreach ($members as $member) {
$deptId = (int) ($member['dept_id'] ?? 0);
$key = $deptId > 0 ? $deptId : -2;
if (!isset($groups[$key])) {
$groups[$key] = [
'id' => 'dept-' . $key,
'dept_id' => $key,
'name' => $key > 0 ? (string) ($deptIndex[$key]['name'] ?? '未命名部门') : '未分配部门',
'row_type' => 'department',
'member_count' => 0,
'registration_count' => 0,
'compare_registration_count' => 0,
'appointment_count' => 0,
'compare_appointment_count' => 0,
'tomorrow_count' => 0,
'day_after_count' => 0,
'order_count' => 0,
'order_amount' => 0.0,
'children' => [],
'_sort' => $key > 0 ? (int) ($deptIndex[$key]['sort'] ?? 0) : -1,
];
}
$groups[$key]['children'][] = $member;
$groups[$key]['member_count']++;
foreach (['registration_count', 'compare_registration_count', 'appointment_count', 'compare_appointment_count', 'tomorrow_count', 'day_after_count', 'order_count'] as $field) {
$groups[$key][$field] += (int) ($member[$field] ?? 0);
}
$groups[$key]['order_amount'] += (float) ($member['order_amount'] ?? 0);
}
foreach ($groups as &$group) {
$group['order_amount'] = round((float) $group['order_amount'], 2);
$group['registration_compare_rate'] = self::relativeChange(
(float) $group['registration_count'],
(float) $group['compare_registration_count']
);
$group['appointment_compare_rate'] = self::relativeChange(
(float) $group['appointment_count'],
(float) $group['compare_appointment_count']
);
$group['status'] = 'normal';
}
unset($group);
$out = array_values($groups);
usort($out, static fn (array $a, array $b): int => ($b['_sort'] <=> $a['_sort']) ?: strcmp((string) $a['name'], (string) $b['name']));
foreach ($out as &$row) {
unset($row['_sort']);
}
unset($row);
return $out;
}
/** @param array<int,array<string,mixed>> $members @return array<string,mixed> */
private static function buildSummary(array $members, array $range): array
{
$registrationCount = 0;
$compareRegistrationCount = 0;
$appointmentCount = 0;
$compareAppointmentCount = 0;
$orderCount = 0;
$orderAmount = 0.0;
foreach ($members as $member) {
$registrationCount += (int) ($member['registration_count'] ?? 0);
$compareRegistrationCount += (int) ($member['compare_registration_count'] ?? 0);
$appointmentCount += (int) ($member['appointment_count'] ?? 0);
$compareAppointmentCount += (int) ($member['compare_appointment_count'] ?? 0);
$orderCount += (int) ($member['order_count'] ?? 0);
$orderAmount += (float) ($member['order_amount'] ?? 0);
}
return [
'registration_count' => $registrationCount,
'registration_compare_count' => $compareRegistrationCount,
'registration_compare_rate' => self::relativeChange($registrationCount, $compareRegistrationCount),
'appointment_count' => $appointmentCount,
'appointment_compare_count' => $compareAppointmentCount,
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
'order_count' => $orderCount,
'order_amount' => round($orderAmount, 2),
'range_label' => $range['label'],
];
}
/** @param array<int,array<string,mixed>> $members @return array<int,array<string,mixed>> */
private static function rankMembers(array $members, string $field, int $limit): array
{
$rows = $members;
usort($rows, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['name'], (string) $b['name']));
$out = [];
foreach (array_slice($rows, 0, $limit) as $row) {
$countField = match ($field) {
'order_amount' => 'order_count',
'registration_count' => 'registration_count',
default => 'appointment_count',
};
$out[] = [
'admin_id' => (int) ($row['admin_id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'value' => $field === 'order_amount'
? round((float) ($row[$field] ?? 0), 2)
: (int) ($row[$field] ?? 0),
'count' => (int) ($row[$countField] ?? 0),
];
}
return $out;
}
/** @param array<int,array<string,mixed>> $groups @return array<int,array<string,mixed>> */
private static function departmentSummaryRows(array $groups): array
{
$rows = [];
foreach ($groups as $group) {
$copy = $group;
unset($copy['children']);
$rows[] = $copy;
}
return $rows;
}
/** @param int[] $selectedDeptIds @return int[]|null */
private static function resolveTargetDeptIds(
int $adminId,
int $scopeValue,
int $selectedAssistantId,
array $selectedDeptIds,
int $selectedDeptId
): ?array {
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return [];
}
$scopeDeptIds = null;
if ($scopeValue !== DataScopeService::SCOPE_ALL) {
$ownDeptIds = self::normalizeIds(AdminDept::where('admin_id', $adminId)->column('dept_id'));
if ($scopeValue === DataScopeService::SCOPE_DEPT) {
$scopeDeptIds = $ownDeptIds;
} else {
$set = [];
foreach ($ownDeptIds as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
$id = (int) $id;
if ($id > 0) {
$set[$id] = true;
}
}
}
$scopeDeptIds = array_map('intval', array_keys($set));
}
}
if ($selectedDeptId <= 0) {
return $scopeDeptIds;
}
if ($scopeDeptIds === null) {
return $selectedDeptIds;
}
return array_values(array_intersect($scopeDeptIds, $selectedDeptIds));
}
/** @param int[] $assistantIds @param int[]|null $targetDeptIds @return array<string,mixed> */
private static function buildTarget(int $year, array $assistantIds, ?array $targetDeptIds): array
{
$targetRows = [];
if ($targetDeptIds !== []) {
$query = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
if ($targetDeptIds !== null) {
$query->whereIn('dept_id', $targetDeptIds);
}
$targetRows = $query
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
->group('`year_month`')
->select()
->toArray();
}
$actualRows = [];
if ($assistantIds !== []) {
$query = Db::name('tcm_prescription_order')->alias('po')
->whereNull('po.delete_time')
->whereIn('po.creator_id', $assistantIds)
->where('po.create_time', 'between', [
strtotime($year . '-01-01 00:00:00'),
strtotime($year . '-12-31 23:59:59'),
]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
$actualRows = $query
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
->group('month_no')
->select()
->toArray();
}
$monthlyTarget = array_fill(1, 12, 0.0);
$monthlyActual = array_fill(1, 12, 0.0);
$deptCount = 0;
foreach ($targetRows as $row) {
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
if ($month >= 1 && $month <= 12) {
$monthlyTarget[$month] = round((float) ($row['target_amount'] ?? 0), 2);
$deptCount = max($deptCount, (int) ($row['dept_count'] ?? 0));
}
}
foreach ($actualRows as $row) {
$month = (int) ($row['month_no'] ?? 0);
if ($month >= 1 && $month <= 12) {
$monthlyActual[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
}
}
$targetCumulative = [];
$actualCumulative = [];
$targetTotal = 0.0;
$actualTotal = 0.0;
for ($month = 1; $month <= 12; $month++) {
$targetTotal = round($targetTotal + $monthlyTarget[$month], 2);
$actualTotal = round($actualTotal + $monthlyActual[$month], 2);
$targetCumulative[] = $targetTotal;
$actualCumulative[] = $actualTotal;
}
return [
'year' => $year,
'target_amount' => $targetTotal,
'actual_amount' => $actualTotal,
'completion_rate' => $targetTotal > 0 ? round($actualTotal / $targetTotal * 100, 2) : null,
'department_count' => $deptCount,
'scope_note' => $targetDeptIds === [] ? '当前为本人或单个员工范围,未设置个人目标' : '按当前可见部门汇总',
'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)),
'target_cumulative' => $targetCumulative,
'actual_cumulative' => $actualCumulative,
];
}
/** @param array<string,array<string,int|float>> $daily */
private static function sumDaily(array $daily, string $start, string $end, string $field): float
{
$sum = 0.0;
foreach ($daily as $date => $values) {
if ($date >= $start && $date <= $end) {
$sum += (float) ($values[$field] ?? 0);
}
}
return $sum;
}
private static function relativeChange(float $current, float $previous): ?float
{
if (abs($previous) < 0.00001) {
return null;
}
return round(($current - $previous) / $previous * 100, 2);
}
/** @param array<int|string,mixed> $ids @return int[] */
private static function normalizeIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\common\model\auth\AdminRole;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Diagnosis;
use app\common\service\DataScope\DataScopeService;
use think\db\Query;
use think\facade\Db;
/**
* “我的患者”统一数据范围。
*
* 角色语义:医生只看本人接诊患者,医助只看本人归属患者;经理、
* 诊室组长和管理员按系统 DataScope 查看团队患者;root 查看全部。
*/
class MyPatientLogic
{
private const DOCTOR_ROLE_ID = 1;
private const ASSISTANT_ROLE_ID = 2;
private const TEAM_ROLE_IDS = [3, 7, 8];
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
/**
* @param Query $query d 作为 zyt_tcm_diagnosis 别名的查询
*/
public static function applyScope(Query $query, int $adminId, array $adminInfo): void
{
if ($adminId <= 0) {
$query->whereRaw('0 = 1');
return;
}
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return;
}
$roleIds = self::roleIds($adminId);
$appointmentTable = (new Appointment())->getTable();
$statusList = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
// 管理角色按系统的数据范围查看“范围内医助归属或医生接诊”的患者。
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleAdminIds === null) {
return;
}
$visibleAdminIds = self::normalizeIds($visibleAdminIds);
if ($visibleAdminIds === []) {
$query->whereRaw('0 = 1');
return;
}
$ids = implode(',', $visibleAdminIds);
$query->whereRaw(
"(CAST(d.assistant_id AS UNSIGNED) IN ({$ids})"
. " OR EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
. ' WHERE scope_apt.patient_id = d.id'
. " AND scope_apt.status IN ({$statusList})"
. " AND scope_apt.doctor_id IN ({$ids})))"
);
return;
}
// 一线角色始终只取“本人关系”,不受数据库中医生角色 ALL 配置影响。
$conditions = [];
if (in_array(self::ASSISTANT_ROLE_ID, $roleIds, true)) {
$conditions[] = 'CAST(d.assistant_id AS UNSIGNED) = ' . $adminId;
}
if (in_array(self::DOCTOR_ROLE_ID, $roleIds, true)) {
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
. ' WHERE scope_apt.patient_id = d.id'
. " AND scope_apt.status IN ({$statusList})"
. " AND scope_apt.doctor_id = {$adminId})";
}
// 未知/异常角色按本人医助或本人医生关系收窄,拒绝意外放大全库。
if ($conditions === []) {
$conditions[] = 'CAST(d.assistant_id AS UNSIGNED) = ' . $adminId;
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
. ' WHERE scope_apt.patient_id = d.id'
. " AND scope_apt.status IN ({$statusList})"
. " AND scope_apt.doctor_id = {$adminId})";
}
$query->whereRaw('(' . implode(' OR ', $conditions) . ')');
}
public static function canAccessDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
if ($diagnosisId <= 0 || $adminId <= 0) {
return false;
}
$diagnosisTable = (new Diagnosis())->getTable();
$query = Db::table($diagnosisTable)
->alias('d')
->where('d.id', $diagnosisId)
->whereNull('d.delete_time')
->where('d.status', 1);
self::applyScope($query, $adminId, $adminInfo);
return (int) $query->count() > 0;
}
/** @return array{mode:string,label:string} */
public static function scopeMeta(int $adminId, array $adminInfo): array
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return ['mode' => 'all', 'label' => '全部数据'];
}
$roleIds = self::roleIds($adminId);
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
$scope = DataScopeService::getEffectiveScope($adminInfo);
$labels = [
DataScopeService::SCOPE_ALL => '全部数据',
DataScopeService::SCOPE_DEPT_AND_CHILD => '本部门及下级',
DataScopeService::SCOPE_DEPT => '本部门',
DataScopeService::SCOPE_SELF => '仅本人',
];
return [
'mode' => $scope === DataScopeService::SCOPE_ALL ? 'all' : 'team',
'label' => $labels[$scope] ?? '仅本人',
];
}
$isDoctor = in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
$isAssistant = in_array(self::ASSISTANT_ROLE_ID, $roleIds, true);
if ($isDoctor && $isAssistant) {
return ['mode' => 'self', 'label' => '本人归属及接诊'];
}
if ($isDoctor) {
return ['mode' => 'self', 'label' => '本人接诊'];
}
return ['mode' => 'self', 'label' => '本人归属'];
}
/** @return int[] */
private static function roleIds(int $adminId): array
{
return self::normalizeIds(AdminRole::where('admin_id', $adminId)->column('role_id'));
}
/** @param array<int|string, mixed> $ids @return int[] */
private static function normalizeIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $id): bool {
return $id > 0;
})));
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use RuntimeException;
use think\facade\Db;
/** 获客客户同步与数据权限统计。 */
class WecomAcquisitionCustomerLogic
{
/** @return array<string,mixed> */
public static function sync(array $params, int $adminId, array $adminInfo): array
{
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? $params['id'] ?? 0));
$query = Db::name('qywx_promotion_link')->alias('l')
->whereNull('l.delete_time')
->where('l.remote_link_id', '<>', '')
->where('l.remote_status', 1);
self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo));
if ($localLinkId > 0) {
$query->where('l.id', $localLinkId);
}
$links = $query->field('l.id,l.remote_link_id')->order('l.id', 'asc')->limit(200)->select()->toArray();
if ($localLinkId > 0 && $links === []) {
throw new RuntimeException('获客链接不存在、已失效,或超出当前权限范围');
}
if ($links === []) {
throw new RuntimeException('当前数据范围内没有可同步的有效官方获客链接;已删除和历史手工链接不会参与客户同步,请先创建官方获客链接');
}
$service = new QywxCustomerAcquisitionCustomerService();
$result = ['links' => count($links), 'scanned' => 0, 'created' => 0, 'updated' => 0, 'failed' => 0, 'errors' => []];
foreach ($links as $link) {
try {
$one = $service->syncLink((string) $link['remote_link_id']);
$result['scanned'] += $one['scanned'];
$result['created'] += $one['created'];
$result['updated'] += $one['updated'];
} catch (\Throwable $e) {
$result['failed']++;
if (count($result['errors']) < 10) {
$result['errors'][] = (string) $link['remote_link_id'] . '' . $e->getMessage();
}
}
}
return $result;
}
/** @return array<string,mixed> */
public static function statistics(array $params, int $adminId, array $adminInfo): array
{
$page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1));
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$base = self::customerQuery($params, $visibleIds);
$total = (int) (clone $base)->count();
$rows = $base
->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name')
->order('c.last_chat_time', 'desc')->order('c.id', 'desc')
->page($page, $pageSize)->select()->toArray();
foreach ($rows as &$row) {
$row['external_userid_masked'] = self::maskIdentifier((string) ($row['external_userid'] ?? ''));
unset($row['external_userid']);
$row['has_messaged'] = (int) ($row['chat_status'] ?? 0) === 1;
$row['message_count_known'] = (int) ($row['message_count_known'] ?? 0);
$row['received_message_count'] = (int) ($row['recv_msg_cnt'] ?? 0);
}
unset($row);
$summaryQuery = self::customerQuery($params, $visibleIds);
$summaryRow = $summaryQuery->fieldRaw(
'COUNT(*) AS customer_count, '
. 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, '
. 'SUM(CASE WHEN c.chat_status = 1 THEN 1 ELSE 0 END) AS started_chat_count, '
. 'SUM(CASE WHEN c.message_count_known = 1 THEN 1 ELSE 0 END) AS message_count_known_count'
)->find() ?: [];
return [
'meta' => [
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
'generated_at' => date('Y-m-d H:i:s'),
],
'summary' => [
'customer_count' => (int) ($summaryRow['customer_count'] ?? 0),
'started_chat_count' => (int) ($summaryRow['started_chat_count'] ?? 0),
'recv_msg_cnt' => (int) ($summaryRow['recv_msg_cnt'] ?? 0),
'received_message_count' => (int) ($summaryRow['recv_msg_cnt'] ?? 0),
'message_count_known_count' => (int) ($summaryRow['message_count_known_count'] ?? 0),
],
'lists' => $rows,
'count' => $total,
'page_no' => $page,
'page_size' => $pageSize,
];
}
private static function customerQuery(array $params, ?array $visibleIds)
{
$query = Db::name('qywx_customer_acquisition_customer')->alias('c')
->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL')
->leftJoin('dept d', 'd.id = c.dept_id')
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
self::applyScope($query, 'c', $visibleIds);
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
if ($localLinkId > 0) {
$query->where('c.promotion_link_id', $localLinkId);
}
$userId = trim((string) ($params['userid'] ?? ''));
if ($userId !== '') {
$query->where('c.userid', $userId);
}
if (isset($params['chat_status']) && $params['chat_status'] !== '') {
$query->where('c.chat_status', max(0, (int) $params['chat_status']));
}
$keyword = trim((string) ($params['keyword'] ?? ''));
if ($keyword !== '') {
$query->whereLike('c.external_userid|c.userid|a.name|l.name', '%' . $keyword . '%');
}
return $query;
}
private static function applyScope($query, string $alias, ?array $visibleIds): void
{
if ($visibleIds === null) {
return;
}
if ($visibleIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds))));
}
private static function maskIdentifier(string $value): string
{
$value = trim($value);
$length = mb_strlen($value);
if ($length <= 0) {
return '-';
}
if ($length <= 4) {
return mb_substr($value, 0, 1) . '***';
}
if ($length <= 8) {
return mb_substr($value, 0, 2) . '***' . mb_substr($value, -1);
}
return mb_substr($value, 0, 4) . '****' . mb_substr($value, -4);
}
}
@@ -0,0 +1,701 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
use RuntimeException;
use think\facade\Db;
/** 一诊 / 企业微信获客助手管理逻辑。 */
class WecomPromotionLogic
{
public static function overview(int $adminId, array $adminInfo, string $domain): array
{
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
->leftJoin('admin u', 'u.id = p.owner_admin_id')
->leftJoin('dept d', 'd.id = p.dept_id')
->whereNull('p.delete_time');
self::applyOwnerScope($poolsQuery, 'p', $visibleIds);
$pools = $poolsQuery
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
->order('p.id', 'desc')
->select()->toArray();
$poolIds = array_values(array_filter(array_map('intval', array_column($pools, 'id'))));
$links = [];
if ($poolIds !== []) {
$links = Db::name('qywx_promotion_link')->alias('l')
->whereNull('l.delete_time')
->whereIn('l.pool_id', $poolIds)
->field('l.id,l.pool_id,l.name,l.group_name,l.wecom_url,l.remote_link_id,l.remote_status,l.remote_create_time,l.range_user_json,l.range_department_json,l.skip_verify,l.priority_option_json,l.last_sync_time,l.sync_error,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time')
->order('l.status', 'desc')
->order('l.weight', 'desc')
->order('l.id', 'desc')
->select()->toArray();
}
$domain = rtrim($domain, '/');
foreach ($pools as &$pool) {
$key = (string) $pool['public_key'];
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
$pool['script_url'] = $scriptUrl;
$pool['go_url'] = $goUrl;
$pool['install_code'] = '<script src="' . $scriptUrl . '" defer></script>';
$pool['trigger_code'] = '<a href="#" data-wecom-promotion="' . $key . '">添加企业微信</a>';
}
unset($pool);
$today = date('Y-m-d');
$todayClicks = 0;
$onlineLinks = 0;
foreach ($links as &$link) {
$link['range_userids'] = self::decodeStringList($link['range_user_json'] ?? null);
$link['range_department_ids'] = self::decodeStringList($link['range_department_json'] ?? null);
$link['priority_option'] = self::decodeObject($link['priority_option_json'] ?? null);
$link['is_official'] = trim((string) ($link['remote_link_id'] ?? '')) !== '';
$link['valid_customer_acquisition_link'] = QywxCustomerAcquisitionLinkService::isAllowed((string) ($link['wecom_url'] ?? ''));
if ((int) ($link['status'] ?? 0) === 1 && $link['valid_customer_acquisition_link']) {
$onlineLinks++;
}
if ((string) ($link['today_date'] ?? '') === $today) {
$todayClicks += (int) ($link['today_count'] ?? 0);
}
}
unset($link);
$config = self::internalApplicationStatus($domain);
return [
'meta' => [
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
'generated_at' => date('Y-m-d H:i:s'),
],
'config' => $config,
'summary' => [
'configured_apps' => $config['ready'] ? 1 : 0,
'pool_count' => count($pools),
'online_links' => $onlineLinks,
'today_clicks' => $todayClicks,
],
'pools' => $pools,
'links' => $links,
'member_options' => self::memberOptions($adminId, $adminInfo),
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
];
}
public static function savePool(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['id'] ?? 0));
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 60) {
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
}
$fallback = trim((string) ($params['fallback_url'] ?? ''));
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
}
$now = time();
$data = [
'name' => $name,
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
'fallback_url' => $fallback,
'update_time' => $now,
];
if ($id > 0) {
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
Db::name('qywx_promotion_pool')->where('id', $id)->update($data);
} else {
$data += [
'public_key' => bin2hex(random_bytes(16)),
'owner_admin_id' => $adminId,
'dept_id' => self::primaryDeptId($adminId),
'click_count' => 0,
'create_time' => $now,
];
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($data);
}
return ['id' => $id];
}
public static function deletePool(int $id, int $adminId, array $adminInfo): void
{
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
$now = time();
Db::transaction(function () use ($id, $now): void {
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update(['delete_time' => $now, 'update_time' => $now]);
});
}
public static function saveLink(array $params, int $adminId, array $adminInfo): array
{
$id = max(0, (int) ($params['id'] ?? 0));
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 80) {
throw new RuntimeException('请输入 1-80 个字符的获客链接名称');
}
$startAt = self::parseTime($params['active_start'] ?? null);
$endAt = self::parseTime($params['active_end'] ?? null);
if ($startAt > 0 && $endAt > 0 && $startAt >= $endAt) {
throw new RuntimeException('生效结束时间必须晚于开始时间');
}
$now = time();
$data = [
'pool_id' => $poolId,
'account_id' => 0,
'name' => $name,
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
'active_start' => $startAt,
'active_end' => $endAt,
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
'update_time' => $now,
];
// 历史手工链接只维护本地分流规则,不会在企业微信端创建重复链接。
if ($existing !== null && trim((string) ($existing['remote_link_id'] ?? '')) === '') {
$url = trim((string) ($params['wecom_url'] ?? $existing['wecom_url'] ?? ''));
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
throw new RuntimeException('历史链接必须是 https://work.weixin.qq.com/ca/... 格式');
}
$data['wecom_url'] = $url;
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
return ['id' => $id, 'mode' => 'legacy'];
}
$userIds = self::resolveMemberUserIds((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
$payload = [
'link_name' => $name,
'range' => ['user_list' => $userIds],
'skip_verify' => $skipVerify === 1,
];
$api = new QywxCustomerAcquisitionApiService();
if ($existing !== null) {
$remoteLinkId = trim((string) ($existing['remote_link_id'] ?? ''));
$payload['link_id'] = $remoteLinkId;
$api->updateLink($payload);
} else {
$created = $api->createLink($payload);
$remoteLinkId = self::extractRemoteLinkId($created);
if ($remoteLinkId === '') {
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请先执行“同步企业微信”确认结果');
}
}
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
$data += self::remoteColumns($remote, $now);
if ($existing !== null) {
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
} else {
$data += [
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
'click_count' => 0,
'today_count' => 0,
'today_date' => null,
'last_click_time' => 0,
'create_time' => $now,
];
try {
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
} catch (\Throwable $e) {
try {
$api->deleteLink($remoteLinkId);
} catch (\Throwable) {
// 远端补偿失败时保留原始异常,管理员可通过“同步企业微信”找回链接。
}
throw $e;
}
}
return ['id' => $id, 'remote_link_id' => $remoteLinkId, 'mode' => 'official'];
}
/** 验证 CorpID、应用 Secret、可信 IP 与获客助手接口权限。 */
public static function checkApiPermission(): array
{
return (new QywxCustomerAcquisitionApiService())->checkPermission();
}
/**
* 将企业微信端获客链接同步进指定分流方案。
* 非全量权限账号仅导入 range.user_list 与其可见成员有交集的链接,未知部门映射时严格隐藏。
*/
public static function syncRemoteLinks(int $poolId, int $adminId, array $adminInfo): array
{
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$legacyCount = (int) Db::name('qywx_promotion_link')
->where('pool_id', $poolId)
->whereNull('delete_time')
->whereRaw("(remote_link_id IS NULL OR remote_link_id = '')")
->count();
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$visibleUserIds = null;
if ($visibleAdminIds !== null) {
$visibleUserIds = array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
}
$api = new QywxCustomerAcquisitionApiService();
$cursor = '';
$seen = 0;
$created = 0;
$updated = 0;
$skipped = 0;
$failed = 0;
$errors = [];
do {
$page = $api->listLinks($cursor, 100);
foreach ($page['link_id_list'] as $remoteLinkId) {
if ($seen >= 500) {
break 2;
}
$seen++;
try {
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
$skipped++;
continue;
}
$result = self::upsertRemoteLink($remote, $pool, $adminId, $adminInfo);
$result === 'created' ? $created++ : $updated++;
} catch (\Throwable $e) {
$failed++;
if (count($errors) < 5) {
$errors[] = $remoteLinkId . '' . $e->getMessage();
}
}
}
$cursor = (string) ($page['next_cursor'] ?? '');
} while ($cursor !== '');
return [
'scanned' => $seen,
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'failed' => $failed,
'legacy_count' => $legacyCount,
'empty_reason' => $seen === 0
? '当前获客助手可调用应用没有通过 API 创建的官方获客链接;历史手工链接及其他应用创建的链接不会出现在该应用的同步列表中。'
: '',
'suggestion' => $seen === 0
? '请点击“创建官方获客链接”通过当前应用创建。历史手工链接仍可参与本地分流,但无法同步官方 link_id 和官方获客数据。'
: '',
'truncated' => $cursor !== '',
'errors' => $errors,
];
}
/** 获取并刷新单条企业微信官方详情。 */
public static function remoteLinkDetail(int $id, int $adminId, array $adminInfo): array
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
if ($remoteLinkId === '') {
throw new RuntimeException('这是历史手工链接,没有企业微信 link_id');
}
$api = new QywxCustomerAcquisitionApiService();
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
$visibleUserIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo) === null
? null
: array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
throw new RuntimeException('该获客链接已不在当前角色或部门的数据范围内');
}
Db::name('qywx_promotion_link')->where('id', $id)->update(self::remoteColumns($remote, time()));
return self::remotePublicPayload($remote);
}
/** 永久删除企业微信端获客链接,本地保留审计记录并停止分流。 */
public static function deleteRemoteLink(int $id, int $adminId, array $adminInfo): void
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
if ($remoteLinkId === '') {
throw new RuntimeException('历史手工链接只能从本地移除');
}
(new QywxCustomerAcquisitionApiService())->deleteLink($remoteLinkId);
Db::name('qywx_promotion_link')->where('id', $id)->update([
'status' => 0,
'remote_status' => 2,
'last_sync_time' => time(),
'sync_error' => '',
'update_time' => time(),
]);
}
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
{
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
if ($status === 1 && (int) ($row['remote_status'] ?? 0) === 2) {
throw new RuntimeException('企业微信端已永久删除该链接,不能重新上线');
}
Db::name('qywx_promotion_link')->where('id', $id)->update([
'status' => $status === 1 ? 1 : 0,
'update_time' => time(),
]);
}
public static function deleteLink(int $id, int $adminId, array $adminInfo): void
{
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
Db::name('qywx_promotion_link')->where('id', $id)->update([
'delete_time' => time(),
'update_time' => time(),
]);
}
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
private static function memberOptions(int $adminId, array $adminInfo): array
{
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
return [];
}
$query = Db::name('admin')->alias('a')
->whereNull('a.delete_time')
->where('a.work_wechat_userid', '<>', '');
if ($visibleIds !== null) {
$query->whereIn('a.id', $visibleIds);
}
$admins = $query->field('a.id,a.name,a.work_wechat_userid')->order('a.id', 'asc')->select()->toArray();
if ($admins === []) {
return [];
}
$adminIds = array_map('intval', array_column($admins, 'id'));
$deptRows = Db::name('admin_dept')->alias('ad')
->leftJoin('dept d', 'd.id = ad.dept_id')
->whereIn('ad.admin_id', $adminIds)
->field('ad.admin_id,ad.dept_id,d.name as dept_name')
->order('ad.dept_id', 'asc')->select()->toArray();
$departments = [];
foreach ($deptRows as $row) {
$aid = (int) ($row['admin_id'] ?? 0);
$departments[$aid]['ids'][] = (int) ($row['dept_id'] ?? 0);
if (trim((string) ($row['dept_name'] ?? '')) !== '') {
$departments[$aid]['names'][] = (string) $row['dept_name'];
}
}
$result = [];
$seenUserIds = [];
foreach ($admins as $admin) {
$userId = trim((string) ($admin['work_wechat_userid'] ?? ''));
if ($userId === '' || isset($seenUserIds[$userId])) {
continue;
}
$seenUserIds[$userId] = true;
$aid = (int) $admin['id'];
$result[] = [
'id' => $aid,
'name' => (string) ($admin['name'] ?? $userId),
'userid' => $userId,
'dept_ids' => array_values(array_unique(array_filter($departments[$aid]['ids'] ?? []))),
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
];
}
return $result;
}
/** @return list<string> */
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
{
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
if ($requested === []) {
throw new RuntimeException('请至少选择一名当前角色或部门范围内的获客成员');
}
$available = [];
foreach (self::memberOptions($adminId, $adminInfo) as $member) {
$available[$member['id']] = $member['userid'];
}
$userIds = [];
foreach ($requested as $requestedId) {
if (!isset($available[$requestedId])) {
throw new RuntimeException('选择的获客成员超出当前角色或部门的数据范围,或尚未绑定企业微信 userid');
}
$userIds[] = $available[$requestedId];
}
if (count($userIds) > 500) {
throw new RuntimeException('单个获客链接最多配置 500 名成员');
}
return array_values(array_unique($userIds));
}
/** @return array<string,mixed> */
private static function normaliseRemoteLink(array $response, string $fallbackId = ''): array
{
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
if ($linkId === '') {
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
}
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
}
$range = isset($link['range']) && is_array($link['range']) ? $link['range'] : [];
return [
'link_id' => $linkId,
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
'url' => $url,
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
'skip_verify' => !empty($link['skip_verify']),
'priority_option' => isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : [],
'snapshot' => $link,
];
}
/** @return array<string,mixed> */
private static function remoteColumns(array $remote, int $now): array
{
return [
'name' => mb_substr((string) ($remote['link_name'] ?? ''), 0, 80),
'wecom_url' => (string) ($remote['url'] ?? ''),
'remote_link_id' => (string) ($remote['link_id'] ?? ''),
'remote_status' => 1,
'remote_create_time' => (int) ($remote['create_time'] ?? 0),
'range_user_json' => self::encodeJson($remote['range_userids'] ?? []),
'range_department_json' => self::encodeJson($remote['range_department_ids'] ?? []),
'skip_verify' => !empty($remote['skip_verify']) ? 1 : 0,
'priority_option_json' => self::encodeJson($remote['priority_option'] ?? []),
'remote_snapshot' => self::encodeJson($remote['snapshot'] ?? []),
'last_sync_time' => $now,
'sync_error' => '',
'update_time' => $now,
];
}
private static function upsertRemoteLink(array $remote, array $pool, int $adminId, array $adminInfo): string
{
$remoteLinkId = (string) $remote['link_id'];
$now = time();
$existing = Db::name('qywx_promotion_link')->where('remote_link_id', $remoteLinkId)->find();
$remoteData = self::remoteColumns($remote, $now);
if ($existing) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds !== null && !in_array((int) ($existing['owner_admin_id'] ?? 0), $visibleIds, true)) {
throw new RuntimeException('该链接已归属其他数据范围');
}
$remoteData['delete_time'] = null;
Db::name('qywx_promotion_link')->where('id', (int) $existing['id'])->update($remoteData);
return 'updated';
}
Db::name('qywx_promotion_link')->insert($remoteData + [
'pool_id' => (int) $pool['id'],
'account_id' => 0,
'group_name' => '企业微信同步',
'weight' => 1,
'status' => 1,
'daily_limit' => 0,
'today_count' => 0,
'today_date' => null,
'active_start' => 0,
'active_end' => 0,
'click_count' => 0,
'last_click_time' => 0,
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
'remark' => '',
'create_time' => $now,
'delete_time' => null,
]);
return 'created';
}
private static function canSeeRemoteLink(array $remote, ?array $visibleUserIds): bool
{
if ($visibleUserIds === null) {
return true;
}
foreach ((array) ($remote['range_userids'] ?? []) as $userId) {
if (isset($visibleUserIds[(string) $userId])) {
return true;
}
}
return false;
}
/** @return array<string,mixed> */
private static function remotePublicPayload(array $remote): array
{
return [
'link_id' => (string) ($remote['link_id'] ?? ''),
'link_name' => (string) ($remote['link_name'] ?? ''),
'url' => (string) ($remote['url'] ?? ''),
'create_time' => (int) ($remote['create_time'] ?? 0),
'range_userids' => (array) ($remote['range_userids'] ?? []),
'range_department_ids' => (array) ($remote['range_department_ids'] ?? []),
'skip_verify' => !empty($remote['skip_verify']),
'priority_option' => (array) ($remote['priority_option'] ?? []),
];
}
private static function extractRemoteLinkId(array $response): string
{
if (isset($response['link']) && is_array($response['link'])) {
return trim((string) ($response['link']['link_id'] ?? ''));
}
return trim((string) ($response['link_id'] ?? ''));
}
/** @return list<string> */
private static function normaliseScalarList(mixed $value): array
{
if (!is_array($value)) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn (mixed $item): string => trim((string) $item),
$value
), static fn (string $item): bool => $item !== '')));
}
/** @return list<string> */
private static function decodeStringList(mixed $value): array
{
if (!is_string($value) || $value === '') {
return [];
}
$decoded = json_decode($value, true);
return self::normaliseScalarList(is_array($decoded) ? $decoded : []);
}
/** @return array<string,mixed> */
private static function decodeObject(mixed $value): array
{
if (!is_string($value) || $value === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
private static function encodeJson(mixed $value): string
{
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return $encoded === false ? '[]' : $encoded;
}
private static function assertScopedRow(string $table, int $id, int $adminId, array $adminInfo): array
{
if ($id <= 0) {
throw new RuntimeException('数据不存在');
}
$query = Db::name($table)->where('id', $id)->whereNull('delete_time');
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds !== null) {
if ($visibleIds === []) {
throw new RuntimeException('无权访问该数据');
}
$query->whereIn('owner_admin_id', $visibleIds);
}
$row = $query->find();
if (!$row) {
throw new RuntimeException('数据不存在或超出当前权限范围');
}
return $row;
}
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
{
if ($visibleIds === null) {
return;
}
if ($visibleIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
}
private static function primaryDeptId(int $adminId): int
{
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
}
private static function parseTime(mixed $value): int
{
if ($value === null || $value === '') {
return 0;
}
if (is_numeric($value)) {
return max(0, (int) $value);
}
$time = strtotime((string) $value);
return $time === false ? 0 : $time;
}
private static function mask(string $value): string
{
$length = strlen($value);
if ($length <= 8) {
return $value === '' ? '' : str_repeat('*', $length);
}
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
}
/**
* 内部应用直接复用项目现有 work_wechat 配置,不经过第三方服务商授权。
*
* @return array<string, mixed>
*/
private static function internalApplicationStatus(string $domain): array
{
$corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
$agentId = trim((string) env('WECHAT_WORK_AGENT_ID', ''));
if ($agentId === '') {
$agentId = trim((string) env('work_wechat.agent_id', ''));
}
$apiStatus = QywxCustomerAcquisitionApiService::configurationStatus();
$callbackTokenConfigured = trim((string) config('pay.wechat_work.contact_callback_token', '')) !== '';
$callbackAesConfigured = trim((string) config('pay.wechat_work.contact_callback_aes_key', '')) !== '';
return [
'mode' => 'internal',
'configured' => $apiStatus['configured'],
'ready' => $apiStatus['configured'],
'missing' => $apiStatus['missing'],
'corp_id_masked' => self::mask($corpId),
'agent_id' => $agentId,
'secret_configured' => trim((string) config('qywx_customer_acquisition.secret', '')) !== '',
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
];
}
}
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\pharmacy;
use app\common\logic\BaseLogic;
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
use app\common\service\pharmacy\EjMedicineMappingPolicy;
use think\facade\Db;
use think\facade\Config;
use Throwable;
class MedicineMappingLogic extends BaseLogic
{
public static function save(array $params, int $operatorId, string $operatorName): bool
{
self::$error = '';
try {
Db::transaction(function () use ($params, $operatorId, $operatorName): void {
$localId = (int) $params['local_medicine_id'];
$medicineCode = trim((string) $params['medicine_code']);
$local = Db::name('doctor_medicine')->where('id', $localId)->lock(true)->find();
$remote = Db::name('ej_medicine_catalog')->where('medicine_code', $medicineCode)->lock(true)->find();
EjMedicineMappingPolicy::assertValid($local ?: [], $remote ?: []);
$now = time();
$mapping = Db::name('ej_medicine_mapping')
->where('local_medicine_id', $localId)
->lock(true)
->find();
$values = [
'medicine_code' => $medicineCode,
'status' => 1,
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'update_time' => $now,
'delete_time' => null,
];
if ($mapping) {
Db::name('ej_medicine_mapping')->where('id', (int) $mapping['id'])->update($values);
return;
}
Db::name('ej_medicine_mapping')->insert(array_merge($values, [
'local_medicine_id' => $localId,
'create_time' => $now,
]));
});
return true;
} catch (Throwable $exception) {
self::setError(self::isDuplicateKey($exception)
? '该本地药材映射刚被其他操作更新,请刷新后重试'
: $exception->getMessage());
return false;
}
}
public static function unlink(int $localMedicineId, int $operatorId, string $operatorName): bool
{
self::$error = '';
try {
Db::transaction(function () use ($localMedicineId, $operatorId, $operatorName): void {
$local = Db::name('doctor_medicine')->where('id', $localMedicineId)->lock(true)->find();
$mapping = Db::name('ej_medicine_mapping')
->where('local_medicine_id', $localMedicineId)
->lock(true)
->find();
$decision = EjMedicineMappingPolicy::unlinkDecision($local ?: [], $mapping ?: null);
if ($decision['already_unlinked']) {
return;
}
$now = time();
Db::name('ej_medicine_mapping')
->where('id', $decision['mapping_id'])
->where('local_medicine_id', $localMedicineId)
->where('status', 1)
->whereNull('delete_time')
->update([
'status' => 0,
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'update_time' => $now,
'delete_time' => $now,
]);
});
return true;
} catch (Throwable $exception) {
self::setError($exception->getMessage());
return false;
}
}
/** @return array<string,mixed>|false */
public static function sync()
{
self::$error = '';
try {
return EjMedicineCatalogSyncService::sync(200);
} catch (Throwable $exception) {
self::setError($exception->getMessage());
return false;
}
}
/** @return array<string,mixed> */
public static function status(): array
{
$state = Db::name('ej_pharmacy_sync_state')->where('id', 1)->find() ?: [];
$catalogTotal = (int) Db::name('ej_medicine_catalog')->count();
$catalogActive = (int) Db::name('ej_medicine_catalog')
->where('status', 1)->where('remote_deleted', 0)->count();
$unmappedLocal = (int) Db::name('doctor_medicine')->alias('l')
->leftJoin(
'ej_medicine_mapping m',
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
)
->where('l.status', 1)
->whereNull('l.delete_time')
->whereNull('m.id')
->count('l.id');
return [
'sync_enabled' => (bool) Config::get('ej_pharmacy.catalog_sync_enabled', false),
'cursor' => (int) ($state['cursor'] ?? 0),
'last_success_time' => (int) ($state['last_success_time'] ?? 0),
'last_failure_time' => (int) ($state['last_failure_time'] ?? 0),
'last_error_summary' => (string) ($state['last_error_summary'] ?? ''),
'is_syncing' => !empty($state['lock_token']) && (int) ($state['lock_expires_at'] ?? 0) >= time(),
'catalog_total' => $catalogTotal,
'catalog_active' => $catalogActive,
'unmapped_local' => $unmappedLocal,
];
}
/** @return array<int,array<string,mixed>> */
public static function catalogOptions(string $keyword, int $limit = 30): array
{
$query = Db::name('ej_medicine_catalog')
->where('status', 1)
->where('remote_deleted', 0);
$keyword = trim($keyword);
if ($keyword !== '') {
$query->where(function ($nested) use ($keyword): void {
$nested->where('name', 'like', '%' . $keyword . '%')
->whereOr('medicine_code', 'like', '%' . $keyword . '%');
});
}
return $query
->field('medicine_code,name,brand,unit,settlement_price,retail_price,catalog_version,status')
->order('catalog_version', 'desc')
->limit(min(max($limit, 1), 50))
->select()
->toArray();
}
private static function isDuplicateKey(Throwable $exception): bool
{
return (string) $exception->getCode() === '23000'
|| str_contains(strtolower($exception->getMessage()), 'duplicate');
}
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use think\facade\Db;
use think\facade\Log;
/**
* 待分配诊单自动指派日志:回退已自动分配的医助
*/
class AutoAssignLogLogic extends BaseLogic
{
/**
* 批量回退:将诊单医助撤回到自动分配前的原医助(指派日志 from_assistant_id),并标记自动分配日志已回退。
*
* @param list<int|string> $ids 自动分配日志 id
* @return array{success:int,failed:int,messages:list<string>}|false
*/
public static function rollback(array $ids, int $adminId, array $adminInfo = []): array|false
{
$idList = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id) => $id > 0)));
if ($idList === []) {
self::setError('请选择要回退的记录');
return false;
}
$adminName = (string) ($adminInfo['name'] ?? '');
$adminAccount = (string) ($adminInfo['account'] ?? '');
$req = request();
$ip = (string) ($req->ip() ?? '');
$now = time();
$success = 0;
$failed = 0;
$messages = [];
foreach ($idList as $logId) {
try {
$ret = self::rollbackOne($logId, $adminId, $adminName, $adminAccount, $ip, $now);
if ($ret === true) {
$success++;
} else {
$failed++;
$messages[] = (string) $ret;
}
} catch (\Throwable $e) {
$failed++;
$messages[] = sprintf('日志#%d%s', $logId, $e->getMessage());
Log::warning('auto assign rollback failed: ' . $e->getMessage(), ['log_id' => $logId]);
}
}
if ($success === 0 && $failed > 0) {
self::setError($messages[0] ?? '回退失败');
return false;
}
return [
'success' => $success,
'failed' => $failed,
'messages' => $messages,
];
}
/**
* @return true|string true=成功,string=失败原因
*/
private static function rollbackOne(
int $logId,
int $adminId,
string $adminName,
string $adminAccount,
string $ip,
int $now
): bool|string {
Db::startTrans();
try {
$log = Db::name('tcm_diagnosis_auto_assign_log')
->where('id', $logId)
->lock(true)
->find();
if ($log === null || $log === []) {
Db::rollback();
return sprintf('日志#%d:记录不存在', $logId);
}
if ((int) ($log['action'] ?? 0) !== 1) {
Db::rollback();
return sprintf('日志#%d:仅「已分配」记录可回退', $logId);
}
if ((int) ($log['rollback_time'] ?? 0) > 0) {
Db::rollback();
return sprintf('日志#%d:已回退,勿重复操作', $logId);
}
$diagnosisId = (int) ($log['diagnosis_id'] ?? 0);
$assignedAssistantId = (int) ($log['assistant_id'] ?? 0);
if ($diagnosisId <= 0 || $assignedAssistantId <= 0) {
Db::rollback();
return sprintf('日志#%d:数据不完整,无法回退', $logId);
}
$diag = Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->lock(true)
->field(['id', 'assistant_id'])
->find();
if ($diag === null || $diag === []) {
Db::rollback();
return sprintf('日志#%d:诊单#%d 不存在', $logId, $diagnosisId);
}
$currentAssistantId = (int) ($diag['assistant_id'] ?? 0);
if ($currentAssistantId !== $assignedAssistantId) {
Db::rollback();
return sprintf(
'日志#%d:诊单#%d 当前医助已变更(非自动分配的医助),跳过回退',
$logId,
$diagnosisId
);
}
$prevAssistantId = self::resolvePreviousAssistantIdFromAutoAssign(
$diagnosisId,
$assignedAssistantId,
(int) ($log['create_time'] ?? 0)
);
$poSnap = Db::name('tcm_prescription_order')
->where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order(['create_time' => 'desc', 'id' => 'desc'])
->field(['creator_id', 'create_time'])
->find();
$relatedPoCreatorId = (int) ($poSnap['creator_id'] ?? 0);
$relatedPoCreateTime = (int) ($poSnap['create_time'] ?? 0);
if ($relatedPoCreateTime <= 0) {
$relatedPoCreateTime = $now;
$relatedPoCreatorId = 0;
}
Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->update([
'assistant_id' => $prevAssistantId,
'assign_read_at' => $prevAssistantId > 0 ? null : 0,
]);
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => $currentAssistantId,
'to_assistant_id' => $prevAssistantId,
'operator_admin_id' => $adminId,
'operator_name' => $adminName !== '' ? $adminName : '回退自动分配',
'operator_account' => $adminAccount,
'ip' => $ip,
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'is_inherit' => 0,
'create_time' => $now,
]);
Db::name('tcm_diagnosis_auto_assign_log')
->where('id', $logId)
->update([
'rollback_time' => $now,
'rollback_admin_id' => $adminId,
'rollback_admin_name' => $adminName,
]);
Db::commit();
return true;
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
/**
* 从「系统自动分配」指派日志取 from_assistant_id 作为回退目标。
*/
private static function resolvePreviousAssistantIdFromAutoAssign(
int $diagnosisId,
int $assignedAssistantId,
int $autoLogCreateTime
): int {
$query = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', $assignedAssistantId)
->where('operator_name', '系统自动分配');
if ($autoLogCreateTime > 0) {
$query->where('create_time', '>=', $autoLogCreateTime - 30)
->where('create_time', '<=', $autoLogCreateTime + 30);
}
$row = $query->order('id', 'desc')->field(['from_assistant_id'])->find();
if ($row !== null && $row !== []) {
return (int) ($row['from_assistant_id'] ?? 0);
}
// 兜底:不限时间窗再查最近一条系统自动分配
$fallback = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', $assignedAssistantId)
->where('operator_name', '系统自动分配')
->order('id', 'desc')
->field(['from_assistant_id'])
->find();
return (int) ($fallback['from_assistant_id'] ?? 0);
}
}
@@ -22,6 +22,8 @@ class ConversionLogic
* @param array $params
* @param int $adminId 当前操作 admin(来自 BaseAdminController
* @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等)
* @param int[]|null $trustedVisibleAdminIdsOverride 仅供服务端内部可信调用覆盖本次可见管理员;不从 HTTP 参数读取
* @param int[]|null $trustedCostAllocationAdminIdsOverride 仅用于成本按加粉占比分摊的分母,不会放大任何业务指标
* @return array<string, mixed>
*
* 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"
@@ -29,20 +31,48 @@ class ConversionLogic
* - []:可见为空(SCOPE_SELF 且无绑定且关闭 fallback),返回空数据
* - 其他:用 visibleAdminIds 收窄 entities 加载、hydrate 数据归属、虚拟桶可见性、filters 选项
*/
public static function overview(array $params = [], int $adminId = 0, ?array $adminInfo = null): array
public static function overview(
array $params = [],
int $adminId = 0,
?array $adminInfo = null,
?array $trustedVisibleAdminIdsOverride = null,
?array $trustedCostAllocationAdminIdsOverride = null
): array
{
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
// 仅供需要“有效挂号”口径的内部看板调用;默认保持转换统计历史口径不变。
$excludeCancelledAppointments = (int)($params['exclude_cancelled_appointments'] ?? 0) === 1;
// 一诊综合转化复用处方订单页的业绩口径;其它调用方继续保留历史“双审完成单”口径。
$usePerformanceOrderMetrics = strtolower(trim((string)($params['order_metric_mode'] ?? ''))) === 'performance';
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
$mediaChannelCode = MediaChannelService::normalizeStatsCode((string) ($params['media_channel_code'] ?? ''));
$mediaChannel = $mediaChannelCode !== '' ? MediaChannelService::getChannelByCode($mediaChannelCode) : null;
$filterEmptyEntities = $mediaChannel !== null;
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15)));
if ($trustedVisibleAdminIdsOverride !== null) {
$trustedVisibleAdminIdsOverride = array_values(array_unique(array_filter(
array_map('intval', $trustedVisibleAdminIdsOverride),
static fn (int $id): bool => $id > 0
)));
}
if ($trustedCostAllocationAdminIdsOverride !== null) {
$trustedCostAllocationAdminIdsOverride = array_values(array_unique(array_filter(
array_map('intval', $trustedCostAllocationAdminIdsOverride),
static fn (int $id): bool => $id > 0
)));
}
$pageSizeLimit = $trustedVisibleAdminIdsOverride !== null
? max(100, count($trustedVisibleAdminIdsOverride))
: 100;
$pageSize = max(1, min($pageSizeLimit, (int)($params['page_size'] ?? 15)));
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
: null;
$visibleAdminIds = $trustedVisibleAdminIdsOverride;
if ($trustedVisibleAdminIdsOverride === null) {
$visibleAdminIds = ($adminInfo !== null && $adminId > 0)
? DataScopeService::getVisibleAdminIds($adminId, $adminInfo)
: null;
}
// 严格隔离:可见 admin 集合为空时直接返回空骨架,避免下游误以为是"全部"。
if ($visibleAdminIds === []) {
$emptyResult = [
@@ -98,9 +128,48 @@ class ConversionLogic
$adminToDeptIds = self::loadAdminDeptMap();
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
$allocationEntities = $entities;
$allocationEntityIds = $entityIds;
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
if ($trustedCostAllocationAdminIdsOverride !== null) {
// 个人口径下,仍以同部门全员加粉作为成本分摊分母,避免将整个部门成本全部计到一个人。
$allocationEntities = self::loadEntities($dimension, $params, $trustedCostAllocationAdminIdsOverride);
// 分摊只能使用实际响应中已允许的部门,防止同事的多部门绑定扩大成本范围。
$allocationEntities = array_intersect_key($allocationEntities, $entities);
$allocationEntityIds = array_keys($allocationEntities);
self::hydrateFanStats(
$allocationEntities,
$dimension,
$allocationEntityIds,
$adminToDeptIds,
$startTimestamp,
$endTimestamp,
$mediaChannel,
$trustedCostAllocationAdminIdsOverride
);
}
self::hydrateAppointmentStats(
$entities,
$dimension,
$entityIds,
$adminToDeptIds,
$startTimestamp,
$endTimestamp,
$startDate,
$endDate,
$mediaChannel,
$visibleAdminIds,
$excludeCancelledAppointments,
$usePerformanceOrderMetrics
);
self::hydrateOrderAndAmountStats(
$entities,
$dimension,
$entityIds,
$adminToDeptIds,
$startTimestamp,
$endTimestamp,
$mediaChannel,
$visibleAdminIds,
$usePerformanceOrderMetrics
);
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
@@ -183,7 +252,9 @@ class ConversionLogic
$adminToDeptIds,
$validDeptIds,
$globalAccountCost,
$visibleAdminIds
$visibleAdminIds,
$excludeCancelledAppointments,
$usePerformanceOrderMetrics
);
$pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId);
}
@@ -657,8 +728,8 @@ class ConversionLogic
/**
* @return array<int, int[]>
*
* 返回 admin_id => [dept_id, ...],每个 admin dept_id 列表按 dept_id 升序排列
* zyt_admin_dept 联合主键 admin_id+dept_id,无独立 id 列)
* 返回 admin_id => [dept_id, ...]。跨部门时优先最深层、再按部门排序,
* 与一诊挂号统计和业绩看板的人员归属规则保持一致
* admin 跨部门时,下游 mapEntityIds 只取列表中第一个落在当前 entityIds 内的部门,
* 避免同一笔加粉/挂号/接诊被多次累加到不同部门。
*/
@@ -666,10 +737,38 @@ class ConversionLogic
{
$rows = Db::name('admin_dept')
->field('admin_id, dept_id')
->order('admin_id', 'asc')
->order('dept_id', 'asc')
->select()
->toArray();
$deptRows = Db::name('dept')
->whereNull('delete_time')
->field('id, pid, sort')
->select()
->toArray();
$deptById = [];
foreach ($deptRows as $deptRow) {
$deptId = (int)($deptRow['id'] ?? 0);
if ($deptId > 0) {
$deptById[$deptId] = [
'pid' => (int)($deptRow['pid'] ?? 0),
'sort' => (int)($deptRow['sort'] ?? 0),
];
}
}
$depthCache = [];
$depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptById): int {
if ($deptId <= 0 || !isset($deptById[$deptId])) {
return 0;
}
if (isset($depthCache[$deptId])) {
return $depthCache[$deptId];
}
$parentId = (int)($deptById[$deptId]['pid'] ?? 0);
if ($parentId <= 0 || $parentId === $deptId || !isset($deptById[$parentId])) {
return $depthCache[$deptId] = 0;
}
return $depthCache[$deptId] = $depthOf($parentId) + 1;
};
$map = [];
foreach ($rows as $row) {
$adminId = (int)($row['admin_id'] ?? 0);
@@ -680,6 +779,21 @@ class ConversionLogic
$map[$adminId] ??= [];
$map[$adminId][] = $deptId;
}
foreach ($map as &$deptIds) {
usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptById): int {
$depthCompare = $depthOf($right) <=> $depthOf($left);
if ($depthCompare !== 0) {
return $depthCompare;
}
$sortCompare = (int)($deptById[$right]['sort'] ?? 0) <=> (int)($deptById[$left]['sort'] ?? 0);
if ($sortCompare !== 0) {
return $sortCompare;
}
return $left <=> $right;
});
}
unset($deptIds);
return $map;
}
@@ -871,9 +985,13 @@ class ConversionLogic
string $startDate,
string $endDate,
?array $mediaChannel,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $excludeCancelledAppointments = false,
bool $useRegistrationMetric = false
): void {
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
$sourceExpr = $dimension === 'doctor'
? 'a.doctor_id'
: 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
@@ -882,6 +1000,10 @@ class ConversionLogic
->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
->group("{$sourceExpr}, a.patient_id");
if ($excludeCancelledAppointments) {
$query->whereIn('a.status', [1, 3, 4]);
}
$query->where(static function (Query $subQuery): void {
$subQuery->whereNull('u.id')
->whereOr(static function (Query $orQuery): void {
@@ -922,7 +1044,17 @@ class ConversionLogic
}
}
self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydratePaidAppointmentStats(
$entities,
$dimension,
$entityIds,
$adminToDeptIds,
$startTimestamp,
$endTimestamp,
$mediaChannel,
$visibleAdminIds,
$useRegistrationMetric
);
foreach ($entities as &$entity) {
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
@@ -947,7 +1079,8 @@ class ConversionLogic
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $useRegistrationMetric = false
): void {
$startDateTime = date('Y-m-d H:i:s', $startTimestamp);
$endDateTime = date('Y-m-d H:i:s', $endTimestamp);
@@ -955,14 +1088,20 @@ class ConversionLogic
->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.order_type', 1)
->where('o.amount', 5)
->whereNotNull('o.payment_time')
->where('o.payment_time', '<>', '')
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
->group('o.creator_id');
if ($useRegistrationMetric) {
// 一诊页面的新“挂号”:已支付且 0 < 实收金额 < 10 元,每笔支付订单计 1 个。
$query->where('o.amount', '>', 0)->where('o.amount', '<', 10);
} else {
// 保留旧统计页面的历史“付费挂号”字段,避免本次一诊改造改变其它模块口径。
$query->where('o.order_type', 1)->where('o.amount', 5);
}
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
@@ -998,8 +1137,59 @@ class ConversionLogic
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $usePerformanceOrderMetrics = false
): void {
if ($usePerformanceOrderMetrics) {
$sourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'po.creator_id';
$query = Db::name('tcm_prescription_order')
->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL')
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id AND dg.delete_time IS NULL')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'po');
$query
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count, SUM(po.amount) AS total_amount")
->group($sourceExpr);
if ($mediaChannel !== null) {
$query
->leftJoin('order o', 'o.id = po.linked_pay_order_id')
->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
}
foreach ($query->select()->toArray() as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds(
$dimension,
$sourceAdminId,
$entityIds,
$adminToDeptIds,
$visibleAdminIds
);
if ($mappedEntityIds === []) {
continue;
}
$orderCount = (int)($row['order_count'] ?? 0);
$amount = round((float)($row['total_amount'] ?? 0), 2);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['completed_order_count'] += $orderCount;
$entities[$entityId]['completed_order_amount'] = round(
(float)$entities[$entityId]['completed_order_amount'] + $amount,
2
);
$entities[$entityId]['business_order_amount'] = round(
(float)$entities[$entityId]['business_order_amount'] + $amount,
2
);
}
}
return;
}
$completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$completedQuery = Db::name('tcm_prescription_order')
->alias('po')
@@ -1007,7 +1197,9 @@ class ConversionLogic
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
->whereNull('po.delete_time')
->where('po.payment_slip_audit_status', 1)
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp])
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($completedQuery, 'po');
$completedQuery
->fieldRaw("{$completedSourceExpr} AS source_admin_id, SUM(CASE WHEN po.prescription_audit_status = 1 THEN 1 ELSE 0 END) AS order_count, SUM(CASE WHEN po.prescription_audit_status = 1 THEN po.amount ELSE 0 END) AS total_amount")
->group($completedSourceExpr);
@@ -1043,7 +1235,7 @@ class ConversionLogic
->leftJoin('tcm_diagnosis dg', 'dg.id = po.diagnosis_id')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTimestamp, $endTimestamp]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($businessQuery, 'po');
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($businessQuery, 'po');
$businessQuery
->fieldRaw("{$businessSourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount")
->group($businessSourceExpr);
@@ -1512,6 +1704,8 @@ class ConversionLogic
* @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。
* -1 表示让本函数自行 hydrate>= 0 时直接复用,避免重复 SQL。
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于成员明细的隔离
* @param bool $excludeCancelledAppointments 是否排除已取消挂号,须与部门汇总口径一致
* @param bool $usePerformanceOrderMetrics 是否使用有效业绩订单口径,须与部门汇总口径一致
* @return array<int, array<int, array<string, mixed>>> dept_id => [member_row, ...]
*/
private static function buildMemberRowsByDept(
@@ -1526,7 +1720,9 @@ class ConversionLogic
array $adminToDeptIds,
array $validDeptIds = [],
float $globalAccountCost = -1.0,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $excludeCancelledAppointments = false,
bool $usePerformanceOrderMetrics = false
): array
{
$assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds);
@@ -1535,15 +1731,15 @@ class ConversionLogic
$assistantIds = array_keys($assistantEntities);
$doctorIds = array_keys($doctorEntities);
if ($assistantIds !== []) {
if ($assistantIds !== [] && !$usePerformanceOrderMetrics) {
self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics);
}
if ($doctorIds !== []) {
if ($doctorIds !== [] && !$usePerformanceOrderMetrics) {
self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics);
}
// 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。
@@ -1578,6 +1774,15 @@ class ConversionLogic
}
}
if ($usePerformanceOrderMetrics && $combined !== []) {
// 一诊综合转化的部门指标均按业务归属人统计。成员明细也必须沿用同一归属,
// 不能再分别按“医助/医生”统计后相加,否则双角色员工会重复、人员合计也无法与部门汇总对齐。
$combinedIds = array_keys($combined);
self::hydrateFanStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments, true);
self::hydrateOrderAndAmountStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, true);
}
if ($combined === []) {
return [];
}
@@ -39,7 +39,13 @@ class DoctorDailyStatsLogic
*
* @return array{start_date:string,end_date:string,rows:array,total:array<string,mixed>}
*/
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
public static function overview(
array $params,
int $viewerAdminId = 0,
array $viewerAdminInfo = [],
?array $trustedDoctorIds = null,
?array $trustedAssistantIds = null
): array
{
// dept_ids 透传至共享上下文:未传时仍按默认「中心」树解析(仅用于挂号率默认 0 等兜底);
// 显式传入时由下方 $deptScopedAdminIds 分支用 adminToPrimary 取出医助集合,并下推到三类聚合作为「经手医助」筛选。
@@ -57,7 +63,9 @@ class DoctorDailyStatsLogic
$filterDoctorId = (int) ($params['doctor_id'] ?? 0);
$deptFilterActive = self::hasExplicitDeptIds($params['dept_ids'] ?? null);
$doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo);
$doctorIds = $trustedDoctorIds === null
? self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo)
: self::resolveTrustedDoctorIds($trustedDoctorIds);
if ($filterDoctorId > 0) {
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
@@ -65,8 +73,10 @@ class DoctorDailyStatsLogic
// 部门下医助集合(含全部子级展开后的 admin_dept 命中者);未显式选部门时不参与筛选 → null。
// 显式选部门但集合为空 ⇒ 该部门下无可见医助,直接返回空结果。
$deptScopedAdminIds = null;
if ($deptFilterActive) {
$deptScopedAdminIds = $trustedAssistantIds === null
? null
: self::normalizePositiveIds($trustedAssistantIds);
if ($trustedAssistantIds === null && $deptFilterActive) {
$deptScopedAdminIds = array_values(array_unique(array_map(
'intval',
array_keys($ctx['adminToPrimary'] ?? [])
@@ -159,7 +169,7 @@ class DoctorDailyStatsLogic
}
// 显式部门筛选时隐藏「该部门无任何关联」的医生,避免列出大量全 0 行。
if ($deptFilterActive) {
if ($deptFilterActive || $trustedAssistantIds !== null) {
$rows = array_values(array_filter($rows, static function (array $r): bool {
return (int) ($r['system_prescription_count'] ?? 0) > 0
|| (int) ($r['manual_prescription_count'] ?? 0) > 0
@@ -243,6 +253,37 @@ class DoctorDailyStatsLogic
return $doctorIds;
}
/**
* 仅供服务端内部聚合页传入已经过权限计算的医生集合;仍再次校验医生角色与软删除状态。
* HTTP 参数不会进入此分支。
*
* @param array<int|string,mixed> $trustedDoctorIds
* @return int[]
*/
private static function resolveTrustedDoctorIds(array $trustedDoctorIds): array
{
$ids = self::normalizePositiveIds($trustedDoctorIds);
if ($ids === []) {
return [];
}
return self::normalizePositiveIds(Db::name('admin_role')->alias('ar')
->join('admin a', 'a.id = ar.admin_id')
->where('ar.role_id', 1)
->whereIn('ar.admin_id', $ids)
->whereNull('a.delete_time')
->column('ar.admin_id'));
}
/** @param array<int|string,mixed> $ids @return int[] */
private static function normalizePositiveIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map(
'intval',
$ids
), static fn (int $id): bool => $id > 0)));
}
/**
* @return array<string, float|int|null>
*/
@@ -392,7 +433,7 @@ class DoctorDailyStatsLogic
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->whereBetween('o.create_time', [$startTs, $endTs]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($q, 'o');
$q->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0);
@@ -0,0 +1,902 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\auth\AdminRole;
use app\common\model\auth\SystemRole;
use app\common\model\dept\Dept;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 数据驾驶舱聚合逻辑。
*
* 数据口径:
* - 所有“业绩/接诊诊单”与业绩统计、业务订单列表保持一致:按业务订单创建时间,
* 排除履约已取消/拒收/退款(4/9/10),金额取业务订单 amount,归属人取订单 creator_id。
* - 今日预约、面诊沿用 ConversionLogic;挂号按已支付且实收低于 10 元的订单统计。
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
* - 所有查询都使用 DataScopeService 返回的可见管理员集合收窄。
*/
class PerformanceDashboardLogic
{
private const TREND_DAYS = 7;
/**
* @return array<string, mixed>
*/
public static function overview(int $adminId, array $adminInfo, array $params = []): array
{
$today = date('Y-m-d');
$yesterday = date('Y-m-d', strtotime('-1 day'));
$dayBeforeYesterday = date('Y-m-d', strtotime('-2 days'));
$monthStart = date('Y-m-01');
$previousMonthStart = date('Y-m-01', strtotime('first day of previous month'));
$previousMonthLastDay = (int) date('t', strtotime($previousMonthStart));
$comparisonDay = min((int) date('j'), $previousMonthLastDay);
$previousMonthComparableEnd = date(
'Y-m-d',
strtotime($previousMonthStart . ' +' . max(0, $comparisonDay - 1) . ' days')
);
$trendStart = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
$scope = self::buildScopeContext($adminId, $adminInfo);
/** @var array<int>|null $visibleAdminIds */
$visibleAdminIds = $scope['_visible_admin_ids'];
unset($scope['_visible_admin_ids']);
$rankingDeptId = self::resolveRankingDeptId(
max(0, (int) ($params['ranking_dept_id'] ?? 0)),
$adminId,
$adminInfo
);
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $visibleAdminIds);
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
$registrationDaily = self::loadRegistrationDaily($trendStart, $today, $visibleAdminIds);
$monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount');
$previousMonthAmount = self::sumDailyMetric(
$orderDaily,
$previousMonthStart,
$previousMonthComparableEnd,
'amount'
);
$yesterdayAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
$dayBeforeAmount = self::dailyMetric($orderDaily, $dayBeforeYesterday, 'amount');
$personalMonthAmount = self::sumDailyMetric($personalOrderDaily, $monthStart, $today, 'amount');
$todayOverview = ConversionLogic::overview([
'dimension' => 'dept',
'time_type' => 'today',
'include_members' => 0,
'include_filters' => 0,
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => 100,
], $adminId, $adminInfo);
$todaySummary = is_array($todayOverview['summary'] ?? null) ? $todayOverview['summary'] : [];
$yesterdayOverview = ConversionLogic::overview([
'dimension' => 'dept',
'time_type' => 'yesterday',
'include_members' => 0,
'include_filters' => 0,
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => 100,
], $adminId, $adminInfo);
$yesterdaySummary = is_array($yesterdayOverview['summary'] ?? null)
? $yesterdayOverview['summary']
: [];
// 业绩指标必须直接复用业绩页的权威聚合,不能使用 ConversionLogic 的“双审完成单”。
$todayPerformanceOverview = YejiStatsLogic::overview([
'start_date' => $today,
'end_date' => $today,
], $adminId, $adminInfo);
$appointmentRanking = self::buildRegistrationRanking(
$adminId,
$adminInfo,
$scope,
$visibleAdminIds,
$rankingDeptId
);
$performanceRanking = self::buildPerformanceRanking(
is_array($todayPerformanceOverview['rows'] ?? null) ? $todayPerformanceOverview['rows'] : []
);
$trendContext = YejiStatsLogic::resolveSharedYejiFilterContext([
'start_date' => $trendStart,
'end_date' => $today,
], $adminId, $adminInfo);
$trend = self::buildTrend(
$trendStart,
$today,
$visibleAdminIds,
$orderDaily,
$registrationDaily,
$trendContext
);
$todayTrendIndex = max(0, count($trend['dates'] ?? []) - 1);
$yesterdayTrendIndex = max(0, $todayTrendIndex - 1);
$todayAddFansCount = (int) ($trend['leads'][$todayTrendIndex] ?? 0);
$yesterdayAddFansCount = (int) ($trend['leads'][$yesterdayTrendIndex] ?? 0);
$todayAppointmentCount = (int) ($trend['appointments'][$todayTrendIndex] ?? 0);
$yesterdayAppointmentCount = (int) ($trend['appointments'][$yesterdayTrendIndex] ?? 0);
$todayInterviewCount = (int) ($todaySummary['interview_count'] ?? 0);
$yesterdayInterviewCount = (int) ($yesterdaySummary['interview_count'] ?? 0);
$todayOrderCount = (int) self::dailyMetric($orderDaily, $today, 'count');
$yesterdayOrderCount = (int) self::dailyMetric($orderDaily, $yesterday, 'count');
$todayOrderAmount = self::dailyMetric($orderDaily, $today, 'amount');
$yesterdayOrderAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
$todayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $today, 'count');
$yesterdayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $yesterday, 'count');
$todayPaidAppointmentCount = $todayLowAmountPaymentCount;
$yesterdayPaidAppointmentCount = $yesterdayLowAmountPaymentCount;
$todayPaidAppointmentRate = self::percent($todayPaidAppointmentCount, $todayAddFansCount);
$yesterdayPaidAppointmentRate = self::percent($yesterdayPaidAppointmentCount, $yesterdayAddFansCount);
// 接诊卡片使用的是有效业务订单,接诊率必须使用同一订单口径,不能继续读取旧的双审完成单。
$todayInterviewReceiveRate = self::percent($todayOrderCount, $todayInterviewCount);
$yesterdayInterviewReceiveRate = self::percent($yesterdayOrderCount, $yesterdayInterviewCount);
$target = self::buildTargetProgress(
$adminId,
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF),
date('Y-m'),
$monthAmount,
$personalMonthAmount
);
return [
'scope' => $scope,
'performance' => [
'month_amount' => round($monthAmount, 2),
'month_compare_rate' => self::relativeChange($monthAmount, $previousMonthAmount),
'month_compare_label' => '较上月同期',
'today_amount' => round($todayOrderAmount, 2),
'today_compare_rate' => self::relativeChange($todayOrderAmount, $yesterdayOrderAmount),
'today_compare_label' => '较昨日',
'yesterday_amount' => round($yesterdayAmount, 2),
'yesterday_compare_rate' => self::relativeChange($yesterdayAmount, $dayBeforeAmount),
'yesterday_compare_label' => '较前一日',
'personal_month_amount' => round($personalMonthAmount, 2),
],
'today' => [
'add_fans_count' => $todayAddFansCount,
'appointment_total_count' => $todayAppointmentCount,
'low_amount_payment_count' => $todayLowAmountPaymentCount,
'interview_count' => $todayInterviewCount,
// 保留原响应字段名以兼容已发布前端,数值含义已统一为“计入业绩的业务订单”。
'completed_order_count' => $todayOrderCount,
'completed_order_amount' => $todayOrderAmount,
'paid_appointment_count' => $todayPaidAppointmentCount,
'paid_appointment_rate' => $todayPaidAppointmentRate,
'interview_receive_rate' => $todayInterviewReceiveRate,
'comparisons' => [
'add_fans_count' => self::buildComparison($todayAddFansCount, $yesterdayAddFansCount),
'appointment_total_count' => self::buildComparison(
$todayAppointmentCount,
$yesterdayAppointmentCount
),
'low_amount_payment_count' => self::buildComparison(
$todayLowAmountPaymentCount,
$yesterdayLowAmountPaymentCount
),
'interview_count' => self::buildComparison($todayInterviewCount, $yesterdayInterviewCount),
'completed_order_count' => self::buildComparison($todayOrderCount, $yesterdayOrderCount),
'completed_order_amount' => self::buildComparison($todayOrderAmount, $yesterdayOrderAmount),
'paid_appointment_rate' => self::buildComparison(
$todayPaidAppointmentRate,
$yesterdayPaidAppointmentRate
),
'interview_receive_rate' => self::buildComparison(
$todayInterviewReceiveRate,
$yesterdayInterviewReceiveRate
),
],
],
'rankings' => [
'appointments' => $appointmentRanking,
'performance' => [
'title' => '今日部门业绩排行',
'scope_label' => (string) ($scope['label'] ?? ''),
'items' => $performanceRanking,
],
],
'filters' => [
'ranking_departments' => self::rankingDepartmentOptions($adminId, $adminInfo),
'ranking_dept_id' => $rankingDeptId,
],
'trend' => $trend,
'target' => $target,
'meta' => [
'generated_at' => date('Y-m-d H:i:s'),
'timezone' => date_default_timezone_get(),
'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。',
'rate_note' => '挂号及挂号率:按支付时间统计已支付且 0<实收金额<10 元的订单,每笔订单计 1 个挂号,并按订单创建人归属;预约按预约日期统计有效预约记录。接诊率:有效业务诊单数 / 已完成面诊数。',
],
];
}
/**
* @return array<string, mixed>
*/
private static function buildScopeContext(int $adminId, array $adminInfo): array
{
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$roleIds = AdminRole::where('admin_id', $adminId)->column('role_id');
$roleIds = array_values(array_unique(array_filter(array_map('intval', $roleIds), static fn (int $id): bool => $id > 0)));
$roleNames = [];
if ($roleIds !== []) {
$roleNames = SystemRole::whereIn('id', $roleIds)
->whereNull('delete_time')
->order('sort', 'desc')
->column('name');
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
}
$deptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
$deptIds = array_values(array_unique(array_filter(array_map('intval', $deptIds), static fn (int $id): bool => $id > 0)));
$deptNames = [];
if ($deptIds !== []) {
$deptNames = Dept::whereIn('id', $deptIds)
->whereNull('delete_time')
->order('id', 'asc')
->column('name');
$deptNames = array_values(array_filter(array_map('strval', $deptNames)));
}
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
$scopeKey = $isRoot ? 'root' : [
DataScopeService::SCOPE_ALL => 'all',
DataScopeService::SCOPE_DEPT_AND_CHILD => 'dept_children',
DataScopeService::SCOPE_DEPT => 'dept',
DataScopeService::SCOPE_SELF => 'self',
][$scopeValue] ?? 'self';
$scopeLabel = $isRoot ? '全部数据' : DataScopeService::scopeLabel($scopeValue);
if ($visibleAdminIds === null) {
$visibleMemberCount = (int) Db::name('admin')->whereNull('delete_time')->count();
} else {
$visibleMemberCount = count($visibleAdminIds);
}
return [
'key' => $scopeKey,
'scope_value' => $scopeValue,
'label' => $scopeLabel,
'is_limited' => $visibleAdminIds !== null,
'viewer_name' => (string) ($adminInfo['name'] ?? $adminInfo['account'] ?? ''),
'role_ids' => $roleIds,
'role_names' => $roleNames,
'department_names' => $deptNames,
'visible_member_count' => $visibleMemberCount,
'_visible_admin_ids' => $visibleAdminIds,
];
}
private static function resolveRankingDeptId(int $requestedDeptId, int $adminId, array $adminInfo): int
{
if ($requestedDeptId <= 0) {
return 0;
}
$exists = Dept::where('id', $requestedDeptId)->whereNull('delete_time')->count() > 0;
if (!$exists) {
return 0;
}
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
if ($allowedDeptSet !== null && !isset($allowedDeptSet[$requestedDeptId])) {
return 0;
}
return $requestedDeptId;
}
/** @return array<int, array<string, mixed>> */
private static function rankingDepartmentOptions(int $adminId, array $adminInfo): array
{
return DeptLogic::getAllDataScoped($adminId, $adminInfo);
}
/**
* @param array<int>|null $visibleAdminIds
* @return array<string, array{amount: float, count: int}>
*/
private static function loadPerformanceOrderDaily(string $startDate, string $endDate, ?array $visibleAdminIds): array
{
if ($visibleAdminIds === []) {
return [];
}
$startTs = (int) strtotime($startDate . ' 00:00:00');
$endTs = (int) strtotime($endDate . ' 23:59:59');
$query = Db::name('tcm_prescription_order')
->alias('po')
->whereNull('po.delete_time')
->where('po.create_time', 'between', [$startTs, $endTs]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
if ($visibleAdminIds !== null) {
$query->whereIn('po.creator_id', $visibleAdminIds);
}
$rows = $query
->fieldRaw("FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, SUM(po.amount) AS amount_sum, COUNT(*) AS order_count")
->group('date_label')
->order('date_label', 'asc')
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date === '') {
continue;
}
$out[$date] = [
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
'count' => (int) ($row['order_count'] ?? 0),
];
}
return $out;
}
/**
* 0 < 实收金额 < 10 元的已支付订单笔数;退款订单状态为 4,不会进入统计。
*
* @param array<int>|null $visibleAdminIds
* @return array<string, array{count:int}>
*/
private static function loadRegistrationDaily(
string $startDate,
string $endDate,
?array $visibleAdminIds
): array {
if ($visibleAdminIds === []) {
return [];
}
$query = Db::name('order')
->whereNull('delete_time')
->where('status', 2)
->where('amount', '>', 0)
->where('amount', '<', 10)
->whereNotNull('payment_time')
->where('payment_time', '<>', '')
->whereBetweenTime(
'payment_time',
$startDate . ' 00:00:00',
$endDate . ' 23:59:59'
);
if ($visibleAdminIds !== null) {
$query->whereIn('creator_id', $visibleAdminIds);
}
$rows = $query
->fieldRaw("DATE(payment_time) AS date_label, COUNT(*) AS item_count")
->group('date_label')
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date !== '') {
$out[$date] = ['count' => (int) ($row['item_count'] ?? 0)];
}
}
return $out;
}
/**
* @param array<string, array{amount: float, count: int}> $daily
*/
private static function sumDailyMetric(array $daily, string $startDate, string $endDate, string $metric): float
{
$sum = 0.0;
foreach ($daily as $date => $values) {
if ($date < $startDate || $date > $endDate) {
continue;
}
$sum += (float) ($values[$metric] ?? 0);
}
return round($sum, 2);
}
/**
* @param array<string, array{amount: float, count: int}> $daily
*/
private static function dailyMetric(array $daily, string $date, string $metric): float
{
return round((float) ($daily[$date][$metric] ?? 0), 2);
}
private static function percent(float $numerator, int $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round(($numerator / $denominator) * 100, 2);
}
private static function relativeChange(float $current, float $previous): ?float
{
if (abs($previous) < 0.00001) {
return null;
}
return round((($current - $previous) / $previous) * 100, 2);
}
/**
* @return array{direction: string, rate: float|null, previous: float}
*/
private static function buildComparison(float $current, float $previous): array
{
$difference = $current - $previous;
$direction = abs($difference) < 0.00001
? 'flat'
: ($difference > 0 ? 'up' : 'down');
return [
'direction' => $direction,
'rate' => self::relativeChange($current, $previous),
'previous' => round($previous, 2),
];
}
/**
* @param array<string, mixed> $scope
* @return array<string, mixed>
*/
private static function buildRegistrationRanking(
int $adminId,
array $adminInfo,
array $scope,
?array $baseVisibleAdminIds,
int $rankingDeptId
): array
{
$roleIds = array_map('intval', $scope['role_ids'] ?? []);
$isDoctorSelf = ($scope['key'] ?? '') === 'self'
&& in_array(1, $roleIds, true)
&& !in_array(2, $roleIds, true);
$rankingVisibleAdminIds = $baseVisibleAdminIds;
$rankingScopeLabel = (string) ($scope['label'] ?? '');
if (
(int) ($scope['scope_value'] ?? DataScopeService::SCOPE_SELF) === DataScopeService::SCOPE_SELF
&& in_array(2, $roleIds, true)
) {
$departmentAssistantIds = self::directDepartmentAssistantIds($adminId);
if ($departmentAssistantIds !== []) {
$rankingVisibleAdminIds = $departmentAssistantIds;
$rankingScopeLabel = '本人所属部门';
}
}
if ($rankingDeptId > 0) {
$deptAdminIds = self::departmentAdminIds($rankingDeptId);
$rankingVisibleAdminIds = $rankingVisibleAdminIds === null
? $deptAdminIds
: array_values(array_intersect($rankingVisibleAdminIds, $deptAdminIds));
$rankingScopeLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingScopeLabel);
}
$items = [];
if ($rankingVisibleAdminIds !== []) {
$query = Db::name('order')
->alias('o')
->join('admin a', 'a.id = o.creator_id AND a.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.amount', '>', 0)
->where('o.amount', '<', 10)
->whereNotNull('o.payment_time')
->where('o.payment_time', '<>', '')
->whereBetweenTime('o.payment_time', date('Y-m-d 00:00:00'), date('Y-m-d 23:59:59'));
if ($rankingVisibleAdminIds !== null) {
$query->whereIn('o.creator_id', $rankingVisibleAdminIds);
}
$rows = $query
->fieldRaw('o.creator_id AS id, a.name, COUNT(*) AS item_count, SUM(o.amount) AS amount_sum')
->group(['o.creator_id', 'a.name'])
->orderRaw('item_count DESC, amount_sum DESC, o.creator_id ASC')
->limit(5)
->select()
->toArray();
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'count' => (int) ($row['item_count'] ?? 0),
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
];
}
}
return [
'title' => '实时挂号排行',
'kind' => $isDoctorSelf ? 'doctor' : 'member',
'scope_label' => $rankingScopeLabel,
'items' => $items,
];
}
/** @return int[] */
private static function departmentAdminIds(int $deptId): array
{
$deptIds = array_values(array_unique(array_filter(
array_map('intval', DeptLogic::getSelfAndDescendantIds($deptId)),
static fn (int $id): bool => $id > 0
)));
if ($deptIds === []) {
return [];
}
return array_values(array_unique(array_filter(
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
static fn (int $id): bool => $id > 0
)));
}
/**
* SELF 医助排行的卡片级例外:只扩展到当前账号所有有效直接部门内的有效医助。
* 不展开子部门,也不改变驾驶舱其它指标的数据范围。
*
* @return int[]
*/
private static function directDepartmentAssistantIds(int $adminId): array
{
if ($adminId <= 0) {
return [];
}
$activeAdmin = Db::name('admin')
->where('id', $adminId)
->whereNull('delete_time')
->value('id');
if ((int) $activeAdmin <= 0) {
return [];
}
$deptIds = Db::name('admin_dept')
->alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
->where('ad.admin_id', $adminId)
->column('ad.dept_id');
$deptIds = array_values(array_unique(array_filter(
array_map('intval', $deptIds),
static fn (int $id): bool => $id > 0
)));
if ($deptIds === []) {
return [];
}
$assistantIds = Db::name('admin_dept')
->alias('ad')
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL', 'INNER')
->join('admin a', 'a.id = ad.admin_id AND a.delete_time IS NULL', 'INNER')
->join('admin_role ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER')
->join('system_role sr', 'sr.id = ar.role_id AND sr.delete_time IS NULL', 'INNER')
->whereIn('ad.dept_id', $deptIds)
->distinct(true)
->column('a.id');
return array_values(array_unique(array_filter(
array_map('intval', $assistantIds),
static fn (int $id): bool => $id > 0
)));
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private static function buildPerformanceRanking(array $rows): array
{
usort($rows, static function (array $a, array $b): int {
$byAmount = (float) ($b['performance_amount'] ?? 0) <=> (float) ($a['performance_amount'] ?? 0);
if ($byAmount !== 0) {
return $byAmount;
}
$byCount = (int) ($b['deal_order_count'] ?? 0) <=> (int) ($a['deal_order_count'] ?? 0);
if ($byCount !== 0) {
return $byCount;
}
return (int) ($a['dept_id'] ?? 0) <=> (int) ($b['dept_id'] ?? 0);
});
$items = [];
foreach (array_slice($rows, 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['dept_id'] ?? 0),
'name' => (string) ($row['dept_name'] ?? '未归属中心'),
'amount' => round((float) ($row['performance_amount'] ?? 0), 2),
'count' => (int) ($row['deal_order_count'] ?? 0),
];
}
return $items;
}
/**
* @param array<int>|null $visibleAdminIds
* @param array<string, array{amount: float, count: int}> $orderDaily
* @param array<string, array{count: int}> $registrationDaily
* @param array<string, mixed> $trendContext
* @return array<string, mixed>
*/
private static function buildTrend(
string $startDate,
string $endDate,
?array $visibleAdminIds,
array $orderDaily,
array $registrationDaily,
array $trendContext
): array {
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
? $trendContext['adminToPrimary']
: [];
$tableRowDeptIds = is_array($trendContext['tableRowDeptIds'] ?? null)
? array_values(array_map('intval', $trendContext['tableRowDeptIds']))
: [];
$leadDaily = self::loadLeadDaily($startDate, $endDate, $adminToPrimary, $tableRowDeptIds);
$appointmentDaily = self::loadAppointmentDaily(
$startDate,
$endDate,
$visibleAdminIds,
$adminToPrimary,
$tableRowDeptIds
);
$dates = [];
$registrations = [];
$appointments = [];
$leads = [];
$orders = [];
$cursor = strtotime($startDate);
$end = strtotime($endDate);
while ($cursor <= $end) {
$date = date('Y-m-d', $cursor);
$dates[] = date('m-d', $cursor);
$registrations[] = (int) ($registrationDaily[$date]['count'] ?? 0);
$appointments[] = (int) ($appointmentDaily[$date] ?? 0);
$leads[] = (int) ($leadDaily[$date] ?? 0);
$orders[] = (int) ($orderDaily[$date]['count'] ?? 0);
$cursor = strtotime('+1 day', $cursor);
}
return [
'date_range' => [$startDate, $endDate],
'dates' => $dates,
'registrations' => $registrations,
'appointments' => $appointments,
'leads' => $leads,
'orders' => $orders,
];
}
/**
* YejiStatsLogic 的“进线”一致:只有能映射到当前业绩中心展示行的管理员事件才计入。
*
* @param array<int, int> $adminToPrimary
* @param int[] $tableRowDeptIds
* @return array<string, int>
*/
private static function loadLeadDaily(
string $startDate,
string $endDate,
array $adminToPrimary,
array $tableRowDeptIds
): array
{
$rowFlip = array_flip($tableRowDeptIds);
$mappedAdminIds = [];
foreach ($adminToPrimary as $adminId => $deptId) {
$adminId = (int) $adminId;
$deptId = (int) $deptId;
if ($adminId > 0 && isset($rowFlip[$deptId])) {
$mappedAdminIds[] = $adminId;
}
}
$mappedAdminIds = array_values(array_unique($mappedAdminIds));
if ($mappedAdminIds === []) {
return [];
}
$query = Db::name('qywx_external_contact_event')
->alias('e')
->join('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL', 'INNER')
->where('e.change_type', 'add_external_contact')
->whereIn('a.id', $mappedAdminIds)
->where('e.event_time', 'between', [
strtotime($startDate . ' 00:00:00'),
strtotime($endDate . ' 23:59:59'),
]);
$rows = $query
->fieldRaw("FROM_UNIXTIME(e.event_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count")
->group('date_label')
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date !== '') {
$out[$date] = (int) ($row['item_count'] ?? 0);
}
}
return $out;
}
/**
* @param array<int>|null $visibleAdminIds
* YejiStatsLogic 的“预约诊单”一致:appointment_date,状态 1/3/4
* 归属优先挂号医助、再诊单医助,缺失时回退医生;受限账号只保留当前业绩中心展示行。
*
* @param array<int>|null $visibleAdminIds
* @param array<int, int> $adminToPrimary
* @param int[] $tableRowDeptIds
* @return array<string, int>
*/
private static function loadAppointmentDaily(
string $startDate,
string $endDate,
?array $visibleAdminIds,
array $adminToPrimary,
array $tableRowDeptIds
): array {
if ($visibleAdminIds === []) {
return [];
}
$effectiveAssistantSql = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
$query = Db::name('doctor_appointment')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', 'between', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
$rows = $query
->field([
'a.appointment_date AS date_label',
Db::raw("({$effectiveAssistantSql}) AS effective_assistant_id"),
'a.doctor_id',
Db::raw('COUNT(*) AS appointment_count'),
])
->group(['a.appointment_date', $effectiveAssistantSql, 'a.doctor_id'])
->select()
->toArray();
$visibleFlip = $visibleAdminIds !== null ? array_flip($visibleAdminIds) : null;
$rowFlip = array_flip($tableRowDeptIds);
$out = [];
foreach ($rows as $row) {
$date = (string) ($row['date_label'] ?? '');
if ($date === '') {
continue;
}
$effectiveAssistantId = (int) ($row['effective_assistant_id'] ?? 0);
$doctorId = (int) ($row['doctor_id'] ?? 0);
if ($visibleFlip !== null) {
if ($effectiveAssistantId > 0) {
if (!isset($visibleFlip[$effectiveAssistantId])) {
continue;
}
} elseif ($doctorId <= 0 || !isset($visibleFlip[$doctorId])) {
continue;
}
}
$deptId = $effectiveAssistantId > 0
? (int) ($adminToPrimary[$effectiveAssistantId] ?? 0)
: 0;
if ($deptId <= 0 && $doctorId > 0) {
$deptId = (int) ($adminToPrimary[$doctorId] ?? 0);
}
if ($visibleFlip !== null && !isset($rowFlip[$deptId])) {
continue;
}
$out[$date] = ($out[$date] ?? 0) + (int) ($row['appointment_count'] ?? 0);
}
return $out;
}
/**
* @return array<string, mixed>
*/
private static function buildTargetProgress(
int $adminId,
int $scopeValue,
string $yearMonth,
float $completedAmount,
float $personalAmount
): array {
$deptIds = self::targetDeptIds($adminId, $scopeValue);
$query = Db::name('dept_performance_target')->where('year_month', $yearMonth);
if ($deptIds !== null) {
if ($deptIds === []) {
return self::emptyTarget($yearMonth, $completedAmount, $personalAmount);
}
$query->whereIn('dept_id', $deptIds);
}
$rows = $query->field('dept_id, dept_name, target_amount')->select()->toArray();
$targetAmount = 0.0;
foreach ($rows as $row) {
$targetAmount += (float) ($row['target_amount'] ?? 0);
}
$targetAmount = round($targetAmount, 2);
return [
'year_month' => $yearMonth,
'target_amount' => $targetAmount,
'completed_amount' => round($completedAmount, 2),
'completion_rate' => $targetAmount > 0 ? round($completedAmount / $targetAmount * 100, 2) : null,
'personal_amount' => round($personalAmount, 2),
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
'department_count' => count($rows),
];
}
/**
* @return array<int>|null null 表示全部部门。
*/
private static function targetDeptIds(int $adminId, int $scopeValue): ?array
{
if ($scopeValue === DataScopeService::SCOPE_ALL) {
return null;
}
$ownDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
$ownDeptIds = array_values(array_unique(array_filter(array_map('intval', $ownDeptIds), static fn (int $id): bool => $id > 0)));
if ($ownDeptIds === [] || $scopeValue !== DataScopeService::SCOPE_DEPT_AND_CHILD) {
return $ownDeptIds;
}
$out = [];
foreach ($ownDeptIds as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
$id = (int) $id;
if ($id > 0) {
$out[$id] = true;
}
}
}
return array_keys($out);
}
/**
* @return array<string, mixed>
*/
private static function emptyTarget(string $yearMonth, float $completedAmount, float $personalAmount): array
{
return [
'year_month' => $yearMonth,
'target_amount' => 0.0,
'completed_amount' => round($completedAmount, 2),
'completion_rate' => null,
'personal_amount' => round($personalAmount, 2),
'personal_contribution_rate' => $completedAmount > 0 ? round($personalAmount / $completedAmount * 100, 2) : null,
'department_count' => 0,
];
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use think\facade\Db;
/**
@@ -12,9 +13,11 @@ use think\facade\Db;
* 口径说明:
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
* 同一诊单当月被多次指派给同一医助只计 1
* 同一诊单当月被多次指派给同一医助只计 1
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
* - N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
@@ -22,7 +25,8 @@ use think\facade\Db;
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
* - 分档动态产出:N 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;部门筛选(dept_ids,含组织下级)按该归属部门过滤。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
*/
class RevisitRateLogic
@@ -294,14 +298,19 @@ class RevisitRateLogic
}
/**
* 部门下拉(全量未删除部门,前端组树)。
* 部门下拉:仅「二中心」及其组织下级(与业绩看板 ErCenter 子树一致)。
*
* @return array{rows: list<array{id:int,pid:int,name:string}>}
*/
public static function deptOptions(): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return ['rows' => []];
}
$rows = Db::name('dept')
->whereNull('delete_time')
->whereIn('id', array_keys($erSet))
->field(['id', 'pid', 'name'])
->order('sort', 'desc')
->order('id', 'asc')
@@ -322,9 +331,9 @@ class RevisitRateLogic
/**
* 核心统计上下文:
* 1. 全量指派日志(≤ 月末)构建持有时间线;
* 2. 分母:当月非继承指派的「医助 × 诊单」;
* 3. 分子:曾被指派诊单的当月订单按全局序号 ≥2 归属持有医助
* 4. 应用部门筛选(含组织下级)。
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊)
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
*
* @param array{month?:string,dept_ids?:int[]|string} $params
*
@@ -375,9 +384,35 @@ class RevisitRateLogic
}
}
// 分子:曾被指派诊单的当月订单(全局序号 ≥2),归属下单时点的持有医助
// 分母:剔除名下存在拒收(9)/退款(10) 业务订单的诊单(与明细 assignLines 同口径)
$assignedDiagIds = [];
foreach ($diagsByAssistant as $diagSet) {
foreach ($diagSet as $did => $_) {
$assignedDiagIds[(int) $did] = true;
}
}
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(array_keys($assignedDiagIds));
if ($refundRejectDiagSet !== []) {
foreach ($diagsByAssistant as $aid => $diagSet) {
foreach ($diagSet as $did => $_) {
if (isset($refundRejectDiagSet[$did])) {
unset($diagsByAssistant[$aid][$did]);
}
}
if ($diagsByAssistant[$aid] === []) {
unset($diagsByAssistant[$aid]);
}
}
$pairsRaw = array_values(array_filter(
$pairsRaw,
static fn (array $p): bool => !isset($refundRejectDiagSet[(int) $p['diagnosis_id']])
));
}
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 0 → 第 1 笔实单为一诊)
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
$slotOrdersByAssistant = [];
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
$orderRows = self::fetchOrderSeqRows(
$chunk,
@@ -387,6 +422,7 @@ class RevisitRateLogic
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = 0;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
@@ -397,8 +433,10 @@ class RevisitRateLogic
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$seq++;
$effectiveSlot = $seq + $offset;
$ct = (int) ($r['create_time'] ?? 0);
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
$tl = $timeline[$did] ?? [];
@@ -407,24 +445,27 @@ class RevisitRateLogic
$holder = (int) $tl[$ptr]['to'];
$ptr++;
}
if ($seq < 2 || $seq > self::MAX_VISIT_SLOT) {
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
continue;
}
if ($ct < $startTs || $ct > $endTs) {
continue;
}
if ($holder > 0) {
$slotOrdersByAssistant[$holder][$seq][] = $r;
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
}
}
}
// 医助归属部门 + 部门筛选(含组织下级)
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
$deptFilterIds = self::parseDeptIds($params['dept_ids'] ?? null);
if ($deptFilterIds !== []) {
$subtreeSet = self::expandDeptSubtreeSet($deptFilterIds);
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
if ($subtreeSet === []) {
// 无二中心部门时整表为空,避免误展示其它中心数据
$diagsByAssistant = [];
$slotOrdersByAssistant = [];
} else {
foreach ($universeIds as $aid) {
$deptId = (int) ($assistantDept[$aid] ?? 0);
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
@@ -543,6 +584,45 @@ class RevisitRateLogic
return [$canonical, $names];
}
/**
* 部门筛选集合:始终落在「二中心」子树内。
* - 未传 dept_ids:整棵二中心子树
* - 已传:所选部门及其下级 二中心子树(非法/非二中心 id 被忽略)
*
* @param mixed $raw
*
* @return array<int, true>
*/
private static function resolveDeptFilterSet(mixed $raw): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return [];
}
$deptFilterIds = self::parseDeptIds($raw);
if ($deptFilterIds === []) {
return $erSet;
}
$allowedRoots = [];
foreach ($deptFilterIds as $id) {
if (isset($erSet[$id])) {
$allowedRoots[] = $id;
}
}
if ($allowedRoots === []) {
return [];
}
$expanded = self::expandDeptSubtreeSet($allowedRoots);
$out = [];
foreach ($expanded as $id => $_) {
if (isset($erSet[$id])) {
$out[$id] = true;
}
}
return $out;
}
/**
* @param mixed $raw int[] | 逗号分隔字符串
*
@@ -596,6 +676,35 @@ class RevisitRateLogic
return $set;
}
/**
* 名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单集合。
* 用于「当月被指派总数」分母过滤;不限订单创建月份。
*
* @param list<int> $diagIds
*
* @return array<int, true>
*/
private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$ids = Db::name('tcm_prescription_order')
->whereIn('diagnosis_id', $chunk)
->whereNull('delete_time')
->whereIn('fulfillment_status', [9, 10])
->group('diagnosis_id')
->column('diagnosis_id');
foreach ($ids as $id) {
$out[(int) $id] = true;
}
}
return $out;
}
/**
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
*
@@ -619,6 +728,48 @@ class RevisitRateLogic
->toArray();
}
/**
* @param list<int> $diagIds
*
* @return array<int, int> diagnosis_id => revisit_slot_start_offset
*/
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $chunk)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
foreach ($rows as $id => $offset) {
$out[(int) $id] = (int) $offset;
}
}
return $out;
}
/**
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移)
*
* @param array<int, int> $offsetMap
*/
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
{
$offset = (int) ($offsetMap[$diagId] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > 20) {
$offset = 20;
}
return $offset;
}
/**
* @param list<int> $diagIds
*
@@ -3893,6 +3893,18 @@ class YejiStatsLogic
}
}
/**
* 有效金额统计:在业绩履约状态口径上,再排除任何已发生退款(含部分退款)的订单。
*
* @param \think\db\BaseQuery|\think\Model $query
*/
public static function applyPrescriptionOrderEffectiveAmountQuery($query, string $tableAlias = ''): void
{
self::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, $tableAlias);
$refundField = $tableAlias !== '' ? "{$tableAlias}.refund_amount" : 'refund_amount';
$query->whereRaw("({$refundField} IS NULL OR {$refundField} <= 0)");
}
/**
* @param \think\db\BaseQuery|\think\Model $query
*/
@@ -748,6 +748,49 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* @notes 诊单指派医助操作记录列表
*/
public static function setRevisitSlotStartOffset(int $diagnosisId, int $offset, array $adminInfo): bool
{
self::$error = '';
if ($diagnosisId <= 0) {
self::setError('诊单不存在');
return false;
}
if ($offset < 0 || $offset > 20) {
self::setError('起始偏移须在 020 之间');
return false;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find();
if (!$diagnosis) {
self::setError('诊单不存在');
return false;
}
$old = (int) ($diagnosis->revisit_slot_start_offset ?? 0);
if ($old < 0) {
$old = 0;
}
if ($old > 20) {
$old = 20;
}
if ($old === $offset) {
return true;
}
try {
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单指派医助操作记录列表
*/
@@ -7,7 +7,9 @@ namespace app\adminapi\logic\tcm;
use app\common\cache\AdminAuthCache;
use app\common\logic\BaseLogic;
use app\common\model\auth\AdminRole;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\tcm\PrescriptionLibrary;
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
use think\facade\Config;
/**
@@ -15,6 +17,18 @@ use think\facade\Config;
*/
class PrescriptionLibraryLogic extends BaseLogic
{
/** @param array<int,array<string,mixed>> $herbs @return array<int,array<string,mixed>> */
private static function normalizeHerbIdentities(array $herbs): array
{
return PharmacyHerbIdentityResolver::resolve(
$herbs,
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
);
}
/**
* @notes 是否可管理全部处方(超级管理员 配置中的管理员角色)
*/
@@ -94,7 +108,10 @@ class PrescriptionLibraryLogic extends BaseLogic
// 处理药材数据
if (isset($params['herbs']) && is_array($params['herbs'])) {
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
$params['herbs'] = json_encode(
self::normalizeHerbIdentities($params['herbs']),
JSON_UNESCAPED_UNICODE
);
}
$model = PrescriptionLibrary::create($params);
@@ -130,7 +147,10 @@ class PrescriptionLibraryLogic extends BaseLogic
// 处理药材数据
if (isset($params['herbs']) && is_array($params['herbs'])) {
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
$params['herbs'] = json_encode(
self::normalizeHerbIdentities($params['herbs']),
JSON_UNESCAPED_UNICODE
);
}
$model->save($params);
@@ -6,10 +6,13 @@ namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\Diagnosis;
use app\common\service\wechat\WechatWorkAppMessageService;
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
use app\adminapi\logic\auth\AuthLogic;
use think\facade\Config;
use think\facade\Log;
@@ -159,6 +162,18 @@ class PrescriptionLogic
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
}
/** @param array<int,array<string,mixed>> $herbs @return array<int,array<string,mixed>> */
private static function normalizeHerbIdentities(array $herbs): array
{
return PharmacyHerbIdentityResolver::resolve(
$herbs,
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
);
}
/**
* @notes 辅方用法 JSON(与主方 dosage/times/days 字段结构一致)
*/
@@ -255,12 +270,11 @@ class PrescriptionLogic
self::setError('请添加中药');
return null;
}
foreach ($herbs as $h) {
if (empty($h['name'])) {
self::setError('中药名称不能为空');
return null;
}
try {
$herbs = self::normalizeHerbIdentities($herbs);
} catch (\DomainException $exception) {
self::setError($exception->getMessage());
return null;
}
$sn = self::generateSn();
@@ -335,6 +349,23 @@ class PrescriptionLogic
* 编辑处方
*/
public static function edit(array $params, int $adminId): bool
{
$prescriptionId = (int) ($params['id'] ?? 0);
self::setError('');
try {
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
$prescriptionId,
static fn (): bool => self::editLocked($params, $adminId)
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
self::setError($exception->getMessage());
}
return false;
}
}
private static function editLocked(array $params, int $adminId): bool
{
try {
$prescription = Prescription::find($params['id']);
@@ -342,6 +373,11 @@ class PrescriptionLogic
self::setError('处方不存在');
return false;
}
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
if ($snapshotLockError !== null) {
self::setError($snapshotLockError);
return false;
}
// 已通过且未作废:不可编辑
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
@@ -381,12 +417,7 @@ class PrescriptionLogic
return false;
}
foreach ($herbs as $h) {
if (empty($h['name'])) {
self::setError('中药名称不能为空');
return false;
}
}
$herbs = self::normalizeHerbIdentities($herbs);
$newDiagnosisId = (int) ($params['diagnosis_id'] ?? $prescription->diagnosis_id);
$newDateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? $prescription->prescription_date);
@@ -474,6 +505,29 @@ class PrescriptionLogic
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
*/
public static function patchPatientContact(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
{
self::setError('');
try {
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
$rxId,
static fn (): bool => self::patchPatientContactLocked(
$rxId,
$patientName,
$phone,
$gender,
$adminId,
$adminInfo
)
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
self::setError($exception->getMessage());
}
return false;
}
}
private static function patchPatientContactLocked(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
{
self::$error = '';
$prescription = Prescription::find($rxId);
@@ -482,6 +536,11 @@ class PrescriptionLogic
return false;
}
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
if ($snapshotLockError !== null) {
self::setError($snapshotLockError);
return false;
}
if (!self::canViewPrescription($prescription, $adminId, $adminInfo)) {
self::setError('无权限修改此处方');
@@ -578,6 +637,22 @@ class PrescriptionLogic
* 删除处方
*/
public static function delete(int $id): bool
{
self::setError('');
try {
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
$id,
static fn (): bool => self::deleteLocked($id)
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
self::setError($exception->getMessage());
}
return false;
}
}
private static function deleteLocked(int $id): bool
{
try {
$prescription = Prescription::find($id);
@@ -1027,6 +1102,22 @@ class PrescriptionLogic
* 作废处方
*/
public static function void(int $id, int $adminId, string $adminName): bool
{
self::setError('');
try {
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
$id,
static fn (): bool => self::voidLocked($id, $adminId, $adminName)
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
self::setError($exception->getMessage());
}
return false;
}
}
private static function voidLocked(int $id, int $adminId, string $adminName): bool
{
$row = Prescription::find($id);
if (!$row) {
File diff suppressed because it is too large Load Diff

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