Compare commits

..
2 Commits
Author SHA1 Message Date
Your Name 8b129b3823 更新 2026-06-15 09:12:32 +08:00
Your Name 4ba75e5a94 更新 2026-06-15 09:12:15 +08:00
1040 changed files with 8961 additions and 22269 deletions
+6 -1
View File
@@ -1,6 +1,7 @@
<script setup>
// 2. 获取组件实例,通过 proxy 访问全局属性
import { ref} from "vue";
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
import { getCurrentInstance } from 'vue'
@@ -96,7 +97,11 @@ const video =async (data)=>{
},
}, false)
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
console.log('SDKAppID',SDKAppID)
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
+7 -1
View File
@@ -239,6 +239,7 @@
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
@@ -560,12 +561,17 @@ const { proxy } = getCurrentInstance()
},
}, false)
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const {userId } = res.data;
patient_data.value=res.data.data
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID;
getApp().globalData.secretKey = secretKey;
uni.setStorageSync('CallManager', userId)
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
}
@@ -186,8 +186,16 @@ const inlineItems = computed(() => {
return metro ? [metro, ...rest] : trainingItems
})
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
const visible = ref(true)
const isDevMode = (): boolean => {
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
try {
return process.env.NODE_ENV !== 'production'
} catch (_) {
return false
}
}
const visible = ref(isDevMode())
const expanded = ref(false)
const toggle = () => {
@@ -178,6 +178,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getCurrentInstance } from 'vue'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
let userId = ref("");
@@ -378,11 +379,10 @@ const loginHandler = async (patient_id) => {
data: { patient_id: patient_id },
}, false)
const {
userId: patientUserId,
userSig: patientUserSig,
sdkAppId: patientSDKAppID,
} = res.data
const { userId: patientUserId } = res.data
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
userID: patientUserId,
})
console.log('获取签名成功:', patientUserSig)
// 更新ref值
-1
View File
@@ -51,7 +51,6 @@
/* */
"mp-weixin" : {
"appid" : "wx79b9a0bfbfe7cbcd",
"lazyCodeLoading" : "requiredComponents",
"setting" : {
"urlCheck" : false,
"minified" : true
+12 -19
View File
@@ -26,6 +26,14 @@
"navigationBarTitleText": "甄养堂"
}
},
{
"path": "TUICallKit/src/Components/TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
},
{
"path": "pages/order/monad/monad",
"style": {
@@ -68,19 +76,6 @@
}
],
"subPackages": [
{
"root": "TUICallKit/src/Components",
"pages": [
{
"path": "TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
}
]
},
{
"root": "TUIKit",
"pages": [
@@ -221,14 +216,12 @@
}
},
{
"path": "endless-game/index",
"path": "pages/game",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
"navigationBarTitleText": "糖分突袭",
"backgroundColor": "#c7d2fe",
"disableScroll": true
}
}
]
+7 -2
View File
@@ -62,6 +62,7 @@
import { ref,onMounted,getCurrentInstance} from "vue";
// 2. 获取组件实例,通过 proxy 访问全局属性
const { proxy } = getCurrentInstance()
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
let userID = ref("4");
@@ -139,9 +140,13 @@ const loginHandler = async (patient_id) => {
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
console.log('获取签名成功:',userSig);
getApp().globalData.userID = userId;
getApp().globalData.userID = userId.value;
getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID;
+5 -1
View File
@@ -52,6 +52,7 @@
<script setup>
import { ref, getCurrentInstance,onMounted } from "vue";
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
let userId = ref("");
@@ -125,7 +126,10 @@ const loginHandler = async (patient_id) => {
data: { patient_id: patient_id },
}, false)
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
console.log('获取签名成功:',userSig);
getApp().globalData.userID = userId;
+17 -12
View File
@@ -370,6 +370,7 @@
import { ref,onMounted,getCurrentInstance } from "vue";
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
// 2. 获取组件实例,通过 proxy 访问全局属性
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
@@ -666,25 +667,25 @@ const closePatientModal = async () => {
const granted = await requestAVPermissionsInModal();
if (!granted) return; // 未授权,停止跳转
const app = getApp();
// const userID = app.globalData.userID;
// const userSig = app.globalData.userSig;
// const SDKAppID = app.globalData.SDKAppID;
const doctor = 'doctor_' + doctorId.value;
await confirmDiagnosis(diagnosisId);
const signatureRes = await proxy.apiUrl({
url: '/api/tcm/getPatientSignature',
method: 'GET',
data: { patient_id: diagnosisId },
}, false);
const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
if(!diagnosisId.includes('patient')){
diagnosisId='patient_'+diagnosisId
}
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:diagnosisId,
});
if (!doctorId.value) {
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
return;
}
app.globalData.userID = userId;
app.globalData.userSig = userSig;
app.globalData.SDKAppID = SDKAppID;
console.log(app.globalData, userId);
console.log(app.globalData, diagnosisId);
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(diagnosisId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
showPatientModal.value = false;
uni.redirectTo({
@@ -706,7 +707,11 @@ const video =async (data)=>{
},
}, false)
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
@@ -0,0 +1,121 @@
<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>
@@ -1,189 +0,0 @@
<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>
@@ -0,0 +1,188 @@
/**
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
*/
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
}
}
@@ -0,0 +1,112 @@
/**
* 游戏进度持久化:关卡、星级、连胜、每日打卡
*/
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
}
}
@@ -25,13 +25,6 @@ const SRC = {
* rate 不同可在同一素材上听出明显差异
*/
const SFX = {
// —— 基础素材别名(供分层音效直接调用)——
tap: { src: SRC.tap, volume: 0.32, rate: 1, pool: 3 },
pop: { src: SRC.pop, volume: 0.3, rate: 1, pool: 4 },
fanfare: { src: SRC.fanfare, volume: 0.56, rate: 1, pool: 2 },
blast: { src: SRC.blast, volume: 0.62, rate: 1, pool: 2 },
sparkle: { src: SRC.sparkle, volume: 0.5, rate: 1, pool: 2 },
// —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
@@ -84,43 +77,6 @@ const SFX = {
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
}
const WARMUP_SFX = [
'select', 'swap', 'swapFail', 'drop', 'dropLight',
'match3', 'match4', 'match5', 'combo2', 'combo3', 'comboMega',
'insulin', 'sparkle', 'reshuffle', 'lose'
]
function createAudioContext() {
if (typeof Audio !== 'undefined') {
const audio = new Audio()
audio.preload = 'auto'
return {
get src() { return audio.src },
set src(value) { audio.src = value },
get volume() { return audio.volume },
set volume(value) { audio.volume = value },
get playbackRate() { return audio.playbackRate },
set playbackRate(value) { audio.playbackRate = value },
play() {
const promise = audio.play()
if (promise?.catch) promise.catch(() => {})
},
stop() {
audio.pause()
try { audio.currentTime = 0 } catch (_) {}
},
seek(time) {
try { audio.currentTime = time } catch (_) {}
},
destroy() {
audio.pause()
audio.removeAttribute('src')
}
}
}
return uni.createInnerAudioContext()
}
class SfxPool {
constructor(src, size, volume, rate = 1) {
this.src = src
@@ -132,8 +88,9 @@ class SfxPool {
this.warmedUp = false
}
createOne() {
const ctx = createAudioContext()
create() {
for (let i = 0; i < this.size; i++) {
const ctx = uni.createInnerAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
@@ -142,16 +99,13 @@ class SfxPool {
ctx.playbackRate = this.rate
} catch (_) {}
this.list.push(ctx)
return ctx
}
create() {
while (this.list.length < this.size) this.createOne()
}
warmUp() {
if (this.warmedUp) return
const ctx = this.list[0] || this.createOne()
if (!this.list.length) this.create()
this.list.forEach((ctx) => {
try {
ctx.volume = 0
ctx.play()
@@ -162,12 +116,12 @@ class SfxPool {
} catch (_) {}
}, 60)
} catch (_) {}
if (this.size > 1) this.cursor = 1
})
this.warmedUp = true
}
play(scale = 1, rateMul = 1) {
if (this.list.length < this.size) this.create()
if (!this.list.length) this.create()
const ctx = this.list[this.cursor % this.list.length]
this.cursor += 1
const vol = Math.min(1, this.volume * scale)
@@ -250,8 +204,7 @@ export function useGameSfx() {
function warmUp() {
if (!enabled.value) return
ensureAudioOption()
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
names.forEach((name) => {
Object.keys(SFX).forEach((name) => {
getPool(name)?.warmUp()
})
}
@@ -2,27 +2,6 @@ import { ref, onUnmounted } from 'vue'
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
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
@@ -133,8 +112,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
function stopListening() {
// #ifdef MP-WEIXIN
const ownsRecording = sharedRecording && activeCtl === controller
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
if (wxRecordManager && (sttListening.value || ownsRecording)) {
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
try {
wxRecordManager.stop()
} catch (e) {}
@@ -150,7 +128,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif
startRequested = false
sttHolding.value = false
sttListening.value = false
}
@@ -194,6 +171,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
}
try {
startRequested = true
sharedRecording = true
wxRecordManager.start({
duration: 60000,
lang: 'zh_CN'
@@ -273,28 +251,22 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif
}
/** 松手结束:手指抬起force=true 时强制取消尚未真正开始的按住会话 */
function endHoldSpeech(force = false) {
/** 松手结束:手指抬起 */
function endHoldSpeech() {
let recording = false
// #ifdef MP-WEIXIN
recording = sharedRecording && activeCtl === controller
// #endif
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) {
return
}
sttHolding.value = false
// 松手后不再补开排队的录音
pendingStartSession = 0
if (sttListening.value || recording) {
if (sttListening.value || startRequested || recording) {
stopListening()
return
}
if (startRequested) {
// start 已发出但 onStart 未到:取消会话,勿调 stop()
startRequested = false
holdSessionId += 1
return
}
holdSessionId += 1
}
@@ -304,9 +276,9 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
const controller = {
handleStart() {
if (!sttHolding.value) {
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
startRequested = false
sttListening.value = false
try {
wxRecordManager.stop()
} catch (e) {}
return
}
startRequested = false
@@ -334,7 +306,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
sttListening.value = false
startRequested = false
const msg = (res && res.msg) || ''
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
if (/please stop after start/i.test(msg)) {
try {
@@ -345,18 +316,12 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
}
pendingStartSession = 0
sttHolding.value = false
const heldMs = Date.now() - holdStartTs
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
if (typeof onError === 'function') onError(msg || '')
return
}
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
if (typeof onError === 'function') {
const handled = onError(msg || '')
if (handled === true) return
}
const tip = friendlySttError(msg)
if (tip) notify(tip)
notify(msg || '语音识别失败')
}
}
@@ -446,5 +411,3 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
stopSpeechToText: endHoldSpeech
}
}
export { isBenignSttError, friendlySttError }
+654
View File
@@ -0,0 +1,654 @@
/**
* 棋盘食材 · 仿真插画 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))
}
@@ -0,0 +1,50 @@
/**
* 食材图标 URLTwemoji PNG,小程序 / H5 通用)
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
*/
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
/** emoji → Twemoji PNG 地址 */
export function emojiToTwemojiUrl(emoji) {
if (!emoji) return ''
const parts = []
for (let i = 0; i < emoji.length;) {
const cp = emoji.codePointAt(i)
if (!cp) break
parts.push(cp.toString(16))
i += cp > 0xffff ? 2 : 1
}
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
}
const imgCache = {}
export function getFoodImgUrl(emoji) {
if (!emoji) return ''
if (imgCache[emoji]) return imgCache[emoji]
const url = emojiToTwemojiUrl(emoji)
imgCache[emoji] = url
return url
}
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
export function warmUpFoodImgs(foods) {
const list = Array.isArray(foods) ? foods : []
list.forEach((f) => {
const icon = typeof f === 'string' ? f : f?.icon
// 优先预热自定义图片素材,缺省回退 emoji PNG
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
if (!url) return
// #ifdef MP-WEIXIN
try {
uni.getImageInfo({ src: url, fail: () => {} })
} catch (_) {}
// #endif
// #ifdef H5
try {
const img = new Image()
img.src = url
} catch (_) {}
// #endif
})
}
@@ -0,0 +1,189 @@
/**
* 全量食材库: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)
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 关卡配置:面向 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
}
@@ -1,40 +0,0 @@
# 识糖小课堂平台接入说明
## 已接入能力
- 复用主小程序 `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。
- 周一验证新周重新分组,旧周成绩不带入新周。
@@ -1,43 +0,0 @@
# 识糖小课堂独立功能包
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 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` 配置中,正式上线前仍需由医院医生审核。
@@ -1,71 +0,0 @@
/**
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 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 }
}
@@ -1,251 +0,0 @@
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
}
}
@@ -1,570 +0,0 @@
.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
+5 -11
View File
@@ -98,19 +98,17 @@
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据累计记录满 7 天后显示您的真实趋势</text>
</view>
<view class="st-game-entry-list">
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
<view class="st-game-entry-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
<TongjiIcon name="activity" 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>
<text class="st-game-entry-sub">认一认哪些食物含糖高</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
</view>
<view class="st-training-section">
<dev-training-entry inline tone="weekly" />
</view>
@@ -1514,19 +1512,15 @@ async function goMorePage() {
uni.navigateTo({ url: buildMorePageUrl() })
}
function openGamePage(url) {
function goGamePage() {
uni.navigateTo({
url,
url: '/tongji/pages/game',
fail() {
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
}
})
}
function goEndlessGamePage() {
openGamePage('/tongji/endless-game/index')
}
function navToUser() {
uni.redirectTo({ url: '/pages/user/user' })
}
File diff suppressed because it is too large Load Diff
+1 -258
View File
@@ -639,8 +639,7 @@
}
.more-page .mc-tasks-section {
margin-top: 0;
margin-bottom: 32rpx;
margin-top: 40rpx;
}
.more-page .mc-tasks-grid {
@@ -1168,259 +1167,3 @@
font-size: 22rpx;
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);
}
/* 游戏入口 */
/* 糖分突袭游戏入口 */
.weekly-page .st-game-entry {
margin-top: 24rpx;
@@ -388,15 +388,8 @@
}
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
.weekly-page .st-chart-card .st-game-entry-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin: 12rpx 20rpx 0;
}
.weekly-page .st-chart-card .st-game-entry--in-card {
margin-top: 0;
margin-top: 12rpx;
padding: 16rpx 20rpx;
gap: 16rpx;
border-radius: 24rpx;
@@ -1,75 +0,0 @@
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
/** 可选树种:每级 emoji 与 treeLevels.js Lv.09 一一对应 */
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 -13
View File
@@ -1,22 +1,10 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import Optimization from '@uni-ku/bundle-optimizer'
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
// 编译产物默认输出到 ./dist/<mode>/<platform>
export default defineConfig({
plugins: [
uni(),
Optimization({
enable: {
optimization: true,
'async-import': false,
'async-component': false,
},
dts: false,
logger: false,
}),
],
plugins: [uni()],
css: {
preprocessorOptions: {
scss: {
+2 -285
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

-87
View File
@@ -1,87 +0,0 @@
import request from '@/utils/request'
export interface MedicineMappingQuery {
page_no: number
page_size: number
local_name?: string
remote_keyword?: string
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
}
export interface MedicineMappingRow {
local_medicine_id: number
local_name: string
local_unit: string
local_status: number
mapping_id: number | null
mapping_status: number
medicine_code: string | null
remote_name: string | null
remote_brand: string | null
remote_unit: string | null
settlement_price: string | number | null
retail_price: string | number | null
catalog_version: number | null
remote_status: number | null
remote_deleted: number | null
operator_name: string | null
mapping_update_time: number | null
}
export interface CatalogOption {
medicine_code: string
name: string
brand: string
unit: string
settlement_price: string | number
retail_price: string | number
catalog_version: number
status: number
}
export interface PharmacySyncStatus {
sync_enabled: boolean
cursor: number
last_success_time: number
last_failure_time: number
last_error_summary: string
is_syncing: boolean
catalog_total: number
catalog_active: number
unmapped_local: number
}
export interface PharmacySyncResult {
pages: number
pulled: number
received: number
created: number
updated: number
unchanged: number
deactivated: number
cursor: number
}
export function medicineMappingLists(params: MedicineMappingQuery) {
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
}
export function medicineMappingStatus() {
return request.get({ url: '/pharmacy.medicineMapping/status' })
}
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
}
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
return request.post({ url: '/pharmacy.medicineMapping/save', params })
}
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
}
export function medicineCatalogSync() {
return request.post({ url: '/pharmacy.medicineMapping/sync' })
}
-25
View File
@@ -96,21 +96,6 @@ export function yejiStatsRevisitBreakdown(params: {
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
}
/** 被指派数明细:与看板「被指派数」同口径;部门行传 dept_id,医助排行榜传 assistant_id */
export function yejiStatsAssignLines(params: {
start_date: string
end_date: string
dept_id?: number
assistant_id?: number
dept_ids?: string
channel_code?: string
tag_id?: string
page?: number
page_size?: number
}) {
return request.get({ url: '/stats.yejiStats/assignLines', params })
}
export function doctorDailyStatsOverview(params: {
start_date?: string
end_date?: string
@@ -227,16 +212,6 @@ export function revisitRateVisitOrderLines(params: {
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
}
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
export function autoAssignLogLists(params: Record<string, any>) {
return request.get({ url: '/stats.autoAssignLog/lists', params })
}
/** 批量回退自动分配:将诊单医助撤回到自动分配前的原医助 */
export function autoAssignLogRollback(data: { ids: number[] }) {
return request.post({ url: '/stats.autoAssignLog/rollback', data })
}
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
-51
View File
@@ -61,14 +61,6 @@ export function tcmDiagnosisDetail(params: any) {
return request.get({ url: '/tcm.diagnosis/detail', params })
}
/** 设置复诊接诊率统计起始偏移(统计诊次=实单序号+偏移;1=二诊起,2=三诊起) */
export function tcmDiagnosisSetRevisitSlotStartOffset(params: {
id: number
revisit_slot_start_offset: number
}) {
return request.post({ url: '/tcm.diagnosis/setRevisitSlotStartOffset', params })
}
/** 诊单挂号 / 取消挂号 操作日志 */
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
@@ -433,17 +425,6 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
}
export function prescriptionOrderPatchPrescriptionUsage(params: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
}
export function prescriptionOrderAuditPrescription(params: {
id: number
action: 'approve' | 'reject'
@@ -465,11 +446,6 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
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 }) {
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
@@ -528,21 +504,6 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
}
/** 按业务订单 ship_mode 上传:gancao 走甘草,direct 走洛阳药房 ERP */
export function prescriptionOrderUploadToPharmacy(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/uploadToPharmacy', params })
}
/** 人工核对甘草不确定提交结果。 */
export function prescriptionOrderConfirmGancaoSubmission(params: {
id: number
resolution: 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED'
remote_order_no?: string
note: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/confirmGancaoSubmission', params })
}
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
export function prescriptionOrderPreviewGancaoRecipel(params: {
id: number
@@ -557,18 +518,6 @@ export function prescriptionOrderLogs(params: { id: number }) {
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 }) {
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
@@ -2,7 +2,7 @@
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '' }}</span>
<el-select
v-else
:model-value="selectedMedicineId"
:model-value="modelValue"
class="medicine-name-select w-full"
filterable
remote
@@ -14,14 +14,7 @@
@visible-change="onVisibleChange"
@update:model-value="onUpdate"
>
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
<div class="medicine-name-option">
<span>{{ item.name }}</span>
<span class="medicine-name-option__meta">
{{ item.id > 0 ? [item.supplier, item.unit, `ID ${item.id}`].filter(Boolean).join(' · ') : '历史名称,请重新选择' }}
</span>
</div>
</el-option>
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.name" />
</el-select>
</template>
@@ -31,7 +24,6 @@ import { medicineLists } from '@/api/medicine'
const props = withDefaults(
defineProps<{
modelValue: string
medicineId?: number | null
disabled?: boolean
}>(),
{ disabled: false }
@@ -39,30 +31,18 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:medicineId': [value: number | undefined]
}>()
const loading = ref(false)
type MedicineOption = { id: number; name: string; supplier?: string; unit?: string }
const options = ref<MedicineOption[]>([])
const selectedMedicineId = computed<number | ''>(() => {
const id = Number(props.medicineId)
if (Number.isInteger(id) && id > 0) {
return id
}
return (props.modelValue || '').trim() ? 0 : ''
})
const options = ref<{ id: number; name: string }[]>([])
function ensureCurrentInOptions() {
const v = (props.modelValue || '').trim()
if (!v) {
return
}
const id = Number(props.medicineId)
const currentId = Number.isInteger(id) && id > 0 ? id : 0
if (!options.value.some((o) => o.id === currentId)) {
options.value = [{ id: currentId, name: v }, ...options.value]
if (!options.value.some((o) => o.name === v)) {
options.value = [{ id: 0, name: v }, ...options.value]
}
}
@@ -76,7 +56,7 @@ const remoteMethod = async (query: string) => {
page_size: 100,
status: 1
})
options.value = (res.lists || []) as MedicineOption[]
options.value = res.lists || []
ensureCurrentInOptions()
} catch (e) {
console.error(e)
@@ -92,19 +72,13 @@ const onVisibleChange = (open: boolean) => {
}
}
const onUpdate = (val: number | string) => {
const id = Number(val)
const selected = id > 0 ? options.value.find((item) => item.id === id) : undefined
emit('update:modelValue', selected?.name || '')
emit('update:medicineId', selected?.id)
const onUpdate = (val: string) => {
emit('update:modelValue', val || '')
}
watch(
() => [props.modelValue, props.medicineId],
() => props.modelValue,
() => {
if (!(props.modelValue || '').trim() && Number(props.medicineId) > 0) {
emit('update:medicineId', undefined)
}
ensureCurrentInOptions()
},
{ immediate: true }
@@ -119,23 +93,4 @@ watch(
.medicine-name-select.w-full {
width: 100%;
}
.medicine-name-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 60%);
align-items: center;
gap: 16px;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.medicine-name-option__meta {
color: var(--el-text-color-secondary);
font-size: 12px;
text-align: right;
}
</style>
@@ -139,12 +139,7 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
<MedicineNameSelect
v-model="formData.herbs[row.index].name"
v-model:medicine-id="formData.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="herb-editor-card__select"
/>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -181,12 +176,7 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">辅方</div>
<MedicineNameSelect
v-model="formData.herbs[row.index].name"
v-model:medicine-id="formData.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="herb-editor-card__select"
/>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -1055,7 +1045,6 @@ import { Search } from '@element-plus/icons-vue'
type FormulaType = '主方' | '辅方'
interface Herb {
medicine_id?: number
name: string
dosage: number
formula_type?: FormulaType
@@ -1082,10 +1071,6 @@ function normalizeHerbRow(raw: any): Herb {
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
if (Number.isInteger(medicineId) && medicineId > 0) {
row.medicine_id = medicineId
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
@@ -1593,8 +1578,8 @@ function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
return herbs
}
/** 仅有一条完全同名记录时返回稳定药材身份,否则不猜测 */
async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测 */
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
const q = rawName.trim()
if (!q) return null
try {
@@ -1605,10 +1590,8 @@ async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: n
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
return exact.length === 1
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
: null
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
} catch {
return null
}
@@ -1629,12 +1612,12 @@ async function handlePasteRecipeImport() {
const resolved: Herb[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const medicine = await resolveHerbFromLibrary(row.name)
if (!medicine) {
const name = await resolveHerbNameFromLibrary(row.name)
if (!name) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
+15 -12
View File
@@ -1,28 +1,31 @@
const defaultSetting = {
showCrumb: true, // 是否显示面包屑
showLogo: false, // 是否显示logo
showLogo: true, // 是否显示logo
isUniqueOpened: true, //只展开一个一级菜单
sideWidth: 183, //侧边栏宽度
sideTheme: 'dark', //侧边栏主题
sideDarkColor: '#1d2124', //侧边栏深色主题颜色
sideWidth: 200, //侧边栏宽度
sideTheme: 'light', //侧边栏主题
sideDarkColor: '#0369A1', //侧边栏深色主题颜色(深天蓝)
openMultipleTabs: true, // 是否开启多标签tab栏
theme: '#4A5DFF', //主题色
successTheme: '#67c23a', //成功主题色
warningTheme: '#e6a23c', //警告主题色
dangerTheme: '#f56c6c', //危险主题色
errorTheme: '#f56c6c', //错误主题色
infoTheme: '#909399' //信息主题色
theme: '#0EA5E9', //主题色(天蓝)
successTheme: '#16A34A', //成功主题色
warningTheme: '#D97706', //警告主题色
dangerTheme: '#DC2626', //危险主题色
errorTheme: '#DC2626', //错误主题色
infoTheme: '#64748B' //信息主题色
}
/** 本地 setting 缓存结构版本。提升后仅对低于该版本的老缓存执行 SETTING_SCHEMA_MIGRATIONS */
export const SETTING_SCHEMA_VERSION = 1
export const SETTING_SCHEMA_VERSION = 4
/**
* defaultSetting localStorage config
* SETTING_SCHEMA_VERSION +1
*/
export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = {
1: ['sideTheme', 'sideDarkColor']
1: ['sideTheme', 'sideDarkColor'],
2: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideTheme', 'sideDarkColor', 'showLogo', 'sideWidth'],
3: ['theme', 'sideTheme', 'sideDarkColor', 'sideWidth'],
4: ['theme', 'sideDarkColor']
}
export default defaultSetting
@@ -26,17 +26,19 @@ useWatchRoute((route) => {
.app-breadcrumb {
:deep(.el-breadcrumb__item) {
.el-breadcrumb__inner {
color: #303133;
font-weight: 500;
color: var(--el-text-color-regular);
font-weight: 400;
transition: color 0.2s ease;
}
&:last-child .el-breadcrumb__inner {
color: #303133;
color: var(--health-primary-dark, var(--el-color-primary));
font-weight: 500;
}
}
:deep(.el-breadcrumb__separator) {
color: #606266;
color: var(--el-text-color-secondary);
}
}
</style>
@@ -1,8 +1,14 @@
<template>
<header class="header">
<div class="navbar">
<div class="flex-1 flex">
<div class="navbar-item">
<div class="navbar-left flex-1 flex items-center min-w-0">
<side-logo
v-if="settingStore.showLogo"
class="navbar-logo shrink-0"
:show-title="!isCollapsed || isMobile"
theme="light"
/>
<div class="navbar-item shrink-0">
<el-tooltip
class="box-item"
effect="dark"
@@ -12,16 +18,19 @@
<fold />
</el-tooltip>
</div>
<div class="navbar-item">
<div class="navbar-item shrink-0">
<el-tooltip class="box-item" effect="dark" content="刷新" placement="bottom">
<refresh />
</el-tooltip>
</div>
<div class="flex items-center px-2" v-if="!isMobile && settingStore.showCrumb">
<div
class="flex items-center px-2 min-w-0"
v-if="!isMobile && settingStore.showCrumb"
>
<breadcrumb />
</div>
</div>
<div class="flex">
<div class="navbar-right flex shrink-0">
<div class="navbar-item" v-if="!isMobile">
<el-tooltip
class="box-item"
@@ -47,7 +56,6 @@
</div>
</div>
</div>
<multiple-tabs v-if="settingStore.openMultipleTabs" />
</header>
</template>
@@ -57,11 +65,11 @@ import { useFullscreen } from '@vueuse/core'
import useAppStore from '@/stores/modules/app'
import useSettingStore from '@/stores/modules/setting'
import SideLogo from '../sidebar/logo.vue'
import Setting from '../setting/index.vue'
import Breadcrumb from './breadcrumb.vue'
import Fold from './fold.vue'
import FullScreen from './full-screen.vue'
import MultipleTabs from './multiple-tabs.vue'
import Refresh from './refresh.vue'
import UserDropDown from './user-drop-down.vue'
@@ -73,11 +81,31 @@ const { isFullscreen } = useFullscreen()
</script>
<style lang="scss">
.header {
background: var(--el-bg-color);
border-bottom: 1px solid var(--health-border, var(--el-border-color-lighter));
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.navbar {
height: var(--navbar-height);
@apply flex px-2 bg-body;
@apply flex px-4;
.navbar-logo {
margin-right: 4px;
border-bottom: none !important;
padding-right: 8px !important;
}
.navbar-item {
@apply h-full flex justify-center items-center hover:bg-page;
@apply h-10 flex justify-center items-center cursor-pointer;
min-width: 40px;
border-radius: 8px;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--el-fill-color);
}
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="app-tabs pl-4 flex bg-body">
<div class="app-tabs flex bg-body">
<div class="flex-1 min-w-0">
<el-tabs
:model-value="currentTab"
@@ -13,7 +13,7 @@
</el-tabs>
</div>
<el-dropdown @command="handleCommand">
<span class="flex items-center px-3">
<span class="flex items-center px-3 cursor-pointer tab-dropdown">
<icon :size="16" name="el-icon-arrow-down" />
</span>
<template #dropdown>
@@ -58,9 +58,22 @@ const handleCommand = (command: any) => {
}
}
</script>
<style lang="scss" scoped>
.app-tabs {
@apply border-t border-br;
padding: 0 16px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--health-border, var(--el-border-color-lighter));
.tab-dropdown {
height: 40px;
border-radius: 6px;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--el-fill-color);
}
}
:deep(.el-tabs) {
height: 40px;
.el-tabs {
@@ -80,35 +93,19 @@ const handleCommand = (command: any) => {
}
&__item {
font-weight: normal;
padding: 0 15px !important;
padding: 0 16px !important;
box-sizing: border-box;
color: var(--el-text-color-secondary);
transition: color 0.2s ease;
&.is-active {
color: var(--el-text-color-primary);
background-color: var(--el-color-primary-light-9);
&::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background-color: var(--el-color-primary);
margin-right: 6px;
border-radius: 50%;
vertical-align: 2px;
}
&::after {
position: absolute;
content: '';
display: block;
top: 0;
height: 2px;
left: 0;
width: 100%;
background-color: var(--el-color-primary);
}
color: var(--health-primary-dark, var(--el-color-primary));
font-weight: 500;
}
.is-icon-close {
color: var(--el-text-color-regular);
color: var(--el-text-color-secondary);
vertical-align: -2px;
border-radius: 50%;
transition: all 0.2s ease;
&:hover {
color: var(--color-white);
background-color: var(--el-color-danger);
@@ -116,7 +113,9 @@ const handleCommand = (command: any) => {
}
}
&__active-bar {
display: none;
height: 2px;
border-radius: 2px;
background-color: var(--health-primary, var(--el-color-primary));
}
}
}
+13 -3
View File
@@ -1,7 +1,7 @@
<template>
<main class="main-wrap h-full bg-page">
<main class="main-wrap h-full">
<el-scrollbar>
<div class="px-2 py-4">
<div class="main-content">
<router-view v-if="isRouteShow" v-slot="{ Component, route }">
<keep-alive :include="includeList" :max="20">
<component :is="Component" :key="route.fullPath" />
@@ -24,4 +24,14 @@ const isRouteShow = computed(() => appStore.isRouteShow)
const includeList = computed(() => (settingStore.openMultipleTabs ? tabsStore.getCacheTabList : []))
</script>
<style></style>
<style lang="scss" scoped>
.main-wrap {
height: 100%;
background: var(--el-bg-color);
}
.main-content {
padding: 16px 20px 24px;
min-height: 100%;
}
</style>
@@ -100,7 +100,7 @@ import theme_light from '@/assets/images/theme_white.png'
import useSettingStore from '@/stores/modules/setting'
const settingStore = useSettingStore()
const predefineColors = ref(['#409EFF', '#28C76F', '#EA5455', '#FF9F43', '#01CFE8', '#4A5DFF'])
const predefineColors = ref(['#0EA5E9', '#38BDF8', '#0284C7', '#16A34A', '#DC2626', '#0369A1'])
const sideThemeList = [
{
type: 'dark',
@@ -44,11 +44,13 @@ const handleClick = () => {
overflow: hidden;
cursor: pointer;
user-select: none;
@apply flex items-center p-2 relative;
@apply flex items-center px-3 relative;
.logo-title {
width: 70%;
position: absolute;
@apply text-xl;
@apply text-base font-medium;
color: var(--el-text-color-primary);
}
.title-width-enter-active {
@@ -67,33 +67,53 @@ const themeClass = computed(() => `theme-${props.theme}`)
&.theme-dark {
.el-menu {
:deep(.el-menu-item) {
margin: 2px 10px;
border-radius: 6px;
&.is-active {
@apply bg-primary border-primary;
background-color: rgba(255, 255, 255, 0.12);
color: #fff;
font-weight: 500;
}
}
:deep(.el-sub-menu__title) {
margin: 2px 10px;
border-radius: 6px;
}
}
:deep(.el-menu--collapse) {
.el-sub-menu.is-active .el-sub-menu__title {
@apply bg-primary #{!important};
background-color: rgba(255, 255, 255, 0.12) !important;
}
}
}
&.theme-light {
:deep(.el-menu) {
.el-menu-item {
border-color: transparent;
margin: 2px 10px;
border-radius: 6px;
color: var(--el-text-color-regular);
&.is-active {
@apply bg-primary-light-9 border-r-2 border-primary;
background-color: var(--health-primary-light, var(--el-color-primary-light-9));
color: var(--health-primary-dark, var(--el-color-primary));
font-weight: 500;
}
}
.el-sub-menu__title {
margin: 2px 10px;
border-radius: 6px;
color: var(--el-text-color-regular);
}
.el-menu-item:hover,
.el-sub-menu__title:hover {
color: var(--el-color-primary);
background-color: var(--el-fill-color);
color: var(--el-text-color-primary);
}
}
}
.el-menu {
border-right: none;
background: transparent;
padding: 10px 0;
&:not(.el-menu--collapse) {
width: var(--aside-width);
}
@@ -1,6 +1,10 @@
<template>
<div class="side" :style="sideStyle">
<side-logo v-if="settingStore.showLogo" :show-title="!isCollapsed" :theme="sideTheme" />
<side-logo
v-if="settingStore.showLogo && appStore.isMobile"
:show-title="!isCollapsed"
:theme="sideTheme"
/>
<side-menu
:routes="routes"
:is-collapsed="isCollapsed"
@@ -45,9 +49,9 @@ const sideStyle = computed(() => {
})
const menuProp = computed(() => {
return {
backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : '',
textColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : '',
activeTextColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : ''
backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : 'transparent',
textColor: sideTheme.value == 'dark' ? 'rgba(255,255,255,0.85)' : '',
activeTextColor: sideTheme.value == 'dark' ? '#ffffff' : ''
}
})
const handleSelect = () => {
@@ -60,8 +64,8 @@ const handleSelect = () => {
<style lang="scss" scoped>
.side {
position: relative;
z-index: 999;
@apply border-r border-br-light h-full flex flex-col;
background-color: var(--side-dark-color, var(--el-bg-color));
z-index: 10;
@apply h-full flex flex-col;
background-color: var(--side-dark-color, var(--health-bg-sidebar, var(--el-bg-color)));
}
</style>
+25 -6
View File
@@ -1,23 +1,42 @@
<template>
<div class="layout-default flex h-screen w-full">
<!-- shrink-0避免主内容区宽表格/图表把侧栏压缩到折叠宽度 -->
<div class="layout-default flex flex-col h-screen w-full bg-page">
<div class="app-header shrink-0">
<layout-header />
</div>
<div class="layout-body flex flex-1 min-h-0">
<div class="app-aside shrink-0">
<layout-sidebar />
</div>
<div class="flex-1 flex flex-col min-w-0">
<div class="app-header">
<layout-header />
</div>
<div class="app-content flex flex-1 flex-col min-w-0 bg-body">
<multiple-tabs v-if="settingStore.openMultipleTabs" />
<div class="app-main flex-1 min-h-0">
<layout-main />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import useSettingStore from '@/stores/modules/setting'
import LayoutHeader from './components/header/index.vue'
import MultipleTabs from './components/header/multiple-tabs.vue'
import LayoutMain from './components/main.vue'
import LayoutSidebar from './components/sidebar/index.vue'
const settingStore = useSettingStore()
</script>
<style lang="scss" scoped>
.layout-body {
flex: 1;
min-height: 0;
}
.app-content {
border-left: 1px solid var(--health-border, var(--el-border-color-lighter));
}
</style>
+34 -31
View File
@@ -1,43 +1,46 @@
:root.dark {
color-scheme: dark;
--table-header-bg-color: var(--el-bg-color);
--el-bg-color-page: #0a0a0a;
--el-bg-color: #1d2124;
--el-bg-color-overlay: #1d1e1f;
--el-text-color-primary: #e5eaf3;
--el-text-color-regular: #cfd3dc;
--el-text-color-secondary: #a3a6ad;
--el-text-color-placeholder: #8d9095;
--el-text-color-disabled: #6c6e72;
--el-border-color-darker: #636466;
--el-border-color-dark: #58585b;
--el-border-color: #4c4d4f;
--el-border-color-light: #414243;
--el-border-color-lighter: #363637;
--el-border-color-extra-light: #2b2b2c;
--el-fill-color-darker: #424243;
--el-fill-color-dark: #39393a;
--el-fill-color: #303030;
--el-fill-color-light: #262727;
--el-fill-color-lighter: #1d1d1d;
--el-fill-color-extra-light: #191919;
--table-header-bg-color: #1E293B;
--health-bg-page: #0F1419;
--health-bg-sidebar: #151B23;
--health-text-primary: #F1F5F9;
--health-text-secondary: #94A3B8;
--health-border: #334155;
--health-border-light: #1E293B;
--health-primary-light: rgba(14, 165, 233, 0.15);
--el-bg-color-page: var(--health-bg-page);
--el-bg-color: #151B23;
--el-bg-color-overlay: #1E293B;
--el-text-color-primary: var(--health-text-primary);
--el-text-color-regular: #CBD5E1;
--el-text-color-secondary: #94A3B8;
--el-text-color-placeholder: #64748B;
--el-text-color-disabled: #475569;
--el-border-color-darker: #475569;
--el-border-color-dark: #334155;
--el-border-color: #334155;
--el-border-color-light: #334155;
--el-border-color-lighter: #1E293B;
--el-border-color-extra-light: #151B23;
--el-fill-color-darker: #334155;
--el-fill-color-dark: #1E293B;
--el-fill-color: #1E293B;
--el-fill-color-light: #151B23;
--el-fill-color-lighter: #0F1419;
--el-fill-color-extra-light: #1E293B;
--el-fill-color-blank: var(--el-bg-color);
--el-mask-color: rgba(0, 0, 0, 0.8);
--el-mask-color-extra-light: rgba(0, 0, 0, 0.3);
--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.36), 0px 8px 20px rgba(0, 0, 0, 0.72);
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.72);
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.72);
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.72), 0px 12px 32px #000000,
0px 8px 16px -8px #000000 !important;
/* wangeditor主题 */
--el-mask-color: rgba(0, 0, 0, 0.72);
--el-mask-color-extra-light: rgba(0, 0, 0, 0.36);
--el-box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
--el-box-shadow-light: 0 1px 4px rgba(0, 0, 0, 0.25);
--el-box-shadow-lighter: 0 1px 2px rgba(0, 0, 0, 0.2);
--el-box-shadow-dark: 0 4px 16px rgba(0, 0, 0, 0.4);
--w-e-textarea-bg-color: var(--el-bg-color);
--w-e-textarea-color: var(--el-text-color-primary);
--w-e-textarea-border-color: var(--el-border-color);
--w-e-textarea-slight-border-color: var(--el-border-color-light);
--w-e-textarea-slight-color: var(--el-border-color);
--w-e-textarea-slight-bg-color: var(--el-bg-color-page);
/* --w-e-textarea-selected-border-color: #b4d5ff;
--w-e-textarea-handler-bg-color: #4290f7; */
--w-e-toolbar-color: var(--el-text-color-primary);
--w-e-toolbar-bg-color: var(--el-bg-color);
--w-e-toolbar-active-color: var(--el-text-color-primary);
+30 -6
View File
@@ -23,7 +23,7 @@
flex: none;
display: flex;
flex-direction: column;
border-radius: 5px;
border-radius: 8px;
&.body-padding .el-dialog__body {
padding: 0;
@@ -56,17 +56,27 @@
}
.el-table {
--el-table-header-text-color: var(--el-text-color-primary);
--el-table-header-text-color: var(--el-text-color-regular);
--el-table-header-bg-color: var(--table-header-bg-color);
--el-table-border-color: var(--health-border, var(--el-border-color-lighter));
--el-table-row-hover-bg-color: var(--el-fill-color-light);
font-size: var(--el-font-size-base);
border-radius: 8px;
overflow: hidden;
thead {
th {
font-weight: 400;
font-weight: 500;
}
}
}
.el-card {
border-radius: 8px;
border-color: var(--health-border, var(--el-border-color-lighter));
box-shadow: none;
}
.el-input-group__prepend {
background-color: var(--el-fill-color-blank);
}
@@ -78,22 +88,30 @@
.el-menu--popup-container {
&.theme-light {
.el-menu {
border-radius: 8px;
padding: 4px;
.el-menu-item {
border-radius: 6px;
&.is-active {
@apply bg-primary-light-9 border-primary border-r-2;
background-color: var(--health-primary-light, var(--el-color-primary-light-9));
color: var(--health-primary-dark, var(--el-color-primary));
font-weight: 500;
}
}
.el-menu-item:hover,
.el-sub-menu__title:hover {
color: var(--el-color-primary);
background-color: var(--el-fill-color-light);
}
}
}
&.theme-dark {
.el-menu {
border-radius: 8px;
padding: 4px;
.el-menu-item {
border-radius: 6px;
&.is-active {
@apply bg-primary;
background-color: rgba(255, 255, 255, 0.12);
}
}
}
@@ -110,6 +128,12 @@
}
}
.el-button {
border-radius: 6px;
font-weight: 500;
transition: all 0.2s ease;
}
.el-button--primary {
--el-button-hover-link-text-color: var(--el-color-primary-light-3);
}
+11
View File
@@ -1,6 +1,12 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
body {
@apply text-base text-tx-primary overflow-hidden min-w-[375px];
font-feature-settings: 'tnum' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.form-tips {
@apply text-tx-secondary text-xs leading-6 mt-1;
}
@@ -15,4 +21,9 @@ body {
/* NProgress */
#nprogress .bar {
@apply bg-primary #{!important};
height: 3px !important;
}
#nprogress .peg {
box-shadow: 0 0 8px var(--el-color-primary), 0 0 4px var(--el-color-primary) !important;
}
+40 -31
View File
@@ -1,49 +1,58 @@
:root {
--el-font-family: theme(fontFamily.sans);
--el-font-weight-primary: 400;
--el-menu-item-height: 46px;
--el-menu-item-height: 42px;
--el-menu-sub-item-height: var(--el-menu-item-height);
--el-menu-icon-width: 18px;
--aside-width: 200px;
--navbar-height: 50px;
--navbar-height: 52px;
--color-white: #ffffff;
--table-header-bg-color: #f8f8f8;
--table-header-bg-color: #F8FAFC;
--el-font-size-extra-large: 18px;
--el-menu-base-level-padding: 16px;
--el-menu-level-padding: 26px;
--el-menu-base-level-padding: 14px;
--el-menu-level-padding: 24px;
--el-font-size-large: 16px;
--el-font-size-medium: 15px;
--el-font-size-base: 14px;
--el-font-size-small: 13px;
--el-font-size-extra-small: 12px;
/* 天蓝点缀 + 中性底色(天蓝仅用于主色/激活态,不铺满全局) */
--health-primary: #0EA5E9;
--health-primary-light: #E0F2FE;
--health-primary-dark: #0284C7;
--health-bg-page: #F1F5F9;
--health-bg-sidebar: #FFFFFF;
--health-text-primary: #1E293B;
--health-text-secondary: #64748B;
--health-border: #E2E8F0;
--health-border-light: #EEF2F6;
--el-bg-color: var(--color-white);
--el-bg-color-page: #f6f6f6;
--el-bg-color-page: var(--health-bg-page);
--el-bg-color-overlay: #ffffff;
--el-text-color-primary: #333333;
--el-text-color-regular: #666666;
--el-text-color-secondary: #999999;
--el-text-color-placeholder: #a8abb2;
--el-text-color-disabled: #c0c4cc;
--el-border-color: #dcdfe6;
--el-border-color-light: #e4e7ed;
--el-border-color-lighter: #ebeef5;
--el-border-color-extra-light: #f2f2f2;
--el-border-color-dark: #d4d7de;
--el-border-color-darker: #cdd0d6;
--el-fill-color: #f0f2f5;
--el-fill-color-light: #f8f8f8;
--el-fill-color-lighter: #fafafa;
--el-fill-color-extra-light: #fafcff;
--el-fill-color-dark: #ebedf0;
--el-fill-color-darker: #e6e8eb;
--el-text-color-primary: var(--health-text-primary);
--el-text-color-regular: var(--health-text-secondary);
--el-text-color-secondary: #94A3B8;
--el-text-color-placeholder: #94A3B8;
--el-text-color-disabled: #CBD5E1;
--el-border-color: var(--health-border);
--el-border-color-light: var(--health-border);
--el-border-color-lighter: var(--health-border-light);
--el-border-color-extra-light: #F8FAFC;
--el-border-color-dark: #CBD5E1;
--el-border-color-darker: #94A3B8;
--el-fill-color: #F1F5F9;
--el-fill-color-light: #F8FAFC;
--el-fill-color-lighter: #FAFBFC;
--el-fill-color-extra-light: #F8FAFC;
--el-fill-color-dark: #E2E8F0;
--el-fill-color-darker: #CBD5E1;
--el-fill-color-blank: #ffffff;
/* 过亮会盖住抽屉/弹窗下的内容;Element Loading 与部分蒙层共用此变量 */
--el-mask-color: rgba(255, 255, 255, 0.5);
--el-mask-color-extra-light: rgba(255, 255, 255, 0.22);
-el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08);
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12),
0px 8px 16px -8px rgba(0, 0, 0, 0.16);
--el-mask-color: rgba(255, 255, 255, 0.65);
--el-mask-color-extra-light: rgba(255, 255, 255, 0.28);
--el-box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
--el-box-shadow-light: 0 1px 3px rgba(15, 23, 42, 0.06);
--el-box-shadow-lighter: 0 1px 2px rgba(15, 23, 42, 0.04);
--el-box-shadow-dark: 0 4px 12px rgba(15, 23, 42, 0.08);
}
+78 -8
View File
@@ -1,14 +1,27 @@
<template>
<div class="login flex flex-col">
<div class="flex-1 flex items-center justify-center">
<div class="login-card flex rounded-md overflow-hidden">
<div class="flex-1 h-full hidden md:inline-block">
<image-contain :src="config.login_image" :width="400" height="100%" />
<div class="login-bg-decoration"></div>
<div class="flex-1 flex items-center justify-center relative z-10">
<div class="login-card flex rounded-xl overflow-hidden shadow-lg">
<div class="login-visual hidden md:flex flex-col justify-center items-center relative">
<div class="login-visual-content">
<div class="login-visual-icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="20" y="8" width="8" height="32" rx="2" fill="currentColor" opacity="0.9"/>
<rect x="8" y="20" width="32" height="8" rx="2" fill="currentColor" opacity="0.9"/>
</svg>
</div>
<h2 class="login-visual-title">中医在线问诊平台</h2>
<p class="login-visual-desc">专业 · 安全 · 可信赖的医疗管理系统</p>
</div>
</div>
<div
class="login-form bg-body flex flex-col justify-center px-10 py-10 md:w-[420px] w-[380px] flex-none mx-auto"
>
<div class="text-center text-3xl font-medium mb-8">{{ config.web_name }}</div>
<div class="text-center mb-8">
<div class="login-form-badge">管理后台</div>
<div class="text-2xl font-semibold text-tx-primary mt-3">{{ config.web_name }}</div>
</div>
<!-- 企业微信自动授权中 -->
<div v-if="wxWorkAutoLogin" class="text-center py-10">
@@ -287,11 +300,68 @@ onMounted(async () => {
<style lang="scss" scoped>
.login {
background-image: url('./images/login_bg.png');
@apply min-h-screen bg-no-repeat bg-center bg-cover;
position: relative;
@apply min-h-screen overflow-hidden;
background: var(--health-bg-page, #F1F5F9);
.login-bg-decoration {
display: none;
}
.login-card {
height: auto;
min-height: 400px;
min-height: 440px;
border: 1px solid var(--health-border, var(--el-border-color-lighter));
box-shadow: var(--el-box-shadow-light);
background: var(--el-bg-color);
}
.login-visual {
width: 400px;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
padding: 48px 40px;
border-right: 1px solid var(--health-border, var(--el-border-color-lighter));
.login-visual-icon {
width: 56px;
height: 56px;
border-radius: 14px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
svg {
width: 28px;
height: 28px;
}
}
.login-visual-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 10px;
color: var(--el-text-color-primary);
}
.login-visual-desc {
font-size: 14px;
color: var(--el-text-color-secondary);
line-height: 1.7;
}
}
.login-form-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
+3 -26
View File
@@ -32,12 +32,8 @@
<!-- 列表区域 -->
<el-card class="!border-none mt-4" shadow="never">
<div class="mb-4 flex items-center gap-2">
<div class="mb-4">
<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>
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
@@ -46,8 +42,7 @@
<el-tab-pane label="语音" name="3"></el-tab-pane>
</el-tabs>
<el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="title" label="标题" min-width="80" />
<el-table-column label="预览" width="100">
@@ -134,9 +129,8 @@ import MaterialPicker from '@/components/material/picker.vue'
import Upload from '@/components/upload/index.vue'
const loading = ref(false)
const tableData = ref<any[]>([])
const tableData = ref([])
const total = ref(0)
const selectedIds = ref<number[]>([])
const searchData = reactive<any>({
title: '',
@@ -204,7 +198,6 @@ const getList = async () => {
const res = await apiAssetResourceList(buildQueryParams())
tableData.value = res.lists
total.value = res.count
selectedIds.value = []
} catch (e) {
console.error(e)
} finally {
@@ -276,22 +269,6 @@ 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 () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
@@ -1,129 +0,0 @@
<template>
<template v-if="needsReconcile">
<el-button
v-perms="['tcm.prescriptionOrder/confirmGancaoSubmission']"
type="danger"
size="small"
plain
@click="openDialog"
>核对甘草提交</el-button
>
<el-dialog
v-model="visible"
title="人工核对甘草提交"
width="min(92vw, 520px)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-alert
title="请先在甘草后台核对。本操作会写入不可变更的审计记录。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="100px" @submit.prevent="submit">
<el-form-item label="核对结果" required>
<el-radio-group v-model="form.resolution">
<el-radio label="CONFIRM_SUCCESS">确认已创建</el-radio>
<el-radio label="CONFIRM_NOT_CREATED">确认未创建</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.resolution === 'CONFIRM_SUCCESS'"
label="甘草单号"
required
>
<el-input v-model="form.remote_order_no" maxlength="64" clearable />
</el-form-item>
<el-form-item label="核对依据" required>
<el-input
v-model="form.note"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="例如:核对时间、甘草后台查询条件及结果"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit"
>确认并记录</el-button
>
</template>
</el-dialog>
</template>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { prescriptionOrderConfirmGancaoSubmission } from '@/api/tcm'
import feedback from '@/utils/feedback'
const props = defineProps<{ order: Record<string, any> | null | undefined }>()
const emit = defineEmits<{ resolved: [] }>()
const needsReconcile = computed(() => {
const target = String(props.order?.pharmacy_claim_target || '')
.trim()
.toLowerCase()
const status = String(props.order?.pharmacy_claim_status || '')
.trim()
.toUpperCase()
const leaseExpiresAt = Number(props.order?.pharmacy_claim_lease_expires_at || 0)
const expiredPending =
status === 'PENDING' &&
leaseExpiresAt > 0 &&
leaseExpiresAt <= Math.floor(Date.now() / 1000)
return (
target === 'gancao' && (expiredPending || ['UNKNOWN', 'PENDING_RECONCILE'].includes(status))
)
})
const visible = ref(false)
const submitting = ref(false)
const form = reactive({
resolution: 'CONFIRM_SUCCESS' as 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED',
remote_order_no: '',
note: ''
})
function openDialog() {
form.resolution = 'CONFIRM_SUCCESS'
form.remote_order_no = ''
form.note = ''
visible.value = true
}
async function submit() {
const id = Number(props.order?.id)
if (!id) return
if (form.resolution === 'CONFIRM_SUCCESS' && !form.remote_order_no.trim()) {
feedback.msgError('请填写甘草药方单号')
return
}
if (!form.note.trim()) {
feedback.msgError('请填写甘草后台核对依据')
return
}
submitting.value = true
try {
await prescriptionOrderConfirmGancaoSubmission({
id,
resolution: form.resolution,
remote_order_no:
form.resolution === 'CONFIRM_SUCCESS' ? form.remote_order_no.trim() : '',
note: form.note.trim()
})
feedback.msgSuccess('甘草提交核对已记录')
visible.value = false
emit('resolved')
} catch {
// Request interceptor presents the server-side reconciliation error.
} finally {
submitting.value = false
}
}
</script>
@@ -30,13 +30,6 @@
round
class="ml-2"
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
<el-tag
v-if="detailData?.ej_pharmacy_order_no"
type="warning"
effect="plain"
round
class="ml-2"
>洛阳 {{ detailData.ej_pharmacy_order_no }}</el-tag>
<!-- 完整版发货类型切换等 -->
<slot v-if="detailData" name="header-extra" :detail="detailData" />
</div>
@@ -334,27 +327,7 @@
</div>
</div>
</el-descriptions-item>
<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>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<template v-if="detailHasAuxHerbs">
<div>
@@ -405,9 +378,6 @@
</div>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="detailDietaryText" label="忌口" :span="2">
{{ detailDietaryText }}
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">
{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}
@@ -658,7 +628,7 @@
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item v-if="showInternalCost" label="内部成本">
@@ -807,18 +777,7 @@
class="po-panel border-gray-100 mt-4"
>
<template #header>
<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>
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
<el-timeline-item
@@ -838,182 +797,19 @@
</el-timeline>
<el-empty v-else description="暂无操作日志" :image-size="64" />
</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>
</el-drawer>
</template>
<script lang="ts" setup name="PrescriptionOrderDetailDrawer">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { Refresh, Loading, Search, Van } from '@element-plus/icons-vue'
import {
prescriptionOrderDetail,
prescriptionOrderLogs,
prescriptionOrderAddLog,
prescriptionOrderLogisticsTrace,
prescriptionOrderLogisticsJdUpdate,
prescriptionOrderPaidPayOrders,
prescriptionOrderPatchPrescriptionUsage
prescriptionOrderPaidPayOrders
} from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
@@ -1032,7 +828,6 @@ import {
consumerRxAuditTag,
expressCompanyLabel,
logActionText,
auditStatusText,
formatPayOrderSource,
normalizeBizPhone,
recipientVsPrescriptionPhoneMismatch,
@@ -1042,11 +837,7 @@ import {
logisticsTraceLineUrgent,
analyzeLogisticsPayloadUrgent,
parseLogisticsTracePayload,
canUpdateAmount,
formatDietaryTaboo,
type ServicePackageOption,
normalizeServicePackageOptions,
formatServicePackageLabels
canUpdateAmount
} from './prescription-order-utils'
const props = withDefaults(
@@ -1078,7 +869,6 @@ const emit = defineEmits<{
(e: 'view-prescription'): void
(e: 'test-gancao-preview'): void
(e: 'view-patient'): void
(e: 'detail-changed'): void
}>()
const userStore = useUserStore()
@@ -1106,8 +896,6 @@ const detailPrescription = computed(() => {
return p && typeof p === 'object' ? p : null
})
const detailDietaryText = computed(() => formatDietaryTaboo(detailPrescription.value?.dietary_taboo))
const detailLinkedPayOrders = computed(() => {
const d = detailData.value
if (!d) return []
@@ -1294,24 +1082,36 @@ const detailFullAddress = computed(() => {
})
//
const servicePackageOptions = ref<ServicePackageOption[]>([])
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
async function loadServicePackageOptions() {
if (servicePackageOptions.value.length > 0) return
try {
const data: any = await getDictData({ type: 'server_order' })
const opts = normalizeServicePackageOptions(data?.server_order)
if (opts.length > 0) {
servicePackageOptions.value = opts
}
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
} catch {
/* 请求被同参数请求取消或失败时保留现值,open() 时会重试 */
servicePackageOptions.value = []
}
}
const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
)
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('、')
}
onMounted(() => {
loadServicePackageOptions()
@@ -1336,196 +1136,6 @@ 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[]) {
if (!diagnosisId) {
@@ -1650,8 +1260,6 @@ async function updateJdLogistics() {
// /
async function open(id: number) {
// axios
void loadServicePackageOptions()
//
detailData.value = null
detailUnlinkedPayOrders.value = []
@@ -11,45 +11,6 @@ export const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
if (!row) return false
if (String(row.gancao_reciperl_order_no || '').trim()) return true
if (String(row.ej_pharmacy_order_no || '').trim()) return true
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
return true
}
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
String(row.pharmacy_claim_status || '').toUpperCase()
)
}
export type SupplyMode = 'gancao' | 'direct' | 'self'
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
if (
String(row?.ship_mode || '')
.trim()
.toLowerCase() === 'direct'
)
return 'direct'
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
return 'self'
}
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
const mode = supplyModeKey(row)
if (mode === 'direct') return '洛阳直发'
return mode === 'gancao' ? '甘草' : '自营'
}
export function supplyModeTagType(
row: Record<string, unknown> | null | undefined
): 'success' | 'warning' | 'info' {
const mode = supplyModeKey(row)
if (mode === 'direct') return 'warning'
return mode === 'gancao' ? 'success' : 'info'
}
export function formatTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
@@ -173,41 +134,25 @@ export function logActionText(act: string) {
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款',
manual_log: '手工备注',
assign_assistant: '改派医助',
add_pay_order: '补齐支付单',
set_ship_mode: '发货类型'
refund: '退款'
}
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 }) {
const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
wechat_work: '企业微信',
fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账'
}
if (paymentMethod && methodMap[paymentMethod]) {
@@ -395,100 +340,3 @@ export function canUpdateAmount(row: { id?: number; fulfillment_status?: number
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
export function formatDietaryTaboo(raw: unknown): string {
if (Array.isArray(raw)) {
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
}
if (typeof raw === 'string' && raw.trim()) {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join('、')
}
return ''
}
/** 服务套餐 dictserver_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
}
+14 -47
View File
@@ -39,7 +39,7 @@
<el-radio-button label="rejected">驳回</el-radio-button>
</el-radio-group>
<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
v-model="formData.source_filter"
size="small"
@@ -48,7 +48,7 @@
>
<el-radio-button label="all">全部</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>
</div>
<div class="pl-toolbar__line pl-toolbar__line--second">
@@ -146,7 +146,7 @@
size="small"
effect="plain"
>
空白处方
系统代开
</el-tag>
<span v-else class="text-gray-400 text-sm">手工</span>
</template>
@@ -290,7 +290,7 @@
type="warning"
size="small"
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-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
@@ -437,7 +437,6 @@
<p>主方服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">辅方服法{{ rxAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="slipDietaryText">忌口{{ slipDietaryText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
@@ -696,12 +695,7 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect
v-model="editForm.herbs[row.index].name"
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="w-full"
/>
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -737,12 +731,7 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect
v-model="editForm.herbs[row.index].name"
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="w-full"
/>
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -1304,14 +1293,6 @@
<div v-show="createOrderStep === 1" class="create-order-step-panel">
<el-row :gutter="20">
<el-col v-if="canSelectShipMode" :span="24">
<el-form-item label="发货药房" prop="ship_mode">
<el-radio-group v-model="createOrderForm.ship_mode">
<el-radio-button label="gancao">甘草药房</el-radio-button>
<el-radio-button label="direct">洛阳药房</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="复诊">
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
@@ -1715,7 +1696,6 @@ import { roleAll } from '@/api/perms/role'
import { usePaging } from '@/hooks/usePaging'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import type { FormInstance, FormRules } from 'element-plus'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
@@ -1728,7 +1708,7 @@ import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方'
type HerbRow = { medicine_id?: number; name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type AuxUsageForm = {
dosage_amount?: number
@@ -1833,10 +1813,6 @@ function normalizeHerbRow(raw: any): HerbRow {
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
if (Number.isInteger(medicineId) && medicineId > 0) {
row.medicine_id = medicineId
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
@@ -1880,7 +1856,7 @@ const formData = reactive({
creator_ids: [] as number[],
/** all | passed | not_passed | pending | rejectedall 表示不限,接口传参会转为空) */
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,
start_time: '',
end_time: ''
@@ -2029,7 +2005,6 @@ const createOrderForm = reactive({
service_package: [] as string[],
express_company: 'auto',
tracking_number: '',
ship_mode: 'gancao' as 'gancao' | 'direct',
fee_type: 3,
amount: 0,
internal_cost: undefined as number | undefined,
@@ -2038,10 +2013,6 @@ const createOrderForm = reactive({
pay_order_ids: [] as number[]
})
//
//
const canSelectShipMode = computed(() => hasPermission(['tcm.prescriptionOrder/setShipMode']))
// API 使
const regionOptions = ref([])
@@ -2280,7 +2251,6 @@ function resetCreateOrderForm() {
createOrderForm.service_package = []
createOrderForm.express_company = 'auto'
createOrderForm.tracking_number = ''
createOrderForm.ship_mode = 'gancao'
createOrderForm.fee_type = 3
createOrderForm.amount = 0
createOrderForm.internal_cost = undefined
@@ -2454,7 +2424,6 @@ async function submitCreateOrderFromPrescription() {
: '',
express_company: createOrderForm.express_company || 'auto',
tracking_number: createOrderForm.tracking_number || '',
ship_mode: createOrderForm.ship_mode,
fee_type: createOrderForm.fee_type,
amount: createOrderForm.amount,
remark_extra: createOrderForm.remark_extra || '',
@@ -3631,7 +3600,7 @@ function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }
return herbs
}
async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
const q = rawName.trim()
if (!q) return null
try {
@@ -3642,10 +3611,8 @@ async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
return exact.length === 1
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
: null
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
} catch {
return null
}
@@ -3666,12 +3633,12 @@ async function handlePasteRecipeImport() {
const resolved: HerbRow[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const medicine = await resolvePasteHerbFromLibrary(row.name)
if (!medicine) {
const name = await resolvePasteHerbNameFromLibrary(row.name)
if (!name) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
@@ -157,11 +157,7 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect
v-model="row.name"
v-model:medicine-id="row.medicine_id"
:disabled="editMode === 'view'"
/>
<MedicineNameSelect v-model="row.name" :disabled="editMode === 'view'" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="150">
@@ -261,7 +257,7 @@ const editForm = reactive({
id: 0,
prescription_name: '',
formula_type: '主方',
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
herbs: [] as Array<{ name: string; dosage: number }>,
is_public: 0,
disable_edit: 0
})
@@ -283,12 +283,6 @@
<el-option label="未指派" value="0" />
</el-select>
</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-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
@@ -298,7 +292,7 @@
:fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams"
:page-size="pager.size"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「处方」导出主方/辅方药材明细;「主方/辅方服用方式、天数」与详情侧栏同口径(主方/辅方天数分别取处方 usage_days、辅方 aux_usage.usage_days;「天数」列为订单 medication_days)。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
/>
</el-form-item>
</el-form>
@@ -309,17 +303,6 @@
<div class="po-table-toolbar__title-wrap">
<span class="po-table-title text-gray-800">订单列表</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 class="po-focus-board">
<div
@@ -356,15 +339,7 @@
</div>
</div>
</div>
<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 :data="pager.lists" size="default" stripe class="po-data-table" :row-class-name="orderRowClassName">
<el-table-column label="订单号" min-width="178" show-overflow-tooltip>
<template #default="{ row }">
<div class="po-order-no-cell">
@@ -373,9 +348,9 @@
<el-tag
size="small"
effect="plain"
:type="supplyModeTagType(row)"
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
>
{{ supplyModeLabel(row) }}
{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}
</el-tag>
<span class="text-gray-400">#{{ row.id }}</span>
</div>
@@ -431,41 +406,16 @@
</div>
<div v-if="canViewFinanceFields() && row.internal_cost != null && row.internal_cost !== ''" class="flex justify-between">
<span class="text-gray-500">内部成本</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>
<span class="font-medium text-orange-600">¥{{ formatMoney(row.internal_cost) }}</span>
</div>
</div>
</el-popover>
</div>
</template>
</el-table-column>
<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>
<el-table-column v-if="canViewFinanceFields()" label="内部成本" width="92">
<template #default="{ row }">
<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>
{{ row.internal_cost != null && row.internal_cost !== '' ? `¥${row.internal_cost}` : '—' }}
</template>
</el-table-column>
<el-table-column label="处方审核" width="110">
@@ -594,13 +544,13 @@
@click="confirmWithdraw(row)"
>撤回</el-button>
<el-button
v-if="canUploadPharmacyRow(row)"
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
v-if="canUploadGancaoRow(row)"
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
type="warning"
link
:loading="pharmacySubmitId === row.id"
@click="confirmUploadToPharmacy(row)"
>上传药</el-button>
:loading="gancaoSubmitId === row.id"
@click="confirmSubmitGancaoRecipel(row)"
>上传药</el-button>
</div>
</template>
</el-table-column>
@@ -623,7 +573,6 @@
@view-prescription="detailData && openPrescriptionView(detailData)"
@test-gancao-preview="testGancaoPreviewFromDetail"
@view-patient="openDiagnosisPatientDetailFromOrder"
@detail-changed="getLists"
>
<template #header-extra="{ detail }">
<div class="flex items-center gap-2 ml-4 shrink-0">
@@ -638,7 +587,7 @@
<el-radio-button label="gancao">甘草药房</el-radio-button>
<el-radio-button
label="direct"
:disabled="isShipModeLocked(detail)"
:disabled="isShipModeLockedToGancao(detail)"
>洛阳药房</el-radio-button>
</el-radio-group>
<el-tag
@@ -650,19 +599,6 @@
</div>
</template>
<template #header-actions="{ detail }">
<gancao-submission-reconcile-button
:order="detail"
@resolved="handleGancaoSubmissionResolved"
/>
<el-button
v-if="canUploadPharmacyRow(detail)"
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
type="warning"
size="small"
plain
:loading="pharmacySubmitId === detail.id"
@click="confirmUploadToPharmacy(detail)"
>上传药房</el-button>
<el-button
v-if="canRxAudit(detail)"
v-perms="['tcm.prescriptionOrder/auditPrescription']"
@@ -768,7 +704,7 @@
<el-form-item label="新金额" prop="amount">
<el-input-number
v-model="updateAmountForm.amount"
:min="0"
:min="0.01"
:step="0.01"
:precision="2"
controls-position="right"
@@ -1038,11 +974,10 @@
class="w-full"
>
<el-option
v-for="item in editServicePackageSelectOptions"
v-for="item in servicePackageOptions"
:key="item.value"
:label="item.name"
:value="item.value"
:disabled="item.status === 0"
/>
</el-select>
</el-form-item>
@@ -1963,7 +1898,6 @@
<p v-if="rxAuxUsageText">辅服法{{ rxAuxUsageText }}</p>
</template>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="slipDietaryText">忌口{{ slipDietaryText }}</p>
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
@@ -2179,34 +2113,6 @@
</div>
</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 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" />
</div>
@@ -2218,7 +2124,6 @@ import { useRoute } from 'vue-router'
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import {
TCM_ASSISTANT_ROLE_ID,
PRESCRIPTION_AUDIT_ROLE_IDS,
@@ -2239,16 +2144,8 @@ import {
analyzeLogisticsPayloadUrgent,
parseLogisticsTracePayload,
canUpdateAmount,
formatDietaryTaboo,
type SlipFormulaType,
type SlipAuxUsageForm,
type ServicePackageOption,
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions,
isRemoteSnapshotLocked,
supplyModeLabel,
supplyModeTagType
type SlipAuxUsageForm
} from './components/prescription-order-utils'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
@@ -2270,10 +2167,9 @@ import {
prescriptionOrderRefund,
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderBatchAssignAssistant,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderLinkPayOrder,
prescriptionOrderUploadToPharmacy,
prescriptionOrderSubmitGancaoRecipel,
prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail,
prescriptionLibraryLists,
@@ -2370,75 +2266,11 @@ const canViewFinanceFields = () => {
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 servicePackageOptions = ref<ServicePackageOption[]>([])
//
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
@@ -2559,7 +2391,8 @@ const loadRegionData = async () => {
const loadServicePackageOptions = async () => {
try {
const data = await getDictData({ type: 'server_order' })
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
} catch (error) {
console.error('加载服务套餐选项失败:', error)
servicePackageOptions.value = []
@@ -2593,13 +2426,12 @@ function handleFulfillmentStatusTabClick(value: number | '') {
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
const supplyModeTabs = [
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
{ label: '甘草', value: 'gancao' as const },
{ label: '洛阳直发', value: 'direct' as const },
{ label: '自营', value: 'self' as const }
]
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
queryParams.supply_mode = value
resetPage()
}
@@ -2633,12 +2465,10 @@ const queryParams = reactive({
fulfillment_status: '' as number | '',
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
supply_mode: '' as '' | 'gancao' | 'self',
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0' */
service_channel: '' as '' | '0',
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
has_aux_formula: '' as '' | '0' | '1',
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
exclude_fulfillment_cancelled: 0 as number,
/** 下单人(关联操作日志 audit_rx_* / audit_pay_* */
@@ -2786,11 +2616,6 @@ function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): R
p.service_channel = String(p.service_channel).trim()
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 (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
delete p.start_time
@@ -3029,7 +2854,6 @@ function handleReset() {
queryParams.payment_slip_audit_status = ''
queryParams.supply_mode = ''
queryParams.service_channel = ''
queryParams.has_aux_formula = ''
queryParams.audit_admin_id = ''
queryParams.exclude_fulfillment_cancelled = 0
resetParams()
@@ -3089,7 +2913,14 @@ function canEditRow(row: {
return false
}
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0
// /// 6 edit
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
// (1)(2)
return fs === 1 || fs === 2
@@ -3117,7 +2948,6 @@ function canRevokeRxAudit(row: {
payment_slip_audit_status?: number
fulfillment_status?: number
}) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const fs = Number(row.fulfillment_status)
if (fs === 3 || fs === 4 || fs === 6) return false
const rxStatus = Number(row.prescription_audit_status)
@@ -3140,7 +2970,6 @@ function canRevokePayAudit(row: {
}
function canWithdrawRow(row: { fulfillment_status?: number }) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
//
return Number(row.fulfillment_status) === 1
}
@@ -3165,13 +2994,13 @@ function shipModeLabel(v: unknown): string {
return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房'
}
function isShipModeLocked(row: { gancao_reciperl_order_no?: string | null; ej_pharmacy_order_no?: string | null }) {
return isRemoteSnapshotLocked(row as Record<string, unknown>)
function isShipModeLockedToGancao(row: { gancao_reciperl_order_no?: string | null }) {
return String(row.gancao_reciperl_order_no || '').trim() !== ''
}
function canEditShipMode(row: { fulfillment_status?: number; gancao_reciperl_order_no?: string; ej_pharmacy_order_no?: string }) {
function canEditShipMode(row: { fulfillment_status?: number }) {
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4 && !isShipModeLocked(row)
return fs !== 3 && fs !== 4
}
const shipModeSaving = ref(false)
@@ -3213,22 +3042,10 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
}
}
async function handleGancaoSubmissionResolved() {
await refreshCurrentPrescriptionOrderDetail()
getLists()
}
function canAddPayOrderRow(row: {
fulfillment_status?: number
amount?: number | string
linked_pay_paid_total?: number | string
}) {
// (5) / (6)
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
// (5) / (6)
const fs = Number(row.fulfillment_status)
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
return fs === 5 || fs === 6
}
function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
@@ -3248,27 +3065,14 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 5
}
function canUploadPharmacyRow(row: {
function canUploadGancaoRow(row: {
prescription_audit_status?: number
fulfillment_status?: number
ship_mode?: string
gancao_reciperl_order_no?: string
ej_pharmacy_order_no?: string
ej_pharmacy_status?: string
ej_pharmacy_review_status?: string
can_upload_pharmacy?: boolean
}) {
if (row.can_upload_pharmacy === false) return false
// (1)
const rxStatus = Number(row.prescription_audit_status)
const fs = Number(row.fulfillment_status)
const ejRejected = ['REJECTED'].includes(String(row.ej_pharmacy_status || '').toUpperCase())
|| ['REJECTED'].includes(String(row.ej_pharmacy_review_status || '').toUpperCase())
if (rxStatus !== 1 || ([3, 4, 8, 9, 10, 11, 12].includes(fs) && !(fs === 9 && ejRejected))) return false
if (normalizeShipMode(row.ship_mode) === 'direct') {
return (String(row.ej_pharmacy_order_no || '').trim() === '' || ejRejected)
&& String(row.gancao_reciperl_order_no || '').trim() === ''
}
return String(row.gancao_reciperl_order_no || '').trim() === '' && String(row.ej_pharmacy_order_no || '').trim() === ''
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
return rxStatus === 1 && gancaoOrderNo === ''
}
// PrescriptionOrderDetailDrawer
@@ -3300,8 +3104,8 @@ const updateAmountRules: FormRules = {
{
validator: (_rule, value, callback) => {
const num = Number(value)
if (!Number.isFinite(num) || num < 0) {
callback(new Error('订单金额不能为负数'))
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('订单金额必须大于0'))
return
}
callback()
@@ -3358,8 +3162,7 @@ function openUpdateAmount() {
return
}
updateAmountForm.id = Number(row?.id) || 0
const amt = Number(row?.amount)
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
updateAmountForm.amount = Number(row?.amount) || undefined
updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate())
}
@@ -3492,11 +3295,6 @@ const editForm = reactive({
diagnosis_creator_dept_path: ''
})
/** 编辑弹窗下拉:字典项 + 当前已选但字典中缺失的兜底项 */
const editServicePackageSelectOptions = computed(() =>
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
)
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
@@ -3729,7 +3527,18 @@ async function openEdit(row: {
editForm.dose_unit = d.dose_unit || '剂'
editForm.prev_staff = d.prev_staff || ''
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.tracking_number = d.tracking_number || ''
editForm.fee_type = Number(d.fee_type) || 3
@@ -3894,7 +3703,7 @@ async function confirmWithdraw(row: { id: number }) {
}
}
const pharmacySubmitId = ref(0)
const gancaoSubmitId = ref(0)
const gancaoPreviewDrawerVisible = ref(false)
const gancaoPreviewLoading = ref(false)
const gancaoPreviewData = ref<any>(null)
@@ -4033,24 +3842,18 @@ async function testGancaoPreviewFromDetail() {
}
}
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
const isLuoyang = normalizeShipMode(row.ship_mode) === 'direct'
const pharmacyName = isLuoyang ? '洛阳药房' : '甘草药房'
async function confirmSubmitGancaoRecipel(row: { id: number }) {
try {
await ElMessageBox.confirm(
`将把本单关联处方提交至${pharmacyName},提交成功后不可切换发货药房。确定继续?`,
'上传药房',
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html 。确定继续?',
'上传甘草药方',
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
)
pharmacySubmitId.value = row.id
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
gancaoSubmitId.value = row.id
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
const d = res?.data ?? res
const no = d?.pharmacy_order_no != null
? String(d.pharmacy_order_no)
: d?.recipel_order_no != null
? String(d.recipel_order_no)
: ''
feedback.msgSuccess(no ? `上传成功,${pharmacyName}单号:${no}` : '上传成功')
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
getLists()
await detailDrawerRef.value?.refreshIfCurrent(row.id)
} catch (e: any) {
@@ -4058,7 +3861,7 @@ async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string })
/* 拦截器已提示 */
}
} finally {
pharmacySubmitId.value = 0
gancaoSubmitId.value = 0
}
}
@@ -4552,18 +4355,7 @@ async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: n
}
}
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
}
function openAddPayOrder(row: { id: number; diagnosis_id?: number; pay_order_ids?: number[] }) {
addPayOrderRowId.value = row.id
addPayOrderForm.add_mode = 'create'
addPayOrderForm.order_type = 3
@@ -4752,7 +4544,12 @@ const slipAuxHerbs = computed(() =>
slipHerbsList.value.filter((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
)
const slipDietaryText = computed(() => formatDietaryTaboo(prescriptionViewData.value?.dietary_taboo))
const slipDietaryText = computed(() => {
const d = prescriptionViewData.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
if (typeof d === 'string' && d.trim()) return d.trim()
return ''
})
const slipChuwanVisible = computed(() => {
const d = prescriptionViewData.value
@@ -365,8 +365,8 @@
<el-tag
size="small"
effect="plain"
:type="supplyModeTagType(row)"
>{{ supplyModeLabel(row) }}</el-tag>
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
>{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}</el-tag>
<span class="po-card__id">#{{ row.id }}</span>
</div>
</div>
@@ -500,11 +500,7 @@
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
<span class="text-red-500">退款</span>
</el-dropdown-item>
<el-dropdown-item
v-if="canUploadPharmacyRow(row)"
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
command="uploadPharmacy"
>上传药房</el-dropdown-item>
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item>
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
<span class="text-red-500">撤回订单</span>
</el-dropdown-item>
@@ -548,10 +544,6 @@
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
</div>
<div v-if="detailData" class="po-detail-drawer-actions flex flex-wrap items-center gap-2">
<gancao-submission-reconcile-button
:order="detailData"
@resolved="handleGancaoSubmissionResolved"
/>
<el-button
v-if="canRxAudit(detailData)"
v-perms="['tcm.prescriptionOrder/auditPrescription']"
@@ -866,9 +858,6 @@
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<div class="flex flex-col gap-1 text-sm leading-relaxed">
<div>
<span v-if="detailHasAuxHerbs" class="text-gray-500 mr-1">主方</span>
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
@@ -879,68 +868,9 @@
</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>
<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>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<template v-if="detailHasAuxHerbs">
<div>
<span class="text-gray-500 mr-1">主方</span>
每天
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '— 次' }}
· 处方开立
{{
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
? detailPrescription.usage_days + ' 天'
: '— 天'
}}
</div>
<div v-if="detailAuxUsage">
<span class="text-gray-500 mr-1">辅方</span>
每天
{{ detailAuxUsage.times_per_day ? detailAuxUsage.times_per_day + ' 次' : '— 次' }}
· 处方开立
{{
detailAuxUsage.usage_days != null && Number(detailAuxUsage.usage_days) > 0
? detailAuxUsage.usage_days + ' 天'
: '— 天'
}}
</div>
</template>
<template v-else>
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
@@ -953,7 +883,6 @@
: '—'
}}
</div>
</template>
<div>
<span class="text-gray-500">订单设置</span>
{{
@@ -965,9 +894,6 @@
</div>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="detailDietaryText" label="忌口" :span="2">
{{ detailDietaryText }}
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">
{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}
@@ -1210,7 +1136,7 @@
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本">
@@ -1369,7 +1295,7 @@
<el-form-item label="新金额" prop="amount">
<el-input-number
v-model="updateAmountForm.amount"
:min="0"
:min="0.01"
:step="0.01"
:precision="2"
controls-position="right"
@@ -1596,11 +1522,10 @@
class="w-full"
>
<el-option
v-for="item in editServicePackageSelectOptions"
v-for="item in servicePackageOptions"
:key="item.value"
:label="item.name"
:value="item.value"
:disabled="item.status === 0"
/>
</el-select>
</el-form-item>
@@ -1893,12 +1818,10 @@
/>
<el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip">
<el-form-item label="发货方式">
<el-tag
:type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'"
effect="plain"
size="default"
>{{ shipDialogModeDisplay }}</el-tag>
<span class="text-xs text-gray-400 ml-2">请在订单详情顶部发货类型中设置此处不可修改</span>
<el-radio-group v-model="shipForm.ship_mode">
<el-radio label="gancao">甘草药方发</el-radio>
<el-radio label="direct">药房直发</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="承运商">
<el-select v-model="shipForm.express_company" class="w-full">
@@ -2225,93 +2148,6 @@
</template>
</el-dialog>
<!-- 修改服用参数主方 / 辅方 / 订单设置 -->
<el-dialog
v-model="patchUsageVisible"
title="修改服用参数"
width="92%"
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="resetPatchUsageForm"
>
<el-form
ref="patchUsageFormRef"
:model="patchUsageForm"
:rules="patchUsageRules"
label-width="96px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="aux_usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.aux_usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</template>
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
<el-form-item label="服用天数" prop="medication_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.medication_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="patchUsageVisible = false">取消</el-button>
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
</template>
</el-dialog>
<!-- 处方详情查看处方单样式 -->
<el-drawer
v-model="prescriptionViewVisible"
@@ -2475,7 +2311,6 @@
<div class="rx-text">
<p>服法{{ rxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="slipDietaryText">忌口{{ slipDietaryText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
@@ -2707,7 +2542,6 @@ import {
User
} from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
prescriptionOrderAuditPayment,
@@ -2727,28 +2561,14 @@ import {
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderPatchPrescriptionUsage,
prescriptionOrderLinkPayOrder,
prescriptionOrderRequestCompletion,
prescriptionOrderUploadToPharmacy,
prescriptionOrderSubmitGancaoRecipel,
prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail,
getDoctors,
getAssistants
} from '@/api/tcm'
import {
formatDietaryTaboo,
type ServicePackageOption,
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions,
formatServicePackageLabels,
normalizeSlipAuxUsageForm,
prescriptionHasAuxFormula,
isRemoteSnapshotLocked,
supplyModeLabel,
supplyModeTagType
} from './components/prescription-order-utils'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import { getDictData } from '@/api/app'
@@ -2844,7 +2664,7 @@ const canViewFinanceFields = () => {
const regionOptions = ref([])
//
const servicePackageOptions = ref<ServicePackageOption[]>([])
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
@@ -2965,7 +2785,8 @@ const loadRegionData = async () => {
const loadServicePackageOptions = async () => {
try {
const data = await getDictData({ type: 'server_order' })
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
} catch (error) {
console.error('加载服务套餐选项失败:', error)
servicePackageOptions.value = []
@@ -2998,13 +2819,12 @@ function handleFulfillmentStatusTabClick(value: number | '') {
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
const supplyModeTabs = [
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
{ label: '甘草', value: 'gancao' as const },
{ label: '洛阳直发', value: 'direct' as const },
{ label: '自营', value: 'self' as const }
]
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
queryParams.supply_mode = value
resetPage()
}
@@ -3038,8 +2858,8 @@ const queryParams = reactive({
fulfillment_status: '' as number | '',
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '',
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
supply_mode: '' as '' | 'gancao' | 'self',
/** 下单人(关联操作日志 audit_rx_* / audit_pay_* */
audit_admin_id: '' as number | ''
})
@@ -3374,6 +3194,28 @@ function feeTypeText(t: number | undefined) {
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) {
if (s === 1) return '已通过'
if (s === 2) return '已驳回'
@@ -3474,7 +3316,13 @@ function canEditRow(row: {
return false
}
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
const gcLocked = gcNo !== '' || gcTime > 0
if (gcLocked) {
return [1, 2, 5, 7].includes(fs)
}
return fs === 1 || fs === 2
}
@@ -3501,7 +3349,6 @@ function canRevokeRxAudit(row: {
payment_slip_audit_status?: number
fulfillment_status?: number
}) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
const fs = Number(row.fulfillment_status)
if (fs === 3 || fs === 4 || fs === 6) return false
const rxStatus = Number(row.prescription_audit_status)
@@ -3524,7 +3371,6 @@ function canRevokePayAudit(row: {
}
function canWithdrawRow(row: { fulfillment_status?: number }) {
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
//
return Number(row.fulfillment_status) === 1
}
@@ -3555,26 +3401,14 @@ function canQuickTrackRow(row: { fulfillment_status?: number }) {
return Number(row.fulfillment_status) === 5
}
function canUploadPharmacyRow(row: {
function canUploadGancaoRow(row: {
prescription_audit_status?: number
fulfillment_status?: number
gancao_reciperl_order_no?: string
ej_pharmacy_order_no?: string
ej_pharmacy_status?: string
ej_pharmacy_review_status?: string
can_upload_pharmacy?: boolean
}) {
if (row.can_upload_pharmacy === false) return false
// (1)
const rxStatus = Number(row.prescription_audit_status)
const fulfillmentStatus = Number(row.fulfillment_status)
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
const ejOrderNo = String(row.ej_pharmacy_order_no || '').trim()
const ejRejected = String(row.ej_pharmacy_status || '').toUpperCase() === 'REJECTED'
|| String(row.ej_pharmacy_review_status || '').toUpperCase() === 'REJECTED'
return rxStatus === 1
&& (![3, 4, 8, 9, 10, 11, 12].includes(fulfillmentStatus) || (fulfillmentStatus === 9 && ejRejected))
&& gancaoOrderNo === ''
&& (ejOrderNo === '' || ejRejected)
return rxStatus === 1 && gancaoOrderNo === ''
}
function orderStatusText(s: number | undefined) {
@@ -3612,7 +3446,7 @@ const h5ActiveFilterCount = computed(() => {
// H5:
function hasMoreCardActions(row: Record<string, any>) {
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadPharmacyRow(row) || canWithdrawRow(row)
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
}
// H5:
@@ -3620,7 +3454,7 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
else if (cmd === 'complete') confirmComplete(row as any)
else if (cmd === 'refund') openRefundOrder(row as any)
else if (cmd === 'uploadPharmacy') confirmUploadToPharmacy(row as any)
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any)
else if (cmd === 'withdraw') confirmWithdraw(row as any)
}
@@ -3634,10 +3468,6 @@ const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
)
// --- ---
const workflowActiveStep = computed(() => {
if (!detailData.value) return 0
@@ -3749,8 +3579,6 @@ const detailPrescription = computed(() => {
return p && typeof p === 'object' ? p : null
})
const detailDietaryText = computed(() => formatDietaryTaboo(detailPrescription.value?.dietary_taboo))
function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
@@ -3794,16 +3622,6 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
const detailHasAuxHerbs = computed(() => prescriptionHasAuxFormula(detailPrescription.value as any))
const detailAuxUsage = computed(() => {
const rx = detailPrescription.value as any
if (!rx || !prescriptionHasAuxFormula(rx)) return null
const raw = rx.aux_usage
if (raw == null || raw === '' || (Array.isArray(raw) && raw.length === 0)) return null
return normalizeSlipAuxUsageForm(raw, rx.prescription_type || '浓缩水丸')
})
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
@@ -3908,8 +3726,8 @@ const updateAmountRules: FormRules = {
{
validator: (_rule, value, callback) => {
const num = Number(value)
if (!Number.isFinite(num) || num < 0) {
callback(new Error('订单金额不能为负数'))
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('订单金额必须大于0'))
return
}
callback()
@@ -3952,8 +3770,6 @@ function logActionText(act: string) {
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单',
@@ -4032,101 +3848,6 @@ const patchRxPatientRules: FormRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
const rules: FormRules = {
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
}
return rules
})
function openPatchUsageDialog() {
const rx = detailPrescription.value
const ord = detailData.value
if (!rx || !ord?.id || !ord.prescription_id) {
feedback.msgWarning('无处方数据')
return
}
const aux = detailAuxUsage.value
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageForm.aux_times_per_day =
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.medication_days = undefined
}
async function submitPatchUsage() {
const form = patchUsageFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
const ordId = detailData.value?.id
if (!ordId) return
patchUsageSaving.value = true
try {
const payload: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
patchUsageVisible.value = false
await refreshCurrentPrescriptionOrderDetail()
await fetchLogs(ordId)
getLists()
} catch {
/* 拦截器已提示 */
} finally {
patchUsageSaving.value = false
}
}
function openPatchRxPatientDialog() {
const rx = detailPrescription.value
const ord = detailData.value
@@ -4158,8 +3879,7 @@ function openUpdateAmount() {
return
}
updateAmountForm.id = Number(row?.id) || 0
const amt = Number(row?.amount)
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
updateAmountForm.amount = Number(row?.amount) || undefined
updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate())
}
@@ -4332,10 +4052,6 @@ const editForm = reactive({
diagnosis_creator_dept_path: ''
})
const editServicePackageSelectOptions = computed(() =>
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
)
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
@@ -4381,14 +4097,12 @@ function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unk
const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
wechat_work: '企业微信',
fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账'
}
if (paymentMethod && methodMap[paymentMethod]) {
@@ -4586,7 +4300,18 @@ async function openEdit(row: {
editForm.dose_unit = d.dose_unit || '剂'
editForm.prev_staff = d.prev_staff || ''
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.tracking_number = d.tracking_number || ''
editForm.fee_type = Number(d.fee_type) || 3
@@ -4746,7 +4471,7 @@ async function confirmWithdraw(row: { id: number }) {
}
}
const pharmacySubmitId = ref(0)
const gancaoSubmitId = ref(0)
const gancaoPreviewDrawerVisible = ref(false)
const gancaoPreviewLoading = ref(false)
const gancaoPreviewData = ref<any>(null)
@@ -4838,19 +4563,18 @@ async function testGancaoPreviewFromDetail() {
}
}
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
async function confirmSubmitGancaoRecipel(row: { id: number }) {
try {
const target = String(row.ship_mode || 'gancao') === 'direct' ? '洛阳药房' : '甘草药房'
await ElMessageBox.confirm(
`确认将本单关联处方上传至${target}`,
'上传药房',
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
'上传甘草药方',
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
)
pharmacySubmitId.value = row.id
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
gancaoSubmitId.value = row.id
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
const d = res?.data ?? res
const no = String(d?.pharmacy_order_no || d?.recipel_order_no || '').trim()
feedback.msgSuccess(no ? `上传成功,药房单号:${no}` : '上传成功')
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
getLists()
if (detailVisible.value && Number(detailData.value?.id) === row.id) {
try {
@@ -4866,7 +4590,7 @@ async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string })
/* 拦截器已提示 */
}
} finally {
pharmacySubmitId.value = 0
gancaoSubmitId.value = 0
}
}
@@ -4969,10 +4693,6 @@ const shipForm = reactive({
tracking_number: ''
})
const shipDialogModeDisplay = computed(() =>
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
)
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
shipRowId.value = row.id
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
@@ -5012,13 +4732,6 @@ async function submitShip() {
}
}
async function handleGancaoSubmissionResolved() {
getLists()
if (detailData.value?.id) {
await openDetail(Number(detailData.value.id))
}
}
const completeOrderStatusOptions = [
{ value: 3, label: '已完成' },
{ value: 7, label: '进行中' },
@@ -5472,7 +5185,12 @@ const slipHerbsList = computed(() => {
return Array.isArray(h) ? h : []
})
const slipDietaryText = computed(() => formatDietaryTaboo(prescriptionViewData.value?.dietary_taboo))
const slipDietaryText = computed(() => {
const d = prescriptionViewData.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
if (typeof d === 'string' && d.trim()) return d.trim()
return ''
})
const slipChuwanVisible = computed(() => {
const d = prescriptionViewData.value
+5 -3
View File
@@ -231,7 +231,8 @@ import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
import feedback from '@/utils/feedback'
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { adminLists } from '@/api/perms/admin'
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { ElMessageBox } from 'element-plus'
dayjs.extend(isoWeek)
@@ -490,9 +491,10 @@ async function removeSegment(row: RosterSegment) {
const loadDoctors = async () => {
try {
const res = await doctorLists({
const res = await adminLists({
page_no: 1,
page_size: 1000
page_size: 1000,
role_id: 1
})
tableData.value = (res?.lists || []).map((doctor: any) => ({
doctorId: doctor.id,
+2 -224
View File
@@ -283,15 +283,7 @@
<td class="col-name">{{ r.name }}</td>
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_count) }}</td>
<td class="col-assign" @click.stop="onLeaderboardAssignCellClick(r)">
<span
v-if="Number(r.assign_count ?? 0) > 0"
class="yeji-lead-cell--link"
>{{ formatLeaderboardInt(r.assign_count ?? 0) }}</span>
<template v-else>{{
formatLeaderboardInt(r.assign_count ?? 0)
}}</template>
</td>
<td class="col-assign">{{ formatLeaderboardInt(r.assign_count ?? 0) }}</td>
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
<span
v-if="Number(r.appointment_count ?? 0) > 0"
@@ -569,13 +561,7 @@
>{{ formatInt(r.lead_count) }}</span>
<template v-else>{{ formatInt(r.lead_count) }}</template>
</td>
<td @click.stop="onAssignCountCellClick($event, tb, r)">
<span
v-if="yejiAssignCountCellClickable(r)"
class="yeji-lead-cell--link"
>{{ formatInt(r.assign_count ?? 0) }}</span>
<template v-else>{{ formatInt(r.assign_count ?? 0) }}</template>
</td>
<td>{{ formatInt(r.assign_count ?? 0) }}</td>
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
<span
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
@@ -1370,59 +1356,6 @@
/>
</div>
</el-dialog>
<el-dialog
v-model="assignLinesDialogVisible"
width="min(960px, 96vw)"
destroy-on-close
class="yeji-leadlines-dialog"
>
<template #header>
<div class="yeji-unassigned-dialog__head">
<span class="yeji-unassigned-dialog__title">被指派明细</span>
<p class="yeji-unassigned-dialog__sub">{{ assignLinesSubtitle }}</p>
</div>
</template>
<p v-if="assignLinesApiNote" class="yeji-unassigned-dialog__note">{{ assignLinesApiNote }}</p>
<div v-loading="assignLinesLoading" class="yeji-unassigned-dialog__body">
<el-table
v-if="assignLinesRows.length > 0 || assignLinesLoading"
:data="assignLinesRows"
size="small"
stripe
border
max-height="440"
class="yeji-unassigned-dialog__table"
:empty-text="assignLinesLoading ? '加载中…' : '暂无数据'"
>
<el-table-column type="index" label="#" width="46" :index="assignLinesIndexMethod" />
<el-table-column prop="diagnosis_id" label="诊单ID" width="88" />
<el-table-column prop="patient_name" label="患者" min-width="100" show-overflow-tooltip />
<el-table-column prop="patient_phone" label="联系电话" min-width="120" show-overflow-tooltip />
<el-table-column prop="assistant_name" label="被指派医助" min-width="108" show-overflow-tooltip />
<el-table-column prop="assign_count" label="指派次数" width="88" align="right" />
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="156" show-overflow-tooltip />
</el-table>
<el-empty
v-if="!assignLinesLoading && assignLinesRows.length === 0"
description="暂无被指派明细"
/>
<div v-if="assignLinesCount > 0" class="yeji-leadlines-dialog__pager">
<el-pagination
:current-page="assignLinesPage"
:page-size="assignLinesPageSize"
background
layout="total, sizes, prev, pager, next, jumper"
:total="assignLinesCount"
:page-sizes="[10, 20, 50, 100]"
:disabled="assignLinesLoading"
:hide-on-single-page="false"
@current-change="onAssignLinesPageChange"
@size-change="onAssignLinesPageSizeChange"
/>
</div>
</div>
</el-dialog>
</div>
</template>
@@ -1443,7 +1376,6 @@ import {
yejiStatsLeadLines,
yejiStatsAppointmentLines,
yejiStatsRevisitBreakdown,
yejiStatsAssignLines,
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
import { prescriptionOrderLists } from '@/api/tcm'
@@ -1559,17 +1491,6 @@ interface YejiLeadLineRow {
external_contact_name: string
}
interface YejiAssignLineRow {
diagnosis_id: number
patient_name: string
patient_phone: string
assistant_id: number
assistant_name: string
assign_count: number
last_assign_time: number
last_assign_time_text: string
}
interface YejiRow {
dept_id: number
dept_name: string
@@ -1788,31 +1709,6 @@ type AppointmentLinesCtx =
}
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
const assignLinesDialogVisible = ref(false)
const assignLinesLoading = ref(false)
const assignLinesSubtitle = ref('')
const assignLinesApiNote = ref('')
const assignLinesRows = ref<YejiAssignLineRow[]>([])
const assignLinesCount = ref(0)
const assignLinesPage = ref(1)
const assignLinesPageSize = ref(20)
type AssignLinesCtx =
| {
mode: 'assistant'
assistant_id: number
name: string
start_date: string
end_date: string
}
| {
mode: 'dept'
dept_id: number
dept_name: string
start_date: string
end_date: string
}
const assignLinesContext = ref<AssignLinesCtx | null>(null)
const appointmentLinesShowChannelColumn = computed(() =>
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
)
@@ -3297,124 +3193,6 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
void openLeadLinesDialog(tb, row)
}
function yejiAssignCountCellClickable(r: YejiRow): boolean {
return r.dept_id > 0 && Number(r.assign_count ?? 0) > 0
}
function onAssignCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
if (!yejiAssignCountCellClickable(row)) {
return
}
ev.stopPropagation()
void openAssignLinesDialogForDept(tb, row)
}
function assignLinesIndexMethod(index: number) {
return (assignLinesPage.value - 1) * assignLinesPageSize.value + index + 1
}
function buildAssignLinesRequestParams(page: number, pageSize: number): Record<string, string | number> | null {
const ctx = assignLinesContext.value
if (!ctx) {
return null
}
const p: Record<string, string | number> = {
start_date: ctx.start_date,
end_date: ctx.end_date,
page,
page_size: pageSize,
}
if (ctx.mode === 'dept') {
p.dept_id = ctx.dept_id
} else {
p.assistant_id = ctx.assistant_id
}
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
return p
}
async function fetchAssignLinesPage() {
const params = buildAssignLinesRequestParams(assignLinesPage.value, assignLinesPageSize.value)
if (!params) {
return
}
assignLinesLoading.value = true
try {
const res: any = await yejiStatsAssignLines(params as any)
assignLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
assignLinesCount.value = Number(res?.count ?? 0)
assignLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载被指派明细失败')
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesApiNote.value = ''
} finally {
assignLinesLoading.value = false
}
}
async function openAssignLinesDialogForDept(tb: YejiTable, row: YejiRow) {
assignLinesContext.value = {
mode: 'dept',
dept_id: row.dept_id,
dept_name: row.dept_name,
start_date: tb.start_date,
end_date: tb.end_date,
}
assignLinesSubtitle.value = `${row.dept_name} · ${tb.start_date} ${tb.end_date}`
assignLinesApiNote.value = ''
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesPage.value = 1
assignLinesPageSize.value = 20
assignLinesDialogVisible.value = true
await fetchAssignLinesPage()
}
function onLeaderboardAssignCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
return
}
if (Number(r.assign_count ?? 0) <= 0) {
return
}
const lb = leaderboardBlock.value
assignLinesContext.value = {
mode: 'assistant',
assistant_id: r.admin_id,
name: r.name,
start_date: lb.start_date,
end_date: lb.end_date,
}
assignLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ${lb.end_date}`
assignLinesApiNote.value = ''
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesPage.value = 1
assignLinesPageSize.value = 20
assignLinesDialogVisible.value = true
void fetchAssignLinesPage()
}
function onAssignLinesPageChange(p: number) {
assignLinesPage.value = p
void fetchAssignLinesPage()
}
function onAssignLinesPageSizeChange(size: number) {
assignLinesPageSize.value = size
assignLinesPage.value = 1
void fetchAssignLinesPage()
}
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
{ key: 'event_time_text', label: '进线时间' },
{ key: 'reception_admin_name', label: '接待' },
@@ -1,686 +0,0 @@
<template>
<div class="mapping-page">
<header class="page-header">
<h1>洛阳药房药材映射</h1>
<el-button
v-if="status.sync_enabled"
v-perms="['pharmacy.medicineMapping/sync']"
type="primary"
:icon="Refresh"
:loading="syncing"
@click="handleSync"
>
增量同步
</el-button>
</header>
<section class="status-strip" v-loading="statusLoading">
<div class="status-item">
<span>目录</span>
<strong>{{ status.catalog_active }} / {{ status.catalog_total }}</strong>
<small>启用 / 总数</small>
</div>
<div class="status-item warning">
<span>未映射本地药材</span>
<strong>{{ status.unmapped_local }}</strong>
<small>仅统计启用药材</small>
</div>
<div class="status-item">
<span>同步游标</span>
<strong>{{ status.cursor }}</strong>
<small>{{ formatTime(status.last_success_time) || '尚未成功同步' }}</small>
</div>
<div class="status-item" :class="{ danger: !!status.last_error_summary }">
<span>最近同步</span>
<strong>{{
status.is_syncing ? '进行中' : status.last_error_summary ? '失败' : '正常'
}}</strong>
<small :title="status.last_error_summary">
{{
status.last_error_summary ||
formatTime(status.last_failure_time) ||
'无失败记录'
}}
</small>
</div>
</section>
<el-form class="filter-bar" inline @submit.prevent>
<el-form-item label="本地药材">
<el-input
v-model="query.local_name"
clearable
placeholder="名称"
:prefix-icon="Search"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="远端目录">
<el-input
v-model="query.remote_keyword"
clearable
placeholder="名称或编码"
:prefix-icon="Search"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="映射状态">
<el-select
v-model="query.mapping_status"
clearable
placeholder="全部"
style="width: 150px"
>
<el-option label="已映射" value="mapped" />
<el-option label="未映射" value="unmapped" />
<el-option label="远端失效" value="invalid" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="table-wrap">
<el-table v-loading="loading" :data="rows" border stripe table-layout="fixed">
<el-table-column label="本地药材" min-width="190" fixed="left">
<template #default="{ row }">
<div class="medicine-name">{{ row.local_name }}</div>
<div class="subline">
ID {{ row.local_medicine_id }} · {{ row.local_unit || '-' }}
</div>
</template>
</el-table-column>
<el-table-column label="本地状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.local_status === 1 ? 'success' : 'info'" size="small">
{{ row.local_status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="映射状态" width="105" align="center">
<template #default="{ row }">
<el-tag :type="mappingTag(row.mapping_status)" size="small">
{{ mappingText(row.mapping_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="洛阳药房目录" min-width="250">
<template #default="{ row }">
<template v-if="row.medicine_code">
<div class="medicine-name">{{ row.remote_name || '目录项不可用' }}</div>
<div class="subline code">{{ row.medicine_code }}</div>
</template>
<template v-else>
<div class="medicine-name">-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
{{ row.mapping_status === 0 ? '-' : row.remote_brand || '-' }}
</template>
</el-table-column>
<el-table-column label="单位" width="80" align="center">
<template #default="{ row }">{{ row.remote_unit || '-' }}</template>
</el-table-column>
<el-table-column label="价格" width="155" align="right">
<template #default="{ row }">
<template v-if="row.mapping_status !== 0">
<div>结算 ¥{{ formatPrice(row.settlement_price) }}</div>
<div class="subline">零售 ¥{{ formatPrice(row.retail_price) }}</div>
</template>
<template v-else>
<div>-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="版本 / 状态" width="130" align="center">
<template #default="{ row }">
<template v-if="row.medicine_code">
<div>v{{ row.catalog_version || 0 }}</div>
<div class="subline">
{{
row.remote_status === 1 && row.remote_deleted !== 1
? '远端启用'
: '远端停用'
}}
</div>
</template>
<template v-else>
<div>-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-perms="['pharmacy.medicineMapping/save']"
type="primary"
link
:icon="Link"
:disabled="row.local_status !== 1"
@click="openMapping(row)"
>
{{ row.mapping_status === 1 ? '更换' : '映射' }}
</el-button>
<el-button
v-if="row.mapping_status === 1 || row.mapping_status === 2"
v-perms="['pharmacy.medicineMapping/unlink']"
type="danger"
link
:icon="CloseBold"
@click="handleUnlink(row)"
>
解除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="query.page_no"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadRows"
@current-change="loadRows"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="`${editingRow?.mapping_status === 1 ? '更换' : '建立'}药材映射`"
width="min(560px, calc(100vw - 32px))"
destroy-on-close
>
<div class="local-summary">
<span>本地药材</span>
<strong>{{ editingRow?.local_name }}</strong>
<small
>ID {{ editingRow?.local_medicine_id }} ·
{{ editingRow?.local_unit || '-' }}</small
>
</div>
<el-form label-position="top" class="mapping-form">
<el-form-item label="洛阳药房药材">
<el-select
v-model="selectedCode"
filterable
remote
reserve-keyword
clearable
:remote-method="searchCatalog"
:loading="catalogLoading"
placeholder="输入药材名称或编码检索"
style="width: 100%"
>
<el-option
v-for="option in catalogOptions"
:key="option.medicine_code"
:label="`${option.name} · ${option.medicine_code}`"
:value="option.medicine_code"
>
<div class="option-row">
<span>{{ option.name }}</span>
<small
>{{ option.medicine_code }} · {{ option.brand || '无品牌' }} ·
{{ option.unit }}</small
>
</div>
</el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="!selectedCode"
@click="saveMapping"
>
保存映射
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="pharmacyMedicineMapping">
import { onMounted, reactive, ref } from 'vue'
import { CloseBold, Link, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import feedback from '@/utils/feedback'
import { createLatestRequestGuard } from './latest-request.mjs'
import {
medicineCatalogOptions,
medicineCatalogSync,
medicineMappingLists,
medicineMappingSave,
medicineMappingStatus,
medicineMappingUnlink,
type CatalogOption,
type MedicineMappingQuery,
type MedicineMappingRow,
type PharmacySyncResult,
type PharmacySyncStatus
} from '@/api/pharmacy'
const emptyStatus = (): PharmacySyncStatus => ({
sync_enabled: false,
cursor: 0,
last_success_time: 0,
last_failure_time: 0,
last_error_summary: '',
is_syncing: false,
catalog_total: 0,
catalog_active: 0,
unmapped_local: 0
})
const loading = ref(false)
const statusLoading = ref(false)
const syncing = ref(false)
const saving = ref(false)
const catalogLoading = ref(false)
const rows = ref<MedicineMappingRow[]>([])
const total = ref(0)
const status = ref<PharmacySyncStatus>(emptyStatus())
const query = reactive<MedicineMappingQuery>({
page_no: 1,
page_size: 20,
local_name: '',
remote_keyword: '',
mapping_status: ''
})
const dialogVisible = ref(false)
const editingRow = ref<MedicineMappingRow | null>(null)
const selectedCode = ref('')
const catalogOptions = ref<CatalogOption[]>([])
let catalogTimer: number | undefined
const listRequests = createLatestRequestGuard<MedicineMappingQuery>()
const catalogRequests = createLatestRequestGuard<{ keyword: string; localMedicineId: number }>()
const statusRequests = createLatestRequestGuard()
const formatPrice = (value: string | number | null | undefined) => {
const number = Number(value || 0)
return Number.isFinite(number) ? number.toFixed(2) : '0.00'
}
const formatTime = (timestamp: number) => {
if (!timestamp) return ''
return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false })
}
const mappingText = (value: number) => {
if (value === 1) return '已映射'
if (value === 2) return '远端失效'
return '未映射'
}
const mappingTag = (value: number): 'success' | 'warning' | 'info' => {
if (value === 1) return 'success'
if (value === 2) return 'warning'
return 'info'
}
const loadRows = async () => {
const ticket = listRequests.next({ ...query })
loading.value = true
try {
const response = await medicineMappingLists(ticket.snapshot)
if (listRequests.isLatest(ticket)) {
rows.value = response.lists || []
total.value = response.count || 0
}
} finally {
if (listRequests.isLatest(ticket)) {
loading.value = false
}
}
}
const loadStatus = async () => {
const ticket = statusRequests.next(undefined)
statusLoading.value = true
try {
const nextStatus = await medicineMappingStatus()
if (statusRequests.isLatest(ticket)) {
status.value = nextStatus
}
} finally {
if (statusRequests.isLatest(ticket)) {
statusLoading.value = false
}
}
}
const search = () => {
query.page_no = 1
void loadRows()
}
const resetFilters = () => {
query.local_name = ''
query.remote_keyword = ''
query.mapping_status = ''
search()
}
const handleSync = async () => {
syncing.value = true
try {
const result = (await medicineCatalogSync()) as PharmacySyncResult
feedback.msgSuccess(
`同步完成:拉取 ${result.pulled},新增 ${result.created},更新 ${result.updated},停用 ${result.deactivated}`
)
await Promise.all([loadRows(), loadStatus()])
} finally {
syncing.value = false
}
}
const searchCatalogNow = async (
keyword: string,
localMedicineId = Number(editingRow.value?.local_medicine_id || 0)
) => {
const ticket = catalogRequests.next({ keyword: keyword.trim(), localMedicineId })
catalogLoading.value = true
try {
const options = await medicineCatalogOptions({ keyword: ticket.snapshot.keyword, limit: 30 })
if (
catalogRequests.isLatest(ticket) &&
Number(editingRow.value?.local_medicine_id || 0) === ticket.snapshot.localMedicineId
) {
catalogOptions.value = options
}
} finally {
if (catalogRequests.isLatest(ticket)) {
catalogLoading.value = false
}
}
return ticket
}
const searchCatalog = (keyword: string) => {
if (catalogTimer) window.clearTimeout(catalogTimer)
catalogRequests.invalidate()
catalogTimer = window.setTimeout(() => void searchCatalogNow(keyword), 250)
}
const openMapping = async (row: MedicineMappingRow) => {
if (catalogTimer) window.clearTimeout(catalogTimer)
catalogRequests.invalidate()
const rowSnapshot = { ...row }
const localMedicineId = Number(rowSnapshot.local_medicine_id)
editingRow.value = rowSnapshot
selectedCode.value = rowSnapshot.mapping_status === 1 ? rowSnapshot.medicine_code || '' : ''
catalogOptions.value = []
dialogVisible.value = true
const initialTicket = await searchCatalogNow(rowSnapshot.local_name, localMedicineId)
if (
!catalogRequests.isLatest(initialTicket) ||
Number(editingRow.value?.local_medicine_id || 0) !== localMedicineId
)
return
if (
selectedCode.value &&
!catalogOptions.value.some((item) => item.medicine_code === selectedCode.value)
) {
await searchCatalogNow(selectedCode.value, localMedicineId)
}
}
const saveMapping = async () => {
if (!editingRow.value || !selectedCode.value) return
saving.value = true
try {
await medicineMappingSave({
local_medicine_id: editingRow.value.local_medicine_id,
medicine_code: selectedCode.value
})
dialogVisible.value = false
await Promise.all([loadRows(), loadStatus()])
} finally {
saving.value = false
}
}
const handleUnlink = async (row: MedicineMappingRow) => {
await ElMessageBox.confirm(
`确认解除“${row.local_name}”与 ${row.medicine_code} 的映射?`,
'解除映射',
{
type: 'warning',
confirmButtonText: '解除',
cancelButtonText: '取消'
}
)
await medicineMappingUnlink({ local_medicine_id: row.local_medicine_id })
await Promise.all([loadRows(), loadStatus()])
}
onMounted(() => {
void Promise.all([loadRows(), loadStatus()])
})
</script>
<style scoped>
.mapping-page {
min-width: 0;
padding: 16px;
color: var(--el-text-color-primary);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.page-header h1 {
margin: 0;
font-size: 20px;
line-height: 28px;
letter-spacing: 0;
}
.status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
min-height: 88px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-light);
background: var(--el-bg-color);
}
.status-item {
min-width: 0;
padding: 14px 16px;
border-right: 1px solid var(--el-border-color-lighter);
}
.status-item:last-child {
border-right: 0;
}
.status-item span,
.status-item small {
display: block;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-item strong {
display: block;
margin: 5px 0 2px;
font-size: 18px;
line-height: 24px;
}
.status-item.warning strong {
color: var(--el-color-warning-dark-2);
}
.status-item.danger strong,
.status-item.danger small {
color: var(--el-color-danger);
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0 8px;
padding: 14px 16px 0;
border: 1px solid var(--el-border-color-light);
border-bottom: 0;
background: var(--el-fill-color-extra-light);
}
.filter-bar :deep(.el-input) {
width: 190px;
}
.table-wrap {
min-width: 0;
overflow-x: auto;
}
.table-wrap :deep(.el-table) {
min-width: 1220px;
}
.medicine-name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.subline,
.muted {
margin-top: 3px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
overflow-x: auto;
padding-top: 16px;
}
.local-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
padding: 12px 14px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
}
.local-summary span,
.local-summary small {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.local-summary small {
grid-column: 2;
}
.mapping-form {
margin-top: 18px;
}
.option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.option-row small {
overflow: hidden;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.status-item:nth-child(2) {
border-right: 0;
}
.status-item:nth-child(-n + 2) {
border-bottom: 1px solid var(--el-border-color-lighter);
}
}
@media (max-width: 600px) {
.mapping-page {
padding: 12px;
}
.page-header {
align-items: stretch;
flex-direction: column;
}
.page-header .el-button {
width: 100%;
}
.status-strip {
grid-template-columns: 1fr;
}
.status-item,
.status-item:nth-child(2) {
border-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.status-item:last-child {
border-bottom: 0;
}
.filter-bar {
display: block;
}
.filter-bar :deep(.el-form-item),
.filter-bar :deep(.el-input),
.filter-bar :deep(.el-select) {
width: 100% !important;
}
.pagination-wrap {
justify-content: flex-start;
}
}
</style>
@@ -1,12 +0,0 @@
export interface LatestRequestTicket<T> {
readonly generation: number
readonly snapshot: T
}
export interface LatestRequestGuard<T> {
next(snapshot: T): LatestRequestTicket<T>
invalidate(): number
isLatest(ticket: LatestRequestTicket<T>): boolean
}
export function createLatestRequestGuard<T>(): LatestRequestGuard<T>
@@ -1,22 +0,0 @@
export function createLatestRequestGuard() {
let generation = 0
return {
next(snapshot) {
generation += 1
const stableSnapshot = Array.isArray(snapshot)
? [...snapshot]
: snapshot && typeof snapshot === 'object'
? { ...snapshot }
: snapshot
return Object.freeze({ generation, snapshot: stableSnapshot })
},
invalidate() {
generation += 1
return generation
},
isLatest(ticket) {
return ticket?.generation === generation
}
}
}
@@ -1,319 +0,0 @@
<template>
<div class="auto-assign-log-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="log-filter-form">
<el-form-item label="记录时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
@change="doSearch"
/>
</el-form-item>
<el-form-item label="执行日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
@change="doSearch"
/>
</el-form-item>
<el-form-item label="结果">
<el-select
v-model="queryParams.action"
clearable
placeholder="全部"
class="w-[120px]"
@change="doSearch"
>
<el-option label="已分配" value="1" />
<el-option label="未分配" value="0" />
</el-select>
</el-form-item>
<el-form-item label="回退">
<el-select
v-model="queryParams.is_rollback"
clearable
placeholder="全部"
class="w-[120px]"
@change="doSearch"
>
<el-option label="未回退" value="0" />
<el-option label="已回退" value="1" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="患者/手机号/医助/诊单ID"
clearable
class="w-[220px]"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="log-hint">
由每日定时任务待分配诊单自动指派写入按上月二诊复诊接诊率分档轮询分配&gt;70% 每人3条/60%~70%
每人2条/50%~60% 每人1条/&lt;50% 不分回退可将已分配医助撤回到自动分配前的原医助通常为一中心并写入指派日志
</div>
</el-card>
<el-card class="!border-none mt-3" shadow="never">
<div class="toolbar mb-3">
<el-button
v-perms="['stats.autoAssignLog/rollback']"
type="warning"
:disabled="selectedRows.length === 0"
:loading="rollbackLoading"
@click="handleBatchRollback"
>
回退选中{{ selectedRows.length }}
</el-button>
<span v-if="selectedRows.length > 0" class="toolbar-tip">
已选 {{ selectedRows.length }} 条可回退记录
</span>
</div>
<el-table
ref="tableRef"
:data="pager.lists"
v-loading="pager.loading"
size="default"
stripe
row-key="id"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
width="48"
:selectable="rowSelectable"
reserve-selection
/>
<el-table-column label="记录时间" width="160">
<template #default="{ row }">{{ row.create_time_text || '—' }}</template>
</el-table-column>
<el-table-column label="诊单" width="90" prop="diagnosis_id" />
<el-table-column label="患者" min-width="110">
<template #default="{ row }">
<div>{{ row.patient_name || '—' }}</div>
<div class="cell-sub">{{ row.patient_phone || '' }}</div>
</template>
</el-table-column>
<el-table-column label="结果" width="90" align="center">
<template #default="{ row }">
<el-tag :type="Number(row.action) === 1 ? 'success' : 'info'" size="small">
{{ row.action_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="回退状态" width="140">
<template #default="{ row }">
<template v-if="Number(row.is_rollback) === 1">
<el-tag type="warning" size="small">已回退</el-tag>
<div class="cell-sub">{{ row.rollback_time_text }}</div>
<div v-if="row.rollback_admin_name" class="cell-sub">
{{ row.rollback_admin_name }}
</div>
</template>
<el-tag v-else-if="Number(row.can_rollback) === 1" type="success" size="small">
可回退
</el-tag>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="医助" min-width="110">
<template #default="{ row }">
<template v-if="Number(row.assistant_id) > 0">
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="档位" width="100" align="center">
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
</el-table-column>
<el-table-column label="轮次" width="70" align="center">
<template #default="{ row }">
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
</template>
</el-table-column>
<el-table-column label="原因" min-width="280" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason }}</template>
</el-table-column>
<el-table-column label="统计月" width="90" prop="stat_month" align="center" />
<el-table-column label="批次" width="160">
<template #default="{ row }">
<span class="cell-sub">{{ row.batch_no }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="Number(row.can_rollback) === 1"
v-perms="['stats.autoAssignLog/rollback']"
link
type="warning"
:loading="rollbackLoading"
@click="handleSingleRollback(row)"
>
回退
</el-button>
<span v-else class="cell-sub"></span>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
</div>
</template>
<script setup lang="ts" name="statsAutoAssignLog">
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import { onMounted, reactive, ref, watch } from 'vue'
const dateRange = ref<[string, string] | null>(null)
const timeRange = ref<[string, string] | null>(null)
const tableRef = ref()
const selectedRows = ref<any[]>([])
const rollbackLoading = ref(false)
const queryParams = reactive({
start_date: '',
end_date: '',
start_time: '',
end_time: '',
action: '' as '' | '0' | '1',
is_rollback: '' as '' | '0' | '1',
keyword: ''
})
watch(dateRange, (val) => {
queryParams.start_date = val?.[0] ?? ''
queryParams.end_date = val?.[1] ?? ''
})
watch(timeRange, (val) => {
queryParams.start_time = val?.[0] ?? ''
queryParams.end_time = val?.[1] ?? ''
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: autoAssignLogLists,
params: queryParams
})
const doSearch = () => {
selectedRows.value = []
tableRef.value?.clearSelection?.()
resetPage()
}
const handleReset = () => {
dateRange.value = null
timeRange.value = null
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.start_time = ''
queryParams.end_time = ''
queryParams.action = ''
queryParams.is_rollback = ''
queryParams.keyword = ''
doSearch()
}
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows.filter((r) => Number(r.can_rollback) === 1)
}
const runRollback = async (ids: number[]) => {
if (ids.length === 0) return
await feedback.confirm(
`确认回退选中的 ${ids.length} 条自动分配?将把诊单医助撤回到自动分配前的原医助,并写入指派日志。`
)
rollbackLoading.value = true
try {
const res: any = await autoAssignLogRollback({ ids })
const success = Number(res?.success ?? 0)
const failed = Number(res?.failed ?? 0)
if (failed > 0 && Array.isArray(res?.messages) && res.messages.length) {
feedback.msgWarning(
`回退成功 ${success} 条,跳过/失败 ${failed} 条:${res.messages.slice(0, 3).join('')}`
)
} else {
feedback.msgSuccess(`回退成功 ${success}`)
}
selectedRows.value = []
tableRef.value?.clearSelection?.()
await getLists()
} finally {
rollbackLoading.value = false
}
}
const handleBatchRollback = () => {
const ids = selectedRows.value.map((r) => Number(r.id)).filter((id) => id > 0)
return runRollback(ids)
}
const handleSingleRollback = (row: any) => {
const id = Number(row?.id ?? 0)
if (id <= 0) return
return runRollback([id])
}
onMounted(() => getLists())
</script>
<style lang="scss" scoped>
.auto-assign-log-page {
.log-filter-form {
:deep(.el-form-item) {
margin-bottom: 8px;
}
}
.log-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>
+20 -113
View File
@@ -129,7 +129,7 @@
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>{{ amountPieTitle }}</span>
<span>诊单金额占比</span>
<span class="card-hint">TOP 10</span>
</div>
</template>
@@ -146,17 +146,17 @@
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>{{ secondaryPieTitle }}</span>
<span>加粉占比</span>
<span class="card-hint">TOP 10</span>
</div>
</template>
<v-charts
v-if="secondaryPieHasData"
v-if="fanPieHasData"
class="stats-chart"
:option="secondaryPieOption"
:option="fanPieOption"
autoresize
/>
<el-empty v-else :description="secondaryPieEmptyText" />
<el-empty v-else description="暂无加粉数据" />
</el-card>
</el-col>
</el-row>
@@ -311,13 +311,10 @@ const overview = reactive<Record<string, any>>({
amounts: [],
order_counts: [],
fan_counts: [],
rois: [],
appointment_counts: [],
interview_counts: []
rois: []
},
amount_share: [],
fan_share: [],
order_share: []
fan_share: []
}
})
@@ -395,7 +392,7 @@ const currentEntityId = computed({
}
})
const defaultSummaryCards: MetricCard[] = [
const summaryCards: MetricCard[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
@@ -413,15 +410,7 @@ const defaultSummaryCards: MetricCard[] = [
{ key: 'roi', label: 'ROI', type: 'ratio' }
]
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[] = [
const tableColumns: MetricColumn[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
@@ -442,18 +431,6 @@ const defaultTableColumns: MetricColumn[] = [
{ 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(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
@@ -461,14 +438,7 @@ const dateRangeText = computed(() => {
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
const amountPieHasData = computed(() => overview.charts.amount_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 fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
const rankingChartData = computed(() => {
const ranking = overview.charts?.ranking || {}
@@ -478,16 +448,14 @@ const rankingChartData = computed(() => {
return {
names,
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 : []),
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
}
})
const rankingChartOption = computed(() => {
const common = {
const rankingChartOption = computed(() => ({
tooltip: { trigger: 'axis' },
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
grid: { left: 48, right: 24, top: 48, bottom: 48 },
xAxis: {
type: 'category',
@@ -499,13 +467,7 @@ const rankingChartOption = computed(() => {
},
yAxis: [
{ type: 'value', name: '金额 / 数量' }
]
}
if (!isDoctorDimension.value) {
return {
...common,
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
],
series: [
{
name: '诊单金额',
@@ -535,53 +497,7 @@ const rankingChartOption = computed(() => {
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(() => ({
tooltip: { trigger: 'item' },
@@ -595,7 +511,7 @@ const amountPieOption = computed(() => ({
},
series: [
{
name: amountPieTitle.value,
name: '诊单金额',
type: 'pie',
radius: ['42%', '70%'],
center: ['50%', '42%'],
@@ -615,7 +531,7 @@ const amountPieOption = computed(() => ({
]
}))
const secondaryPieOption = computed(() => ({
const fanPieOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: {
bottom: 0,
@@ -627,7 +543,7 @@ const secondaryPieOption = computed(() => ({
},
series: [
{
name: secondaryPieTitle.value,
name: '加粉数',
type: 'pie',
radius: ['42%', '70%'],
center: ['50%', '42%'],
@@ -642,7 +558,7 @@ const secondaryPieOption = computed(() => ({
labelLayout: {
hideOverlap: true,
},
data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
data: overview.charts.fan_share
}
]
}))
@@ -742,18 +658,9 @@ const handleReset = () => {
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) => {
if (placeholder) return '—'
const value = isDoctorDimension.value && key === 'receive_rate'
? calcDoctorReceiveRate(source)
: source?.[key] ?? 0
const value = source?.[key] ?? 0
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
if (type === 'ratio') return Number(value || 0).toFixed(2)
+4 -4
View File
@@ -18,7 +18,7 @@
<el-tree-select
v-model="deptId"
:data="deptTreeOptions"
placeholder="二中心(全部)"
placeholder="全部部门"
clearable
filterable
check-strictly
@@ -43,10 +43,10 @@
</template>
<div class="rate-caliber">
<p>
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重<b>再剔除</b>名下存在履约拒收 / 退款业务订单的诊单
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重
</p>
<p>
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序编号为实单序号<b>统计诊次 = 实单序号 + 诊单偏移</b>默认偏移 0 1 笔实单为一诊偏移 1 1 笔实单为二诊偏移 2 1 笔实单为三诊5 笔实单等价七诊诊次<b>跨月累计不重置</b>诊单可在业务订单tab 配置偏移量
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序的全局序号<b>跨月累计不重置</b> 5 月指派后旗下成交 4 单为二诊~五诊下月再成交即为六诊
</p>
<p>
<b>当月 N 诊单数</b>当月内下单且诊次为 N 的订单数归属下单时点<b>持有该患者的医助</b>指派可在往月释放后不再归属继承指派会转移持有人但不计被指派数
@@ -55,7 +55,7 @@
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数往月指派当月成交会推高分子比率可能超过 100%医助当月无新指派但旗下有成交时被指派数为 0比率显示
</p>
<p>
医助按人事部门归组<b>仅统计二中心及其组织下级</b>部门下拉与未选时的默认范围均限定在该子树选定部门时含其组织下级
医助按人事部门归组选定部门时含其组织下级
</p>
</div>
</el-popover>
@@ -131,7 +131,7 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="row.name" v-model:medicine-id="row.medicine_id" />
<MedicineNameSelect v-model="row.name" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -321,7 +321,7 @@ const formData = reactive({
pulse: '',
pulse_condition: '',
clinical_diagnosis: '',
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
herbs: [] as Array<{ name: string; dosage: number }>,
dose_count: 7,
dose_unit: '剂',
usage_days: 7,
+1 -45
View File
@@ -83,23 +83,6 @@
<el-radio-button value="0">未确认</el-radio-button>
</el-radio-group>
</div>
<div class="filter-group">
<span class="filter-label">部门</span>
<el-tree-select
v-model="formData.assistant_dept_id"
:data="departmentTreeRaw"
class="filter-dept-select"
clearable
filterable
check-strictly
:default-expand-all="true"
node-key="id"
size="small"
:props="assistantDeptTreeProps"
placeholder="选父级含子级"
@change="handleAssistantDeptChange"
/>
</div>
</div>
<el-dialog
@@ -559,7 +542,6 @@
import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
import { deptAll } from '@/api/org/department'
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { addDoctorNote } from '@/api/patient'
@@ -621,19 +603,10 @@ const formData = reactive({
end_date: '',
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
diagnosis_confirmed: '' as '' | '0' | '1', // ''= 1= 0=
/** 接诊医生 / 诊单医助 / 挂号医助所属部门(选父级含子级) */
assistant_dept_id: '' as number | '',
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
include_status_counts: 0 as 0 | 1
})
const departmentTreeRaw = ref<unknown[]>([])
const assistantDeptTreeProps = {
value: 'id',
label: 'name',
children: 'children'
}
const activeTab = ref('1')
const dateCustomVisible = ref(false)
const statusCount = ref<Record<number, number>>({
@@ -748,12 +721,6 @@ const handleDiagnosisConfirmedChange = () => {
loadData()
}
//
const handleAssistantDeptChange = () => {
pager.page = 1
loadData()
}
//
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
const v = String(val || '')
@@ -803,7 +770,6 @@ const handleReset = () => {
formData.doctor_name = ''
formData.date_preset = 'today'
formData.diagnosis_confirmed = ''
formData.assistant_dept_id = ''
const t = new Date()
const p = (n: number) => String(n).padStart(2, '0')
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
@@ -1133,13 +1099,7 @@ formData.start_date = `${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${
formData.end_date = formData.start_date
formData.status = 1
onMounted(async () => {
try {
const deptTree = await deptAll()
departmentTreeRaw.value = Array.isArray(deptTree) ? deptTree : []
} catch {
departmentTreeRaw.value = []
}
onMounted(() => {
loadData()
listPollTimer = setInterval(() => {
loadData({ silent: true })
@@ -1268,10 +1228,6 @@ onUnmounted(() => {
padding: 6px 14px;
}
}
.filter-dept-select {
width: 200px;
}
}
}
+1 -6
View File
@@ -556,11 +556,7 @@ const validatePhone = (rule: any, value: any, callback: any) => {
}
const validateIdCard = (rule: any, value: any, callback: any) => {
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)) {
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)) {
callback(new Error('身份证号格式不正确'))
return
}
@@ -576,7 +572,6 @@ const formRules = {
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
syndrome_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' }]
}
@@ -4,43 +4,6 @@
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
</div>
<template v-else>
<div v-if="diagnosisId > 0" class="po-revisit-offset-bar mb-3">
<div class="po-revisit-offset-bar__main">
<span class="text-sm text-gray-600">复诊统计起始偏移</span>
<el-tooltip placement="top">
<template #content>
<div class="max-w-xs leading-relaxed">
在实单诊次序号上叠加偏移量设为 0默认 1 笔实单计为一诊设为 1 1 笔实单计为二诊设为 2 1 笔实单计为三诊若有 5 笔实单且偏移 2则统计上相当于计至七诊5+2
</div>
</template>
<el-icon class="text-gray-400 align-middle ml-1"><QuestionFilled /></el-icon>
</el-tooltip>
<el-input-number
v-model="revisitSlotStartOffset"
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
:min="0"
:max="20"
:step="1"
controls-position="right"
class="w-[120px] ml-3"
:disabled="offsetSaving"
/>
<span class="text-xs text-gray-500 ml-2">
1 笔实单计为{{ visitSlotStartLabel }}
</span>
</div>
<el-button
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
type="primary"
size="small"
:loading="offsetSaving"
:disabled="!offsetDirty"
@click="saveRevisitSlotStartOffset"
>
保存
</el-button>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
@@ -49,23 +12,6 @@
empty-text="暂无业务订单"
>
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
<el-table-column label="全局诊次" width="96" align="center">
<template #default="{ row }">
<span v-if="row.global_visit_seq">{{ row.global_visit_seq }}</span>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="计入统计" width="96" align="center">
<template #default="{ row }">
<el-tag
v-if="row.counts_for_revisit_rate"
type="success"
size="small"
effect="plain"
></el-tag>
<el-tag v-else type="info" size="small" effect="plain"></el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
@@ -117,11 +63,9 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists, tcmDiagnosisDetail, tcmDiagnosisSetRevisitSlotStartOffset } from '@/api/tcm'
import { prescriptionOrderLists } from '@/api/tcm'
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
import feedback from '@/utils/feedback'
import {
formatTime,
fulfillmentText,
@@ -147,26 +91,6 @@ const { pager, getLists, resetPage } = usePaging({
size: 10
})
const revisitSlotStartOffset = ref(0)
const savedRevisitSlotStartOffset = ref(0)
const offsetSaving = ref(false)
const offsetLoading = ref(false)
const offsetDirty = computed(
() => Number(revisitSlotStartOffset.value) !== Number(savedRevisitSlotStartOffset.value)
)
const visitSlotStartLabel = computed(() => {
const raw = Number(revisitSlotStartOffset.value)
const offset = Number.isFinite(raw) ? raw : 0
const slot = offset + 1
const cn = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
if (slot >= 1 && slot <= 10) {
return cn[slot] + '诊'
}
return `${slot}`
})
const buildParams = () => {
Object.keys(queryParams).forEach((k) => delete queryParams[k])
if (props.diagnosisId > 0) {
@@ -178,47 +102,6 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit'
}
async function loadRevisitSlotStartOffset() {
if (props.diagnosisId <= 0) return
offsetLoading.value = true
try {
const res: any = await tcmDiagnosisDetail({ id: props.diagnosisId })
const d = res?.data ?? res ?? {}
const offset = Number(d.revisit_slot_start_offset)
const val = Number.isFinite(offset) && offset >= 0 && offset <= 20 ? offset : 0
revisitSlotStartOffset.value = val
savedRevisitSlotStartOffset.value = val
} catch {
revisitSlotStartOffset.value = 0
savedRevisitSlotStartOffset.value = 0
} finally {
offsetLoading.value = false
}
}
async function saveRevisitSlotStartOffset() {
if (props.diagnosisId <= 0) return
const offset = Number(revisitSlotStartOffset.value)
if (!Number.isFinite(offset) || offset < 0 || offset > 20) {
feedback.msgWarning('起始偏移须在 020 之间')
return
}
offsetSaving.value = true
try {
await tcmDiagnosisSetRevisitSlotStartOffset({
id: props.diagnosisId,
revisit_slot_start_offset: offset
})
savedRevisitSlotStartOffset.value = offset
feedback.msgSuccess('保存成功')
getLists()
} catch {
/* 拦截器已提示 */
} finally {
offsetSaving.value = false
}
}
// /
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
@@ -234,13 +117,8 @@ const formatAmount = (value: unknown) => {
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
if (!patientIdAvailable.value) {
pager.lists = []
pager.count = 0
return
}
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return }
buildParams()
void loadRevisitSlotStartOffset()
resetPage()
},
{ immediate: true }
@@ -256,21 +134,4 @@ defineExpose({ refresh: () => getLists() })
.po-empty-tip {
padding: 24px 0;
}
.po-revisit-offset-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.po-revisit-offset-bar__main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
</style>
+24 -152
View File
@@ -68,65 +68,25 @@
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-alert
v-if="mode === 'edit' && patientBasicLocked && !canEditPatientBasicFields"
type="warning"
:closable="false"
show-icon
class="mb-3"
>
<template v-if="latestPrescriptionOrder">
最近业务订单 #{{ latestPrescriptionOrder.id }} 状态为{{ latestPrescriptionOrder.fulfillment_status_text }}患者基本信息不可修改
</template>
<template v-else>
存在未完成的业务订单患者基本信息不可修改
</template>
</el-alert>
<el-form-item label="诊单ID">
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
</el-form-item>
<fieldset
:disabled="!canEditPatientBasicFields"
class="diagnosis-patient-basic-fieldset border-0 min-w-0 p-0 m-0"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓名" prop="patient_name">
<el-input
v-model="formData.patient_name"
placeholder="请输入姓名"
:disabled="!canEditPatientBasicFields"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<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
v-else
v-model="formData.id_card"
placeholder="请输入身份证号"
maxlength="18"
:disabled="!canEditIdCard"
@focus="handleIdCardFocus"
@blur="handleIdCardBlur"
/>
@@ -160,7 +120,6 @@
v-model="formData.phone"
placeholder="请输入手机号"
maxlength="11"
:disabled="!canEditPatientBasicFields"
@focus="handlePhoneFocus"
@blur="handlePhoneBlur"
/>
@@ -168,7 +127,7 @@
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender" :disabled="!canEditPatientBasicFields">
<el-radio-group v-model="formData.gender">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
@@ -185,14 +144,11 @@
:max="150"
placeholder="请输入年龄"
class="w-full"
:disabled="!canEditPatientBasicFields"
:controls="canEditPatientBasicFields"
/>
</el-form-item>
</el-col>
</el-row>
</fieldset>
<!-- 生命体征 -->
@@ -822,40 +778,9 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
const latestPrescriptionOrder = ref<{
id: number
fulfillment_status: number
fulfillment_status_text: string
} | null>(null)
const canEditPatientBasicFields = computed(() => {
if (viewOnly.value) return false
if (mode.value === 'add') return true
return canEditPatientBasicFromApi.value
})
/** 编辑且无明文权限或基本信息锁定:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => {
if (mode.value !== 'edit') return false
if (!canEditPatientBasicFields.value) return true
return !hasPhonePlainPermission.value
})
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
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 drawerTitle = computed(() => {
if (viewOnly.value) return '诊单详情'
@@ -995,11 +920,6 @@ const togglePhoneReveal = () => {
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
}
const toggleIdCardReveal = () => {
if (!originalIdCard.value) return
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
}
const maskIdCard = (idCard: string) => {
if (!idCard) return idCard
if (idCard.length === 15) {
@@ -1070,28 +990,29 @@ const handlePhoneBlur = async () => {
}
}
// -
// -
const handleIdCardFocus = () => {
isIdCardFocused.value = true
if (!originalIdCard.value) return
if (hasPhonePlainPermission.value) {
formData.value.id_card = originalIdCard.value
return
}
if (mode.value === 'add') {
if (originalIdCard.value) {
formData.value.id_card = originalIdCard.value
}
}
//
// -
const handleIdCardBlur = async () => {
isIdCardFocused.value = false
const processIdCard = async (idCard: string) => {
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)) {
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
//
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
//
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()
let age = today.getFullYear() - birthYear
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
@@ -1099,11 +1020,13 @@ const handleIdCardBlur = async () => {
}
formData.value.age = age
//
try {
const result = await checkIdCard({
id_card: idCard,
id_card: formData.value.id_card,
id: formData.value.id || ''
})
if (result.exists) {
ElMessage.warning(result.message)
}
@@ -1111,24 +1034,8 @@ const handleIdCardBlur = async () => {
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)
}
}
@@ -1151,11 +1058,7 @@ const validatePhone = (rule: any, value: any, callback: any) => {
const validateIdCard = (rule: any, value: any, callback: any) => {
// 使
const idCardToValidate = originalIdCard.value || value
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)) {
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)) {
callback(new Error('身份证号格式不正确'))
return
}
@@ -1170,7 +1073,6 @@ const formRules = {
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
}
@@ -1265,12 +1167,9 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
originalPhone.value = data.phone || ''
originalIdCard.value = data.id_card || ''
patientBasicLocked.value = !!data.patient_basic_locked
canEditPatientBasicFromApi.value = data.can_edit_patient_basic !== false
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
data.id_card = maskIdCard(data.id_card || '')
formData.value = data
formData.value.create_source = data.create_source ?? ''
@@ -1279,20 +1178,12 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
}
const resetPatientBasicLockState = () => {
patientBasicLocked.value = false
canEditPatientBasicFromApi.value = true
latestPrescriptionOrder.value = null
}
const open = async (type: string, id?: number) => {
viewOnly.value = false
mode.value = type
visible.value = true
activeTab.value = 'basic' //
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
//
await getDictOptions()
@@ -1315,8 +1206,6 @@ const openViewOnly = async (id: number) => {
visible.value = true
activeTab.value = 'basic'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
await getDictOptions()
await loadDiagnosisDetailIntoForm(id)
}
@@ -1341,7 +1230,6 @@ const handleSubmit = async () => {
if (result && result.id) {
mode.value = 'edit'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
formData.value.id = result.id
// patient_id
@@ -1356,9 +1244,7 @@ const handleSubmit = async () => {
formData.value.phone = hasPhonePlainPermission.value
? detail.phone || ''
: maskPhone(detail.phone || '')
formData.value.id_card = hasPhonePlainPermission.value
? detail.id_card || ''
: maskIdCard(detail.id_card || '')
formData.value.id_card = maskIdCard(detail.id_card || '')
} catch (error) {
console.error('获取详情失败:', error)
}
@@ -1460,7 +1346,6 @@ const handleClose = () => {
isPhoneFocused.value = false
isIdCardFocused.value = false
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
viewOnly.value = false
visible.value = false
@@ -1485,19 +1370,6 @@ defineExpose({
}
}
.diagnosis-patient-basic-fieldset:disabled {
opacity: 1;
:deep(.el-input__wrapper),
:deep(.el-input-number),
:deep(.el-input-number__decrease),
:deep(.el-input-number__increase),
:deep(.el-radio),
:deep(.el-radio__input) {
cursor: not-allowed;
pointer-events: none;
}
}
:deep(.el-form) {
padding-bottom: 20px;
font-size: 12px;
+21 -123
View File
@@ -121,16 +121,6 @@
end-placeholder="最近挂号结束"
@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
v-model="formData.latest_appointment_channel_source"
placeholder="最近挂号渠道"
@@ -183,7 +173,6 @@
v-loading="pager.loading"
@selection-change="handleSelectionChange"
@row-dblclick="goReadonly"
@sort-change="handleTableSortChange"
:row-class-name="getRowClassName"
class="diagnosis-table"
stripe
@@ -294,14 +283,7 @@
<span v-else class="status-unprescribed">未开方</span>
</template>
</el-table-column>
<el-table-column
label="未服务天数"
prop="unserved_days"
width="110"
align="center"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<el-table-column label="未服务天数" width="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.last_blood_record_at"
@@ -780,10 +762,6 @@ const formData = reactive({
latest_appointment_start_date: '' as string,
latest_appointment_end_date: '' 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',
appointment_date: '' as string,
has_appointment: '' as '' | '0' | '1',
@@ -871,50 +849,22 @@ function resolvePendingAssignOrderMonthForRequest(): string {
return dayjs().format('YYYY-MM')
}
/** 除顶部 Tab 专属条件外,与主列表共用的「更多筛选」参数(角标 count 需同步) */
function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
return {
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_size: 1,
...buildSharedDiagnosisFilterPayload(),
appointment_date: '',
pending_booking: '',
completed_appointment: '',
pending_assign: '',
pending_assign_order_month: '',
pending_assign_keyword: '',
sort_unserved_days: '',
...overrides
}
}
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
function buildPendingAssignCountPayload(): Record<string, unknown> {
return buildTcmDiagnosisListRequestPayload(
buildDateCountRequestPayload({
return buildTcmDiagnosisListRequestPayload({
...formData,
page_no: 1,
page_size: 1,
pending_assign: 1,
appointment_date: '',
has_appointment: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest(),
pending_assign_keyword: formData.pending_assign_keyword
}) as Record<string, unknown>
) as Record<string, unknown>
pending_booking: '',
completed_appointment: '',
latest_appointment_start_date: '',
latest_appointment_end_date: '',
latest_appointment_channel_source: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
} as Record<string, unknown>) as Record<string, unknown>
}
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
@@ -943,9 +893,6 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = ''
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
@@ -1047,26 +994,14 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
const fetchDateCounts = async () => {
try {
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: dayBeforeStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(
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({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
])
dateCounts.value = {
@@ -1200,11 +1135,6 @@ const clearLatestAppointmentFilters = () => {
formData.latest_appointment_channel_source = ''
}
const clearLatestAssignFilters = () => {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
}
const hasLatestAppointmentFilter = () =>
!!(
formData.latest_appointment_start_date ||
@@ -1212,9 +1142,6 @@ const hasLatestAppointmentFilter = () =>
formData.latest_appointment_channel_source
)
const hasLatestAssignFilter = () =>
!!(formData.latest_assign_start_date || formData.latest_assign_end_date)
const handleLatestAppointmentFilterChange = () => {
if (hasLatestAppointmentFilter()) {
formData.appointment_date = ''
@@ -1227,27 +1154,6 @@ const handleLatestAppointmentFilterChange = () => {
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 desc = String(row?.latest_appointment_channel_source_desc || '').trim()
const raw = String(row?.latest_appointment_channel_source || '').trim()
@@ -1294,9 +1200,6 @@ const handleReset = () => {
formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = ''
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = ''
formData.has_appointment = ''
@@ -2395,11 +2298,6 @@ onUnmounted(() => {
max-width: 100%;
}
.latest-assign-range {
width: 260px;
max-width: 100%;
}
.latest-appointment-channel {
width: 170px;
}
+8 -1
View File
@@ -77,7 +77,14 @@ module.exports = {
mask: 'var(--el-mask-color)'
},
fontFamily: {
sans: ['PingFang SC', 'Arial', 'Hiragino Sans GB', 'Microsoft YaHei', 'sans-serif']
sans: [
'Noto Sans SC',
'PingFang SC',
'Microsoft YaHei',
'Hiragino Sans GB',
'Arial',
'sans-serif'
]
},
boxShadow: {
DEFAULT: 'var(--el-box-shadow)',
-16
View File
@@ -1,21 +1,5 @@
APP_DEBUG = true
# 恩济药房 ERP:必须写在第一个 INI 分区之前
# 在 ej 后台创建商户后,将一次性返回的 app_key、app_secret、webhook_secret 填入下方
EJ_PHARMACY_ENABLED = false
EJ_PHARMACY_CATALOG_SYNC_ENABLED = false
EJ_PHARMACY_BASE_URL = "https://ej.example.com"
EJ_PHARMACY_APP_KEY = ""
EJ_PHARMACY_APP_SECRET = ""
EJ_PHARMACY_CALLBACK_SECRET = ""
# autoNSS cURL 自动使用 PHP OpenSSL;也可显式填写 openssl 或 curl。
EJ_PHARMACY_HTTP_TRANSPORT = "auto"
# CentOS/RHEL 7 可填写 /etc/pki/tls/certs/ca-bundle.crt;留空使用 PHP 默认 CA。
EJ_PHARMACY_CA_FILE = ""
EJ_PHARMACY_SUBMISSION_LEASE_SECONDS = 300
EJ_PHARMACY_CONNECT_TIMEOUT = 5
EJ_PHARMACY_REQUEST_TIMEOUT = 30
[APP]
DEFAULT_TIMEZONE = "Asia/Shanghai"
@@ -169,19 +169,14 @@ class AssetResourceController extends BaseAdminController
public function delete()
{
$id = $this->request->post('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)) {
if (empty($id)) {
return $this->fail('缺少参数');
}
Db::startTrans();
try {
AssetResource::destroy($ids);
AssetUserResource::whereIn('resource_id', $ids)->delete();
AssetResource::destroy($id);
AssetUserResource::where('resource_id', $id)->delete();
Db::commit();
return $this->success('删除成功');
} catch (\Exception $e) {
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\pharmacy;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\pharmacy\MedicineMappingLists;
use app\adminapi\logic\pharmacy\MedicineMappingLogic;
use app\adminapi\validate\pharmacy\MedicineMappingValidate;
class MedicineMappingController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new MedicineMappingLists());
}
public function status()
{
return $this->data(MedicineMappingLogic::status());
}
public function catalogOptions()
{
return $this->data(MedicineMappingLogic::catalogOptions(
(string) $this->request->get('keyword', ''),
(int) $this->request->get('limit', 30)
));
}
public function save()
{
$params = (new MedicineMappingValidate())->post()->goCheck('save');
$name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? '');
if (!MedicineMappingLogic::save($params, $this->adminId, $name)) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('映射已保存', [], 1, 1);
}
public function unlink()
{
$params = (new MedicineMappingValidate())->post()->goCheck('unlink');
$name = (string) ($this->adminInfo['name'] ?? $this->adminInfo['nickname'] ?? '');
if (!MedicineMappingLogic::unlink((int) $params['local_medicine_id'], $this->adminId, $name)) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('映射已解除', [], 1, 1);
}
public function sync()
{
$result = MedicineMappingLogic::sync();
if ($result === false) {
return $this->fail(MedicineMappingLogic::getError());
}
return $this->success('目录同步完成', $result);
}
}
@@ -1,44 +0,0 @@
<?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);
}
}
@@ -17,7 +17,6 @@ use app\adminapi\logic\stats\YejiStatsLogic;
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
* - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
* - GET stats.yejiStats/assignLines 被指派数明细(医助×诊单去重,与看板同口径)
*/
class YejiStatsController extends BaseAdminController
{
@@ -142,13 +141,4 @@ class YejiStatsController extends BaseAdminController
return $this->data(YejiStatsLogic::revisitDeptAssistantBreakdown($params, $this->adminId, $this->adminInfo));
}
/** 被指派数明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重) */
public function assignLines()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::assignLines($params, $this->adminId, $this->adminInfo));
}
}
@@ -87,31 +87,13 @@ class DiagnosisController extends BaseAdminController
public function edit()
{
$params = (new DiagnosisValidate())->post()->goCheck('edit');
$result = DiagnosisLogic::edit($params, $this->adminInfo);
$result = DiagnosisLogic::edit($params);
if ($result) {
return $this->success('编辑成功', [], 1, 1);
}
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 设置复诊接诊率统计起始偏移(业务订单 tab
*/
public function setRevisitSlotStartOffset()
{
$params = (new DiagnosisValidate())->post()->goCheck('setRevisitSlotStartOffset');
$ok = DiagnosisLogic::setRevisitSlotStartOffset(
(int) $params['id'],
(int) $params['revisit_slot_start_offset'],
$this->adminInfo
);
if (!$ok) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('保存成功');
}
/**
* @notes 删除诊单
* @return \think\response\Json
@@ -134,7 +116,7 @@ class DiagnosisController extends BaseAdminController
{
$params = (new DiagnosisValidate())->goCheck('id');
$result = DiagnosisLogic::detail($params, $this->adminInfo);
$result = DiagnosisLogic::detail($params);
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
return $this->data($result);
}
@@ -161,27 +161,6 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('已保存', $result);
}
/**
* 人工核对甘草不确定提交:确认远端成功或确认未创建。
*/
public function confirmGancaoSubmission()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('confirmGancaoSubmission');
$result = PrescriptionOrderLogic::confirmGancaoSubmission(
(int) $params['id'],
(string) $params['resolution'],
(string) ($params['remote_order_no'] ?? ''),
(string) $params['note'],
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('甘草提交核对已记录', $result);
}
/**
* 修改关联处方的患者姓名与手机号(订单详情场景)
*/
@@ -202,20 +181,6 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('保存成功');
}
/**
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
*/
public function patchPrescriptionUsage()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('patchPrescriptionUsage');
$ok = PrescriptionOrderLogic::patchPrescriptionUsage($params, $this->adminId, $this->adminInfo);
if (!$ok) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('保存成功');
}
public function auditPrescription()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
@@ -321,32 +286,6 @@ class PrescriptionOrderController extends BaseAdminController
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);
}
/**
* 获取业务订单操作日志
*/
@@ -363,20 +302,6 @@ class PrescriptionOrderController extends BaseAdminController
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);
}
/**
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
*/
@@ -472,7 +397,7 @@ class PrescriptionOrderController extends BaseAdminController
{
try {
$params = (new PrescriptionOrderValidate())->post()->goCheck('submitGancaoRecipel');
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
$result = PrescriptionOrderLogic::submitGancaoRecipel((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
$error = PrescriptionOrderLogic::getError();
@@ -486,7 +411,7 @@ class PrescriptionOrderController extends BaseAdminController
return $this->fail('返回数据格式错误');
}
return $this->success('药方上传成功', $result);
return $this->success('甘草药方上传成功', $result);
} catch (\Throwable $e) {
\think\facade\Log::error('submitGancaoRecipel exception', [
'message' => $e->getMessage(),
@@ -498,19 +423,6 @@ class PrescriptionOrderController extends BaseAdminController
}
}
/**
* Unified pharmacy upload. The order ship_mode decides the target.
*/
public function uploadToPharmacy()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('uploadToPharmacy');
$result = PrescriptionOrderLogic::uploadToPharmacy((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('药方上传成功', $result);
}
/**
* 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单)
* 用于在编辑订单时测试价格和配置
@@ -17,7 +17,6 @@ declare (strict_types=1);
namespace app\adminapi\http\middleware;
use app\adminapi\logic\LoginLogic;
use app\common\service\pharmacy\PharmacyUploadPermissionAlias;
use app\common\{
cache\AdminAuthCache,
service\JsonService
@@ -75,8 +74,7 @@ class AuthMiddleware
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri, true)
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
if (!in_array($accessUri, $allUri)) {
return $next($request);
}
@@ -111,10 +109,6 @@ class AuthMiddleware
*/
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
{
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
return PharmacyUploadPermissionAlias::allows($accessUri, $adminUris);
}
if (in_array('tcm.diagnosis/dailyrecord', $adminUris, true)
&& in_array($accessUri, [
'tcm.diagnosistodo/lists',
@@ -3,8 +3,6 @@
namespace app\adminapi\lists\doctor;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Prescription;
@@ -77,35 +75,6 @@ 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、或两者皆有的表结构
*
@@ -222,8 +191,6 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyAssistantIdFilter($query);
$this->applyAssistantDeptIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter);
// 是否确认诊单:1=已确认 0=未确认
@@ -406,8 +373,6 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyAssistantIdFilter($query);
$this->applyAssistantDeptIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter);
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
@@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\pharmacy;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use think\facade\Db;
class MedicineMappingLists extends BaseAdminDataLists implements ListsSearchInterface
{
public function setSearch(): array
{
return [];
}
public function lists(): array
{
$rows = $this->query()
->field($this->fields())
->limit($this->limitOffset, $this->limitLength)
->order('l.id', 'desc')
->select()
->toArray();
foreach ($rows as &$row) {
foreach ([
'local_medicine_id', 'local_status', 'mapping_id', 'mapping_status',
'operator_id', 'mapping_update_time', 'catalog_version', 'remote_status', 'remote_deleted',
] as $field) {
if ($row[$field] !== null) {
$row[$field] = (int) $row[$field];
}
}
}
unset($row);
return $rows;
}
public function count(): int
{
return (int) $this->query()->count('l.id');
}
private function query()
{
$query = Db::name('doctor_medicine')->alias('l')
->leftJoin(
'ej_medicine_mapping m',
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
)
->leftJoin('ej_medicine_catalog c', 'c.medicine_code = m.medicine_code')
->whereNull('l.delete_time');
$localName = trim((string) ($this->params['local_name'] ?? ''));
if ($localName !== '') {
$query->where('l.name', 'like', '%' . $localName . '%');
}
$remoteKeyword = trim((string) ($this->params['remote_keyword'] ?? ''));
if ($remoteKeyword !== '') {
$query->where(function ($nested) use ($remoteKeyword): void {
$nested->where('c.name', 'like', '%' . $remoteKeyword . '%')
->whereOr('c.medicine_code', 'like', '%' . $remoteKeyword . '%');
});
}
$mappingStatus = trim((string) ($this->params['mapping_status'] ?? ''));
if ($mappingStatus === 'mapped') {
$query->whereNotNull('m.id')->where('c.status', 1)->where('c.remote_deleted', 0);
} elseif ($mappingStatus === 'unmapped') {
$query->whereNull('m.id');
} elseif ($mappingStatus === 'invalid') {
$query->whereNotNull('m.id')
->where(function ($nested): void {
$nested->whereNull('c.id')
->whereOr('c.status', '<>', 1)
->whereOr('c.remote_deleted', 1);
});
}
return $query;
}
private function fields(): string
{
return implode(',', [
'l.id AS local_medicine_id',
'l.name AS local_name',
'l.unit AS local_unit',
'l.status AS local_status',
'm.id AS mapping_id',
'm.medicine_code',
'm.operator_id',
'm.operator_name',
'm.update_time AS mapping_update_time',
'c.name AS remote_name',
'c.brand AS remote_brand',
'c.unit AS remote_unit',
'c.settlement_price',
'c.retail_price',
'c.catalog_version',
'c.status AS remote_status',
'c.remote_deleted',
"CASE WHEN m.id IS NULL THEN 0 "
. "WHEN c.id IS NULL OR c.status <> 1 OR c.remote_deleted = 1 THEN 2 ELSE 1 END AS mapping_status",
]);
}
}
@@ -1,147 +0,0 @@
<?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,7 +140,6 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
$this->applyLatestAssignFilters($query, $pendingWideSearch);
$this->applyPendingAssignBusinessOrderMonthFilter($query);
@@ -168,7 +167,30 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
}
$orderRaw = $this->resolveListOrderRaw($pendingWideSearch);
// 按挂号状态优先级排序:已过号(4) > 已预约(1) > 已完成(3),然后按挂号日期+时间升序
// 若传了 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
->with(['DiagnosisViewRecord'])
@@ -549,7 +571,6 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
$this->applyLatestAssignFilters($query, $pendingWideSearch);
// 仅已开方(待分配+关键词检索时不限制)
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
@@ -623,99 +644,6 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$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
{
$statuses = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
@@ -9,7 +9,6 @@ use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\common\enum\ExportEnum;
use app\common\cache\ExportCache;
use app\common\lists\ListsExcelInterface;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
@@ -24,16 +23,9 @@ use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\ExpressTracking;
use app\common\service\gancao\GancaoScmRecipelService;
use app\common\service\pharmacy\EjPharmacyClient;
use think\facade\Config;
use think\facade\Db;
use think\db\Query;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface, ListsExcelInterface
{
@@ -116,7 +108,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyExpressKeywordFilter($query);
$this->applyServiceChannelFilter($query);
$this->applySupplyModeFilter($query);
$this->applyHasAuxFormulaFilter($query);
$this->applyAuditAdminFilter($query);
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
@@ -334,9 +325,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
/**
* 供货方式:洛阳直发(ship_mode=direct,含尚未上传)/ 甘草(有甘草单号)/ 自营(其余)。
* 供货方式:甘草(已上传甘草药方单号)/ 自营(无甘草单号,走药房直发等)
*
* 入参 supply_modegancao | direct | self,空表示不限
* 入参 supply_modegancao | self,空表示不限
*/
private function applySupplyModeFilter($query): void
{
@@ -344,62 +335,18 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($mode === '') {
return;
}
if ($mode === 'direct') {
$query->whereRaw("LOWER(TRIM(IFNULL(`ship_mode`,''))) = 'direct'");
return;
}
if ($mode === 'gancao') {
$query->whereRaw(
"LOWER(TRIM(IFNULL(`ship_mode`,''))) <> 'direct'"
. " AND TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''"
);
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) <> ''");
return;
}
if ($mode === 'self') {
$query->whereRaw(
"LOWER(TRIM(IFNULL(`ship_mode`,''))) <> 'direct'"
. " AND TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''"
);
$query->whereRaw("TRIM(IFNULL(`gancao_reciperl_order_no`,'')) = ''");
return;
}
}
/**
* 是否含辅方:关联处方 herbs 中是否存在 formula_type=辅方(与 PrescriptionOrderLogic::detail 口径一致)
*
* 入参 has_aux_formula1 含辅方 | 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 则额外包含关联处方开方人为本人的订单。
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
@@ -718,22 +665,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$assistantNames = $assistantIds !== []
? \app\common\model\auth\Admin::whereIn('id', $assistantIds)->column('name', 'id')
: [];
$orderIdsForClaims = array_values(array_unique(array_filter(array_map('intval', array_column($lists, 'id')))));
$claimByOrder = [];
if ($orderIdsForClaims !== []) {
$claimRows = Db::name('pharmacy_submission_claim')
->whereIn('prescription_order_id', $orderIdsForClaims)
->field(['prescription_order_id', 'target', 'status', 'lease_expires_at'])
->order('source_revision', 'desc')
->select()
->toArray();
foreach ($claimRows as $claimRow) {
$claimId = (int) $claimRow['prescription_order_id'];
if (!isset($claimByOrder[$claimId])) {
$claimByOrder[$claimId] = $claimRow;
}
}
}
foreach ($lists as &$item) {
PrescriptionOrderLogic::maskInternalCostIfNeeded($item, $this->adminInfo);
PrescriptionOrderLogic::maskRemarkExtraIfNeeded($item, $this->adminInfo);
@@ -741,22 +672,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$item['linked_pay_order_count'] = (int) ($linkCountByPo[$pid] ?? 0);
$item['linked_pay_paid_total'] = $paidSumByPo[$pid] ?? 0.0;
$item['deposit_min_amount'] = $depMin;
$claim = $claimByOrder[$pid] ?? [];
$item['pharmacy_claim_target'] = (string) ($claim['target'] ?? '');
$item['pharmacy_claim_status'] = (string) ($claim['status'] ?? '');
$item['pharmacy_claim_lease_expires_at'] = (int) ($claim['lease_expires_at'] ?? 0);
$item['can_upload_gancao_reciperl'] = PrescriptionOrderLogic::canUploadGancaoRecipel(
$item,
$this->adminId,
$this->adminInfo,
$assistantByDiag
);
$item['can_upload_pharmacy'] = PrescriptionOrderLogic::canUploadToPharmacy(
$item,
$this->adminId,
$this->adminInfo,
$assistantByDiag
);
// 添加创建人姓名
$creatorId = (int) ($item['creator_id'] ?? 0);
@@ -779,10 +700,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
unset($item);
if ($this->shouldBypassListVisibilityForDiagnosisEdit()) {
$this->appendDiagnosisEditVisitSeqFields($lists);
}
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
@@ -804,7 +721,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$base = [
'deposit_min_amount' => PrescriptionOrderLogic::depositMinAmount(),
'gancao_scm_enabled' => GancaoScmRecipelService::isConfigured(),
'ej_pharmacy_enabled' => EjPharmacyClient::isConfigured(),
'stats_order_amount' => $s['order_amount'],
'stats_order_amount_not_cancelled' => $s['order_amount_not_cancelled'],
'stats_order_amount_cancelled' => $s['order_amount_cancelled'],
@@ -863,21 +779,15 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
'export_medication_form' => '药品形态',
'export_prescription_name' => '药方名称',
'export_prescription_herbs' => '处方',
'export_main_usage' => '主方服用方式',
'export_main_usage_days' => '主方天数',
'export_aux_usage' => '辅方服用方式',
'export_aux_usage_days' => '辅方天数',
'export_service_package' => '服务套餐',
'export_medication_days' => '天数',
'export_amount' => '总金额',
'export_paid_amount' => '已付金额',
'export_linked_pay_records' => '关联收款记录',
'export_refund_amount' => '退款金额',
'export_agency_collect' => '代收金额',
'export_tracking_number' => '快递单号',
'export_sign_time' => '签收日期',
'export_supply_mode' => '供货方式',
'export_supply_mode' => '甘草还是自营',
'export_gancao_prescription_cost' => '处方成本',
'export_first_visit_assistant' => '初诊医助',
'export_rx_audit_time' => '审核时间',
@@ -885,141 +795,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
];
}
/**
* 处方业务订单导出:深色表头、斑马纹、长文本自动换行(覆盖默认亮蓝表头)
*
* @param array<string, string> $excelFields
* @param array<int, array<string, mixed>> $lists
*/
public function createExcel($excelFields, $lists): string
{
$title = array_values($excelFields);
$fieldKeys = array_keys($excelFields);
$data = [];
foreach ($lists as $row) {
$temp = [];
foreach ($excelFields as $key => $excelField) {
$fieldData = $row[$key] ?? '';
if (is_numeric($fieldData) && strlen((string) $fieldData) >= 12) {
$fieldData .= "\t";
}
$temp[$key] = $fieldData;
}
$data[] = $temp;
}
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('处方业务订单');
foreach ($title as $key => $value) {
$sheet->setCellValueByColumnAndRow($key + 1, 1, $value);
}
$rowNum = 2;
foreach ($data as $item) {
$column = 1;
foreach ($item as $value) {
$sheet->setCellValueByColumnAndRow($column, $rowNum, $value);
++$column;
}
++$rowNum;
}
$highest = $sheet->getHighestRowAndColumn();
$highestRow = (int) $highest['row'];
$lastColumn = (string) $highest['column'];
$titleScope = 'A1:' . $lastColumn . '1';
$allScope = 'A1:' . $lastColumn . $highestRow;
$bodyScope = 'A2:' . $lastColumn . $highestRow;
$sheet->freezePane('A2');
$sheet->getRowDimension(1)->setRowHeight(26);
$sheet->getStyle($titleScope)->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF334155');
$sheet->getStyle($titleScope)->getFont()->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle($titleScope)->getFont()->setBold(true);
$sheet->getStyle($titleScope)->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle($allScope)->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)
->getColor()->setARGB('FFE2E8F0');
$sheet->getStyle($bodyScope)->getAlignment()
->setVertical(Alignment::VERTICAL_TOP)
->setWrapText(true);
$wrapWideKeys = [
'export_linked_pay_records',
'export_prescription_name',
'export_prescription_herbs',
'export_main_usage',
'export_aux_usage',
'export_guahao_channel_source',
'export_assistant_dept',
'export_service_package',
];
$fixedWidths = [
'export_fulfillment_status_text' => 12,
'export_order_time' => 18,
'export_patient_gender' => 6,
'export_patient_age' => 6,
'export_medication_days' => 6,
'export_main_usage_days' => 8,
'export_aux_usage_days' => 8,
'export_amount' => 10,
'export_paid_amount' => 10,
'export_refund_amount' => 10,
'export_agency_collect' => 10,
'export_sign_time' => 12,
'export_supply_mode' => 10,
'export_linked_pay_records' => 52,
'export_prescription_name' => 34,
'export_prescription_herbs' => 36,
'export_main_usage' => 28,
'export_aux_usage' => 28,
'export_guahao_channel_source' => 22,
'export_assistant_dept' => 24,
'export_service_package' => 18,
'export_tracking_number' => 18,
];
foreach ($fieldKeys as $idx => $fieldKey) {
$colLetter = Coordinate::stringFromColumnIndex($idx + 1);
if (isset($fixedWidths[$fieldKey])) {
$sheet->getColumnDimension($colLetter)->setWidth($fixedWidths[$fieldKey]);
} elseif (!in_array($fieldKey, $wrapWideKeys, true)) {
$sheet->getColumnDimension($colLetter)->setAutoSize(true);
} else {
$sheet->getColumnDimension($colLetter)->setWidth(28);
}
}
for ($r = 2; $r <= $highestRow; ++$r) {
if ($r % 2 === 0) {
$sheet->getStyle('A' . $r . ':' . $lastColumn . $r)->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FFF8FAFC');
}
}
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$exportCache = new ExportCache();
$src = $exportCache->getSrc();
if (!file_exists($src)) {
mkdir($src, 0775, true);
}
$writer->save($src . $this->fileName);
$vars = ['file' => $exportCache->setFile($this->fileName)];
return (string) url('adminapi/download/export', $vars, true, true);
}
/**
* 业绩看板侧栏专用:约诊/接诊条数,与 YejiStatsLogic::applyConsultFiltersAlignedWithAppointmentLists 同口径
* doctor_appointment.status=3appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
@@ -1460,69 +1235,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
);
}
/**
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)
*
* @param array<int, array<string, mixed>> $lists
*/
private function appendDiagnosisEditVisitSeqFields(array &$lists): void
{
if ($lists === []) {
return;
}
$diagIds = [];
foreach ($lists as $row) {
$d = (int) ($row['diagnosis_id'] ?? 0);
if ($d > 0) {
$diagIds[$d] = true;
}
}
$contextDid = (int) ($this->params['context_diagnosis_id'] ?? 0);
if ($contextDid > 0) {
$diagIds[$contextDid] = true;
}
$diagIdList = array_keys($diagIds);
if ($diagIdList === []) {
return;
}
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
$offsetMap = [];
foreach ($offsetRows as $id => $offset) {
$offsetMap[(int) $id] = max(0, min(20, (int) $offset));
}
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
$seqByOrderId = [];
foreach ($diagIdList as $did) {
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
$orderIds = $q
->order(['create_time' => 'asc', 'id' => 'asc'])
->column('id');
$seq = 0;
foreach ($orderIds as $oid) {
$seq++;
$seqByOrderId[(int) $oid] = $seq;
}
}
foreach ($lists as &$row) {
$oid = (int) ($row['id'] ?? 0);
$did = (int) ($row['diagnosis_id'] ?? 0);
$seq = (int) ($seqByOrderId[$oid] ?? 0);
$offset = (int) ($offsetMap[$did] ?? 0);
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
$row['counts_for_revisit_rate'] = $effectiveSlot >= 2 ? 1 : 0;
}
unset($row);
}
/**
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
+1 -32
View File
@@ -151,24 +151,6 @@ class DeptLogic extends BaseLogic
* @return list<int>
*/
private static function findErCenterRootDeptIds(): array
{
return self::findCenterRootDeptIdsByNameKeyword('二中心');
}
/**
* 名称含「一中心」的部门 id。
*
* @return list<int>
*/
private static function findYiCenterRootDeptIds(): array
{
return self::findCenterRootDeptIdsByNameKeyword('一中心');
}
/**
* @return list<int>
*/
private static function findCenterRootDeptIdsByNameKeyword(string $keyword): array
{
$rows = Dept::whereNull('delete_time')
->field(['id', 'name'])
@@ -177,7 +159,7 @@ class DeptLogic extends BaseLogic
$out = [];
foreach ($rows as $r) {
$name = (string) ($r['name'] ?? '');
if ($name !== '' && mb_strpos($name, $keyword) !== false) {
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
$out[] = (int) $r['id'];
}
}
@@ -219,19 +201,6 @@ class DeptLogic extends BaseLogic
return array_fill_keys($subtreeIds, true);
}
/**
* 名称含「一中心」的部门及其全部下级 idmap)。
*
* @return array<int, true>
*/
public static function getYiCenterSubtreeDeptIdSet(): array
{
$yiRoots = self::findYiCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($yiRoots);
return array_fill_keys($subtreeIds, true);
}
/**
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
*
@@ -1,162 +0,0 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\pharmacy;
use app\common\logic\BaseLogic;
use app\common\service\pharmacy\EjMedicineCatalogSyncService;
use app\common\service\pharmacy\EjMedicineMappingPolicy;
use think\facade\Db;
use think\facade\Config;
use Throwable;
class MedicineMappingLogic extends BaseLogic
{
public static function save(array $params, int $operatorId, string $operatorName): bool
{
self::$error = '';
try {
Db::transaction(function () use ($params, $operatorId, $operatorName): void {
$localId = (int) $params['local_medicine_id'];
$medicineCode = trim((string) $params['medicine_code']);
$local = Db::name('doctor_medicine')->where('id', $localId)->lock(true)->find();
$remote = Db::name('ej_medicine_catalog')->where('medicine_code', $medicineCode)->lock(true)->find();
EjMedicineMappingPolicy::assertValid($local ?: [], $remote ?: []);
$now = time();
$mapping = Db::name('ej_medicine_mapping')
->where('local_medicine_id', $localId)
->lock(true)
->find();
$values = [
'medicine_code' => $medicineCode,
'status' => 1,
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'update_time' => $now,
'delete_time' => null,
];
if ($mapping) {
Db::name('ej_medicine_mapping')->where('id', (int) $mapping['id'])->update($values);
return;
}
Db::name('ej_medicine_mapping')->insert(array_merge($values, [
'local_medicine_id' => $localId,
'create_time' => $now,
]));
});
return true;
} catch (Throwable $exception) {
self::setError(self::isDuplicateKey($exception)
? '该本地药材映射刚被其他操作更新,请刷新后重试'
: $exception->getMessage());
return false;
}
}
public static function unlink(int $localMedicineId, int $operatorId, string $operatorName): bool
{
self::$error = '';
try {
Db::transaction(function () use ($localMedicineId, $operatorId, $operatorName): void {
$local = Db::name('doctor_medicine')->where('id', $localMedicineId)->lock(true)->find();
$mapping = Db::name('ej_medicine_mapping')
->where('local_medicine_id', $localMedicineId)
->lock(true)
->find();
$decision = EjMedicineMappingPolicy::unlinkDecision($local ?: [], $mapping ?: null);
if ($decision['already_unlinked']) {
return;
}
$now = time();
Db::name('ej_medicine_mapping')
->where('id', $decision['mapping_id'])
->where('local_medicine_id', $localMedicineId)
->where('status', 1)
->whereNull('delete_time')
->update([
'status' => 0,
'operator_id' => $operatorId,
'operator_name' => mb_substr(trim($operatorName), 0, 80),
'update_time' => $now,
'delete_time' => $now,
]);
});
return true;
} catch (Throwable $exception) {
self::setError($exception->getMessage());
return false;
}
}
/** @return array<string,mixed>|false */
public static function sync()
{
self::$error = '';
try {
return EjMedicineCatalogSyncService::sync(200);
} catch (Throwable $exception) {
self::setError($exception->getMessage());
return false;
}
}
/** @return array<string,mixed> */
public static function status(): array
{
$state = Db::name('ej_pharmacy_sync_state')->where('id', 1)->find() ?: [];
$catalogTotal = (int) Db::name('ej_medicine_catalog')->count();
$catalogActive = (int) Db::name('ej_medicine_catalog')
->where('status', 1)->where('remote_deleted', 0)->count();
$unmappedLocal = (int) Db::name('doctor_medicine')->alias('l')
->leftJoin(
'ej_medicine_mapping m',
'm.local_medicine_id = l.id AND m.status = 1 AND m.delete_time IS NULL'
)
->where('l.status', 1)
->whereNull('l.delete_time')
->whereNull('m.id')
->count('l.id');
return [
'sync_enabled' => (bool) Config::get('ej_pharmacy.catalog_sync_enabled', false),
'cursor' => (int) ($state['cursor'] ?? 0),
'last_success_time' => (int) ($state['last_success_time'] ?? 0),
'last_failure_time' => (int) ($state['last_failure_time'] ?? 0),
'last_error_summary' => (string) ($state['last_error_summary'] ?? ''),
'is_syncing' => !empty($state['lock_token']) && (int) ($state['lock_expires_at'] ?? 0) >= time(),
'catalog_total' => $catalogTotal,
'catalog_active' => $catalogActive,
'unmapped_local' => $unmappedLocal,
];
}
/** @return array<int,array<string,mixed>> */
public static function catalogOptions(string $keyword, int $limit = 30): array
{
$query = Db::name('ej_medicine_catalog')
->where('status', 1)
->where('remote_deleted', 0);
$keyword = trim($keyword);
if ($keyword !== '') {
$query->where(function ($nested) use ($keyword): void {
$nested->where('name', 'like', '%' . $keyword . '%')
->whereOr('medicine_code', 'like', '%' . $keyword . '%');
});
}
return $query
->field('medicine_code,name,brand,unit,settlement_price,retail_price,catalog_version,status')
->order('catalog_version', 'desc')
->limit(min(max($limit, 1), 50))
->select()
->toArray();
}
private static function isDuplicateKey(Throwable $exception): bool
{
return (string) $exception->getCode() === '23000'
|| str_contains(strtolower($exception->getMessage()), 'duplicate');
}
}
@@ -1,227 +0,0 @@
<?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 = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
@@ -57,8 +57,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
],
];
if ($includeFilters) {
@@ -75,8 +75,8 @@ class ConversionLogic
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
@@ -84,8 +84,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
],
];
if ($includeFilters) {
@@ -124,8 +124,8 @@ class ConversionLogic
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
@@ -133,8 +133,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
],
];
if ($includeFilters) {
@@ -191,8 +191,8 @@ class ConversionLogic
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows, $dimension),
'charts' => self::buildCharts($chartRows, $dimension),
'summary' => self::buildSummary($allRows),
'charts' => self::buildCharts($chartRows),
'lists' => $pagedRows,
'count' => count($allRows),
'page_no' => $pageNo,
@@ -200,8 +200,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows, $dimension),
'charts' => self::buildCharts($chartRows, $dimension),
'summary' => self::buildSummary($allRows),
'charts' => self::buildCharts($chartRows),
],
];
if ($includeFilters) {
@@ -226,8 +226,8 @@ class ConversionLogic
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows, $dimension),
'charts' => self::buildCharts($rows, $dimension),
'summary' => self::buildSummary($rows),
'charts' => self::buildCharts($rows),
'lists' => array_slice($rows, $offset, $pageSize),
'count' => $count,
'page_no' => $pageNo,
@@ -235,8 +235,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows, $dimension),
'charts' => self::buildCharts($rows, $dimension),
'summary' => self::buildSummary($rows),
'charts' => self::buildCharts($rows),
],
];
if ($includeFilters) {
@@ -1226,7 +1226,7 @@ class ConversionLogic
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -1459,7 +1459,7 @@ class ConversionLogic
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
$node['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -2010,12 +2010,7 @@ class ConversionLogic
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
'receive_rate' => self::receiveRate(
$completedOrderCount,
$addFansCount,
$interviewCount,
'member'
),
'receive_rate' => self::percent($completedOrderCount, $addFansCount),
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
@@ -2154,7 +2149,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
private static function buildSummary(array $rows, string $dimension = 'dept'): array
private static function buildSummary(array $rows): array
{
$summary = [
'add_fans_count' => 0,
@@ -2186,12 +2181,7 @@ class ConversionLogic
$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['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
$summary['receive_rate'] = self::receiveRate(
$summary['completed_order_count'],
$summary['add_fans_count'],
$summary['interview_count'],
$dimension
);
$summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_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['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
@@ -2320,7 +2310,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
private static function buildCharts(array $rows, string $dimension = 'dept'): array
private static function buildCharts(array $rows): array
{
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
$topRows = array_slice($chartableRows, 0, 10);
@@ -2329,56 +2319,29 @@ class ConversionLogic
'names' => [],
'amounts' => [],
'order_counts' => [],
'fan_counts' => [],
'rois' => [],
];
if ($dimension === 'doctor') {
$ranking['appointment_counts'] = [];
$ranking['interview_counts'] = [];
} else {
$ranking['fan_counts'] = [];
$ranking['rois'] = [];
}
foreach ($topRows as $row) {
$ranking['names'][] = $row['name'];
$ranking['amounts'][] = $row['completed_order_amount'];
$ranking['order_counts'][] = $row['completed_order_count'];
if ($dimension === 'doctor') {
$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'];
}
}
$charts = [
return [
'ranking' => $ranking,
'amount_share' => array_map(
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)
),
];
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(
'fan_share' => array_map(
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)
);
}
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
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use think\facade\Db;
/**
@@ -13,11 +12,9 @@ use think\facade\Db;
* 口径说明:
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
* 同一诊单当月被多次指派给同一医助只计 1
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
* 同一诊单当月被多次指派给同一医助只计 1
* - N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
@@ -25,8 +22,7 @@ use think\facade\Db;
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
* - 分档动态产出:N 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;部门筛选(dept_ids,含组织下级)按该归属部门过滤。
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
*/
class RevisitRateLogic
@@ -298,19 +294,14 @@ class RevisitRateLogic
}
/**
* 部门下拉:仅「二中心」及其组织下级(与业绩看板 ErCenter 子树一致)。
* 部门下拉(全量未删除部门,前端组树)。
*
* @return array{rows: list<array{id:int,pid:int,name:string}>}
*/
public static function deptOptions(): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return ['rows' => []];
}
$rows = Db::name('dept')
->whereNull('delete_time')
->whereIn('id', array_keys($erSet))
->field(['id', 'pid', 'name'])
->order('sort', 'desc')
->order('id', 'asc')
@@ -331,9 +322,9 @@ class RevisitRateLogic
/**
* 核心统计上下文:
* 1. 全量指派日志(≤ 月末)构建持有时间线;
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊)
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
* 2. 分母:当月非继承指派的「医助 × 诊单」;
* 3. 分子:曾被指派诊单的当月订单按全局序号 ≥2 归属持有医助
* 4. 应用部门筛选(含组织下级)。
*
* @param array{month?:string,dept_ids?:int[]|string} $params
*
@@ -384,35 +375,9 @@ class RevisitRateLogic
}
}
// 分母:剔除名下存在拒收(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 笔实单为一诊)
// 分子:曾被指派诊单的当月订单(全局序号 ≥2),归属下单时点的持有医助
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
$slotOrdersByAssistant = [];
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
$orderRows = self::fetchOrderSeqRows(
$chunk,
@@ -422,7 +387,6 @@ class RevisitRateLogic
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = 0;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
@@ -433,10 +397,8 @@ class RevisitRateLogic
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$seq++;
$effectiveSlot = $seq + $offset;
$ct = (int) ($r['create_time'] ?? 0);
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
$tl = $timeline[$did] ?? [];
@@ -445,27 +407,24 @@ class RevisitRateLogic
$holder = (int) $tl[$ptr]['to'];
$ptr++;
}
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
if ($seq < 2 || $seq > self::MAX_VISIT_SLOT) {
continue;
}
if ($ct < $startTs || $ct > $endTs) {
continue;
}
if ($holder > 0) {
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
$slotOrdersByAssistant[$holder][$seq][] = $r;
}
}
}
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
// 医助归属部门 + 部门筛选(含组织下级)
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
if ($subtreeSet === []) {
// 无二中心部门时整表为空,避免误展示其它中心数据
$diagsByAssistant = [];
$slotOrdersByAssistant = [];
} else {
$deptFilterIds = self::parseDeptIds($params['dept_ids'] ?? null);
if ($deptFilterIds !== []) {
$subtreeSet = self::expandDeptSubtreeSet($deptFilterIds);
foreach ($universeIds as $aid) {
$deptId = (int) ($assistantDept[$aid] ?? 0);
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
@@ -584,45 +543,6 @@ class RevisitRateLogic
return [$canonical, $names];
}
/**
* 部门筛选集合:始终落在「二中心」子树内。
* - 未传 dept_ids:整棵二中心子树
* - 已传:所选部门及其下级 二中心子树(非法/非二中心 id 被忽略)
*
* @param mixed $raw
*
* @return array<int, true>
*/
private static function resolveDeptFilterSet(mixed $raw): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return [];
}
$deptFilterIds = self::parseDeptIds($raw);
if ($deptFilterIds === []) {
return $erSet;
}
$allowedRoots = [];
foreach ($deptFilterIds as $id) {
if (isset($erSet[$id])) {
$allowedRoots[] = $id;
}
}
if ($allowedRoots === []) {
return [];
}
$expanded = self::expandDeptSubtreeSet($allowedRoots);
$out = [];
foreach ($expanded as $id => $_) {
if (isset($erSet[$id])) {
$out[$id] = true;
}
}
return $out;
}
/**
* @param mixed $raw int[] | 逗号分隔字符串
*
@@ -676,35 +596,6 @@ class RevisitRateLogic
return $set;
}
/**
* 名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单集合。
* 用于「当月被指派总数」分母过滤;不限订单创建月份。
*
* @param list<int> $diagIds
*
* @return array<int, true>
*/
private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$ids = Db::name('tcm_prescription_order')
->whereIn('diagnosis_id', $chunk)
->whereNull('delete_time')
->whereIn('fulfillment_status', [9, 10])
->group('diagnosis_id')
->column('diagnosis_id');
foreach ($ids as $id) {
$out[(int) $id] = true;
}
}
return $out;
}
/**
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
*
@@ -728,48 +619,6 @@ class RevisitRateLogic
->toArray();
}
/**
* @param list<int> $diagIds
*
* @return array<int, int> diagnosis_id => revisit_slot_start_offset
*/
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $chunk)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
foreach ($rows as $id => $offset) {
$out[(int) $id] = (int) $offset;
}
}
return $out;
}
/**
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移)
*
* @param array<int, int> $offsetMap
*/
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
{
$offset = (int) ($offsetMap[$diagId] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > 20) {
$offset = 20;
}
return $offset;
}
/**
* @param list<int> $diagIds
*
@@ -2616,420 +2616,6 @@ class YejiStatsLogic
];
}
/**
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。
*
* @param array{
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
* assistant_id?:int|string,admin_id?:int|string,dept_id?:int|string,
* page?:int|string,page_size?:int|string
* } $params
*
* @return array<string, mixed>
*/
public static function assignLines(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$assistantId = (int) ($params['assistant_id'] ?? $params['admin_id'] ?? 0);
if ($assistantId <= 0 && array_key_exists('dept_id', $params)) {
return self::yejiDeptAssignLinesInner($params, $c);
}
return self::yejiAssistantAssignLinesInner($params, $c, $assistantId);
}
/**
* @param array<string, mixed> $params
* @param array<string, mixed> $c
*
* @return array<string, mixed>
*/
private static function yejiDeptAssignLinesInner(array $params, array $c): array
{
$deptIdParam = (int) ($params['dept_id'] ?? 0);
$channelCode = (string) $c['channelCode'];
$deptName = $deptIdParam === 0
? '未归属中心'
: self::formatYejiDeptRowDisplayName($deptIdParam, $c['deptById']);
$empty = static function (string $note) use ($c, $channelCode, $deptIdParam, $deptName): array {
return self::yejiAssignLinesEmptyPayload($c, $note, 0, '', $deptIdParam, $deptName, $channelCode);
};
if ($deptIdParam <= 0) {
return $empty('未归属中心无被指派明细(看板该行为 0)。');
}
if (!in_array($deptIdParam, $c['tableRowDeptIds'], true)) {
return $empty('该部门不在当前展示部门筛选或数据权限范围内。');
}
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($channelCode !== '' && isset($erCenterDeptSet[$deptIdParam])) {
return $empty('与看板一致:二中心及其组织下级在选定渠道下被指派数计 0,无明细。');
}
$targetAssistantIds = [];
foreach ($c['adminToPrimary'] as $aid => $primary) {
if ((int) $primary === $deptIdParam) {
$targetAssistantIds[] = (int) $aid;
}
}
if ($targetAssistantIds === []) {
return $empty('当前部门下没有可映射的被指派医助,无明细。');
}
return self::yejiAssignLinesFetch($c, $params, $targetAssistantIds, 0, '', $deptIdParam, $deptName);
}
/**
* @param array<string, mixed> $params
* @param array<string, mixed> $c
*
* @return array<string, mixed>
*/
private static function yejiAssistantAssignLinesInner(array $params, array $c, int $assistantId): array
{
$channelCode = (string) $c['channelCode'];
$assistantName = '';
if ($assistantId > 0) {
$assistantName = (string) (Db::name('admin')->where('id', $assistantId)->whereNull('delete_time')->value('name') ?: '');
}
$empty = static function (string $note) use ($c, $assistantId, $assistantName, $channelCode): array {
return self::yejiAssignLinesEmptyPayload($c, $note, $assistantId, $assistantName, 0, '', $channelCode);
};
if ($assistantId <= 0) {
return $empty('请指定有效医助,或传入 dept_id 查看部门汇总明细。');
}
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
if ($dataScopeRestricted && $dataScopeAdminIds !== null) {
$flip = array_flip($dataScopeAdminIds);
if (!isset($flip[$assistantId])) {
return $empty('当前账号数据权限下不可查看该医助被指派明细。');
}
}
if (!isset($c['adminToPrimary'][$assistantId])) {
return $empty('该医助不在当前业绩看板展示范围内。');
}
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
if ($tagDiagIds !== null && $tagDiagIds === []) {
return $empty('当前渠道标签下无关联诊单,无被指派明细。');
}
if ($scopedAssistants !== null && $scopedAssistants === []) {
return $empty('当前渠道标签下无关联医助,无被指派明细。');
}
if ($scopedAssistants !== null && !isset($scopedAssistants[$assistantId])) {
return $empty('当前渠道标签口径下该医助无被指派记录(与排行榜「被指派数」一致)。');
}
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$primaryDept = (int) ($c['adminToPrimary'][$assistantId] ?? 0);
if ($channelCode !== '' && $primaryDept > 0 && isset($erCenterDeptSet[$primaryDept])) {
return $empty('与看板一致:二中心及其组织下级医助在选定渠道下被指派数计 0,无明细。');
}
return self::yejiAssignLinesFetch(
$c,
$params,
[$assistantId],
$assistantId,
$assistantName,
0,
''
);
}
/**
* @param array<string, mixed> $c
* @param array<string, mixed> $params
* @param list<int> $targetAssistantIds
*
* @return array<string, mixed>
*/
private static function yejiAssignLinesFetch(
array $c,
array $params,
array $targetAssistantIds,
int $assistantIdOut,
string $assistantNameOut,
int $deptIdOut,
string $deptNameOut
): array {
$channelCode = (string) $c['channelCode'];
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) {
$visFlip = array_flip($dataScopeAdminIds);
$targetAssistantIds = array_values(array_filter(
$targetAssistantIds,
static fn (int $aid): bool => isset($visFlip[$aid])
));
}
if ($targetAssistantIds === []) {
return self::yejiAssignLinesEmptyPayload(
$c,
'当前数据权限下无可查看的被指派医助。',
$assistantIdOut,
$assistantNameOut,
$deptIdOut,
$deptNameOut,
$channelCode
);
}
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
if ($tagDiagIds !== null && $tagDiagIds === []) {
return self::yejiAssignLinesEmptyPayload(
$c,
'当前渠道标签下无关联诊单,无被指派明细。',
$assistantIdOut,
$assistantNameOut,
$deptIdOut,
$deptNameOut,
$channelCode
);
}
if ($scopedAssistants !== null && $scopedAssistants === []) {
return self::yejiAssignLinesEmptyPayload(
$c,
'当前渠道标签下无关联医助,无被指派明细。',
$assistantIdOut,
$assistantNameOut,
$deptIdOut,
$deptNameOut,
$channelCode
);
}
$buildBaseQuery = static function () use (
$c,
$targetAssistantIds,
$startTs,
$endTs,
$tagDiagIds,
$scopedAssistants
) {
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$q = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0)
->where('lg.is_inherit', 0)
->whereIn('lg.to_assistant_id', $targetAssistantIds);
self::applyYejiAssignLogChannelFilters(
$q,
$tagDiagIds,
$scopedAssistants,
$c['appointmentChannelValues'],
$c['channelFilterActive'],
$c['channelInfo']
);
return $q;
};
$countRows = $buildBaseQuery()
->field(['lg.to_assistant_id', 'lg.diagnosis_id'])
->group('lg.to_assistant_id,lg.diagnosis_id')
->select()
->toArray();
$count = \count($countRows);
$rawList = $buildBaseQuery()
->field([
'lg.to_assistant_id',
'lg.diagnosis_id',
Db::raw('COUNT(*) AS assign_count'),
Db::raw('MAX(lg.create_time) AS last_assign_time'),
])
->group('lg.to_assistant_id,lg.diagnosis_id')
->order('last_assign_time', 'desc')
->order('lg.diagnosis_id', 'desc')
->limit($offset, $limit)
->select()
->toArray();
$diagIds = [];
$assistantIds = [];
foreach ($rawList as $rw) {
$diagIds[(int) ($rw['diagnosis_id'] ?? 0)] = true;
$assistantIds[(int) ($rw['to_assistant_id'] ?? 0)] = true;
}
$diagInfo = self::fetchYejiAssignDiagnosisInfo(array_keys($diagIds));
$nameMap = $assistantIds !== []
? Db::name('admin')->whereIn('id', array_keys($assistantIds))->column('name', 'id')
: [];
$lists = [];
foreach ($rawList as $rw) {
$did = (int) ($rw['diagnosis_id'] ?? 0);
$aid = (int) ($rw['to_assistant_id'] ?? 0);
$lastTs = (int) ($rw['last_assign_time'] ?? 0);
$lists[] = [
'diagnosis_id' => $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'assistant_id' => $aid,
'assistant_name' => $aid > 0 ? (string) ($nameMap[$aid] ?? ('#' . $aid)) : '—',
'assign_count' => (int) ($rw['assign_count'] ?? 0),
'last_assign_time' => $lastTs,
'last_assign_time_text' => $lastTs > 0 ? date('Y-m-d H:i:s', $lastTs) : '',
];
}
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。';
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => $assistantIdOut,
'assistant_name' => $assistantNameOut,
'dept_id' => $deptIdOut,
'dept_name' => $deptNameOut,
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* @param array<string, mixed> $c
*
* @return array<string, mixed>
*/
private static function yejiAssignLinesEmptyPayload(
array $c,
string $note,
int $assistantId,
string $assistantName,
int $deptId,
string $deptName,
string $channelCode
): array {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => $assistantId,
'assistant_name' => $assistantName,
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => $note,
];
}
/**
* @param \think\db\Query $query
*/
private static function applyYejiAssignLogChannelFilters(
$query,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $channelInfo
): void {
if (!$channelFilterActive) {
return;
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$query->whereRaw('0 = 1');
return;
}
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo);
if ($pack === null) {
$query->whereRaw('0 = 1');
return;
}
$query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
$query->whereRaw('0 = 1');
return;
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
$query->whereRaw('0 = 1');
return;
}
if ($tagDiagIds !== null) {
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
}
} else {
$query->whereRaw('0 = 1');
}
}
/**
* @param list<int> $diagIds
*
* @return array<int, array{patient_name:string,phone:string}>
*/
private static function fetchYejiAssignDiagnosisInfo(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $diagIds)
->field(['id', 'patient_name', 'phone'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
'phone' => trim((string) ($r['phone'] ?? '')),
];
}
return $out;
}
/**
* admin 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。
*/
+18 -164
View File
@@ -28,7 +28,6 @@ use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
@@ -74,8 +73,8 @@ class DiagnosisLogic extends BaseLogic
}
}
// 新患者先占位 0,写入后 patient_id 与自增 id 对齐
$params['patient_id'] = 0;
// 生成患者ID
$params['patient_id'] = self::generatePatientId();
// 处理既往史数组
if (isset($params['past_history']) && is_array($params['past_history'])) {
@@ -120,7 +119,6 @@ class DiagnosisLogic extends BaseLogic
}
$model = Diagnosis::create($params);
$model = self::syncPatientIdWithDiagnosisId($model);
// 图片写入 doctor_note 表
if (!empty($newTongueImages) || !empty($newReportFiles)) {
@@ -132,7 +130,7 @@ class DiagnosisLogic extends BaseLogic
}
// 自动为患者创建 TRTC 账号
self::createPatientTrtcAccount($model->patient_id);
self::createPatientTrtcAccount($model->id);
return $model->id;
} catch (\Exception $e) {
@@ -142,20 +140,16 @@ class DiagnosisLogic extends BaseLogic
}
/**
* @notes 新患者首张诊单:patient_id 与自增 id 对齐
* @param Diagnosis $model
* @return Diagnosis
* @notes 生成患者ID
* @return int
*/
private static function syncPatientIdWithDiagnosisId(Diagnosis $model): Diagnosis
private static function generatePatientId(): int
{
if ((int) $model->patient_id === (int) $model->id) {
return $model;
}
// 获取当前最大的患者ID
$maxPatientId = Diagnosis::max('id') ?? 10000000;
Diagnosis::where('id', $model->id)->update(['patient_id' => $model->id]);
$model->patient_id = (int) $model->id;
return $model;
// 返回下一个患者ID
return $maxPatientId + 1;
}
/**
@@ -163,23 +157,9 @@ class DiagnosisLogic extends BaseLogic
* @param array $params
* @return bool
*/
/** 诊单编辑:患者基本信息字段(姓名/身份证/手机/性别/年龄) */
private const PATIENT_BASIC_FIELDS = ['patient_name', 'id_card', 'phone', 'gender', 'age'];
public static function edit(array $params, array $adminInfo = []): bool
public static function edit(array $params): bool
{
try {
if (!empty($params['id']) && $adminInfo !== []) {
$existing = Diagnosis::find((int) $params['id']);
if ($existing && !self::canEditPatientBasicInfo((int) $params['id'], $adminInfo)) {
if (self::patientBasicFieldsChanged($existing, $params)) {
self::setError('最近业务订单未完成,无法修改患者基本信息');
return false;
}
}
}
// 检查手机号是否重复(排除当前记录)
if (!empty($params['phone'])) {
$exists = Diagnosis::where('phone', $params['phone'])
@@ -270,7 +250,7 @@ class DiagnosisLogic extends BaseLogic
* @param $params
* @return array
*/
public static function detail($params, array $adminInfo = []): array
public static function detail($params): array
{
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
@@ -338,16 +318,6 @@ class DiagnosisLogic extends BaseLogic
}
$diagnosis['is_view'] =DiagnosisViewRecord::where(['diagnosis_id'=> $diagnosis['id'], 'user_id' =>$params['user_id']??0])->find() ? 1 : 0;
$latestPo = self::getLatestPrescriptionOrderRowForDiagnosis((int) ($diagnosis['id'] ?? 0));
$patientBasicLocked = $latestPo !== null && (int) ($latestPo['fulfillment_status'] ?? 0) !== 3;
$diagnosis['patient_basic_locked'] = $patientBasicLocked;
$diagnosis['can_edit_patient_basic'] = self::canEditPatientBasicInfo((int) ($diagnosis['id'] ?? 0), $adminInfo);
$diagnosis['latest_prescription_order'] = $latestPo !== null ? [
'id' => (int) ($latestPo['id'] ?? 0),
'fulfillment_status' => (int) ($latestPo['fulfillment_status'] ?? 0),
'fulfillment_status_text'=> PrescriptionOrderLogic::fulfillmentStatusLabel((int) ($latestPo['fulfillment_status'] ?? 0)),
] : null;
return $diagnosis;
}
@@ -516,11 +486,11 @@ class DiagnosisLogic extends BaseLogic
}
/**
* 诊单下「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
*
* @return array<string, mixed>|null
* @return array{creator_id: int, create_time: int}|null
*/
private static function getLatestPrescriptionOrderRowForDiagnosis(int $diagnosisId): ?array
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
{
if ($diagnosisId <= 0) {
return null;
@@ -530,88 +500,19 @@ class DiagnosisLogic extends BaseLogic
->whereNull('delete_time')
->order('create_time', 'desc')
->order('id', 'desc')
->field(['id', 'creator_id', 'create_time', 'fulfillment_status'])
->field(['creator_id', 'create_time'])
->find();
if ($row === null || $row === []) {
return null;
}
return $row;
}
/**
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
*
* @return array{creator_id: int, create_time: int}|null
*/
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
{
$row = self::getLatestPrescriptionOrderRowForDiagnosis($diagnosisId);
if ($row === null) {
return null;
}
return [
'creator_id' => (int) ($row['creator_id'] ?? 0),
'create_time' => (int) ($row['create_time'] ?? 0),
];
}
/**
* 菜单权限 tcm.diagnosis/editPatientBasic:最近业务订单未完成时仍可改患者基本信息
*/
public static function hasEditPatientBasicPermission(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
return in_array('tcm.diagnosis/editPatientBasic', $perms, true);
}
/**
* 是否允许编辑诊单患者基本信息(无业务单 / 最近一单已完成 / 具备 editPatientBasic 权限)
*/
public static function canEditPatientBasicInfo(int $diagnosisId, array $adminInfo): bool
{
$latest = self::getLatestPrescriptionOrderRowForDiagnosis($diagnosisId);
if ($latest === null) {
return true;
}
if ((int) ($latest['fulfillment_status'] ?? 0) === 3) {
return true;
}
return self::hasEditPatientBasicPermission($adminInfo);
}
/**
* @param Diagnosis $existing
*/
private static function patientBasicFieldsChanged($existing, array $params): bool
{
foreach (self::PATIENT_BASIC_FIELDS as $field) {
if (!array_key_exists($field, $params)) {
continue;
}
$old = $existing->{$field};
$new = $params[$field];
if ($field === 'age' || $field === 'gender') {
if ((int) $old !== (int) $new) {
return true;
}
continue;
}
if ((string) $old !== (string) $new) {
return true;
}
}
return false;
}
/**
* 诊单 current assistant_id 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
@@ -748,49 +649,6 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* @notes 诊单指派医助操作记录列表
*/
public static function setRevisitSlotStartOffset(int $diagnosisId, int $offset, array $adminInfo): bool
{
self::$error = '';
if ($diagnosisId <= 0) {
self::setError('诊单不存在');
return false;
}
if ($offset < 0 || $offset > 20) {
self::setError('起始偏移须在 020 之间');
return false;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find();
if (!$diagnosis) {
self::setError('诊单不存在');
return false;
}
$old = (int) ($diagnosis->revisit_slot_start_offset ?? 0);
if ($old < 0) {
$old = 0;
}
if ($old > 20) {
$old = 20;
}
if ($old === $offset) {
return true;
}
try {
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单指派医助操作记录列表
*/
@@ -3234,9 +3092,8 @@ class DiagnosisLogic extends BaseLogic
unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record
$patientId = $params['patient_id'] ?? 0;
$isNewPatient = !$patientId;
if ($isNewPatient) {
$params['patient_id'] = 0;
if (!$patientId) {
$params['patient_id'] = self::generatePatientId();
}
$params['status'] = 1;
@@ -3266,9 +3123,6 @@ class DiagnosisLogic extends BaseLogic
}
$model = Diagnosis::create($params);
if ($isNewPatient) {
$model = self::syncPatientIdWithDiagnosisId($model);
}
self::createPatientTrtcAccount($model->patient_id);
// 图片写入 doctor_note
@@ -7,9 +7,7 @@ namespace app\adminapi\logic\tcm;
use app\common\cache\AdminAuthCache;
use app\common\logic\BaseLogic;
use app\common\model\auth\AdminRole;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\tcm\PrescriptionLibrary;
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
use think\facade\Config;
/**
@@ -17,18 +15,6 @@ use think\facade\Config;
*/
class PrescriptionLibraryLogic extends BaseLogic
{
/** @param array<int,array<string,mixed>> $herbs @return array<int,array<string,mixed>> */
private static function normalizeHerbIdentities(array $herbs): array
{
return PharmacyHerbIdentityResolver::resolve(
$herbs,
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
);
}
/**
* @notes 是否可管理全部处方(超级管理员 配置中的管理员角色)
*/
@@ -108,10 +94,7 @@ class PrescriptionLibraryLogic extends BaseLogic
// 处理药材数据
if (isset($params['herbs']) && is_array($params['herbs'])) {
$params['herbs'] = json_encode(
self::normalizeHerbIdentities($params['herbs']),
JSON_UNESCAPED_UNICODE
);
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
}
$model = PrescriptionLibrary::create($params);
@@ -147,10 +130,7 @@ class PrescriptionLibraryLogic extends BaseLogic
// 处理药材数据
if (isset($params['herbs']) && is_array($params['herbs'])) {
$params['herbs'] = json_encode(
self::normalizeHerbIdentities($params['herbs']),
JSON_UNESCAPED_UNICODE
);
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
}
$model->save($params);
@@ -6,13 +6,10 @@ namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\tcm\Prescription;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\Diagnosis;
use app\common\service\wechat\WechatWorkAppMessageService;
use app\common\service\pharmacy\PharmacyHerbIdentityResolver;
use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
use app\adminapi\logic\auth\AuthLogic;
use think\facade\Config;
use think\facade\Log;
@@ -162,18 +159,6 @@ class PrescriptionLogic
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
}
/** @param array<int,array<string,mixed>> $herbs @return array<int,array<string,mixed>> */
private static function normalizeHerbIdentities(array $herbs): array
{
return PharmacyHerbIdentityResolver::resolve(
$herbs,
static fn (array $ids): array => DoctorMedicine::whereIn('id', $ids)
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray(),
static fn (array $names): array => DoctorMedicine::whereIn('name', $names)
->field(['id', 'name', 'status', 'delete_time'])->select()->toArray()
);
}
/**
* @notes 辅方用法 JSON(与主方 dosage/times/days 字段结构一致)
*/
@@ -270,12 +255,13 @@ class PrescriptionLogic
self::setError('请添加中药');
return null;
}
try {
$herbs = self::normalizeHerbIdentities($herbs);
} catch (\DomainException $exception) {
self::setError($exception->getMessage());
foreach ($herbs as $h) {
if (empty($h['name'])) {
self::setError('中药名称不能为空');
return null;
}
}
$sn = self::generateSn();
while (Prescription::where('sn', $sn)->find()) {
@@ -349,23 +335,6 @@ class PrescriptionLogic
* 编辑处方
*/
public static function edit(array $params, int $adminId): bool
{
$prescriptionId = (int) ($params['id'] ?? 0);
self::setError('');
try {
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
$prescriptionId,
static fn (): bool => self::editLocked($params, $adminId)
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
self::setError($exception->getMessage());
}
return false;
}
}
private static function editLocked(array $params, int $adminId): bool
{
try {
$prescription = Prescription::find($params['id']);
@@ -373,11 +342,6 @@ class PrescriptionLogic
self::setError('处方不存在');
return false;
}
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
if ($snapshotLockError !== null) {
self::setError($snapshotLockError);
return false;
}
// 已通过且未作废:不可编辑
if ((int) ($prescription->audit_status ?? 1) === 1 && (int) ($prescription->void_status ?? 0) === 0) {
@@ -417,7 +381,12 @@ class PrescriptionLogic
return false;
}
$herbs = self::normalizeHerbIdentities($herbs);
foreach ($herbs as $h) {
if (empty($h['name'])) {
self::setError('中药名称不能为空');
return false;
}
}
$newDiagnosisId = (int) ($params['diagnosis_id'] ?? $prescription->diagnosis_id);
$newDateYmd = self::normalizePrescriptionDate($params['prescription_date'] ?? $prescription->prescription_date);
@@ -505,29 +474,6 @@ class PrescriptionLogic
* 仅修正处方笺展示用患者姓名、手机号与性别(zyt_tcm_prescription),不改变审核状态与其它字段
*/
public static function patchPatientContact(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
{
self::setError('');
try {
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
$rxId,
static fn (): bool => self::patchPatientContactLocked(
$rxId,
$patientName,
$phone,
$gender,
$adminId,
$adminInfo
)
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
self::setError($exception->getMessage());
}
return false;
}
}
private static function patchPatientContactLocked(int $rxId, string $patientName, string $phone, int $gender, int $adminId, array $adminInfo): bool
{
self::$error = '';
$prescription = Prescription::find($rxId);
@@ -536,11 +482,6 @@ class PrescriptionLogic
return false;
}
$snapshotLockError = PrescriptionOrderLogic::remoteSnapshotLockErrorForPrescription((int) $prescription->id);
if ($snapshotLockError !== null) {
self::setError($snapshotLockError);
return false;
}
if (!self::canViewPrescription($prescription, $adminId, $adminInfo)) {
self::setError('无权限修改此处方');
@@ -637,22 +578,6 @@ class PrescriptionLogic
* 删除处方
*/
public static function delete(int $id): bool
{
self::setError('');
try {
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
$id,
static fn (): bool => self::deleteLocked($id)
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
self::setError($exception->getMessage());
}
return false;
}
}
private static function deleteLocked(int $id): bool
{
try {
$prescription = Prescription::find($id);
@@ -1102,22 +1027,6 @@ class PrescriptionLogic
* 作废处方
*/
public static function void(int $id, int $adminId, string $adminName): bool
{
self::setError('');
try {
return (bool) LockedPharmacySnapshotMutation::executeForPrescription(
$id,
static fn (): bool => self::voidLocked($id, $adminId, $adminName)
);
} catch (\Throwable $exception) {
if (self::getError() === '') {
self::setError($exception->getMessage());
}
return false;
}
}
private static function voidLocked(int $id, int $adminId, string $adminName): bool
{
$row = Prescription::find($id);
if (!$row) {

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