Compare commits

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

After

Width:  |  Height:  |  Size: 64 KiB

-158
View File
@@ -1,158 +0,0 @@
/**
* 将标准双 el-card 列表页迁移为 admin-page 架构组件
* 用法: node scripts/migrate-admin-pages.mjs [--dry-run] [glob...]
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const viewsRoot = path.join(__dirname, '../src/views')
const dryRun = process.argv.includes('--dry-run')
const extraPaths = process.argv.slice(2).filter((a) => !a.startsWith('--'))
const defaultTargets = [
'article/lists/index.vue',
'article/column/index.vue',
'permission/role/index.vue',
'permission/menu/index.vue',
'dev_tools/code/index.vue',
'setting/dict/type/index.vue',
'setting/dict/data/index.vue',
'message/notice/index.vue',
'message/short_letter/index.vue',
'fans/index.vue',
'order/index.vue',
'asset/user/index.vue',
'asset/resource/index.vue',
'finance/balance_details.vue',
'finance/recharge_record.vue',
'finance/refund_record.vue'
]
function migrate(content) {
if (content.includes('admin-page-filter-panel')) return null
if (!content.includes('el-card class="!border-none"')) return null
if (!content.includes('<el-table')) return null
let out = content
// 根容器 class 清理
out = out.replace(
/<div class="[^"]*">\s*\n\s*<el-card class="!border-none" shadow="never">/,
'<div>\n <admin-page-filter-panel>'
)
if (!out.includes('admin-page-filter-panel')) {
out = out.replace(
/<el-card class="!border-none" shadow="never">/,
'<admin-page-filter-panel>'
)
}
// 第一个 filter card 结束
out = out.replace(
/<\/el-form>\s*\n\s*<\/el-card>/,
'</el-form>\n </admin-page-filter-panel>'
)
// 第二个 data card 开始 - 提取 v-loading
const loadingMatch = out.match(
/<el-card([^>]*?)class="[^"]*mt-4[^"]*"[^>]*shadow="never"[^>]*>/
)
const altLoadingMatch = out.match(
/<el-card v-loading="([^"]+)"([^>]*)class="[^"]*mt-4[^"]*"([^>]*)shadow="never"([^>]*)>/
)
if (altLoadingMatch) {
out = out.replace(
altLoadingMatch[0],
`<admin-page-data-panel v-loading="${altLoadingMatch[1]}">`
)
} else if (loadingMatch) {
out = out.replace(loadingMatch[0], '<admin-page-data-panel>')
} else {
out = out.replace(
/<el-card class="!border-none mt-4" shadow="never">/,
'<admin-page-data-panel>'
)
out = out.replace(
/<el-card v-loading="([^"]+)" class="mt-4 !border-none" shadow="never">/,
'<admin-page-data-panel v-loading="$1">'
)
out = out.replace(
/<el-card v-loading="([^"]+)" class="!border-none mt-4" shadow="never">/,
'<admin-page-data-panel v-loading="$1">'
)
}
// toolbar: 紧跟 data panel 后的首个 div 包裹按钮
out = out.replace(
/(<admin-page-data-panel[^>]*>\s*)<div>\s*\n(\s*<el-button[\s\S]*?<\/div>\s*\n)/,
'$1<template #toolbar>\n$2</template>\n'
)
out = out.replace(
/(<admin-page-data-panel[^>]*>\s*)<div>\s*\n(\s*<router-link[\s\S]*?<\/div>\s*\n)/,
'$1<template #toolbar>\n$2</template>\n'
)
// 移除 table 前的 mt-4 class
out = out.replace(/<el-table class="mt-4"/g, '<el-table')
out = out.replace(/<el-table\s+class="mt-4"\s+/g, '<el-table ')
// pagination footer
out = out.replace(
/<div class="flex(?: mt-4)? justify-end(?: mt-4)?">\s*\n\s*<pagination([^/]*)\/>\s*\n\s*<\/div>\s*\n\s*<\/el-card>/,
'<template #footer>\n <pagination$1/>\n </template>\n </admin-page-data-panel>'
)
out = out.replace(
/<div class="flex mt-4 justify-end">\s*\n\s*<pagination([^/]*)\/>\s*\n\s*<\/div>\s*\n\s*<\/el-card>/,
'<template #footer>\n <pagination$1/>\n </template>\n </admin-page-data-panel>'
)
// 无 pagination 的 data card 闭合
if (out.includes('<admin-page-data-panel') && out.includes('</el-card>')) {
out = out.replace(/<\/el-table>\s*\n\s*<\/el-card>/, '</el-table>\n </admin-page-data-panel>')
}
// table 上的 v-loading 移到 panel(若 panel 还没有)
out = out.replace(
/<admin-page-data-panel>\s*\n(\s*)<el-table([^>]*?)v-loading="([^"]+)"([^>]*)>/,
'<admin-page-data-panel v-loading="$3">\n$1<el-table$2$4>'
)
out = out.replace(/<el-table([^>]*?)v-loading="([^"]+)"([^>]*)>/, '<el-table$1$3>')
if (out === content || !out.includes('admin-page-data-panel')) return null
return out
}
const targets = extraPaths.length
? extraPaths.map((p) => path.resolve(p))
: defaultTargets.map((p) => path.join(viewsRoot, p))
let migrated = 0
let skipped = 0
for (const file of targets) {
if (!fs.existsSync(file)) {
console.warn('skip (missing):', path.relative(viewsRoot, file))
skipped++
continue
}
const original = fs.readFileSync(file, 'utf8')
const result = migrate(original)
if (!result) {
console.log('skip (no match):', path.relative(viewsRoot, file))
skipped++
continue
}
if (dryRun) {
console.log('would migrate:', path.relative(viewsRoot, file))
} else {
fs.writeFileSync(file, result, 'utf8')
console.log('migrated:', path.relative(viewsRoot, file))
}
migrated++
}
console.log(`\nDone. migrated=${migrated} skipped=${skipped}${dryRun ? ' (dry-run)' : ''}`)
+10
View File
@@ -227,6 +227,16 @@ export function revisitRateVisitOrderLines(params: {
return request.get({ url: '/stats.revisitRate/visitOrderLines', params }) return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
} }
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
export function autoAssignLogLists(params: Record<string, any>) {
return request.get({ url: '/stats.autoAssignLog/lists', params })
}
/** 批量回退自动分配:将诊单医助撤回到自动分配前的原医助 */
export function autoAssignLogRollback(data: { ids: number[] }) {
return request.post({ url: '/stats.autoAssignLog/rollback', data })
}
/** 医助个人业绩概览 */ /** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: { export function assistantPerformanceOverview(params: {
time_type?: string time_type?: string
+31
View File
@@ -61,6 +61,14 @@ export function tcmDiagnosisDetail(params: any) {
return request.get({ url: '/tcm.diagnosis/detail', params }) return request.get({ url: '/tcm.diagnosis/detail', params })
} }
/** 设置复诊接诊率统计起始偏移(统计诊次=实单序号+偏移;1=二诊起,2=三诊起) */
export function tcmDiagnosisSetRevisitSlotStartOffset(params: {
id: number
revisit_slot_start_offset: number
}) {
return request.post({ url: '/tcm.diagnosis/setRevisitSlotStartOffset', params })
}
/** 诊单挂号 / 取消挂号 操作日志 */ /** 诊单挂号 / 取消挂号 操作日志 */
export function tcmDiagnosisGuahaoLogList(params: { id: number }) { export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params }) return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
@@ -425,6 +433,17 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params }) return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
} }
export function prescriptionOrderPatchPrescriptionUsage(params: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
}
export function prescriptionOrderAuditPrescription(params: { export function prescriptionOrderAuditPrescription(params: {
id: number id: number
action: 'approve' | 'reject' action: 'approve' | 'reject'
@@ -523,6 +542,18 @@ export function prescriptionOrderLogs(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionOrder/logs', params }) return request.get({ url: '/tcm.prescriptionOrder/logs', params })
} }
/** 手工新增操作日志(可选调整处方/支付单审核状态) */
export function prescriptionOrderAddLog(params: {
id: number
summary: string
prescription_audit_status?: number | ''
payment_slip_audit_status?: number | ''
prescription_audit_remark?: string
payment_slip_audit_remark?: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
}
/** 修改订单金额 */ /** 修改订单金额 */
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) { export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params }) return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
@@ -1,24 +0,0 @@
<template>
<section class="admin-data-panel" v-loading="loading">
<div v-if="$slots.toolbar" class="admin-data-panel__toolbar">
<slot name="toolbar" />
</div>
<div class="admin-data-panel__body">
<slot />
</div>
<div v-if="$slots.footer" class="admin-data-panel__footer">
<slot name="footer" />
</div>
</section>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
loading?: boolean
}>(),
{
loading: false
}
)
</script>
@@ -1,60 +0,0 @@
<template>
<section class="admin-filter-panel" :class="{ 'is-collapsed': collapsed }">
<div v-if="collapsible" class="admin-filter-panel__head">
<span class="admin-filter-panel__label">{{ title }}</span>
<el-button class="admin-filter-panel__toggle" link type="primary" @click="collapsed = !collapsed">
{{ collapsed ? '展开筛选' : '收起筛选' }}
</el-button>
</div>
<div class="admin-filter-panel__body">
<slot />
</div>
</section>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
/** 是否显示收起/展开 */
collapsible?: boolean
/** 筛选区标题 */
title?: string
}>(),
{
collapsible: true,
title: '筛选条件'
}
)
const collapsed = ref(false)
</script>
@@ -1,24 +0,0 @@
<template>
<section class="admin-form-panel">
<header v-if="title || $slots.header" class="admin-form-panel__header">
<slot name="header">
<h2 v-if="title" class="admin-form-panel__title">{{ title }}</h2>
</slot>
</header>
<div class="admin-form-panel__body">
<slot />
</div>
<footer v-if="$slots.footer" class="admin-form-panel__footer">
<slot name="footer" />
</footer>
</section>
</template>
<script setup lang="ts">
defineProps({
title: {
type: String,
default: ''
}
})
</script>
@@ -1,5 +0,0 @@
<template>
<div class="admin-page-actions">
<slot />
</div>
</template>
@@ -1,16 +0,0 @@
<template>
<div class="admin-stat-grid" :class="[`admin-stat-grid--cols-${columns}`]">
<slot />
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
columns?: 2 | 3 | 4 | 5
}>(),
{
columns: 4
}
)
</script>
@@ -1,27 +0,0 @@
<template>
<article class="admin-stat-item" :class="[`admin-stat-item--${tone}`]">
<div class="admin-stat-item__label">{{ label }}</div>
<div class="admin-stat-item__value" :class="{ 'is-money': money }">
<slot>{{ value }}</slot>
</div>
<p v-if="hint" class="admin-stat-item__hint">{{ hint }}</p>
</article>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
value?: string | number
hint?: string
money?: boolean
tone?: 'default' | 'primary' | 'success' | 'warning'
}>(),
{
value: '',
hint: '',
money: false,
tone: 'default'
}
)
</script>
+3 -7
View File
@@ -17,18 +17,14 @@ defineProps({
<style scoped lang="scss"> <style scoped lang="scss">
.footer-btns { .footer-btns {
height: 64px; height: 60px;
&__content { &__content {
bottom: 0; bottom: 0;
height: 64px; height: 60px;
right: 0; right: 0;
left: 0; left: 0;
z-index: 99; z-index: 99;
padding: 0 20px; @apply flex justify-center items-center shadow bg-body;
border-top: 1px solid var(--el-border-color-lighter);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04);
@apply flex justify-center items-center gap-3 bg-body;
} }
} }
</style> </style>
-228
View File
@@ -1,228 +0,0 @@
<template>
<div class="page-stage admin-page" :class="{ 'page-stage--flat': hideChrome }">
<header v-if="showHeader" class="page-stage__hero">
<div class="page-stage__hero-glow" aria-hidden="true"></div>
<div class="page-stage__hero-inner">
<div class="page-stage__intro">
<h1 class="page-stage__title">{{ pageTitle }}</h1>
<p v-if="pageDesc" class="page-stage__desc">{{ pageDesc }}</p>
</div>
<div v-if="$slots.actions" class="page-stage__actions">
<slot name="actions" />
</div>
</div>
</header>
<div class="page-stage__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { PageEnum } from '@/enums/pageEnum'
const route = useRoute()
const hideChrome = computed(() => {
if (route.meta?.hidePageHeader) return true
if (route.path === PageEnum.INDEX || route.path === `${PageEnum.INDEX}/`) return true
const title = (route.meta?.title as string) || ''
return title.includes('工作台')
})
const pageTitle = computed(() => {
const matched = route.matched.filter((item) => item.meta?.title)
const last = matched[matched.length - 1]
return (last?.meta?.title as string) || ''
})
const pageDesc = computed(() => (route.meta?.pageDesc as string) || '')
const showHeader = computed(() => !hideChrome.value && Boolean(pageTitle.value))
</script>
<style lang="scss" scoped>
.page-stage {
min-height: 100%;
}
.page-stage__hero {
position: relative;
margin-bottom: 20px;
padding: 22px 24px;
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-glass);
backdrop-filter: blur(16px) saturate(140%);
box-shadow: var(--el-box-shadow-light);
overflow: hidden;
}
.page-stage__hero-glow {
position: absolute;
inset: 0;
background:
linear-gradient(120deg, rgba(6, 182, 212, 0.12) 0%, transparent 42%),
linear-gradient(300deg, rgba(16, 185, 129, 0.1) 0%, transparent 38%);
pointer-events: none;
}
.page-stage__hero-inner {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 20px;
}
.page-stage__title {
margin: 0;
font-size: 24px;
font-weight: 800;
line-height: 1.2;
letter-spacing: -0.03em;
color: var(--el-text-color-primary);
background: var(--admin-brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.page-stage__desc {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.5;
color: var(--el-text-color-secondary);
max-width: 62ch;
}
.page-stage__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.page-stage__body {
min-width: 0;
}
.page-stage--flat .page-stage__hero {
display: none;
}
</style>
+20 -20
View File
@@ -1,28 +1,28 @@
const defaultSetting = { const defaultSetting = {
showCrumb: false, showCrumb: true, // 是否显示面包屑
showLogo: true, showLogo: false, // 是否显示logo
isUniqueOpened: true, isUniqueOpened: true, //只展开一个一级菜单
sideWidth: 248, sideWidth: 183, //侧边栏宽度
sideTheme: 'dark', sideTheme: 'dark', //侧边栏主题
sideDarkColor: '#060d18', sideDarkColor: '#1d2124', //侧边栏深色主题颜色
openMultipleTabs: true, openMultipleTabs: true, // 是否开启多标签tab栏
theme: '#06b6d4', theme: '#4A5DFF', //主题色
successTheme: '#10b981', successTheme: '#67c23a', //成功主题色
warningTheme: '#f59e0b', warningTheme: '#e6a23c', //警告主题色
dangerTheme: '#ef4444', dangerTheme: '#f56c6c', //危险主题色
errorTheme: '#ef4444', errorTheme: '#f56c6c', //错误主题色
infoTheme: '#6366f1' infoTheme: '#909399' //信息主题色
} }
export const SETTING_SCHEMA_VERSION = 6 /** 本地 setting 缓存结构版本。提升后仅对低于该版本的老缓存执行 SETTING_SCHEMA_MIGRATIONS */
export const SETTING_SCHEMA_VERSION = 1
/**
* defaultSetting localStorage config
* SETTING_SCHEMA_VERSION +1
*/
export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = { export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = {
1: ['sideTheme', 'sideDarkColor'], 1: ['sideTheme', 'sideDarkColor']
2: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor', 'sideWidth', 'showLogo'],
3: ['theme', 'sideDarkColor', 'sideWidth', 'showCrumb', 'showLogo'],
4: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor'],
5: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor'],
6: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor', 'sideWidth']
} }
export default defaultSetting export default defaultSetting
@@ -1,5 +1,5 @@
<template> <template>
<el-breadcrumb class="app-breadcrumb" separator="/"> <el-breadcrumb class="app-breadcrumb">
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path"> <el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
{{ item.meta.title }} {{ item.meta.title }}
</el-breadcrumb-item> </el-breadcrumb-item>
@@ -21,24 +21,22 @@ useWatchRoute((route) => {
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.app-breadcrumb { .app-breadcrumb {
:deep(.el-breadcrumb__item) { :deep(.el-breadcrumb__item) {
.el-breadcrumb__inner { .el-breadcrumb__inner {
color: var(--el-text-color-secondary); color: #303133;
font-weight: 500; font-weight: 500;
font-size: var(--el-font-size-small);
} }
&:last-child .el-breadcrumb__inner { &:last-child .el-breadcrumb__inner {
color: var(--el-text-color-primary); color: #303133;
font-weight: 600;
} }
} }
:deep(.el-breadcrumb__separator) { :deep(.el-breadcrumb__separator) {
color: var(--el-text-color-placeholder); color: #606266;
font-weight: 400;
} }
} }
</style> </style>
@@ -1,7 +1,7 @@
<template> <template>
<header class="header"> <header class="header">
<div class="navbar"> <div class="navbar">
<div class="flex-1 flex items-center gap-1 min-w-0"> <div class="flex-1 flex">
<div class="navbar-item"> <div class="navbar-item">
<el-tooltip <el-tooltip
class="box-item" class="box-item"
@@ -17,14 +17,11 @@
<refresh /> <refresh />
</el-tooltip> </el-tooltip>
</div> </div>
<div <div class="flex items-center px-2" v-if="!isMobile && settingStore.showCrumb">
class="hidden md:flex items-center min-w-0 px-2"
v-if="settingStore.showCrumb && breadcrumbs.length"
>
<breadcrumb /> <breadcrumb />
</div> </div>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex">
<div class="navbar-item" v-if="!isMobile"> <div class="navbar-item" v-if="!isMobile">
<el-tooltip <el-tooltip
class="box-item" class="box-item"
@@ -39,7 +36,12 @@
<user-drop-down /> <user-drop-down />
</div> </div>
<div class="navbar-item"> <div class="navbar-item">
<el-tooltip class="box-item" effect="dark" content="主题设置" placement="bottom"> <el-tooltip
class="box-item"
effect="dark"
content="主题设置"
placement="bottom"
>
<setting /> <setting />
</el-tooltip> </el-tooltip>
</div> </div>
@@ -50,10 +52,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RouteLocationMatched } from 'vue-router'
import { useFullscreen } from '@vueuse/core' import { useFullscreen } from '@vueuse/core'
import { useWatchRoute } from '@/hooks/useWatchRoute'
import useAppStore from '@/stores/modules/app' import useAppStore from '@/stores/modules/app'
import useSettingStore from '@/stores/modules/setting' import useSettingStore from '@/stores/modules/setting'
@@ -70,20 +70,14 @@ const isMobile = computed(() => appStore.isMobile)
const isCollapsed = computed(() => appStore.isCollapsed) const isCollapsed = computed(() => appStore.isCollapsed)
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { isFullscreen } = useFullscreen() const { isFullscreen } = useFullscreen()
const breadcrumbs = ref<RouteLocationMatched[]>([])
useWatchRoute((route) => {
breadcrumbs.value = route.matched.filter((item) => item.meta && item.meta.title)
})
</script> </script>
<style lang="scss"> <style lang="scss">
.navbar { .navbar {
height: var(--navbar-height); height: var(--navbar-height);
@apply flex px-3 bg-body; @apply flex px-2 bg-body;
.navbar-item { .navbar-item {
@apply h-full flex justify-center items-center; @apply h-full flex justify-center items-center hover:bg-page;
} }
} }
</style> </style>
@@ -1,6 +1,6 @@
<template> <template>
<div class="app-tabs flex bg-body"> <div class="app-tabs pl-4 flex bg-body">
<div class="flex-1 min-w-0 pl-3"> <div class="flex-1 min-w-0">
<el-tabs <el-tabs
:model-value="currentTab" :model-value="currentTab"
:closable="tabsLists.length > 1" :closable="tabsLists.length > 1"
@@ -13,7 +13,7 @@
</el-tabs> </el-tabs>
</div> </div>
<el-dropdown @command="handleCommand"> <el-dropdown @command="handleCommand">
<span class="tabs-more-btn flex items-center px-3"> <span class="flex items-center px-3">
<icon :size="16" name="el-icon-arrow-down" /> <icon :size="16" name="el-icon-arrow-down" />
</span> </span>
<template #dropdown> <template #dropdown>
@@ -60,84 +60,61 @@ const handleCommand = (command: any) => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.app-tabs { .app-tabs {
height: var(--tabs-height); @apply border-t border-br;
border-top: 1px solid var(--admin-header-border);
background: var(--admin-surface-glass);
backdrop-filter: blur(12px);
.tabs-more-btn {
height: var(--tabs-height);
color: var(--el-text-color-secondary);
cursor: pointer;
transition: color 0.2s ease, background-color 0.2s ease;
&:hover {
color: var(--admin-brand-primary-dark);
background: var(--admin-brand-gradient-soft);
}
}
:deep(.el-tabs) { :deep(.el-tabs) {
height: var(--tabs-height); height: 40px;
.el-tabs { .el-tabs {
&__header { &__header {
margin-bottom: 0; margin-bottom: 0;
} }
&__content { &__content {
display: none; display: none;
} }
&__nav-next, &__nav-next,
&__nav-prev { &__nav-prev {
@apply text-lg; @apply text-xl;
} }
&__nav-wrap::after { &__nav-wrap::after {
height: 0; height: 0;
} }
&__item { &__item {
font-weight: 600; font-weight: normal;
font-size: var(--el-font-size-small); padding: 0 15px !important;
padding: 0 16px !important;
height: calc(var(--tabs-height) - 8px);
margin-top: 4px;
border-radius: var(--admin-radius-md) var(--admin-radius-md) 0 0;
box-sizing: border-box; box-sizing: border-box;
color: var(--el-text-color-secondary);
transition: color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
color: var(--admin-brand-primary-dark);
}
&.is-active { &.is-active {
color: var(--admin-brand-primary-dark); color: var(--el-text-color-primary);
background: var(--el-bg-color); background-color: var(--el-color-primary-light-9);
border: 1px solid var(--el-border-color-lighter); &::before {
border-bottom-color: transparent; content: '';
box-shadow: var(--el-box-shadow-lighter); display: inline-block;
width: 6px;
&::before, height: 6px;
background-color: var(--el-color-primary);
margin-right: 6px;
border-radius: 50%;
vertical-align: 2px;
}
&::after { &::after {
display: none; position: absolute;
content: '';
display: block;
top: 0;
height: 2px;
left: 0;
width: 100%;
background-color: var(--el-color-primary);
} }
} }
.is-icon-close { .is-icon-close {
color: var(--el-text-color-placeholder); color: var(--el-text-color-regular);
vertical-align: -2px; vertical-align: -2px;
border-radius: var(--admin-radius-sm);
&:hover { &:hover {
color: var(--color-white); color: var(--color-white);
background-color: var(--el-color-danger); background-color: var(--el-color-danger);
} }
} }
} }
&__active-bar { &__active-bar {
display: none; display: none;
} }
@@ -1,11 +1,9 @@
<template> <template>
<el-dropdown class="user-dropdown px-1" @command="handleCommand"> <el-dropdown class="px-2" @command="handleCommand">
<div class="user-trigger flex items-center gap-2.5 px-2 py-1 rounded-md cursor-pointer"> <div class="flex items-center">
<el-avatar :size="32" :src="userInfo.avatar" /> <el-avatar :size="34" :src="userInfo.avatar" />
<span class="user-name max-w-[120px] truncate text-sm font-medium text-tx-primary">{{ <div class="ml-3 mr-1">{{ userInfo.name }}</div>
userInfo.name <icon name="el-icon-ArrowDown" />
}}</span>
<icon name="el-icon-ArrowDown" :size="14" />
</div> </div>
<template #dropdown> <template #dropdown>
@@ -43,13 +41,3 @@ const handleCommand = async (command: string) => {
} }
} }
</script> </script>
<style scoped lang="scss">
.user-trigger {
transition: background-color 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
}
}
</style>
+4 -33
View File
@@ -1,56 +1,27 @@
<template> <template>
<main class="main-wrap h-full bg-page">
<main class="main-wrap h-full">
<el-scrollbar> <el-scrollbar>
<div class="px-2 py-4">
<div class="main-stage"> <router-view v-if="isRouteShow" v-slot="{ Component, route }">
<page-shell v-if="isRouteShow">
<router-view v-slot="{ Component, route }">
<keep-alive :include="includeList" :max="20"> <keep-alive :include="includeList" :max="20">
<component :is="Component" :key="route.fullPath" /> <component :is="Component" :key="route.fullPath" />
</keep-alive> </keep-alive>
</router-view> </router-view>
</page-shell>
</div> </div>
</el-scrollbar> </el-scrollbar>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import PageShell from '@/components/page-shell/index.vue'
import useAppStore from '@/stores/modules/app' import useAppStore from '@/stores/modules/app'
import useTabsStore from '@/stores/modules/multipleTabs' import useTabsStore from '@/stores/modules/multipleTabs'
import useSettingStore from '@/stores/modules/setting' import useSettingStore from '@/stores/modules/setting'
const appStore = useAppStore() const appStore = useAppStore()
const tabsStore = useTabsStore() const tabsStore = useTabsStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const isRouteShow = computed(() => appStore.isRouteShow) const isRouteShow = computed(() => appStore.isRouteShow)
const includeList = computed(() => (settingStore.openMultipleTabs ? tabsStore.getCacheTabList : [])) const includeList = computed(() => (settingStore.openMultipleTabs ? tabsStore.getCacheTabList : []))
</script> </script>
<style></style>
@@ -100,7 +100,7 @@ import theme_light from '@/assets/images/theme_white.png'
import useSettingStore from '@/stores/modules/setting' import useSettingStore from '@/stores/modules/setting'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const predefineColors = ref(['#06b6d4', '#10b981', '#0891b2', '#6366f1', '#f59e0b', '#ef4444', '#64748b']) const predefineColors = ref(['#409EFF', '#28C76F', '#EA5455', '#FF9F43', '#01CFE8', '#4A5DFF'])
const sideThemeList = [ const sideThemeList = [
{ {
type: 'dark', type: 'dark',
@@ -68,42 +68,32 @@ const themeClass = computed(() => `theme-${props.theme}`)
.el-menu { .el-menu {
:deep(.el-menu-item) { :deep(.el-menu-item) {
&.is-active { &.is-active {
@apply bg-primary; @apply bg-primary border-primary;
box-shadow: inset 3px 0 0 var(--el-color-primary-light-3);
} }
} }
} }
:deep(.el-menu--collapse) { :deep(.el-menu--collapse) {
.el-sub-menu.is-active .el-sub-menu__title { .el-sub-menu.is-active .el-sub-menu__title {
@apply bg-primary #{!important}; @apply bg-primary #{!important};
} }
} }
} }
&.theme-light { &.theme-light {
:deep(.el-menu) { :deep(.el-menu) {
.el-menu-item { .el-menu-item {
border-color: transparent; border-color: transparent;
&.is-active { &.is-active {
@apply bg-primary-light-9; @apply bg-primary-light-9 border-r-2 border-primary;
color: var(--el-color-primary);
font-weight: 600;
box-shadow: inset 3px 0 0 var(--el-color-primary);
} }
} }
.el-menu-item:hover, .el-menu-item:hover,
.el-sub-menu__title:hover { .el-sub-menu__title:hover {
color: var(--el-color-primary); color: var(--el-color-primary);
} }
} }
} }
.el-menu { .el-menu {
border-right: none; border-right: none;
&:not(.el-menu--collapse) { &:not(.el-menu--collapse) {
width: var(--aside-width); width: var(--aside-width);
} }
@@ -1,15 +1,5 @@
<template> <template>
<div class="side" :style="sideStyle"> <div class="side" :style="sideStyle">
<div v-if="showBrandStrip" class="sidebar-brand">
<div class="sidebar-brand__mark">ZY</div>
<overflow-tooltip
class="sidebar-brand__name"
:content="config.web_name"
:teleported="true"
placement="bottom"
overflo-type="unset"
/>
</div>
<side-logo v-if="settingStore.showLogo" :show-title="!isCollapsed" :theme="sideTheme" /> <side-logo v-if="settingStore.showLogo" :show-title="!isCollapsed" :theme="sideTheme" />
<side-menu <side-menu
:routes="routes" :routes="routes"
@@ -35,19 +25,17 @@ const appStore = useAppStore()
const isCollapsed = computed(() => { const isCollapsed = computed(() => {
if (appStore.isMobile) { if (appStore.isMobile) {
return false return false
} } else {
return appStore.isCollapsed return appStore.isCollapsed
}
}) })
const settingStore = useSettingStore() const settingStore = useSettingStore()
const sideTheme = computed(() => settingStore.sideTheme) const sideTheme = computed(() => settingStore.sideTheme)
const userStore = useUserStore() const userStore = useUserStore()
const config = computed(() => appStore.config)
const routes = computed(() => userStore.routes) const routes = computed(() => userStore.routes)
const showBrandStrip = computed(() => !settingStore.showLogo && !isCollapsed.value)
const sideStyle = computed(() => { const sideStyle = computed(() => {
return sideTheme.value == 'dark' return sideTheme.value == 'dark'
? { ? {
@@ -55,7 +43,6 @@ const sideStyle = computed(() => {
} }
: '' : ''
}) })
const menuProp = computed(() => { const menuProp = computed(() => {
return { return {
backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : '', backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : '',
@@ -63,7 +50,6 @@ const menuProp = computed(() => {
activeTextColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : '' activeTextColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : ''
} }
}) })
const handleSelect = () => { const handleSelect = () => {
if (appStore.isMobile) { if (appStore.isMobile) {
appStore.toggleCollapsed(true) appStore.toggleCollapsed(true)
@@ -75,8 +61,7 @@ const handleSelect = () => {
.side { .side {
position: relative; position: relative;
z-index: 999; z-index: 999;
@apply h-full flex flex-col; @apply border-r border-br-light h-full flex flex-col;
border-right: 1px solid var(--admin-sidebar-border); background-color: var(--side-dark-color, var(--el-bg-color));
background-color: var(--side-dark-color, var(--sidebar-dark-bg));
} }
</style> </style>
-298
View File
@@ -1,298 +0,0 @@
/**
* Admin 页面架构 v2 - 大改版面板系统
*/
.admin-page .page-stage__body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ---------- FilterPanel ---------- */
.admin-filter-panel {
position: relative;
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-glass);
backdrop-filter: blur(12px);
box-shadow: var(--el-box-shadow-lighter);
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: 3px;
background: var(--admin-brand-gradient);
}
}
.admin-filter-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 22px 0;
}
.admin-filter-panel__label {
font-size: 14px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.admin-filter-panel__toggle {
padding: 0;
font-size: 13px;
font-weight: 600;
}
.admin-filter-panel__body {
padding: 14px 22px 12px;
}
.admin-filter-panel.is-collapsed .admin-filter-panel__body {
display: none;
}
.admin-filter-panel .el-form--inline {
margin-bottom: 0;
.el-form-item {
margin-bottom: 12px;
margin-right: 18px;
}
.el-form-item__label {
font-weight: 600;
color: var(--el-text-color-regular);
}
}
/* ---------- DataPanel ---------- */
.admin-data-panel {
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-elevated);
box-shadow: var(--el-box-shadow);
overflow: hidden;
}
.admin-data-panel__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 16px 22px;
border-bottom: 1px solid var(--el-border-color-extra-light);
background: var(--admin-brand-gradient-soft);
}
.admin-data-panel__body {
padding: 0 22px 18px;
}
.admin-data-panel__toolbar + .admin-data-panel__body {
padding-top: 16px;
}
.admin-data-panel__footer {
display: flex;
justify-content: flex-end;
padding: 0 22px 18px;
}
/* ---------- FormPanel ---------- */
.admin-form-panel {
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-elevated);
box-shadow: var(--el-box-shadow-light);
overflow: hidden;
}
.admin-form-panel__header {
padding: 16px 22px;
border-bottom: 1px solid var(--el-border-color-extra-light);
background: var(--admin-brand-gradient-soft);
}
.admin-form-panel__title {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.admin-form-panel__body {
padding: 22px;
}
.admin-form-panel__footer {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 22px 22px;
border-top: 1px solid var(--el-border-color-extra-light);
background: var(--el-fill-color-lighter);
}
/* ---------- StatGrid ---------- */
.admin-stat-grid {
display: grid;
gap: 14px;
}
.admin-stat-grid--cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.admin-stat-grid--cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.admin-stat-grid--cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.admin-stat-grid--cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.admin-stat-item {
position: relative;
padding: 18px 20px;
border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-elevated);
box-shadow: var(--el-box-shadow-lighter);
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: var(--el-border-color);
}
&:hover {
transform: translateY(-2px);
box-shadow: var(--el-box-shadow-light);
}
}
.admin-stat-item--primary {
&::before { background: var(--admin-brand-gradient); }
border-color: rgba(6, 182, 212, 0.2);
background: linear-gradient(145deg, rgba(6, 182, 212, 0.08), rgba(16, 185, 129, 0.05));
}
.admin-stat-item--success {
&::before { background: #10b981; }
border-color: rgba(16, 185, 129, 0.22);
}
.admin-stat-item--warning {
&::before { background: #f59e0b; }
border-color: rgba(245, 158, 11, 0.22);
}
.admin-stat-item__label {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.admin-stat-item__value {
margin-top: 10px;
font-size: 30px;
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--el-text-color-primary);
font-variant-numeric: tabular-nums;
}
.admin-stat-item__value.is-money {
font-size: 26px;
}
.admin-stat-item__hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
/* ---------- Tables ---------- */
.admin-data-panel .el-table,
.admin-page .el-card .el-table {
--el-table-border-color: transparent;
border-radius: var(--admin-radius-md);
thead th.el-table__cell {
position: sticky;
top: 0;
z-index: 2;
background: var(--table-header-bg-color) !important;
border-bottom: 1px solid var(--el-border-color-lighter);
font-weight: 700;
font-size: var(--el-font-size-small);
}
td.el-table__cell {
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.el-table__row:hover > td.el-table__cell {
background-color: rgba(6, 182, 212, 0.04) !important;
}
.el-table__row.current-row > td.el-table__cell {
background-color: rgba(6, 182, 212, 0.08) !important;
}
}
/* ---------- Legacy cards ---------- */
.admin-page .page-stage__body {
> div > .el-card:first-child:has(.el-form),
> .el-card:first-child:has(.el-form) {
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-lighter);
}
> div > .el-card + .el-card,
> .el-card + .el-card {
border-radius: var(--admin-radius-xl);
box-shadow: var(--el-box-shadow);
}
}
.workbench-page {
margin: 0;
}
@media (max-width: 1200px) {
.admin-stat-grid--cols-4,
.admin-stat-grid--cols-5 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.admin-filter-panel__body,
.admin-data-panel__body,
.admin-data-panel__toolbar,
.admin-data-panel__footer {
padding-left: 16px;
padding-right: 16px;
}
.admin-stat-grid--cols-2,
.admin-stat-grid--cols-3,
.admin-stat-grid--cols-4,
.admin-stat-grid--cols-5 {
grid-template-columns: 1fr;
}
.admin-stat-item:hover {
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-stat-item {
transition: none;
}
}
-190
View File
@@ -1,190 +0,0 @@
/* Admin Shell v2 - 互联网医院工作台大改版 */
.layout-default {
background: var(--sidebar-dark-bg);
}
/* ---------- Sidebar ---------- */
.app-aside .side {
position: relative;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: var(--sidebar-rail-width);
background: var(--admin-brand-gradient);
z-index: 2;
pointer-events: none;
}
&::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 0% 0%, var(--admin-brand-mesh-a), transparent 45%),
radial-gradient(circle at 100% 100%, var(--admin-brand-mesh-b), transparent 40%);
pointer-events: none;
}
}
.sidebar-brand {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
min-height: calc(var(--navbar-height) + 4px);
padding: 0 18px 0 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.sidebar-brand__mark {
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
color: #ffffff;
background: var(--admin-brand-gradient);
box-shadow: var(--admin-brand-glow);
flex-shrink: 0;
}
.sidebar-brand__name {
font-size: 15px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu.theme-dark .el-menu {
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: var(--sidebar-dark-hover);
--el-menu-active-color: #ffffff;
--el-menu-text-color: rgba(255, 255, 255, 0.62);
padding: 12px 10px 16px;
position: relative;
z-index: 1;
}
.menu.theme-dark .el-menu .el-menu-item,
.menu.theme-dark .el-menu .el-sub-menu__title {
margin: 3px 8px;
border-radius: var(--admin-radius-md);
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.12s ease;
}
.menu.theme-dark .el-menu .el-menu-item.is-active {
background: var(--sidebar-dark-active) !important;
color: #ffffff;
font-weight: 600;
box-shadow:
inset 0 0 0 1px rgba(6, 182, 212, 0.28),
0 8px 24px rgba(6, 182, 212, 0.15);
}
.menu.theme-dark .el-menu .el-menu-item:hover,
.menu.theme-dark .el-menu .el-sub-menu__title:hover {
background: var(--sidebar-dark-hover);
color: #ffffff;
}
.menu.theme-light .el-menu .el-menu-item.is-active {
font-weight: 600;
color: var(--admin-brand-primary-dark);
background: var(--admin-brand-gradient-soft);
box-shadow: inset 3px 0 0 var(--admin-brand-primary);
}
/* ---------- Header ---------- */
.app-header {
position: relative;
z-index: 20;
background: var(--admin-surface-glass);
backdrop-filter: blur(20px) saturate(160%);
border-bottom: 1px solid var(--admin-header-border);
&::after {
content: '';
position: absolute;
inset: auto 0 0;
height: 2px;
background: var(--admin-brand-gradient);
opacity: 0.75;
pointer-events: none;
}
}
:root.dark .app-header {
background: rgba(15, 23, 42, 0.88);
}
.navbar-tool {
width: 38px;
height: 38px;
border-radius: var(--admin-radius-md);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-regular);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease, transform 0.12s ease, box-shadow 0.2s ease;
&:hover {
background: var(--admin-brand-gradient-soft);
color: var(--admin-brand-primary-dark);
box-shadow: var(--el-box-shadow-lighter);
}
&:active {
transform: scale(0.96);
}
}
/* ---------- Main stage (去掉双层白盒) ---------- */
.main-wrap {
position: relative;
background:
radial-gradient(circle at 0% 0%, var(--admin-brand-mesh-a), transparent 34%),
radial-gradient(circle at 100% 0%, var(--admin-brand-mesh-c), transparent 30%),
radial-gradient(circle at 50% 100%, var(--admin-brand-mesh-b), transparent 38%),
var(--el-bg-color-page);
}
.main-stage {
max-width: var(--content-max-width);
margin: 0 auto;
padding: var(--stage-padding-y) var(--stage-padding-x);
min-height: calc(100vh - var(--navbar-height) - var(--tabs-height, 0px));
}
@media (max-width: 768px) {
:root {
--stage-padding-x: 14px;
--stage-padding-y: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.navbar-tool,
.menu.theme-dark .el-menu .el-menu-item,
.menu.theme-dark .el-menu .el-sub-menu__title {
transition: none;
}
}
@media (prefers-reduced-transparency: reduce) {
.app-header {
backdrop-filter: none;
background: var(--el-bg-color);
}
}
+31 -46
View File
@@ -1,58 +1,43 @@
:root.dark { :root.dark {
color-scheme: dark; color-scheme: dark;
--table-header-bg-color: var(--el-bg-color);
--table-header-bg-color: rgba(6, 182, 212, 0.12); --el-bg-color-page: #0a0a0a;
--sidebar-dark-bg: #030712; --el-bg-color: #1d2124;
--sidebar-dark-hover: rgba(255, 255, 255, 0.05); --el-bg-color-overlay: #1d1e1f;
--sidebar-dark-active: rgba(6, 182, 212, 0.2); --el-text-color-primary: #e5eaf3;
--el-text-color-regular: #cfd3dc;
--admin-surface-elevated: #111827; --el-text-color-secondary: #a3a6ad;
--admin-surface-muted: rgba(17, 24, 39, 0.88); --el-text-color-placeholder: #8d9095;
--admin-surface-glass: rgba(17, 24, 39, 0.86); --el-text-color-disabled: #6c6e72;
--el-border-color-darker: #636466;
--el-bg-color-page: #030712; --el-border-color-dark: #58585b;
--el-bg-color: #0f172a; --el-border-color: #4c4d4f;
--el-bg-color-overlay: #1e293b; --el-border-color-light: #414243;
--el-text-color-primary: #f1f5f9; --el-border-color-lighter: #363637;
--el-text-color-regular: #cbd5e1; --el-border-color-extra-light: #2b2b2c;
--el-text-color-secondary: #94a3b8; --el-fill-color-darker: #424243;
--el-text-color-placeholder: #64748b; --el-fill-color-dark: #39393a;
--el-text-color-disabled: #475569; --el-fill-color: #303030;
--el-border-color-darker: #64748b; --el-fill-color-light: #262727;
--el-border-color-dark: #475569; --el-fill-color-lighter: #1d1d1d;
--el-border-color: rgba(6, 182, 212, 0.18); --el-fill-color-extra-light: #191919;
--el-border-color-light: rgba(6, 182, 212, 0.12);
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
--el-border-color-extra-light: rgba(255, 255, 255, 0.04);
--el-fill-color-darker: #334155;
--el-fill-color-dark: #1e293b;
--el-fill-color: #1e293b;
--el-fill-color-light: #172033;
--el-fill-color-lighter: #131c2e;
--el-fill-color-extra-light: #0f172a;
--el-fill-color-blank: var(--el-bg-color); --el-fill-color-blank: var(--el-bg-color);
--el-mask-color: rgba(0, 0, 0, 0.8);
--el-mask-color: rgba(2, 6, 23, 0.78); --el-mask-color-extra-light: rgba(0, 0, 0, 0.3);
--el-mask-color-extra-light: rgba(2, 6, 23, 0.42); --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: 0 4px 24px rgba(0, 0, 0, 0.28); --el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.72);
--el-box-shadow-light: 0 2px 16px rgba(0, 0, 0, 0.22); --el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.72), 0px 12px 32px #000000,
--el-box-shadow-lighter: 0 1px 4px rgba(0, 0, 0, 0.16); 0px 8px 16px -8px #000000 !important;
--el-box-shadow-dark: 0 16px 48px rgba(0, 0, 0, 0.36); /* wangeditor主题 */
--admin-header-border: rgba(6, 182, 212, 0.14);
--admin-sidebar-border: rgba(255, 255, 255, 0.04);
--admin-brand-mesh-a: rgba(6, 182, 212, 0.12);
--admin-brand-mesh-b: rgba(16, 185, 129, 0.08);
--admin-brand-mesh-c: rgba(99, 102, 241, 0.06);
--w-e-textarea-bg-color: var(--el-bg-color); --w-e-textarea-bg-color: var(--el-bg-color);
--w-e-textarea-color: var(--el-text-color-primary); --w-e-textarea-color: var(--el-text-color-primary);
--w-e-textarea-border-color: var(--el-border-color); --w-e-textarea-border-color: var(--el-border-color);
--w-e-textarea-slight-border-color: var(--el-border-color-light); --w-e-textarea-slight-border-color: var(--el-border-color-light);
--w-e-textarea-slight-color: var(--el-border-color); --w-e-textarea-slight-color: var(--el-border-color);
--w-e-textarea-slight-bg-color: var(--el-bg-color-page); --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-color: var(--el-text-color-primary);
--w-e-toolbar-bg-color: var(--el-bg-color); --w-e-toolbar-bg-color: var(--el-bg-color);
--w-e-toolbar-active-color: var(--el-text-color-primary); --w-e-toolbar-active-color: var(--el-text-color-primary);
+15 -235
View File
@@ -1,20 +1,14 @@
:root { :root {
/* Messages & notifications */ // 确保消息提示在最上层
.el-message { .el-message {
z-index: 9999 !important; z-index: 9999 !important;
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
} }
.el-notification { .el-notification {
z-index: 9999 !important; z-index: 9999 !important;
border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
} }
/* Dialog */ // 弹窗居中
.el-overlay-dialog { .el-overlay-dialog {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -25,14 +19,11 @@
.el-dialog { .el-dialog {
--el-dialog-content-font-size: var(--el-font-size-base); --el-dialog-content-font-size: var(--el-font-size-base);
--el-dialog-margin-top: 50px; --el-dialog-margin-top: 50px;
--el-dialog-border-radius: var(--admin-radius-lg); max-width: calc(100vw - 30px);
max-width: calc(100vw - 32px);
flex: none; flex: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: var(--admin-radius-lg); border-radius: 5px;
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-dark);
&.body-padding .el-dialog__body { &.body-padding .el-dialog__body {
padding: 0; padding: 0;
@@ -40,158 +31,50 @@
.el-dialog__body { .el-dialog__body {
flex: 1; flex: 1;
padding: 16px 20px 20px; padding: 15px 20px;
} }
.el-dialog__header { .el-dialog__header {
font-size: var(--el-font-size-large); font-size: var(--el-font-size-large);
font-weight: 600;
padding: 16px 20px 12px;
margin-right: 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.el-dialog__footer {
padding: 12px 20px 16px;
border-top: 1px solid var(--el-border-color-extra-light);
} }
} }
} }
/* Cards - applies to all list pages */
.el-card {
--el-card-border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
box-shadow: none;
background: var(--admin-surface-elevated);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&.is-always-shadow,
&.is-hover-shadow:hover {
box-shadow: var(--el-box-shadow-light);
}
.el-card__header {
padding: 14px 18px;
border-bottom: 1px solid var(--el-border-color-extra-light);
font-weight: 600;
color: var(--el-text-color-primary);
}
.el-card__body {
padding: 18px;
}
}
/* Menu */
.el-menu { .el-menu {
user-select: none; user-select: none;
} }
/* Drawer */
.el-drawer { .el-drawer {
--el-drawer-padding-primary: 16px; --el-drawer-padding-primary: 16px;
&__header { &__header {
margin-bottom: 0; margin-bottom: 0;
padding: 14px 18px; padding: 13px 16px;
border-bottom: 1px solid var(--el-border-color-lighter); border-bottom: 1px solid var(--el-border-color-lighter);
font-weight: 600;
} }
&__title { &__title {
@apply text-tx-primary; @apply text-tx-primary;
} }
&__body {
padding: 16px 18px;
}
} }
/* Table */
.el-table { .el-table {
--el-table-header-text-color: var(--el-text-color-primary); --el-table-header-text-color: var(--el-text-color-primary);
--el-table-header-bg-color: var(--table-header-bg-color); --el-table-header-bg-color: var(--table-header-bg-color);
--el-table-border-color: var(--el-border-color-extra-light);
--el-table-row-hover-bg-color: var(--el-fill-color-lighter);
font-size: var(--el-font-size-base); font-size: var(--el-font-size-base);
border-radius: var(--admin-radius-md);
overflow: hidden;
thead { thead {
th { th {
font-weight: 600; font-weight: 400;
font-size: var(--el-font-size-small);
letter-spacing: 0.01em;
} }
th.el-table__cell {
background-color: var(--table-header-bg-color);
} }
} }
.el-table__cell {
padding: 12px 0;
}
}
/* Form controls */
.el-input-group__prepend { .el-input-group__prepend {
background-color: var(--el-fill-color-light); background-color: var(--el-fill-color-blank);
border-color: var(--el-border-color);
} }
.el-checkbox { .el-checkbox {
--el-checkbox-font-size: var(--el-font-size-base); --el-checkbox-font-size: var(--el-font-size-base);
} }
.el-button {
--el-border-radius-base: var(--admin-radius-md);
font-weight: 500;
transition: transform 0.12s ease, box-shadow 0.2s ease, background-color 0.2s ease;
&:active:not(.is-disabled) {
transform: scale(0.98);
}
}
.el-button--primary:not(.is-link):not(.is-text):not(.is-plain) {
background: var(--admin-brand-gradient);
border: none;
box-shadow: var(--admin-brand-glow);
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
--el-button-hover-bg-color: transparent;
--el-button-hover-border-color: transparent;
&:hover {
filter: brightness(1.05);
box-shadow: 0 12px 32px rgba(6, 182, 212, 0.32);
}
}
.el-button--primary.is-link,
.el-button--primary.is-text {
--el-button-hover-link-text-color: var(--admin-brand-primary-dark);
}
.el-button--primary.is-plain {
--el-button-bg-color: rgba(6, 182, 212, 0.1);
--el-button-border-color: rgba(6, 182, 212, 0.35);
--el-button-text-color: var(--admin-brand-primary-dark);
--el-button-hover-bg-color: rgba(6, 182, 212, 0.16);
--el-button-hover-border-color: var(--admin-brand-primary);
}
.el-button--large {
--el-border-radius-base: var(--admin-radius-md);
}
.el-button.is-round {
--el-border-radius-base: var(--admin-radius-round);
}
/* Popup menus */
.el-menu--popup-container { .el-menu--popup-container {
&.theme-light { &.theme-light {
.el-menu { .el-menu {
@@ -200,14 +83,12 @@
@apply bg-primary-light-9 border-primary border-r-2; @apply bg-primary-light-9 border-primary border-r-2;
} }
} }
.el-menu-item:hover, .el-menu-item:hover,
.el-sub-menu__title:hover { .el-sub-menu__title:hover {
color: var(--el-color-primary); color: var(--el-color-primary);
} }
} }
} }
&.theme-dark { &.theme-dark {
.el-menu { .el-menu {
.el-menu-item { .el-menu-item {
@@ -220,120 +101,52 @@
} }
.el-message-box { .el-message-box {
--el-messagebox-width: 380px; --el-messagebox-width: 350px;
--el-messagebox-border-radius: var(--admin-radius-lg);
} }
.el-date-editor { .el-date-editor {
--el-date-editor-datetimerange-width: 380px; --el-date-editor-datetimerange-width: 380px;
.el-range-input { .el-range-input {
font-size: var(--el-font-size-small); font-size: var(--el-font-size-small);
} }
} }
.el-button--primary {
--el-button-hover-link-text-color: var(--el-color-primary-light-3);
}
.el-button--success { .el-button--success {
--el-button-hover-link-text-color: var(--el-color-success-light-3); --el-button-hover-link-text-color: var(--el-color-success-light-3);
} }
.el-button--info { .el-button--info {
--el-button-hover-link-text-color: var(--el-color-info-light-3); --el-button-hover-link-text-color: var(--el-color-info-light-3);
} }
.el-button--warning { .el-button--warning {
--el-button-hover-link-text-color: var(--el-color-warning-light-3); --el-button-hover-link-text-color: var(--el-color-warning-light-3);
} }
.el-button--danger { .el-button--danger {
--el-button-hover-link-text-color: var(--el-color-danger-light-3); --el-button-hover-link-text-color: var(--el-color-danger-light-3);
} }
.el-image__error { .el-image__error {
font-size: 12px; font-size: 12px;
} }
.el-tabs__nav-wrap::after { .el-tabs__nav-wrap::after {
height: 1px; height: 1px;
background: var(--el-border-color-extra-light);
} }
.el-tabs__item {
font-weight: 500;
transition: color 0.2s ease;
&.is-active {
color: var(--el-color-primary);
font-weight: 600;
}
&:hover {
color: var(--el-color-primary-light-3);
}
}
.el-tabs__active-bar {
height: 3px;
border-radius: 999px;
background: var(--admin-brand-gradient);
}
.el-page-header { .el-page-header {
&__breadcrumb { &__breadcrumb {
margin-bottom: 0; margin-bottom: 0;
} }
} }
/* Tags */
.el-tag {
--el-tag-border-radius: var(--admin-radius-sm);
border: none;
font-weight: 500;
}
/* Pagination */
.el-pagination {
.el-pager li {
border-radius: var(--admin-radius-sm);
font-weight: 500;
}
.btn-prev,
.btn-next {
border-radius: var(--admin-radius-sm);
}
}
/* Alert */
.el-alert {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-extra-light);
}
/* Empty state */
.el-empty {
padding: 32px 0;
}
/* Descriptions */
.el-descriptions {
.el-descriptions__label {
font-weight: 500;
color: var(--el-text-color-secondary);
}
}
/* Focus rings */
.el-input, .el-input,
.el-select, .el-select,
.el-textarea { .el-textarea {
@apply shadow-primary-light-8; @apply shadow-primary-light-8;
box-shadow: 0 0 0 0 var(--tw-shadow-color); box-shadow: 0 0 0 0 var(--tw-shadow-color);
&:focus-within { &:focus-within {
box-shadow: 0 0 0 2px var(--tw-shadow-color); box-shadow: 0 0 0 2px var(--tw-shadow-color);
border-radius: var(--el-input-border-radius, var(--el-border-radius-base)); border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
transition: box-shadow ease 0.15s; transition: box-shadow ease 0.1s;
} }
} }
@@ -343,7 +156,6 @@
border-radius: var(--el-checkbox-border-radius); border-radius: var(--el-checkbox-border-radius);
box-shadow: 0 0 0 0 var(--tw-shadow-color); box-shadow: 0 0 0 0 var(--tw-shadow-color);
&:active { &:active {
box-shadow: 0 0 0 2px var(--tw-shadow-color); box-shadow: 0 0 0 2px var(--tw-shadow-color);
transition: box-shadow ease 0s; transition: box-shadow ease 0s;
@@ -361,61 +173,29 @@
.el-form-item.is-error .el-checkbox { .el-form-item.is-error .el-checkbox {
@apply shadow-danger-light-8; @apply shadow-danger-light-8;
} }
/* Dropdown */
.el-dropdown-menu {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
padding: 4px;
} }
.el-dropdown-menu__item {
border-radius: var(--admin-radius-sm);
}
/* Popover / tooltip polish */
.el-popover.el-popper {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.el-pagination > .el-pagination__jump { .el-pagination > .el-pagination__jump {
display: none !important; display: none !important;
} }
.el-pagination > .el-pagination__sizes { .el-pagination > .el-pagination__sizes {
display: none !important; display: none !important;
} }
} }
.el-button { .el-button {
// 防止被tailwindcss默认样式覆盖
background-color: var(--el-button-bg-color, var(--el-color-white)); background-color: var(--el-button-bg-color, var(--el-color-white));
//覆盖el-button的点击样式
&:focus { &:focus {
color: var(--el-button-text-color); color: var(--el-button-text-color);
border-color: var(--el-button-border-color); border-color: var(--el-button-border-color);
background-color: var(--el-button-bg-color); background-color: var(--el-button-bg-color);
} }
&:hover { &:hover {
color: var(--el-button-hover-text-color); color: var(--el-button-hover-text-color);
border-color: var(--el-button-hover-border-color); border-color: var(--el-button-hover-border-color);
background-color: var(--el-button-hover-bg-color); background-color: var(--el-button-hover-bg-color);
} }
} }
/* Inline form filter blocks on list pages */
.el-card .el-form--inline {
.el-form-item {
margin-bottom: 12px;
}
}
/* Page section spacing between stacked cards */
.el-card + .el-card {
margin-top: 16px;
}
+3 -5
View File
@@ -1,7 +1,5 @@
@import 'var.css';
@import 'dark.css';
@import 'tailwind.css';
@import 'element.scss'; @import 'element.scss';
@import 'admin-shell.scss'; @import 'dark.css';
@import 'admin-pages.scss'; @import 'var.css';
@import 'tailwind.css';
@import 'public.scss'; @import 'public.scss';
-48
View File
@@ -1,10 +1,6 @@
body { body {
@apply text-base text-tx-primary overflow-hidden min-w-[375px]; @apply text-base text-tx-primary overflow-hidden min-w-[375px];
font-feature-settings: 'kern' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
.form-tips { .form-tips {
@apply text-tx-secondary text-xs leading-6 mt-1; @apply text-tx-secondary text-xs leading-6 mt-1;
} }
@@ -16,51 +12,7 @@ body {
visibility: hidden; visibility: hidden;
} }
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color-dark);
background-clip: content-box;
}
/* NProgress */ /* NProgress */
#nprogress .bar { #nprogress .bar {
@apply bg-primary #{!important}; @apply bg-primary #{!important};
height: 2px !important;
}
#nprogress .peg {
box-shadow: 0 0 8px var(--el-color-primary), 0 0 4px var(--el-color-primary) !important;
}
/* Shared page utilities */
.admin-page-title {
@apply text-xl font-semibold text-tx-primary tracking-tight;
}
.admin-page-desc {
@apply text-sm text-tx-secondary mt-1;
}
.admin-stat-value {
@apply text-3xl font-semibold text-tx-primary tabular-nums;
}
.admin-stat-label {
@apply text-sm text-tx-secondary;
} }
+35 -85
View File
@@ -1,99 +1,49 @@
:root { :root {
/* Typography */
--el-font-family: theme(fontFamily.sans); --el-font-family: theme(fontFamily.sans);
--el-font-weight-primary: 400; --el-font-weight-primary: 400;
--el-menu-item-height: 46px;
--el-menu-sub-item-height: var(--el-menu-item-height);
--el-menu-icon-width: 18px;
--aside-width: 200px;
--navbar-height: 50px;
--color-white: #ffffff;
--table-header-bg-color: #f8f8f8;
--el-font-size-extra-large: 18px; --el-font-size-extra-large: 18px;
--el-menu-base-level-padding: 16px;
--el-menu-level-padding: 26px;
--el-font-size-large: 16px; --el-font-size-large: 16px;
--el-font-size-medium: 15px; --el-font-size-medium: 15px;
--el-font-size-base: 14px; --el-font-size-base: 14px;
--el-font-size-small: 13px; --el-font-size-small: 13px;
--el-font-size-extra-small: 12px; --el-font-size-extra-small: 12px;
/* Brand - MedTech 绚丽临床 */
--admin-brand-primary: #06b6d4;
--admin-brand-primary-dark: #0891b2;
--admin-brand-accent: #10b981;
--admin-brand-secondary: #6366f1;
--admin-brand-success: #10b981;
--admin-brand-gradient: linear-gradient(120deg, #06b6d4 0%, #10b981 48%, #0891b2 100%);
--admin-brand-gradient-soft: linear-gradient(
120deg,
rgba(6, 182, 212, 0.16) 0%,
rgba(16, 185, 129, 0.1) 50%,
rgba(8, 145, 178, 0.08) 100%
);
--admin-brand-glow: 0 0 24px rgba(6, 182, 212, 0.35);
--admin-brand-mesh-a: rgba(6, 182, 212, 0.18);
--admin-brand-mesh-b: rgba(16, 185, 129, 0.14);
--admin-brand-mesh-c: rgba(99, 102, 241, 0.1);
/* Layout shell v2 */
--aside-width: 248px;
--navbar-height: 60px;
--tabs-height: 42px;
--stage-padding-x: 28px;
--stage-padding-y: 24px;
--content-max-width: 1680px;
--sidebar-rail-width: 3px;
/* Shape */
--admin-radius-sm: 8px;
--admin-radius-md: 12px;
--admin-radius-lg: 16px;
--admin-radius-xl: 22px;
--admin-radius-2xl: 28px;
--el-border-radius-base: var(--admin-radius-md);
--el-border-radius-small: var(--admin-radius-sm);
--el-border-radius-round: 999px;
/* Menu */
--el-menu-item-height: 46px;
--el-menu-sub-item-height: var(--el-menu-item-height);
--el-menu-icon-width: 20px;
--el-menu-base-level-padding: 14px;
--el-menu-level-padding: 22px;
/* Surfaces */
--color-white: #ffffff;
--table-header-bg-color: rgba(6, 182, 212, 0.06);
--sidebar-dark-bg: #060d18;
--sidebar-dark-hover: rgba(255, 255, 255, 0.05);
--sidebar-dark-active: rgba(6, 182, 212, 0.14);
--admin-surface-elevated: #ffffff;
--admin-surface-muted: rgba(255, 255, 255, 0.72);
--admin-surface-glass: rgba(255, 255, 255, 0.82);
--el-bg-color: var(--color-white); --el-bg-color: var(--color-white);
--el-bg-color-page: #eef6fb; --el-bg-color-page: #f6f6f6;
--el-bg-color-overlay: #ffffff; --el-bg-color-overlay: #ffffff;
--el-text-color-primary: #0b1220; --el-text-color-primary: #333333;
--el-text-color-regular: #334155; --el-text-color-regular: #666666;
--el-text-color-secondary: #64748b; --el-text-color-secondary: #999999;
--el-text-color-placeholder: #94a3b8; --el-text-color-placeholder: #a8abb2;
--el-text-color-disabled: #cbd5e1; --el-text-color-disabled: #c0c4cc;
--el-border-color: rgba(6, 182, 212, 0.12); --el-border-color: #dcdfe6;
--el-border-color-light: rgba(6, 182, 212, 0.08); --el-border-color-light: #e4e7ed;
--el-border-color-lighter: rgba(15, 23, 42, 0.06); --el-border-color-lighter: #ebeef5;
--el-border-color-extra-light: rgba(15, 23, 42, 0.04); --el-border-color-extra-light: #f2f2f2;
--el-border-color-dark: #cbd5e1; --el-border-color-dark: #d4d7de;
--el-border-color-darker: #94a3b8; --el-border-color-darker: #cdd0d6;
--el-fill-color: #f1f5f9; --el-fill-color: #f0f2f5;
--el-fill-color-light: #f8fafc; --el-fill-color-light: #f8f8f8;
--el-fill-color-lighter: #fafbfc; --el-fill-color-lighter: #fafafa;
--el-fill-color-extra-light: #fcfdfe; --el-fill-color-extra-light: #fafcff;
--el-fill-color-dark: #e2e8f0; --el-fill-color-dark: #ebedf0;
--el-fill-color-darker: #cbd5e1; --el-fill-color-darker: #e6e8eb;
--el-fill-color-blank: #ffffff; --el-fill-color-blank: #ffffff;
/* 过亮会盖住抽屉/弹窗下的内容;Element Loading 与部分蒙层共用此变量 */
--el-mask-color: rgba(6, 13, 24, 0.55); --el-mask-color: rgba(255, 255, 255, 0.5);
--el-mask-color-extra-light: rgba(6, 13, 24, 0.1); --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: 0 4px 24px rgba(6, 182, 212, 0.08), 0 12px 40px rgba(15, 23, 42, 0.06); --el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
--el-box-shadow-light: 0 2px 16px rgba(6, 182, 212, 0.1); --el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
--el-box-shadow-lighter: 0 1px 4px rgba(15, 23, 42, 0.04); --el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12),
--el-box-shadow-dark: 0 16px 48px rgba(6, 182, 212, 0.14), 0 32px 64px rgba(15, 23, 42, 0.1); 0px 8px 16px -8px rgba(0, 0, 0, 0.16);
--admin-header-border: rgba(6, 182, 212, 0.1);
--admin-sidebar-border: rgba(255, 255, 255, 0.06);
--admin-stage-bg: transparent;
} }
+6 -25
View File
@@ -1,10 +1,9 @@
<template> <template>
<div class="change-password flex flex-col"> <div class="change-password flex flex-col">
<div class="change-password-backdrop" aria-hidden="true"></div> <div class="flex-1 flex items-center justify-center">
<div class="flex-1 flex items-center justify-center relative z-[1] px-4"> <div class="change-password-card bg-body rounded-md px-10 py-10 w-[480px]">
<div class="change-password-card"> <div class="text-center text-2xl font-medium mb-2">首次登录</div>
<div class="text-center text-2xl font-semibold mb-2 text-tx-primary">首次登录</div> <div class="text-center text-gray-500 text-sm mb-8">为了您的账号安全请修改初始密码</div>
<div class="text-center text-tx-secondary text-sm mb-8">为了您的账号安全请修改初始密码</div>
<el-form ref="formRef" :model="formData" size="large" :rules="rules"> <el-form ref="formRef" :model="formData" size="large" :rules="rules">
<el-form-item prop="password"> <el-form-item prop="password">
@@ -118,25 +117,7 @@ const { isLock, lockFn: lockSubmit } = useLockFn(handleSubmit)
<style lang="scss" scoped> <style lang="scss" scoped>
.change-password { .change-password {
position: relative; background-image: url('./images/login_bg.png');
min-height: 100vh; @apply min-h-screen bg-no-repeat bg-center bg-cover;
background: #0f172a;
}
.change-password-backdrop {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 15% 20%, rgba(15, 118, 110, 0.35), transparent 42%),
linear-gradient(160deg, #0f172a 0%, #111827 48%, #0b1120 100%);
}
.change-password-card {
width: min(480px, 100%);
padding: 40px 36px;
border-radius: var(--admin-radius-xl);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 64px rgba(2, 6, 23, 0.45);
background: var(--el-bg-color);
} }
</style> </style>
+32 -192
View File
@@ -1,41 +1,33 @@
<template> <template>
<div class="login-v2"> <div class="login flex flex-col">
<div class="login-v2__mesh" aria-hidden="true"></div> <div class="flex-1 flex items-center justify-center">
<div class="login-v2__layout"> <div class="login-card flex rounded-md overflow-hidden">
<aside class="login-v2__brand"> <div class="flex-1 h-full hidden md:inline-block">
<div class="login-v2__brand-inner"> <image-contain :src="config.login_image" :width="400" height="100%" />
<div class="login-v2__mark">ZYT</div>
<p class="login-v2__kicker">互联网医院</p>
<h1 class="login-v2__headline">医生与医助<br />智慧工作台</h1>
<p class="login-v2__lead">问诊处方订单与运营数据一屏协同</p>
<div v-if="config.login_image" class="login-v2__visual hidden xl:block">
<image-contain :src="config.login_image" :width="420" height="280" />
</div> </div>
</div> <div
</aside> 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>
<section class="login-v2__panel"> <!-- 企业微信自动授权中 -->
<div class="login-v2__glass"> <div v-if="wxWorkAutoLogin" class="text-center py-10">
<div class="login-v2__form-head">
<h2 class="login-v2__form-title">{{ config.web_name }}</h2>
<p class="login-v2__form-sub">登录后继续你的接诊与管理工作</p>
</div>
<div v-if="wxWorkAutoLogin" class="login-v2__loading">
<el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)"> <el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
<Loading /> <Loading />
</el-icon> </el-icon>
<div class="text-tx-secondary">企业微信授权登录中...</div> <div class="text-gray-500">企业微信授权登录中...</div>
</div> </div>
<template v-else> <template v-else>
<div v-if="wxWorkEnabled" class="login-v2__mode"> <!-- 登录方式切换标签 -->
<div v-if="wxWorkEnabled" class="flex justify-center mb-6">
<el-radio-group v-model="loginMode" size="large"> <el-radio-group v-model="loginMode" size="large">
<el-radio-button value="account">账号登录</el-radio-button> <el-radio-button value="account">账号登录</el-radio-button>
<el-radio-button value="wxwork">企业微信</el-radio-button> <el-radio-button value="wxwork">企业微信</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
<!-- 账号密码登录 -->
<template v-if="loginMode === 'account'"> <template v-if="loginMode === 'account'">
<el-form ref="formRef" :model="formData" size="large" :rules="rules"> <el-form ref="formRef" :model="formData" size="large" :rules="rules">
<el-form-item prop="account"> <el-form-item prop="account">
@@ -66,34 +58,29 @@
<div class="mb-5"> <div class="mb-5">
<el-checkbox v-model="remAccount" label="记住账号"></el-checkbox> <el-checkbox v-model="remAccount" label="记住账号"></el-checkbox>
</div> </div>
<el-button <el-button type="primary" size="large" :loading="isLock" @click="lockLogin">
class="login-v2__submit" 登录
type="primary"
size="large"
:loading="isLock"
@click="lockLogin"
>
进入工作台
</el-button> </el-button>
</template> </template>
<!-- 企业微信扫码登录非企业微信内浏览器 -->
<template v-if="loginMode === 'wxwork'"> <template v-if="loginMode === 'wxwork'">
<div class="wxwork-qrcode-wrap"> <div class="wxwork-qrcode-wrap">
<div v-if="wxWorkLoading" class="text-center py-10"> <div v-if="wxWorkLoading" class="text-center py-10">
<el-icon class="is-loading" :size="32" color="var(--el-color-primary)"> <el-icon class="is-loading" :size="32" color="var(--el-color-primary)">
<Loading /> <Loading />
</el-icon> </el-icon>
<div class="mt-2 text-tx-secondary text-sm">加载企业微信扫码...</div> <div class="mt-2 text-gray-400 text-sm">加载企业微信扫码...</div>
</div> </div>
<div v-else id="wxwork_qrcode_container" class="wxwork-qrcode"></div> <div v-else id="wxwork_qrcode_container" class="wxwork-qrcode"></div>
</div> </div>
<div class="text-center text-sm text-tx-secondary mt-4"> <div class="text-center text-sm text-gray-400 mt-4">
请使用企业微信扫描二维码登录 请使用企业微信扫描二维码登录
</div> </div>
</template> </template>
</template> </template>
</div> </div>
</section> </div>
</div> </div>
<layout-footer /> <layout-footer />
</div> </div>
@@ -299,178 +286,31 @@ onMounted(async () => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.login-v2 { .login {
position: relative; background-image: url('./images/login_bg.png');
min-height: 100vh; @apply min-h-screen bg-no-repeat bg-center bg-cover;
display: flex; .login-card {
flex-direction: column; height: auto;
background: #030712; min-height: 400px;
overflow: hidden;
} }
.login-v2__mesh {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 12% 18%, rgba(6, 182, 212, 0.35), transparent 42%),
radial-gradient(circle at 88% 12%, rgba(16, 185, 129, 0.28), transparent 38%),
radial-gradient(circle at 70% 88%, rgba(99, 102, 241, 0.18), transparent 40%),
linear-gradient(155deg, #030712 0%, #0b1220 45%, #060d18 100%);
}
.login-v2__layout {
position: relative;
z-index: 1;
flex: 1;
display: grid;
grid-template-columns: 1fr;
min-height: calc(100vh - 48px);
}
@media (min-width: 1024px) {
.login-v2__layout {
grid-template-columns: minmax(0, 1.05fr) minmax(420px, 520px);
}
}
.login-v2__brand {
display: none;
padding: 48px 56px;
color: #ffffff;
}
@media (min-width: 1024px) {
.login-v2__brand {
display: flex;
align-items: center;
}
}
.login-v2__brand-inner {
max-width: 520px;
}
.login-v2__mark {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
background: var(--admin-brand-gradient);
box-shadow: var(--admin-brand-glow);
margin-bottom: 28px;
}
.login-v2__kicker {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
}
.login-v2__headline {
margin: 0;
font-size: clamp(32px, 4vw, 44px);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.03em;
}
.login-v2__lead {
margin: 16px 0 0;
font-size: 16px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.72);
max-width: 36ch;
}
.login-v2__visual {
margin-top: 36px;
border-radius: var(--admin-radius-xl);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
}
.login-v2__panel {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 20px;
}
.login-v2__glass {
width: min(440px, 100%);
padding: 36px 32px;
border-radius: var(--admin-radius-2xl);
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24px) saturate(160%);
box-shadow: var(--el-box-shadow-dark);
}
.login-v2__form-head {
margin-bottom: 28px;
}
.login-v2__form-title {
margin: 0;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--el-text-color-primary);
}
.login-v2__form-sub {
margin: 8px 0 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
.login-v2__mode {
display: flex;
justify-content: center;
margin-bottom: 24px;
}
.login-v2__submit {
width: 100%;
}
.login-v2__loading {
text-align: center;
padding: 40px 0;
} }
.wxwork-qrcode-wrap { .wxwork-qrcode-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 360px; min-height: 400px;
} }
.wxwork-qrcode { .wxwork-qrcode {
width: 320px; width: 340px;
height: 360px; height: 400px;
overflow: hidden; overflow: hidden;
:deep(iframe) { :deep(iframe) {
width: 320px !important; width: 340px !important;
height: 360px !important; height: 400px !important;
border: none; border: none;
} }
} }
@media (prefers-reduced-transparency: reduce) {
.login-v2__glass {
background: #ffffff;
backdrop-filter: none;
}
}
</style> </style>
+9 -9
View File
@@ -1,17 +1,17 @@
<template> <template>
<div> <div>
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-alert <el-alert
type="warning" type="warning"
title="温馨提示:用于管理网站的分类,只可添加到一级" title="温馨提示:用于管理网站的分类,只可添加到一级"
:closable="false" :closable="false"
show-icon show-icon
/> />
</admin-page-filter-panel> </el-card>
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
<admin-page-data-panel v-loading="pager.loading"> <div>
<template #toolbar>
<el-button <el-button
class="mb-4"
v-perms="['article.articleCate/add']" v-perms="['article.articleCate/add']"
type="primary" type="primary"
@click="handleAdd()" @click="handleAdd()"
@@ -21,7 +21,7 @@
</template> </template>
新增 新增
</el-button> </el-button>
</template> </div>
<el-table size="large" :data="pager.lists"> <el-table size="large" :data="pager.lists">
<el-table-column label="栏目名称" prop="name" min-width="120" /> <el-table-column label="栏目名称" prop="name" min-width="120" />
<el-table-column label="文章数" prop="article_count" min-width="120" /> <el-table-column label="文章数" prop="article_count" min-width="120" />
@@ -58,10 +58,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
+11 -12
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div class="article-lists">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true"> <el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="文章标题"> <el-form-item class="w-[280px]" label="文章标题">
<el-input <el-input
@@ -33,25 +33,24 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<el-card class="!border-none mt-4" shadow="never">
<admin-page-data-panel v-loading="pager.loading"> <div>
<template #toolbar>
<router-link <router-link
v-perms="['article.article/add', 'article.article/add:edit']" v-perms="['article.article/add', 'article.article/add:edit']"
:to="{ :to="{
path: getRoutePath('article.article/add:edit') path: getRoutePath('article.article/add:edit')
}" }"
> >
<el-button type="primary"> <el-button type="primary" class="mb-4">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
发布文章 发布文章
</el-button> </el-button>
</router-link> </router-link>
</template> </div>
<el-table size="large" :data="pager.lists"> <el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="ID" prop="id" min-width="80" /> <el-table-column label="ID" prop="id" min-width="80" />
<el-table-column label="封面" min-width="100"> <el-table-column label="封面" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
@@ -117,10 +116,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
</div> </div>
</template> </template>
<script lang="ts" setup name="articleLists"> <script lang="ts" setup name="articleLists">
+9 -9
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="asset-resource-container"> <div class="asset-resource-container">
<!-- 搜索区域 --> <!-- 搜索区域 -->
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="searchData" inline> <el-form class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="标题"> <el-form-item class="w-[280px]" label="标题">
<el-input <el-input
@@ -28,17 +28,17 @@
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<!-- 列表区域 --> <!-- 列表区域 -->
<admin-page-data-panel v-loading="loading"> <el-card class="!border-none mt-4" shadow="never">
<template #toolbar> <div class="mb-4 flex items-center gap-2">
<el-button type="primary" @click="handleAdd">上传资源</el-button> <el-button type="primary" @click="handleAdd">上传资源</el-button>
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete"> <el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
批量删除 批量删除
</el-button> </el-button>
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} </span> <span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} </span>
</template> </div>
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange"> <el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
<el-tab-pane label="图片" name="1"></el-tab-pane> <el-tab-pane label="图片" name="1"></el-tab-pane>
@@ -46,7 +46,7 @@
<el-tab-pane label="语音" name="3"></el-tab-pane> <el-tab-pane label="语音" name="3"></el-tab-pane>
</el-tabs> </el-tabs>
<el-table :data="tableData" @selection-change="handleSelectionChange"> <el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<!-- <el-table-column prop="id" label="ID" width="80" /> --> <!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="title" label="标题" min-width="80" /> <el-table-column prop="title" label="标题" min-width="80" />
@@ -71,7 +71,7 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="mt-4 flex justify-end">
<el-pagination <el-pagination
v-model:current-page="queryParams.page_no" v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size" v-model:page-size="queryParams.page_size"
@@ -80,8 +80,8 @@
@size-change="getList" @size-change="getList"
@current-change="getList" @current-change="getList"
/> />
</template> </div>
</admin-page-data-panel> </el-card>
<!-- 新增/编辑资源弹窗 --> <!-- 新增/编辑资源弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
+9 -9
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="asset-user-container"> <div class="asset-user-container">
<!-- 搜索区域 --> <!-- 搜索区域 -->
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="searchData" inline> <el-form class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="手机号"> <el-form-item class="w-[280px]" label="手机号">
<el-input <el-input
@@ -16,15 +16,15 @@
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<!-- 列表区域 --> <!-- 列表区域 -->
<admin-page-data-panel v-loading="loading"> <el-card class="!border-none mt-4" shadow="never">
<template #toolbar> <div class="mb-4">
<el-button type="primary" @click="handleAdd">新增账号</el-button> <el-button type="primary" @click="handleAdd">新增账号</el-button>
</template> </div>
<el-table :data="tableData"> <el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> --> <!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="phone" label="手机号" /> <el-table-column prop="phone" label="手机号" />
<el-table-column label="备注" min-width="200"> <el-table-column label="备注" min-width="200">
@@ -63,7 +63,7 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="mt-4 flex justify-end">
<el-pagination <el-pagination
v-model:current-page="queryParams.page_no" v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size" v-model:page-size="queryParams.page_size"
@@ -72,8 +72,8 @@
@size-change="getList" @size-change="getList"
@current-change="getList" @current-change="getList"
/> />
</template> </div>
</admin-page-data-panel> </el-card>
<!-- 编辑/新增弹窗 --> <!-- 编辑/新增弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
+6 -7
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true"> <el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="用户信息"> <el-form-item class="w-[280px]" label="用户信息">
<el-input <el-input
@@ -37,9 +37,8 @@
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<el-card class="!border-none mt-4" shadow="never">
<admin-page-data-panel>
<el-table size="large" v-loading="pager.loading" :data="pager.lists"> <el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="头像" min-width="100"> <el-table-column label="头像" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
@@ -68,10 +67,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
</div> </div>
</template> </template>
<script lang="ts" setup name="consumerLists"> <script lang="ts" setup name="consumerLists">
@@ -327,7 +327,27 @@
</div> </div>
</div> </div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="服用方式"> <el-descriptions-item>
<template #label>
<div class="flex items-center gap-1">
<span>服用方式</span>
<el-button
v-if="
!readonly &&
detailData.prescription_id &&
detailPrescription &&
!String(detailData.prescription_detail_error || '').trim()
"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
@click="openPatchUsageDialog"
>
修改
</el-button>
</div>
</template>
<div class="flex flex-col gap-1.5 text-sm leading-relaxed"> <div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<template v-if="detailHasAuxHerbs"> <template v-if="detailHasAuxHerbs">
<div> <div>
@@ -631,7 +651,7 @@
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item> <el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item> <el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item> <el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item> <el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item v-if="showInternalCost" label="内部成本"> <el-descriptions-item v-if="showInternalCost" label="内部成本">
@@ -780,7 +800,18 @@
class="po-panel border-gray-100 mt-4" class="po-panel border-gray-100 mt-4"
> >
<template #header> <template #header>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-[15px]">操作日志</span> <span class="font-medium text-[15px]">操作日志</span>
<el-button
v-if="canAddPrescriptionOrderLog()"
type="primary"
link
size="small"
@click="openAddLogDialog"
>
新增日志
</el-button>
</div>
</template> </template>
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2"> <el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
<el-timeline-item <el-timeline-item
@@ -800,19 +831,182 @@
</el-timeline> </el-timeline>
<el-empty v-else description="暂无操作日志" :image-size="64" /> <el-empty v-else description="暂无操作日志" :image-size="64" />
</el-card> </el-card>
<!-- 新增操作日志 -->
<el-dialog
v-model="addLogVisible"
title="新增操作日志"
width="520px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
@closed="resetAddLogForm"
>
<el-form ref="addLogFormRef" :model="addLogForm" :rules="addLogRules" label-width="108px">
<el-form-item label="日志内容" prop="summary">
<el-input
v-model="addLogForm.summary"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="记录本次操作说明、沟通结果等"
/>
</el-form-item>
<el-form-item v-if="canSetRxAuditOnAddLog" label="处方审核">
<el-select v-model="addLogForm.prescription_audit_status" class="w-full" clearable placeholder="不修改">
<el-option label="待审核" :value="0" />
<el-option label="已通过" :value="1" />
<el-option label="已驳回" :value="2" />
</el-select>
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
当前{{ auditStatusText(detailData.prescription_audit_status) }}
</div>
</el-form-item>
<el-form-item
v-if="canSetRxAuditOnAddLog && addLogForm.prescription_audit_status !== '' && addLogForm.prescription_audit_status !== null && addLogForm.prescription_audit_status !== undefined"
label="处方审核意见"
>
<el-input
v-model="addLogForm.prescription_audit_remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填"
/>
</el-form-item>
<el-form-item v-if="canSetPayAuditOnAddLog" label="支付单审核">
<el-select v-model="addLogForm.payment_slip_audit_status" class="w-full" clearable placeholder="不修改">
<el-option label="待审核" :value="0" />
<el-option label="已通过" :value="1" />
<el-option label="已驳回" :value="2" />
</el-select>
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
当前{{ auditStatusText(detailData.payment_slip_audit_status) }}
</div>
</el-form-item>
<el-form-item
v-if="canSetPayAuditOnAddLog && addLogForm.payment_slip_audit_status !== '' && addLogForm.payment_slip_audit_status !== null && addLogForm.payment_slip_audit_status !== undefined"
label="支付审核意见"
>
<el-input
v-model="addLogForm.payment_slip_audit_remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addLogVisible = false">取消</el-button>
<el-button type="primary" :loading="addLogSaving" @click="submitAddLog">保存</el-button>
</template>
</el-dialog>
<!-- 修改服用参数主方 / 辅方 / 订单设置 -->
<el-dialog
v-model="patchUsageVisible"
title="修改服用参数"
width="480px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetPatchUsageForm"
>
<el-form
ref="patchUsageFormRef"
:model="patchUsageForm"
:rules="patchUsageRules"
label-width="108px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="aux_usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.aux_usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</template>
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
<el-form-item label="服用天数" prop="medication_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.medication_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="patchUsageVisible = false">取消</el-button>
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
</template>
</el-dialog>
</div> </div>
</el-drawer> </el-drawer>
</template> </template>
<script lang="ts" setup name="PrescriptionOrderDetailDrawer"> <script lang="ts" setup name="PrescriptionOrderDetailDrawer">
import { computed, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Refresh, Loading, Search, Van } from '@element-plus/icons-vue' import { Refresh, Loading, Search, Van } from '@element-plus/icons-vue'
import { import {
prescriptionOrderDetail, prescriptionOrderDetail,
prescriptionOrderLogs, prescriptionOrderLogs,
prescriptionOrderAddLog,
prescriptionOrderLogisticsTrace, prescriptionOrderLogisticsTrace,
prescriptionOrderLogisticsJdUpdate, prescriptionOrderLogisticsJdUpdate,
prescriptionOrderPaidPayOrders prescriptionOrderPaidPayOrders,
prescriptionOrderPatchPrescriptionUsage
} from '@/api/tcm' } from '@/api/tcm'
import { getDictData } from '@/api/app' import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback' import feedback from '@/utils/feedback'
@@ -831,6 +1025,7 @@ import {
consumerRxAuditTag, consumerRxAuditTag,
expressCompanyLabel, expressCompanyLabel,
logActionText, logActionText,
auditStatusText,
formatPayOrderSource, formatPayOrderSource,
normalizeBizPhone, normalizeBizPhone,
recipientVsPrescriptionPhoneMismatch, recipientVsPrescriptionPhoneMismatch,
@@ -841,7 +1036,10 @@ import {
analyzeLogisticsPayloadUrgent, analyzeLogisticsPayloadUrgent,
parseLogisticsTracePayload, parseLogisticsTracePayload,
canUpdateAmount, canUpdateAmount,
formatDietaryTaboo formatDietaryTaboo,
type ServicePackageOption,
normalizeServicePackageOptions,
formatServicePackageLabels
} from './prescription-order-utils' } from './prescription-order-utils'
const props = withDefaults( const props = withDefaults(
@@ -873,6 +1071,7 @@ const emit = defineEmits<{
(e: 'view-prescription'): void (e: 'view-prescription'): void
(e: 'test-gancao-preview'): void (e: 'test-gancao-preview'): void
(e: 'view-patient'): void (e: 'view-patient'): void
(e: 'detail-changed'): void
}>() }>()
const userStore = useUserStore() const userStore = useUserStore()
@@ -1088,36 +1287,24 @@ const detailFullAddress = computed(() => {
}) })
// //
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([]) const servicePackageOptions = ref<ServicePackageOption[]>([])
async function loadServicePackageOptions() { async function loadServicePackageOptions() {
if (servicePackageOptions.value.length > 0) return
try { try {
const data: any = await getDictData({ type: 'server_order' }) const data: any = await getDictData({ type: 'server_order' })
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0) const opts = normalizeServicePackageOptions(data?.server_order)
if (opts.length > 0) {
servicePackageOptions.value = opts
}
} catch { } catch {
servicePackageOptions.value = [] /* 请求被同参数请求取消或失败时保留现值,open() 时会重试 */
} }
} }
function formatServicePackage(value: any): string { const detailServicePackageText = computed(() =>
if (!value) return '—' formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
)
let packages: string[] = []
if (Array.isArray(value)) {
packages = value
} else if (typeof value === 'string') {
packages = value.split(',').filter((v) => v.trim() !== '')
}
if (packages.length === 0) return '—'
const names = packages.map((val) => {
const option = servicePackageOptions.value.find((opt) => opt.value === val)
return option ? option.name : val
})
return names.join('、')
}
onMounted(() => { onMounted(() => {
loadServicePackageOptions() loadServicePackageOptions()
@@ -1142,6 +1329,196 @@ async function fetchLogs(id: number) {
} }
} }
function hasPerm(perm: string) {
const p = userStore.perms || []
return p.includes('*') || p.includes(perm)
}
function canAddPrescriptionOrderLog() {
return hasPerm('tcm.prescriptionOrder/addLog')
}
/** 与后端 canAuditPrescriptionOrder 同档:超管或 prescription_audit_roles */
const canSetRxAuditOnAddLog = computed(() => {
const u = userStore.userInfo
if (!u || Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
})
/** 与后端 canAuditPaymentSlipOrder 同档:超管或 prescription_order_payment_audit_roles 默认 0,3 */
const canSetPayAuditOnAddLog = computed(() => {
const u = userStore.userInfo
if (!u || Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
return ids.some((id) => [0, 3].includes(id))
})
const addLogVisible = ref(false)
const addLogSaving = ref(false)
const addLogFormRef = ref<FormInstance>()
const addLogForm = reactive({
summary: '',
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '',
prescription_audit_remark: '',
payment_slip_audit_remark: ''
})
const addLogRules: FormRules = {
summary: [{ required: true, message: '请填写日志内容', trigger: 'blur' }]
}
const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
const rules: FormRules = {
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
}
return rules
})
function openPatchUsageDialog() {
const rx = detailPrescription.value
const ord = detailData.value
if (!rx || !ord?.id || !ord.prescription_id) {
feedback.msgWarning('无处方数据')
return
}
const aux = detailAuxUsage.value
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageForm.aux_times_per_day =
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.medication_days = undefined
}
async function submitPatchUsage() {
const form = patchUsageFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
const ordId = detailData.value?.id
if (!ordId) return
patchUsageSaving.value = true
try {
const payload: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
patchUsageVisible.value = false
await refresh()
emit('detail-changed')
} catch {
/* 拦截器已提示 */
} finally {
patchUsageSaving.value = false
}
}
function resetAddLogForm() {
addLogForm.summary = ''
addLogForm.prescription_audit_status = ''
addLogForm.payment_slip_audit_status = ''
addLogForm.prescription_audit_remark = ''
addLogForm.payment_slip_audit_remark = ''
addLogFormRef.value?.clearValidate()
}
function openAddLogDialog() {
if (!detailData.value?.id) return
resetAddLogForm()
const d = detailData.value
addLogForm.prescription_audit_remark = String(d.prescription_audit_remark || '')
addLogForm.payment_slip_audit_remark = String(d.payment_slip_audit_remark || '')
addLogVisible.value = true
}
async function submitAddLog() {
if (!addLogFormRef.value || !detailData.value?.id) return
await addLogFormRef.value.validate()
addLogSaving.value = true
try {
const payload: Record<string, unknown> = {
id: detailData.value.id,
summary: addLogForm.summary.trim()
}
if (
canSetRxAuditOnAddLog.value &&
addLogForm.prescription_audit_status !== '' &&
addLogForm.prescription_audit_status !== null &&
addLogForm.prescription_audit_status !== undefined
) {
payload.prescription_audit_status = addLogForm.prescription_audit_status
payload.prescription_audit_remark = addLogForm.prescription_audit_remark
}
if (
canSetPayAuditOnAddLog.value &&
addLogForm.payment_slip_audit_status !== '' &&
addLogForm.payment_slip_audit_status !== null &&
addLogForm.payment_slip_audit_status !== undefined
) {
payload.payment_slip_audit_status = addLogForm.payment_slip_audit_status
payload.payment_slip_audit_remark = addLogForm.payment_slip_audit_remark
}
await prescriptionOrderAddLog(payload as any)
feedback.msgSuccess('日志已添加')
addLogVisible.value = false
await refresh()
emit('detail-changed')
} catch {
/* 拦截器已提示 */
} finally {
addLogSaving.value = false
}
}
// //
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) { async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
if (!diagnosisId) { if (!diagnosisId) {
@@ -1266,6 +1643,8 @@ async function updateJdLogistics() {
// / // /
async function open(id: number) { async function open(id: number) {
// axios
void loadServicePackageOptions()
// //
detailData.value = null detailData.value = null
detailUnlinkedPayOrders.value = [] detailUnlinkedPayOrders.value = []
@@ -135,13 +135,25 @@ export function logActionText(act: string) {
revoke_pay_audit: '撤回支付审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息', patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额', update_amount: '修改订单金额',
complete: '完成订单', complete: '完成订单',
refund: '退款' refund: '退款',
manual_log: '手工备注',
assign_assistant: '改派医助',
add_pay_order: '补齐支付单',
set_ship_mode: '发货类型'
} }
return m[act] || act return m[act] || act
} }
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
export function auditStatusText(s: number | undefined) {
if (s === 1) return '已通过'
if (s === 2) return '已驳回'
return '待审核'
}
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */ /** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) { export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
const createType = String(row?.create_type || '') const createType = String(row?.create_type || '')
@@ -357,3 +369,85 @@ export function formatDietaryTaboo(raw: unknown): string {
} }
return '' 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
}
@@ -298,7 +298,7 @@
:fetch-fun="prescriptionOrderExport" :fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams" :params="prescriptionOrderExportParams"
:page-size="pager.size" :page-size="pager.size"
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。" export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「处方」导出主方/辅方药材明细;「主方/辅方服用方式、天数」与详情侧栏同口径(主方/辅方天数分别取处方 usage_days、辅方 aux_usage.usage_days;「天数」列为订单 medication_days)。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -623,6 +623,7 @@
@view-prescription="detailData && openPrescriptionView(detailData)" @view-prescription="detailData && openPrescriptionView(detailData)"
@test-gancao-preview="testGancaoPreviewFromDetail" @test-gancao-preview="testGancaoPreviewFromDetail"
@view-patient="openDiagnosisPatientDetailFromOrder" @view-patient="openDiagnosisPatientDetailFromOrder"
@detail-changed="getLists"
> >
<template #header-extra="{ detail }"> <template #header-extra="{ detail }">
<div class="flex items-center gap-2 ml-4 shrink-0"> <div class="flex items-center gap-2 ml-4 shrink-0">
@@ -1024,10 +1025,11 @@
class="w-full" class="w-full"
> >
<el-option <el-option
v-for="item in servicePackageOptions" v-for="item in editServicePackageSelectOptions"
:key="item.value" :key="item.value"
:label="item.name" :label="item.name"
:value="item.value" :value="item.value"
:disabled="item.status === 0"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -2225,7 +2227,11 @@ import {
canUpdateAmount, canUpdateAmount,
formatDietaryTaboo, formatDietaryTaboo,
type SlipFormulaType, type SlipFormulaType,
type SlipAuxUsageForm type SlipAuxUsageForm,
type ServicePackageOption,
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions
} from './components/prescription-order-utils' } from './components/prescription-order-utils'
import { useListTimeFilter } from '@/hooks/useListTimeFilter' import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import { import {
@@ -2414,8 +2420,8 @@ async function submitReassign() {
// //
const regionOptions = ref([]) const regionOptions = ref([])
// // 便
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([]) const servicePackageOptions = ref<ServicePackageOption[]>([])
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */ /** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
const doctorOptions = ref<Array<{ id: number; name: string }>>([]) const doctorOptions = ref<Array<{ id: number; name: string }>>([])
@@ -2536,8 +2542,7 @@ const loadRegionData = async () => {
const loadServicePackageOptions = async () => { const loadServicePackageOptions = async () => {
try { try {
const data = await getDictData({ type: 'server_order' }) const data = await getDictData({ type: 'server_order' })
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0) servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
} catch (error) { } catch (error) {
console.error('加载服务套餐选项失败:', error) console.error('加载服务套餐选项失败:', error)
servicePackageOptions.value = [] servicePackageOptions.value = []
@@ -3195,10 +3200,17 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
} }
} }
function canAddPayOrderRow(row: { fulfillment_status?: number }) { function canAddPayOrderRow(row: {
// (5) / (6) fulfillment_status?: number
amount?: number | string
linked_pay_paid_total?: number | string
}) {
// (5) / (6)
const fs = Number(row.fulfillment_status) const fs = Number(row.fulfillment_status)
return fs === 5 || fs === 6 if (fs !== 5 && fs !== 6) return false
const orderAmount = Math.round((Number(row.amount) || 0) * 100) / 100
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
return paidTotal < orderAmount
} }
function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) { function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
@@ -3449,6 +3461,11 @@ const editForm = reactive({
diagnosis_creator_dept_path: '' diagnosis_creator_dept_path: ''
}) })
/** 编辑弹窗下拉:字典项 + 当前已选但字典中缺失的兜底项 */
const editServicePackageSelectOptions = computed(() =>
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
)
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */ /** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => { const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
const raw = String(editForm.diagnosis_creator_dept_path || '').trim() const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
@@ -3681,18 +3698,7 @@ async function openEdit(row: {
editForm.dose_unit = d.dose_unit || '剂' editForm.dose_unit = d.dose_unit || '剂'
editForm.prev_staff = d.prev_staff || '' editForm.prev_staff = d.prev_staff || ''
editForm.service_channel = d.service_channel || '' editForm.service_channel = d.service_channel || ''
// editForm.service_package = parseServicePackageValues(d.service_package)
if (d.service_package) {
if (Array.isArray(d.service_package)) {
editForm.service_package = d.service_package
} else if (typeof d.service_package === 'string') {
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
} else {
editForm.service_package = []
}
} else {
editForm.service_package = []
}
editForm.express_company = String(d.express_company || 'auto') || 'auto' editForm.express_company = String(d.express_company || 'auto') || 'auto'
editForm.tracking_number = d.tracking_number || '' editForm.tracking_number = d.tracking_number || ''
editForm.fee_type = Number(d.fee_type) || 3 editForm.fee_type = Number(d.fee_type) || 3
@@ -4509,7 +4515,18 @@ async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: n
} }
} }
function openAddPayOrder(row: { id: number; diagnosis_id?: number; pay_order_ids?: number[] }) { function openAddPayOrder(row: {
id: number
diagnosis_id?: number
pay_order_ids?: number[]
fulfillment_status?: number
amount?: number | string
linked_pay_paid_total?: number | string
}) {
if (!canAddPayOrderRow(row)) {
feedback.msgWarning('订单总金额与已付金额一致,无需补齐支付单')
return
}
addPayOrderRowId.value = row.id addPayOrderRowId.value = row.id
addPayOrderForm.add_mode = 'create' addPayOrderForm.add_mode = 'create'
addPayOrderForm.order_type = 3 addPayOrderForm.order_type = 3
@@ -858,6 +858,9 @@
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="用量"> <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"> <template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }} {{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'"> <template v-if="detailPrescription.prescription_type === '浓缩水丸'">
@@ -868,9 +871,68 @@
</span> </span>
</template> </template>
<template v-else></template> <template v-else></template>
</div>
<div v-if="detailHasAuxHerbs && detailAuxUsage">
<span class="text-gray-500 mr-1">辅方</span>
<template v-if="detailAuxUsage.dosage_amount != null && detailAuxUsage.dosage_amount !== 0">
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</div>
</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="服用方式"> <el-descriptions-item>
<template #label>
<div class="flex items-center gap-1 flex-wrap">
<span>服用方式</span>
<el-button
v-if="
detailData.prescription_id &&
detailPrescription &&
!String(detailData.prescription_detail_error || '').trim()
"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
@click="openPatchUsageDialog"
>
修改
</el-button>
</div>
</template>
<div class="flex flex-col gap-1.5 text-sm leading-relaxed"> <div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<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> <div>
<span class="text-gray-500">每天次数</span> <span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }} {{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
@@ -883,6 +945,7 @@
: '—' : '—'
}} }}
</div> </div>
</template>
<div> <div>
<span class="text-gray-500">订单设置</span> <span class="text-gray-500">订单设置</span>
{{ {{
@@ -1139,7 +1202,7 @@
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item> <el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item> <el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item> <el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item> <el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本"> <el-descriptions-item v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本">
@@ -1525,10 +1588,11 @@
class="w-full" class="w-full"
> >
<el-option <el-option
v-for="item in servicePackageOptions" v-for="item in editServicePackageSelectOptions"
:key="item.value" :key="item.value"
:label="item.name" :label="item.name"
:value="item.value" :value="item.value"
:disabled="item.status === 0"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -2151,6 +2215,93 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 修改服用参数主方 / 辅方 / 订单设置 -->
<el-dialog
v-model="patchUsageVisible"
title="修改服用参数"
width="92%"
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="resetPatchUsageForm"
>
<el-form
ref="patchUsageFormRef"
:model="patchUsageForm"
:rules="patchUsageRules"
label-width="96px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="aux_usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.aux_usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</template>
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
<el-form-item label="服用天数" prop="medication_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.medication_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="patchUsageVisible = false">取消</el-button>
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
</template>
</el-dialog>
<!-- 处方详情查看处方单样式 --> <!-- 处方详情查看处方单样式 -->
<el-drawer <el-drawer
v-model="prescriptionViewVisible" v-model="prescriptionViewVisible"
@@ -2565,6 +2716,7 @@ import {
prescriptionOrderRevokeRxAudit, prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit, prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient, prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderPatchPrescriptionUsage,
prescriptionOrderLinkPayOrder, prescriptionOrderLinkPayOrder,
prescriptionOrderRequestCompletion, prescriptionOrderRequestCompletion,
prescriptionOrderSubmitGancaoRecipel, prescriptionOrderSubmitGancaoRecipel,
@@ -2573,7 +2725,16 @@ import {
getDoctors, getDoctors,
getAssistants getAssistants
} from '@/api/tcm' } from '@/api/tcm'
import { formatDietaryTaboo } from './components/prescription-order-utils' import {
formatDietaryTaboo,
type ServicePackageOption,
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions,
formatServicePackageLabels,
normalizeSlipAuxUsageForm,
prescriptionHasAuxFormula
} from './components/prescription-order-utils'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf' import { jsPDF } from 'jspdf'
import { getDictData } from '@/api/app' import { getDictData } from '@/api/app'
@@ -2669,7 +2830,7 @@ const canViewFinanceFields = () => {
const regionOptions = ref([]) const regionOptions = ref([])
// //
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([]) const servicePackageOptions = ref<ServicePackageOption[]>([])
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */ /** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
const doctorOptions = ref<Array<{ id: number; name: string }>>([]) const doctorOptions = ref<Array<{ id: number; name: string }>>([])
@@ -2790,8 +2951,7 @@ const loadRegionData = async () => {
const loadServicePackageOptions = async () => { const loadServicePackageOptions = async () => {
try { try {
const data = await getDictData({ type: 'server_order' }) const data = await getDictData({ type: 'server_order' })
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0) servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
} catch (error) { } catch (error) {
console.error('加载服务套餐选项失败:', error) console.error('加载服务套餐选项失败:', error)
servicePackageOptions.value = [] servicePackageOptions.value = []
@@ -3199,28 +3359,6 @@ function feeTypeText(t: number | undefined) {
return m[Number(t)] ?? '—' return m[Number(t)] ?? '—'
} }
//
function formatServicePackage(value: any): string {
if (!value) return '—'
let packages: string[] = []
if (Array.isArray(value)) {
packages = value
} else if (typeof value === 'string') {
packages = value.split(',').filter(v => v.trim() !== '')
}
if (packages.length === 0) return '—'
//
const names = packages.map(val => {
const option = servicePackageOptions.value.find(opt => opt.value === val)
return option ? option.name : val
})
return names.join('、')
}
function auditStatusText(s: number | undefined) { function auditStatusText(s: number | undefined) {
if (s === 1) return '已通过' if (s === 1) return '已通过'
if (s === 2) return '已驳回' if (s === 2) return '已驳回'
@@ -3473,6 +3611,10 @@ const detailVisible = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null) const detailData = ref<Record<string, any> | null>(null)
const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
)
// --- --- // --- ---
const workflowActiveStep = computed(() => { const workflowActiveStep = computed(() => {
if (!detailData.value) return 0 if (!detailData.value) return 0
@@ -3629,6 +3771,16 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs)) const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
const detailHasAuxHerbs = computed(() => prescriptionHasAuxFormula(detailPrescription.value as any))
const detailAuxUsage = computed(() => {
const rx = detailPrescription.value as any
if (!rx || !prescriptionHasAuxFormula(rx)) return null
const raw = rx.aux_usage
if (raw == null || raw === '' || (Array.isArray(raw) && raw.length === 0)) return null
return normalizeSlipAuxUsageForm(raw, rx.prescription_type || '浓缩水丸')
})
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */ /** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false) const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
@@ -3855,6 +4007,101 @@ const patchRxPatientRules: FormRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }] phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
} }
const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
const rules: FormRules = {
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
}
return rules
})
function openPatchUsageDialog() {
const rx = detailPrescription.value
const ord = detailData.value
if (!rx || !ord?.id || !ord.prescription_id) {
feedback.msgWarning('无处方数据')
return
}
const aux = detailAuxUsage.value
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageForm.aux_times_per_day =
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.medication_days = undefined
}
async function submitPatchUsage() {
const form = patchUsageFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
const ordId = detailData.value?.id
if (!ordId) return
patchUsageSaving.value = true
try {
const payload: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
patchUsageVisible.value = false
await refreshCurrentPrescriptionOrderDetail()
await fetchLogs(ordId)
getLists()
} catch {
/* 拦截器已提示 */
} finally {
patchUsageSaving.value = false
}
}
function openPatchRxPatientDialog() { function openPatchRxPatientDialog() {
const rx = detailPrescription.value const rx = detailPrescription.value
const ord = detailData.value const ord = detailData.value
@@ -4060,6 +4307,10 @@ const editForm = reactive({
diagnosis_creator_dept_path: '' diagnosis_creator_dept_path: ''
}) })
const editServicePackageSelectOptions = computed(() =>
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
)
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */ /** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => { const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
const raw = String(editForm.diagnosis_creator_dept_path || '').trim() const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
@@ -4310,18 +4561,7 @@ async function openEdit(row: {
editForm.dose_unit = d.dose_unit || '剂' editForm.dose_unit = d.dose_unit || '剂'
editForm.prev_staff = d.prev_staff || '' editForm.prev_staff = d.prev_staff || ''
editForm.service_channel = d.service_channel || '' editForm.service_channel = d.service_channel || ''
// editForm.service_package = parseServicePackageValues(d.service_package)
if (d.service_package) {
if (Array.isArray(d.service_package)) {
editForm.service_package = d.service_package
} else if (typeof d.service_package === 'string') {
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
} else {
editForm.service_package = []
}
} else {
editForm.service_package = []
}
editForm.express_company = String(d.express_company || 'auto') || 'auto' editForm.express_company = String(d.express_company || 'auto') || 'auto'
editForm.tracking_number = d.tracking_number || '' editForm.tracking_number = d.tracking_number || ''
editForm.fee_type = Number(d.fee_type) || 3 editForm.fee_type = Number(d.fee_type) || 3
+7 -7
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div class="code-generation">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="formData" inline> <el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="表名称"> <el-form-item class="w-[280px]" label="表名称">
<el-input v-model="formData.table_name" clearable @keyup.enter="resetPage" /> <el-input v-model="formData.table_name" clearable @keyup.enter="resetPage" />
@@ -13,8 +13,8 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<admin-page-data-panel> <el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
<div class="flex"> <div class="flex">
<data-table <data-table
v-perms="['tools.generator/selectTable']" v-perms="['tools.generator/selectTable']"
@@ -126,10 +126,10 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
<code-preview <code-preview
v-if="previewState.show" v-if="previewState.show"
v-model="previewState.show" v-model="previewState.show"
+1 -1
View File
@@ -866,7 +866,7 @@ onUnmounted(() => {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
background: linear-gradient(145deg, #0d9488 0%, #0891b2 100%); background: linear-gradient(145deg, #6366f1 0%, #8b5cf6 100%);
flex-shrink: 0; flex-shrink: 0;
} }
+7 -29
View File
@@ -1,11 +1,11 @@
<template> <template>
<div class="error"> <div class="error">
<div class="error-panel"> <div>
<slot name="content"> <slot name="content">
<div class="error-code">{{ code }}</div> <div class="error-code">{{ code }}</div>
</slot> </slot>
<div class="error-title">{{ title }}</div> <div class="text-lg text-tx-secondary mt-7 mb-7">{{ title }}</div>
<el-button v-if="showBtn" type="primary" size="large" @click="router.go(-1)"> <el-button v-if="showBtn" type="primary" @click="router.go(-1)">
{{ second }} 秒后返回上一页 {{ second }} 秒后返回上一页
</el-button> </el-button>
</div> </div>
@@ -43,38 +43,16 @@ onUnmounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.error { .error {
text-align: center; text-align: center;
min-height: 100vh; height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background: var(--el-bg-color-page);
padding: 24px;
}
.error-panel {
width: min(480px, 100%);
padding: 48px 32px;
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
box-shadow: var(--el-box-shadow);
}
.error-code { .error-code {
@apply text-primary; @apply text-primary;
font-size: 96px; font-size: 150px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.04em;
} }
.error-title {
@apply text-tx-secondary;
font-size: 18px;
margin: 20px 0 28px;
}
.el-button { .el-button {
min-width: 176px; width: 176px;
}
} }
</style> </style>
+9 -9
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="fans-management"> <div class="fans-management">
<!-- 搜索区域 --> <!-- 搜索区域 -->
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="formData" inline> <el-form class="ls-form" :model="formData" inline>
<el-form-item class="w-[280px]" label="姓名"> <el-form-item class="w-[280px]" label="姓名">
<el-input <el-input
@@ -36,14 +36,14 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<!-- 列表区域 --> <!-- 列表区域 -->
<admin-page-data-panel v-loading="pager.loading"> <el-card class="!border-none mt-4" shadow="never">
<template #toolbar> <div class="mb-4">
<el-button type="primary" @click="handleAdd">新增粉丝</el-button> <el-button type="primary" @click="handleAdd">新增粉丝</el-button>
</template> </div>
<el-table :data="pager.lists" size="large"> <el-table :data="pager.lists" size="large" v-loading="pager.loading">
<el-table-column label="ID" prop="id" width="70" /> <el-table-column label="ID" prop="id" width="70" />
<el-table-column label="姓名" prop="name" min-width="100" /> <el-table-column label="姓名" prop="name" min-width="100" />
<el-table-column label="手机号" prop="phone" min-width="130" /> <el-table-column label="手机号" prop="phone" min-width="130" />
@@ -81,10 +81,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
<!-- 新增/编辑粉丝弹窗 --> <!-- 新增/编辑粉丝弹窗 -->
<el-dialog <el-dialog
+3 -3
View File
@@ -3704,7 +3704,7 @@ function getYejiDeptComboOption(tb: YejiTable) {
const cats = rows.map(r => const cats = rows.map(r =>
r.dept_name.length > 10 ? `${r.dept_name.slice(0, 10)}` : r.dept_name r.dept_name.length > 10 ? `${r.dept_name.slice(0, 10)}` : r.dept_name
) )
const palette = ['#64748b', '#38bdf8', '#0d9488'] const palette = ['#64748b', '#38bdf8', '#6366f1']
const series: any[] = [ const series: any[] = [
{ {
name: '进线', name: '进线',
@@ -4029,8 +4029,8 @@ onMounted(async () => {
.yeji-page { .yeji-page {
min-width: 0; min-width: 0;
--yj-brand: #0d9488; --yj-brand: #6366f1;
--yj-brand-soft: #f0fdfa; --yj-brand-soft: #eef2ff;
--yj-teal: #0ea5e9; --yj-teal: #0ea5e9;
--yj-teal-soft: #f0f9ff; --yj-teal-soft: #f0f9ff;
--yj-accent: #f43f5e; --yj-accent: #f43f5e;
+7 -7
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-alert <el-alert
type="warning" type="warning"
title="温馨提示:用户账户变动记录" title="温馨提示:用户账户变动记录"
@@ -38,9 +38,9 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<admin-page-data-panel v-loading="pager.loading"> <el-card class="!border-none mt-4" shadow="never">
<el-table size="large" :data="pager.lists"> <el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="用户账号" prop="account" min-width="100" /> <el-table-column label="用户账号" prop="account" min-width="100" />
<el-table-column label="用户昵称" min-width="160"> <el-table-column label="用户昵称" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
@@ -71,10 +71,10 @@
<el-table-column label="来源单号" prop="source_sn" min-width="100" /> <el-table-column label="来源单号" prop="source_sn" min-width="100" />
<el-table-column label="记录时间" prop="create_time" min-width="120" /> <el-table-column label="记录时间" prop="create_time" min-width="120" />
</el-table> </el-table>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
</div> </div>
</template> </template>
<script lang="ts" setup name="balanceDetail"> <script lang="ts" setup name="balanceDetail">
+7 -7
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-alert <el-alert
type="warning" type="warning"
title="温馨提示:用户充值记录" title="温馨提示:用户充值记录"
@@ -54,9 +54,9 @@
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<admin-page-data-panel v-loading="pager.loading"> <el-card class="!border-none mt-4" shadow="never">
<el-table size="large" :data="pager.lists"> <el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="用户信息" min-width="160"> <el-table-column label="用户信息" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<div class="flex items-center"> <div class="flex items-center">
@@ -104,10 +104,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
</div> </div>
</template> </template>
<script lang="ts" setup name="rechargeRecord"> <script lang="ts" setup name="rechargeRecord">
+7 -7
View File
@@ -20,7 +20,7 @@
</div> </div>
</div> </div>
</el-card> </el-card>
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px] mt-[16px]" :model="queryParams" :inline="true"> <el-form ref="formRef" class="mb-[-16px] mt-[16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="退款单号"> <el-form-item class="w-[280px]" label="退款单号">
<el-input <el-input
@@ -69,8 +69,8 @@
/> --> /> -->
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<admin-page-data-panel> <el-card class="!border-none mt-4" shadow="never">
<el-tabs v-model="activeTab" @tab-change="handleTabChange"> <el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane <el-tab-pane
v-for="(item, index) in tabLists" v-for="(item, index) in tabLists"
@@ -78,7 +78,7 @@
:name="index" :name="index"
:key="index" :key="index"
> >
<el-table size="large" :data="pager.lists"> <el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="退款单号" prop="sn" min-width="190" /> <el-table-column label="退款单号" prop="sn" min-width="190" />
<el-table-column label="用户信息" min-width="160"> <el-table-column label="用户信息" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
@@ -140,10 +140,10 @@
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
<refund-log v-model="showRefundLog" :refund-id="selectRefundId" /> <refund-log v-model="showRefundLog" :refund-id="selectRefundId" />
</div> </div>
</template> </template>
+5 -5
View File
@@ -1,14 +1,14 @@
<template> <template>
<div> <div>
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-alert <el-alert
type="warning" type="warning"
title="温馨提示:平台配置在各个场景下的通知发送方式和内容模板" title="温馨提示:平台配置在各个场景下的通知发送方式和内容模板"
:closable="false" :closable="false"
show-icon show-icon
></el-alert> ></el-alert>
</admin-page-filter-panel> </el-card>
<admin-page-data-panel v-loading="pager.loading"> <el-card class="!border-none mt-4" shadow="never">
<el-tabs v-model="tabsActive" @tab-change="getLists"> <el-tabs v-model="tabsActive" @tab-change="getLists">
<el-tab-pane <el-tab-pane
v-for="(item, index) in tabsMap" v-for="(item, index) in tabsMap"
@@ -18,7 +18,7 @@
lazy lazy
></el-tab-pane> ></el-tab-pane>
</el-tabs> </el-tabs>
<el-table size="large" :data="pager.lists" > <el-table size="large" :data="pager.lists" v-loading="pager.loading">
<el-table-column label="通知场景" prop="scene_name" min-width="120" /> <el-table-column label="通知场景" prop="scene_name" min-width="120" />
<el-table-column label="通知类型" prop="type_desc" min-width="160" /> <el-table-column label="通知类型" prop="type_desc" min-width="160" />
<el-table-column label="短信通知" min-width="80"> <el-table-column label="短信通知" min-width="80">
@@ -44,7 +44,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</admin-page-data-panel> </el-card>
</div> </div>
</template> </template>
<script lang="ts" setup name="notice"> <script lang="ts" setup name="notice">
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<admin-page-data-panel v-loading="state.loading"> <el-card class="!border-none" shadow="never" v-loading="state.loading">
<el-table size="large" :data="state.lists"> <el-table size="large" :data="state.lists">
<el-table-column label="短信渠道" prop="name" min-width="120" /> <el-table-column label="短信渠道" prop="name" min-width="120" />
<el-table-column label="状态" min-width="120"> <el-table-column label="状态" min-width="120">
@@ -22,7 +22,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</admin-page-data-panel> </el-card>
<edit-popup ref="editRef" @success="getLists" /> <edit-popup ref="editRef" @success="getLists" />
</div> </div>
</template> </template>
@@ -33,11 +33,13 @@ import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>() const editRef = shallowRef<InstanceType<typeof EditPopup>>()
//
const state = reactive({ const state = reactive({
loading: false, loading: false,
lists: [] lists: []
}) })
//
const getLists = async () => { const getLists = async () => {
try { try {
state.loading = true state.loading = true
+12 -8
View File
@@ -1,7 +1,7 @@
<!-- 订单列表 --> <!-- 订单列表 -->
<template> <template>
<div> <div class="order-list">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<!-- 搜索表单 --> <!-- 搜索表单 -->
<el-form class="ls-form" :model="queryParams" inline> <el-form class="ls-form" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="订单号"> <el-form-item class="w-[280px]" label="订单号">
@@ -106,10 +106,10 @@
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<!-- Tab 筛选 + 今日收益 + 数据表格 --> <!-- Tab 筛选 + 今日收益 -->
<admin-page-data-panel v-loading="pager.loading"> <el-card class="!border-none mt-4" shadow="never">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<el-tabs v-model="patientAssociationTab" @tab-change="handleTabChange"> <el-tabs v-model="patientAssociationTab" @tab-change="handleTabChange">
<el-tab-pane label="全部" name="" /> <el-tab-pane label="全部" name="" />
@@ -122,7 +122,10 @@
<span class="text-gray-400">{{ todayRevenue.count }} </span> <span class="text-gray-400">{{ todayRevenue.count }} </span>
</div> </div>
</div> </div>
</el-card>
<!-- 数据表格 -->
<el-card class="!border-none mt-4" shadow="never">
<div v-perms="['order.order/zhipai']" class="mb-3 flex items-center gap-3"> <div v-perms="['order.order/zhipai']" class="mb-3 flex items-center gap-3">
<el-button type="primary" :disabled="!selectedOrderIds.length" @click="openAssignDialog"> <el-button type="primary" :disabled="!selectedOrderIds.length" @click="openAssignDialog">
将创建人指给医助 将创建人指给医助
@@ -133,6 +136,7 @@
</div> </div>
<el-table <el-table
ref="orderTableRef" ref="orderTableRef"
v-loading="pager.loading"
:data="pager.lists" :data="pager.lists"
row-key="id" row-key="id"
size="large" size="large"
@@ -279,10 +283,10 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
<!-- 详情弹窗 --> <!-- 详情弹窗 -->
<el-dialog <el-dialog
@@ -1,6 +1,6 @@
<template> <template>
<div> <div class="department">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true"> <el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="部门名称" prop="name"> <el-form-item class="w-[280px]" label="部门名称" prop="name">
<el-input <el-input
@@ -22,10 +22,9 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<el-card class="!border-none mt-4" shadow="never">
<admin-page-data-panel v-loading="loading"> <div>
<template #toolbar>
<el-button v-perms="['dept.dept/add']" type="primary" @click="handleAdd()"> <el-button v-perms="['dept.dept/add']" type="primary" @click="handleAdd()">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
@@ -33,10 +32,12 @@
新增 新增
</el-button> </el-button>
<el-button @click="handleExpand"> 展开/折叠 </el-button> <el-button @click="handleExpand"> 展开/折叠 </el-button>
</template> </div>
<el-table <el-table
ref="tableRef" ref="tableRef"
class="mt-4"
size="large" size="large"
v-loading="loading"
:data="lists" :data="lists"
row-key="id" row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
@@ -67,6 +68,7 @@
}}</el-tag> }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="排序" prop="sort" min-width="100" /> <el-table-column label="排序" prop="sort" min-width="100" />
<el-table-column label="更新时间" prop="update_time" min-width="180" /> <el-table-column label="更新时间" prop="update_time" min-width="180" />
<el-table-column label="操作" width="160" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
@@ -99,7 +101,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</admin-page-data-panel> </el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
@@ -118,19 +120,15 @@ let isExpand = false
const loading = ref(false) const loading = ref(false)
const lists = ref<any[]>([]) const lists = ref<any[]>([])
const queryParams = reactive({ const queryParams = reactive({
name: '', status: '',
status: '' name: ''
}) })
const showEdit = ref(false) const showEdit = ref(false)
const getLists = async () => { const getLists = async () => {
loading.value = true loading.value = true
try {
lists.value = await deptLists(queryParams) lists.value = await deptLists(queryParams)
} finally {
loading.value = false loading.value = false
} }
}
const resetParams = () => { const resetParams = () => {
formRef.value?.resetFields() formRef.value?.resetFields()
+10 -11
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div class="post-lists">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true"> <el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="岗位编码"> <el-form-item class="w-[280px]" label="岗位编码">
<el-input <el-input
@@ -31,18 +31,17 @@
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<el-card class="!border-none mt-4" shadow="never">
<admin-page-data-panel v-loading="pager.loading"> <div>
<template #toolbar>
<el-button v-perms="['dept.jobs/add']" type="primary" @click="handleAdd()"> <el-button v-perms="['dept.jobs/add']" type="primary" @click="handleAdd()">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
新增 新增
</el-button> </el-button>
</template> </div>
<el-table size="large" :data="pager.lists"> <el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="岗位编码" prop="code" min-width="100" /> <el-table-column label="岗位编码" prop="code" min-width="100" />
<el-table-column label="岗位名称" prop="name" min-width="100" /> <el-table-column label="岗位名称" prop="name" min-width="100" />
<el-table-column label="排序" prop="sort" min-width="100" /> <el-table-column label="排序" prop="sort" min-width="100" />
@@ -76,10 +75,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
+1 -1
View File
@@ -1275,7 +1275,7 @@ onUnmounted(() => {
.float-action.edit { .float-action.edit {
color: #fff; color: #fff;
background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%); background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
} }
.float-action.edit:hover, .float-action.edit:hover,
+11 -11
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div class="admin">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="formData" inline> <el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="管理员账号"> <el-form-item class="w-[280px]" label="管理员账号">
<el-input <el-input
@@ -40,19 +40,17 @@
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd"> <el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
新增 新增
</el-button> </el-button>
</template> <div class="mt-4">
<el-table :data="pager.lists" size="large"> <el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" /> <el-table-column label="ID" prop="id" min-width="60" />>
<el-table-column label="头像" min-width="100"> <el-table-column label="头像" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-avatar :size="50" :src="row.avatar"></el-avatar> <el-avatar :size="50" :src="row.avatar"></el-avatar>
@@ -108,10 +106,11 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> </div>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
@@ -126,6 +125,7 @@ import feedback from '@/utils/feedback'
import EditPopup from './edit.vue' import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>() const editRef = shallowRef<InstanceType<typeof EditPopup>>()
//
const formData = reactive({ const formData = reactive({
account: '', account: '',
name: '', name: '',
+6 -4
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="menu-lists">
<el-card class="!border-none" shadow="never">
<div> <div>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button v-perms="['auth.menu/add']" type="primary" @click="handleAdd()"> <el-button v-perms="['auth.menu/add']" type="primary" @click="handleAdd()">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
@@ -9,10 +9,12 @@
新增 新增
</el-button> </el-button>
<el-button @click="handleExpand"> 展开/折叠 </el-button> <el-button @click="handleExpand"> 展开/折叠 </el-button>
</template> </div>
<el-table <el-table
ref="tableRef" ref="tableRef"
class="mt-4"
size="large" size="large"
v-loading="pager.loading"
:data="pager.lists" :data="pager.lists"
row-key="id" row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
@@ -85,7 +87,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</admin-page-data-panel> </el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
+11 -6
View File
@@ -1,14 +1,16 @@
<template> <template>
<div class="role-lists">
<el-card class="!border-none" shadow="never">
<div> <div>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button v-perms="['auth.role/add']" type="primary" @click="handleAdd"> <el-button v-perms="['auth.role/add']" type="primary" @click="handleAdd">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
新增 新增
</el-button> </el-button>
</template> </div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table :data="pager.lists" size="large"> <el-table :data="pager.lists" size="large">
<el-table-column prop="id" label="ID" min-width="100" /> <el-table-column prop="id" label="ID" min-width="100" />
<el-table-column prop="name" label="名称" min-width="150" /> <el-table-column prop="name" label="名称" min-width="150" />
@@ -55,10 +57,12 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> </div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
<auth-popup v-if="showAuth" ref="authRef" @success="getLists" @close="showAuth = false" /> <auth-popup v-if="showAuth" ref="authRef" @success="getLists" @close="showAuth = false" />
</div> </div>
@@ -111,6 +115,7 @@ const dataScopeLabel = (scope: number | string | null | undefined) => {
) )
} }
//
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除?') await feedback.confirm('确定要删除?')
await roleDelete({ id }) await roleDelete({ id })
+13 -10
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div class="dict-type">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-page-header class="mb-4" content="数据管理" @back="$router.back()" /> <el-page-header class="mb-4" content="数据管理" @back="$router.back()" />
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline> <el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="字典名称"> <el-form-item class="w-[280px]" label="字典名称">
@@ -28,10 +28,9 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<el-card class="!border-none mt-4" shadow="never">
<admin-page-data-panel v-loading="pager.loading"> <div>
<template #toolbar>
<el-button <el-button
v-perms="['setting.dict.dict_data/add']" v-perms="['setting.dict.dict_data/add']"
type="primary" type="primary"
@@ -53,7 +52,9 @@
</template> </template>
删除 删除
</el-button> </el-button>
</template> </div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table <el-table
:data="pager.lists" :data="pager.lists"
size="large" size="large"
@@ -97,10 +98,12 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> </div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
+14 -10
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div class="dict-type">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline> <el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="字典名称"> <el-form-item class="w-[280px]" label="字典名称">
<el-input v-model="queryParams.name" clearable @keyup.enter="resetPage" /> <el-input v-model="queryParams.name" clearable @keyup.enter="resetPage" />
@@ -20,10 +20,9 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<el-card class="!border-none mt-4" shadow="never">
<admin-page-data-panel v-loading="pager.loading"> <div>
<template #toolbar>
<el-button <el-button
v-perms="['setting.dict.dict_type/add']" v-perms="['setting.dict.dict_type/add']"
type="primary" type="primary"
@@ -45,7 +44,9 @@
</template> </template>
删除 删除
</el-button> </el-button>
</template> </div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table <el-table
:data="pager.lists" :data="pager.lists"
size="large" size="large"
@@ -100,10 +101,12 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> </div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</template> </div>
</admin-page-data-panel> </div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
@@ -148,6 +151,7 @@ const handleEdit = async (data: any) => {
editRef.value?.setFormData(data) editRef.value?.setFormData(data)
} }
//
const handleDelete = async (id: any[] | number) => { const handleDelete = async (id: any[] | number) => {
await feedback.confirm('确定要删除?') await feedback.confirm('确定要删除?')
await dictTypeDelete({ id }) await dictTypeDelete({ id })
@@ -0,0 +1,319 @@
<template>
<div class="auto-assign-log-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="log-filter-form">
<el-form-item label="记录时间">
<el-date-picker
v-model="timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
@change="doSearch"
/>
</el-form-item>
<el-form-item label="执行日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
@change="doSearch"
/>
</el-form-item>
<el-form-item label="结果">
<el-select
v-model="queryParams.action"
clearable
placeholder="全部"
class="w-[120px]"
@change="doSearch"
>
<el-option label="已分配" value="1" />
<el-option label="未分配" value="0" />
</el-select>
</el-form-item>
<el-form-item label="回退">
<el-select
v-model="queryParams.is_rollback"
clearable
placeholder="全部"
class="w-[120px]"
@change="doSearch"
>
<el-option label="未回退" value="0" />
<el-option label="已回退" value="1" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="患者/手机号/医助/诊单ID"
clearable
class="w-[220px]"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="log-hint">
由每日定时任务待分配诊单自动指派写入按上月二诊复诊接诊率分档轮询分配&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>
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="conversion-stats-page"> <div class="conversion-stats-page">
<admin-page-filter-panel> <el-card class="!border-none" shadow="never">
<el-form :inline="true" :model="queryParams" class="stats-filter-form"> <el-form :inline="true" :model="queryParams" class="stats-filter-form">
<el-form-item label="统计维度"> <el-form-item label="统计维度">
<el-segmented <el-segmented
@@ -89,7 +89,7 @@
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</admin-page-filter-panel> </el-card>
<div class="stats-kpi-grid"> <div class="stats-kpi-grid">
<div <div
+4 -4
View File
@@ -18,7 +18,7 @@
<el-tree-select <el-tree-select
v-model="deptId" v-model="deptId"
:data="deptTreeOptions" :data="deptTreeOptions"
placeholder="全部部门" placeholder="二中心(全部)"
clearable clearable
filterable filterable
check-strictly check-strictly
@@ -43,10 +43,10 @@
</template> </template>
<div class="rate-caliber"> <div class="rate-caliber">
<p> <p>
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重 <b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重<b>再剔除</b>名下存在履约拒收 / 退款业务订单的诊单
</p> </p>
<p> <p>
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序的全局序号<b>跨月累计不重置</b> 5 月指派后旗下成交 4 单为二诊~五诊下月再成交即为六诊 <b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序编号为实单序号<b>统计诊次 = 实单序号 + 诊单偏移</b>默认偏移 0 1 笔实单为一诊偏移 1 1 笔实单为二诊偏移 2 1 笔实单为三诊5 笔实单等价七诊诊次<b>跨月累计不重置</b>诊单可在业务订单tab 配置偏移量
</p> </p>
<p> <p>
<b>当月 N 诊单数</b>当月内下单且诊次为 N 的订单数归属下单时点<b>持有该患者的医助</b>指派可在往月释放后不再归属继承指派会转移持有人但不计被指派数 <b>当月 N 诊单数</b>当月内下单且诊次为 N 的订单数归属下单时点<b>持有该患者的医助</b>指派可在往月释放后不再归属继承指派会转移持有人但不计被指派数
@@ -55,7 +55,7 @@
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数往月指派当月成交会推高分子比率可能超过 100%医助当月无新指派但旗下有成交时被指派数为 0比率显示 <b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数往月指派当月成交会推高分子比率可能超过 100%医助当月无新指派但旗下有成交时被指派数为 0比率显示
</p> </p>
<p> <p>
医助按人事部门归组选定部门时含其组织下级 医助按人事部门归组<b>仅统计二中心及其组织下级</b>部门下拉与未选时的默认范围均限定在该子树选定部门时含其组织下级
</p> </p>
</div> </div>
</el-popover> </el-popover>
+45 -1
View File
@@ -83,6 +83,23 @@
<el-radio-button value="0">未确认</el-radio-button> <el-radio-button value="0">未确认</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
<div class="filter-group">
<span class="filter-label">部门</span>
<el-tree-select
v-model="formData.assistant_dept_id"
:data="departmentTreeRaw"
class="filter-dept-select"
clearable
filterable
check-strictly
:default-expand-all="true"
node-key="id"
size="small"
:props="assistantDeptTreeProps"
placeholder="选父级含子级"
@change="handleAssistantDeptChange"
/>
</div>
</div> </div>
<el-dialog <el-dialog
@@ -542,6 +559,7 @@
import { usePaging } from '@/hooks/usePaging' import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue' import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor' import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
import { deptAll } from '@/api/org/department'
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm' import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
import { getDictData } from '@/api/app' import { getDictData } from '@/api/app'
import { addDoctorNote } from '@/api/patient' import { addDoctorNote } from '@/api/patient'
@@ -603,10 +621,19 @@ const formData = reactive({
end_date: '', end_date: '',
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after', date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
diagnosis_confirmed: '' as '' | '0' | '1', // ''= 1= 0= diagnosis_confirmed: '' as '' | '0' | '1', // ''= 1= 0=
/** 接诊医生 / 诊单医助 / 挂号医助所属部门(选父级含子级) */
assistant_dept_id: '' as number | '',
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */ /** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
include_status_counts: 0 as 0 | 1 include_status_counts: 0 as 0 | 1
}) })
const departmentTreeRaw = ref<unknown[]>([])
const assistantDeptTreeProps = {
value: 'id',
label: 'name',
children: 'children'
}
const activeTab = ref('1') const activeTab = ref('1')
const dateCustomVisible = ref(false) const dateCustomVisible = ref(false)
const statusCount = ref<Record<number, number>>({ const statusCount = ref<Record<number, number>>({
@@ -721,6 +748,12 @@ const handleDiagnosisConfirmedChange = () => {
loadData() loadData()
} }
//
const handleAssistantDeptChange = () => {
pager.page = 1
loadData()
}
// //
const handleDatePresetChange = (val: string | number | boolean | undefined) => { const handleDatePresetChange = (val: string | number | boolean | undefined) => {
const v = String(val || '') const v = String(val || '')
@@ -770,6 +803,7 @@ const handleReset = () => {
formData.doctor_name = '' formData.doctor_name = ''
formData.date_preset = 'today' formData.date_preset = 'today'
formData.diagnosis_confirmed = '' formData.diagnosis_confirmed = ''
formData.assistant_dept_id = ''
const t = new Date() const t = new Date()
const p = (n: number) => String(n).padStart(2, '0') const p = (n: number) => String(n).padStart(2, '0')
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}` formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
@@ -1099,7 +1133,13 @@ formData.start_date = `${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${
formData.end_date = formData.start_date formData.end_date = formData.start_date
formData.status = 1 formData.status = 1
onMounted(() => { onMounted(async () => {
try {
const deptTree = await deptAll()
departmentTreeRaw.value = Array.isArray(deptTree) ? deptTree : []
} catch {
departmentTreeRaw.value = []
}
loadData() loadData()
listPollTimer = setInterval(() => { listPollTimer = setInterval(() => {
loadData({ silent: true }) loadData({ silent: true })
@@ -1228,6 +1268,10 @@ onUnmounted(() => {
padding: 6px 14px; padding: 6px 14px;
} }
} }
.filter-dept-select {
width: 200px;
}
} }
} }
@@ -4,6 +4,43 @@
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" /> <el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
</div> </div>
<template v-else> <template v-else>
<div v-if="diagnosisId > 0" class="po-revisit-offset-bar mb-3">
<div class="po-revisit-offset-bar__main">
<span class="text-sm text-gray-600">复诊统计起始偏移</span>
<el-tooltip placement="top">
<template #content>
<div class="max-w-xs leading-relaxed">
在实单诊次序号上叠加偏移量设为 0默认 1 笔实单计为一诊设为 1 1 笔实单计为二诊设为 2 1 笔实单计为三诊若有 5 笔实单且偏移 2则统计上相当于计至七诊5+2
</div>
</template>
<el-icon class="text-gray-400 align-middle ml-1"><QuestionFilled /></el-icon>
</el-tooltip>
<el-input-number
v-model="revisitSlotStartOffset"
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
:min="0"
:max="20"
:step="1"
controls-position="right"
class="w-[120px] ml-3"
:disabled="offsetSaving"
/>
<span class="text-xs text-gray-500 ml-2">
1 笔实单计为{{ visitSlotStartLabel }}
</span>
</div>
<el-button
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
type="primary"
size="small"
:loading="offsetSaving"
:disabled="!offsetDirty"
@click="saveRevisitSlotStartOffset"
>
保存
</el-button>
</div>
<el-table <el-table
v-loading="pager.loading" v-loading="pager.loading"
:data="pager.lists" :data="pager.lists"
@@ -12,6 +49,23 @@
empty-text="暂无业务订单" empty-text="暂无业务订单"
> >
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip /> <el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
<el-table-column label="全局诊次" width="96" align="center">
<template #default="{ row }">
<span v-if="row.global_visit_seq">{{ row.global_visit_seq }}</span>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="计入统计" width="96" align="center">
<template #default="{ row }">
<el-tag
v-if="row.counts_for_revisit_rate"
type="success"
size="small"
effect="plain"
></el-tag>
<el-tag v-else type="info" size="small" effect="plain"></el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right"> <el-table-column label="金额" width="120" align="right">
<template #default="{ row }"> <template #default="{ row }">
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span> <span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
@@ -63,9 +117,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging' import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists } from '@/api/tcm' import { prescriptionOrderLists, tcmDiagnosisDetail, tcmDiagnosisSetRevisitSlotStartOffset } from '@/api/tcm'
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue' import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
import feedback from '@/utils/feedback'
import { import {
formatTime, formatTime,
fulfillmentText, fulfillmentText,
@@ -91,6 +147,26 @@ const { pager, getLists, resetPage } = usePaging({
size: 10 size: 10
}) })
const revisitSlotStartOffset = ref(0)
const savedRevisitSlotStartOffset = ref(0)
const offsetSaving = ref(false)
const offsetLoading = ref(false)
const offsetDirty = computed(
() => Number(revisitSlotStartOffset.value) !== Number(savedRevisitSlotStartOffset.value)
)
const visitSlotStartLabel = computed(() => {
const raw = Number(revisitSlotStartOffset.value)
const offset = Number.isFinite(raw) ? raw : 0
const slot = offset + 1
const cn = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
if (slot >= 1 && slot <= 10) {
return cn[slot] + '诊'
}
return `${slot}`
})
const buildParams = () => { const buildParams = () => {
Object.keys(queryParams).forEach((k) => delete queryParams[k]) Object.keys(queryParams).forEach((k) => delete queryParams[k])
if (props.diagnosisId > 0) { if (props.diagnosisId > 0) {
@@ -102,6 +178,47 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit' queryParams.scene = 'diagnosis_edit'
} }
async function loadRevisitSlotStartOffset() {
if (props.diagnosisId <= 0) return
offsetLoading.value = true
try {
const res: any = await tcmDiagnosisDetail({ id: props.diagnosisId })
const d = res?.data ?? res ?? {}
const offset = Number(d.revisit_slot_start_offset)
const val = Number.isFinite(offset) && offset >= 0 && offset <= 20 ? offset : 0
revisitSlotStartOffset.value = val
savedRevisitSlotStartOffset.value = val
} catch {
revisitSlotStartOffset.value = 0
savedRevisitSlotStartOffset.value = 0
} finally {
offsetLoading.value = false
}
}
async function saveRevisitSlotStartOffset() {
if (props.diagnosisId <= 0) return
const offset = Number(revisitSlotStartOffset.value)
if (!Number.isFinite(offset) || offset < 0 || offset > 20) {
feedback.msgWarning('起始偏移须在 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>>() const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
@@ -117,8 +234,13 @@ const formatAmount = (value: unknown) => {
watch( watch(
() => [props.diagnosisId, patientIdNum.value] as const, () => [props.diagnosisId, patientIdNum.value] as const,
() => { () => {
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return } if (!patientIdAvailable.value) {
pager.lists = []
pager.count = 0
return
}
buildParams() buildParams()
void loadRevisitSlotStartOffset()
resetPage() resetPage()
}, },
{ immediate: true } { immediate: true }
@@ -134,4 +256,21 @@ defineExpose({ refresh: () => getLists() })
.po-empty-tip { .po-empty-tip {
padding: 24px 0; padding: 24px 0;
} }
.po-revisit-offset-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.po-revisit-offset-bar__main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
</style> </style>
+122 -20
View File
@@ -121,6 +121,16 @@
end-placeholder="最近挂号结束" end-placeholder="最近挂号结束"
@change="handleLatestAppointmentFilterChange" @change="handleLatestAppointmentFilterChange"
/> />
<daterange-picker
class="latest-assign-range"
v-model:startTime="formData.latest_assign_start_date"
v-model:endTime="formData.latest_assign_end_date"
picker-type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="最近指派开始"
end-placeholder="最近指派结束"
@change="handleLatestAssignFilterChange"
/>
<el-select <el-select
v-model="formData.latest_appointment_channel_source" v-model="formData.latest_appointment_channel_source"
placeholder="最近挂号渠道" placeholder="最近挂号渠道"
@@ -173,6 +183,7 @@
v-loading="pager.loading" v-loading="pager.loading"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
@row-dblclick="goReadonly" @row-dblclick="goReadonly"
@sort-change="handleTableSortChange"
:row-class-name="getRowClassName" :row-class-name="getRowClassName"
class="diagnosis-table" class="diagnosis-table"
stripe stripe
@@ -283,7 +294,14 @@
<span v-else class="status-unprescribed">未开方</span> <span v-else class="status-unprescribed">未开方</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="未服务天数" width="110" align="center"> <el-table-column
label="未服务天数"
prop="unserved_days"
width="110"
align="center"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #default="{ row }"> <template #default="{ row }">
<el-tooltip <el-tooltip
v-if="row.last_blood_record_at" v-if="row.last_blood_record_at"
@@ -762,6 +780,10 @@ const formData = reactive({
latest_appointment_start_date: '' as string, latest_appointment_start_date: '' as string,
latest_appointment_end_date: '' as string, latest_appointment_end_date: '' as string,
latest_appointment_channel_source: '' as string, latest_appointment_channel_source: '' as string,
latest_assign_start_date: '' as string,
latest_assign_end_date: '' as string,
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
sort_unserved_days: '' as '' | 'asc' | 'desc',
diagnosis_confirmed: '' as '' | '0' | '1', diagnosis_confirmed: '' as '' | '0' | '1',
appointment_date: '' as string, appointment_date: '' as string,
has_appointment: '' as '' | '0' | '1', has_appointment: '' as '' | '0' | '1',
@@ -849,22 +871,50 @@ function resolvePendingAssignOrderMonthForRequest(): string {
return dayjs().format('YYYY-MM') return dayjs().format('YYYY-MM')
} }
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」 */ /** 除顶部 Tab 专属条件外,与主列表共用的「更多筛选」参数(角标 count 需同步 */
function buildPendingAssignCountPayload(): Record<string, unknown> { function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
return buildTcmDiagnosisListRequestPayload({ return {
...formData, keyword: formData.keyword,
diagnosis_type: formData.diagnosis_type,
syndrome_type: formData.syndrome_type,
assistant_id: formData.assistant_id,
diagnosis_confirmed: formData.diagnosis_confirmed,
has_appointment: formData.has_appointment,
latest_appointment_start_date: formData.latest_appointment_start_date,
latest_appointment_end_date: formData.latest_appointment_end_date,
latest_appointment_channel_source: formData.latest_appointment_channel_source,
latest_assign_start_date: formData.latest_assign_start_date,
latest_assign_end_date: formData.latest_assign_end_date
}
}
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
page_no: 1, page_no: 1,
page_size: 1, page_size: 1,
pending_assign: 1, ...buildSharedDiagnosisFilterPayload(),
appointment_date: '', appointment_date: '',
has_appointment: '',
pending_booking: '', pending_booking: '',
completed_appointment: '', completed_appointment: '',
latest_appointment_start_date: '', pending_assign: '',
latest_appointment_end_date: '', pending_assign_order_month: '',
latest_appointment_channel_source: '', pending_assign_keyword: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest() sort_unserved_days: '',
} as Record<string, unknown>) as Record<string, unknown> ...overrides
}
}
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
function buildPendingAssignCountPayload(): Record<string, unknown> {
return buildTcmDiagnosisListRequestPayload(
buildDateCountRequestPayload({
pending_assign: 1,
has_appointment: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest(),
pending_assign_keyword: formData.pending_assign_keyword
}) as Record<string, unknown>
) as Record<string, unknown>
} }
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) => const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
@@ -893,6 +943,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
formData.latest_appointment_start_date = '' formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = '' formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = '' formData.latest_appointment_channel_source = ''
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.pending_assign_order_month = '' formData.pending_assign_order_month = ''
activeTab.value = 'all' activeTab.value = 'all'
if (kw1 !== '') { if (kw1 !== '') {
@@ -994,14 +1047,26 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
const fetchDateCounts = async () => { const fetchDateCounts = async () => {
try { try {
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([ const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }), tcmDiagnosisLists(
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }), buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }), ),
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }), tcmDiagnosisLists(
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }), buildDateCountRequestPayload({ appointment_date: dayBeforeStr.value, has_appointment: '' }) as any
tcmDiagnosisLists({ page_no: 1, page_size: 1 }), ),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }), tcmDiagnosisLists(
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }), buildDateCountRequestPayload({ appointment_date: todayStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: tomorrowStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: dayAfterStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(buildDateCountRequestPayload() as any),
tcmDiagnosisLists(buildDateCountRequestPayload({ has_appointment: 0 }) as any),
tcmDiagnosisLists(
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
),
tcmDiagnosisLists(buildPendingAssignCountPayload() as any) tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
]) ])
dateCounts.value = { dateCounts.value = {
@@ -1135,6 +1200,11 @@ const clearLatestAppointmentFilters = () => {
formData.latest_appointment_channel_source = '' formData.latest_appointment_channel_source = ''
} }
const clearLatestAssignFilters = () => {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
}
const hasLatestAppointmentFilter = () => const hasLatestAppointmentFilter = () =>
!!( !!(
formData.latest_appointment_start_date || formData.latest_appointment_start_date ||
@@ -1142,6 +1212,9 @@ const hasLatestAppointmentFilter = () =>
formData.latest_appointment_channel_source formData.latest_appointment_channel_source
) )
const hasLatestAssignFilter = () =>
!!(formData.latest_assign_start_date || formData.latest_assign_end_date)
const handleLatestAppointmentFilterChange = () => { const handleLatestAppointmentFilterChange = () => {
if (hasLatestAppointmentFilter()) { if (hasLatestAppointmentFilter()) {
formData.appointment_date = '' formData.appointment_date = ''
@@ -1154,6 +1227,27 @@ const handleLatestAppointmentFilterChange = () => {
doSearch() doSearch()
} }
const handleLatestAssignFilterChange = () => {
doSearch()
}
const handleTableSortChange = ({
prop,
order
}: {
prop: string
order: 'ascending' | 'descending' | null
}) => {
if (prop === 'unserved_days') {
formData.sort_unserved_days =
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
} else {
formData.sort_unserved_days = ''
}
pager.page = 1
getLists()
}
const latestAppointmentChannelText = (row: any) => { const latestAppointmentChannelText = (row: any) => {
const desc = String(row?.latest_appointment_channel_source_desc || '').trim() const desc = String(row?.latest_appointment_channel_source_desc || '').trim()
const raw = String(row?.latest_appointment_channel_source || '').trim() const raw = String(row?.latest_appointment_channel_source || '').trim()
@@ -1200,6 +1294,9 @@ const handleReset = () => {
formData.latest_appointment_start_date = '' formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = '' formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = '' formData.latest_appointment_channel_source = ''
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.diagnosis_confirmed = '' formData.diagnosis_confirmed = ''
formData.appointment_date = '' formData.appointment_date = ''
formData.has_appointment = '' formData.has_appointment = ''
@@ -2298,6 +2395,11 @@ onUnmounted(() => {
max-width: 100%; max-width: 100%;
} }
.latest-assign-range {
width: 260px;
max-width: 100%;
}
.latest-appointment-channel { .latest-appointment-channel {
width: 170px; width: 170px;
} }
+68 -103
View File
@@ -18,7 +18,7 @@
</span> </span>
<div> <div>
<div class="wb-section-name">订单统计</div> <div class="wb-section-name">订单统计</div>
<div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '-' }}</div> <div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '' }}</div>
</div> </div>
</div> </div>
<div class="wb-toolbar"> <div class="wb-toolbar">
@@ -821,8 +821,8 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.workbench-page { .workbench-page {
min-height: 100%; min-height: 100%;
padding: 4px 0 24px; padding: 20px 20px 40px;
background: transparent; background: linear-gradient(160deg, #eef2ff 0%, #f8fafc 38%, #f1f5f9 100%);
} }
.wb-hero { .wb-hero {
@@ -830,86 +830,56 @@ onMounted(() => {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 16px;
margin-bottom: 24px; margin-bottom: 22px;
padding: 28px 28px; padding: 22px 26px;
border-radius: var(--admin-radius-xl); border-radius: 20px;
border: 1px solid var(--el-border-color-lighter); background: linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.65) 100%);
background: var(--admin-surface-glass); border: 1px solid rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px); box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06);
box-shadow: var(--el-box-shadow-light);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(120deg, rgba(6, 182, 212, 0.14) 0%, transparent 45%),
linear-gradient(300deg, rgba(16, 185, 129, 0.1) 0%, transparent 40%);
pointer-events: none;
}
&::after {
content: '';
position: absolute;
inset: 0 0 auto;
height: 3px;
background: var(--admin-brand-gradient);
}
}
.wb-hero-text {
position: relative;
z-index: 1;
} }
.wb-hero-title { .wb-hero-title {
margin: 0 0 8px; margin: 0 0 6px;
font-size: 28px; font-size: 26px;
font-weight: 800; font-weight: 700;
letter-spacing: -0.03em; letter-spacing: 0.02em;
background: var(--admin-brand-gradient); color: #0f172a;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
} }
.wb-hero-desc { .wb-hero-desc {
margin: 0; margin: 0;
font-size: 15px; font-size: 14px;
color: var(--el-text-color-secondary); color: #64748b;
} }
.wb-refresh-btn { .wb-refresh-btn {
position: relative; box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25);
z-index: 1;
box-shadow: var(--admin-brand-glow);
} }
.wb-section { .wb-section {
margin-bottom: 20px; margin-bottom: 20px;
border-radius: var(--admin-radius-xl); border-radius: 20px;
background: var(--admin-surface-elevated); background: rgba(255, 255, 255, 0.88);
border: 1px solid var(--el-border-color-lighter); border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: var(--el-box-shadow); box-shadow: 0 8px 32px rgba(15, 23, 42, 0.05);
overflow: hidden; overflow: hidden;
backdrop-filter: blur(10px);
} }
.wb-section--diagnosis { .wb-section--diagnosis {
border-top: 3px solid transparent; border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #06b6d4, #10b981) 1; border-image: linear-gradient(90deg, #3b82f6, #60a5fa) 1;
} }
.wb-section--order { .wb-section--order {
border-top: 3px solid transparent; border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #0891b2, #6366f1) 1; border-image: linear-gradient(90deg, #8b5cf6, #a78bfa) 1;
} }
.wb-section--trend { .wb-section--trend {
border-top: 3px solid transparent; border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #10b981, #06b6d4) 1; border-image: linear-gradient(90deg, #14b8a6, #2dd4bf) 1;
} }
.wb-section-head { .wb-section-head {
@@ -919,8 +889,8 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 18px 22px; padding: 18px 22px;
border-bottom: 1px solid var(--el-border-color-extra-light); border-bottom: 1px solid rgba(226, 232, 240, 0.9);
background: var(--admin-brand-gradient-soft); background: linear-gradient(180deg, rgba(248, 250, 252, 0.9) 0%, rgba(255, 255, 255, 0) 100%);
} }
.wb-section-title { .wb-section-title {
@@ -933,68 +903,38 @@ onMounted(() => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 46px; width: 44px;
height: 46px; height: 44px;
border-radius: 14px; border-radius: 14px;
font-size: 22px; font-size: 22px;
color: #fff; color: #fff;
} }
.wb-section-icon--blue { .wb-section-icon--blue {
background: var(--admin-brand-gradient); background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: var(--admin-brand-glow); box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35);
} }
.wb-section-icon--violet { .wb-section-icon--violet {
background: linear-gradient(135deg, #0891b2, #6366f1); background: linear-gradient(135deg, #8b5cf6, #7c3aed);
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25); box-shadow: 0 8px 20px rgba(124, 58, 237, 0.3);
} }
.wb-section-icon--teal { .wb-section-icon--teal {
background: linear-gradient(135deg, #10b981, #06b6d4); background: linear-gradient(135deg, #14b8a6, #0d9488);
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.25); box-shadow: 0 8px 20px rgba(13, 148, 136, 0.3);
}
.wb-kpi--blue {
background: linear-gradient(145deg, rgba(6, 182, 212, 0.12), rgba(16, 185, 129, 0.06));
border: 1px solid rgba(6, 182, 212, 0.22);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-kpi--violet {
background: linear-gradient(145deg, rgba(99, 102, 241, 0.1), rgba(6, 182, 212, 0.06));
border: 1px solid rgba(99, 102, 241, 0.2);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-kpi--amber {
background: linear-gradient(145deg, rgba(245, 158, 11, 0.12), rgba(251, 191, 36, 0.06));
border: 1px solid rgba(245, 158, 11, 0.24);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-rank-strip {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 112px;
padding: 14px 18px;
border-radius: var(--admin-radius-lg);
background: var(--admin-brand-gradient-soft);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-lighter);
} }
.wb-section-name { .wb-section-name {
font-size: 16px; font-size: 17px;
font-weight: 600; font-weight: 600;
color: var(--el-text-color-primary); color: #0f172a;
} }
.wb-section-sub { .wb-section-sub {
margin-top: 2px; margin-top: 2px;
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary); color: #94a3b8;
} }
.wb-toolbar { .wb-toolbar {
@@ -1028,6 +968,21 @@ onMounted(() => {
box-sizing: border-box; box-sizing: border-box;
} }
.wb-kpi--blue {
background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid rgba(59, 130, 246, 0.2);
}
.wb-kpi--violet {
background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 100%);
border: 1px solid rgba(139, 92, 246, 0.22);
}
.wb-kpi--amber {
background: linear-gradient(145deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid rgba(245, 158, 11, 0.25);
}
.wb-kpi-label { .wb-kpi-label {
font-size: 13px; font-size: 13px;
color: #64748b; color: #64748b;
@@ -1052,6 +1007,17 @@ onMounted(() => {
color: #94a3b8; color: #94a3b8;
} }
.wb-rank-strip {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 112px;
padding: 14px 18px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.wb-rank-strip--compact { .wb-rank-strip--compact {
min-height: 112px; min-height: 112px;
} }
@@ -1114,10 +1080,9 @@ onMounted(() => {
.wb-chart-card { .wb-chart-card {
height: 100%; height: 100%;
padding: 14px 16px 8px; padding: 14px 16px 8px;
border-radius: var(--admin-radius-lg); border-radius: 16px;
background: var(--admin-surface-elevated); background: #fafbfc;
border: 1px solid var(--el-border-color-lighter); border: 1px solid #eef0f4;
box-shadow: var(--el-box-shadow-lighter);
} }
.wb-chart-card-head { .wb-chart-card-head {
+1 -16
View File
@@ -77,22 +77,7 @@ module.exports = {
mask: 'var(--el-mask-color)' mask: 'var(--el-mask-color)'
}, },
fontFamily: { fontFamily: {
sans: [ sans: ['PingFang SC', 'Arial', 'Hiragino Sans GB', 'Microsoft YaHei', 'sans-serif']
'PingFang SC',
'SF Pro Text',
'Segoe UI',
'Arial',
'Hiragino Sans GB',
'Microsoft YaHei',
'sans-serif'
]
},
borderRadius: {
sm: 'var(--admin-radius-sm)',
DEFAULT: 'var(--admin-radius-md)',
md: 'var(--admin-radius-md)',
lg: 'var(--admin-radius-lg)',
xl: 'var(--admin-radius-xl)'
}, },
boxShadow: { boxShadow: {
DEFAULT: 'var(--el-box-shadow)', DEFAULT: 'var(--el-box-shadow)',
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\stats\AutoAssignLogLists;
use app\adminapi\logic\stats\AutoAssignLogLogic;
/**
* 待分配诊单自动指派日志
*
* - GET stats.autoAssignLog/lists 日志列表
* - POST stats.autoAssignLog/rollback 批量回退已自动分配的医助
*/
class AutoAssignLogController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new AutoAssignLogLists());
}
public function rollback()
{
$ids = $this->request->post('ids', []);
if (!is_array($ids)) {
$ids = [$ids];
}
$result = AutoAssignLogLogic::rollback($ids, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(AutoAssignLogLogic::getError());
}
$msg = sprintf('回退成功 %d 条', (int) ($result['success'] ?? 0));
$failed = (int) ($result['failed'] ?? 0);
if ($failed > 0) {
$msg .= sprintf(',跳过/失败 %d 条', $failed);
}
// show=0:由前端按 success/failed 明细提示,避免与部分失败警告重复
return $this->success($msg, $result, 1, 0);
}
}
@@ -94,6 +94,24 @@ class DiagnosisController extends BaseAdminController
return $this->fail(DiagnosisLogic::getError()); return $this->fail(DiagnosisLogic::getError());
} }
/**
* @notes 设置复诊接诊率统计起始偏移(业务订单 tab
*/
public function setRevisitSlotStartOffset()
{
$params = (new DiagnosisValidate())->post()->goCheck('setRevisitSlotStartOffset');
$ok = DiagnosisLogic::setRevisitSlotStartOffset(
(int) $params['id'],
(int) $params['revisit_slot_start_offset'],
$this->adminInfo
);
if (!$ok) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('保存成功');
}
/** /**
* @notes 删除诊单 * @notes 删除诊单
* @return \think\response\Json * @return \think\response\Json
@@ -181,6 +181,20 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('保存成功'); return $this->success('保存成功');
} }
/**
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
*/
public function patchPrescriptionUsage()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('patchPrescriptionUsage');
$ok = PrescriptionOrderLogic::patchPrescriptionUsage($params, $this->adminId, $this->adminInfo);
if (!$ok) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('保存成功');
}
public function auditPrescription() public function auditPrescription()
{ {
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription'); $params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
@@ -328,6 +342,20 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('', $result); return $this->success('', $result);
} }
/**
* 手工新增操作日志(可选同步调整处方/支付单审核状态)
*/
public function addLog()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('addLog');
$result = PrescriptionOrderLogic::addLog($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('日志已添加', $result);
}
/** /**
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核 * 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
*/ */
@@ -3,6 +3,8 @@
namespace app\adminapi\lists\doctor; namespace app\adminapi\lists\doctor;
use app\adminapi\lists\BaseAdminDataLists; use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\DiagnosisViewRecord; use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment; use app\common\model\doctor\Appointment;
use app\common\model\tcm\Prescription; use app\common\model\tcm\Prescription;
@@ -75,6 +77,35 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
}); });
} }
/**
* 按部门筛选:接诊医生、诊单医助或挂号医助所属部门命中子树即可(选父级含子级)
*
* @param mixed $query
*/
private function applyAssistantDeptIdFilter($query): void
{
if (!isset($this->params['assistant_dept_id']) || $this->params['assistant_dept_id'] === '' || (int) $this->params['assistant_dept_id'] <= 0) {
return;
}
$rootDeptId = (int) $this->params['assistant_dept_id'];
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
return $id > 0;
}));
if ($deptIds === []) {
$query->whereRaw('0 = 1');
return;
}
$inList = implode(',', $deptIds);
$adTbl = (new AdminDept())->getTable();
$query->whereRaw(
"(EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`doctor_id` AND ad.`dept_id` IN ({$inList}))"
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = u.`assistant_id` AND ad.`dept_id` IN ({$inList}))"
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`assistant_id` AND ad.`dept_id` IN ({$inList})))"
);
}
/** /**
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构 * 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
* *
@@ -191,6 +222,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyAssistantIdFilter($query); $this->applyAssistantIdFilter($query);
$this->applyAssistantDeptIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter); $this->applyChannelSourceFilter($query, $chFilter);
// 是否确认诊单:1=已确认 0=未确认 // 是否确认诊单:1=已确认 0=未确认
@@ -373,6 +406,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyAssistantIdFilter($query); $this->applyAssistantIdFilter($query);
$this->applyAssistantDeptIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter); $this->applyChannelSourceFilter($query, $chFilter);
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) { if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {

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