Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6890767e | ||
|
|
d57e60539e | ||
|
|
78fd00fa05 | ||
|
|
fd7e6071a8 | ||
|
|
7192c4db8c | ||
|
|
b02c340348 | ||
|
|
0f21dd4451 | ||
|
|
1ed3e06fdf | ||
|
|
c608145023 | ||
|
|
4f95d9182e | ||
|
|
f662723a25 | ||
|
|
32370df7fe | ||
|
|
62467dc01c | ||
|
|
97fefbae8f | ||
|
|
541fe59ad1 | ||
|
|
b6c5b422c2 | ||
|
|
500038601c | ||
|
|
f6688a740d | ||
|
|
a347e0642c | ||
|
|
13adbac3fd | ||
|
|
333a18c37e | ||
|
|
e3a91a91f7 | ||
|
|
5ca5f06255 | ||
|
|
e01f3e7072 | ||
|
|
ed78803015 | ||
|
|
546138d60e | ||
|
|
fdddfdb3dd | ||
|
|
f5a89b4310 | ||
|
|
a650747fd3 | ||
|
|
46b62229a6 | ||
|
|
0f33f2ad28 | ||
|
|
a677b69e97 | ||
|
|
6432e9990c | ||
|
|
4d6125268a | ||
|
|
7f93cf5480 | ||
|
|
f8f1953a2a | ||
|
|
99eca8b2e5 | ||
|
|
bde396189a | ||
|
|
55e3b5d9e0 | ||
|
|
eb0efba739 | ||
|
|
c7e7022181 | ||
|
|
26e6cc433b | ||
|
|
c22b84cff9 | ||
|
|
4f9486fb3d | ||
|
|
d24b04f9e9 | ||
|
|
465cba3788 | ||
|
|
dc590d37e9 | ||
|
|
d1f587fdd7 | ||
|
|
e8f68101c4 | ||
|
|
cea9392239 | ||
|
|
3ea3c97c42 | ||
|
|
efd058bce5 | ||
|
|
e538dd03a1 | ||
|
|
4a17fe8a0c | ||
|
|
e2a1e62867 | ||
|
|
b6cdd20c56 | ||
|
|
c1be0aa3e5 | ||
|
|
6c87b72ce4 | ||
|
|
e28480af32 |
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||||
import { ref} from "vue";
|
import { ref} from "vue";
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
|
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
|
||||||
import { getCurrentInstance } from 'vue'
|
import { getCurrentInstance } from 'vue'
|
||||||
@@ -97,11 +96,7 @@ const video =async (data)=>{
|
|||||||
},
|
},
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
|
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
console.log('SDKAppID',SDKAppID)
|
console.log('SDKAppID',SDKAppID)
|
||||||
getApp().globalData.userID = userId;
|
getApp().globalData.userID = userId;
|
||||||
getApp().globalData.userSig = userSig;
|
getApp().globalData.userSig = userSig;
|
||||||
|
|||||||
@@ -239,7 +239,6 @@
|
|||||||
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
|
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
|
||||||
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
|
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
|
||||||
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
|
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
|
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
|
||||||
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
|
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
|
||||||
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
|
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
|
||||||
@@ -561,17 +560,12 @@ const { proxy } = getCurrentInstance()
|
|||||||
},
|
},
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
|
|
||||||
patient_data.value=res.data.data
|
patient_data.value=res.data.data
|
||||||
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
getApp().globalData.userID = userId;
|
getApp().globalData.userID = userId;
|
||||||
getApp().globalData.userSig = userSig;
|
getApp().globalData.userSig = userSig;
|
||||||
getApp().globalData.SDKAppID = SDKAppID;
|
getApp().globalData.SDKAppID = SDKAppID;
|
||||||
getApp().globalData.secretKey = secretKey;
|
|
||||||
uni.setStorageSync('CallManager', userId)
|
uni.setStorageSync('CallManager', userId)
|
||||||
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
|
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,16 +186,8 @@ const inlineItems = computed(() => {
|
|||||||
return metro ? [metro, ...rest] : trainingItems
|
return metro ? [metro, ...rest] : trainingItems
|
||||||
})
|
})
|
||||||
|
|
||||||
const isDevMode = (): boolean => {
|
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
|
||||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
const visible = ref(true)
|
||||||
try {
|
|
||||||
return process.env.NODE_ENV !== 'production'
|
|
||||||
} catch (_) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visible = ref(isDevMode())
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
|
|||||||
@@ -178,7 +178,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { getCurrentInstance } from 'vue'
|
import { getCurrentInstance } from 'vue'
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
let userId = ref("");
|
let userId = ref("");
|
||||||
@@ -379,10 +378,11 @@ const loginHandler = async (patient_id) => {
|
|||||||
data: { patient_id: patient_id },
|
data: { patient_id: patient_id },
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const { userId: patientUserId } = res.data
|
const {
|
||||||
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
|
userId: patientUserId,
|
||||||
userID: patientUserId,
|
userSig: patientUserSig,
|
||||||
})
|
sdkAppId: patientSDKAppID,
|
||||||
|
} = res.data
|
||||||
console.log('获取签名成功:', patientUserSig)
|
console.log('获取签名成功:', patientUserSig)
|
||||||
|
|
||||||
// 更新ref值
|
// 更新ref值
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
/* 小程序特有相关 */
|
/* 小程序特有相关 */
|
||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||||
|
"lazyCodeLoading" : "requiredComponents",
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false,
|
"urlCheck" : false,
|
||||||
"minified" : true
|
"minified" : true
|
||||||
|
|||||||
+19
-12
@@ -26,14 +26,6 @@
|
|||||||
"navigationBarTitleText": "甄养堂"
|
"navigationBarTitleText": "甄养堂"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "TUICallKit/src/Components/TUICallKit",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "视频问诊",
|
|
||||||
"navigationBarHidden": true,
|
|
||||||
"navigationStyle": "custom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/order/monad/monad",
|
"path": "pages/order/monad/monad",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -76,6 +68,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subPackages": [
|
"subPackages": [
|
||||||
|
{
|
||||||
|
"root": "TUICallKit/src/Components",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "TUICallKit",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "视频问诊",
|
||||||
|
"navigationBarHidden": true,
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"root": "TUIKit",
|
"root": "TUIKit",
|
||||||
"pages": [
|
"pages": [
|
||||||
@@ -216,12 +221,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/game",
|
"path": "endless-game/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "糖分突袭",
|
"navigationBarTitleText": "识糖小课堂",
|
||||||
"backgroundColor": "#c7d2fe",
|
"backgroundColor": "#eefbf4",
|
||||||
"disableScroll": true
|
"disableScroll": false,
|
||||||
|
"enableShareAppMessage": true,
|
||||||
|
"enableShareTimeline": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -62,7 +62,6 @@
|
|||||||
import { ref,onMounted,getCurrentInstance} from "vue";
|
import { ref,onMounted,getCurrentInstance} from "vue";
|
||||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
|
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
|
||||||
let userID = ref("4");
|
let userID = ref("4");
|
||||||
@@ -140,13 +139,9 @@ const loginHandler = async (patient_id) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
|
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
console.log('获取签名成功:',userSig);
|
console.log('获取签名成功:',userSig);
|
||||||
getApp().globalData.userID = userId.value;
|
getApp().globalData.userID = userId;
|
||||||
getApp().globalData.userSig = userSig;
|
getApp().globalData.userSig = userSig;
|
||||||
getApp().globalData.SDKAppID = SDKAppID;
|
getApp().globalData.SDKAppID = SDKAppID;
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, getCurrentInstance,onMounted } from "vue";
|
import { ref, getCurrentInstance,onMounted } from "vue";
|
||||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
let userId = ref("");
|
let userId = ref("");
|
||||||
@@ -126,10 +125,7 @@ const loginHandler = async (patient_id) => {
|
|||||||
data: { patient_id: patient_id },
|
data: { patient_id: patient_id },
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
console.log('获取签名成功:',userSig);
|
console.log('获取签名成功:',userSig);
|
||||||
|
|
||||||
getApp().globalData.userID = userId;
|
getApp().globalData.userID = userId;
|
||||||
|
|||||||
@@ -370,7 +370,6 @@
|
|||||||
import { ref,onMounted,getCurrentInstance } from "vue";
|
import { ref,onMounted,getCurrentInstance } from "vue";
|
||||||
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
|
||||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
|
||||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
@@ -667,25 +666,25 @@ const closePatientModal = async () => {
|
|||||||
const granted = await requestAVPermissionsInModal();
|
const granted = await requestAVPermissionsInModal();
|
||||||
if (!granted) return; // 未授权,停止跳转
|
if (!granted) return; // 未授权,停止跳转
|
||||||
const app = getApp();
|
const app = getApp();
|
||||||
// const userID = app.globalData.userID;
|
|
||||||
// const userSig = app.globalData.userSig;
|
|
||||||
// const SDKAppID = app.globalData.SDKAppID;
|
|
||||||
const doctor = 'doctor_' + doctorId.value;
|
const doctor = 'doctor_' + doctorId.value;
|
||||||
await confirmDiagnosis(diagnosisId);
|
await confirmDiagnosis(diagnosisId);
|
||||||
if(!diagnosisId.includes('patient')){
|
const signatureRes = await proxy.apiUrl({
|
||||||
diagnosisId='patient_'+diagnosisId
|
url: '/api/tcm/getPatientSignature',
|
||||||
}
|
method: 'GET',
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
data: { patient_id: diagnosisId },
|
||||||
userID:diagnosisId,
|
}, false);
|
||||||
});
|
const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
|
||||||
if (!doctorId.value) {
|
if (!doctorId.value) {
|
||||||
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
|
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
|
||||||
return;
|
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;
|
showPatientModal.value = false;
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
@@ -707,11 +706,7 @@ const video =async (data)=>{
|
|||||||
},
|
},
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
const {userId } = res.data;
|
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||||
|
|
||||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
|
||||||
userID:userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
getApp().globalData.userID = userId;
|
getApp().globalData.userID = userId;
|
||||||
getApp().globalData.userSig = userSig;
|
getApp().globalData.userSig = userSig;
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
|
|
||||||
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
|
||||||
<image
|
|
||||||
v-if="src && !imageFailed"
|
|
||||||
class="food-tile-icon__img"
|
|
||||||
:src="src"
|
|
||||||
mode="aspectFit"
|
|
||||||
@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 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 ''
|
|
||||||
})
|
|
||||||
|
|
||||||
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 .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__img {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 2;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
|
||||||
|
<view class="vsm-body">
|
||||||
|
<view class="vsm-leaf" aria-hidden="true" />
|
||||||
|
<view class="vsm-face">
|
||||||
|
<view class="vsm-eye vsm-eye--l" />
|
||||||
|
<view class="vsm-eye vsm-eye--r" />
|
||||||
|
<view class="vsm-blush vsm-blush--l" />
|
||||||
|
<view class="vsm-blush vsm-blush--r" />
|
||||||
|
<view class="vsm-mouth" />
|
||||||
|
</view>
|
||||||
|
<view v-if="active" class="vsm-waves" aria-hidden="true">
|
||||||
|
<view class="vsm-wave vsm-wave--1" />
|
||||||
|
<view class="vsm-wave vsm-wave--2" />
|
||||||
|
<view class="vsm-wave vsm-wave--3" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
/** 正在按住说话 / 录音中 */
|
||||||
|
active: { type: Boolean, default: false },
|
||||||
|
/** 深色条背景上使用浅色卡通 */
|
||||||
|
onDark: { type: Boolean, default: false },
|
||||||
|
size: { type: String, default: 'md' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.vsm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vsm--md { width: 72rpx; height: 72rpx; }
|
||||||
|
.vsm--sm { width: 64rpx; height: 64rpx; }
|
||||||
|
|
||||||
|
.vsm-body {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-leaf {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rpx;
|
||||||
|
left: 50%;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
margin-left: -8rpx;
|
||||||
|
border-radius: 0 100% 0 100%;
|
||||||
|
background: #34d399;
|
||||||
|
transform: rotate(-18deg);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-leaf {
|
||||||
|
background: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-face {
|
||||||
|
position: relative;
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fef9c3;
|
||||||
|
border: 3rpx solid #047857;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-face {
|
||||||
|
width: 46rpx;
|
||||||
|
height: 46rpx;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-face {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-eye {
|
||||||
|
position: absolute;
|
||||||
|
top: 16rpx;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #064e3b;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
|
||||||
|
.vsm--on-dark .vsm-eye { background: #134e4a; }
|
||||||
|
.vsm-eye--l { left: 12rpx; }
|
||||||
|
.vsm-eye--r { right: 12rpx; }
|
||||||
|
.vsm--sm .vsm-eye--l { left: 10rpx; }
|
||||||
|
.vsm--sm .vsm-eye--r { right: 10rpx; }
|
||||||
|
|
||||||
|
.vsm-blush {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
width: 10rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(251, 113, 133, 0.45);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
|
||||||
|
.vsm-blush--l { left: 6rpx; }
|
||||||
|
.vsm-blush--r { right: 6rpx; }
|
||||||
|
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
|
||||||
|
|
||||||
|
.vsm-mouth {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 18rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
margin-left: -9rpx;
|
||||||
|
border-radius: 0 0 18rpx 18rpx;
|
||||||
|
background: #047857;
|
||||||
|
transform-origin: center top;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-mouth {
|
||||||
|
bottom: 9rpx;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 7rpx;
|
||||||
|
margin-left: -8rpx;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
|
||||||
|
|
||||||
|
.vsm-waves {
|
||||||
|
position: absolute;
|
||||||
|
right: -2rpx;
|
||||||
|
top: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
margin-top: -14rpx;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.vsm-wave {
|
||||||
|
width: 5rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: #047857;
|
||||||
|
animation: vsm-wave-jump 0.72s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
|
||||||
|
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
|
||||||
|
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
|
||||||
|
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
|
||||||
|
|
||||||
|
.vsm--active .vsm-body {
|
||||||
|
animation: vsm-bob 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vsm--active .vsm-mouth {
|
||||||
|
animation: vsm-talk 0.32s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.vsm--active .vsm-eye {
|
||||||
|
animation: vsm-blink 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vsm-bob {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-4rpx); }
|
||||||
|
}
|
||||||
|
@keyframes vsm-talk {
|
||||||
|
0% {
|
||||||
|
transform: scaleY(0.45);
|
||||||
|
border-radius: 0 0 18rpx 18rpx;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1.15);
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes vsm-blink {
|
||||||
|
0%, 42%, 46%, 100% { transform: scaleY(1); }
|
||||||
|
44% { transform: scaleY(0.15); }
|
||||||
|
}
|
||||||
|
@keyframes vsm-wave-jump {
|
||||||
|
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
|
||||||
|
50% { transform: scaleY(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
</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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,27 @@ import { ref, onUnmounted } from 'vue'
|
|||||||
|
|
||||||
const MIN_HOLD_MS = 280
|
const MIN_HOLD_MS = 280
|
||||||
|
|
||||||
|
/** 短按/过早松手时插件常见错误,不应打扰用户 */
|
||||||
|
function isBenignSttError(msg, retcode) {
|
||||||
|
const m = String(msg || '').toLowerCase()
|
||||||
|
if (/internal voice data failed/i.test(m)) return true
|
||||||
|
if (/voice data failed/i.test(m)) return true
|
||||||
|
if (/recordfailed|record failed|record manager/i.test(m)) return true
|
||||||
|
if (/please stop after start/i.test(m)) return true
|
||||||
|
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
|
||||||
|
const code = Number(retcode)
|
||||||
|
if (code === -30002 || code === -30003 || code === -30012) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlySttError(msg) {
|
||||||
|
const m = String(msg || '').trim()
|
||||||
|
if (!m || isBenignSttError(m)) return ''
|
||||||
|
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
|
||||||
|
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
|
||||||
|
return '语音识别失败,请重试'
|
||||||
|
}
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
||||||
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
||||||
@@ -112,7 +133,8 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
|||||||
function stopListening() {
|
function stopListening() {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
const ownsRecording = sharedRecording && activeCtl === controller
|
const ownsRecording = sharedRecording && activeCtl === controller
|
||||||
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
|
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
|
||||||
|
if (wxRecordManager && (sttListening.value || ownsRecording)) {
|
||||||
try {
|
try {
|
||||||
wxRecordManager.stop()
|
wxRecordManager.stop()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -128,6 +150,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
startRequested = false
|
startRequested = false
|
||||||
|
sttHolding.value = false
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +194,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
startRequested = true
|
startRequested = true
|
||||||
sharedRecording = true
|
|
||||||
wxRecordManager.start({
|
wxRecordManager.start({
|
||||||
duration: 60000,
|
duration: 60000,
|
||||||
lang: 'zh_CN'
|
lang: 'zh_CN'
|
||||||
@@ -251,22 +273,28 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
|||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 松手结束:手指抬起 */
|
/** 松手结束:手指抬起;force=true 时强制取消尚未真正开始的按住会话 */
|
||||||
function endHoldSpeech() {
|
function endHoldSpeech(force = false) {
|
||||||
let recording = false
|
let recording = false
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
recording = sharedRecording && activeCtl === controller
|
recording = sharedRecording && activeCtl === controller
|
||||||
// #endif
|
// #endif
|
||||||
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
// 松手后不再补开排队的录音
|
// 松手后不再补开排队的录音
|
||||||
pendingStartSession = 0
|
pendingStartSession = 0
|
||||||
if (sttListening.value || startRequested || recording) {
|
if (sttListening.value || recording) {
|
||||||
stopListening()
|
stopListening()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (startRequested) {
|
||||||
|
// start 已发出但 onStart 未到:取消会话,勿调 stop()
|
||||||
|
startRequested = false
|
||||||
|
holdSessionId += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
holdSessionId += 1
|
holdSessionId += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,9 +304,9 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
|||||||
const controller = {
|
const controller = {
|
||||||
handleStart() {
|
handleStart() {
|
||||||
if (!sttHolding.value) {
|
if (!sttHolding.value) {
|
||||||
try {
|
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
|
||||||
wxRecordManager.stop()
|
startRequested = false
|
||||||
} catch (e) {}
|
sttListening.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startRequested = false
|
startRequested = false
|
||||||
@@ -306,6 +334,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
|||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
startRequested = false
|
startRequested = false
|
||||||
const msg = (res && res.msg) || ''
|
const msg = (res && res.msg) || ''
|
||||||
|
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
|
||||||
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
|
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
|
||||||
if (/please stop after start/i.test(msg)) {
|
if (/please stop after start/i.test(msg)) {
|
||||||
try {
|
try {
|
||||||
@@ -316,12 +345,18 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
|||||||
}
|
}
|
||||||
pendingStartSession = 0
|
pendingStartSession = 0
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
|
const heldMs = Date.now() - holdStartTs
|
||||||
|
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
|
||||||
|
if (typeof onError === 'function') onError(msg || '')
|
||||||
|
return
|
||||||
|
}
|
||||||
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
||||||
if (typeof onError === 'function') {
|
if (typeof onError === 'function') {
|
||||||
const handled = onError(msg || '')
|
const handled = onError(msg || '')
|
||||||
if (handled === true) return
|
if (handled === true) return
|
||||||
}
|
}
|
||||||
notify(msg || '语音识别失败')
|
const tip = friendlySttError(msg)
|
||||||
|
if (tip) notify(tip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,3 +446,5 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
|
|||||||
stopSpeechToText: endHoldSpeech
|
stopSpeechToText: endHoldSpeech
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { isBenignSttError, friendlySttError }
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
/**
|
|
||||||
* 食材图标 URL(Twemoji 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,189 +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: '白粥熬得软烂,升糖很快,糖友要少喝'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 含糖等级通用科普文案 */
|
|
||||||
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' },
|
|
||||||
{ 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: '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' },
|
|
||||||
{ 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' },
|
|
||||||
{ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
|
|
||||||
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
|
|
||||||
* 第一关固定为这 5 种带真实图片素材的食物。
|
|
||||||
*/
|
|
||||||
const FIXED_LEVEL_FOODS = {
|
|
||||||
1: ['milkOats', 'grainMantou', 'riceNoodleSoup', 'friedNoodles', 'centuryEggCongee']
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
-26
@@ -25,6 +25,13 @@ const SRC = {
|
|||||||
* rate 不同可在同一素材上听出明显差异
|
* rate 不同可在同一素材上听出明显差异
|
||||||
*/
|
*/
|
||||||
const SFX = {
|
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 },
|
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
|
||||||
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
|
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 }
|
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 {
|
class SfxPool {
|
||||||
constructor(src, size, volume, rate = 1) {
|
constructor(src, size, volume, rate = 1) {
|
||||||
this.src = src
|
this.src = src
|
||||||
@@ -88,40 +132,42 @@ class SfxPool {
|
|||||||
this.warmedUp = false
|
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() {
|
create() {
|
||||||
for (let i = 0; i < this.size; i++) {
|
while (this.list.length < this.size) this.createOne()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
warmUp() {
|
warmUp() {
|
||||||
if (this.warmedUp) return
|
if (this.warmedUp) return
|
||||||
if (!this.list.length) this.create()
|
const ctx = this.list[0] || this.createOne()
|
||||||
this.list.forEach((ctx) => {
|
try {
|
||||||
try {
|
ctx.volume = 0
|
||||||
ctx.volume = 0
|
ctx.play()
|
||||||
ctx.play()
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
try {
|
||||||
try {
|
ctx.stop()
|
||||||
ctx.stop()
|
ctx.volume = this.volume
|
||||||
ctx.volume = this.volume
|
} catch (_) {}
|
||||||
} catch (_) {}
|
}, 60)
|
||||||
}, 60)
|
} catch (_) {}
|
||||||
} catch (_) {}
|
if (this.size > 1) this.cursor = 1
|
||||||
})
|
|
||||||
this.warmedUp = true
|
this.warmedUp = true
|
||||||
}
|
}
|
||||||
|
|
||||||
play(scale = 1, rateMul = 1) {
|
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]
|
const ctx = this.list[this.cursor % this.list.length]
|
||||||
this.cursor += 1
|
this.cursor += 1
|
||||||
const vol = Math.min(1, this.volume * scale)
|
const vol = Math.min(1, this.volume * scale)
|
||||||
@@ -204,7 +250,8 @@ export function useGameSfx() {
|
|||||||
function warmUp() {
|
function warmUp() {
|
||||||
if (!enabled.value) return
|
if (!enabled.value) return
|
||||||
ensureAudioOption()
|
ensureAudioOption()
|
||||||
Object.keys(SFX).forEach((name) => {
|
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
|
||||||
|
names.forEach((name) => {
|
||||||
getPool(name)?.warmUp()
|
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
File diff suppressed because it is too large
Load Diff
@@ -98,15 +98,17 @@
|
|||||||
<view v-if="chartUseMockData" class="st-chart-foot">
|
<view v-if="chartUseMockData" class="st-chart-foot">
|
||||||
<text class="st-chart-mock-hint">示例数据,累计记录满 7 天后显示您的真实趋势</text>
|
<text class="st-chart-mock-hint">示例数据,累计记录满 7 天后显示您的真实趋势</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
|
<view class="st-game-entry-list">
|
||||||
<view class="st-game-entry-icon">
|
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
|
||||||
<TongjiIcon name="activity" size="sm" color="#006c49" />
|
<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>
|
||||||
<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>
|
</view>
|
||||||
<view class="st-training-section">
|
<view class="st-training-section">
|
||||||
@@ -1512,15 +1514,19 @@ async function goMorePage() {
|
|||||||
uni.navigateTo({ url: buildMorePageUrl() })
|
uni.navigateTo({ url: buildMorePageUrl() })
|
||||||
}
|
}
|
||||||
|
|
||||||
function goGamePage() {
|
function openGamePage(url) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: '/tongji/pages/game',
|
url,
|
||||||
fail() {
|
fail() {
|
||||||
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
|
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goEndlessGamePage() {
|
||||||
|
openGamePage('/tongji/endless-game/index')
|
||||||
|
}
|
||||||
|
|
||||||
function navToUser() {
|
function navToUser() {
|
||||||
uni.redirectTo({ url: '/pages/user/user' })
|
uni.redirectTo({ url: '/pages/user/user' })
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -639,7 +639,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.more-page .mc-tasks-section {
|
.more-page .mc-tasks-section {
|
||||||
margin-top: 40rpx;
|
margin-top: 0;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-page .mc-tasks-grid {
|
.more-page .mc-tasks-grid {
|
||||||
@@ -1167,3 +1168,259 @@
|
|||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: rgba(108, 122, 113, 0.6);
|
color: rgba(108, 122, 113, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.more-page .mc-tree-species-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
margin-left: 8rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(0, 108, 73, 0.08);
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel,
|
||||||
|
.more-page .mc-species-panel {
|
||||||
|
width: calc(100% - 48rpx);
|
||||||
|
max-width: 680rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-height: 78vh;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
|
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-summary {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 18rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row.is-water {
|
||||||
|
border-color: rgba(0, 108, 73, 0.22);
|
||||||
|
background: rgba(0, 108, 73, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row.is-claimed {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-growth {
|
||||||
|
padding: 16rpx 18rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(0, 108, 73, 0.06);
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--on-surface);
|
||||||
|
color: var(--surface);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-btn.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-btn.ghost {
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-panel-tip {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--outline);
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-scroll {
|
||||||
|
max-height: 56vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card {
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
border: 2rpx solid rgba(0, 108, 73, 0.1);
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(0, 108, 73, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card-emoji {
|
||||||
|
font-size: 56rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card-tagline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stages {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage {
|
||||||
|
width: calc(20% - 8rpx);
|
||||||
|
min-width: 96rpx;
|
||||||
|
padding: 10rpx 6rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage.reached {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage.current {
|
||||||
|
box-shadow: 0 0 0 2rpx var(--primary);
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage-emoji {
|
||||||
|
display: block;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage-lv {
|
||||||
|
display: block;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
margin-top: 2rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 糖分突袭游戏入口 */
|
/* 游戏入口 */
|
||||||
.weekly-page .st-game-entry {
|
.weekly-page .st-game-entry {
|
||||||
|
|
||||||
margin-top: 24rpx;
|
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 {
|
.weekly-page .st-chart-card .st-game-entry--in-card {
|
||||||
margin-top: 12rpx;
|
margin-top: 0;
|
||||||
padding: 16rpx 20rpx;
|
padding: 16rpx 20rpx;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
|
||||||
|
|
||||||
|
/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */
|
||||||
|
export const TREE_SPECIES_LIST = [
|
||||||
|
{
|
||||||
|
id: 'classic',
|
||||||
|
name: '稳糖灵树',
|
||||||
|
tagline: '经典路线,从种子到圆满',
|
||||||
|
stages: TREE_LEVELS.map((l) => l.emoji)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bonsai',
|
||||||
|
name: '雅韵盆景',
|
||||||
|
tagline: '小巧精致,书桌旁的温柔陪伴',
|
||||||
|
stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pine',
|
||||||
|
name: '苍劲青松',
|
||||||
|
tagline: '挺拔向上,越记越稳',
|
||||||
|
stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sakura',
|
||||||
|
name: '樱花小树',
|
||||||
|
tagline: '坚持打卡,枝头渐开',
|
||||||
|
stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ginkgo',
|
||||||
|
name: '金秋银杏',
|
||||||
|
tagline: '叶色渐变,见证每一天',
|
||||||
|
stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'
|
||||||
|
|
||||||
|
/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */
|
||||||
|
export const DAILY_MAX_TASK_POINTS = 40
|
||||||
|
|
||||||
|
export function getTreeSpecies(id) {
|
||||||
|
return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speciesEmojiAtLevel(speciesId, level) {
|
||||||
|
const sp = getTreeSpecies(speciesId)
|
||||||
|
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
|
||||||
|
return sp.stages[lv] || sp.stages[0] || '🌱'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前树种成长路线图(供选择面板展示) */
|
||||||
|
export function speciesGrowthRoadmap(speciesId) {
|
||||||
|
const sp = getTreeSpecies(speciesId)
|
||||||
|
return TREE_LEVELS.map((meta, i) => {
|
||||||
|
const xpTotal = i * TREE_XP_PER_LEVEL
|
||||||
|
const daysHint = i === 0
|
||||||
|
? 0
|
||||||
|
: Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))
|
||||||
|
return {
|
||||||
|
level: i,
|
||||||
|
name: meta.name,
|
||||||
|
emoji: sp.stages[i] || meta.emoji,
|
||||||
|
desc: meta.desc,
|
||||||
|
xpTotal,
|
||||||
|
daysHint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全勤打卡大约多少天满级 */
|
||||||
|
export function estimateDaysToMaxLevel() {
|
||||||
|
const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL
|
||||||
|
return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))
|
||||||
|
}
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import uni from '@dcloudio/vite-plugin-uni'
|
import uni from '@dcloudio/vite-plugin-uni'
|
||||||
|
import Optimization from '@uni-ku/bundle-optimizer'
|
||||||
|
|
||||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [uni()],
|
plugins: [
|
||||||
|
uni(),
|
||||||
|
Optimization({
|
||||||
|
enable: {
|
||||||
|
optimization: true,
|
||||||
|
'async-import': false,
|
||||||
|
'async-component': false,
|
||||||
|
},
|
||||||
|
dts: false,
|
||||||
|
logger: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
css: {
|
css: {
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
scss: {
|
scss: {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -227,6 +227,16 @@ export function revisitRateVisitOrderLines(params: {
|
|||||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', 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: {
|
export function assistantPerformanceOverview(params: {
|
||||||
time_type?: string
|
time_type?: string
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ export function tcmDiagnosisDetail(params: any) {
|
|||||||
return request.get({ url: '/tcm.diagnosis/detail', params })
|
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 }) {
|
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
|
||||||
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
||||||
@@ -425,6 +433,17 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', 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: {
|
export function prescriptionOrderAuditPrescription(params: {
|
||||||
id: number
|
id: number
|
||||||
action: 'approve' | 'reject'
|
action: 'approve' | 'reject'
|
||||||
@@ -446,6 +465,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
|
||||||
|
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 撤回支付单审核 */
|
/** 撤回支付单审核 */
|
||||||
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
||||||
@@ -518,6 +542,18 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
|||||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手工新增操作日志(可选调整处方/支付单审核状态) */
|
||||||
|
export function prescriptionOrderAddLog(params: {
|
||||||
|
id: number
|
||||||
|
summary: string
|
||||||
|
prescription_audit_status?: number | ''
|
||||||
|
payment_slip_audit_status?: number | ''
|
||||||
|
prescription_audit_remark?: string
|
||||||
|
payment_slip_audit_remark?: string
|
||||||
|
}) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 修改订单金额 */
|
/** 修改订单金额 */
|
||||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||||
|
|||||||
@@ -32,8 +32,12 @@
|
|||||||
|
|
||||||
<!-- 列表区域 -->
|
<!-- 列表区域 -->
|
||||||
<el-card class="!border-none mt-4" shadow="never">
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
<div class="mb-4">
|
<div class="mb-4 flex items-center gap-2">
|
||||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||||
|
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
|
||||||
|
批量删除
|
||||||
|
</el-button>
|
||||||
|
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} 条</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||||
@@ -42,7 +46,8 @@
|
|||||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
<el-table :data="tableData" v-loading="loading">
|
<el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="55" />
|
||||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||||
<el-table-column prop="title" label="标题" min-width="80" />
|
<el-table-column prop="title" label="标题" min-width="80" />
|
||||||
<el-table-column label="预览" width="100">
|
<el-table-column label="预览" width="100">
|
||||||
@@ -129,8 +134,9 @@ import MaterialPicker from '@/components/material/picker.vue'
|
|||||||
import Upload from '@/components/upload/index.vue'
|
import Upload from '@/components/upload/index.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tableData = ref([])
|
const tableData = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const selectedIds = ref<number[]>([])
|
||||||
|
|
||||||
const searchData = reactive<any>({
|
const searchData = reactive<any>({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -198,6 +204,7 @@ const getList = async () => {
|
|||||||
const res = await apiAssetResourceList(buildQueryParams())
|
const res = await apiAssetResourceList(buildQueryParams())
|
||||||
tableData.value = res.lists
|
tableData.value = res.lists
|
||||||
total.value = res.count
|
total.value = res.count
|
||||||
|
selectedIds.value = []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -269,6 +276,22 @@ const handleDelete = async (row: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelectionChange = (rows: any[]) => {
|
||||||
|
selectedIds.value = rows.map((row) => Number(row.id)).filter((id) => id > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (!selectedIds.value.length) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个资源吗?用户将无法再看到。`, '提示', { type: 'warning' })
|
||||||
|
await apiAssetResourceDelete({ id: selectedIds.value })
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
getList()
|
||||||
|
} catch (e) {
|
||||||
|
// canceled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
|||||||
+407
-28
@@ -327,7 +327,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-descriptions-item>
|
</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">
|
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
||||||
<template v-if="detailHasAuxHerbs">
|
<template v-if="detailHasAuxHerbs">
|
||||||
<div>
|
<div>
|
||||||
@@ -631,7 +651,7 @@
|
|||||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item v-if="showInternalCost" label="内部成本">
|
<el-descriptions-item v-if="showInternalCost" label="内部成本">
|
||||||
@@ -780,7 +800,18 @@
|
|||||||
class="po-panel border-gray-100 mt-4"
|
class="po-panel border-gray-100 mt-4"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="font-medium text-[15px]">操作日志</span>
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium text-[15px]">操作日志</span>
|
||||||
|
<el-button
|
||||||
|
v-if="canAddPrescriptionOrderLog()"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="openAddLogDialog"
|
||||||
|
>
|
||||||
|
新增日志
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
|
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
|
||||||
<el-timeline-item
|
<el-timeline-item
|
||||||
@@ -800,19 +831,182 @@
|
|||||||
</el-timeline>
|
</el-timeline>
|
||||||
<el-empty v-else description="暂无操作日志" :image-size="64" />
|
<el-empty v-else description="暂无操作日志" :image-size="64" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新增操作日志 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="addLogVisible"
|
||||||
|
title="新增操作日志"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
@closed="resetAddLogForm"
|
||||||
|
>
|
||||||
|
<el-form ref="addLogFormRef" :model="addLogForm" :rules="addLogRules" label-width="108px">
|
||||||
|
<el-form-item label="日志内容" prop="summary">
|
||||||
|
<el-input
|
||||||
|
v-model="addLogForm.summary"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="记录本次操作说明、沟通结果等"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="canSetRxAuditOnAddLog" label="处方审核">
|
||||||
|
<el-select v-model="addLogForm.prescription_audit_status" class="w-full" clearable placeholder="不修改">
|
||||||
|
<el-option label="待审核" :value="0" />
|
||||||
|
<el-option label="已通过" :value="1" />
|
||||||
|
<el-option label="已驳回" :value="2" />
|
||||||
|
</el-select>
|
||||||
|
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
|
||||||
|
当前:{{ auditStatusText(detailData.prescription_audit_status) }}
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-if="canSetRxAuditOnAddLog && addLogForm.prescription_audit_status !== '' && addLogForm.prescription_audit_status !== null && addLogForm.prescription_audit_status !== undefined"
|
||||||
|
label="处方审核意见"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="addLogForm.prescription_audit_remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="canSetPayAuditOnAddLog" label="支付单审核">
|
||||||
|
<el-select v-model="addLogForm.payment_slip_audit_status" class="w-full" clearable placeholder="不修改">
|
||||||
|
<el-option label="待审核" :value="0" />
|
||||||
|
<el-option label="已通过" :value="1" />
|
||||||
|
<el-option label="已驳回" :value="2" />
|
||||||
|
</el-select>
|
||||||
|
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
|
||||||
|
当前:{{ auditStatusText(detailData.payment_slip_audit_status) }}
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-if="canSetPayAuditOnAddLog && addLogForm.payment_slip_audit_status !== '' && addLogForm.payment_slip_audit_status !== null && addLogForm.payment_slip_audit_status !== undefined"
|
||||||
|
label="支付审核意见"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="addLogForm.payment_slip_audit_remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="选填"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="addLogVisible = false">取消</el-button>
|
||||||
|
<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>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup name="PrescriptionOrderDetailDrawer">
|
<script lang="ts" setup name="PrescriptionOrderDetailDrawer">
|
||||||
import { computed, onMounted, 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 { Refresh, Loading, Search, Van } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
prescriptionOrderDetail,
|
prescriptionOrderDetail,
|
||||||
prescriptionOrderLogs,
|
prescriptionOrderLogs,
|
||||||
|
prescriptionOrderAddLog,
|
||||||
prescriptionOrderLogisticsTrace,
|
prescriptionOrderLogisticsTrace,
|
||||||
prescriptionOrderLogisticsJdUpdate,
|
prescriptionOrderLogisticsJdUpdate,
|
||||||
prescriptionOrderPaidPayOrders
|
prescriptionOrderPaidPayOrders,
|
||||||
|
prescriptionOrderPatchPrescriptionUsage
|
||||||
} from '@/api/tcm'
|
} from '@/api/tcm'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
@@ -831,6 +1025,7 @@ import {
|
|||||||
consumerRxAuditTag,
|
consumerRxAuditTag,
|
||||||
expressCompanyLabel,
|
expressCompanyLabel,
|
||||||
logActionText,
|
logActionText,
|
||||||
|
auditStatusText,
|
||||||
formatPayOrderSource,
|
formatPayOrderSource,
|
||||||
normalizeBizPhone,
|
normalizeBizPhone,
|
||||||
recipientVsPrescriptionPhoneMismatch,
|
recipientVsPrescriptionPhoneMismatch,
|
||||||
@@ -841,7 +1036,10 @@ import {
|
|||||||
analyzeLogisticsPayloadUrgent,
|
analyzeLogisticsPayloadUrgent,
|
||||||
parseLogisticsTracePayload,
|
parseLogisticsTracePayload,
|
||||||
canUpdateAmount,
|
canUpdateAmount,
|
||||||
formatDietaryTaboo
|
formatDietaryTaboo,
|
||||||
|
type ServicePackageOption,
|
||||||
|
normalizeServicePackageOptions,
|
||||||
|
formatServicePackageLabels
|
||||||
} from './prescription-order-utils'
|
} from './prescription-order-utils'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -873,6 +1071,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'view-prescription'): void
|
(e: 'view-prescription'): void
|
||||||
(e: 'test-gancao-preview'): void
|
(e: 'test-gancao-preview'): void
|
||||||
(e: 'view-patient'): void
|
(e: 'view-patient'): void
|
||||||
|
(e: 'detail-changed'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -1088,36 +1287,24 @@ const detailFullAddress = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ─── 服务套餐字典 ───
|
// ─── 服务套餐字典 ───
|
||||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
const servicePackageOptions = ref<ServicePackageOption[]>([])
|
||||||
|
|
||||||
async function loadServicePackageOptions() {
|
async function loadServicePackageOptions() {
|
||||||
|
if (servicePackageOptions.value.length > 0) return
|
||||||
try {
|
try {
|
||||||
const data: any = await getDictData({ type: 'server_order' })
|
const data: any = await getDictData({ type: 'server_order' })
|
||||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
const opts = normalizeServicePackageOptions(data?.server_order)
|
||||||
|
if (opts.length > 0) {
|
||||||
|
servicePackageOptions.value = opts
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
servicePackageOptions.value = []
|
/* 请求被同参数请求取消或失败时保留现值,open() 时会重试 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatServicePackage(value: any): string {
|
const detailServicePackageText = computed(() =>
|
||||||
if (!value) return '—'
|
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
||||||
|
)
|
||||||
let packages: string[] = []
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
packages = value
|
|
||||||
} else if (typeof value === 'string') {
|
|
||||||
packages = value.split(',').filter((v) => v.trim() !== '')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packages.length === 0) return '—'
|
|
||||||
|
|
||||||
const names = packages.map((val) => {
|
|
||||||
const option = servicePackageOptions.value.find((opt) => opt.value === val)
|
|
||||||
return option ? option.name : val
|
|
||||||
})
|
|
||||||
|
|
||||||
return names.join('、')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadServicePackageOptions()
|
loadServicePackageOptions()
|
||||||
@@ -1142,6 +1329,196 @@ async function fetchLogs(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPerm(perm: string) {
|
||||||
|
const p = userStore.perms || []
|
||||||
|
return p.includes('*') || p.includes(perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAddPrescriptionOrderLog() {
|
||||||
|
return hasPerm('tcm.prescriptionOrder/addLog')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 canAuditPrescriptionOrder 同档:超管或 prescription_audit_roles */
|
||||||
|
const canSetRxAuditOnAddLog = computed(() => {
|
||||||
|
const u = userStore.userInfo
|
||||||
|
if (!u || Number(u.root) === 1) return true
|
||||||
|
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||||
|
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 与后端 canAuditPaymentSlipOrder 同档:超管或 prescription_order_payment_audit_roles 默认 0,3 */
|
||||||
|
const canSetPayAuditOnAddLog = computed(() => {
|
||||||
|
const u = userStore.userInfo
|
||||||
|
if (!u || Number(u.root) === 1) return true
|
||||||
|
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||||
|
return ids.some((id) => [0, 3].includes(id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const addLogVisible = ref(false)
|
||||||
|
const addLogSaving = ref(false)
|
||||||
|
const addLogFormRef = ref<FormInstance>()
|
||||||
|
const addLogForm = reactive({
|
||||||
|
summary: '',
|
||||||
|
prescription_audit_status: '' as number | '',
|
||||||
|
payment_slip_audit_status: '' as number | '',
|
||||||
|
prescription_audit_remark: '',
|
||||||
|
payment_slip_audit_remark: ''
|
||||||
|
})
|
||||||
|
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 = ''
|
||||||
|
addLogForm.payment_slip_audit_status = ''
|
||||||
|
addLogForm.prescription_audit_remark = ''
|
||||||
|
addLogForm.payment_slip_audit_remark = ''
|
||||||
|
addLogFormRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddLogDialog() {
|
||||||
|
if (!detailData.value?.id) return
|
||||||
|
resetAddLogForm()
|
||||||
|
const d = detailData.value
|
||||||
|
addLogForm.prescription_audit_remark = String(d.prescription_audit_remark || '')
|
||||||
|
addLogForm.payment_slip_audit_remark = String(d.payment_slip_audit_remark || '')
|
||||||
|
addLogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAddLog() {
|
||||||
|
if (!addLogFormRef.value || !detailData.value?.id) return
|
||||||
|
await addLogFormRef.value.validate()
|
||||||
|
addLogSaving.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
id: detailData.value.id,
|
||||||
|
summary: addLogForm.summary.trim()
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
canSetRxAuditOnAddLog.value &&
|
||||||
|
addLogForm.prescription_audit_status !== '' &&
|
||||||
|
addLogForm.prescription_audit_status !== null &&
|
||||||
|
addLogForm.prescription_audit_status !== undefined
|
||||||
|
) {
|
||||||
|
payload.prescription_audit_status = addLogForm.prescription_audit_status
|
||||||
|
payload.prescription_audit_remark = addLogForm.prescription_audit_remark
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
canSetPayAuditOnAddLog.value &&
|
||||||
|
addLogForm.payment_slip_audit_status !== '' &&
|
||||||
|
addLogForm.payment_slip_audit_status !== null &&
|
||||||
|
addLogForm.payment_slip_audit_status !== undefined
|
||||||
|
) {
|
||||||
|
payload.payment_slip_audit_status = addLogForm.payment_slip_audit_status
|
||||||
|
payload.payment_slip_audit_remark = addLogForm.payment_slip_audit_remark
|
||||||
|
}
|
||||||
|
await prescriptionOrderAddLog(payload as any)
|
||||||
|
feedback.msgSuccess('日志已添加')
|
||||||
|
addLogVisible.value = false
|
||||||
|
await refresh()
|
||||||
|
emit('detail-changed')
|
||||||
|
} catch {
|
||||||
|
/* 拦截器已提示 */
|
||||||
|
} finally {
|
||||||
|
addLogSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 未关联支付单 ───
|
// ─── 未关联支付单 ───
|
||||||
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
|
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
|
||||||
if (!diagnosisId) {
|
if (!diagnosisId) {
|
||||||
@@ -1266,6 +1643,8 @@ async function updateJdLogistics() {
|
|||||||
|
|
||||||
// ─── 打开 / 刷新 ───
|
// ─── 打开 / 刷新 ───
|
||||||
async function open(id: number) {
|
async function open(id: number) {
|
||||||
|
// 页面级同参数字典请求会取消抽屉挂载时的那次(axios 去重取消),打开时兜底重试
|
||||||
|
void loadServicePackageOptions()
|
||||||
// 显式彻底清空缓存,防止前一次弹窗的数据残留
|
// 显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||||
detailData.value = null
|
detailData.value = null
|
||||||
detailUnlinkedPayOrders.value = []
|
detailUnlinkedPayOrders.value = []
|
||||||
|
|||||||
@@ -135,24 +135,38 @@ export function logActionText(act: string) {
|
|||||||
revoke_pay_audit: '撤回支付审核',
|
revoke_pay_audit: '撤回支付审核',
|
||||||
gancao_submit: '甘草下单',
|
gancao_submit: '甘草下单',
|
||||||
patch_rx_patient: '处方患者信息',
|
patch_rx_patient: '处方患者信息',
|
||||||
|
patch_rx_usage: '服用参数',
|
||||||
update_amount: '修改订单金额',
|
update_amount: '修改订单金额',
|
||||||
complete: '完成订单',
|
complete: '完成订单',
|
||||||
refund: '退款'
|
refund: '退款',
|
||||||
|
manual_log: '手工备注',
|
||||||
|
assign_assistant: '改派医助',
|
||||||
|
add_pay_order: '补齐支付单',
|
||||||
|
set_ship_mode: '发货类型'
|
||||||
}
|
}
|
||||||
return m[act] || act
|
return m[act] || act
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 支付单来源/方式:企微对外收款、付呗等创建链路 + 支付方式回退 */
|
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
|
||||||
|
export function auditStatusText(s: number | undefined) {
|
||||||
|
if (s === 1) return '已通过'
|
||||||
|
if (s === 2) return '已驳回'
|
||||||
|
return '待审核'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
|
||||||
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||||
const createType = String(row?.create_type || '')
|
const createType = String(row?.create_type || '')
|
||||||
if (createType === 'wechat_work') return '企业微信对外收款'
|
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||||
if (createType === 'fubei') return '付呗'
|
if (createType === 'fubei') return '付呗'
|
||||||
|
if (createType === 'express_cod') return '快递代收'
|
||||||
const paymentMethod = String(row?.payment_method || '')
|
const paymentMethod = String(row?.payment_method || '')
|
||||||
const methodMap: Record<string, string> = {
|
const methodMap: Record<string, string> = {
|
||||||
alipay: '支付宝',
|
alipay: '支付宝',
|
||||||
wechat: '微信',
|
wechat: '微信',
|
||||||
wechat_work: '企业微信',
|
wechat_work: '企业微信',
|
||||||
fubei: '付呗',
|
fubei: '付呗',
|
||||||
|
express_cod: '快递代收',
|
||||||
manual: '手动确认到账'
|
manual: '手动确认到账'
|
||||||
}
|
}
|
||||||
if (paymentMethod && methodMap[paymentMethod]) {
|
if (paymentMethod && methodMap[paymentMethod]) {
|
||||||
@@ -355,3 +369,85 @@ export function formatDietaryTaboo(raw: unknown): string {
|
|||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 服务套餐 dict:server_order */
|
||||||
|
export type ServicePackageOption = { name: string; value: string; status?: number }
|
||||||
|
|
||||||
|
export function normalizeServicePackageValue(v: unknown): string {
|
||||||
|
return String(v ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
|
||||||
|
export function parseServicePackageValues(raw: unknown): string[] {
|
||||||
|
if (raw == null || raw === '') return []
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map(normalizeServicePackageValue).filter(Boolean)
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
return raw.split(',').map((v) => v.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
const one = normalizeServicePackageValue(raw)
|
||||||
|
return one ? [one] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
|
||||||
|
const sa = normalizeServicePackageValue(a)
|
||||||
|
const sb = normalizeServicePackageValue(b)
|
||||||
|
if (!sa || !sb) return false
|
||||||
|
if (sa === sb) return true
|
||||||
|
const na = Number(sa)
|
||||||
|
const nb = Number(sb)
|
||||||
|
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw
|
||||||
|
.map((item: any) => ({
|
||||||
|
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
|
||||||
|
value: normalizeServicePackageValue(item?.value),
|
||||||
|
status: Number(item?.status ?? 1)
|
||||||
|
}))
|
||||||
|
.filter((item) => item.value !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findServicePackageOption(
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
value: unknown
|
||||||
|
): ServicePackageOption | undefined {
|
||||||
|
const key = normalizeServicePackageValue(value)
|
||||||
|
if (!key) return undefined
|
||||||
|
return options.find((opt) => servicePackageValueEquals(opt.value, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示用:value → 字典 name,多选用「、」连接 */
|
||||||
|
export function formatServicePackageLabels(
|
||||||
|
value: unknown,
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
emptyText = '—'
|
||||||
|
): string {
|
||||||
|
const packages = parseServicePackageValues(value)
|
||||||
|
if (packages.length === 0) return emptyText
|
||||||
|
const names = packages.map((val) => {
|
||||||
|
const option = findServicePackageOption(options, val)
|
||||||
|
return option?.name || val
|
||||||
|
})
|
||||||
|
return names.join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
|
||||||
|
export function mergeServicePackageSelectOptions(
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
selected: unknown[]
|
||||||
|
): ServicePackageOption[] {
|
||||||
|
const known = new Set(options.map((o) => o.value))
|
||||||
|
const extras: ServicePackageOption[] = []
|
||||||
|
for (const raw of selected) {
|
||||||
|
const val = normalizeServicePackageValue(raw)
|
||||||
|
if (!val || known.has(val)) continue
|
||||||
|
const matched = findServicePackageOption(options, val)
|
||||||
|
extras.push(matched ?? { name: val, value: val, status: 0 })
|
||||||
|
known.add(val)
|
||||||
|
}
|
||||||
|
return extras.length ? [...options, ...extras] : options
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<el-radio-button label="rejected">驳回</el-radio-button>
|
<el-radio-button label="rejected">驳回</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<span class="pl-toolbar__sep" aria-hidden="true" />
|
<span class="pl-toolbar__sep" aria-hidden="true" />
|
||||||
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
|
<span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
|
||||||
<el-radio-group
|
<el-radio-group
|
||||||
v-model="formData.source_filter"
|
v-model="formData.source_filter"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
>
|
>
|
||||||
<el-radio-button label="all">全部</el-radio-button>
|
<el-radio-button label="all">全部</el-radio-button>
|
||||||
<el-radio-button label="manual">手工</el-radio-button>
|
<el-radio-button label="manual">手工</el-radio-button>
|
||||||
<el-radio-button label="system">系统代开</el-radio-button>
|
<el-radio-button label="system">空白处方</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-toolbar__line pl-toolbar__line--second">
|
<div class="pl-toolbar__line pl-toolbar__line--second">
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
size="small"
|
size="small"
|
||||||
effect="plain"
|
effect="plain"
|
||||||
>
|
>
|
||||||
系统代开
|
空白处方
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span v-else class="text-gray-400 text-sm">手工</span>
|
<span v-else class="text-gray-400 text-sm">手工</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
type="warning"
|
type="warning"
|
||||||
size="small"
|
size="small"
|
||||||
effect="plain"
|
effect="plain"
|
||||||
>系统代开</el-tag>
|
>空白处方</el-tag>
|
||||||
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
|
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
|
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
|
||||||
@@ -1857,7 +1857,7 @@ const formData = reactive({
|
|||||||
creator_ids: [] as number[],
|
creator_ids: [] as number[],
|
||||||
/** all | passed | not_passed | pending | rejected(all 表示不限,接口传参会转为空) */
|
/** all | passed | not_passed | pending | rejected(all 表示不限,接口传参会转为空) */
|
||||||
audit_filter: 'all' as string,
|
audit_filter: 'all' as string,
|
||||||
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */
|
/** all | manual | system — 与 is_system_auto:手工(0) / 空白处方(1) */
|
||||||
source_filter: 'all' as string,
|
source_filter: 'all' as string,
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: ''
|
end_time: ''
|
||||||
|
|||||||
@@ -283,6 +283,12 @@
|
|||||||
<el-option label="未指派" value="0" />
|
<el-option label="未指派" value="0" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item class="w-[140px]" label="辅方">
|
||||||
|
<el-select v-model="queryParams.has_aux_formula" clearable placeholder="全部" class="!w-full">
|
||||||
|
<el-option label="含辅方" value="1" />
|
||||||
|
<el-option label="不含辅方" value="0" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
@@ -292,7 +298,7 @@
|
|||||||
:fetch-fun="prescriptionOrderExport"
|
:fetch-fun="prescriptionOrderExport"
|
||||||
:params="prescriptionOrderExportParams"
|
:params="prescriptionOrderExportParams"
|
||||||
:page-size="pager.size"
|
:page-size="pager.size"
|
||||||
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -303,6 +309,17 @@
|
|||||||
<div class="po-table-toolbar__title-wrap">
|
<div class="po-table-toolbar__title-wrap">
|
||||||
<span class="po-table-title text-gray-800">订单列表</span>
|
<span class="po-table-title text-gray-800">订单列表</span>
|
||||||
<span class="po-table-hint text-gray-400">共 {{ pager.count }} 条</span>
|
<span class="po-table-hint text-gray-400">共 {{ pager.count }} 条</span>
|
||||||
|
<el-button
|
||||||
|
v-if="canBatchReassign"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
class="ml-3"
|
||||||
|
:disabled="selectedOrders.length === 0"
|
||||||
|
@click="openReassignDialog"
|
||||||
|
>
|
||||||
|
批量改派医助{{ selectedOrders.length ? `(${selectedOrders.length})` : '' }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="po-focus-board">
|
<div class="po-focus-board">
|
||||||
<div
|
<div
|
||||||
@@ -339,7 +356,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="pager.lists" size="default" stripe class="po-data-table" :row-class-name="orderRowClassName">
|
<el-table
|
||||||
|
:data="pager.lists"
|
||||||
|
size="default"
|
||||||
|
stripe
|
||||||
|
class="po-data-table"
|
||||||
|
:row-class-name="orderRowClassName"
|
||||||
|
@selection-change="onOrderSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column v-if="canBatchReassign" type="selection" width="44" :selectable="() => true" />
|
||||||
<el-table-column label="订单号" min-width="178" show-overflow-tooltip>
|
<el-table-column label="订单号" min-width="178" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="po-order-no-cell">
|
<div class="po-order-no-cell">
|
||||||
@@ -406,16 +431,41 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="canViewFinanceFields() && row.internal_cost != null && row.internal_cost !== ''" class="flex justify-between">
|
<div v-if="canViewFinanceFields() && row.internal_cost != null && row.internal_cost !== ''" class="flex justify-between">
|
||||||
<span class="text-gray-500">内部成本:</span>
|
<span class="text-gray-500">内部成本:</span>
|
||||||
<span class="font-medium text-orange-600">¥{{ formatMoney(row.internal_cost) }}</span>
|
<span
|
||||||
|
class="font-medium cursor-pointer select-none"
|
||||||
|
:class="internalCostVisible ? 'text-orange-600' : 'text-gray-400'"
|
||||||
|
title="点击显示/隐藏"
|
||||||
|
@click.stop="internalCostVisible = !internalCostVisible"
|
||||||
|
>
|
||||||
|
{{ internalCostVisible ? `¥${formatMoney(row.internal_cost)}` : '****' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="canViewFinanceFields()" label="内部成本" width="92">
|
<el-table-column v-if="canViewFinanceFields()" width="100">
|
||||||
|
<template #header>
|
||||||
|
<span
|
||||||
|
class="cursor-pointer select-none hover:text-primary"
|
||||||
|
title="点击显示/隐藏内部成本"
|
||||||
|
@click.stop="internalCostVisible = !internalCostVisible"
|
||||||
|
>
|
||||||
|
内部成本
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ row.internal_cost != null && row.internal_cost !== '' ? `¥${row.internal_cost}` : '—' }}
|
<span
|
||||||
|
v-if="row.internal_cost != null && row.internal_cost !== ''"
|
||||||
|
class="cursor-pointer select-none"
|
||||||
|
:class="internalCostVisible ? 'text-orange-600 font-medium' : 'text-gray-400'"
|
||||||
|
title="点击显示/隐藏"
|
||||||
|
@click.stop="internalCostVisible = !internalCostVisible"
|
||||||
|
>
|
||||||
|
{{ internalCostVisible ? `¥${row.internal_cost}` : '****' }}
|
||||||
|
</span>
|
||||||
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="处方审核" width="110">
|
<el-table-column label="处方审核" width="110">
|
||||||
@@ -573,6 +623,7 @@
|
|||||||
@view-prescription="detailData && openPrescriptionView(detailData)"
|
@view-prescription="detailData && openPrescriptionView(detailData)"
|
||||||
@test-gancao-preview="testGancaoPreviewFromDetail"
|
@test-gancao-preview="testGancaoPreviewFromDetail"
|
||||||
@view-patient="openDiagnosisPatientDetailFromOrder"
|
@view-patient="openDiagnosisPatientDetailFromOrder"
|
||||||
|
@detail-changed="getLists"
|
||||||
>
|
>
|
||||||
<template #header-extra="{ detail }">
|
<template #header-extra="{ detail }">
|
||||||
<div class="flex items-center gap-2 ml-4 shrink-0">
|
<div class="flex items-center gap-2 ml-4 shrink-0">
|
||||||
@@ -704,7 +755,7 @@
|
|||||||
<el-form-item label="新金额" prop="amount">
|
<el-form-item label="新金额" prop="amount">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="updateAmountForm.amount"
|
v-model="updateAmountForm.amount"
|
||||||
:min="0.01"
|
:min="0"
|
||||||
:step="0.01"
|
:step="0.01"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
controls-position="right"
|
controls-position="right"
|
||||||
@@ -974,10 +1025,11 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in servicePackageOptions"
|
v-for="item in editServicePackageSelectOptions"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
:label="item.name"
|
:label="item.name"
|
||||||
:value="item.value"
|
:value="item.value"
|
||||||
|
:disabled="item.status === 0"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -2114,6 +2166,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 批量改派医助 -->
|
||||||
|
<el-dialog v-model="reassignVisible" title="批量改派医助" width="440px" append-to-body>
|
||||||
|
<div class="text-sm text-gray-600 mb-3">
|
||||||
|
已选 <strong class="text-primary">{{ selectedOrders.length }}</strong> 单,将订单「创建人(医助归属)」改派为:
|
||||||
|
</div>
|
||||||
|
<el-select
|
||||||
|
v-model="reassignAssistantId"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="选择目标医助"
|
||||||
|
class="!w-full"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="a in assistantOptions"
|
||||||
|
:key="a.id"
|
||||||
|
:label="a.name"
|
||||||
|
:value="a.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<p class="text-xs text-gray-400 mt-2 leading-relaxed">
|
||||||
|
仅变更订单的业务归属(医助筛选 / 业绩口径),并写入操作日志;不影响处方、支付单及审核状态。已是该医助的订单会自动跳过。
|
||||||
|
</p>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="reassignVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="reassignSubmitting" @click="submitReassign">确定改派</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 与 consumer/prescription/index、诊单 edit 同一套界面:只读诊单与全部分页签 -->
|
<!-- 与 consumer/prescription/index、诊单 edit 同一套界面:只读诊单与全部分页签 -->
|
||||||
<TcmDiagnosisEditView ref="diagnosisViewRef" />
|
<TcmDiagnosisEditView ref="diagnosisViewRef" />
|
||||||
</div>
|
</div>
|
||||||
@@ -2147,7 +2227,11 @@ import {
|
|||||||
canUpdateAmount,
|
canUpdateAmount,
|
||||||
formatDietaryTaboo,
|
formatDietaryTaboo,
|
||||||
type SlipFormulaType,
|
type SlipFormulaType,
|
||||||
type SlipAuxUsageForm
|
type SlipAuxUsageForm,
|
||||||
|
type ServicePackageOption,
|
||||||
|
normalizeServicePackageOptions,
|
||||||
|
parseServicePackageValues,
|
||||||
|
mergeServicePackageSelectOptions
|
||||||
} from './components/prescription-order-utils'
|
} from './components/prescription-order-utils'
|
||||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
import {
|
import {
|
||||||
@@ -2169,6 +2253,7 @@ import {
|
|||||||
prescriptionOrderRefund,
|
prescriptionOrderRefund,
|
||||||
prescriptionOrderRevokeRxAudit,
|
prescriptionOrderRevokeRxAudit,
|
||||||
prescriptionOrderRevokePayAudit,
|
prescriptionOrderRevokePayAudit,
|
||||||
|
prescriptionOrderBatchAssignAssistant,
|
||||||
prescriptionOrderPatchPrescriptionPatient,
|
prescriptionOrderPatchPrescriptionPatient,
|
||||||
prescriptionOrderLinkPayOrder,
|
prescriptionOrderLinkPayOrder,
|
||||||
prescriptionOrderSubmitGancaoRecipel,
|
prescriptionOrderSubmitGancaoRecipel,
|
||||||
@@ -2268,11 +2353,75 @@ const canViewFinanceFields = () => {
|
|||||||
return FINANCE_ROLE_IDS.some((rid) => ids.includes(rid))
|
return FINANCE_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 内部成本默认隐藏,点击表头或单元格切换显示 */
|
||||||
|
const internalCostVisible = ref(false)
|
||||||
|
|
||||||
|
/** 是否可批量改派医助(与后端 canSeeAllPrescriptionOrders 一致:超管 / 全量编辑角色) */
|
||||||
|
const canBatchReassign = computed(() => {
|
||||||
|
const u = userStore.userInfo
|
||||||
|
if (!u) return false
|
||||||
|
if (Number(u.root) === 1) return true
|
||||||
|
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||||
|
return ORDER_EDIT_ALL_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 批量改派:表格多选 */
|
||||||
|
const selectedOrders = ref<Array<{ id: number; order_no?: string; creator_id?: number }>>([])
|
||||||
|
function onOrderSelectionChange(rows: Array<{ id: number; order_no?: string; creator_id?: number }>) {
|
||||||
|
selectedOrders.value = Array.isArray(rows) ? rows : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量改派弹窗 */
|
||||||
|
const reassignVisible = ref(false)
|
||||||
|
const reassignAssistantId = ref<number | ''>('')
|
||||||
|
const reassignSubmitting = ref(false)
|
||||||
|
|
||||||
|
function openReassignDialog() {
|
||||||
|
if (selectedOrders.value.length === 0) {
|
||||||
|
feedback.msgWarning('请先勾选要改派的订单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reassignAssistantId.value = ''
|
||||||
|
reassignVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReassign() {
|
||||||
|
const assistantId = Number(reassignAssistantId.value)
|
||||||
|
if (!assistantId) {
|
||||||
|
feedback.msgWarning('请选择目标医助')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const orderIds = selectedOrders.value.map((r) => Number(r.id)).filter((id) => id > 0)
|
||||||
|
if (orderIds.length === 0) {
|
||||||
|
feedback.msgWarning('请先勾选要改派的订单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reassignSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await prescriptionOrderBatchAssignAssistant({
|
||||||
|
order_ids: orderIds,
|
||||||
|
assistant_id: assistantId
|
||||||
|
})
|
||||||
|
const ok = Number(res?.success ?? 0)
|
||||||
|
const errs: string[] = Array.isArray(res?.errors) ? res.errors : []
|
||||||
|
let msg = `已改派 ${ok} 单`
|
||||||
|
if (errs.length) msg += `;${errs.join(';')}`
|
||||||
|
feedback.msgSuccess(msg)
|
||||||
|
reassignVisible.value = false
|
||||||
|
selectedOrders.value = []
|
||||||
|
getLists()
|
||||||
|
} catch {
|
||||||
|
// request 拦截器已弹错误提示
|
||||||
|
} finally {
|
||||||
|
reassignSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 省市区数据
|
// 省市区数据
|
||||||
const regionOptions = ref([])
|
const regionOptions = ref([])
|
||||||
|
|
||||||
// 服务套餐选项
|
// 服务套餐选项(含已停用项,便于编辑时回显历史值)
|
||||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
const servicePackageOptions = ref<ServicePackageOption[]>([])
|
||||||
|
|
||||||
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
|
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
|
||||||
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
|
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
|
||||||
@@ -2393,8 +2542,7 @@ const loadRegionData = async () => {
|
|||||||
const loadServicePackageOptions = async () => {
|
const loadServicePackageOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getDictData({ type: 'server_order' })
|
const data = await getDictData({ type: 'server_order' })
|
||||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
|
||||||
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载服务套餐选项失败:', error)
|
console.error('加载服务套餐选项失败:', error)
|
||||||
servicePackageOptions.value = []
|
servicePackageOptions.value = []
|
||||||
@@ -2471,6 +2619,8 @@ const queryParams = reactive({
|
|||||||
supply_mode: '' as '' | 'gancao' | 'self',
|
supply_mode: '' as '' | 'gancao' | 'self',
|
||||||
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
||||||
service_channel: '' as '' | '0',
|
service_channel: '' as '' | '0',
|
||||||
|
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
|
||||||
|
has_aux_formula: '' as '' | '0' | '1',
|
||||||
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
|
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
|
||||||
exclude_fulfillment_cancelled: 0 as number,
|
exclude_fulfillment_cancelled: 0 as number,
|
||||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||||
@@ -2618,6 +2768,11 @@ function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): R
|
|||||||
p.service_channel = String(p.service_channel).trim()
|
p.service_channel = String(p.service_channel).trim()
|
||||||
if (p.service_channel === '') delete p.service_channel
|
if (p.service_channel === '') delete p.service_channel
|
||||||
}
|
}
|
||||||
|
if (p.has_aux_formula === '' || p.has_aux_formula === undefined || p.has_aux_formula === null) {
|
||||||
|
delete p.has_aux_formula
|
||||||
|
} else {
|
||||||
|
p.has_aux_formula = Number(p.has_aux_formula)
|
||||||
|
}
|
||||||
if (Number(p.exclude_fulfillment_cancelled) !== 1) delete p.exclude_fulfillment_cancelled
|
if (Number(p.exclude_fulfillment_cancelled) !== 1) delete p.exclude_fulfillment_cancelled
|
||||||
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
||||||
delete p.start_time
|
delete p.start_time
|
||||||
@@ -2856,6 +3011,7 @@ function handleReset() {
|
|||||||
queryParams.payment_slip_audit_status = ''
|
queryParams.payment_slip_audit_status = ''
|
||||||
queryParams.supply_mode = ''
|
queryParams.supply_mode = ''
|
||||||
queryParams.service_channel = ''
|
queryParams.service_channel = ''
|
||||||
|
queryParams.has_aux_formula = ''
|
||||||
queryParams.audit_admin_id = ''
|
queryParams.audit_admin_id = ''
|
||||||
queryParams.exclude_fulfillment_cancelled = 0
|
queryParams.exclude_fulfillment_cancelled = 0
|
||||||
resetParams()
|
resetParams()
|
||||||
@@ -3044,10 +3200,17 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
|
function canAddPayOrderRow(row: {
|
||||||
// 已发货(5) / 已签收(6) 状态可补齐支付单
|
fulfillment_status?: number
|
||||||
|
amount?: number | string
|
||||||
|
linked_pay_paid_total?: number | string
|
||||||
|
}) {
|
||||||
|
// 已发货(5) / 已签收(6) 状态可补齐支付单;总金额已付清则不允许
|
||||||
const fs = Number(row.fulfillment_status)
|
const fs = Number(row.fulfillment_status)
|
||||||
return fs === 5 || fs === 6
|
if (fs !== 5 && fs !== 6) return false
|
||||||
|
const orderAmount = Math.round((Number(row.amount) || 0) * 100) / 100
|
||||||
|
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
|
||||||
|
return paidTotal < orderAmount
|
||||||
}
|
}
|
||||||
|
|
||||||
function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
|
function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
|
||||||
@@ -3106,8 +3269,8 @@ const updateAmountRules: FormRules = {
|
|||||||
{
|
{
|
||||||
validator: (_rule, value, callback) => {
|
validator: (_rule, value, callback) => {
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
if (!Number.isFinite(num) || num <= 0) {
|
if (!Number.isFinite(num) || num < 0) {
|
||||||
callback(new Error('订单金额必须大于0'))
|
callback(new Error('订单金额不能为负数'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
callback()
|
callback()
|
||||||
@@ -3164,7 +3327,8 @@ function openUpdateAmount() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
updateAmountForm.id = Number(row?.id) || 0
|
updateAmountForm.id = Number(row?.id) || 0
|
||||||
updateAmountForm.amount = Number(row?.amount) || undefined
|
const amt = Number(row?.amount)
|
||||||
|
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
|
||||||
updateAmountVisible.value = true
|
updateAmountVisible.value = true
|
||||||
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
||||||
}
|
}
|
||||||
@@ -3297,6 +3461,11 @@ const editForm = reactive({
|
|||||||
diagnosis_creator_dept_path: ''
|
diagnosis_creator_dept_path: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 编辑弹窗下拉:字典项 + 当前已选但字典中缺失的兜底项 */
|
||||||
|
const editServicePackageSelectOptions = computed(() =>
|
||||||
|
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
|
||||||
|
)
|
||||||
|
|
||||||
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
|
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
|
||||||
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
|
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
|
||||||
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
|
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
|
||||||
@@ -3529,18 +3698,7 @@ async function openEdit(row: {
|
|||||||
editForm.dose_unit = d.dose_unit || '剂'
|
editForm.dose_unit = d.dose_unit || '剂'
|
||||||
editForm.prev_staff = d.prev_staff || ''
|
editForm.prev_staff = d.prev_staff || ''
|
||||||
editForm.service_channel = d.service_channel || ''
|
editForm.service_channel = d.service_channel || ''
|
||||||
// 处理服务套餐:如果是字符串,转换为数组
|
editForm.service_package = parseServicePackageValues(d.service_package)
|
||||||
if (d.service_package) {
|
|
||||||
if (Array.isArray(d.service_package)) {
|
|
||||||
editForm.service_package = d.service_package
|
|
||||||
} else if (typeof d.service_package === 'string') {
|
|
||||||
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
|
|
||||||
} else {
|
|
||||||
editForm.service_package = []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editForm.service_package = []
|
|
||||||
}
|
|
||||||
editForm.express_company = String(d.express_company || 'auto') || 'auto'
|
editForm.express_company = String(d.express_company || 'auto') || 'auto'
|
||||||
editForm.tracking_number = d.tracking_number || ''
|
editForm.tracking_number = d.tracking_number || ''
|
||||||
editForm.fee_type = Number(d.fee_type) || 3
|
editForm.fee_type = Number(d.fee_type) || 3
|
||||||
@@ -4357,7 +4515,18 @@ async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddPayOrder(row: { id: number; diagnosis_id?: number; pay_order_ids?: number[] }) {
|
function openAddPayOrder(row: {
|
||||||
|
id: number
|
||||||
|
diagnosis_id?: number
|
||||||
|
pay_order_ids?: number[]
|
||||||
|
fulfillment_status?: number
|
||||||
|
amount?: number | string
|
||||||
|
linked_pay_paid_total?: number | string
|
||||||
|
}) {
|
||||||
|
if (!canAddPayOrderRow(row)) {
|
||||||
|
feedback.msgWarning('订单总金额与已付金额一致,无需补齐支付单')
|
||||||
|
return
|
||||||
|
}
|
||||||
addPayOrderRowId.value = row.id
|
addPayOrderRowId.value = row.id
|
||||||
addPayOrderForm.add_mode = 'create'
|
addPayOrderForm.add_mode = 'create'
|
||||||
addPayOrderForm.order_type = 3
|
addPayOrderForm.order_type = 3
|
||||||
|
|||||||
@@ -858,31 +858,94 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="用量">
|
<el-descriptions-item label="用量">
|
||||||
<template v-if="detailPrescription.dosage_amount">
|
<div class="flex flex-col gap-1 text-sm leading-relaxed">
|
||||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
<div>
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
<span v-if="detailHasAuxHerbs" class="text-gray-500 mr-1">主方:</span>
|
||||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
<template v-if="detailPrescription.dosage_amount">
|
||||||
</template>
|
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||||
</span>
|
</template>
|
||||||
</template>
|
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||||
<template v-else>—</template>
|
({{ 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>
|
||||||
<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 class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
||||||
<div>
|
<template v-if="detailHasAuxHerbs">
|
||||||
<span class="text-gray-500">每天次数:</span>
|
<div>
|
||||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
|
<span class="text-gray-500 mr-1">主方:</span>
|
||||||
</div>
|
每天
|
||||||
<div>
|
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '— 次' }}
|
||||||
<span class="text-gray-500">处方开立:</span>
|
· 处方开立
|
||||||
{{
|
{{
|
||||||
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
|
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
|
||||||
? detailPrescription.usage_days + ' 天'
|
? detailPrescription.usage_days + ' 天'
|
||||||
: '—'
|
: '— 天'
|
||||||
}}
|
}}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<span class="text-gray-500">订单设置:</span>
|
<span class="text-gray-500">订单设置:</span>
|
||||||
{{
|
{{
|
||||||
@@ -1139,7 +1202,7 @@
|
|||||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||||
|
|
||||||
<el-descriptions-item v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本">
|
<el-descriptions-item v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本">
|
||||||
@@ -1298,7 +1361,7 @@
|
|||||||
<el-form-item label="新金额" prop="amount">
|
<el-form-item label="新金额" prop="amount">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="updateAmountForm.amount"
|
v-model="updateAmountForm.amount"
|
||||||
:min="0.01"
|
:min="0"
|
||||||
:step="0.01"
|
:step="0.01"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
controls-position="right"
|
controls-position="right"
|
||||||
@@ -1525,10 +1588,11 @@
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in servicePackageOptions"
|
v-for="item in editServicePackageSelectOptions"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
:label="item.name"
|
:label="item.name"
|
||||||
:value="item.value"
|
:value="item.value"
|
||||||
|
:disabled="item.status === 0"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -2151,6 +2215,93 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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
|
<el-drawer
|
||||||
v-model="prescriptionViewVisible"
|
v-model="prescriptionViewVisible"
|
||||||
@@ -2565,6 +2716,7 @@ import {
|
|||||||
prescriptionOrderRevokeRxAudit,
|
prescriptionOrderRevokeRxAudit,
|
||||||
prescriptionOrderRevokePayAudit,
|
prescriptionOrderRevokePayAudit,
|
||||||
prescriptionOrderPatchPrescriptionPatient,
|
prescriptionOrderPatchPrescriptionPatient,
|
||||||
|
prescriptionOrderPatchPrescriptionUsage,
|
||||||
prescriptionOrderLinkPayOrder,
|
prescriptionOrderLinkPayOrder,
|
||||||
prescriptionOrderRequestCompletion,
|
prescriptionOrderRequestCompletion,
|
||||||
prescriptionOrderSubmitGancaoRecipel,
|
prescriptionOrderSubmitGancaoRecipel,
|
||||||
@@ -2573,7 +2725,16 @@ import {
|
|||||||
getDoctors,
|
getDoctors,
|
||||||
getAssistants
|
getAssistants
|
||||||
} from '@/api/tcm'
|
} from '@/api/tcm'
|
||||||
import { formatDietaryTaboo } from './components/prescription-order-utils'
|
import {
|
||||||
|
formatDietaryTaboo,
|
||||||
|
type ServicePackageOption,
|
||||||
|
normalizeServicePackageOptions,
|
||||||
|
parseServicePackageValues,
|
||||||
|
mergeServicePackageSelectOptions,
|
||||||
|
formatServicePackageLabels,
|
||||||
|
normalizeSlipAuxUsageForm,
|
||||||
|
prescriptionHasAuxFormula
|
||||||
|
} from './components/prescription-order-utils'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import { jsPDF } from 'jspdf'
|
import { jsPDF } from 'jspdf'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
@@ -2669,7 +2830,7 @@ const canViewFinanceFields = () => {
|
|||||||
const regionOptions = ref([])
|
const regionOptions = ref([])
|
||||||
|
|
||||||
// 服务套餐选项
|
// 服务套餐选项
|
||||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
const servicePackageOptions = ref<ServicePackageOption[]>([])
|
||||||
|
|
||||||
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
|
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
|
||||||
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
|
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
|
||||||
@@ -2790,8 +2951,7 @@ const loadRegionData = async () => {
|
|||||||
const loadServicePackageOptions = async () => {
|
const loadServicePackageOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getDictData({ type: 'server_order' })
|
const data = await getDictData({ type: 'server_order' })
|
||||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
|
||||||
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载服务套餐选项失败:', error)
|
console.error('加载服务套餐选项失败:', error)
|
||||||
servicePackageOptions.value = []
|
servicePackageOptions.value = []
|
||||||
@@ -3199,28 +3359,6 @@ function feeTypeText(t: number | undefined) {
|
|||||||
return m[Number(t)] ?? '—'
|
return m[Number(t)] ?? '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化服务套餐显示
|
|
||||||
function formatServicePackage(value: any): string {
|
|
||||||
if (!value) return '—'
|
|
||||||
|
|
||||||
let packages: string[] = []
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
packages = value
|
|
||||||
} else if (typeof value === 'string') {
|
|
||||||
packages = value.split(',').filter(v => v.trim() !== '')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packages.length === 0) return '—'
|
|
||||||
|
|
||||||
// 将值转换为名称
|
|
||||||
const names = packages.map(val => {
|
|
||||||
const option = servicePackageOptions.value.find(opt => opt.value === val)
|
|
||||||
return option ? option.name : val
|
|
||||||
})
|
|
||||||
|
|
||||||
return names.join('、')
|
|
||||||
}
|
|
||||||
|
|
||||||
function auditStatusText(s: number | undefined) {
|
function auditStatusText(s: number | undefined) {
|
||||||
if (s === 1) return '已通过'
|
if (s === 1) return '已通过'
|
||||||
if (s === 2) return '已驳回'
|
if (s === 2) return '已驳回'
|
||||||
@@ -3473,6 +3611,10 @@ const detailVisible = ref(false)
|
|||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailData = ref<Record<string, any> | null>(null)
|
const detailData = ref<Record<string, any> | null>(null)
|
||||||
|
|
||||||
|
const detailServicePackageText = computed(() =>
|
||||||
|
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
||||||
|
)
|
||||||
|
|
||||||
// --- 订单可视化审批履约流程逻辑 ---
|
// --- 订单可视化审批履约流程逻辑 ---
|
||||||
const workflowActiveStep = computed(() => {
|
const workflowActiveStep = computed(() => {
|
||||||
if (!detailData.value) return 0
|
if (!detailData.value) return 0
|
||||||
@@ -3629,6 +3771,16 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
|
|||||||
|
|
||||||
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
|
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/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
|
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
|
||||||
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
|
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
|
||||||
|
|
||||||
@@ -3733,8 +3885,8 @@ const updateAmountRules: FormRules = {
|
|||||||
{
|
{
|
||||||
validator: (_rule, value, callback) => {
|
validator: (_rule, value, callback) => {
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
if (!Number.isFinite(num) || num <= 0) {
|
if (!Number.isFinite(num) || num < 0) {
|
||||||
callback(new Error('订单金额必须大于0'))
|
callback(new Error('订单金额不能为负数'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
callback()
|
callback()
|
||||||
@@ -3855,6 +4007,101 @@ const patchRxPatientRules: FormRules = {
|
|||||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
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() {
|
function openPatchRxPatientDialog() {
|
||||||
const rx = detailPrescription.value
|
const rx = detailPrescription.value
|
||||||
const ord = detailData.value
|
const ord = detailData.value
|
||||||
@@ -3886,7 +4133,8 @@ function openUpdateAmount() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
updateAmountForm.id = Number(row?.id) || 0
|
updateAmountForm.id = Number(row?.id) || 0
|
||||||
updateAmountForm.amount = Number(row?.amount) || undefined
|
const amt = Number(row?.amount)
|
||||||
|
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
|
||||||
updateAmountVisible.value = true
|
updateAmountVisible.value = true
|
||||||
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
||||||
}
|
}
|
||||||
@@ -4059,6 +4307,10 @@ const editForm = reactive({
|
|||||||
diagnosis_creator_dept_path: ''
|
diagnosis_creator_dept_path: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const editServicePackageSelectOptions = computed(() =>
|
||||||
|
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
|
||||||
|
)
|
||||||
|
|
||||||
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
|
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
|
||||||
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
|
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
|
||||||
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
|
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
|
||||||
@@ -4104,12 +4356,14 @@ function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unk
|
|||||||
const createType = String(row?.create_type || '')
|
const createType = String(row?.create_type || '')
|
||||||
if (createType === 'wechat_work') return '企业微信对外收款'
|
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||||
if (createType === 'fubei') return '付呗'
|
if (createType === 'fubei') return '付呗'
|
||||||
|
if (createType === 'express_cod') return '快递代收'
|
||||||
const paymentMethod = String(row?.payment_method || '')
|
const paymentMethod = String(row?.payment_method || '')
|
||||||
const methodMap: Record<string, string> = {
|
const methodMap: Record<string, string> = {
|
||||||
alipay: '支付宝',
|
alipay: '支付宝',
|
||||||
wechat: '微信',
|
wechat: '微信',
|
||||||
wechat_work: '企业微信',
|
wechat_work: '企业微信',
|
||||||
fubei: '付呗',
|
fubei: '付呗',
|
||||||
|
express_cod: '快递代收',
|
||||||
manual: '手动确认到账'
|
manual: '手动确认到账'
|
||||||
}
|
}
|
||||||
if (paymentMethod && methodMap[paymentMethod]) {
|
if (paymentMethod && methodMap[paymentMethod]) {
|
||||||
@@ -4307,18 +4561,7 @@ async function openEdit(row: {
|
|||||||
editForm.dose_unit = d.dose_unit || '剂'
|
editForm.dose_unit = d.dose_unit || '剂'
|
||||||
editForm.prev_staff = d.prev_staff || ''
|
editForm.prev_staff = d.prev_staff || ''
|
||||||
editForm.service_channel = d.service_channel || ''
|
editForm.service_channel = d.service_channel || ''
|
||||||
// 处理服务套餐:如果是字符串,转换为数组
|
editForm.service_package = parseServicePackageValues(d.service_package)
|
||||||
if (d.service_package) {
|
|
||||||
if (Array.isArray(d.service_package)) {
|
|
||||||
editForm.service_package = d.service_package
|
|
||||||
} else if (typeof d.service_package === 'string') {
|
|
||||||
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
|
|
||||||
} else {
|
|
||||||
editForm.service_package = []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editForm.service_package = []
|
|
||||||
}
|
|
||||||
editForm.express_company = String(d.express_company || 'auto') || 'auto'
|
editForm.express_company = String(d.express_company || 'auto') || 'auto'
|
||||||
editForm.tracking_number = d.tracking_number || ''
|
editForm.tracking_number = d.tracking_number || ''
|
||||||
editForm.fee_type = Number(d.fee_type) || 3
|
editForm.fee_type = Number(d.fee_type) || 3
|
||||||
|
|||||||
@@ -231,8 +231,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { adminLists } from '@/api/perms/admin'
|
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||||
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
dayjs.extend(isoWeek)
|
dayjs.extend(isoWeek)
|
||||||
@@ -491,10 +490,9 @@ async function removeSegment(row: RosterSegment) {
|
|||||||
|
|
||||||
const loadDoctors = async () => {
|
const loadDoctors = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await adminLists({
|
const res = await doctorLists({
|
||||||
page_no: 1,
|
page_no: 1,
|
||||||
page_size: 1000,
|
page_size: 1000
|
||||||
role_id: 1
|
|
||||||
})
|
})
|
||||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||||
doctorId: doctor.id,
|
doctorId: doctor.id,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
||||||
|
每人2条/日,50%~60% 每人1条/日,<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>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>诊单金额占比</span>
|
<span>{{ amountPieTitle }}</span>
|
||||||
<span class="card-hint">TOP 10</span>
|
<span class="card-hint">TOP 10</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -146,17 +146,17 @@
|
|||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>加粉占比</span>
|
<span>{{ secondaryPieTitle }}</span>
|
||||||
<span class="card-hint">TOP 10</span>
|
<span class="card-hint">TOP 10</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<v-charts
|
<v-charts
|
||||||
v-if="fanPieHasData"
|
v-if="secondaryPieHasData"
|
||||||
class="stats-chart"
|
class="stats-chart"
|
||||||
:option="fanPieOption"
|
:option="secondaryPieOption"
|
||||||
autoresize
|
autoresize
|
||||||
/>
|
/>
|
||||||
<el-empty v-else description="暂无加粉数据" />
|
<el-empty v-else :description="secondaryPieEmptyText" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -311,10 +311,13 @@ const overview = reactive<Record<string, any>>({
|
|||||||
amounts: [],
|
amounts: [],
|
||||||
order_counts: [],
|
order_counts: [],
|
||||||
fan_counts: [],
|
fan_counts: [],
|
||||||
rois: []
|
rois: [],
|
||||||
|
appointment_counts: [],
|
||||||
|
interview_counts: []
|
||||||
},
|
},
|
||||||
amount_share: [],
|
amount_share: [],
|
||||||
fan_share: []
|
fan_share: [],
|
||||||
|
order_share: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -392,7 +395,7 @@ const currentEntityId = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const summaryCards: MetricCard[] = [
|
const defaultSummaryCards: MetricCard[] = [
|
||||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
@@ -410,7 +413,15 @@ const summaryCards: MetricCard[] = [
|
|||||||
{ key: 'roi', label: 'ROI', type: 'ratio' }
|
{ key: 'roi', label: 'ROI', type: 'ratio' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const tableColumns: MetricColumn[] = [
|
const doctorSummaryCards: MetricCard[] = [
|
||||||
|
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||||
|
{ key: 'completed_order_amount', label: '开药金额', type: 'money' },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultTableColumns: MetricColumn[] = [
|
||||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
|
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
|
||||||
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
|
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
|
||||||
@@ -431,6 +442,18 @@ const tableColumns: MetricColumn[] = [
|
|||||||
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
|
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const doctorTableColumns: MetricColumn[] = [
|
||||||
|
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||||
|
{ key: 'completed_order_amount', label: '开药金额', type: 'money', minWidth: 120 },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const isDoctorDimension = computed(() => queryParams.dimension === 'doctor')
|
||||||
|
const summaryCards = computed(() => isDoctorDimension.value ? doctorSummaryCards : defaultSummaryCards)
|
||||||
|
const tableColumns = computed(() => isDoctorDimension.value ? doctorTableColumns : defaultTableColumns)
|
||||||
|
|
||||||
const dateRangeText = computed(() => {
|
const dateRangeText = computed(() => {
|
||||||
if (!overview.date_range?.length) return '未选择'
|
if (!overview.date_range?.length) return '未选择'
|
||||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
@@ -438,7 +461,14 @@ const dateRangeText = computed(() => {
|
|||||||
|
|
||||||
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
|
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
|
||||||
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
|
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
|
||||||
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
|
const secondaryPieHasData = computed(() =>
|
||||||
|
isDoctorDimension.value
|
||||||
|
? (overview.charts.order_share?.length || 0) > 0
|
||||||
|
: (overview.charts.fan_share?.length || 0) > 0
|
||||||
|
)
|
||||||
|
const amountPieTitle = computed(() => isDoctorDimension.value ? '开药金额占比' : '诊单金额占比')
|
||||||
|
const secondaryPieTitle = computed(() => isDoctorDimension.value ? '接诊单数占比' : '加粉占比')
|
||||||
|
const secondaryPieEmptyText = computed(() => isDoctorDimension.value ? '暂无接诊数据' : '暂无加粉数据')
|
||||||
|
|
||||||
const rankingChartData = computed(() => {
|
const rankingChartData = computed(() => {
|
||||||
const ranking = overview.charts?.ranking || {}
|
const ranking = overview.charts?.ranking || {}
|
||||||
@@ -448,56 +478,110 @@ const rankingChartData = computed(() => {
|
|||||||
return {
|
return {
|
||||||
names,
|
names,
|
||||||
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
|
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
|
||||||
|
appointmentCounts: normalize(Array.isArray(ranking.appointment_counts) ? ranking.appointment_counts : []),
|
||||||
|
interviewCounts: normalize(Array.isArray(ranking.interview_counts) ? ranking.interview_counts : []),
|
||||||
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
|
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
|
||||||
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
|
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const rankingChartOption = computed(() => ({
|
const rankingChartOption = computed(() => {
|
||||||
tooltip: { trigger: 'axis' },
|
const common = {
|
||||||
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
tooltip: { trigger: 'axis' },
|
||||||
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: rankingChartData.value.names,
|
data: rankingChartData.value.names,
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
interval: 0,
|
interval: 0,
|
||||||
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||||
}
|
}
|
||||||
},
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: '金额 / 数量' }
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '诊单金额',
|
|
||||||
type: 'bar',
|
|
||||||
barMaxWidth: 36,
|
|
||||||
data: rankingChartData.value.amounts,
|
|
||||||
itemStyle: { color: '#4a78ff' }
|
|
||||||
},
|
},
|
||||||
{
|
yAxis: [
|
||||||
name: '接诊诊单',
|
{ type: 'value', name: '金额 / 数量' }
|
||||||
type: 'line',
|
]
|
||||||
smooth: true,
|
}
|
||||||
showSymbol: true,
|
|
||||||
symbolSize: 8,
|
if (!isDoctorDimension.value) {
|
||||||
lineStyle: { width: 3 },
|
return {
|
||||||
data: rankingChartData.value.orderCounts,
|
...common,
|
||||||
itemStyle: { color: '#15b8a6' }
|
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||||
},
|
series: [
|
||||||
{
|
{
|
||||||
name: '加粉数',
|
name: '诊单金额',
|
||||||
type: 'line',
|
type: 'bar',
|
||||||
smooth: true,
|
barMaxWidth: 36,
|
||||||
showSymbol: true,
|
data: rankingChartData.value.amounts,
|
||||||
symbolSize: 8,
|
itemStyle: { color: '#4a78ff' }
|
||||||
lineStyle: { width: 3 },
|
},
|
||||||
data: rankingChartData.value.fanCounts,
|
{
|
||||||
itemStyle: { color: '#ff9f43' }
|
name: '接诊诊单',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.orderCounts,
|
||||||
|
itemStyle: { color: '#15b8a6' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '加粉数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.fanCounts,
|
||||||
|
itemStyle: { color: '#ff9f43' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}))
|
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
legend: { data: ['开药金额', '挂号数', '面诊数', '接诊单数'] },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '开药金额',
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 36,
|
||||||
|
data: rankingChartData.value.amounts,
|
||||||
|
itemStyle: { color: '#4a78ff' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '挂号数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.appointmentCounts,
|
||||||
|
itemStyle: { color: '#15b8a6' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '面诊数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.interviewCounts,
|
||||||
|
itemStyle: { color: '#ff9f43' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '接诊单数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.orderCounts,
|
||||||
|
itemStyle: { color: '#7c3aed' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const amountPieOption = computed(() => ({
|
const amountPieOption = computed(() => ({
|
||||||
tooltip: { trigger: 'item' },
|
tooltip: { trigger: 'item' },
|
||||||
@@ -511,7 +595,7 @@ const amountPieOption = computed(() => ({
|
|||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '诊单金额',
|
name: amountPieTitle.value,
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['42%', '70%'],
|
radius: ['42%', '70%'],
|
||||||
center: ['50%', '42%'],
|
center: ['50%', '42%'],
|
||||||
@@ -531,7 +615,7 @@ const amountPieOption = computed(() => ({
|
|||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const fanPieOption = computed(() => ({
|
const secondaryPieOption = computed(() => ({
|
||||||
tooltip: { trigger: 'item' },
|
tooltip: { trigger: 'item' },
|
||||||
legend: {
|
legend: {
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -543,7 +627,7 @@ const fanPieOption = computed(() => ({
|
|||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '加粉数',
|
name: secondaryPieTitle.value,
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['42%', '70%'],
|
radius: ['42%', '70%'],
|
||||||
center: ['50%', '42%'],
|
center: ['50%', '42%'],
|
||||||
@@ -558,7 +642,7 @@ const fanPieOption = computed(() => ({
|
|||||||
labelLayout: {
|
labelLayout: {
|
||||||
hideOverlap: true,
|
hideOverlap: true,
|
||||||
},
|
},
|
||||||
data: overview.charts.fan_share
|
data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
@@ -658,9 +742,18 @@ const handleReset = () => {
|
|||||||
fetchOverview()
|
fetchOverview()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const calcDoctorReceiveRate = (source: Record<string, any>) => {
|
||||||
|
const completedOrderCount = Number(source?.completed_order_count || 0)
|
||||||
|
const interviewCount = Number(source?.interview_count || 0)
|
||||||
|
if (interviewCount <= 0) return 0
|
||||||
|
return (completedOrderCount / interviewCount) * 100
|
||||||
|
}
|
||||||
|
|
||||||
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
|
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
|
||||||
if (placeholder) return '—'
|
if (placeholder) return '—'
|
||||||
const value = source?.[key] ?? 0
|
const value = isDoctorDimension.value && key === 'receive_rate'
|
||||||
|
? calcDoctorReceiveRate(source)
|
||||||
|
: source?.[key] ?? 0
|
||||||
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||||
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||||
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<el-tree-select
|
<el-tree-select
|
||||||
v-model="deptId"
|
v-model="deptId"
|
||||||
:data="deptTreeOptions"
|
:data="deptTreeOptions"
|
||||||
placeholder="全部部门"
|
placeholder="二中心(全部)"
|
||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
check-strictly
|
check-strictly
|
||||||
@@ -43,10 +43,10 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="rate-caliber">
|
<div class="rate-caliber">
|
||||||
<p>
|
<p>
|
||||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重。
|
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
||||||
</p>
|
</p>
|
||||||
<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>
|
||||||
<p>
|
<p>
|
||||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
医助按人事部门归组;选定部门时含其组织下级。
|
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</el-popover>
|
</el-popover>
|
||||||
|
|||||||
@@ -83,6 +83,23 @@
|
|||||||
<el-radio-button value="0">未确认</el-radio-button>
|
<el-radio-button value="0">未确认</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -542,6 +559,7 @@
|
|||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||||
|
import { deptAll } from '@/api/org/department'
|
||||||
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import { addDoctorNote } from '@/api/patient'
|
import { addDoctorNote } from '@/api/patient'
|
||||||
@@ -603,10 +621,19 @@ const formData = reactive({
|
|||||||
end_date: '',
|
end_date: '',
|
||||||
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
|
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
|
||||||
diagnosis_confirmed: '' as '' | '0' | '1', // ''=全部 1=已确认 0=未确认
|
diagnosis_confirmed: '' as '' | '0' | '1', // ''=全部 1=已确认 0=未确认
|
||||||
|
/** 接诊医生 / 诊单医助 / 挂号医助所属部门(选父级含子级) */
|
||||||
|
assistant_dept_id: '' as number | '',
|
||||||
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
|
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
|
||||||
include_status_counts: 0 as 0 | 1
|
include_status_counts: 0 as 0 | 1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const departmentTreeRaw = ref<unknown[]>([])
|
||||||
|
const assistantDeptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children'
|
||||||
|
}
|
||||||
|
|
||||||
const activeTab = ref('1')
|
const activeTab = ref('1')
|
||||||
const dateCustomVisible = ref(false)
|
const dateCustomVisible = ref(false)
|
||||||
const statusCount = ref<Record<number, number>>({
|
const statusCount = ref<Record<number, number>>({
|
||||||
@@ -721,6 +748,12 @@ const handleDiagnosisConfirmedChange = () => {
|
|||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 部门筛选变更
|
||||||
|
const handleAssistantDeptChange = () => {
|
||||||
|
pager.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
// 快捷日期变更
|
// 快捷日期变更
|
||||||
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
|
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
|
||||||
const v = String(val || '')
|
const v = String(val || '')
|
||||||
@@ -770,6 +803,7 @@ const handleReset = () => {
|
|||||||
formData.doctor_name = ''
|
formData.doctor_name = ''
|
||||||
formData.date_preset = 'today'
|
formData.date_preset = 'today'
|
||||||
formData.diagnosis_confirmed = ''
|
formData.diagnosis_confirmed = ''
|
||||||
|
formData.assistant_dept_id = ''
|
||||||
const t = new Date()
|
const t = new Date()
|
||||||
const p = (n: number) => String(n).padStart(2, '0')
|
const p = (n: number) => String(n).padStart(2, '0')
|
||||||
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
|
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.end_date = formData.start_date
|
||||||
formData.status = 1
|
formData.status = 1
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const deptTree = await deptAll()
|
||||||
|
departmentTreeRaw.value = Array.isArray(deptTree) ? deptTree : []
|
||||||
|
} catch {
|
||||||
|
departmentTreeRaw.value = []
|
||||||
|
}
|
||||||
loadData()
|
loadData()
|
||||||
listPollTimer = setInterval(() => {
|
listPollTimer = setInterval(() => {
|
||||||
loadData({ silent: true })
|
loadData({ silent: true })
|
||||||
@@ -1228,6 +1268,10 @@ onUnmounted(() => {
|
|||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-dept-select {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -556,7 +556,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||||
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
if (!value) {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||||
callback(new Error('身份证号格式不正确'))
|
callback(new Error('身份证号格式不正确'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -572,6 +576,7 @@ const formRules = {
|
|||||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
||||||
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
|
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
|
||||||
|
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
|
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,43 @@
|
|||||||
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
|
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<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
|
<el-table
|
||||||
v-loading="pager.loading"
|
v-loading="pager.loading"
|
||||||
:data="pager.lists"
|
:data="pager.lists"
|
||||||
@@ -12,6 +49,23 @@
|
|||||||
empty-text="暂无业务订单"
|
empty-text="暂无业务订单"
|
||||||
>
|
>
|
||||||
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
|
<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">
|
<el-table-column label="金额" width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
|
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
|
||||||
@@ -63,9 +117,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
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 PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
import {
|
import {
|
||||||
formatTime,
|
formatTime,
|
||||||
fulfillmentText,
|
fulfillmentText,
|
||||||
@@ -91,6 +147,26 @@ const { pager, getLists, resetPage } = usePaging({
|
|||||||
size: 10
|
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 = () => {
|
const buildParams = () => {
|
||||||
Object.keys(queryParams).forEach((k) => delete queryParams[k])
|
Object.keys(queryParams).forEach((k) => delete queryParams[k])
|
||||||
if (props.diagnosisId > 0) {
|
if (props.diagnosisId > 0) {
|
||||||
@@ -102,6 +178,47 @@ const buildParams = () => {
|
|||||||
queryParams.scene = 'diagnosis_edit'
|
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('起始偏移须在 0~20 之间')
|
||||||
|
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>>()
|
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||||
|
|
||||||
@@ -117,8 +234,13 @@ const formatAmount = (value: unknown) => {
|
|||||||
watch(
|
watch(
|
||||||
() => [props.diagnosisId, patientIdNum.value] as const,
|
() => [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()
|
buildParams()
|
||||||
|
void loadRevisitSlotStartOffset()
|
||||||
resetPage()
|
resetPage()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -134,4 +256,21 @@ defineExpose({ refresh: () => getLists() })
|
|||||||
.po-empty-tip {
|
.po-empty-tip {
|
||||||
padding: 24px 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -103,11 +103,30 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="身份证号" prop="id_card">
|
<el-form-item label="身份证号" prop="id_card">
|
||||||
|
<template v-if="showIdCardMaskedEdit">
|
||||||
|
<el-input
|
||||||
|
:model-value="
|
||||||
|
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
|
||||||
|
"
|
||||||
|
readonly
|
||||||
|
placeholder="点击可查看完整身份证号"
|
||||||
|
maxlength="18"
|
||||||
|
class="cursor-pointer phone-mask-toggle"
|
||||||
|
@click="toggleIdCardReveal"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="originalIdCard && !idCardRevealUnlocked"
|
||||||
|
class="text-xs text-gray-400 mt-1"
|
||||||
|
>
|
||||||
|
当前为脱敏显示,点击输入框可查看完整号码
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
<el-input
|
<el-input
|
||||||
|
v-else
|
||||||
v-model="formData.id_card"
|
v-model="formData.id_card"
|
||||||
placeholder="请输入身份证号"
|
placeholder="请输入身份证号"
|
||||||
maxlength="18"
|
maxlength="18"
|
||||||
:disabled="!canEditPatientBasicFields"
|
:disabled="!canEditIdCard"
|
||||||
@focus="handleIdCardFocus"
|
@focus="handleIdCardFocus"
|
||||||
@blur="handleIdCardBlur"
|
@blur="handleIdCardBlur"
|
||||||
/>
|
/>
|
||||||
@@ -822,6 +841,21 @@ const showPhoneMaskedEdit = computed(() => {
|
|||||||
return !hasPhonePlainPermission.value
|
return !hasPhonePlainPermission.value
|
||||||
})
|
})
|
||||||
const phoneRevealUnlocked = ref(false)
|
const phoneRevealUnlocked = ref(false)
|
||||||
|
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
|
||||||
|
const showIdCardMaskedEdit = computed(() => {
|
||||||
|
if (mode.value !== 'edit') return false
|
||||||
|
if (!originalIdCard.value) return false
|
||||||
|
if (!canEditPatientBasicFields.value) return true
|
||||||
|
return !hasPhonePlainPermission.value
|
||||||
|
})
|
||||||
|
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
|
||||||
|
const canEditIdCard = computed(() => {
|
||||||
|
if (!canEditPatientBasicFields.value) return false
|
||||||
|
if (mode.value === 'add') return true
|
||||||
|
if (!originalIdCard.value) return true
|
||||||
|
return hasPhonePlainPermission.value
|
||||||
|
})
|
||||||
|
const idCardRevealUnlocked = ref(false)
|
||||||
const activeTab = ref('basic')
|
const activeTab = ref('basic')
|
||||||
const drawerTitle = computed(() => {
|
const drawerTitle = computed(() => {
|
||||||
if (viewOnly.value) return '诊单详情'
|
if (viewOnly.value) return '诊单详情'
|
||||||
@@ -961,6 +995,11 @@ const togglePhoneReveal = () => {
|
|||||||
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
|
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleIdCardReveal = () => {
|
||||||
|
if (!originalIdCard.value) return
|
||||||
|
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
|
||||||
|
}
|
||||||
|
|
||||||
const maskIdCard = (idCard: string) => {
|
const maskIdCard = (idCard: string) => {
|
||||||
if (!idCard) return idCard
|
if (!idCard) return idCard
|
||||||
if (idCard.length === 15) {
|
if (idCard.length === 15) {
|
||||||
@@ -1031,29 +1070,28 @@ const handlePhoneBlur = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 身份证聚焦 - 显示完整数据
|
// 身份证聚焦 - 显示完整数据(无「明文」权限时仅新增模式在失焦后可再次展开编辑)
|
||||||
const handleIdCardFocus = () => {
|
const handleIdCardFocus = () => {
|
||||||
isIdCardFocused.value = true
|
isIdCardFocused.value = true
|
||||||
if (originalIdCard.value) {
|
if (!originalIdCard.value) return
|
||||||
|
if (hasPhonePlainPermission.value) {
|
||||||
|
formData.value.id_card = originalIdCard.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode.value === 'add') {
|
||||||
formData.value.id_card = originalIdCard.value
|
formData.value.id_card = originalIdCard.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 身份证失焦 - 恢复脱敏并验证
|
// 身份证失焦:有明文权限则保持明文并校验;新增且无明文权限时仍脱敏展示
|
||||||
const handleIdCardBlur = async () => {
|
const handleIdCardBlur = async () => {
|
||||||
isIdCardFocused.value = false
|
isIdCardFocused.value = false
|
||||||
|
|
||||||
// 保存原始数据并脱敏显示
|
const processIdCard = async (idCard: string) => {
|
||||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
|
||||||
originalIdCard.value = formData.value.id_card
|
const birthYear = parseInt(idCard.substring(6, 10))
|
||||||
|
const birthMonth = parseInt(idCard.substring(10, 12))
|
||||||
// 验证身份证格式并计算年龄
|
const birthDay = parseInt(idCard.substring(12, 14))
|
||||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
|
||||||
// 从身份证提取出生日期计算年龄
|
|
||||||
const id = formData.value.id_card
|
|
||||||
const birthYear = parseInt(id.substring(6, 10))
|
|
||||||
const birthMonth = parseInt(id.substring(10, 12))
|
|
||||||
const birthDay = parseInt(id.substring(12, 14))
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
let age = today.getFullYear() - birthYear
|
let age = today.getFullYear() - birthYear
|
||||||
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
||||||
@@ -1061,13 +1099,11 @@ const handleIdCardBlur = async () => {
|
|||||||
}
|
}
|
||||||
formData.value.age = age
|
formData.value.age = age
|
||||||
|
|
||||||
// 验证身份证唯一性
|
|
||||||
try {
|
try {
|
||||||
const result = await checkIdCard({
|
const result = await checkIdCard({
|
||||||
id_card: formData.value.id_card,
|
id_card: idCard,
|
||||||
id: formData.value.id || ''
|
id: formData.value.id || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.exists) {
|
if (result.exists) {
|
||||||
ElMessage.warning(result.message)
|
ElMessage.warning(result.message)
|
||||||
}
|
}
|
||||||
@@ -1075,8 +1111,24 @@ const handleIdCardBlur = async () => {
|
|||||||
console.error('检查身份证号失败:', error)
|
console.error('检查身份证号失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 脱敏显示
|
if (hasPhonePlainPermission.value) {
|
||||||
|
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||||
|
originalIdCard.value = formData.value.id_card
|
||||||
|
await processIdCard(formData.value.id_card)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑模式下已有身份证号且无明文权限时不处理
|
||||||
|
if (mode.value === 'edit' && originalIdCard.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||||
|
originalIdCard.value = formData.value.id_card
|
||||||
|
await processIdCard(formData.value.id_card)
|
||||||
formData.value.id_card = maskIdCard(formData.value.id_card)
|
formData.value.id_card = maskIdCard(formData.value.id_card)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1151,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
|||||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||||
// 使用原始数据进行验证
|
// 使用原始数据进行验证
|
||||||
const idCardToValidate = originalIdCard.value || value
|
const idCardToValidate = originalIdCard.value || value
|
||||||
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
if (!idCardToValidate) {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||||
callback(new Error('身份证号格式不正确'))
|
callback(new Error('身份证号格式不正确'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1114,6 +1170,7 @@ const formRules = {
|
|||||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||||
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
||||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||||
|
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
|
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,7 +1270,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
|||||||
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
|
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
|
||||||
|
|
||||||
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
|
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
|
||||||
data.id_card = maskIdCard(data.id_card || '')
|
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
|
||||||
|
|
||||||
formData.value = data
|
formData.value = data
|
||||||
formData.value.create_source = data.create_source ?? ''
|
formData.value.create_source = data.create_source ?? ''
|
||||||
@@ -1234,6 +1291,7 @@ const open = async (type: string, id?: number) => {
|
|||||||
visible.value = true
|
visible.value = true
|
||||||
activeTab.value = 'basic' // 重置到基本信息标签页
|
activeTab.value = 'basic' // 重置到基本信息标签页
|
||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
|
idCardRevealUnlocked.value = false
|
||||||
resetPatientBasicLockState()
|
resetPatientBasicLockState()
|
||||||
|
|
||||||
// 加载字典数据
|
// 加载字典数据
|
||||||
@@ -1257,6 +1315,7 @@ const openViewOnly = async (id: number) => {
|
|||||||
visible.value = true
|
visible.value = true
|
||||||
activeTab.value = 'basic'
|
activeTab.value = 'basic'
|
||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
|
idCardRevealUnlocked.value = false
|
||||||
resetPatientBasicLockState()
|
resetPatientBasicLockState()
|
||||||
await getDictOptions()
|
await getDictOptions()
|
||||||
await loadDiagnosisDetailIntoForm(id)
|
await loadDiagnosisDetailIntoForm(id)
|
||||||
@@ -1282,6 +1341,7 @@ const handleSubmit = async () => {
|
|||||||
if (result && result.id) {
|
if (result && result.id) {
|
||||||
mode.value = 'edit'
|
mode.value = 'edit'
|
||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
|
idCardRevealUnlocked.value = false
|
||||||
formData.value.id = result.id
|
formData.value.id = result.id
|
||||||
|
|
||||||
// 重新获取详情,确保patient_id等字段正确
|
// 重新获取详情,确保patient_id等字段正确
|
||||||
@@ -1296,7 +1356,9 @@ const handleSubmit = async () => {
|
|||||||
formData.value.phone = hasPhonePlainPermission.value
|
formData.value.phone = hasPhonePlainPermission.value
|
||||||
? detail.phone || ''
|
? detail.phone || ''
|
||||||
: maskPhone(detail.phone || '')
|
: maskPhone(detail.phone || '')
|
||||||
formData.value.id_card = maskIdCard(detail.id_card || '')
|
formData.value.id_card = hasPhonePlainPermission.value
|
||||||
|
? detail.id_card || ''
|
||||||
|
: maskIdCard(detail.id_card || '')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取详情失败:', error)
|
console.error('获取详情失败:', error)
|
||||||
}
|
}
|
||||||
@@ -1398,6 +1460,7 @@ const handleClose = () => {
|
|||||||
isPhoneFocused.value = false
|
isPhoneFocused.value = false
|
||||||
isIdCardFocused.value = false
|
isIdCardFocused.value = false
|
||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
|
idCardRevealUnlocked.value = false
|
||||||
viewOnly.value = false
|
viewOnly.value = false
|
||||||
|
|
||||||
visible.value = false
|
visible.value = false
|
||||||
|
|||||||
@@ -121,6 +121,16 @@
|
|||||||
end-placeholder="最近挂号结束"
|
end-placeholder="最近挂号结束"
|
||||||
@change="handleLatestAppointmentFilterChange"
|
@change="handleLatestAppointmentFilterChange"
|
||||||
/>
|
/>
|
||||||
|
<daterange-picker
|
||||||
|
class="latest-assign-range"
|
||||||
|
v-model:startTime="formData.latest_assign_start_date"
|
||||||
|
v-model:endTime="formData.latest_assign_end_date"
|
||||||
|
picker-type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
start-placeholder="最近指派开始"
|
||||||
|
end-placeholder="最近指派结束"
|
||||||
|
@change="handleLatestAssignFilterChange"
|
||||||
|
/>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="formData.latest_appointment_channel_source"
|
v-model="formData.latest_appointment_channel_source"
|
||||||
placeholder="最近挂号渠道"
|
placeholder="最近挂号渠道"
|
||||||
@@ -173,6 +183,7 @@
|
|||||||
v-loading="pager.loading"
|
v-loading="pager.loading"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
@row-dblclick="goReadonly"
|
@row-dblclick="goReadonly"
|
||||||
|
@sort-change="handleTableSortChange"
|
||||||
:row-class-name="getRowClassName"
|
:row-class-name="getRowClassName"
|
||||||
class="diagnosis-table"
|
class="diagnosis-table"
|
||||||
stripe
|
stripe
|
||||||
@@ -283,7 +294,14 @@
|
|||||||
<span v-else class="status-unprescribed">未开方</span>
|
<span v-else class="status-unprescribed">未开方</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="未服务天数" width="110" align="center">
|
<el-table-column
|
||||||
|
label="未服务天数"
|
||||||
|
prop="unserved_days"
|
||||||
|
width="110"
|
||||||
|
align="center"
|
||||||
|
sortable="custom"
|
||||||
|
:sort-orders="['descending', 'ascending']"
|
||||||
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
v-if="row.last_blood_record_at"
|
v-if="row.last_blood_record_at"
|
||||||
@@ -762,6 +780,10 @@ const formData = reactive({
|
|||||||
latest_appointment_start_date: '' as string,
|
latest_appointment_start_date: '' as string,
|
||||||
latest_appointment_end_date: '' as string,
|
latest_appointment_end_date: '' as string,
|
||||||
latest_appointment_channel_source: '' as string,
|
latest_appointment_channel_source: '' as string,
|
||||||
|
latest_assign_start_date: '' as string,
|
||||||
|
latest_assign_end_date: '' as string,
|
||||||
|
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
||||||
|
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
||||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||||
appointment_date: '' as string,
|
appointment_date: '' as string,
|
||||||
has_appointment: '' as '' | '0' | '1',
|
has_appointment: '' as '' | '0' | '1',
|
||||||
@@ -849,22 +871,50 @@ function resolvePendingAssignOrderMonthForRequest(): string {
|
|||||||
return dayjs().format('YYYY-MM')
|
return dayjs().format('YYYY-MM')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
|
/** 除顶部 Tab 专属条件外,与主列表共用的「更多筛选」参数(角标 count 需同步) */
|
||||||
function buildPendingAssignCountPayload(): Record<string, unknown> {
|
function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
|
||||||
return buildTcmDiagnosisListRequestPayload({
|
return {
|
||||||
...formData,
|
keyword: formData.keyword,
|
||||||
|
diagnosis_type: formData.diagnosis_type,
|
||||||
|
syndrome_type: formData.syndrome_type,
|
||||||
|
assistant_id: formData.assistant_id,
|
||||||
|
diagnosis_confirmed: formData.diagnosis_confirmed,
|
||||||
|
has_appointment: formData.has_appointment,
|
||||||
|
latest_appointment_start_date: formData.latest_appointment_start_date,
|
||||||
|
latest_appointment_end_date: formData.latest_appointment_end_date,
|
||||||
|
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
||||||
|
latest_assign_start_date: formData.latest_assign_start_date,
|
||||||
|
latest_assign_end_date: formData.latest_assign_end_date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
|
||||||
|
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
page_no: 1,
|
page_no: 1,
|
||||||
page_size: 1,
|
page_size: 1,
|
||||||
pending_assign: 1,
|
...buildSharedDiagnosisFilterPayload(),
|
||||||
appointment_date: '',
|
appointment_date: '',
|
||||||
has_appointment: '',
|
|
||||||
pending_booking: '',
|
pending_booking: '',
|
||||||
completed_appointment: '',
|
completed_appointment: '',
|
||||||
latest_appointment_start_date: '',
|
pending_assign: '',
|
||||||
latest_appointment_end_date: '',
|
pending_assign_order_month: '',
|
||||||
latest_appointment_channel_source: '',
|
pending_assign_keyword: '',
|
||||||
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
|
sort_unserved_days: '',
|
||||||
} as Record<string, unknown>) as Record<string, unknown>
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
|
||||||
|
function buildPendingAssignCountPayload(): Record<string, unknown> {
|
||||||
|
return buildTcmDiagnosisListRequestPayload(
|
||||||
|
buildDateCountRequestPayload({
|
||||||
|
pending_assign: 1,
|
||||||
|
has_appointment: '',
|
||||||
|
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest(),
|
||||||
|
pending_assign_keyword: formData.pending_assign_keyword
|
||||||
|
}) as Record<string, unknown>
|
||||||
|
) as Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
|
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
|
||||||
@@ -893,6 +943,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
|||||||
formData.latest_appointment_start_date = ''
|
formData.latest_appointment_start_date = ''
|
||||||
formData.latest_appointment_end_date = ''
|
formData.latest_appointment_end_date = ''
|
||||||
formData.latest_appointment_channel_source = ''
|
formData.latest_appointment_channel_source = ''
|
||||||
|
formData.latest_assign_start_date = ''
|
||||||
|
formData.latest_assign_end_date = ''
|
||||||
|
formData.sort_unserved_days = ''
|
||||||
formData.pending_assign_order_month = ''
|
formData.pending_assign_order_month = ''
|
||||||
activeTab.value = 'all'
|
activeTab.value = 'all'
|
||||||
if (kw1 !== '') {
|
if (kw1 !== '') {
|
||||||
@@ -994,14 +1047,26 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
|||||||
const fetchDateCounts = async () => {
|
const fetchDateCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
||||||
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists(
|
||||||
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
),
|
||||||
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists(
|
||||||
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
|
buildDateCountRequestPayload({ appointment_date: dayBeforeStr.value, has_appointment: '' }) as any
|
||||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
),
|
||||||
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists(
|
||||||
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
buildDateCountRequestPayload({ appointment_date: todayStr.value, has_appointment: '' }) as any
|
||||||
|
),
|
||||||
|
tcmDiagnosisLists(
|
||||||
|
buildDateCountRequestPayload({ appointment_date: tomorrowStr.value, has_appointment: '' }) as any
|
||||||
|
),
|
||||||
|
tcmDiagnosisLists(
|
||||||
|
buildDateCountRequestPayload({ appointment_date: dayAfterStr.value, has_appointment: '' }) as any
|
||||||
|
),
|
||||||
|
tcmDiagnosisLists(buildDateCountRequestPayload() as any),
|
||||||
|
tcmDiagnosisLists(buildDateCountRequestPayload({ has_appointment: 0 }) as any),
|
||||||
|
tcmDiagnosisLists(
|
||||||
|
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
||||||
|
),
|
||||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
||||||
])
|
])
|
||||||
dateCounts.value = {
|
dateCounts.value = {
|
||||||
@@ -1135,6 +1200,11 @@ const clearLatestAppointmentFilters = () => {
|
|||||||
formData.latest_appointment_channel_source = ''
|
formData.latest_appointment_channel_source = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearLatestAssignFilters = () => {
|
||||||
|
formData.latest_assign_start_date = ''
|
||||||
|
formData.latest_assign_end_date = ''
|
||||||
|
}
|
||||||
|
|
||||||
const hasLatestAppointmentFilter = () =>
|
const hasLatestAppointmentFilter = () =>
|
||||||
!!(
|
!!(
|
||||||
formData.latest_appointment_start_date ||
|
formData.latest_appointment_start_date ||
|
||||||
@@ -1142,6 +1212,9 @@ const hasLatestAppointmentFilter = () =>
|
|||||||
formData.latest_appointment_channel_source
|
formData.latest_appointment_channel_source
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasLatestAssignFilter = () =>
|
||||||
|
!!(formData.latest_assign_start_date || formData.latest_assign_end_date)
|
||||||
|
|
||||||
const handleLatestAppointmentFilterChange = () => {
|
const handleLatestAppointmentFilterChange = () => {
|
||||||
if (hasLatestAppointmentFilter()) {
|
if (hasLatestAppointmentFilter()) {
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
@@ -1154,6 +1227,27 @@ const handleLatestAppointmentFilterChange = () => {
|
|||||||
doSearch()
|
doSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLatestAssignFilterChange = () => {
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTableSortChange = ({
|
||||||
|
prop,
|
||||||
|
order
|
||||||
|
}: {
|
||||||
|
prop: string
|
||||||
|
order: 'ascending' | 'descending' | null
|
||||||
|
}) => {
|
||||||
|
if (prop === 'unserved_days') {
|
||||||
|
formData.sort_unserved_days =
|
||||||
|
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||||
|
} else {
|
||||||
|
formData.sort_unserved_days = ''
|
||||||
|
}
|
||||||
|
pager.page = 1
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
const latestAppointmentChannelText = (row: any) => {
|
const latestAppointmentChannelText = (row: any) => {
|
||||||
const desc = String(row?.latest_appointment_channel_source_desc || '').trim()
|
const desc = String(row?.latest_appointment_channel_source_desc || '').trim()
|
||||||
const raw = String(row?.latest_appointment_channel_source || '').trim()
|
const raw = String(row?.latest_appointment_channel_source || '').trim()
|
||||||
@@ -1200,6 +1294,9 @@ const handleReset = () => {
|
|||||||
formData.latest_appointment_start_date = ''
|
formData.latest_appointment_start_date = ''
|
||||||
formData.latest_appointment_end_date = ''
|
formData.latest_appointment_end_date = ''
|
||||||
formData.latest_appointment_channel_source = ''
|
formData.latest_appointment_channel_source = ''
|
||||||
|
formData.latest_assign_start_date = ''
|
||||||
|
formData.latest_assign_end_date = ''
|
||||||
|
formData.sort_unserved_days = ''
|
||||||
formData.diagnosis_confirmed = ''
|
formData.diagnosis_confirmed = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
@@ -2298,6 +2395,11 @@ onUnmounted(() => {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.latest-assign-range {
|
||||||
|
width: 260px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.latest-appointment-channel {
|
.latest-appointment-channel {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,14 +169,19 @@ class AssetResourceController extends BaseAdminController
|
|||||||
public function delete()
|
public function delete()
|
||||||
{
|
{
|
||||||
$id = $this->request->post('id');
|
$id = $this->request->post('id');
|
||||||
if (empty($id)) {
|
$ids = is_array($id) ? $id : [$id];
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $item): bool {
|
||||||
|
return $item > 0;
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
return $this->fail('缺少参数');
|
return $this->fail('缺少参数');
|
||||||
}
|
}
|
||||||
|
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
AssetResource::destroy($id);
|
AssetResource::destroy($ids);
|
||||||
AssetUserResource::where('resource_id', $id)->delete();
|
AssetUserResource::whereIn('resource_id', $ids)->delete();
|
||||||
Db::commit();
|
Db::commit();
|
||||||
return $this->success('删除成功');
|
return $this->success('删除成功');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,24 @@ class DiagnosisController extends BaseAdminController
|
|||||||
return $this->fail(DiagnosisLogic::getError());
|
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 删除诊单
|
* @notes 删除诊单
|
||||||
* @return \think\response\Json
|
* @return \think\response\Json
|
||||||
|
|||||||
@@ -181,6 +181,20 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('保存成功');
|
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()
|
public function auditPrescription()
|
||||||
{
|
{
|
||||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
||||||
@@ -286,6 +300,32 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('已撤回', $result);
|
return $this->success('已撤回', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 批量将处方业务订单改派给其他医助(写入 creator_id 并逐单记操作日志)
|
||||||
|
*/
|
||||||
|
public function batchAssignAssistant()
|
||||||
|
{
|
||||||
|
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
||||||
|
return $this->fail('无权限批量改派订单');
|
||||||
|
}
|
||||||
|
$params = $this->request->post();
|
||||||
|
$rawIds = $params['order_ids'] ?? [];
|
||||||
|
if (!\is_array($rawIds) || $rawIds === []) {
|
||||||
|
return $this->fail('请选择订单');
|
||||||
|
}
|
||||||
|
$assistantId = (int) ($params['assistant_id'] ?? 0);
|
||||||
|
$result = PrescriptionOrderLogic::batchAssignAssistant($rawIds, $assistantId, $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
$msg = '已改派 ' . (int) $result['success'] . ' 单';
|
||||||
|
if (!empty($result['errors'])) {
|
||||||
|
$msg .= ';' . implode(';', $result['errors']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success($msg, $result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取业务订单操作日志
|
* 获取业务订单操作日志
|
||||||
*/
|
*/
|
||||||
@@ -302,6 +342,20 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('', $result);
|
return $this->success('', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手工新增操作日志(可选同步调整处方/支付单审核状态)
|
||||||
|
*/
|
||||||
|
public function addLog()
|
||||||
|
{
|
||||||
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('addLog');
|
||||||
|
$result = PrescriptionOrderLogic::addLog($params, $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('日志已添加', $result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
|
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace app\adminapi\lists\doctor;
|
namespace app\adminapi\lists\doctor;
|
||||||
|
|
||||||
use app\adminapi\lists\BaseAdminDataLists;
|
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\DiagnosisViewRecord;
|
||||||
use app\common\model\doctor\Appointment;
|
use app\common\model\doctor\Appointment;
|
||||||
use app\common\model\tcm\Prescription;
|
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、或两者皆有的表结构
|
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
|
||||||
*
|
*
|
||||||
@@ -191,6 +222,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
|
|
||||||
$this->applyAssistantIdFilter($query);
|
$this->applyAssistantIdFilter($query);
|
||||||
|
|
||||||
|
$this->applyAssistantDeptIdFilter($query);
|
||||||
|
|
||||||
$this->applyChannelSourceFilter($query, $chFilter);
|
$this->applyChannelSourceFilter($query, $chFilter);
|
||||||
|
|
||||||
// 是否确认诊单:1=已确认 0=未确认
|
// 是否确认诊单:1=已确认 0=未确认
|
||||||
@@ -373,6 +406,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
|
|
||||||
$this->applyAssistantIdFilter($query);
|
$this->applyAssistantIdFilter($query);
|
||||||
|
|
||||||
|
$this->applyAssistantDeptIdFilter($query);
|
||||||
|
|
||||||
$this->applyChannelSourceFilter($query, $chFilter);
|
$this->applyChannelSourceFilter($query, $chFilter);
|
||||||
|
|
||||||
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
|
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||||
|
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||||
|
|
||||||
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||||
|
|
||||||
@@ -167,30 +168,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按挂号状态优先级排序:已过号(4) > 已预约(1) > 已完成(3),然后按挂号日期+时间升序
|
$orderRaw = $this->resolveListOrderRaw($pendingWideSearch);
|
||||||
// 若传了 appointment_date(当天/明天等筛选),只按「该日」的挂号排序与展示
|
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
|
||||||
$aptTbl = (new Appointment())->getTable();
|
|
||||||
$minAptDateCond = '';
|
|
||||||
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
|
||||||
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
|
||||||
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取最早的挂号状态(用于排序优先级)
|
|
||||||
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ' ORDER BY CASE apt.status WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END, apt.appointment_date ASC, apt.appointment_time ASC LIMIT 1)';
|
|
||||||
|
|
||||||
// 获取最早的挂号时间(用于同状态内排序)
|
|
||||||
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
|
|
||||||
|
|
||||||
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序…
|
|
||||||
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
|
|
||||||
if ($isCompletedTab) {
|
|
||||||
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
|
|
||||||
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
|
|
||||||
} else {
|
|
||||||
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
|
|
||||||
}
|
|
||||||
|
|
||||||
$lists = $query
|
$lists = $query
|
||||||
->with(['DiagnosisViewRecord'])
|
->with(['DiagnosisViewRecord'])
|
||||||
@@ -571,6 +549,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||||
|
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||||
|
|
||||||
// 仅已开方(待分配+关键词检索时不限制)
|
// 仅已开方(待分配+关键词检索时不限制)
|
||||||
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||||
@@ -644,6 +623,99 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
$query->whereExists("SELECT 1 FROM {$aptTbl} latest_apt WHERE " . implode(' AND ', $conditions));
|
$query->whereExists("SELECT 1 FROM {$aptTbl} latest_apt WHERE " . implode(' AND ', $conditions));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次成功指派过滤:按 create_time DESC, id DESC 取 to_assistant_id>0 的一条。
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyLatestAssignFilters($query, bool $pendingWideSearch): void
|
||||||
|
{
|
||||||
|
if ($pendingWideSearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = $this->normalizeYmd($this->params['latest_assign_start_date'] ?? '');
|
||||||
|
$endDate = $this->normalizeYmd($this->params['latest_assign_end_date'] ?? '');
|
||||||
|
if ($startDate === '' && $endDate === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logTbl = Db::name('tcm_diagnosis_assign_log')->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$latestIdSql = $this->latestAssignLogIdSubSql($logTbl, $diagTbl);
|
||||||
|
$conditions = ["latest_lg.id = ({$latestIdSql})"];
|
||||||
|
|
||||||
|
if ($startDate !== '') {
|
||||||
|
$startTs = (int) strtotime($startDate . ' 00:00:00');
|
||||||
|
$conditions[] = "latest_lg.create_time >= {$startTs}";
|
||||||
|
}
|
||||||
|
if ($endDate !== '') {
|
||||||
|
$endTs = (int) strtotime($endDate . ' 23:59:59');
|
||||||
|
$conditions[] = "latest_lg.create_time <= {$endTs}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereExists("SELECT 1 FROM {$logTbl} latest_lg WHERE " . implode(' AND ', $conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestAssignLogIdSubSql(string $logTbl, string $diagTbl): string
|
||||||
|
{
|
||||||
|
return "SELECT lg_latest.id FROM {$logTbl} lg_latest "
|
||||||
|
. "WHERE lg_latest.diagnosis_id = {$diagTbl}.id "
|
||||||
|
. 'AND lg_latest.to_assistant_id > 0 '
|
||||||
|
. 'ORDER BY lg_latest.create_time DESC, lg_latest.id DESC LIMIT 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表默认排序;支持 sort_unserved_days=asc|desc 按未服务天数排序。
|
||||||
|
*/
|
||||||
|
private function resolveListOrderRaw(bool $pendingWideSearch): string
|
||||||
|
{
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
|
||||||
|
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
|
||||||
|
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
|
||||||
|
$nullLast = "CASE WHEN IFNULL({$anchorExpr}, 0) = 0 THEN 1 ELSE 0 END ASC";
|
||||||
|
if ($sortUnserved === 'desc') {
|
||||||
|
return "{$nullLast}, IFNULL({$anchorExpr}, 0) ASC, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$nullLast}, IFNULL({$anchorExpr}, 0) DESC, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
$aptTbl = (new Appointment())->getTable();
|
||||||
|
$minAptDateCond = '';
|
||||||
|
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
||||||
|
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
||||||
|
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ' ORDER BY CASE apt.status WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END, apt.appointment_date ASC, apt.appointment_time ASC LIMIT 1)';
|
||||||
|
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
|
||||||
|
|
||||||
|
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
|
||||||
|
if ($isCompletedTab) {
|
||||||
|
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
|
||||||
|
|
||||||
|
return $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $diagTbl . '.assign_read_at IS NULL DESC, CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未服务天数锚点:血糖/饮食/运动记录最近 record_date 的最大值 */
|
||||||
|
private function unservedAnchorExpr(string $diagTbl): string
|
||||||
|
{
|
||||||
|
$bloodTbl = (new BloodRecord())->getTable();
|
||||||
|
$dietTbl = (new DietRecord())->getTable();
|
||||||
|
$exerciseTbl = (new ExerciseRecord())->getTable();
|
||||||
|
|
||||||
|
return 'GREATEST('
|
||||||
|
. "COALESCE((SELECT MAX(br.record_date) FROM {$bloodTbl} br WHERE br.diagnosis_id = {$diagTbl}.id AND br.delete_time IS NULL), 0), "
|
||||||
|
. "COALESCE((SELECT MAX(dr.record_date) FROM {$dietTbl} dr WHERE dr.diagnosis_id = {$diagTbl}.id AND dr.delete_time IS NULL), 0), "
|
||||||
|
. "COALESCE((SELECT MAX(er.record_date) FROM {$exerciseTbl} er WHERE er.diagnosis_id = {$diagTbl}.id AND er.delete_time IS NULL), 0)"
|
||||||
|
. ')';
|
||||||
|
}
|
||||||
|
|
||||||
private function latestAppointmentIdSubSql(string $aptTbl, string $diagTbl): string
|
private function latestAppointmentIdSubSql(string $aptTbl, string $diagTbl): string
|
||||||
{
|
{
|
||||||
$statuses = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
|
$statuses = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$this->applyExpressKeywordFilter($query);
|
$this->applyExpressKeywordFilter($query);
|
||||||
$this->applyServiceChannelFilter($query);
|
$this->applyServiceChannelFilter($query);
|
||||||
$this->applySupplyModeFilter($query);
|
$this->applySupplyModeFilter($query);
|
||||||
|
$this->applyHasAuxFormulaFilter($query);
|
||||||
$this->applyAuditAdminFilter($query);
|
$this->applyAuditAdminFilter($query);
|
||||||
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||||
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
|
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
|
||||||
@@ -354,6 +355,39 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否含辅方:关联处方 herbs 中是否存在 formula_type=辅方(与 PrescriptionOrderLogic::detail 口径一致)
|
||||||
|
*
|
||||||
|
* 入参 has_aux_formula:1 含辅方 | 0 不含辅方,空表示不限
|
||||||
|
*/
|
||||||
|
private function applyHasAuxFormulaFilter($query): void
|
||||||
|
{
|
||||||
|
$raw = $this->params['has_aux_formula'] ?? '';
|
||||||
|
if ($raw === '' || $raw === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$wantAux = (int) $raw === 1;
|
||||||
|
$poTbl = (new PrescriptionOrder())->getTable();
|
||||||
|
$rxTbl = (new Prescription())->getTable();
|
||||||
|
// herbs 为 JSON 字段,ThinkPHP 写入时中文按 Unicode 转义存储(辅方 => \u8f85\u65b9)。
|
||||||
|
// 用 ESCAPE '~' 让反斜杠按字面匹配;同时兼容极少数原文「辅方」写法。
|
||||||
|
$auxEsc = '%"formula_type":"\\\\u8f85\\\\u65b9"%';
|
||||||
|
$auxRaw = '%"formula_type":"辅方"%';
|
||||||
|
$herbsHasAux = "(IFNULL(rx.`herbs`,'') LIKE '{$auxEsc}' ESCAPE '~'"
|
||||||
|
. " OR IFNULL(rx.`herbs`,'') LIKE '{$auxRaw}')";
|
||||||
|
$existsSql = "SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
|
||||||
|
. " AND rx.`delete_time` IS NULL AND {$herbsHasAux}";
|
||||||
|
if ($wantAux) {
|
||||||
|
$query->whereExists($existsSql);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->whereExists(
|
||||||
|
"SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
|
||||||
|
. " AND rx.`delete_time` IS NULL AND NOT ({$herbsHasAux})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则额外包含关联处方开方人为本人的订单。
|
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则额外包含关联处方开方人为本人的订单。
|
||||||
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
|
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
|
||||||
@@ -707,6 +741,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
unset($item);
|
unset($item);
|
||||||
|
|
||||||
|
if ($this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||||
|
$this->appendDiagnosisEditVisitSeqFields($lists);
|
||||||
|
}
|
||||||
|
|
||||||
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
|
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
|
||||||
|
|
||||||
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
|
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
|
||||||
@@ -786,6 +824,11 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
|
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
|
||||||
'export_medication_form' => '药品形态',
|
'export_medication_form' => '药品形态',
|
||||||
'export_prescription_name' => '药方名称',
|
'export_prescription_name' => '药方名称',
|
||||||
|
'export_prescription_herbs' => '处方',
|
||||||
|
'export_main_usage' => '主方服用方式',
|
||||||
|
'export_main_usage_days' => '主方天数',
|
||||||
|
'export_aux_usage' => '辅方服用方式',
|
||||||
|
'export_aux_usage_days' => '辅方天数',
|
||||||
'export_service_package' => '服务套餐',
|
'export_service_package' => '服务套餐',
|
||||||
'export_medication_days' => '天数',
|
'export_medication_days' => '天数',
|
||||||
'export_amount' => '总金额',
|
'export_amount' => '总金额',
|
||||||
@@ -875,6 +918,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$wrapWideKeys = [
|
$wrapWideKeys = [
|
||||||
'export_linked_pay_records',
|
'export_linked_pay_records',
|
||||||
'export_prescription_name',
|
'export_prescription_name',
|
||||||
|
'export_prescription_herbs',
|
||||||
|
'export_main_usage',
|
||||||
|
'export_aux_usage',
|
||||||
'export_guahao_channel_source',
|
'export_guahao_channel_source',
|
||||||
'export_assistant_dept',
|
'export_assistant_dept',
|
||||||
'export_service_package',
|
'export_service_package',
|
||||||
@@ -885,6 +931,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'export_patient_gender' => 6,
|
'export_patient_gender' => 6,
|
||||||
'export_patient_age' => 6,
|
'export_patient_age' => 6,
|
||||||
'export_medication_days' => 6,
|
'export_medication_days' => 6,
|
||||||
|
'export_main_usage_days' => 8,
|
||||||
|
'export_aux_usage_days' => 8,
|
||||||
'export_amount' => 10,
|
'export_amount' => 10,
|
||||||
'export_paid_amount' => 10,
|
'export_paid_amount' => 10,
|
||||||
'export_refund_amount' => 10,
|
'export_refund_amount' => 10,
|
||||||
@@ -893,6 +941,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'export_supply_mode' => 10,
|
'export_supply_mode' => 10,
|
||||||
'export_linked_pay_records' => 52,
|
'export_linked_pay_records' => 52,
|
||||||
'export_prescription_name' => 34,
|
'export_prescription_name' => 34,
|
||||||
|
'export_prescription_herbs' => 36,
|
||||||
|
'export_main_usage' => 28,
|
||||||
|
'export_aux_usage' => 28,
|
||||||
'export_guahao_channel_source' => 22,
|
'export_guahao_channel_source' => 22,
|
||||||
'export_assistant_dept' => 24,
|
'export_assistant_dept' => 24,
|
||||||
'export_service_package' => 18,
|
'export_service_package' => 18,
|
||||||
@@ -1370,6 +1421,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)是否指向本业务单,
|
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
|
||||||
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
|
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
|
||||||
|
|||||||
@@ -151,6 +151,24 @@ class DeptLogic extends BaseLogic
|
|||||||
* @return list<int>
|
* @return list<int>
|
||||||
*/
|
*/
|
||||||
private static function findErCenterRootDeptIds(): array
|
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')
|
$rows = Dept::whereNull('delete_time')
|
||||||
->field(['id', 'name'])
|
->field(['id', 'name'])
|
||||||
@@ -159,7 +177,7 @@ class DeptLogic extends BaseLogic
|
|||||||
$out = [];
|
$out = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$name = (string) ($r['name'] ?? '');
|
$name = (string) ($r['name'] ?? '');
|
||||||
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
|
if ($name !== '' && mb_strpos($name, $keyword) !== false) {
|
||||||
$out[] = (int) $r['id'];
|
$out[] = (int) $r['id'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +219,19 @@ class DeptLogic extends BaseLogic
|
|||||||
return array_fill_keys($subtreeIds, true);
|
return array_fill_keys($subtreeIds, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名称含「一中心」的部门及其全部下级 id(map)。
|
||||||
|
*
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
public static function getYiCenterSubtreeDeptIdSet(): array
|
||||||
|
{
|
||||||
|
$yiRoots = self::findYiCenterRootDeptIds();
|
||||||
|
$subtreeIds = self::unionErCenterSubtreeDeptIds($yiRoots);
|
||||||
|
|
||||||
|
return array_fill_keys($subtreeIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
|
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,8 +48,8 @@ class ConversionLogic
|
|||||||
$emptyResult = [
|
$emptyResult = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
'lists' => [],
|
'lists' => [],
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -57,8 +57,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -75,8 +75,8 @@ class ConversionLogic
|
|||||||
$result = [
|
$result = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
'lists' => [],
|
'lists' => [],
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -84,8 +84,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -124,8 +124,8 @@ class ConversionLogic
|
|||||||
$result = [
|
$result = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
'lists' => [],
|
'lists' => [],
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -133,8 +133,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -191,8 +191,8 @@ class ConversionLogic
|
|||||||
$result = [
|
$result = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary($allRows),
|
'summary' => self::buildSummary($allRows, $dimension),
|
||||||
'charts' => self::buildCharts($chartRows),
|
'charts' => self::buildCharts($chartRows, $dimension),
|
||||||
'lists' => $pagedRows,
|
'lists' => $pagedRows,
|
||||||
'count' => count($allRows),
|
'count' => count($allRows),
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -200,8 +200,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary($allRows),
|
'summary' => self::buildSummary($allRows, $dimension),
|
||||||
'charts' => self::buildCharts($chartRows),
|
'charts' => self::buildCharts($chartRows, $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -226,8 +226,8 @@ class ConversionLogic
|
|||||||
$result = [
|
$result = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary($rows),
|
'summary' => self::buildSummary($rows, $dimension),
|
||||||
'charts' => self::buildCharts($rows),
|
'charts' => self::buildCharts($rows, $dimension),
|
||||||
'lists' => array_slice($rows, $offset, $pageSize),
|
'lists' => array_slice($rows, $offset, $pageSize),
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -235,8 +235,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary($rows),
|
'summary' => self::buildSummary($rows, $dimension),
|
||||||
'charts' => self::buildCharts($rows),
|
'charts' => self::buildCharts($rows, $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -1226,7 +1226,7 @@ class ConversionLogic
|
|||||||
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||||
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||||
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
|
||||||
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||||
@@ -1459,7 +1459,7 @@ class ConversionLogic
|
|||||||
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||||
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||||
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
$node['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
|
||||||
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||||
@@ -2010,7 +2010,12 @@ class ConversionLogic
|
|||||||
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
|
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
|
||||||
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
|
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
|
||||||
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
|
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
|
||||||
'receive_rate' => self::percent($completedOrderCount, $addFansCount),
|
'receive_rate' => self::receiveRate(
|
||||||
|
$completedOrderCount,
|
||||||
|
$addFansCount,
|
||||||
|
$interviewCount,
|
||||||
|
'member'
|
||||||
|
),
|
||||||
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
||||||
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
||||||
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
||||||
@@ -2149,7 +2154,7 @@ class ConversionLogic
|
|||||||
* @param array<int, array<string, mixed>> $rows
|
* @param array<int, array<string, mixed>> $rows
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static function buildSummary(array $rows): array
|
private static function buildSummary(array $rows, string $dimension = 'dept'): array
|
||||||
{
|
{
|
||||||
$summary = [
|
$summary = [
|
||||||
'add_fans_count' => 0,
|
'add_fans_count' => 0,
|
||||||
@@ -2181,7 +2186,12 @@ class ConversionLogic
|
|||||||
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
|
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
|
||||||
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
|
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
|
||||||
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
|
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
|
||||||
$summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_count']);
|
$summary['receive_rate'] = self::receiveRate(
|
||||||
|
$summary['completed_order_count'],
|
||||||
|
$summary['add_fans_count'],
|
||||||
|
$summary['interview_count'],
|
||||||
|
$dimension
|
||||||
|
);
|
||||||
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
||||||
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
|
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
|
||||||
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
|
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
|
||||||
@@ -2310,7 +2320,7 @@ class ConversionLogic
|
|||||||
* @param array<int, array<string, mixed>> $rows
|
* @param array<int, array<string, mixed>> $rows
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static function buildCharts(array $rows): array
|
private static function buildCharts(array $rows, string $dimension = 'dept'): array
|
||||||
{
|
{
|
||||||
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
|
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
|
||||||
$topRows = array_slice($chartableRows, 0, 10);
|
$topRows = array_slice($chartableRows, 0, 10);
|
||||||
@@ -2319,29 +2329,56 @@ class ConversionLogic
|
|||||||
'names' => [],
|
'names' => [],
|
||||||
'amounts' => [],
|
'amounts' => [],
|
||||||
'order_counts' => [],
|
'order_counts' => [],
|
||||||
'fan_counts' => [],
|
|
||||||
'rois' => [],
|
|
||||||
];
|
];
|
||||||
|
if ($dimension === 'doctor') {
|
||||||
|
$ranking['appointment_counts'] = [];
|
||||||
|
$ranking['interview_counts'] = [];
|
||||||
|
} else {
|
||||||
|
$ranking['fan_counts'] = [];
|
||||||
|
$ranking['rois'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($topRows as $row) {
|
foreach ($topRows as $row) {
|
||||||
$ranking['names'][] = $row['name'];
|
$ranking['names'][] = $row['name'];
|
||||||
$ranking['amounts'][] = $row['completed_order_amount'];
|
$ranking['amounts'][] = $row['completed_order_amount'];
|
||||||
$ranking['order_counts'][] = $row['completed_order_count'];
|
$ranking['order_counts'][] = $row['completed_order_count'];
|
||||||
$ranking['fan_counts'][] = $row['add_fans_count'];
|
if ($dimension === 'doctor') {
|
||||||
$ranking['rois'][] = $row['roi'];
|
$ranking['appointment_counts'][] = $row['appointment_total_count'];
|
||||||
|
$ranking['interview_counts'][] = $row['interview_count'];
|
||||||
|
} else {
|
||||||
|
$ranking['fan_counts'][] = $row['add_fans_count'];
|
||||||
|
$ranking['rois'][] = $row['roi'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
$charts = [
|
||||||
'ranking' => $ranking,
|
'ranking' => $ranking,
|
||||||
'amount_share' => array_map(
|
'amount_share' => array_map(
|
||||||
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
|
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
|
||||||
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
|
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
|
||||||
),
|
),
|
||||||
'fan_share' => array_map(
|
];
|
||||||
|
|
||||||
|
if ($dimension === 'doctor') {
|
||||||
|
$charts['order_share'] = array_map(
|
||||||
|
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_count']],
|
||||||
|
array_filter($topRows, static fn (array $row): bool => (int)$row['completed_order_count'] > 0)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$charts['fan_share'] = array_map(
|
||||||
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
|
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
|
||||||
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
|
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
|
||||||
),
|
);
|
||||||
];
|
}
|
||||||
|
|
||||||
|
return $charts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
|
||||||
|
{
|
||||||
|
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
|
||||||
|
|
||||||
|
return self::percent($completedOrderCount, $denominator);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function percent(int $numerator, int $denominator): float
|
private static function percent(int $numerator, int $denominator): float
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\adminapi\logic\stats;
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,9 +13,11 @@ use think\facade\Db;
|
|||||||
* 口径说明:
|
* 口径说明:
|
||||||
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||||
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
||||||
* 同一诊单当月被多次指派给同一医助只计 1 次。
|
* 同一诊单当月被多次指派给同一医助只计 1 次;
|
||||||
|
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
|
||||||
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
||||||
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
|
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
|
||||||
|
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
|
||||||
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
|
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
|
||||||
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
|
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
|
||||||
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
|
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
|
||||||
@@ -22,7 +25,8 @@ use think\facade\Db;
|
|||||||
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
||||||
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
||||||
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
||||||
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;部门筛选(dept_ids,含组织下级)按该归属部门过滤。
|
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
||||||
|
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
||||||
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
||||||
*/
|
*/
|
||||||
class RevisitRateLogic
|
class RevisitRateLogic
|
||||||
@@ -294,14 +298,19 @@ class RevisitRateLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 部门下拉(全量未删除部门,前端组树)。
|
* 部门下拉:仅「二中心」及其组织下级(与业绩看板 ErCenter 子树一致)。
|
||||||
*
|
*
|
||||||
* @return array{rows: list<array{id:int,pid:int,name:string}>}
|
* @return array{rows: list<array{id:int,pid:int,name:string}>}
|
||||||
*/
|
*/
|
||||||
public static function deptOptions(): array
|
public static function deptOptions(): array
|
||||||
{
|
{
|
||||||
|
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
if ($erSet === []) {
|
||||||
|
return ['rows' => []];
|
||||||
|
}
|
||||||
$rows = Db::name('dept')
|
$rows = Db::name('dept')
|
||||||
->whereNull('delete_time')
|
->whereNull('delete_time')
|
||||||
|
->whereIn('id', array_keys($erSet))
|
||||||
->field(['id', 'pid', 'name'])
|
->field(['id', 'pid', 'name'])
|
||||||
->order('sort', 'desc')
|
->order('sort', 'desc')
|
||||||
->order('id', 'asc')
|
->order('id', 'asc')
|
||||||
@@ -322,9 +331,9 @@ class RevisitRateLogic
|
|||||||
/**
|
/**
|
||||||
* 核心统计上下文:
|
* 核心统计上下文:
|
||||||
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
||||||
* 2. 分母:当月非继承指派的「医助 × 诊单」;
|
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
||||||
* 3. 分子:曾被指派诊单的当月订单按全局序号 ≥2 归属持有医助;
|
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
|
||||||
* 4. 应用部门筛选(含组织下级)。
|
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
|
||||||
*
|
*
|
||||||
* @param array{month?:string,dept_ids?:int[]|string} $params
|
* @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 */
|
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
|
||||||
$slotOrdersByAssistant = [];
|
$slotOrdersByAssistant = [];
|
||||||
|
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
|
||||||
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
|
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
|
||||||
$orderRows = self::fetchOrderSeqRows(
|
$orderRows = self::fetchOrderSeqRows(
|
||||||
$chunk,
|
$chunk,
|
||||||
@@ -387,6 +422,7 @@ class RevisitRateLogic
|
|||||||
$seq = 0;
|
$seq = 0;
|
||||||
$ptr = 0;
|
$ptr = 0;
|
||||||
$holder = 0;
|
$holder = 0;
|
||||||
|
$offset = 0;
|
||||||
foreach ($orderRows as $r) {
|
foreach ($orderRows as $r) {
|
||||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
if ($did <= 0) {
|
if ($did <= 0) {
|
||||||
@@ -397,8 +433,10 @@ class RevisitRateLogic
|
|||||||
$seq = 0;
|
$seq = 0;
|
||||||
$ptr = 0;
|
$ptr = 0;
|
||||||
$holder = 0;
|
$holder = 0;
|
||||||
|
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||||
}
|
}
|
||||||
$seq++;
|
$seq++;
|
||||||
|
$effectiveSlot = $seq + $offset;
|
||||||
$ct = (int) ($r['create_time'] ?? 0);
|
$ct = (int) ($r['create_time'] ?? 0);
|
||||||
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
||||||
$tl = $timeline[$did] ?? [];
|
$tl = $timeline[$did] ?? [];
|
||||||
@@ -407,24 +445,27 @@ class RevisitRateLogic
|
|||||||
$holder = (int) $tl[$ptr]['to'];
|
$holder = (int) $tl[$ptr]['to'];
|
||||||
$ptr++;
|
$ptr++;
|
||||||
}
|
}
|
||||||
if ($seq < 2 || $seq > self::MAX_VISIT_SLOT) {
|
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($ct < $startTs || $ct > $endTs) {
|
if ($ct < $startTs || $ct > $endTs) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($holder > 0) {
|
if ($holder > 0) {
|
||||||
$slotOrdersByAssistant[$holder][$seq][] = $r;
|
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 医助归属部门 + 部门筛选(含组织下级)
|
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
||||||
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
||||||
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
||||||
$deptFilterIds = self::parseDeptIds($params['dept_ids'] ?? null);
|
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
|
||||||
if ($deptFilterIds !== []) {
|
if ($subtreeSet === []) {
|
||||||
$subtreeSet = self::expandDeptSubtreeSet($deptFilterIds);
|
// 无二中心部门时整表为空,避免误展示其它中心数据
|
||||||
|
$diagsByAssistant = [];
|
||||||
|
$slotOrdersByAssistant = [];
|
||||||
|
} else {
|
||||||
foreach ($universeIds as $aid) {
|
foreach ($universeIds as $aid) {
|
||||||
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
||||||
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
|
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
|
||||||
@@ -543,6 +584,45 @@ class RevisitRateLogic
|
|||||||
return [$canonical, $names];
|
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[] | 逗号分隔字符串
|
* @param mixed $raw int[] | 逗号分隔字符串
|
||||||
*
|
*
|
||||||
@@ -596,6 +676,35 @@ class RevisitRateLogic
|
|||||||
return $set;
|
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();
|
->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
|
* @param list<int> $diagIds
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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('起始偏移须在 0~20 之间');
|
||||||
|
|
||||||
|
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 诊单指派医助操作记录列表
|
* @notes 诊单指派医助操作记录列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2375,6 +2375,123 @@ class PrescriptionOrderLogic
|
|||||||
->toArray();
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手工新增操作日志;可选单独调整处方审核 / 支付单审核状态(不触发常规审核流程副作用)
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params id, summary, prescription_audit_status?, payment_slip_audit_status?, prescription_audit_remark?, payment_slip_audit_remark?
|
||||||
|
* @return array<string,mixed>|false
|
||||||
|
*/
|
||||||
|
public static function addLog(array $params, int $adminId, array $adminInfo)
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
$id = (int) ($params['id'] ?? 0);
|
||||||
|
$summary = mb_substr(trim((string) ($params['summary'] ?? '')), 0, 500);
|
||||||
|
if ($summary === '') {
|
||||||
|
self::$error = '请填写日志内容';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||||
|
if (!$order) {
|
||||||
|
self::$error = '订单不存在';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||||
|
self::$error = '无权限操作';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changeParts = [];
|
||||||
|
$hasRxChange = array_key_exists('prescription_audit_status', $params)
|
||||||
|
&& $params['prescription_audit_status'] !== ''
|
||||||
|
&& $params['prescription_audit_status'] !== null;
|
||||||
|
$hasPayChange = array_key_exists('payment_slip_audit_status', $params)
|
||||||
|
&& $params['payment_slip_audit_status'] !== ''
|
||||||
|
&& $params['payment_slip_audit_status'] !== null;
|
||||||
|
|
||||||
|
if ($hasRxChange) {
|
||||||
|
if (!self::canAuditPrescriptionOrder($adminInfo)) {
|
||||||
|
self::$error = '无处方审核权限,不能调整处方审核状态';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$newRx = (int) $params['prescription_audit_status'];
|
||||||
|
if (!in_array($newRx, [0, 1, 2], true)) {
|
||||||
|
self::$error = '处方审核状态无效';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$oldRx = (int) $order->prescription_audit_status;
|
||||||
|
if ($newRx !== $oldRx) {
|
||||||
|
$order->prescription_audit_status = $newRx;
|
||||||
|
$changeParts[] = '处方审核:' . self::auditStatusLabelForLog($oldRx)
|
||||||
|
. ' → ' . self::auditStatusLabelForLog($newRx);
|
||||||
|
}
|
||||||
|
if (array_key_exists('prescription_audit_remark', $params)) {
|
||||||
|
$order->prescription_audit_remark = mb_substr(trim((string) $params['prescription_audit_remark']), 0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasPayChange) {
|
||||||
|
if (!self::canAuditPaymentSlipOrder($adminInfo)) {
|
||||||
|
self::$error = '无支付单审核权限,不能调整支付单审核状态';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$newPay = (int) $params['payment_slip_audit_status'];
|
||||||
|
if (!in_array($newPay, [0, 1, 2], true)) {
|
||||||
|
self::$error = '支付单审核状态无效';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$oldPay = (int) $order->payment_slip_audit_status;
|
||||||
|
if ($newPay !== $oldPay) {
|
||||||
|
$order->payment_slip_audit_status = $newPay;
|
||||||
|
$changeParts[] = '支付单审核:' . self::auditStatusLabelForLog($oldPay)
|
||||||
|
. ' → ' . self::auditStatusLabelForLog($newPay);
|
||||||
|
}
|
||||||
|
if (array_key_exists('payment_slip_audit_remark', $params)) {
|
||||||
|
$order->payment_slip_audit_remark = mb_substr(trim((string) $params['payment_slip_audit_remark']), 0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasRxChange || $hasPayChange) {
|
||||||
|
self::syncFulfillmentStatus($order);
|
||||||
|
try {
|
||||||
|
$order->save();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
self::$error = $e->getMessage();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$logSummary = $summary;
|
||||||
|
if ($changeParts !== []) {
|
||||||
|
$logSummary .= '(' . implode(';', $changeParts) . ')';
|
||||||
|
}
|
||||||
|
self::writeLog($id, $adminId, $adminInfo, 'manual_log', $logSummary);
|
||||||
|
|
||||||
|
$out = $order->toArray();
|
||||||
|
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||||
|
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||||
|
self::attachLinkedPayOrders($out);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function auditStatusLabelForLog(int $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
1 => '已通过',
|
||||||
|
2 => '已驳回',
|
||||||
|
default => '待审核',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为「已发货/已签收」(fulfillment_status=5/6) 的业务订单新增一条关联支付单(zyt_order),
|
* 为「已发货/已签收」(fulfillment_status=5/6) 的业务订单新增一条关联支付单(zyt_order),
|
||||||
* 创建后将支付单链接到业务订单,并将处方/支付审核状态重置为待审核以启动再次审核流程。
|
* 创建后将支付单链接到业务订单,并将处方/支付审核状态重置为待审核以启动再次审核流程。
|
||||||
@@ -3107,6 +3224,207 @@ class PrescriptionOrderLogic
|
|||||||
return $type;
|
return $type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用:处方药材明细(主方/辅方分行,与处方笺一致)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $rx
|
||||||
|
*/
|
||||||
|
public static function formatPrescriptionHerbsForExport(array $rx): string
|
||||||
|
{
|
||||||
|
[$mainHerbs, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rx);
|
||||||
|
$formatList = static function (array $herbs): string {
|
||||||
|
$parts = [];
|
||||||
|
foreach ($herbs as $h) {
|
||||||
|
if (!\is_array($h)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = trim((string) ($h['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parts[] = $name . ' ' . self::formatExportDosageNumber((float) ($h['dosage'] ?? 0)) . 'g';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('、', $parts);
|
||||||
|
};
|
||||||
|
|
||||||
|
$sections = [];
|
||||||
|
$mainText = $formatList($mainHerbs);
|
||||||
|
if ($mainText !== '') {
|
||||||
|
$sections[] = '主方:' . $mainText;
|
||||||
|
}
|
||||||
|
$auxText = $formatList($auxHerbs);
|
||||||
|
if ($auxText !== '') {
|
||||||
|
$sections[] = '辅方:' . $auxText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用:主方/辅方服用方式(与前端 buildUsageSegmentText / 处方笺同口径)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $usage
|
||||||
|
*/
|
||||||
|
public static function formatUsageSegmentForExport(
|
||||||
|
array $usage,
|
||||||
|
string $prescriptionType = '浓缩水丸',
|
||||||
|
string $fallbackWay = '',
|
||||||
|
string $fallbackTime = ''
|
||||||
|
): string {
|
||||||
|
$pt = trim($prescriptionType) !== '' ? trim($prescriptionType) : '浓缩水丸';
|
||||||
|
$times = (int) ($usage['times_per_day'] ?? 0);
|
||||||
|
if ($times <= 0) {
|
||||||
|
$times = 3;
|
||||||
|
}
|
||||||
|
$amount = isset($usage['dosage_amount']) && $usage['dosage_amount'] !== '' && $usage['dosage_amount'] !== null
|
||||||
|
? (float) $usage['dosage_amount']
|
||||||
|
: 10.0;
|
||||||
|
$unit = trim((string) ($usage['usage_dosage_unit'] ?? ($usage['dosage_unit'] ?? '')));
|
||||||
|
if ($unit === '') {
|
||||||
|
$unit = $pt === '饮片' ? 'ml' : 'g';
|
||||||
|
}
|
||||||
|
$usageWay = trim((string) ($usage['usage_way'] ?? ''));
|
||||||
|
if ($usageWay === '') {
|
||||||
|
$usageWay = $fallbackWay !== '' ? $fallbackWay : '温水送服';
|
||||||
|
}
|
||||||
|
$usageTime = trim((string) ($usage['usage_time'] ?? ''));
|
||||||
|
if ($usageTime === '') {
|
||||||
|
$usageTime = $fallbackTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seg = ['每天' . $times . '次'];
|
||||||
|
if ($pt === '浓缩水丸') {
|
||||||
|
$bags = (int) ($usage['dosage_bag_count'] ?? 0);
|
||||||
|
if ($bags <= 0) {
|
||||||
|
$bags = 1;
|
||||||
|
}
|
||||||
|
$seg[] = '一次' . $bags . '袋';
|
||||||
|
$seg[] = '每袋' . self::formatExportDosageNumber($amount) . $unit;
|
||||||
|
} else {
|
||||||
|
$seg[] = '一次' . self::formatExportDosageNumber($amount) . $unit;
|
||||||
|
}
|
||||||
|
$seg[] = $usageWay;
|
||||||
|
if ($usageTime !== '') {
|
||||||
|
$seg[] = $usageTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $seg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用:辅方用法 JSON 规范化(与前端 normalizeSlipAuxUsageForm 默认值一致)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function normalizeAuxUsageForExport($raw, string $prescriptionType): array
|
||||||
|
{
|
||||||
|
$pt = trim($prescriptionType) !== '' ? trim($prescriptionType) : '浓缩水丸';
|
||||||
|
if ($pt === '饮片') {
|
||||||
|
$base = [
|
||||||
|
'dosage_amount' => 50.0,
|
||||||
|
'dosage_bag_count' => 1,
|
||||||
|
'times_per_day' => 3,
|
||||||
|
'usage_days' => 7,
|
||||||
|
];
|
||||||
|
} elseif ($pt === '浓缩水丸') {
|
||||||
|
$base = [
|
||||||
|
'dosage_amount' => 5.0,
|
||||||
|
'dosage_bag_count' => 1,
|
||||||
|
'times_per_day' => 3,
|
||||||
|
'usage_days' => 7,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$base = [
|
||||||
|
'dosage_amount' => 1.0,
|
||||||
|
'dosage_bag_count' => 1,
|
||||||
|
'times_per_day' => 3,
|
||||||
|
'usage_days' => 7,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\is_string($raw) && $raw !== '') {
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
$raw = \is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
if (!\is_array($raw)) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'dosage_amount' => isset($raw['dosage_amount']) && $raw['dosage_amount'] !== '' && $raw['dosage_amount'] !== null
|
||||||
|
? (float) $raw['dosage_amount']
|
||||||
|
: $base['dosage_amount'],
|
||||||
|
'dosage_bag_count' => (int) ($raw['dosage_bag_count'] ?? 0) > 0
|
||||||
|
? (int) $raw['dosage_bag_count']
|
||||||
|
: $base['dosage_bag_count'],
|
||||||
|
'times_per_day' => (int) ($raw['times_per_day'] ?? 0) > 0
|
||||||
|
? (int) $raw['times_per_day']
|
||||||
|
: $base['times_per_day'],
|
||||||
|
'usage_days' => (int) ($raw['usage_days'] ?? 0) > 0
|
||||||
|
? (int) $raw['usage_days']
|
||||||
|
: $base['usage_days'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
private static function splitPrescriptionHerbsFromRx(array $rx): array
|
||||||
|
{
|
||||||
|
$herbs = $rx['herbs'] ?? null;
|
||||||
|
if (\is_string($herbs) && $herbs !== '') {
|
||||||
|
$decoded = json_decode($herbs, true);
|
||||||
|
$herbs = \is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
if (!\is_array($herbs)) {
|
||||||
|
$herbs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$mainHerbs = [];
|
||||||
|
$auxHerbs = [];
|
||||||
|
foreach ($herbs as $h) {
|
||||||
|
if (!\is_array($h)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (((string) ($h['formula_type'] ?? '')) === '辅方') {
|
||||||
|
$auxHerbs[] = $h;
|
||||||
|
} else {
|
||||||
|
$mainHerbs[] = $h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$mainHerbs, $auxHerbs];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatExportDosageNumber(float $dosage): string
|
||||||
|
{
|
||||||
|
if (floor($dosage) === $dosage) {
|
||||||
|
return (string) (int) $dosage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(rtrim(number_format($dosage, 4, '.', ''), '0'), '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用:主方/辅方开立天数(与详情侧栏「处方开立」同口径:主方取处方 usage_days,辅方取 aux_usage.usage_days)
|
||||||
|
* 订单服用天数单独导出在 export_medication_days 列,不在此混用。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $rx
|
||||||
|
* @param array<string, mixed>|null $auxUsage
|
||||||
|
*/
|
||||||
|
private static function resolveExportUsageDays(array $rx, ?array $auxUsage, bool $isAux): string
|
||||||
|
{
|
||||||
|
if ($isAux) {
|
||||||
|
$days = (int) ($auxUsage['usage_days'] ?? 0);
|
||||||
|
|
||||||
|
return $days > 0 ? (string) $days : '';
|
||||||
|
}
|
||||||
|
$days = (int) ($rx['usage_days'] ?? 0);
|
||||||
|
|
||||||
|
return $days > 0 ? (string) $days : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出列:挂号表渠道来源展示(与 AppointmentLists channel_source_desc 同字典口径)
|
* 导出列:挂号表渠道来源展示(与 AppointmentLists channel_source_desc 同字典口径)
|
||||||
*
|
*
|
||||||
@@ -3214,12 +3532,16 @@ class PrescriptionOrderLogic
|
|||||||
if ($createType === 'fubei') {
|
if ($createType === 'fubei') {
|
||||||
return '付呗';
|
return '付呗';
|
||||||
}
|
}
|
||||||
|
if ($createType === 'express_cod') {
|
||||||
|
return '快递代收';
|
||||||
|
}
|
||||||
$paymentMethod = trim((string) ($row['payment_method'] ?? ''));
|
$paymentMethod = trim((string) ($row['payment_method'] ?? ''));
|
||||||
$methodMap = [
|
$methodMap = [
|
||||||
'alipay' => '支付宝',
|
'alipay' => '支付宝',
|
||||||
'wechat' => '微信',
|
'wechat' => '微信',
|
||||||
'wechat_work' => '企业微信',
|
'wechat_work' => '企业微信',
|
||||||
'fubei' => '付呗',
|
'fubei' => '付呗',
|
||||||
|
'express_cod' => '快递代收',
|
||||||
'manual' => '手动确认到账',
|
'manual' => '手动确认到账',
|
||||||
];
|
];
|
||||||
if ($paymentMethod !== '' && isset($methodMap[$paymentMethod])) {
|
if ($paymentMethod !== '' && isset($methodMap[$paymentMethod])) {
|
||||||
@@ -3399,7 +3721,12 @@ class PrescriptionOrderLogic
|
|||||||
$rxById = [];
|
$rxById = [];
|
||||||
if ($rxIdList !== []) {
|
if ($rxIdList !== []) {
|
||||||
$rxRows = Prescription::whereIn('id', $rxIdList)->whereNull('delete_time')
|
$rxRows = Prescription::whereIn('id', $rxIdList)->whereNull('delete_time')
|
||||||
->field(['id', 'prescription_type', 'need_decoction', 'dose_unit', 'assistant_id', 'appointment_id', 'prescription_name', 'aux_usage', 'herbs', 'creator_id'])
|
->field([
|
||||||
|
'id', 'prescription_type', 'need_decoction', 'dose_unit', 'assistant_id', 'appointment_id',
|
||||||
|
'prescription_name', 'aux_usage', 'herbs', 'creator_id',
|
||||||
|
'dosage_amount', 'dosage_unit', 'dosage_bag_count', 'times_per_day', 'usage_days',
|
||||||
|
'usage_way', 'usage_time',
|
||||||
|
])
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
foreach ($rxRows as $xr) {
|
foreach ($rxRows as $xr) {
|
||||||
@@ -3633,6 +3960,38 @@ class PrescriptionOrderLogic
|
|||||||
}
|
}
|
||||||
$item['export_prescription_name'] = implode(' ', $rxNameParts);
|
$item['export_prescription_name'] = implode(' ', $rxNameParts);
|
||||||
|
|
||||||
|
$rxArr = \is_array($rx) ? $rx : [];
|
||||||
|
$rxType = trim((string) ($rxArr['prescription_type'] ?? '')) ?: '浓缩水丸';
|
||||||
|
$item['export_prescription_herbs'] = self::formatPrescriptionHerbsForExport($rxArr);
|
||||||
|
$item['export_main_usage'] = $rxArr !== []
|
||||||
|
? self::formatUsageSegmentForExport($rxArr, $rxType)
|
||||||
|
: '';
|
||||||
|
[, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rxArr);
|
||||||
|
$auxUsageNorm = $auxHerbs !== []
|
||||||
|
? self::normalizeAuxUsageForExport($rxArr['aux_usage'] ?? null, $rxType)
|
||||||
|
: null;
|
||||||
|
if ($auxHerbs !== [] && $auxUsageNorm !== null) {
|
||||||
|
$item['export_aux_usage'] = self::formatUsageSegmentForExport(
|
||||||
|
[
|
||||||
|
'dosage_amount' => $auxUsageNorm['dosage_amount'],
|
||||||
|
'dosage_bag_count' => $auxUsageNorm['dosage_bag_count'],
|
||||||
|
'times_per_day' => $auxUsageNorm['times_per_day'],
|
||||||
|
'usage_dosage_unit' => $rxArr['dosage_unit'] ?? '',
|
||||||
|
'usage_way' => $rxArr['usage_way'] ?? '',
|
||||||
|
'usage_time' => $rxArr['usage_time'] ?? '',
|
||||||
|
],
|
||||||
|
$rxType,
|
||||||
|
(string) ($rxArr['usage_way'] ?? ''),
|
||||||
|
(string) ($rxArr['usage_time'] ?? '')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$item['export_aux_usage'] = '';
|
||||||
|
}
|
||||||
|
$item['export_main_usage_days'] = self::resolveExportUsageDays($rxArr, $auxUsageNorm, false);
|
||||||
|
$item['export_aux_usage_days'] = $auxHerbs !== []
|
||||||
|
? self::resolveExportUsageDays($rxArr, $auxUsageNorm, true)
|
||||||
|
: '';
|
||||||
|
|
||||||
$item['export_service_package'] = self::formatServicePackageForExport(
|
$item['export_service_package'] = self::formatServicePackageForExport(
|
||||||
$item['service_package'] ?? '',
|
$item['service_package'] ?? '',
|
||||||
$packageNameByValue
|
$packageNameByValue
|
||||||
@@ -3803,27 +4162,7 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
$doctorId = (int) ($rx['creator_id'] ?? 0);
|
$doctorId = (int) ($rx['creator_id'] ?? 0);
|
||||||
|
|
||||||
$herbs = $rx['herbs'] ?? null;
|
[$mainHerbs, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rx);
|
||||||
if (\is_string($herbs) && $herbs !== '') {
|
|
||||||
$decoded = json_decode($herbs, true);
|
|
||||||
$herbs = \is_array($decoded) ? $decoded : [];
|
|
||||||
}
|
|
||||||
if (!\is_array($herbs)) {
|
|
||||||
$herbs = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$mainHerbs = [];
|
|
||||||
$auxHerbs = [];
|
|
||||||
foreach ($herbs as $h) {
|
|
||||||
if (!\is_array($h)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (((string) ($h['formula_type'] ?? '')) === '辅方') {
|
|
||||||
$auxHerbs[] = $h;
|
|
||||||
} else {
|
|
||||||
$mainHerbs[] = $h;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$lookup = static function (string $ft, array $hs) use ($doctorId, $libByDoctor, $libPublic): string {
|
$lookup = static function (string $ft, array $hs) use ($doctorId, $libByDoctor, $libPublic): string {
|
||||||
if ($hs === []) {
|
if ($hs === []) {
|
||||||
@@ -4405,6 +4744,112 @@ class PrescriptionOrderLogic
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量将处方业务订单「创建人(医助归属)」改派给其他医助。
|
||||||
|
* 仅改写 creator_id(业务归属/医助筛选口径),逐单记操作日志;与支付单批量改派口径一致。
|
||||||
|
*
|
||||||
|
* @param int[] $orderIds
|
||||||
|
* @param int $assistantAdminId 目标医助 admin_id(须含医助角色)
|
||||||
|
* @return array{success:int, fail:int, errors:string[], per_log:array<int,array{order_id:int,summary:string}>}|false
|
||||||
|
*/
|
||||||
|
public static function batchAssignAssistant(array $orderIds, int $assistantAdminId, int $adminId, array $adminInfo)
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
$assistantAdminId = (int) $assistantAdminId;
|
||||||
|
if ($assistantAdminId <= 0) {
|
||||||
|
self::$error = '请选择医助';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标须为医助角色(与支付单批量改派一致)
|
||||||
|
$assistantRoleId = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
|
||||||
|
if (
|
||||||
|
\app\common\model\auth\AdminRole::where('admin_id', $assistantAdminId)
|
||||||
|
->where('role_id', $assistantRoleId)
|
||||||
|
->count() === 0
|
||||||
|
) {
|
||||||
|
self::$error = '目标账号不是医助角色';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_values(array_unique(array_filter(
|
||||||
|
array_map('intval', $orderIds),
|
||||||
|
static fn (int $id) => $id > 0
|
||||||
|
)));
|
||||||
|
if ($ids === []) {
|
||||||
|
self::$error = '请选择订单';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (count($ids) > 200) {
|
||||||
|
self::$error = '单次最多改派200单';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetName = (string) Admin::where('id', $assistantAdminId)->whereNull('delete_time')->value('name');
|
||||||
|
if ($targetName === '') {
|
||||||
|
$targetName = (string) $assistantAdminId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$perLog = [];
|
||||||
|
$errors = [];
|
||||||
|
$success = 0;
|
||||||
|
foreach ($ids as $oid) {
|
||||||
|
$order = PrescriptionOrder::where('id', $oid)->whereNull('delete_time')->find();
|
||||||
|
if (! $order) {
|
||||||
|
$errors[] = "订单{$oid}不存在或已删除";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$label = (string) ($order->order_no ?: $oid);
|
||||||
|
if (! self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||||
|
$errors[] = "订单{$label}无操作权限";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$oldCreatorId = (int) $order->creator_id;
|
||||||
|
if ($oldCreatorId === $assistantAdminId) {
|
||||||
|
$errors[] = "订单{$label}创建人已是该医助,已跳过";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$oldName = $oldCreatorId > 0
|
||||||
|
? (string) Admin::where('id', $oldCreatorId)->whereNull('delete_time')->value('name')
|
||||||
|
: '';
|
||||||
|
if ($oldName === '' && $oldCreatorId > 0) {
|
||||||
|
$oldName = (string) $oldCreatorId;
|
||||||
|
} elseif ($oldName === '') {
|
||||||
|
$oldName = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->creator_id = $assistantAdminId;
|
||||||
|
try {
|
||||||
|
$order->save();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors[] = "订单{$label}保存失败";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = '创建人(医助)由「' . $oldName . '」改派为「' . $targetName . '」';
|
||||||
|
self::writeLog($oid, $adminId, $adminInfo, 'assign_assistant', $summary);
|
||||||
|
$perLog[] = ['order_id' => $oid, 'summary' => $summary];
|
||||||
|
$success++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($success === 0) {
|
||||||
|
self::$error = $errors !== [] ? implode(';', $errors) : '未能改派任何订单';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $success,
|
||||||
|
'fail' => count($errors),
|
||||||
|
'errors' => $errors,
|
||||||
|
'per_log' => $perLog,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
|
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
|
||||||
{
|
{
|
||||||
$adminName = $adminInfo['name'] ?? '';
|
$adminName = $adminInfo['name'] ?? '';
|
||||||
@@ -4436,4 +4881,175 @@ class PrescriptionOrderLogic
|
|||||||
{
|
{
|
||||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_patient', $summary);
|
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_patient', $summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务订单详情场景:更新主方/辅方服用次数与开立天数,以及订单服用天数
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
*/
|
||||||
|
public static function patchPrescriptionUsage(array $params, int $adminId, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
$prescriptionOrderId = (int) ($params['id'] ?? 0);
|
||||||
|
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
|
||||||
|
if (!$order) {
|
||||||
|
self::setError('订单不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||||
|
self::setError('无权限操作');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((int) $order->fulfillment_status === 4) {
|
||||||
|
self::setError('已取消的订单不可修改');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$rxId = (int) ($order->prescription_id ?? 0);
|
||||||
|
if ($rxId <= 0) {
|
||||||
|
self::setError('该订单未关联处方');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
|
||||||
|
if (!$rx) {
|
||||||
|
self::setError('处方不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!PrescriptionLogic::canViewPrescription($rx, $adminId, $adminInfo)) {
|
||||||
|
self::setError('无权限修改此处方');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
|
||||||
|
$usageDays = (int) ($params['usage_days'] ?? 0);
|
||||||
|
$medDays = (int) ($params['medication_days'] ?? 0);
|
||||||
|
if ($timesPerDay < 1 || $timesPerDay > 6) {
|
||||||
|
self::setError('主方每天次数须在 1~6 之间');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($usageDays < 1 || $usageDays > 999) {
|
||||||
|
self::setError('主方开立天数须在 1~999 之间');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($medDays < 1 || $medDays > 999) {
|
||||||
|
self::setError('订单服用天数须在 1~999 之间');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasAux = self::prescriptionHasAuxFormula($rx);
|
||||||
|
$auxTimesPerDay = null;
|
||||||
|
$auxUsageDays = null;
|
||||||
|
if ($hasAux) {
|
||||||
|
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
|
||||||
|
self::setError('含辅方处方须填写辅方服用参数');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$auxTimesPerDay = (int) $params['aux_times_per_day'];
|
||||||
|
$auxUsageDays = (int) $params['aux_usage_days'];
|
||||||
|
if ($auxTimesPerDay < 1 || $auxTimesPerDay > 6) {
|
||||||
|
self::setError('辅方每天次数须在 1~6 之间');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
|
||||||
|
self::setError('辅方开立天数须在 1~999 之间');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldTimes = (int) ($rx->times_per_day ?? 0);
|
||||||
|
$oldUsageDays = (int) ($rx->usage_days ?? 0);
|
||||||
|
$oldMedDays = (int) ($order->medication_days ?? 0);
|
||||||
|
$oldAuxUsage = $rx->aux_usage;
|
||||||
|
if (is_string($oldAuxUsage)) {
|
||||||
|
$decoded = json_decode($oldAuxUsage, true);
|
||||||
|
$oldAuxUsage = is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
if (!is_array($oldAuxUsage)) {
|
||||||
|
$oldAuxUsage = [];
|
||||||
|
}
|
||||||
|
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
|
||||||
|
$oldAuxUsageDays = (int) ($oldAuxUsage['usage_days'] ?? 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rxUpdates = [
|
||||||
|
'times_per_day' => $timesPerDay,
|
||||||
|
'usage_days' => $usageDays,
|
||||||
|
];
|
||||||
|
if ($hasAux) {
|
||||||
|
$auxUsage = $oldAuxUsage;
|
||||||
|
$auxUsage['times_per_day'] = $auxTimesPerDay;
|
||||||
|
$auxUsage['usage_days'] = $auxUsageDays;
|
||||||
|
$rxUpdates['aux_usage'] = $auxUsage;
|
||||||
|
}
|
||||||
|
$rx->save($rxUpdates);
|
||||||
|
|
||||||
|
$order->medication_days = $medDays;
|
||||||
|
$order->save();
|
||||||
|
|
||||||
|
$parts = [
|
||||||
|
sprintf(
|
||||||
|
'主方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||||
|
$oldTimes > 0 ? $oldTimes : 0,
|
||||||
|
$oldUsageDays > 0 ? $oldUsageDays : 0,
|
||||||
|
$timesPerDay,
|
||||||
|
$usageDays
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if ($hasAux) {
|
||||||
|
$parts[] = sprintf(
|
||||||
|
'辅方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||||
|
$oldAuxTimes > 0 ? $oldAuxTimes : 0,
|
||||||
|
$oldAuxUsageDays > 0 ? $oldAuxUsageDays : 0,
|
||||||
|
$auxTimesPerDay,
|
||||||
|
$auxUsageDays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$parts[] = sprintf(
|
||||||
|
'订单设置 %d天 → %d天',
|
||||||
|
$oldMedDays > 0 ? $oldMedDays : 0,
|
||||||
|
$medDays
|
||||||
|
);
|
||||||
|
$summary = '服用参数:' . implode(';', $parts);
|
||||||
|
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处方是否含辅方药材(与列表/详情 has_aux_formula 口径一致)
|
||||||
|
*/
|
||||||
|
private static function prescriptionHasAuxFormula(Prescription $rx): bool
|
||||||
|
{
|
||||||
|
$herbs = $rx->herbs;
|
||||||
|
if (is_string($herbs)) {
|
||||||
|
$decoded = json_decode($herbs, true);
|
||||||
|
$herbs = is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
if (!is_array($herbs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ($herbs as $h) {
|
||||||
|
if (is_array($h) && (string) ($h['formula_type'] ?? '') === '辅方') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'gender' => 'require|in:0,1',
|
'gender' => 'require|in:0,1',
|
||||||
'age' => 'require|number|between:0,150',
|
'age' => 'require|number|between:0,150',
|
||||||
'diagnosis_type' => 'require',
|
'diagnosis_type' => 'require',
|
||||||
|
'local_hospital_name' => 'require|max:255',
|
||||||
'status' => 'in:0,1',
|
'status' => 'in:0,1',
|
||||||
'show_card' => 'in:0,1',
|
'show_card' => 'in:0,1',
|
||||||
'create_source' => 'max:32',
|
'create_source' => 'max:32',
|
||||||
@@ -43,6 +44,7 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'end_date' => 'date|checkDateRange',
|
'end_date' => 'date|checkDateRange',
|
||||||
'diagnosis_id' => 'require|integer|checkDiagnosisId',
|
'diagnosis_id' => 'require|integer|checkDiagnosisId',
|
||||||
'tracking_content' => 'require|length:1,1000',
|
'tracking_content' => 'require|length:1,1000',
|
||||||
|
'revisit_slot_start_offset' => 'integer|between:0,20',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $message = [
|
protected $message = [
|
||||||
@@ -50,6 +52,7 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'patient_name.require' => '请输入患者姓名',
|
'patient_name.require' => '请输入患者姓名',
|
||||||
'patient_name.length' => '患者姓名长度须在1-50位字符',
|
'patient_name.length' => '患者姓名长度须在1-50位字符',
|
||||||
'id_card.length' => '身份证号长度不正确',
|
'id_card.length' => '身份证号长度不正确',
|
||||||
|
'id_card.require' => '请输入身份证号',
|
||||||
'phone.require' => '请输入手机号',
|
'phone.require' => '请输入手机号',
|
||||||
'phone.mobile' => '手机号格式不正确',
|
'phone.mobile' => '手机号格式不正确',
|
||||||
'gender.require' => '请选择性别',
|
'gender.require' => '请选择性别',
|
||||||
@@ -58,6 +61,8 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'age.number' => '年龄必须为数字',
|
'age.number' => '年龄必须为数字',
|
||||||
'age.between' => '年龄范围0-150',
|
'age.between' => '年龄范围0-150',
|
||||||
'diagnosis_type.require' => '请选择诊断类型',
|
'diagnosis_type.require' => '请选择诊断类型',
|
||||||
|
'local_hospital_name.require' => '请输入当地就诊医院名称',
|
||||||
|
'local_hospital_name.max' => '当地就诊医院名称最多255个字符',
|
||||||
'status.in' => '状态参数错误',
|
'status.in' => '状态参数错误',
|
||||||
'create_source.max' => '渠道来源长度不能超过32个字符',
|
'create_source.max' => '渠道来源长度不能超过32个字符',
|
||||||
'current_medications.max' => '在用药物最多2000个字符',
|
'current_medications.max' => '在用药物最多2000个字符',
|
||||||
@@ -135,6 +140,13 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
return $this->only(['id']);
|
return $this->only(['id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 业务订单 tab:设置复诊接诊率统计起始偏移 */
|
||||||
|
public function sceneSetRevisitSlotStartOffset()
|
||||||
|
{
|
||||||
|
return $this->only(['id', 'revisit_slot_start_offset'])
|
||||||
|
->append('revisit_slot_start_offset', 'require|integer|between:0,20');
|
||||||
|
}
|
||||||
|
|
||||||
protected function checkDiagnosis($value)
|
protected function checkDiagnosis($value)
|
||||||
{
|
{
|
||||||
$diagnosis = Diagnosis::findOrEmpty($value);
|
$diagnosis = Diagnosis::findOrEmpty($value);
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'remark_assistant' => 'max:500',
|
'remark_assistant' => 'max:500',
|
||||||
'action' => 'require|in:approve,reject',
|
'action' => 'require|in:approve,reject',
|
||||||
'remark' => 'max:500',
|
'remark' => 'max:500',
|
||||||
|
'summary' => 'require|max:500',
|
||||||
|
'prescription_audit_status' => 'in:0,1,2',
|
||||||
|
'payment_slip_audit_status' => 'in:0,1,2',
|
||||||
|
'prescription_audit_remark' => 'max:500',
|
||||||
|
'payment_slip_audit_remark' => 'max:500',
|
||||||
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
|
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
|
||||||
'reason' => 'require|max:500',
|
'reason' => 'require|max:500',
|
||||||
'refund_amount' => 'float|egt:0',
|
'refund_amount' => 'float|egt:0',
|
||||||
@@ -74,6 +79,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'withdraw' => ['id'],
|
'withdraw' => ['id'],
|
||||||
'ship' => ['id', 'tracking_number', 'express_company', 'ship_mode'],
|
'ship' => ['id', 'tracking_number', 'express_company', 'ship_mode'],
|
||||||
'logs' => ['id'],
|
'logs' => ['id'],
|
||||||
|
'addLog' => ['id', 'summary'],
|
||||||
'paidPayOrders' => ['diagnosis_id'],
|
'paidPayOrders' => ['diagnosis_id'],
|
||||||
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
||||||
'linkPayOrder' => ['id', 'pay_order_id'],
|
'linkPayOrder' => ['id', 'pay_order_id'],
|
||||||
@@ -83,6 +89,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'submitGancaoRecipel' => ['id'],
|
'submitGancaoRecipel' => ['id'],
|
||||||
'previewGancaoRecipel' => ['id'],
|
'previewGancaoRecipel' => ['id'],
|
||||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||||
|
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
||||||
'updateAmount' => ['id', 'amount'],
|
'updateAmount' => ['id', 'amount'],
|
||||||
'setShipMode' => ['id', 'ship_mode'],
|
'setShipMode' => ['id', 'ship_mode'],
|
||||||
];
|
];
|
||||||
@@ -91,6 +98,17 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
{
|
{
|
||||||
return $this->only(['id', 'amount'])
|
return $this->only(['id', 'amount'])
|
||||||
->append('id', 'require|integer|gt:0')
|
->append('id', 'require|integer|gt:0')
|
||||||
->append('amount', 'require|float|gt:0');
|
->append('amount', 'require|float|egt:0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patchPrescriptionUsage(): PrescriptionOrderValidate
|
||||||
|
{
|
||||||
|
return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'])
|
||||||
|
->append('id', 'require|integer|gt:0')
|
||||||
|
->append('times_per_day', 'require|integer|between:1,6')
|
||||||
|
->append('usage_days', 'require|integer|between:1,999')
|
||||||
|
->append('medication_days', 'require|integer|between:1,999')
|
||||||
|
->append('aux_times_per_day', 'integer|between:1,6')
|
||||||
|
->append('aux_usage_days', 'integer|between:1,999');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use app\api\logic\tcm\DailyFamilyLikeLogic;
|
|||||||
use app\api\logic\tcm\DailyShareLogic;
|
use app\api\logic\tcm\DailyShareLogic;
|
||||||
use app\api\logic\tcm\DailyPhoneLogic;
|
use app\api\logic\tcm\DailyPhoneLogic;
|
||||||
use app\api\logic\tcm\DailyDietAiLogic;
|
use app\api\logic\tcm\DailyDietAiLogic;
|
||||||
|
use app\api\logic\tcm\GamePlatformLogic;
|
||||||
use app\adminapi\logic\ConfigLogic;
|
use app\adminapi\logic\ConfigLogic;
|
||||||
use app\common\model\tcm\Diagnosis;
|
use app\common\model\tcm\Diagnosis;
|
||||||
use app\common\model\tcm\BloodRecord;
|
use app\common\model\tcm\BloodRecord;
|
||||||
@@ -40,6 +41,54 @@ class TcmController extends BaseApiController
|
|||||||
*/
|
*/
|
||||||
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
|
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
|
||||||
|
|
||||||
|
/** 获取当前登录用户的控糖消消乐每周7人同行榜。 */
|
||||||
|
public function gameWeeklyLeaderboard()
|
||||||
|
{
|
||||||
|
$result = GamePlatformLogic::leaderboard((int) $this->userId);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(GamePlatformLogic::getError());
|
||||||
|
}
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 幂等上报一局的认糖进度和分数。 */
|
||||||
|
public function gameSubmitProgress()
|
||||||
|
{
|
||||||
|
$result = GamePlatformLogic::submitProgress((int) $this->userId, [
|
||||||
|
'session_key' => (string) $this->request->post('session_key', ''),
|
||||||
|
'learned_count' => (int) $this->request->post('learned_count', 0),
|
||||||
|
'score' => (int) $this->request->post('score', 0),
|
||||||
|
'ended' => (int) $this->request->post('ended', 0),
|
||||||
|
]);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(GamePlatformLogic::getError());
|
||||||
|
}
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录发起分享,返回本周不包含用户ID的分享码。 */
|
||||||
|
public function gameRecordShare()
|
||||||
|
{
|
||||||
|
$result = GamePlatformLogic::recordShare((int) $this->userId);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(GamePlatformLogic::getError());
|
||||||
|
}
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录用户从微信分享卡片进入时,记录一次轻量同行关系。 */
|
||||||
|
public function gameAcceptShare()
|
||||||
|
{
|
||||||
|
$result = GamePlatformLogic::acceptShare(
|
||||||
|
(int) $this->userId,
|
||||||
|
(string) $this->request->post('invite_code', '')
|
||||||
|
);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(GamePlatformLogic::getError());
|
||||||
|
}
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 获取患者签名(供小程序调用)
|
* @notes 获取患者签名(供小程序调用)
|
||||||
* @return \think\response\Json
|
* @return \think\response\Json
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\api\logic\tcm;
|
||||||
|
|
||||||
|
use app\common\service\FileService;
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控糖消消乐平台能力:真实用户、每周7人同行榜、幂等成绩上报和微信分享。
|
||||||
|
*/
|
||||||
|
class GamePlatformLogic
|
||||||
|
{
|
||||||
|
private const GROUP_SIZE = 7;
|
||||||
|
private const MAX_SESSION_LEARNED = 20000;
|
||||||
|
private const MAX_SCORE = 100000000;
|
||||||
|
|
||||||
|
protected static string $error = '';
|
||||||
|
|
||||||
|
public static function getError(): string
|
||||||
|
{
|
||||||
|
return self::$error;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function fail(string $message): bool
|
||||||
|
{
|
||||||
|
self::$error = $message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户所在的真实周榜;首次进入会分配到当周同性别7人组。
|
||||||
|
*/
|
||||||
|
public static function leaderboard(int $userId): array|false
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return self::fail('请先登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Db::startTrans();
|
||||||
|
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||||||
|
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||||||
|
Db::commit();
|
||||||
|
return self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
self::logException('leaderboard', $e);
|
||||||
|
return self::fail('同行榜暂时不可用,请稍后再试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报每局绝对进度。session_key + learned_count 共同保证网络重试不会重复计分。
|
||||||
|
*
|
||||||
|
* @param array{session_key:string,learned_count:int,score:int,ended:int|bool} $params
|
||||||
|
*/
|
||||||
|
public static function submitProgress(int $userId, array $params): array|false
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return self::fail('请先登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionKey = trim((string) ($params['session_key'] ?? ''));
|
||||||
|
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
|
||||||
|
return self::fail('游戏局标识无效');
|
||||||
|
}
|
||||||
|
$learned = max(0, min(self::MAX_SESSION_LEARNED, (int) ($params['learned_count'] ?? 0)));
|
||||||
|
$scoreValue = max(0, min(self::MAX_SCORE, (int) ($params['score'] ?? 0)));
|
||||||
|
$ended = !empty($params['ended']) ? 1 : 0;
|
||||||
|
$weekStart = self::weekStart();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Db::startTrans();
|
||||||
|
$weeklyScore = self::ensureWeeklyScore($userId, $weekStart);
|
||||||
|
$session = Db::name('tcm_game_session')
|
||||||
|
->where('session_key', $sessionKey)
|
||||||
|
->lock(true)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
if ($session && (int) $session['user_id'] !== $userId) {
|
||||||
|
Db::rollback();
|
||||||
|
return self::fail('游戏局标识已被使用');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$session) {
|
||||||
|
$sessionId = Db::name('tcm_game_session')->insertGetId([
|
||||||
|
'session_key' => $sessionKey,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'week_start' => $weekStart,
|
||||||
|
'learned_count' => 0,
|
||||||
|
'last_score' => 0,
|
||||||
|
'ended' => 0,
|
||||||
|
'create_time' => $now,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
$session = [
|
||||||
|
'id' => $sessionId,
|
||||||
|
'week_start' => $weekStart,
|
||||||
|
'learned_count' => 0,
|
||||||
|
'last_score' => 0,
|
||||||
|
'ended' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionWeek = (string) $session['week_start'];
|
||||||
|
if ($sessionWeek !== $weekStart) {
|
||||||
|
$weeklyScore = self::ensureWeeklyScore($userId, $sessionWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
$confirmedLearned = (int) $session['learned_count'];
|
||||||
|
$nextLearned = max($confirmedLearned, $learned);
|
||||||
|
$delta = $nextLearned - $confirmedLearned;
|
||||||
|
$wasEnded = (int) $session['ended'] === 1;
|
||||||
|
$markEnded = $wasEnded || $ended === 1;
|
||||||
|
|
||||||
|
Db::name('tcm_game_session')->where('id', (int) $session['id'])->update([
|
||||||
|
'learned_count' => $nextLearned,
|
||||||
|
'last_score' => max((int) $session['last_score'], $scoreValue),
|
||||||
|
'ended' => $markEnded ? 1 : 0,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$weeklyLearned = (int) $weeklyScore['learned_count'] + $delta;
|
||||||
|
$weeklyBest = max((int) $weeklyScore['best_score'], $scoreValue);
|
||||||
|
$gamesPlayed = (int) $weeklyScore['games_played'] + (!$wasEnded && $ended === 1 ? 1 : 0);
|
||||||
|
Db::name('tcm_game_weekly_score')->where('id', (int) $weeklyScore['id'])->update([
|
||||||
|
'learned_count' => $weeklyLearned,
|
||||||
|
'best_score' => $weeklyBest,
|
||||||
|
'games_played' => $gamesPlayed,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inviteCode = self::ensureShareInvite($userId, $sessionWeek);
|
||||||
|
Db::commit();
|
||||||
|
|
||||||
|
$result = self::buildLeaderboard((int) $weeklyScore['group_id'], $userId, $inviteCode, $sessionWeek);
|
||||||
|
$result['confirmed_session_learned'] = $nextLearned;
|
||||||
|
return $result;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
self::logException('submitProgress', $e);
|
||||||
|
return self::fail('成绩保存失败,请稍后再试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录用户发起一次微信分享,并返回当前分享码。 */
|
||||||
|
public static function recordShare(int $userId): array|false
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return self::fail('请先登录');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Db::startTrans();
|
||||||
|
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||||||
|
Db::name('tcm_game_weekly_score')->where('id', (int) $score['id'])->inc('share_count')->update([
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||||||
|
Db::commit();
|
||||||
|
return ['invite_code' => $inviteCode];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
self::logException('recordShare', $e);
|
||||||
|
return self::fail('分享记录失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录从分享卡片进入。仅记录一次轻量同行关系,不做家庭/好友强绑定。
|
||||||
|
*/
|
||||||
|
public static function acceptShare(int $userId, string $inviteCode): array|false
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
$inviteCode = strtoupper(trim($inviteCode));
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return self::fail('请先登录');
|
||||||
|
}
|
||||||
|
if (!preg_match('/^[A-F0-9]{12}$/', $inviteCode)) {
|
||||||
|
return self::fail('分享码无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$invite = Db::name('tcm_game_share_invite')->where('invite_code', $inviteCode)->find();
|
||||||
|
if (!$invite) {
|
||||||
|
return self::fail('分享已失效');
|
||||||
|
}
|
||||||
|
$inviterUserId = (int) $invite['user_id'];
|
||||||
|
if ($inviterUserId === $userId) {
|
||||||
|
return ['accepted' => false, 'message' => '这是您自己的分享'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
$inserted = Db::name('tcm_game_share_visit')->duplicate([
|
||||||
|
'invite_code',
|
||||||
|
])->insert([
|
||||||
|
'invite_code' => $inviteCode,
|
||||||
|
'inviter_user_id' => $inviterUserId,
|
||||||
|
'visitor_user_id' => $userId,
|
||||||
|
'create_time' => time(),
|
||||||
|
]);
|
||||||
|
$accepted = $inserted === 1;
|
||||||
|
if ($accepted) {
|
||||||
|
Db::name('tcm_game_share_invite')->where('id', (int) $invite['id'])->inc('open_count')->update([
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
|
||||||
|
$inviter = Db::name('user')->where('id', $inviterUserId)->field('nickname')->find();
|
||||||
|
return [
|
||||||
|
'accepted' => $accepted,
|
||||||
|
'message' => '已加入控糖消消乐',
|
||||||
|
'inviter' => self::displayName((string) ($inviter['nickname'] ?? ''), $inviterUserId),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
self::logException('acceptShare', $e);
|
||||||
|
return self::fail('分享关系记录失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureWeeklyScore(int $userId, string $weekStart): array
|
||||||
|
{
|
||||||
|
$profile = self::userProfile($userId);
|
||||||
|
$now = time();
|
||||||
|
$existing = Db::name('tcm_game_weekly_score')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existing = Db::name('tcm_game_weekly_score')
|
||||||
|
->where('id', (int) $existing['id'])
|
||||||
|
->lock(true)
|
||||||
|
->find();
|
||||||
|
$profileChanged = (string) $existing['nickname'] !== $profile['nickname']
|
||||||
|
|| (string) $existing['avatar'] !== $profile['avatar'];
|
||||||
|
if ($profileChanged) {
|
||||||
|
Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([
|
||||||
|
'nickname' => $profile['nickname'],
|
||||||
|
'avatar' => $profile['avatar'],
|
||||||
|
'update_time'=> $now,
|
||||||
|
]);
|
||||||
|
$existing['nickname'] = $profile['nickname'];
|
||||||
|
$existing['avatar'] = $profile['avatar'];
|
||||||
|
}
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每周、每个性别使用一行分配锁串行化首次入组。直接 upsert 锁行,
|
||||||
|
// 避免空分组上的间隙锁导致首批并发请求互相等待或偶发死锁。
|
||||||
|
Db::name('tcm_game_weekly_allocator')->duplicate([
|
||||||
|
'update_time',
|
||||||
|
])->insert([
|
||||||
|
'week_start' => $weekStart,
|
||||||
|
'sex' => $profile['sex'],
|
||||||
|
'create_time'=> $now,
|
||||||
|
'update_time'=> $now,
|
||||||
|
]);
|
||||||
|
$allocator = Db::name('tcm_game_weekly_allocator')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('sex', $profile['sex'])
|
||||||
|
->lock(true)
|
||||||
|
->find();
|
||||||
|
if (!$allocator) {
|
||||||
|
throw new \RuntimeException('同行分配锁创建失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待分配锁期间,同一用户的另一个请求可能已经完成分配。
|
||||||
|
$existing = Db::name('tcm_game_weekly_score')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->lock(true)
|
||||||
|
->find();
|
||||||
|
if ($existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$group = Db::name('tcm_game_weekly_group')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('sex', $profile['sex'])
|
||||||
|
->where('member_count', '<', self::GROUP_SIZE)
|
||||||
|
->order('group_no', 'asc')
|
||||||
|
->lock(true)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (!$group) {
|
||||||
|
$maxGroupNo = (int) Db::name('tcm_game_weekly_group')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('sex', $profile['sex'])
|
||||||
|
->max('group_no');
|
||||||
|
$groupNo = $maxGroupNo + 1;
|
||||||
|
// 首批用户并发进入时可能同时算出相同 group_no。利用唯一键 upsert,
|
||||||
|
// 让请求汇合到同一组,再锁定该组继续分配,避免偶发 1062/死锁。
|
||||||
|
Db::name('tcm_game_weekly_group')->duplicate([
|
||||||
|
'update_time',
|
||||||
|
])->insert([
|
||||||
|
'week_start' => $weekStart,
|
||||||
|
'sex' => $profile['sex'],
|
||||||
|
'group_no' => $groupNo,
|
||||||
|
'member_count'=> 0,
|
||||||
|
'create_time' => $now,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
$group = Db::name('tcm_game_weekly_group')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('sex', $profile['sex'])
|
||||||
|
->where('group_no', $groupNo)
|
||||||
|
->lock(true)
|
||||||
|
->find();
|
||||||
|
if (!$group) {
|
||||||
|
throw new \RuntimeException('同行分组创建失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoreRow = [
|
||||||
|
'group_id' => (int) $group['id'],
|
||||||
|
'week_start' => $weekStart,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'learned_count' => 0,
|
||||||
|
'best_score' => 0,
|
||||||
|
'games_played' => 0,
|
||||||
|
'share_count' => 0,
|
||||||
|
'nickname' => $profile['nickname'],
|
||||||
|
'avatar' => $profile['avatar'],
|
||||||
|
'sex' => $profile['sex'],
|
||||||
|
'create_time' => $now,
|
||||||
|
'update_time' => $now,
|
||||||
|
];
|
||||||
|
$inserted = Db::name('tcm_game_weekly_score')->duplicate([
|
||||||
|
'nickname',
|
||||||
|
'avatar',
|
||||||
|
'sex',
|
||||||
|
'update_time',
|
||||||
|
])->insert($scoreRow);
|
||||||
|
$score = Db::name('tcm_game_weekly_score')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->lock(true)
|
||||||
|
->find();
|
||||||
|
if (!$score) {
|
||||||
|
throw new \RuntimeException('同行成绩创建失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅新插入时刷新人数;使用实际成绩行数纠正历史并发造成的计数漂移。
|
||||||
|
if ($inserted === 1) {
|
||||||
|
$memberCount = (int) Db::name('tcm_game_weekly_score')
|
||||||
|
->where('group_id', (int) $group['id'])
|
||||||
|
->count();
|
||||||
|
Db::name('tcm_game_weekly_group')->where('id', (int) $group['id'])->update([
|
||||||
|
'member_count' => min(self::GROUP_SIZE, $memberCount),
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildLeaderboard(
|
||||||
|
int $groupId,
|
||||||
|
int $userId,
|
||||||
|
string $inviteCode,
|
||||||
|
?string $weekStart = null
|
||||||
|
): array {
|
||||||
|
$weekStart = $weekStart ?: self::weekStart();
|
||||||
|
$rows = Db::name('tcm_game_weekly_score')
|
||||||
|
->where('group_id', $groupId)
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->order('learned_count', 'desc')
|
||||||
|
->order('best_score', 'desc')
|
||||||
|
->order('create_time', 'asc')
|
||||||
|
->limit(self::GROUP_SIZE)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$players = [];
|
||||||
|
$myIndex = 0;
|
||||||
|
foreach ($rows as $index => $row) {
|
||||||
|
$isMe = (int) $row['user_id'] === $userId;
|
||||||
|
if ($isMe) {
|
||||||
|
$myIndex = $index;
|
||||||
|
}
|
||||||
|
$players[] = [
|
||||||
|
// 前端只需要稳定列表键,不暴露平台内部 user_id。
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'name' => self::displayName((string) $row['nickname'], (int) $row['user_id']),
|
||||||
|
'avatar' => self::avatarUrl((string) $row['avatar']),
|
||||||
|
'count' => (int) $row['learned_count'],
|
||||||
|
'best_score' => (int) $row['best_score'],
|
||||||
|
'rank' => $index + 1,
|
||||||
|
'is_me' => $isMe,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$me = $players[$myIndex] ?? [
|
||||||
|
'count' => 0,
|
||||||
|
'rank' => 1,
|
||||||
|
'best_score' => 0,
|
||||||
|
];
|
||||||
|
$distance = $myIndex > 0
|
||||||
|
? max(1, (int) $players[$myIndex - 1]['count'] - (int) $me['count'] + 1)
|
||||||
|
: 0;
|
||||||
|
$group = Db::name('tcm_game_weekly_group')->where('id', $groupId)->find();
|
||||||
|
$sex = (int) ($group['sex'] ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'week_start' => $weekStart,
|
||||||
|
'week_end' => date('Y-m-d', strtotime($weekStart . ' +6 days')),
|
||||||
|
'sex' => $sex,
|
||||||
|
'sex_label' => $sex === 1 ? '男士同行' : ($sex === 2 ? '女士同行' : '同行'),
|
||||||
|
'group_size' => self::GROUP_SIZE,
|
||||||
|
'member_count'=> count($players),
|
||||||
|
'players' => $players,
|
||||||
|
'me' => [
|
||||||
|
'count' => (int) ($me['count'] ?? 0),
|
||||||
|
'rank' => (int) ($me['rank'] ?? 1),
|
||||||
|
'best_score' => (int) ($me['best_score'] ?? 0),
|
||||||
|
'distance' => $distance,
|
||||||
|
'is_first' => $myIndex === 0,
|
||||||
|
],
|
||||||
|
'invite_code' => $inviteCode,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{nickname:string,avatar:string,sex:int} */
|
||||||
|
private static function userProfile(int $userId): array
|
||||||
|
{
|
||||||
|
$user = Db::name('user')
|
||||||
|
->where('id', $userId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->field('id,sn,nickname,avatar,sex')
|
||||||
|
->find();
|
||||||
|
if (!$user) {
|
||||||
|
throw new \RuntimeException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sex = (int) ($user['sex'] ?? 0);
|
||||||
|
if ($sex !== 1 && $sex !== 2) {
|
||||||
|
$gender = Db::name('diagnosis_view_records')->alias('v')
|
||||||
|
->join('tcm_diagnosis d', 'd.id = v.diagnosis_id')
|
||||||
|
->where('v.user_id', $userId)
|
||||||
|
->whereNull('v.delete_time')
|
||||||
|
->whereNull('d.delete_time')
|
||||||
|
->order('v.id', 'desc')
|
||||||
|
->value('d.gender');
|
||||||
|
if ($gender !== null && $gender !== '') {
|
||||||
|
$sex = (int) $gender === 1 ? 1 : 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nickname' => self::displayName((string) ($user['nickname'] ?? ''), $userId),
|
||||||
|
'avatar' => (string) ($user['avatar'] ?? ''),
|
||||||
|
'sex' => in_array($sex, [1, 2], true) ? $sex : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function displayName(string $nickname, int $userId): string
|
||||||
|
{
|
||||||
|
$nickname = trim(strip_tags($nickname));
|
||||||
|
if ($nickname === '') {
|
||||||
|
return '控糖好友' . substr((string) $userId, -2);
|
||||||
|
}
|
||||||
|
return mb_substr($nickname, 0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function avatarUrl(string $avatar): string
|
||||||
|
{
|
||||||
|
return $avatar === '' ? '' : FileService::getFileUrl($avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureShareInvite(int $userId, string $weekStart): string
|
||||||
|
{
|
||||||
|
$existing = Db::name('tcm_game_share_invite')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->find();
|
||||||
|
if ($existing) {
|
||||||
|
return (string) $existing['invite_code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||||
|
$code = strtoupper(bin2hex(random_bytes(6)));
|
||||||
|
$now = time();
|
||||||
|
// 同一用户并发打开榜单时,以 week_start + user_id 唯一键汇合;
|
||||||
|
// 极小概率随机码撞车时,查询不到本人的记录就继续生成新码。
|
||||||
|
Db::name('tcm_game_share_invite')->duplicate([
|
||||||
|
'update_time',
|
||||||
|
])->insert([
|
||||||
|
'invite_code' => $code,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'week_start' => $weekStart,
|
||||||
|
'open_count' => 0,
|
||||||
|
'create_time' => $now,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
$invite = Db::name('tcm_game_share_invite')
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->find();
|
||||||
|
if ($invite) {
|
||||||
|
return (string) $invite['invite_code'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new \RuntimeException('分享码生成失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function weekStart(?int $timestamp = null): string
|
||||||
|
{
|
||||||
|
$timestamp = $timestamp ?: time();
|
||||||
|
$day = (int) date('N', $timestamp);
|
||||||
|
return date('Y-m-d', strtotime('-' . ($day - 1) . ' days', $timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function logException(string $action, \Throwable $e): void
|
||||||
|
{
|
||||||
|
Log::error(sprintf(
|
||||||
|
'tcm endless game %s failed: %s at %s:%d',
|
||||||
|
$action,
|
||||||
|
$e->getMessage(),
|
||||||
|
$e->getFile(),
|
||||||
|
$e->getLine()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\command;
|
||||||
|
|
||||||
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\adminapi\logic\stats\RevisitRateLogic;
|
||||||
|
use think\console\Command;
|
||||||
|
use think\console\Input;
|
||||||
|
use think\console\input\Option;
|
||||||
|
use think\console\Output;
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待分配诊单自动指派(每天定时执行)
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* php think tcm:auto-assign-pending # 正式执行
|
||||||
|
* php think tcm:auto-assign-pending --dry-run # 只演练输出分配计划,不写库
|
||||||
|
*
|
||||||
|
* 推荐 cron(每天 09:00 执行一次;或经 zyt_dev_crontab 表调度):
|
||||||
|
* 0 9 * * * cd /path/to/server && php think tcm:auto-assign-pending >> /var/log/zyt-auto-assign.log 2>&1
|
||||||
|
*
|
||||||
|
* 分配规则(依据上个自然月「二诊复诊接诊率」,与 RevisitRateLogic::overview 口径完全一致):
|
||||||
|
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
|
||||||
|
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
|
||||||
|
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
|
||||||
|
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
|
||||||
|
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
|
||||||
|
*
|
||||||
|
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
|
||||||
|
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
|
||||||
|
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
|
||||||
|
* 第一轮 3+7+5=15 条;剩余 5 条进第二轮:>70% 再各 1 条(3 条),余 2 条给 60%~70% 档前 2 人。
|
||||||
|
*
|
||||||
|
* 日上限跨执行累计:同一天重复执行命令时,会先从 tcm_diagnosis_auto_assign_log 扣减当日已自动分配数,不会超额。
|
||||||
|
*
|
||||||
|
* 日志:无论分配与否,每条待指派诊单都会写入 tcm_diagnosis_auto_assign_log,记录原因(为什么分配 / 为什么不分配)。
|
||||||
|
* 成功分配同时写 tcm_diagnosis_assign_log(is_inherit=0,计入次月接诊率分母),与手动指派同口径。
|
||||||
|
* 写入时 from_assistant_id 尽量带上「一中心」原医助(指派日志中最近的一中心身份,否则回退业务单创建人若属一中心),避免原医助恒为空。
|
||||||
|
*/
|
||||||
|
class AutoAssignPendingDiagnosis extends Command
|
||||||
|
{
|
||||||
|
/** 档位标识 */
|
||||||
|
private const TIER_GT70 = 'gt70';
|
||||||
|
private const TIER_60_70 = '60_70';
|
||||||
|
private const TIER_50_60 = '50_60';
|
||||||
|
|
||||||
|
/** 各档位当日每人分配上限(按优先级排列,先高档后低档) */
|
||||||
|
private const TIER_DAILY_CAPS = [
|
||||||
|
self::TIER_GT70 => 3,
|
||||||
|
self::TIER_60_70 => 2,
|
||||||
|
self::TIER_50_60 => 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
private const TIER_LABELS = [
|
||||||
|
self::TIER_GT70 => '>70%',
|
||||||
|
self::TIER_60_70 => '60%~70%',
|
||||||
|
self::TIER_50_60 => '50%~60%',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$this->setName('tcm:auto-assign-pending')
|
||||||
|
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
|
||||||
|
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
$runDate = date('Y-m-d', $now);
|
||||||
|
$statMonth = date('Y-m', strtotime(date('Y-m-01', $now) . ' -1 month'));
|
||||||
|
$batchNo = date('YmdHis', $now) . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
|
||||||
|
date('Y-m-d H:i:s', $now),
|
||||||
|
$batchNo,
|
||||||
|
$statMonth,
|
||||||
|
$dryRun ? '(演练模式,不写库)'
|
||||||
|
: ''
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 上月二诊复诊接诊率 → 医助分档
|
||||||
|
$tiers = $this->buildAssistantTiers($statMonth);
|
||||||
|
$tierTotal = array_sum(array_map('count', $tiers));
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
|
||||||
|
\count($tiers[self::TIER_GT70]),
|
||||||
|
\count($tiers[self::TIER_60_70]),
|
||||||
|
\count($tiers[self::TIER_50_60])
|
||||||
|
));
|
||||||
|
|
||||||
|
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
|
||||||
|
$remaining = $this->buildRemainingQuota($tiers, $runDate);
|
||||||
|
|
||||||
|
// 3. 待指派池:与「待分配医助」Tab 同口径(assistant_id 空/0 + 当月有业务订单),先到先分
|
||||||
|
[$pool, $ineligible] = $this->fetchPendingPool($now);
|
||||||
|
$output->writeln(sprintf('待指派池:符合条件 %d 条,不符合条件 %d 条', \count($pool), \count($ineligible)));
|
||||||
|
|
||||||
|
$logRows = [];
|
||||||
|
|
||||||
|
// 不符合条件的待指派诊单:不分配,逐条记录原因
|
||||||
|
foreach ($ineligible as $item) {
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diag'], [
|
||||||
|
'action' => 0,
|
||||||
|
'reason' => $item['reason'],
|
||||||
|
], $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pool === []) {
|
||||||
|
$this->flushLogs($logRows, $dryRun, $output);
|
||||||
|
$output->writeln('待指派池为空,本次无需分配。');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tierTotal === 0) {
|
||||||
|
$reason = sprintf('未分配:上月(%s)无二诊复诊接诊率≥50%%的医助,本批次不执行分配', $statMonth);
|
||||||
|
foreach ($pool as $diag) {
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
|
||||||
|
'action' => 0,
|
||||||
|
'reason' => $reason,
|
||||||
|
], $now);
|
||||||
|
}
|
||||||
|
$this->flushLogs($logRows, $dryRun, $output);
|
||||||
|
$output->writeln($reason);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 轮询排分配计划
|
||||||
|
[$plan, $leftover] = $this->buildAssignPlan($pool, $tiers, $remaining);
|
||||||
|
|
||||||
|
// 5. 执行计划(逐条事务 + 行锁复核,落库诊单/指派日志/自动指派日志)
|
||||||
|
$assigned = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($plan as $item) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$assigned++;
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'[演练] 诊单#%d(%s) → %s(%s,第%d轮,当日第%d/%d条)',
|
||||||
|
$item['diagnosis']['id'],
|
||||||
|
(string) $item['diagnosis']['patient_name'],
|
||||||
|
$item['assistant']['name'],
|
||||||
|
self::TIER_LABELS[$item['tier']],
|
||||||
|
$item['round'],
|
||||||
|
$item['day_seq'],
|
||||||
|
self::TIER_DAILY_CAPS[$item['tier']]
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ok = $this->applyAssignment($item, $now);
|
||||||
|
if ($ok) {
|
||||||
|
$assigned++;
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
|
||||||
|
'action' => 1,
|
||||||
|
'assistant_id' => $item['assistant']['id'],
|
||||||
|
'assistant_name' => $item['assistant']['name'],
|
||||||
|
'tier' => $item['tier'],
|
||||||
|
'visit2_rate' => $item['assistant']['rate'],
|
||||||
|
'round_no' => $item['round'],
|
||||||
|
'reason' => sprintf(
|
||||||
|
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
|
||||||
|
$item['assistant']['name'],
|
||||||
|
$item['assistant']['id'],
|
||||||
|
$statMonth,
|
||||||
|
$item['assistant']['rate'],
|
||||||
|
self::TIER_LABELS[$item['tier']],
|
||||||
|
self::TIER_DAILY_CAPS[$item['tier']],
|
||||||
|
$item['round'],
|
||||||
|
$item['day_seq']
|
||||||
|
),
|
||||||
|
], $now);
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
|
||||||
|
'action' => 0,
|
||||||
|
'reason' => '未分配:执行时诊单已被指派给其他医助(并发/人工抢先),本次跳过',
|
||||||
|
], $now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 额度用尽后剩余的待指派诊单:不分配,记录原因
|
||||||
|
foreach ($leftover as $diag) {
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
|
||||||
|
'action' => 0,
|
||||||
|
'reason' => '未分配:各档位医助当日剩余额度已用尽(>70%每人3条、60%~70%每人2条、50%~60%每人1条),顺延至下次执行',
|
||||||
|
], $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flushLogs($logRows, $dryRun, $output);
|
||||||
|
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'执行完成。计划分配: %d, 实际分配: %d, 并发跳过: %d, 额度不足未分: %d, 不符合条件: %d',
|
||||||
|
\count($plan),
|
||||||
|
$assigned,
|
||||||
|
$skipped,
|
||||||
|
\count($leftover),
|
||||||
|
\count($ineligible)
|
||||||
|
));
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('待分配诊单自动指派异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
$output->error('执行异常: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按上月二诊复诊接诊率把医助分档(复用 RevisitRateLogic 口径),并剔除已禁用/已删除账号。
|
||||||
|
*
|
||||||
|
* @return array{gt70: list<array{id:int,name:string,rate:float}>, 60_70: list<array{id:int,name:string,rate:float}>, 50_60: list<array{id:int,name:string,rate:float}>}
|
||||||
|
*/
|
||||||
|
private function buildAssistantTiers(string $statMonth): array
|
||||||
|
{
|
||||||
|
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
|
||||||
|
|
||||||
|
/** @var list<array{id:int,name:string,rate:float}> $candidates */
|
||||||
|
$candidates = [];
|
||||||
|
foreach ($overview['rows'] ?? [] as $deptRow) {
|
||||||
|
foreach ($deptRow['children'] ?? [] as $row) {
|
||||||
|
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||||
|
$rate = $row['visit2_rate'] ?? null;
|
||||||
|
// 上月无被指派数据(rate=null)或接诊率低于 50% 的医助不参与分配
|
||||||
|
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$candidates[] = [
|
||||||
|
'id' => $aid,
|
||||||
|
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
|
||||||
|
'rate' => (float) $rate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($candidates === []) {
|
||||||
|
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只分配给当前在职可用的医助账号(role_id=2 且未禁用未删除),
|
||||||
|
// 且当前部门须在「二中心」子树内(统计月在二中心、后来调离的不再参与)
|
||||||
|
$erDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
if ($erDeptSet === []) {
|
||||||
|
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||||
|
}
|
||||||
|
$activeIds = Db::name('admin')
|
||||||
|
->alias('a')
|
||||||
|
->join('admin_role ar', 'a.id = ar.admin_id')
|
||||||
|
->join('admin_dept ad', 'a.id = ad.admin_id')
|
||||||
|
->where('ar.role_id', 2)
|
||||||
|
->where('a.disable', 0)
|
||||||
|
->whereNull('a.delete_time')
|
||||||
|
->whereIn('ad.dept_id', array_keys($erDeptSet))
|
||||||
|
->whereIn('a.id', array_column($candidates, 'id'))
|
||||||
|
->group('a.id')
|
||||||
|
->column('a.id');
|
||||||
|
$activeSet = array_fill_keys(array_map('intval', $activeIds), true);
|
||||||
|
|
||||||
|
$tiers = [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||||
|
foreach ($candidates as $c) {
|
||||||
|
if (!isset($activeSet[$c['id']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($c['rate'] > 70.0) {
|
||||||
|
$tiers[self::TIER_GT70][] = $c;
|
||||||
|
} elseif ($c['rate'] > 60.0) {
|
||||||
|
$tiers[self::TIER_60_70][] = $c;
|
||||||
|
} else { // 50 <= rate <= 60
|
||||||
|
$tiers[self::TIER_50_60][] = $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 档内按接诊率降序、id 升序,保证分配顺序确定可复现
|
||||||
|
foreach ($tiers as &$list) {
|
||||||
|
usort($list, static function (array $a, array $b): int {
|
||||||
|
if ($a['rate'] !== $b['rate']) {
|
||||||
|
return $b['rate'] <=> $a['rate'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $a['id'] <=> $b['id'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
unset($list);
|
||||||
|
|
||||||
|
return $tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各医助当日剩余额度 = 档位日上限 - 当日已自动分配条数(同日重复执行不超额)。
|
||||||
|
*
|
||||||
|
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
|
||||||
|
*
|
||||||
|
* @return array<int, int> assistant_id => 剩余额度
|
||||||
|
*/
|
||||||
|
private function buildRemainingQuota(array $tiers, string $runDate): array
|
||||||
|
{
|
||||||
|
$usedToday = Db::name('tcm_diagnosis_auto_assign_log')
|
||||||
|
->where('run_date', $runDate)
|
||||||
|
->where('action', 1)
|
||||||
|
->where('assistant_id', '>', 0)
|
||||||
|
->where('rollback_time', 0)
|
||||||
|
->group('assistant_id')
|
||||||
|
->column('COUNT(*)', 'assistant_id');
|
||||||
|
|
||||||
|
$remaining = [];
|
||||||
|
foreach (self::TIER_DAILY_CAPS as $tier => $cap) {
|
||||||
|
foreach ($tiers[$tier] as $assistant) {
|
||||||
|
$used = (int) ($usedToday[$assistant['id']] ?? 0);
|
||||||
|
$remaining[$assistant['id']] = max(0, $cap - $used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待指派池:与后台「待分配医助」Tab 同口径 —— assistant_id 为空/0、未删除,
|
||||||
|
* 且当月内存在业务订单(order.patient_id = 诊单 id,Tab 默认按当月过滤)。先到先分。
|
||||||
|
* 返回 [符合条件, 不符合条件(附原因)] 两组;不符合条件的诊单不分配,只记日志。
|
||||||
|
*
|
||||||
|
* @return array{0: list<array<string,mixed>>, 1: list<array{diag:array<string,mixed>,reason:string}>}
|
||||||
|
*/
|
||||||
|
private function fetchPendingPool(int $now): array
|
||||||
|
{
|
||||||
|
$rows = Db::name('tcm_diagnosis')
|
||||||
|
->whereRaw('(assistant_id IS NULL OR assistant_id = 0)')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->field(['id', 'patient_name', 'phone', 'status', 'create_time'])
|
||||||
|
->order(['create_time' => 'asc', 'id' => 'asc'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
if ($rows === []) {
|
||||||
|
return [[], []];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当月存在业务订单的诊单集合(order.create_time 兼容整型时间戳与 datetime 字符串,与 DiagnosisLists 一致)
|
||||||
|
$tStart = (int) strtotime(date('Y-m-01 00:00:00', $now));
|
||||||
|
$tEnd = (int) strtotime(date('Y-m-t 23:59:59', $now));
|
||||||
|
$dsStart = date('Y-m-d H:i:s', $tStart);
|
||||||
|
$dsEnd = date('Y-m-d H:i:s', $tEnd);
|
||||||
|
$hasOrderSet = [];
|
||||||
|
foreach (array_chunk(array_column($rows, 'id'), 2000) as $chunk) {
|
||||||
|
$ids = Db::name('order')
|
||||||
|
->whereIn('patient_id', $chunk)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->where(static function ($q) use ($tStart, $tEnd, $dsStart, $dsEnd) {
|
||||||
|
$q->whereBetween('create_time', [$tStart, $tEnd])
|
||||||
|
->whereOr(static function ($q2) use ($dsStart, $dsEnd) {
|
||||||
|
$q2->where('create_time', '>=', $dsStart)->where('create_time', '<=', $dsEnd);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->group('patient_id')
|
||||||
|
->column('patient_id');
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$hasOrderSet[(int) $id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$curMonth = date('Y-m', $now);
|
||||||
|
$eligible = [];
|
||||||
|
$ineligible = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$did = (int) ($r['id'] ?? 0);
|
||||||
|
if (!isset($hasOrderSet[$did])) {
|
||||||
|
$ineligible[] = [
|
||||||
|
'diag' => $r,
|
||||||
|
'reason' => sprintf('未分配:诊单当月(%s)无业务订单,不在「待分配医助」列表范围内,不满足自动分配条件', $curMonth),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((int) ($r['status'] ?? 0) !== 1) {
|
||||||
|
$ineligible[] = [
|
||||||
|
'diag' => $r,
|
||||||
|
'reason' => '未分配:诊单未启用(status≠1),不满足自动分配条件',
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$eligible[] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$eligible, $ineligible];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询排分配计划:每轮内先 >70% 档每人 1 条,再 60%~70%,再 50%~60%;受各自当日剩余额度约束。
|
||||||
|
*
|
||||||
|
* @param list<array<string,mixed>> $pool 待指派诊单(先到先分)
|
||||||
|
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
|
||||||
|
* @param array<int, int> $remaining assistant_id => 剩余额度(会被消耗)
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* 0: list<array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float},tier:string,round:int,day_seq:int}>,
|
||||||
|
* 1: list<array<string,mixed>>
|
||||||
|
* } [分配计划, 额度用尽后剩余诊单]
|
||||||
|
*/
|
||||||
|
private function buildAssignPlan(array $pool, array $tiers, array $remaining): array
|
||||||
|
{
|
||||||
|
$plan = [];
|
||||||
|
$poolIdx = 0;
|
||||||
|
$poolCount = \count($pool);
|
||||||
|
/** @var array<int, int> $daySeq 医助当日已排序号(含历史已用额度) */
|
||||||
|
$daySeq = [];
|
||||||
|
foreach ($remaining as $aid => $left) {
|
||||||
|
// 起始序号 = 日上限 - 剩余额度(同日多次执行时序号衔接)
|
||||||
|
$cap = 0;
|
||||||
|
foreach (self::TIER_DAILY_CAPS as $tier => $tierCap) {
|
||||||
|
foreach ($tiers[$tier] as $assistant) {
|
||||||
|
if ($assistant['id'] === $aid) {
|
||||||
|
$cap = $tierCap;
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$daySeq[$aid] = $cap - $left;
|
||||||
|
}
|
||||||
|
|
||||||
|
$round = 0;
|
||||||
|
while ($poolIdx < $poolCount) {
|
||||||
|
$round++;
|
||||||
|
$assignedThisRound = 0;
|
||||||
|
foreach (self::TIER_DAILY_CAPS as $tier => $_cap) {
|
||||||
|
foreach ($tiers[$tier] as $assistant) {
|
||||||
|
if ($poolIdx >= $poolCount) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
$aid = $assistant['id'];
|
||||||
|
if (($remaining[$aid] ?? 0) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$remaining[$aid]--;
|
||||||
|
$daySeq[$aid]++;
|
||||||
|
$plan[] = [
|
||||||
|
'diagnosis' => $pool[$poolIdx],
|
||||||
|
'assistant' => $assistant,
|
||||||
|
'tier' => $tier,
|
||||||
|
'round' => $round,
|
||||||
|
'day_seq' => $daySeq[$aid],
|
||||||
|
];
|
||||||
|
$poolIdx++;
|
||||||
|
$assignedThisRound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($assignedThisRound === 0) {
|
||||||
|
// 所有医助额度用尽,剩余诊单不再分配
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$plan, \array_slice($pool, $poolIdx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落库单条分配:事务 + 行锁复核诊单仍未指派,更新诊单并写指派日志(与手动指派 DiagnosisLogic::assign 同口径)。
|
||||||
|
*
|
||||||
|
* @param array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float}} $item
|
||||||
|
*/
|
||||||
|
private function applyAssignment(array $item, int $now): bool
|
||||||
|
{
|
||||||
|
$diagnosisId = (int) $item['diagnosis']['id'];
|
||||||
|
$toAssistantId = (int) $item['assistant']['id'];
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$diagLock = Db::name('tcm_diagnosis')
|
||||||
|
->where('id', $diagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->lock(true)
|
||||||
|
->field(['id', 'assistant_id'])
|
||||||
|
->find();
|
||||||
|
if ($diagLock === null || $diagLock === [] || (int) ($diagLock['assistant_id'] ?? 0) > 0) {
|
||||||
|
Db::rollback();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('tcm_diagnosis')
|
||||||
|
->where('id', $diagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->update([
|
||||||
|
'assistant_id' => $toAssistantId,
|
||||||
|
'assign_read_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动分给二中心时,原医助带上一中心身份(列表「原医助」不再恒为空)
|
||||||
|
$fromAssistantId = $this->resolveYiCenterPreviousAssistantId($diagnosisId, $relatedPoCreatorId);
|
||||||
|
|
||||||
|
Db::name('tcm_diagnosis_assign_log')->insert([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'from_assistant_id' => $fromAssistantId,
|
||||||
|
'to_assistant_id' => $toAssistantId,
|
||||||
|
'operator_admin_id' => 0,
|
||||||
|
'operator_name' => '系统自动分配',
|
||||||
|
'operator_account' => 'system',
|
||||||
|
'ip' => '',
|
||||||
|
'related_po_creator_id' => $relatedPoCreatorId,
|
||||||
|
'related_po_create_time' => $relatedPoCreateTime,
|
||||||
|
'is_inherit' => 0,
|
||||||
|
'create_time' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
Log::error(sprintf('自动指派落库失败 diagnosis_id=%d assistant_id=%d msg=%s', $diagnosisId, $toAssistantId, $e->getMessage()));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析诊单对应的「一中心」原医助 id,供自动指派写入 from_assistant_id。
|
||||||
|
* 优先:指派日志由新到旧,取最近出现的一中心身份(先 to 后 from);
|
||||||
|
* 回退:最新业务单创建人若属一中心则用之。跳过二中心身份,避免原医助写成上一任二中心。
|
||||||
|
*/
|
||||||
|
private function resolveYiCenterPreviousAssistantId(int $diagnosisId, int $relatedPoCreatorId): int
|
||||||
|
{
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$yiDeptSet = DeptLogic::getYiCenterSubtreeDeptIdSet();
|
||||||
|
if ($yiDeptSet === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = Db::name('tcm_diagnosis_assign_log')
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->field(['from_assistant_id', 'to_assistant_id'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$candidateIds = [];
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$to = (int) ($log['to_assistant_id'] ?? 0);
|
||||||
|
$from = (int) ($log['from_assistant_id'] ?? 0);
|
||||||
|
if ($to > 0) {
|
||||||
|
$candidateIds[] = $to;
|
||||||
|
}
|
||||||
|
if ($from > 0) {
|
||||||
|
$candidateIds[] = $from;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($relatedPoCreatorId > 0) {
|
||||||
|
$candidateIds[] = $relatedPoCreatorId;
|
||||||
|
}
|
||||||
|
$candidateIds = array_values(array_unique(array_filter($candidateIds)));
|
||||||
|
if ($candidateIds === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$yiAdminSet = $this->filterAdminIdsInDeptSet($candidateIds, $yiDeptSet);
|
||||||
|
if ($yiAdminSet === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$to = (int) ($log['to_assistant_id'] ?? 0);
|
||||||
|
if ($to > 0 && isset($yiAdminSet[$to])) {
|
||||||
|
return $to;
|
||||||
|
}
|
||||||
|
$from = (int) ($log['from_assistant_id'] ?? 0);
|
||||||
|
if ($from > 0 && isset($yiAdminSet[$from])) {
|
||||||
|
return $from;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($relatedPoCreatorId > 0 && isset($yiAdminSet[$relatedPoCreatorId])) {
|
||||||
|
return $relatedPoCreatorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $adminIds
|
||||||
|
* @param array<int, true> $deptIdSet
|
||||||
|
*
|
||||||
|
* @return array<int, true> admin_id => true
|
||||||
|
*/
|
||||||
|
private function filterAdminIdsInDeptSet(array $adminIds, array $deptIdSet): array
|
||||||
|
{
|
||||||
|
if ($adminIds === [] || $deptIdSet === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$rows = Db::name('admin_dept')
|
||||||
|
->whereIn('admin_id', $adminIds)
|
||||||
|
->whereIn('dept_id', array_keys($deptIdSet))
|
||||||
|
->column('admin_id');
|
||||||
|
|
||||||
|
return array_fill_keys(array_map('intval', $rows), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $diag 诊单行(含 id/patient_name/phone)
|
||||||
|
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/round_no/reason
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function buildLogRow(string $batchNo, string $runDate, string $statMonth, array $diag, array $extra, int $now): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'batch_no' => $batchNo,
|
||||||
|
'run_date' => $runDate,
|
||||||
|
'stat_month' => $statMonth,
|
||||||
|
'diagnosis_id' => (int) ($diag['id'] ?? 0),
|
||||||
|
'patient_name' => mb_substr(trim((string) ($diag['patient_name'] ?? '')), 0, 64),
|
||||||
|
'patient_phone' => mb_substr(trim((string) ($diag['phone'] ?? '')), 0, 32),
|
||||||
|
'action' => (int) ($extra['action'] ?? 0),
|
||||||
|
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
|
||||||
|
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
|
||||||
|
'tier' => (string) ($extra['tier'] ?? ''),
|
||||||
|
'visit2_rate' => $extra['visit2_rate'] ?? null,
|
||||||
|
'round_no' => (int) ($extra['round_no'] ?? 0),
|
||||||
|
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
|
||||||
|
'create_time' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $logRows
|
||||||
|
*/
|
||||||
|
private function flushLogs(array $logRows, bool $dryRun, Output $output): void
|
||||||
|
{
|
||||||
|
if ($logRows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln(sprintf('[演练] 应写入自动指派日志 %d 条(未落库)', \count($logRows)));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (array_chunk($logRows, 500) as $chunk) {
|
||||||
|
Db::name('tcm_diagnosis_auto_assign_log')->insertAll($chunk);
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('已写入自动指派日志 %d 条', \count($logRows)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ use think\console\Output;
|
|||||||
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货」核对释放诊单医助(跳过已完成/已签收业务单,与是否甘草单无关)
|
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货」核对释放诊单医助(跳过已完成/已签收业务单,与是否甘草单无关)
|
||||||
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
|
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
|
||||||
*
|
*
|
||||||
* 已对「业务订单创建人非二中心」(或创建人为开方医生时按待释放身份判定)执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
* 已对「待释放医助非二中心」执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||||
*/
|
*/
|
||||||
class ExpressAutoUpdate extends Command
|
class ExpressAutoUpdate extends Command
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ use think\console\Output;
|
|||||||
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics >> runtime/log/gancao_sync.log 2>&1
|
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics >> runtime/log/gancao_sync.log 2>&1
|
||||||
*
|
*
|
||||||
* 跳过履约状态为已完成(3)、已签收(6) 的业务订单,不再拉取甘草路由。
|
* 跳过履约状态为已完成(3)、已签收(6) 的业务订单,不再拉取甘草路由。
|
||||||
* 已对「业务订单创建人非二中心」(发货/签收自动释放规则见 ExpressTrackingService)的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
* 已对「待释放医助非二中心」(发货/签收自动释放规则见 ExpressTrackingService)的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||||
*/
|
*/
|
||||||
class GancaoSyncLogisticsRoute extends Command
|
class GancaoSyncLogisticsRoute extends Command
|
||||||
|
|||||||
@@ -588,7 +588,7 @@ class ExpressTrackingService
|
|||||||
/**
|
/**
|
||||||
* 处方业务订单履约「已发货(5) / 已签收(6)」时:清空关联诊单(患者)医助,并写入 tcm_diagnosis_assign_log(含关联业务订单 creator_id / create_time 快照,便于核对医助创建订单时间)。
|
* 处方业务订单履约「已发货(5) / 已签收(6)」时:清空关联诊单(患者)医助,并写入 tcm_diagnosis_assign_log(含关联业务订单 creator_id / create_time 快照,便于核对医助创建订单时间)。
|
||||||
* 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则。
|
* 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则。
|
||||||
* 不自动清空的情形:(1)按业务订单 creator_id(代建场景)或回退身份判断是否在「二中心」部门子树;(2)诊单已有 shipped_non_er_assistant_cleared_at 且 assistant_id 已为 0(视为已处理);(3)指派日志存在后台人工指派:`operator_admin_id>0` 且 `to_assistant_id>0`(系统任务为 operator_admin_id=0);或 related_po_creator_id=from_assistant_id 且 to>0 的旧数据兼容。避免「系统·已发货履约核对」覆盖人工指派链。
|
* 不自动清空的情形:(1)待释放医助(诊单 assistant_id,或代建回退身份)归属「二中心」部门子树;(2)诊单当前已指派、或指派日志曾 to 给「二中心」医助(含系统自动分配);(3)诊单已有 shipped_non_er_assistant_cleared_at 且 assistant_id 已为 0(视为已处理);(4)指派日志存在后台人工指派:`operator_admin_id>0` 且 `to_assistant_id>0`;或 related_po_creator_id=from_assistant_id 且 to>0 的旧数据兼容。避免「系统·已发货履约核对」覆盖人工指派链。
|
||||||
*
|
*
|
||||||
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
|
* @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -663,19 +663,18 @@ class ExpressTrackingService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否因「二中心」跳过释放:优先看业务订单创建人 creator_id(代建场景);创建人即开方医生时用 fromAssistantId */
|
// 二中心不释放:(a)待释放医助属二中心;(b)诊单当前医助属二中心;(c)指派日志曾指派给二中心医助
|
||||||
$erZhongxinCheckAdminId = ($poCreatorId > 0 && ($rxId <= 0 || $poCreatorId !== $rxCreator))
|
$skipErzhongxin = self::currentAssistantBelongsToErzhongxinDeptTree($fromAssistantId)
|
||||||
? $poCreatorId
|
|| ($diagAssistantIdNow > 0 && self::currentAssistantBelongsToErzhongxinDeptTree($diagAssistantIdNow))
|
||||||
: $fromAssistantId;
|
|| self::diagnosisHasBeenAssignedToErzhongxinAssistant($diagnosisId);
|
||||||
|
if ($skipErzhongxin) {
|
||||||
if ($erZhongxinCheckAdminId > 0 && self::currentAssistantBelongsToErzhongxinDeptTree($erZhongxinCheckAdminId)) {
|
Log::info('Skipped auto assistant release (shipped/signed): 二中心 assistant assigned or targeted', [
|
||||||
Log::info('Skipped auto assistant release (shipped/signed): order creator (or fallback from-id) under 二中心 dept tree', [
|
|
||||||
'diagnosis_id' => $diagnosisId,
|
'diagnosis_id' => $diagnosisId,
|
||||||
'prescription_order_id' => $orderId,
|
'prescription_order_id' => $orderId,
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
'er_zhongxin_check_admin_id' => $erZhongxinCheckAdminId,
|
'from_assistant_id' => $fromAssistantId,
|
||||||
'prescription_order_creator_id' => $poCreatorId,
|
'diagnosis_assistant_id' => $diagAssistantIdNow,
|
||||||
'from_assistant_id_for_log' => $fromAssistantId,
|
'prescription_order_creator_id' => $poCreatorId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -798,6 +797,36 @@ class ExpressTrackingService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单指派日志是否曾指派给「二中心」医助(to_assistant_id>0,含人工指派与系统自动分配)。
|
||||||
|
*/
|
||||||
|
private static function diagnosisHasBeenAssignedToErzhongxinAssistant(int $diagnosisId): bool
|
||||||
|
{
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$toIds = Db::name('tcm_diagnosis_assign_log')
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
|
->where('to_assistant_id', '>', 0)
|
||||||
|
->column('to_assistant_id');
|
||||||
|
if ($toIds === [] || $toIds === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$seen = [];
|
||||||
|
foreach ($toIds as $aid) {
|
||||||
|
$aid = (int) $aid;
|
||||||
|
if ($aid <= 0 || isset($seen[$aid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$aid] = true;
|
||||||
|
if (self::currentAssistantBelongsToErzhongxinDeptTree($aid)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 若诊单曾被后台人工指派到具体医助,则不再执行发运/签收自动清空(避免「系统·已发货履约核对」覆盖人工链)。
|
* 若诊单曾被后台人工指派到具体医助,则不再执行发运/签收自动清空(避免「系统·已发货履约核对」覆盖人工链)。
|
||||||
* 判定:operator_admin_id>0 且 to_assistant_id>0(人工);系统写入的释放/同步为 operator_admin_id=0。
|
* 判定:operator_admin_id>0 且 to_assistant_id>0(人工);系统写入的释放/同步为 operator_admin_id=0。
|
||||||
@@ -1031,7 +1060,7 @@ class ExpressTrackingService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 按处方订单履约核对:仅已发货(5) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder(已完成/已签收不再扫描)。
|
* 按处方订单履约核对:仅已发货(5) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder(已完成/已签收不再扫描)。
|
||||||
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看业务单创建人**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
|
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看待释放医助**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
|
||||||
*
|
*
|
||||||
* @return array{scanned: int, cleared: int, lines: list<string>}
|
* @return array{scanned: int, cleared: int, lines: list<string>}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -40,5 +40,7 @@ return [
|
|||||||
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
|
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
|
||||||
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
|
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
|
||||||
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
|
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
|
||||||
|
// 待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配(>70% 3条/60~70% 2条/50~60% 1条),写自动指派日志
|
||||||
|
'tcm:auto-assign-pending' => 'app\\command\\AutoAssignPendingDiagnosis',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+1
-1
@@ -1 +1 @@
|
|||||||
import r from"./error-CIC7FjML.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DU4wa6E0.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
|
import r from"./error-D7g_31ON.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DiYdpaIG.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import o from"./error-CIC7FjML.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DU4wa6E0.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
|
import o from"./error-D7g_31ON.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DiYdpaIG.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
|
||||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user