Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76f16193b |
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
import { ref} from "vue";
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
@@ -96,7 +97,11 @@ const video =async (data)=>{
|
||||
},
|
||||
}, false)
|
||||
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
console.log('SDKAppID',SDKAppID)
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
@@ -110,4 +115,4 @@ const video =async (data)=>{
|
||||
uni.setStorageSync('CallManager', userId)
|
||||
await uni.CallManager.init(cc);
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -239,6 +239,7 @@
|
||||
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
|
||||
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
|
||||
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
|
||||
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
|
||||
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
|
||||
@@ -560,12 +561,17 @@ const { proxy } = getCurrentInstance()
|
||||
},
|
||||
}, false)
|
||||
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
const {userId } = res.data;
|
||||
|
||||
patient_data.value=res.data.data
|
||||
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
getApp().globalData.SDKAppID = SDKAppID;
|
||||
getApp().globalData.secretKey = secretKey;
|
||||
uni.setStorageSync('CallManager', userId)
|
||||
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
|
||||
}
|
||||
|
||||
@@ -186,8 +186,16 @@ const inlineItems = computed(() => {
|
||||
return metro ? [metro, ...rest] : trainingItems
|
||||
})
|
||||
|
||||
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
|
||||
const visible = ref(true)
|
||||
const isDevMode = (): boolean => {
|
||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||
try {
|
||||
return process.env.NODE_ENV !== 'production'
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const visible = ref(isDevMode())
|
||||
const expanded = ref(false)
|
||||
|
||||
const toggle = () => {
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
let userId = ref("");
|
||||
@@ -378,11 +379,10 @@ const loginHandler = async (patient_id) => {
|
||||
data: { patient_id: patient_id },
|
||||
}, false)
|
||||
|
||||
const {
|
||||
userId: patientUserId,
|
||||
userSig: patientUserSig,
|
||||
sdkAppId: patientSDKAppID,
|
||||
} = res.data
|
||||
const { userId: patientUserId } = res.data
|
||||
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID: patientUserId,
|
||||
})
|
||||
console.log('获取签名成功:', patientUserSig)
|
||||
|
||||
// 更新ref值
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
|
||||
+12
-19
@@ -26,6 +26,14 @@
|
||||
"navigationBarTitleText": "甄养堂"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "TUICallKit/src/Components/TUICallKit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "视频问诊",
|
||||
"navigationBarHidden": true,
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/monad/monad",
|
||||
"style": {
|
||||
@@ -68,19 +76,6 @@
|
||||
}
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "TUICallKit/src/Components",
|
||||
"pages": [
|
||||
{
|
||||
"path": "TUICallKit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "视频问诊",
|
||||
"navigationBarHidden": true,
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "TUIKit",
|
||||
"pages": [
|
||||
@@ -221,14 +216,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "endless-game/index",
|
||||
"path": "pages/game",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "识糖小课堂",
|
||||
"backgroundColor": "#eefbf4",
|
||||
"disableScroll": false,
|
||||
"enableShareAppMessage": true,
|
||||
"enableShareTimeline": true
|
||||
"navigationBarTitleText": "糖分突袭",
|
||||
"backgroundColor": "#c7d2fe",
|
||||
"disableScroll": true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
import { ref,onMounted,getCurrentInstance} from "vue";
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
const { proxy } = getCurrentInstance()
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
|
||||
let userID = ref("4");
|
||||
@@ -139,9 +140,13 @@ const loginHandler = async (patient_id) => {
|
||||
|
||||
|
||||
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
console.log('获取签名成功:',userSig);
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userID = userId.value;
|
||||
getApp().globalData.userSig = userSig;
|
||||
getApp().globalData.SDKAppID = SDKAppID;
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<script setup>
|
||||
import { ref, getCurrentInstance,onMounted } from "vue";
|
||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
let userId = ref("");
|
||||
@@ -125,7 +126,10 @@ const loginHandler = async (patient_id) => {
|
||||
data: { patient_id: patient_id },
|
||||
}, false)
|
||||
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
const {userId } = res.data;
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
console.log('获取签名成功:',userSig);
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
import { ref,onMounted,getCurrentInstance } from "vue";
|
||||
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
@@ -666,25 +667,25 @@ const closePatientModal = async () => {
|
||||
const granted = await requestAVPermissionsInModal();
|
||||
if (!granted) return; // 未授权,停止跳转
|
||||
const app = getApp();
|
||||
// const userID = app.globalData.userID;
|
||||
// const userSig = app.globalData.userSig;
|
||||
// const SDKAppID = app.globalData.SDKAppID;
|
||||
const doctor = 'doctor_' + doctorId.value;
|
||||
await confirmDiagnosis(diagnosisId);
|
||||
const signatureRes = await proxy.apiUrl({
|
||||
url: '/api/tcm/getPatientSignature',
|
||||
method: 'GET',
|
||||
data: { patient_id: diagnosisId },
|
||||
}, false);
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
|
||||
if(!diagnosisId.includes('patient')){
|
||||
diagnosisId='patient_'+diagnosisId
|
||||
}
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:diagnosisId,
|
||||
});
|
||||
if (!doctorId.value) {
|
||||
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
app.globalData.userID = userId;
|
||||
app.globalData.userSig = userSig;
|
||||
app.globalData.SDKAppID = SDKAppID;
|
||||
console.log(app.globalData, userId);
|
||||
console.log(app.globalData, diagnosisId);
|
||||
|
||||
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
|
||||
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(diagnosisId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
|
||||
|
||||
showPatientModal.value = false;
|
||||
uni.redirectTo({
|
||||
@@ -706,7 +707,11 @@ const video =async (data)=>{
|
||||
},
|
||||
}, false)
|
||||
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<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>
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
|
||||
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
let fxSeq = 0
|
||||
function nextFxId(prefix) {
|
||||
fxSeq += 1
|
||||
return `${prefix}-${fxSeq}-${Date.now()}`
|
||||
}
|
||||
|
||||
// 开心消消乐式糖果色板(彩虹爆裂用)
|
||||
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
|
||||
// 星屑字形
|
||||
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
|
||||
|
||||
export function useGameFx() {
|
||||
const floatingTexts = ref([])
|
||||
const burstParticles = ref([])
|
||||
const ripples = ref([])
|
||||
const comboBanner = ref({ show: false, text: '', tier: 1 })
|
||||
const screenShaking = ref(false)
|
||||
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
|
||||
|
||||
let comboBannerTimer = null
|
||||
let shakeTimer = null
|
||||
let flashTimer = null
|
||||
let flashFadeTimer = null
|
||||
|
||||
function spawnFloatingText(payload) {
|
||||
const id = nextFxId('ft')
|
||||
floatingTexts.value.push({ id, ...payload })
|
||||
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
|
||||
setTimeout(() => {
|
||||
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
|
||||
}, duration)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
|
||||
* @param {number} cx,cy 爆裂中心(屏幕坐标)
|
||||
* @param {string} color 基础颜色
|
||||
* @param {number} count 粒子数量
|
||||
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
|
||||
*/
|
||||
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
|
||||
const {
|
||||
star = true,
|
||||
rainbow = false,
|
||||
gravity = 64,
|
||||
sizeMin = 8,
|
||||
sizeMax = 16,
|
||||
distMin = 36,
|
||||
distMax = 110,
|
||||
life = 850
|
||||
} = opts
|
||||
const dur = (life / 1000).toFixed(2)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = nextFxId('p')
|
||||
const isStar = star && Math.random() < 0.65
|
||||
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const distance = Math.random() * (distMax - distMin) + distMin
|
||||
const tx = Math.cos(angle) * distance
|
||||
// 先上抛、再叠加重力下落,形成喷泉抛物线
|
||||
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
|
||||
const half = pSize / 2
|
||||
const pColor = rainbow
|
||||
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
|
||||
: color
|
||||
const rot = Math.random() * 720 - 360
|
||||
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
|
||||
|
||||
const particle = {
|
||||
id,
|
||||
left: cx - half,
|
||||
top: cy - half,
|
||||
size: pSize,
|
||||
color: pColor,
|
||||
char: glyph,
|
||||
opacity: 1,
|
||||
transform: 'scale(0.4) rotate(0deg)',
|
||||
transition: 'none'
|
||||
}
|
||||
burstParticles.value.push(particle)
|
||||
|
||||
setTimeout(() => {
|
||||
particle.transition =
|
||||
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
|
||||
particle.left = cx - half + tx
|
||||
particle.top = cy - half + ty
|
||||
particle.opacity = 0
|
||||
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
|
||||
}, 16)
|
||||
|
||||
setTimeout(() => {
|
||||
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
|
||||
}, life + 80)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
|
||||
* @param {object} opts { thickness, life }
|
||||
*/
|
||||
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
|
||||
const id = nextFxId('rp')
|
||||
const size = 36 + tier * 18
|
||||
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
|
||||
ripples.value.push({
|
||||
id,
|
||||
left: cx,
|
||||
top: cy,
|
||||
size,
|
||||
color,
|
||||
thickness,
|
||||
opacity: 0.95
|
||||
})
|
||||
setTimeout(() => {
|
||||
ripples.value = ripples.value.filter((r) => r.id !== id)
|
||||
}, life)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
|
||||
*/
|
||||
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
|
||||
if (flashFadeTimer) clearTimeout(flashFadeTimer)
|
||||
if (flashTimer) clearTimeout(flashTimer)
|
||||
flash.value = { active: true, color, opacity: intensity }
|
||||
flashFadeTimer = setTimeout(() => {
|
||||
flash.value = { ...flash.value, opacity: 0 }
|
||||
}, 24)
|
||||
flashTimer = setTimeout(() => {
|
||||
flash.value = { active: false, color, opacity: 0 }
|
||||
}, life)
|
||||
}
|
||||
|
||||
function showComboBanner(chain, points) {
|
||||
if (chain <= 1) return
|
||||
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
|
||||
comboBanner.value = {
|
||||
show: true,
|
||||
text: `连消 ×${chain} +${points}`,
|
||||
tier
|
||||
}
|
||||
if (comboBannerTimer) clearTimeout(comboBannerTimer)
|
||||
comboBannerTimer = setTimeout(() => {
|
||||
comboBanner.value = { ...comboBanner.value, show: false }
|
||||
}, 1100)
|
||||
}
|
||||
|
||||
function triggerShake(intensity = 1) {
|
||||
screenShaking.value = true
|
||||
if (shakeTimer) clearTimeout(shakeTimer)
|
||||
shakeTimer = setTimeout(() => {
|
||||
screenShaking.value = false
|
||||
}, intensity >= 2 ? 420 : 280)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
floatingTexts.value = []
|
||||
burstParticles.value = []
|
||||
ripples.value = []
|
||||
comboBanner.value = { show: false, text: '', tier: 1 }
|
||||
screenShaking.value = false
|
||||
flash.value = { active: false, color: '#ffffff', opacity: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
floatingTexts,
|
||||
burstParticles,
|
||||
ripples,
|
||||
comboBanner,
|
||||
screenShaking,
|
||||
flash,
|
||||
spawnFloatingText,
|
||||
spawnBurst,
|
||||
spawnRipple,
|
||||
showComboBanner,
|
||||
triggerShake,
|
||||
triggerFlash,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 游戏进度持久化:关卡、星级、连胜、每日打卡
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { calcLevelStars } from '../data/gameLevels.js'
|
||||
|
||||
const STORAGE_KEY = 'tongji_game_progress_v1'
|
||||
|
||||
const DEFAULT = {
|
||||
currentLevel: 1,
|
||||
maxUnlocked: 1,
|
||||
stars: {},
|
||||
winStreak: 0,
|
||||
dailyStreak: 0,
|
||||
lastPlayDate: '',
|
||||
totalWins: 0,
|
||||
bestCombo: 0,
|
||||
totalStars: 0
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function sumStars(starsMap) {
|
||||
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
}
|
||||
|
||||
function loadRaw() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
|
||||
} catch (_) {}
|
||||
return { ...DEFAULT }
|
||||
}
|
||||
|
||||
function saveRaw(data) {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, data)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function useGameProgress() {
|
||||
const progress = ref(loadRaw())
|
||||
|
||||
function persist() {
|
||||
progress.value.totalStars = sumStars(progress.value.stars)
|
||||
saveRaw(progress.value)
|
||||
}
|
||||
|
||||
/** 进入游戏时更新每日打卡 */
|
||||
function touchDailyPlay() {
|
||||
const today = todayStr()
|
||||
const last = progress.value.lastPlayDate
|
||||
if (last === today) return progress.value.dailyStreak
|
||||
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
|
||||
|
||||
if (last === yStr) {
|
||||
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
|
||||
} else {
|
||||
progress.value.dailyStreak = 1
|
||||
}
|
||||
progress.value.lastPlayDate = today
|
||||
persist()
|
||||
return progress.value.dailyStreak
|
||||
}
|
||||
|
||||
function getStars(levelId) {
|
||||
return progress.value.stars[String(levelId)] || 0
|
||||
}
|
||||
|
||||
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
|
||||
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
|
||||
const key = String(levelId)
|
||||
const prev = progress.value.stars[key] || 0
|
||||
if (stars > prev) progress.value.stars[key] = stars
|
||||
|
||||
progress.value.winStreak = (progress.value.winStreak || 0) + 1
|
||||
progress.value.totalWins = (progress.value.totalWins || 0) + 1
|
||||
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
|
||||
|
||||
const next = levelId + 1
|
||||
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
|
||||
progress.value.currentLevel = next
|
||||
persist()
|
||||
return { stars, newBest: stars > prev }
|
||||
}
|
||||
|
||||
function recordLoss() {
|
||||
progress.value.winStreak = 0
|
||||
persist()
|
||||
}
|
||||
|
||||
function setCurrentLevel(levelId) {
|
||||
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
|
||||
persist()
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
touchDailyPlay,
|
||||
getStars,
|
||||
recordWin,
|
||||
recordLoss,
|
||||
setCurrentLevel,
|
||||
persist
|
||||
}
|
||||
}
|
||||
+26
-73
@@ -25,13 +25,6 @@ const SRC = {
|
||||
* rate 不同可在同一素材上听出明显差异
|
||||
*/
|
||||
const SFX = {
|
||||
// —— 基础素材别名(供分层音效直接调用)——
|
||||
tap: { src: SRC.tap, volume: 0.32, rate: 1, pool: 3 },
|
||||
pop: { src: SRC.pop, volume: 0.3, rate: 1, pool: 4 },
|
||||
fanfare: { src: SRC.fanfare, volume: 0.56, rate: 1, pool: 2 },
|
||||
blast: { src: SRC.blast, volume: 0.62, rate: 1, pool: 2 },
|
||||
sparkle: { src: SRC.sparkle, volume: 0.5, rate: 1, pool: 2 },
|
||||
|
||||
// —— 交互 ——
|
||||
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
|
||||
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
|
||||
@@ -84,43 +77,6 @@ const SFX = {
|
||||
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
|
||||
}
|
||||
|
||||
const WARMUP_SFX = [
|
||||
'select', 'swap', 'swapFail', 'drop', 'dropLight',
|
||||
'match3', 'match4', 'match5', 'combo2', 'combo3', 'comboMega',
|
||||
'insulin', 'sparkle', 'reshuffle', 'lose'
|
||||
]
|
||||
|
||||
function createAudioContext() {
|
||||
if (typeof Audio !== 'undefined') {
|
||||
const audio = new Audio()
|
||||
audio.preload = 'auto'
|
||||
return {
|
||||
get src() { return audio.src },
|
||||
set src(value) { audio.src = value },
|
||||
get volume() { return audio.volume },
|
||||
set volume(value) { audio.volume = value },
|
||||
get playbackRate() { return audio.playbackRate },
|
||||
set playbackRate(value) { audio.playbackRate = value },
|
||||
play() {
|
||||
const promise = audio.play()
|
||||
if (promise?.catch) promise.catch(() => {})
|
||||
},
|
||||
stop() {
|
||||
audio.pause()
|
||||
try { audio.currentTime = 0 } catch (_) {}
|
||||
},
|
||||
seek(time) {
|
||||
try { audio.currentTime = time } catch (_) {}
|
||||
},
|
||||
destroy() {
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
}
|
||||
return uni.createInnerAudioContext()
|
||||
}
|
||||
|
||||
class SfxPool {
|
||||
constructor(src, size, volume, rate = 1) {
|
||||
this.src = src
|
||||
@@ -132,42 +88,40 @@ class SfxPool {
|
||||
this.warmedUp = false
|
||||
}
|
||||
|
||||
createOne() {
|
||||
const ctx = createAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
ctx.volume = this.volume
|
||||
try {
|
||||
ctx.playbackRate = this.rate
|
||||
} catch (_) {}
|
||||
this.list.push(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
create() {
|
||||
while (this.list.length < this.size) this.createOne()
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
ctx.volume = this.volume
|
||||
try {
|
||||
ctx.playbackRate = this.rate
|
||||
} catch (_) {}
|
||||
this.list.push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
const ctx = this.list[0] || this.createOne()
|
||||
try {
|
||||
ctx.volume = 0
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.volume
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
if (this.size > 1) this.cursor = 1
|
||||
if (!this.list.length) this.create()
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = 0
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.volume
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
})
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play(scale = 1, rateMul = 1) {
|
||||
if (this.list.length < this.size) this.create()
|
||||
if (!this.list.length) this.create()
|
||||
const ctx = this.list[this.cursor % this.list.length]
|
||||
this.cursor += 1
|
||||
const vol = Math.min(1, this.volume * scale)
|
||||
@@ -250,8 +204,7 @@ export function useGameSfx() {
|
||||
function warmUp() {
|
||||
if (!enabled.value) return
|
||||
ensureAudioOption()
|
||||
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
|
||||
names.forEach((name) => {
|
||||
Object.keys(SFX).forEach((name) => {
|
||||
getPool(name)?.warmUp()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
|
||||
*/
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
|
||||
function gid(key, suffix) {
|
||||
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
|
||||
}
|
||||
|
||||
function wrap(key, body) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
|
||||
}
|
||||
|
||||
function shadow(key) {
|
||||
const id = gid(key, 'sh')
|
||||
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
|
||||
}
|
||||
|
||||
function shine(x, y, rx, ry) {
|
||||
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
|
||||
}
|
||||
|
||||
/** 圆形水果 */
|
||||
function artRound(key, base, dark, light, extra = '') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
|
||||
<stop offset="0%" stop-color="${light}"/>
|
||||
<stop offset="55%" stop-color="${base}"/>
|
||||
<stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
|
||||
${shine(24, 28, 7, 5)}
|
||||
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
|
||||
${extra}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 叶菜 */
|
||||
function artLeafy(key, light, mid, dark) {
|
||||
const g1 = gid(key, 'g1')
|
||||
const g2 = gid(key, 'g2')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
|
||||
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
|
||||
${shine(26, 24, 6, 8)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 西兰花 */
|
||||
function artBroccoli(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
|
||||
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
|
||||
</radialGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
|
||||
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
|
||||
${shine(28, 20, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 长条(黄瓜/香蕉/玉米) */
|
||||
function artLong(key, base, dark, light, type = 'cucumber') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'banana') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
|
||||
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
|
||||
${shine(30, 26, 5, 3)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'corn') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
|
||||
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
|
||||
Array.from({ length: 4 }, (_, c) =>
|
||||
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
|
||||
).join('')
|
||||
).join('')}</g>
|
||||
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
|
||||
${shine(26, 26, 5, 8)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
|
||||
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 茄子 */
|
||||
function artEggplant(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
|
||||
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
|
||||
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
|
||||
${shine(26, 32, 5, 7)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 番茄 */
|
||||
function artTomato(key) {
|
||||
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
|
||||
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
|
||||
}
|
||||
|
||||
/** 甜椒 */
|
||||
function artPepper(key, color, dark) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
|
||||
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
|
||||
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
|
||||
${shine(28, 26, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 洋葱 */
|
||||
function artOnion(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
|
||||
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
|
||||
${shine(24, 30, 6, 5)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 葡萄串 */
|
||||
function artGrapes(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
|
||||
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
|
||||
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
${shine(28, 24, 3, 2)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 樱桃 */
|
||||
function artCherry(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
|
||||
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
|
||||
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 草莓 */
|
||||
function artStrawberry(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
|
||||
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
|
||||
${shine(26, 28, 4, 6)}
|
||||
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
|
||||
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
|
||||
).join('')}</g>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 豆类 */
|
||||
function artBeans(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
|
||||
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
|
||||
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 饮品杯 */
|
||||
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'bottle') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
|
||||
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
|
||||
${shine(28, 30, 4, 10)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'wine') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
|
||||
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
|
||||
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
|
||||
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
|
||||
${shine(26, 32, 4, 8)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 蘑菇 */
|
||||
function artMushroom(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
|
||||
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
|
||||
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
|
||||
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
|
||||
${shine(24, 24, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 芹菜 */
|
||||
function artCelery(key) {
|
||||
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
|
||||
}
|
||||
|
||||
/** 四季豆 */
|
||||
function artGreenBean(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
|
||||
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 饭碗 */
|
||||
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
|
||||
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
|
||||
${shine(24, 34, 6, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 薯类 */
|
||||
function artRoot(key, base, dark, light) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
|
||||
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
|
||||
${shine(24, 30, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 肉类 */
|
||||
function artMeat(key, base, dark, type = 'steak') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'chicken') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
|
||||
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
|
||||
${shine(26, 32, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'fish') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
|
||||
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
|
||||
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
|
||||
${shine(28, 30, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
|
||||
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
|
||||
${shine(26, 28, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 虾/蟹 */
|
||||
function artSeafood(key, type = 'shrimp') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'crab') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
|
||||
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
|
||||
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
|
||||
${shine(30, 28, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 菠萝 */
|
||||
function artPineapple(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
|
||||
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
|
||||
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
|
||||
).join('')}</g>
|
||||
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
|
||||
${shine(26, 32, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 奇异果 */
|
||||
function artKiwi(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
|
||||
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
|
||||
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
|
||||
${shine(26, 28, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 蜜瓜 */
|
||||
function artMelon(key) {
|
||||
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
|
||||
}
|
||||
|
||||
/** 蜂蜜 */
|
||||
function artHoney(key) {
|
||||
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
|
||||
}
|
||||
|
||||
/** 面包类 */
|
||||
function artBread(key, base, dark, type = 'loaf') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'donut') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
|
||||
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
|
||||
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
${shine(24, 28, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'mantou') {
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
|
||||
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
|
||||
${shine(24, 32, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
|
||||
${shine(22, 28, 8, 5)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 面条 */
|
||||
function artNoodle(key, soup = '#d7ccc8') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
|
||||
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
|
||||
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 南瓜 */
|
||||
function artPumpkin(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
|
||||
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
|
||||
${shine(26, 28, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 薯条 */
|
||||
function artFries(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
|
||||
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 西瓜 */
|
||||
function artWatermelon(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
|
||||
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
|
||||
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
|
||||
${shine(24, 30, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 糖果 */
|
||||
function artCandy(key, base, dark, type = 'lollipop') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'wrap') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
|
||||
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
|
||||
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
|
||||
${shine(26, 24, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 糯米团 */
|
||||
function artDango(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="22" cy="36" r="8" fill="#fff"/>
|
||||
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="36" r="8" fill="#fff"/>
|
||||
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 爆米花 */
|
||||
function artPopcorn(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
|
||||
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
|
||||
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
const FOOD_ART = {
|
||||
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
|
||||
broccoli: () => artBroccoli('broccoli'),
|
||||
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
|
||||
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
|
||||
eggplant: () => artEggplant('eggplant'),
|
||||
tomato: () => artTomato('tomato'),
|
||||
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
|
||||
onion: () => artOnion('onion'),
|
||||
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
|
||||
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
|
||||
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
|
||||
cherry: () => artCherry('cherry'),
|
||||
grape: () => artGrapes('grape'),
|
||||
strawberry: () => artStrawberry('strawberry'),
|
||||
soybean: () => artBeans('soybean'),
|
||||
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
|
||||
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
|
||||
mushroom: () => artMushroom('mushroom'),
|
||||
celery: () => artCelery('celery'),
|
||||
greenBean: () => artGreenBean('greenBean'),
|
||||
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
|
||||
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
|
||||
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
|
||||
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
|
||||
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
|
||||
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
|
||||
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
|
||||
shrimp: () => artSeafood('shrimp', 'shrimp'),
|
||||
crab: () => artSeafood('crab', 'crab'),
|
||||
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
|
||||
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
|
||||
pineapple: () => artPineapple('pineapple'),
|
||||
kiwi: () => artKiwi('kiwi'),
|
||||
melon: () => artMelon('melon'),
|
||||
honey: () => artHoney('honey'),
|
||||
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
|
||||
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
|
||||
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
|
||||
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
|
||||
udon: () => artNoodle('udon', '#d9b88f'),
|
||||
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
|
||||
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
|
||||
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
|
||||
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
|
||||
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
|
||||
popcorn: () => artPopcorn('popcorn'),
|
||||
ramen: () => artNoodle('ramen', '#e0a96d'),
|
||||
pumpkin: () => artPumpkin('pumpkin'),
|
||||
bakedPotato: () => artFries('bakedPotato'),
|
||||
watermelon: () => artWatermelon('watermelon'),
|
||||
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
|
||||
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
|
||||
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
|
||||
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
|
||||
stickyRice: () => artDango('stickyRice')
|
||||
}
|
||||
|
||||
const imgCache = {}
|
||||
|
||||
/** 获取食材插画 data URL(带缓存) */
|
||||
export function getFoodImg(key) {
|
||||
if (imgCache[key]) return imgCache[key]
|
||||
const builder = FOOD_ART[key]
|
||||
if (!builder) return ''
|
||||
imgCache[key] = svgToDataUrl(builder())
|
||||
return imgCache[key]
|
||||
}
|
||||
|
||||
export function warmUpFoodImgs(keys) {
|
||||
keys.forEach((k) => getFoodImg(k))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 食材图标 URL(Twemoji PNG,小程序 / H5 通用)
|
||||
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
|
||||
*/
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
|
||||
|
||||
/** emoji → Twemoji PNG 地址 */
|
||||
export function emojiToTwemojiUrl(emoji) {
|
||||
if (!emoji) return ''
|
||||
const parts = []
|
||||
for (let i = 0; i < emoji.length;) {
|
||||
const cp = emoji.codePointAt(i)
|
||||
if (!cp) break
|
||||
parts.push(cp.toString(16))
|
||||
i += cp > 0xffff ? 2 : 1
|
||||
}
|
||||
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
|
||||
}
|
||||
|
||||
const imgCache = {}
|
||||
|
||||
export function getFoodImgUrl(emoji) {
|
||||
if (!emoji) return ''
|
||||
if (imgCache[emoji]) return imgCache[emoji]
|
||||
const url = emojiToTwemojiUrl(emoji)
|
||||
imgCache[emoji] = url
|
||||
return url
|
||||
}
|
||||
|
||||
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
|
||||
export function warmUpFoodImgs(foods) {
|
||||
const list = Array.isArray(foods) ? foods : []
|
||||
list.forEach((f) => {
|
||||
const icon = typeof f === 'string' ? f : f?.icon
|
||||
// 优先预热自定义图片素材,缺省回退 emoji PNG
|
||||
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
|
||||
if (!url) return
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.getImageInfo({ src: url, fail: () => {} })
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
try {
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 全量食材库: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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 关卡配置:面向 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
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# 识糖小课堂平台接入说明
|
||||
|
||||
## 已接入能力
|
||||
|
||||
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
|
||||
- 按周一日期、性别自动分配最多 7 人的同行组。
|
||||
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
|
||||
- 以真实平台昵称、头像、认糖数和本周最高分排序。
|
||||
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
|
||||
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
|
||||
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
|
||||
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
|
||||
|
||||
## 接口
|
||||
|
||||
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
|
||||
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key`、`learned_count`、`score`、`ended`。
|
||||
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
|
||||
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`。
|
||||
|
||||
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`。
|
||||
|
||||
## 部署顺序
|
||||
|
||||
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`。
|
||||
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php` 和 `TcmController.php`。
|
||||
3. 重新构建并上传小程序前端。
|
||||
|
||||
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
|
||||
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
|
||||
|
||||
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
|
||||
|
||||
## 上线前检查
|
||||
|
||||
- 用男女各两个测试账号进入,确认被分入对应性别组。
|
||||
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
|
||||
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
|
||||
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
|
||||
- 周一验证新周重新分组,旧周成绩不带入新周。
|
||||
@@ -1,43 +0,0 @@
|
||||
# 识糖小课堂独立功能包
|
||||
|
||||
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
|
||||
|
||||
## 目录内容
|
||||
|
||||
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
|
||||
- `game-endless.scss`:完整页面与动画样式。
|
||||
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
|
||||
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
|
||||
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
1. 复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录,并确保目标项目同时提供 `tongji/components/TongjiIcon.vue` 与 `tongji/utils/svgDataUrl.js`。
|
||||
2. 在目标项目 `pages.json` 的 `tongji` 分包 `pages` 数组中加入:
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "endless-game/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "识糖小课堂",
|
||||
"backgroundColor": "#eefbf4",
|
||||
"disableScroll": false,
|
||||
"enableShareAppMessage": true,
|
||||
"enableShareTimeline": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 使用 `/tongji/endless-game/index` 打开游戏。
|
||||
4. 微信小程序后台需要将 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com` 配置为合法的音频与图片下载域名。
|
||||
|
||||
## 说明
|
||||
|
||||
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
|
||||
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
|
||||
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
|
||||
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
|
||||
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`。
|
||||
- 本功能包依赖 uni-app Vue 3、`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
|
||||
- 食物风险等级和科普文案集中在 `index.vue` 的 `FOODS` 配置中,正式上线前仍需由医院医生审核。
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
|
||||
*/
|
||||
export function useGameAuth(proxy) {
|
||||
let loginPromise = null
|
||||
|
||||
function hasToken() {
|
||||
return !!String(uni.getStorageSync('token') || '').trim()
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
uni.removeStorageSync('token')
|
||||
}
|
||||
|
||||
function wxLoginCode() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithCode(code) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/login/mnpLogin',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
}, false)
|
||||
if (res?.code !== 1 || !res.data?.token) {
|
||||
clearToken()
|
||||
throw new Error(res?.msg || '登录失败')
|
||||
}
|
||||
uni.setStorageSync('token', res.data.token)
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
|
||||
async function verifyOrLogin() {
|
||||
if (hasToken()) {
|
||||
try {
|
||||
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
|
||||
if (res?.code !== -1) return true
|
||||
} catch (_) {
|
||||
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
|
||||
return true
|
||||
}
|
||||
clearToken()
|
||||
}
|
||||
const code = await wxLoginCode()
|
||||
return loginWithCode(code)
|
||||
}
|
||||
|
||||
async function ensureLoggedIn() {
|
||||
if (loginPromise) return loginPromise
|
||||
loginPromise = verifyOrLogin()
|
||||
.then(ok => !!ok)
|
||||
.catch(() => false)
|
||||
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
|
||||
.finally(() => { loginPromise = null })
|
||||
return loginPromise
|
||||
}
|
||||
|
||||
return { ensureLoggedIn }
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const EMPTY_BOARD = {
|
||||
week_start: '',
|
||||
week_end: '',
|
||||
sex_label: '同行',
|
||||
group_size: 7,
|
||||
member_count: 0,
|
||||
players: [],
|
||||
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
|
||||
invite_code: ''
|
||||
}
|
||||
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
|
||||
|
||||
function createSessionKey() {
|
||||
const random = Math.random().toString(36).slice(2, 12)
|
||||
return `game_${Date.now().toString(36)}_${random}`
|
||||
}
|
||||
|
||||
function readPendingPayloads() {
|
||||
try {
|
||||
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
|
||||
if (!Array.isArray(stored)) return []
|
||||
return stored.filter(item => (
|
||||
item
|
||||
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
|
||||
&& Number(item.learned_count) >= 0
|
||||
)).slice(-8)
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
|
||||
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
|
||||
*/
|
||||
export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
const connected = ref(false)
|
||||
const loading = ref(false)
|
||||
const syncStatus = ref('idle')
|
||||
const leaderboard = ref({ ...EMPTY_BOARD })
|
||||
const confirmedSessionLearned = ref(0)
|
||||
|
||||
let sessionKey = createSessionKey()
|
||||
let syncTimer = null
|
||||
let syncing = false
|
||||
let queuedPayloads = readPendingPayloads()
|
||||
let connectPromise = null
|
||||
|
||||
function persistPendingPayloads() {
|
||||
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
|
||||
}
|
||||
|
||||
async function api(request) {
|
||||
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
|
||||
return proxy.apiUrl(request, false)
|
||||
}
|
||||
|
||||
function applyLeaderboard(data, confirmedForSession = '') {
|
||||
if (!data || !Array.isArray(data.players)) return
|
||||
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
|
||||
if (!inactiveSessionResponse) {
|
||||
leaderboard.value = {
|
||||
...EMPTY_BOARD,
|
||||
...data,
|
||||
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
|
||||
players: data.players
|
||||
}
|
||||
}
|
||||
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
|
||||
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
|
||||
}
|
||||
connected.value = true
|
||||
syncStatus.value = 'synced'
|
||||
}
|
||||
|
||||
async function refreshLeaderboard() {
|
||||
const res = await api({
|
||||
url: '/api/tcm/gameWeeklyLeaderboard',
|
||||
method: 'GET'
|
||||
})
|
||||
if (res?.code !== 1 || !res.data) {
|
||||
throw new Error(res?.msg || '同行榜加载失败')
|
||||
}
|
||||
applyLeaderboard(res.data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function acceptShare(inviteCode) {
|
||||
const code = String(inviteCode || '').trim().toUpperCase()
|
||||
if (!code) return
|
||||
try {
|
||||
await api({
|
||||
url: '/api/tcm/gameAcceptShare',
|
||||
method: 'POST',
|
||||
data: { invite_code: code }
|
||||
})
|
||||
} catch (_) {
|
||||
// 分享关系是辅助能力,不阻断进入游戏。
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(inviteCode = '') {
|
||||
if (connectPromise) return connectPromise
|
||||
connectPromise = (async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const loggedIn = await ensureLoggedIn()
|
||||
if (!loggedIn) throw new Error('登录失败')
|
||||
await acceptShare(inviteCode)
|
||||
await refreshLeaderboard()
|
||||
if (queuedPayloads.length) flushProgress()
|
||||
return true
|
||||
} catch (_) {
|
||||
connected.value = false
|
||||
syncStatus.value = 'offline'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
connectPromise = null
|
||||
}
|
||||
})()
|
||||
return connectPromise
|
||||
}
|
||||
|
||||
function beginSession() {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
sessionKey = createSessionKey()
|
||||
confirmedSessionLearned.value = 0
|
||||
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
|
||||
return sessionKey
|
||||
}
|
||||
|
||||
function queueProgress(learnedCount, score, ended = false) {
|
||||
const nextPayload = {
|
||||
session_key: sessionKey,
|
||||
learned_count: Math.max(0, Number(learnedCount) || 0),
|
||||
score: Math.max(0, Number(score) || 0),
|
||||
ended: ended ? 1 : 0
|
||||
}
|
||||
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
|
||||
if (existingIndex >= 0) {
|
||||
const existing = queuedPayloads[existingIndex]
|
||||
queuedPayloads[existingIndex] = {
|
||||
...nextPayload,
|
||||
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
|
||||
score: Math.max(existing.score, nextPayload.score),
|
||||
ended: Math.max(existing.ended, nextPayload.ended)
|
||||
}
|
||||
} else {
|
||||
queuedPayloads.push(nextPayload)
|
||||
}
|
||||
persistPendingPayloads()
|
||||
syncStatus.value = 'pending'
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
if (ended) {
|
||||
flushProgress()
|
||||
} else {
|
||||
syncTimer = setTimeout(flushProgress, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function flushProgress() {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
if (syncing || !queuedPayloads.length) return
|
||||
const payload = queuedPayloads[0]
|
||||
syncing = true
|
||||
syncStatus.value = 'syncing'
|
||||
try {
|
||||
if (!connected.value) {
|
||||
const loggedIn = await ensureLoggedIn()
|
||||
if (!loggedIn) throw new Error('未登录')
|
||||
}
|
||||
const res = await api({
|
||||
url: '/api/tcm/gameSubmitProgress',
|
||||
method: 'POST',
|
||||
data: payload
|
||||
})
|
||||
if (res?.code !== 1 || !res.data) {
|
||||
throw new Error(res?.msg || '成绩保存失败')
|
||||
}
|
||||
applyLeaderboard(res.data, payload.session_key)
|
||||
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
|
||||
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
|
||||
queuedPayloads = queuedPayloads.filter(item => (
|
||||
item.session_key !== payload.session_key
|
||||
|| Number(item.learned_count) > Number(payload.learned_count)
|
||||
|| Number(item.score) > Number(payload.score)
|
||||
|| Number(item.ended) > Number(payload.ended)
|
||||
))
|
||||
persistPendingPayloads()
|
||||
} catch (_) {
|
||||
connected.value = false
|
||||
syncStatus.value = 'offline'
|
||||
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
|
||||
persistPendingPayloads()
|
||||
} finally {
|
||||
syncing = false
|
||||
if (queuedPayloads.length && connected.value) {
|
||||
setTimeout(flushProgress, 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAndRefresh(learnedCount, score) {
|
||||
queueProgress(learnedCount, score, false)
|
||||
await flushProgress()
|
||||
if (!connected.value) {
|
||||
await connect()
|
||||
if (queuedPayloads.length) await flushProgress()
|
||||
} else {
|
||||
try { await refreshLeaderboard() } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function recordShare() {
|
||||
try {
|
||||
if (!connected.value && !(await connect())) return
|
||||
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
|
||||
if (res?.code === 1 && res.data?.invite_code) {
|
||||
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
|
||||
}
|
||||
} catch (_) {
|
||||
// 分享本身仍可进行,统计失败不影响用户。
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
persistPendingPayloads()
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
loading,
|
||||
syncStatus,
|
||||
leaderboard,
|
||||
confirmedSessionLearned,
|
||||
connect,
|
||||
beginSession,
|
||||
queueProgress,
|
||||
flushProgress,
|
||||
syncAndRefresh,
|
||||
refreshLeaderboard,
|
||||
recordShare,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
.eg-page {
|
||||
min-height: 100vh;
|
||||
color: #173b32;
|
||||
background: linear-gradient(180deg, #eefbf4 0%, #f9f3dd 56%, #fffaf1 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
.eg-confetti-layer { position: fixed; z-index: 80; inset: 0; overflow: hidden; pointer-events: none; }
|
||||
.eg-confetti-piece {
|
||||
position: absolute;
|
||||
top: -70rpx;
|
||||
display: block;
|
||||
border-radius: 3rpx;
|
||||
box-shadow: 0 2rpx 4rpx rgba(0,0,0,.12);
|
||||
animation-name: egConfettiFall;
|
||||
animation-timing-function: cubic-bezier(.18,.72,.35,1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.eg-confetti-piece.is-emoji { width: auto !important; height: auto !important; background: transparent !important; box-shadow: none; font-size: 42rpx; }
|
||||
|
||||
.eg-nav {
|
||||
position: relative;
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
grid-template-columns: 156rpx minmax(0, 1fr) 156rpx;
|
||||
align-items: center;
|
||||
column-gap: 12rpx;
|
||||
padding-right: 28rpx;
|
||||
padding-bottom: 16rpx;
|
||||
padding-left: 28rpx;
|
||||
}
|
||||
|
||||
.eg-nav-leading { display: flex; width: 156rpx; justify-content: flex-start; }
|
||||
|
||||
.eg-nav-btn {
|
||||
display: flex;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid rgba(21, 94, 75, .12);
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255,255,255,.82);
|
||||
}
|
||||
|
||||
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
|
||||
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
|
||||
|
||||
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
|
||||
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
|
||||
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
|
||||
.eg-content { padding: 0 16rpx 48rpx; }
|
||||
|
||||
.eg-score-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
border: 2rpx solid rgba(21, 94, 75, .1);
|
||||
border-radius: 28rpx;
|
||||
background: rgba(255,255,255,.9);
|
||||
box-shadow: 0 10rpx 28rpx rgba(32, 78, 43, .08);
|
||||
}
|
||||
|
||||
.eg-stat { display: flex; flex: 1; flex-direction: column; align-items: center; }
|
||||
.eg-stat--main { border-right: 2rpx solid #e2eee8; border-left: 2rpx solid #e2eee8; }
|
||||
.eg-stat-label { color: #71847d; font-size: 24rpx; }
|
||||
.eg-stat-value { margin-top: 4rpx; color: #204e2b; font-size: 38rpx; font-weight: 800; }
|
||||
.eg-stat-score { margin-top: 2rpx; color: #e16f24; font-size: 46rpx; font-weight: 900; }
|
||||
.eg-stat--rank { position: relative; cursor: pointer; }
|
||||
.eg-stat--rank.is-locked { opacity: .52; }
|
||||
.eg-stat-rank-value { margin-top: 1rpx; color: #176b52; font-size: 35rpx; font-weight: 1000; line-height: 1.08; }
|
||||
.eg-stat-rank-hint { margin-top: 2rpx; color: #df7427; font-size: 18rpx; font-weight: 800; }
|
||||
|
||||
.eg-task-card {
|
||||
margin-bottom: 16rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 249, 223, .94);
|
||||
box-shadow: 0 8rpx 22rpx rgba(118, 89, 25, .08);
|
||||
}
|
||||
.eg-task-card { position: relative; overflow: visible; }
|
||||
|
||||
.eg-task-reward-flight {
|
||||
position: absolute;
|
||||
z-index: 72;
|
||||
top: 4rpx;
|
||||
left: 42%;
|
||||
display: flex;
|
||||
width: 112rpx;
|
||||
height: 92rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 4rpx solid rgba(255,255,255,.96);
|
||||
border-radius: 24rpx;
|
||||
background: #fff7e8;
|
||||
box-shadow: 0 10rpx 24rpx rgba(159,94,23,.28);
|
||||
animation: egTaskRewardFlight 1.12s cubic-bezier(.18,.78,.22,1) both;
|
||||
pointer-events: none;
|
||||
}
|
||||
.eg-task-reward-flight > image { position: relative; z-index: 2; width: 78rpx; height: 66rpx; }
|
||||
.eg-task-reward-flight > text { position: absolute; z-index: 3; top: -14rpx; right: -16rpx; display: flex; width: 48rpx; height: 48rpx; align-items: center; justify-content: center; border: 4rpx solid #fff; border-radius: 50%; color: #fff; background: #ed7625; box-shadow: 0 5rpx 12rpx rgba(191,75,19,.3); font-size: 25rpx; font-weight: 1000; }
|
||||
.eg-task-reward-glow { position: absolute; z-index: 1; inset: -15rpx; border-radius: 32rpx; background: radial-gradient(circle, rgba(255,210,67,.52), rgba(255,210,67,0) 68%); animation: egRewardGlow .42s ease-in-out infinite alternate; }
|
||||
|
||||
.eg-task-head { display: flex; align-items: center; justify-content: space-between; color: #7c5b1b; font-size: 27rpx; font-weight: 700; }
|
||||
.eg-task-name { display: flex; align-items: center; gap: 10rpx; }
|
||||
.eg-task-count { color: #9a6718; font-size: 29rpx; }
|
||||
.eg-progress { height: 14rpx; margin-top: 12rpx; overflow: hidden; border-radius: 999rpx; background: #eadfbd; }
|
||||
.eg-progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #f0a128, #f4c34f); transition: width .25s; }
|
||||
|
||||
.eg-board-shell {
|
||||
position: relative;
|
||||
padding: 14rpx;
|
||||
border: 2rpx solid rgba(34, 103, 79, .16);
|
||||
border-radius: 34rpx;
|
||||
background: rgba(255,255,255,.92);
|
||||
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
|
||||
}
|
||||
|
||||
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
|
||||
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
|
||||
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
|
||||
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
|
||||
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
|
||||
.eg-risk-dots { display: flex; gap: 5rpx; }
|
||||
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
|
||||
.eg-risk-dot.on { background: #e96b3b; box-shadow: 0 0 0 3rpx rgba(233,107,59,.12); }
|
||||
.eg-board { display: flex; flex-direction: column; gap: 8rpx; opacity: 1; transition: opacity .15s; }
|
||||
.eg-board.is-busy { opacity: .82; }
|
||||
.eg-row { display: flex; gap: 8rpx; }
|
||||
|
||||
.eg-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 166rpx;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 5rpx solid transparent;
|
||||
border-radius: 26rpx;
|
||||
box-shadow: inset 0 -5rpx 0 rgba(0,0,0,.05), 0 5rpx 12rpx rgba(31, 66, 53, .08);
|
||||
transition: transform .16s cubic-bezier(.2,.8,.2,1), border-color .15s, opacity .16s, filter .16s;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.eg-cell.is-dragging { transition: transform .055s linear, border-color .15s; }
|
||||
.eg-cell.is-selected { z-index: 2; border-color: #167d61; transform: scale(1.06); box-shadow: 0 0 0 6rpx rgba(22,125,97,.14); }
|
||||
.eg-cell.is-camel-target { border-color: #f59e0b; animation: egPulse 1s infinite; }
|
||||
.eg-replace-target { position: absolute; z-index: 7; right: 7rpx; bottom: 6rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; background: #1570a6; box-shadow: 0 3rpx 9rpx rgba(20,91,135,.28); font-size: 20rpx; font-weight: 900; }
|
||||
.eg-cell.is-clearing {
|
||||
z-index: 8;
|
||||
border-color: #fff;
|
||||
animation: egHit .22s cubic-bezier(.2,.9,.3,1) forwards;
|
||||
filter: brightness(1.28) saturate(1.25);
|
||||
}
|
||||
.eg-cell.is-yellow,
|
||||
.eg-cell.is-cream,
|
||||
.eg-cell.is-red,
|
||||
.eg-cell.is-purple { background: #fffaf0; }
|
||||
.eg-cell.is-level-low { background: #f4faf6; }
|
||||
.eg-cell.is-level-mid { background: #fff9eb; }
|
||||
.eg-cell.is-orange,
|
||||
.eg-cell.is-level-high { background: #fff0e4; }
|
||||
.eg-cell.is-milk { background: #edf7ff; }
|
||||
.eg-cell.is-high { border-color: #ef7b45; }
|
||||
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
|
||||
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
|
||||
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
|
||||
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
|
||||
.eg-sugar-tag.is-mid { color: #735a15; border-color: rgba(255,255,255,.9); background: #f7e7a8; box-shadow: 0 2rpx 6rpx rgba(137,91,0,.15); }
|
||||
.eg-sugar-tag.is-high { background: #d9431f; box-shadow: 0 4rpx 10rpx rgba(163,45,21,.32); }
|
||||
.eg-sugar-tag.is-prop { background: #2563a8; }
|
||||
.eg-special-mark { position: absolute; z-index: 12; bottom: 5rpx; left: 6rpx; display: flex; width: 40rpx; height: 40rpx; align-items: center; justify-content: center; border: 3rpx solid rgba(255,255,255,.96); border-radius: 50%; color: #fff; background: #6d28d9; box-shadow: 0 4rpx 11rpx rgba(73,31,130,.3); font-size: 23rpx; font-weight: 900; }
|
||||
.eg-special-mark.is-row { width: 58rpx; height: 40rpx; border-radius: 999rpx; background: linear-gradient(135deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
|
||||
.eg-special-mark.is-col { width: 40rpx; height: 58rpx; border-radius: 999rpx; background: linear-gradient(180deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
|
||||
.eg-special-mark.is-burst { width: 48rpx; height: 48rpx; border-color: #fff3a8; border-radius: 15rpx 50% 50%; background: linear-gradient(145deg, #7c3aed 5%, #dd3b70 48%, #ff8a18 100%); box-shadow: 0 4rpx 13rpx rgba(108,35,155,.38), 0 0 12rpx rgba(255,179,38,.3); animation: egLReady 1.25s ease-in-out infinite alternate; }
|
||||
.eg-mini-flame { position: relative; width: 25rpx; height: 30rpx; border-radius: 70% 32% 68% 40%; background: linear-gradient(135deg, #fff8a8 8%, #ffc21f 42%, #f04420 84%); box-shadow: 0 0 14rpx rgba(255,230,92,.95); transform: rotate(43deg); }
|
||||
.eg-mini-flame-core { position: absolute; right: 4rpx; bottom: 3rpx; width: 10rpx; height: 15rpx; border-radius: 70% 35% 70% 42%; background: #fffbd1; box-shadow: 0 0 7rpx rgba(255,255,210,.95); }
|
||||
.eg-l-special { position: relative; width: 29rpx; height: 29rpx; filter: drop-shadow(0 1rpx 2rpx rgba(73,22,105,.3)); }
|
||||
.eg-l-arm { position: absolute; left: 3rpx; bottom: 3rpx; border-radius: 999rpx; background: #fff9b8; box-shadow: 0 0 6rpx rgba(255,249,184,.92); }
|
||||
.eg-l-arm.is-vertical { width: 8rpx; height: 25rpx; }
|
||||
.eg-l-arm.is-horizontal { width: 25rpx; height: 8rpx; }
|
||||
.eg-l-core { position: absolute; left: 1rpx; bottom: 1rpx; width: 12rpx; height: 12rpx; border: 2rpx solid #fff; border-radius: 50%; background: #ff5b25; }
|
||||
.eg-cell.is-fire-clearing { border-color: #ff7a1a; box-shadow: inset 0 0 25rpx rgba(255,102,18,.36), 0 0 18rpx rgba(255,105,20,.48); filter: brightness(1.35) saturate(1.42); }
|
||||
.eg-cell.is-clearing.is-fire-clearing { animation: egFireCellHit .36s cubic-bezier(.2,.78,.22,1) forwards; }
|
||||
.eg-fire-clear { position: absolute; z-index: 14; inset: 2rpx; overflow: hidden; border: 3rpx solid rgba(255,210,71,.92); border-radius: 22rpx; opacity: .96; background: linear-gradient(180deg, rgba(255,230,86,.12), rgba(239,63,27,.22)); pointer-events: none; }
|
||||
.eg-fire-glow { position: absolute; inset: 6%; border-radius: 18rpx; background: radial-gradient(circle, rgba(255,246,177,.74), rgba(255,131,22,.32) 47%, rgba(222,47,23,0) 75%); animation: egFireGlow .34s ease-out both; }
|
||||
.eg-fire-sweep { position: absolute; top: 23%; left: -48%; width: 168%; height: 55%; border-radius: 60% 45% 55% 42%; background: linear-gradient(90deg, rgba(255,184,38,0), rgba(255,238,124,.94) 28%, rgba(255,133,22,.92) 53%, rgba(226,54,25,.82) 72%, rgba(255,184,38,0)); box-shadow: 0 0 27rpx rgba(255,91,17,.72); animation: egFireSweep .34s cubic-bezier(.2,.78,.22,1) both; }
|
||||
.eg-fire-flame { position: absolute; bottom: 13%; width: 25rpx; height: 39rpx; border-radius: 72% 35% 68% 42%; background: linear-gradient(145deg, #fff7a6 4%, #ffc329 39%, #ff7419 66%, #dc321f 100%); box-shadow: 0 0 15rpx rgba(255,124,21,.88); transform: rotate(42deg); animation: egFireFlame .34s ease-out both; }
|
||||
.eg-fire-flame > view { position: absolute; right: 5rpx; bottom: 4rpx; width: 10rpx; height: 18rpx; border-radius: 70% 35% 70% 42%; background: #fffbd6; }
|
||||
.eg-fire-flame.is-left { left: 16%; animation-delay: .01s; }
|
||||
.eg-fire-flame.is-center { left: 43%; bottom: 19%; transform: rotate(42deg) scale(1.18); animation-delay: .035s; }
|
||||
.eg-fire-flame.is-right { right: 15%; animation-delay: .065s; }
|
||||
.eg-fire-spark { position: absolute; width: 10rpx; height: 17rpx; border-radius: 70% 35% 70% 42%; background: #ffe45e; box-shadow: 0 0 11rpx rgba(255,139,28,.9); animation: egFireSpark .34s ease-out both; }
|
||||
.eg-fire-spark.is-one { top: 46%; left: 25%; }
|
||||
.eg-fire-spark.is-two { top: 34%; left: 62%; animation-delay: .035s; }
|
||||
.eg-fire-spark.is-three { top: 52%; left: 78%; animation-delay: .07s; }
|
||||
.eg-board-tip { display: flex; align-items: center; justify-content: center; gap: 8rpx; min-height: 58rpx; margin-top: 12rpx; color: #684817; font-size: 26rpx; font-weight: 700; text-align: center; }
|
||||
.eg-impact-text {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 46%;
|
||||
left: 50%;
|
||||
padding: 10rpx 24rpx;
|
||||
border: 4rpx solid rgba(255,255,255,.9);
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #f59e0b, #e4572e);
|
||||
box-shadow: 0 10rpx 28rpx rgba(190, 70, 25, .35);
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: egImpact .52s cubic-bezier(.16,.82,.3,1) forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
.eg-impact-text.is-level-2 { font-size: 40rpx; background: linear-gradient(135deg, #8b5cf6, #ec4899); }
|
||||
.eg-impact-text.is-level-3 { font-size: 46rpx; background: linear-gradient(135deg, #ef4444, #f59e0b); box-shadow: 0 12rpx 36rpx rgba(239,68,68,.42); }
|
||||
.eg-jackpot {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 48%;
|
||||
left: 50%;
|
||||
width: 540rpx;
|
||||
padding: 24rpx 22rpx 20rpx;
|
||||
overflow: hidden;
|
||||
border: 7rpx solid #ffd83d;
|
||||
border-radius: 36rpx;
|
||||
color: #fff;
|
||||
background: radial-gradient(circle at 50% 0%, #ef4444, #9f1239 65%, #64102a);
|
||||
box-shadow: 0 0 0 7rpx #fff2a8, 0 22rpx 52rpx rgba(100,16,42,.5);
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: egJackpotIn 1.35s cubic-bezier(.16,.85,.25,1) forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
.eg-jackpot.is-match-4 { background: radial-gradient(circle at 50% 0%, #8b5cf6, #5b21b6 68%, #35106d); }
|
||||
.eg-jackpot.is-match-5 { border-color: #fff36a; background: radial-gradient(circle at 50% 0%, #ff6b24, #d7193f 58%, #760c2a); animation-duration: 1.9s; }
|
||||
.eg-jackpot.is-match-3 {
|
||||
width: 414rpx;
|
||||
padding: 20rpx 22rpx 18rpx;
|
||||
border-width: 4rpx;
|
||||
border-color: #f1c663;
|
||||
color: #31574b;
|
||||
background: linear-gradient(155deg, #f7fff9, #fff5d9);
|
||||
box-shadow: 0 0 0 4rpx rgba(255,255,255,.84), 0 14rpx 34rpx rgba(98,82,39,.2);
|
||||
animation: egTripleIn 1.12s cubic-bezier(.2,.82,.28,1) forwards;
|
||||
}
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-lights { display: none; }
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-kicker { color: #5b8c73; font-size: 21rpx; letter-spacing: 3rpx; text-shadow: none; }
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-title { color: #365b4e; font-size: 31rpx; text-shadow: none; }
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-reward { color: #ad7021; font-size: 25rpx; }
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-note { color: #829087; }
|
||||
.eg-triple-cans { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 12rpx 0 10rpx; }
|
||||
.eg-triple-can { display: flex; width: 78rpx; height: 76rpx; align-items: center; justify-content: center; border: 3rpx solid #f4d996; border-radius: 19rpx; background: rgba(255,255,255,.9); box-shadow: 0 5rpx 12rpx rgba(128,92,30,.12); animation: egTripleCan .42s cubic-bezier(.2,.86,.26,1) both; }
|
||||
.eg-triple-can:nth-child(2) { animation-delay: .07s; }
|
||||
.eg-triple-can:nth-child(3) { animation-delay: .14s; }
|
||||
.eg-triple-can > image { width: 62rpx; height: 58rpx; }
|
||||
|
||||
.eg-jackpot.is-match-4 {
|
||||
width: 548rpx;
|
||||
padding: 25rpx 22rpx 22rpx;
|
||||
border-color: #ffd95b;
|
||||
background: radial-gradient(circle at 50% -10%, #a97cff, #6031b5 58%, #341066);
|
||||
box-shadow: 0 0 0 6rpx #fff0a9, 0 0 36rpx rgba(255,210,76,.5), 0 24rpx 58rpx rgba(53,16,109,.48);
|
||||
animation: egFourIn 1.88s cubic-bezier(.16,.84,.24,1) forwards;
|
||||
}
|
||||
.eg-jackpot.is-match-4 .eg-jackpot-kicker { color: #ffef8d; font-size: 24rpx; letter-spacing: 3rpx; }
|
||||
.eg-jackpot.is-match-4 .eg-jackpot-title { margin-top: 9rpx; color: #fff; font-size: 39rpx; }
|
||||
.eg-jackpot.is-match-4 .eg-jackpot-reward { color: #fff2a8; }
|
||||
.eg-four-teaser { position: relative; margin-top: 13rpx; }
|
||||
.eg-four-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
|
||||
.eg-four-can { display: flex; width: 76rpx; height: 92rpx; align-items: center; justify-content: center; border: 4rpx solid #ffe078; border-radius: 18rpx; background: linear-gradient(180deg, #fffef7, #ffe9a5); box-shadow: inset 0 -5rpx 0 rgba(171,106,17,.12), 0 7rpx 13rpx rgba(34,12,67,.28); animation: egFourCan .48s cubic-bezier(.18,.9,.25,1.2) both; }
|
||||
.eg-four-can:nth-child(2) { animation-delay: .07s; }
|
||||
.eg-four-can:nth-child(3) { animation-delay: .14s; }
|
||||
.eg-four-can:nth-child(4) { animation-delay: .21s; }
|
||||
.eg-four-can > image { width: 64rpx; height: 70rpx; }
|
||||
.eg-four-can.is-locked { border-style: dashed; border-color: rgba(255,241,161,.86); color: #fff6ae; background: rgba(48,18,91,.54); box-shadow: inset 0 0 16rpx rgba(255,225,91,.18), 0 0 18rpx rgba(255,222,91,.36); font-size: 48rpx; font-weight: 1000; animation: egLockedCan .68s .34s ease-in-out infinite alternate; }
|
||||
.eg-four-promise { display: inline-flex; margin-top: 15rpx; padding: 8rpx 18rpx; border: 2rpx solid rgba(255,247,184,.72); border-radius: 999rpx; color: #3d176f; background: linear-gradient(90deg, #fff3a3, #ffd85b); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); font-size: 25rpx; font-weight: 1000; animation: egPromisePulse .72s ease-in-out infinite alternate; }
|
||||
.eg-five-celebration { position: relative; margin: 15rpx 0 10rpx; }
|
||||
.eg-five-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
|
||||
.eg-five-can { display: flex; width: 78rpx; height: 93rpx; align-items: center; justify-content: center; border: 4rpx solid #fff17b; border-radius: 18rpx; background: linear-gradient(180deg, #fff, #fff2ac); box-shadow: inset 0 -5rpx 0 rgba(184,91,12,.13), 0 7rpx 0 #bd5b13, 0 0 16rpx rgba(255,240,101,.5); animation: egFiveCan .56s cubic-bezier(.16,.92,.24,1.2) both; }
|
||||
.eg-five-can:nth-child(2) { animation-delay: .07s; }
|
||||
.eg-five-can:nth-child(3) { animation-delay: .14s; }
|
||||
.eg-five-can:nth-child(4) { animation-delay: .21s; }
|
||||
.eg-five-can:nth-child(5) { animation-delay: .28s; }
|
||||
.eg-five-can > image { width: 65rpx; height: 72rpx; }
|
||||
.eg-five-sevens { display: flex; justify-content: center; gap: 8rpx; margin-top: 13rpx; }
|
||||
.eg-five-sevens > text { display: flex; width: 58rpx; height: 55rpx; align-items: center; justify-content: center; border: 3rpx solid #fff5a7; border-radius: 13rpx; color: #fff36a; background: linear-gradient(155deg, #d7193f, #9f1239); box-shadow: 0 5rpx 0 #70102b, 0 0 13rpx rgba(255,243,106,.45); font-size: 43rpx; font-weight: 1000; line-height: 1; text-shadow: 0 3rpx 0 #8f1733; }
|
||||
.eg-jackpot-lights { position: absolute; inset: 8rpx; border: 4rpx dotted rgba(255,255,255,.88); border-radius: 25rpx; animation: egLights .24s steps(2) infinite; }
|
||||
.eg-jackpot-kicker { position: relative; display: block; color: #fff36a; font-size: 28rpx; font-weight: 900; letter-spacing: 6rpx; text-shadow: 0 3rpx 0 rgba(83,19,25,.45); }
|
||||
.eg-jackpot-reels { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 13rpx 0; }
|
||||
.eg-jackpot-reel { display: flex; width: 112rpx; height: 126rpx; flex-direction: column; align-items: center; justify-content: center; border: 6rpx solid #ffcf31; border-radius: 20rpx; background: linear-gradient(180deg, #fff 0%, #fff7cf 48%, #ffd55e 50%, #fff 53%, #fff8dc 100%); box-shadow: inset 0 0 18rpx rgba(137,78,10,.25), 0 7rpx 0 #a85a0a; animation: egReelStop .52s cubic-bezier(.2,.9,.25,1) both; }
|
||||
.eg-jackpot-reel:nth-child(2) { animation-delay: .09s; }
|
||||
.eg-jackpot-reel:nth-child(3) { animation-delay: .18s; }
|
||||
.eg-jackpot-seven { color: #e11d48; font-size: 61rpx; font-weight: 1000; line-height: .85; text-shadow: 0 3rpx 0 #ffd1d8; }
|
||||
.eg-jackpot-milk { width: 54rpx; height: 36rpx; margin-top: 3rpx; border-radius: 7rpx; }
|
||||
.eg-jackpot-title { position: relative; display: block; font-size: 37rpx; font-weight: 900; text-shadow: 0 4rpx 0 rgba(73,10,30,.5); }
|
||||
.eg-jackpot-points { position: relative; display: block; margin-top: 2rpx; color: #fff36a; font-size: 66rpx; font-weight: 1000; line-height: 1.05; letter-spacing: 2rpx; text-shadow: 0 5rpx 0 #9e2813, 0 0 18rpx rgba(255,243,106,.85); animation: egPointsPop .62s .42s cubic-bezier(.16,.9,.25,1.25) both; }
|
||||
.eg-jackpot-reward { position: relative; display: block; margin-top: 7rpx; color: #fff5a5; font-size: 27rpx; font-weight: 800; line-height: 1.35; }
|
||||
.eg-jackpot-note { position: relative; display: block; margin-top: 8rpx; color: rgba(255,255,255,.8); font-size: 22rpx; }
|
||||
.eg-tools { margin-top: 16rpx; }
|
||||
.eg-tool { display: flex; align-items: center; padding: 16rpx 18rpx; border: 3rpx solid #c9e0d6; border-radius: 26rpx; background: rgba(255,255,255,.92); box-shadow: 0 7rpx 18rpx rgba(24, 84, 63, .07); }
|
||||
.eg-tool.active { border-color: #f59e0b; background: #fffbeb; }
|
||||
.eg-tool.disabled { opacity: .45; }
|
||||
.eg-tool-icon { position: relative; display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; overflow: visible; border-radius: 24rpx; font-size: 46rpx; background: linear-gradient(145deg, #e8f7ff, #d4ecff); box-shadow: inset 0 -4rpx 0 rgba(33,104,151,.08); }
|
||||
.eg-tool-food-image { width: 76rpx; height: 58rpx; border-radius: 14rpx; }
|
||||
.eg-replace-badge { position: absolute; right: -12rpx; bottom: -8rpx; display: flex; min-width: 78rpx; height: 38rpx; align-items: center; justify-content: center; gap: 2rpx; padding: 0 8rpx; border: 3rpx solid #fff; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #2389b9, #145d91); box-shadow: 0 5rpx 12rpx rgba(20,93,145,.3); }
|
||||
.eg-replace-badge .tj-icon-wrap { width: 22rpx; height: 22rpx; }
|
||||
.eg-replace-badge > text { font-size: 20rpx; font-weight: 900; line-height: 1; }
|
||||
.eg-tool-copy { display: flex; flex: 1; flex-direction: column; margin-left: 16rpx; }
|
||||
.eg-tool-name { color: #244a3d; font-size: 29rpx; font-weight: 800; }
|
||||
.eg-tool-desc { margin-top: 5rpx; color: #6c8179; font-size: 25rpx; }
|
||||
.eg-tool-count { color: #155e4b; font-size: 32rpx; font-weight: 900; }
|
||||
.eg-legend { display: flex; justify-content: center; gap: 18rpx; margin-top: 18rpx; color: #53685f; font-size: 25rpx; font-weight: 700; }
|
||||
.eg-dot--safe { color: #4cae7b; }
|
||||
.eg-dot--mid { color: #e2aa19; }
|
||||
.eg-dot--high { color: #e4572e; }
|
||||
|
||||
.eg-weekly-overlay { position: fixed; z-index: 90; inset: 0; display: flex; align-items: center; justify-content: center; padding: 28rpx; background: rgba(16, 55, 43, .66); backdrop-filter: blur(5px); }
|
||||
.eg-weekly-card { width: 100%; max-width: 664rpx; max-height: calc(100vh - 56rpx); overflow-y: auto; padding: 28rpx 26rpx 22rpx; border: 5rpx solid rgba(255,255,255,.96); border-radius: 38rpx; background: linear-gradient(160deg, #f5fff9 0%, #fffdf3 56%, #fff4db 100%); box-shadow: 0 30rpx 80rpx rgba(5,45,32,.38); animation: egWeeklyIn .36s cubic-bezier(.18,.88,.27,1.08) both; }
|
||||
.eg-weekly-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
|
||||
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
|
||||
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
|
||||
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
|
||||
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
|
||||
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
|
||||
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
|
||||
.eg-weekly-progress-label { display: block; color: #657b73; font-size: 22rpx; font-weight: 700; }
|
||||
.eg-weekly-progress-value { display: block; margin-top: -2rpx; color: #e06b24; font-size: 43rpx; font-weight: 1000; }
|
||||
.eg-weekly-progress-copy { display: flex; flex-direction: column; align-items: flex-end; color: #235b49; font-size: 24rpx; font-weight: 900; line-height: 1.55; }
|
||||
.eg-weekly-progress-copy text:last-child { color: #b86820; }
|
||||
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
|
||||
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
|
||||
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
|
||||
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
|
||||
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
|
||||
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
|
||||
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
|
||||
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
|
||||
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
|
||||
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
|
||||
.eg-weekly-avatar.is-tone-5 { background: #a673bd; }
|
||||
.eg-weekly-avatar.is-tone-6 { background: #448e9c; }
|
||||
.eg-weekly-avatar.is-tone-7 { background: #c27b49; }
|
||||
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
|
||||
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
|
||||
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
|
||||
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
|
||||
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
|
||||
.eg-cheer-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.eg-cheer-source { color: #8e5c18; font-size: 21rpx; font-weight: 900; }
|
||||
.eg-cheer-card.is-family .eg-cheer-source { color: #77539c; }
|
||||
.eg-cheer-change { padding: 5rpx 12rpx; border-radius: 999rpx; color: #176b52; background: rgba(255,255,255,.75); font-size: 19rpx; font-weight: 900; }
|
||||
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
|
||||
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
|
||||
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
|
||||
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
|
||||
.eg-weekly-share::after { border: 0; }
|
||||
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
|
||||
|
||||
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
|
||||
.eg-stage-card { width: 100%; max-width: 620rpx; padding: 34rpx 28rpx 30rpx; border: 6rpx solid #fff; border-radius: 38rpx; background: linear-gradient(155deg, #f5fff9, #fff8da); box-shadow: 0 26rpx 70rpx rgba(10,56,41,.3); text-align: center; animation: egStageIn 1.8s cubic-bezier(.16,.86,.28,1) both; }
|
||||
.eg-stage-kicker { display: block; color: #16815f; font-size: 23rpx; font-weight: 900; letter-spacing: 3rpx; }
|
||||
.eg-stage-number { display: block; margin-top: 5rpx; color: #e47722; font-size: 48rpx; font-weight: 1000; line-height: 1.1; }
|
||||
.eg-stage-title { display: block; margin-top: 7rpx; color: #194e3d; font-size: 38rpx; font-weight: 900; }
|
||||
.eg-stage-foods { display: flex; justify-content: center; gap: 9rpx; margin-top: 22rpx; }
|
||||
.eg-stage-food { display: flex; width: 92rpx; min-height: 110rpx; flex-direction: column; align-items: center; justify-content: center; border: 3rpx solid #dbece4; border-radius: 20rpx; color: #315b4e; background: #fff; font-size: 20rpx; font-weight: 800; }
|
||||
.eg-stage-food > image { width: 72rpx; height: 52rpx; border-radius: 12rpx; }
|
||||
.eg-stage-food > text { margin-top: 8rpx; }
|
||||
.eg-stage-food.is-danger { border-color: #ef8b52; color: #a43c20; background: #fff1e6; box-shadow: 0 0 0 4rpx rgba(239,139,82,.12); }
|
||||
.eg-stage-tip { display: block; margin-top: 20rpx; color: #6e776d; font-size: 21rpx; font-weight: 700; }
|
||||
.eg-reshuffle-overlay { position: fixed; z-index: 65; inset: 0; display: flex; align-items: center; justify-content: center; padding: 46rpx; background: rgba(19,55,45,.5); }
|
||||
.eg-reshuffle-card { width: 100%; max-width: 540rpx; padding: 38rpx 30rpx; border: 5rpx solid rgba(255,255,255,.95); border-radius: 36rpx; background: linear-gradient(155deg, #f4fff9, #e6f6ef); box-shadow: 0 24rpx 64rpx rgba(10,48,37,.3); text-align: center; }
|
||||
.eg-reshuffle-icon { display: flex; width: 94rpx; height: 94rpx; align-items: center; justify-content: center; margin: 0 auto 18rpx; border-radius: 50%; background: linear-gradient(135deg, #2aa87b, #126a50); box-shadow: 0 10rpx 24rpx rgba(18,106,80,.28); animation: egReshuffleSpin .85s linear infinite; }
|
||||
.eg-reshuffle-title { display: block; color: #174e3d; font-size: 36rpx; font-weight: 900; }
|
||||
.eg-reshuffle-desc { display: block; margin-top: 10rpx; color: #5b746b; font-size: 24rpx; font-weight: 700; }
|
||||
|
||||
.eg-overlay { position: fixed; z-index: 50; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; padding: 36rpx; background: rgba(20, 48, 40, .62); }
|
||||
.eg-modal { width: 100%; max-width: 650rpx; padding: 34rpx 28rpx 28rpx; border-radius: 38rpx; background: #fffdf8; box-shadow: 0 28rpx 70rpx rgba(0,0,0,.22); }
|
||||
.eg-modal-icon { display: block; font-size: 68rpx; text-align: center; }
|
||||
.eg-modal-title { display: block; margin-top: 6rpx; color: #204e3d; font-size: 38rpx; font-weight: 900; text-align: center; }
|
||||
.eg-modal-sub { display: block; margin: 8rpx 0 22rpx; color: #718078; font-size: 24rpx; text-align: center; }
|
||||
.eg-result-warning { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; margin: 0 auto; border-radius: 50%; color: #fff; background: #e4572e; font-size: 60rpx; font-weight: 900; }
|
||||
.eg-result-food { display: block; margin: 12rpx 0 18rpx; color: #c34b27; font-size: 28rpx; font-weight: 800; text-align: center; }
|
||||
.eg-knowledge-card { padding: 22rpx; border-radius: 24rpx; background: #fff3e7; }
|
||||
.eg-knowledge-title { display: block; color: #8d4427; font-size: 26rpx; font-weight: 800; }
|
||||
.eg-knowledge-copy { display: block; margin-top: 9rpx; color: #5f514a; font-size: 23rpx; line-height: 1.65; }
|
||||
.eg-knowledge-review { display: block; margin-top: 12rpx; color: #9b7768; font-size: 19rpx; }
|
||||
.eg-result-score { display: flex; align-items: center; justify-content: space-between; margin: 20rpx 6rpx; color: #466258; font-size: 25rpx; }
|
||||
.eg-result-score text:last-child { color: #e16f24; font-size: 36rpx; font-weight: 900; }
|
||||
.eg-result-best { display: flex; align-items: center; justify-content: space-between; margin: -12rpx 6rpx 20rpx; color: #75867f; font-size: 22rpx; }
|
||||
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
|
||||
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
|
||||
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
|
||||
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
|
||||
.eg-share-btn::after { border: 0; }
|
||||
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
|
||||
@keyframes egPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
|
||||
50% { box-shadow: 0 0 0 8rpx rgba(245,158,11,.12); }
|
||||
}
|
||||
|
||||
@keyframes egWeeklyIn {
|
||||
0% { opacity: 0; transform: translateY(34rpx) scale(.91); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egHit {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
42% { opacity: 1; transform: scale(1.18) rotate(-3deg); }
|
||||
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
|
||||
}
|
||||
|
||||
@keyframes egTaskRewardFlight {
|
||||
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
|
||||
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
|
||||
72% { opacity: 1; transform: translate3d(270rpx, 900rpx, 0) scale(.68) rotate(8deg); }
|
||||
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egRewardGlow {
|
||||
from { opacity: .4; transform: scale(.86); }
|
||||
to { opacity: .9; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
@keyframes egFireSweep {
|
||||
0% { opacity: 0; transform: translate3d(-38%, 20rpx, 0) scaleX(.6); }
|
||||
28% { opacity: 1; }
|
||||
72% { opacity: .96; }
|
||||
100% { opacity: 0; transform: translate3d(58%, -15rpx, 0) scaleX(1.08); }
|
||||
}
|
||||
|
||||
@keyframes egFireSpark {
|
||||
0% { opacity: 0; transform: translateY(14rpx) scale(.5) rotate(35deg); }
|
||||
38% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(-38rpx) scale(1.05) rotate(58deg); }
|
||||
}
|
||||
|
||||
@keyframes egFireGlow {
|
||||
0% { opacity: 0; transform: scale(.72); }
|
||||
38% { opacity: 1; transform: scale(1.08); }
|
||||
100% { opacity: 0; transform: scale(1.28); }
|
||||
}
|
||||
|
||||
@keyframes egFireCellHit {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
24% { opacity: 1; transform: scale(1.09); }
|
||||
72% { opacity: 1; transform: scale(1.02); }
|
||||
100% { opacity: 0; transform: scale(.48); }
|
||||
}
|
||||
|
||||
@keyframes egFireFlame {
|
||||
0% { opacity: 0; transform: translateY(20rpx) rotate(42deg) scale(.55); }
|
||||
38% { opacity: 1; transform: translateY(0) rotate(42deg) scale(1.05); }
|
||||
100% { opacity: 0; transform: translateY(-31rpx) rotate(48deg) scale(.86); }
|
||||
}
|
||||
|
||||
@keyframes egFlameReady {
|
||||
from { transform: scale(.94); filter: brightness(.96); }
|
||||
to { transform: scale(1.08); filter: brightness(1.18); }
|
||||
}
|
||||
|
||||
@keyframes egLReady {
|
||||
from { transform: scale(.94) rotate(-2deg); filter: brightness(.96); }
|
||||
to { transform: scale(1.06) rotate(2deg); filter: brightness(1.12); }
|
||||
}
|
||||
|
||||
@keyframes egImpact {
|
||||
0% { opacity: 0; transform: translate(-50%, -42%) scale(.55); }
|
||||
35% { opacity: 1; transform: translate(-50%, -50%) scale(1.16); }
|
||||
72% { opacity: 1; transform: translate(-50%, -54%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -76%) scale(.92); }
|
||||
}
|
||||
|
||||
@keyframes egJackpotIn {
|
||||
0% { opacity: 0; transform: translate(-50%, -45%) scale(.48) rotate(-4deg); }
|
||||
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.09) rotate(2deg); }
|
||||
31% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
|
||||
78% { opacity: 1; transform: translate(-50%, -53%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -68%) scale(.92); }
|
||||
}
|
||||
|
||||
@keyframes egTripleIn {
|
||||
0% { opacity: 0; transform: translate(-50%, -44%) scale(.82); }
|
||||
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
|
||||
74% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -60%) scale(.96); }
|
||||
}
|
||||
|
||||
@keyframes egTripleCan {
|
||||
0% { opacity: 0; transform: translateY(18rpx) scale(.72); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egFourIn {
|
||||
0% { opacity: 0; transform: translate(-50%, -44%) scale(.62) rotate(-3deg); }
|
||||
18% { opacity: 1; transform: translate(-50%, -50%) scale(1.08) rotate(1deg); }
|
||||
29% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
|
||||
78% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -65%) scale(.94); }
|
||||
}
|
||||
|
||||
@keyframes egFourCan {
|
||||
0% { opacity: 0; transform: translateY(-22rpx) rotate(-7deg) scale(.65); }
|
||||
75% { opacity: 1; transform: translateY(5rpx) rotate(2deg) scale(1.05); }
|
||||
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egFiveCan {
|
||||
0% { opacity: 0; transform: translateY(-40rpx) scale(.52) rotate(-8deg); filter: brightness(1.5); }
|
||||
70% { opacity: 1; transform: translateY(7rpx) scale(1.08) rotate(2deg); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0); filter: brightness(1); }
|
||||
}
|
||||
|
||||
@keyframes egLockedCan {
|
||||
from { opacity: .62; transform: scale(.92); filter: brightness(.9); }
|
||||
to { opacity: 1; transform: scale(1.06); filter: brightness(1.2); }
|
||||
}
|
||||
|
||||
@keyframes egPromisePulse {
|
||||
from { transform: scale(.97); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); }
|
||||
to { transform: scale(1.03); box-shadow: 0 7rpx 21rpx rgba(255,221,89,.38); }
|
||||
}
|
||||
|
||||
@keyframes egReelStop {
|
||||
0% { opacity: .2; transform: translateY(-48rpx) scaleY(1.4); filter: blur(5rpx); }
|
||||
70% { opacity: 1; transform: translateY(7rpx) scaleY(.94); filter: blur(0); }
|
||||
100% { transform: translateY(0) scaleY(1); }
|
||||
}
|
||||
|
||||
@keyframes egLights {
|
||||
0% { opacity: .35; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes egConfettiFall {
|
||||
0% { opacity: 0; transform: translate3d(0, -8vh, 0) rotate(0deg) scale(.7); }
|
||||
9% { opacity: 1; }
|
||||
48% { transform: translate3d(42rpx, 48vh, 0) rotate(420deg) scale(1); }
|
||||
100% { opacity: .92; transform: translate3d(-28rpx, 112vh, 0) rotate(920deg) scale(.86); }
|
||||
}
|
||||
|
||||
@keyframes egPointsPop {
|
||||
0% { opacity: 0; transform: scale(.35) rotate(-7deg); }
|
||||
55% { opacity: 1; transform: scale(1.28) rotate(3deg); }
|
||||
78% { transform: scale(.92) rotate(0); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egStageIn {
|
||||
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
|
||||
18% { opacity: 1; transform: scale(1.06) translateY(0); }
|
||||
30%, 76% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(.96) translateY(-22rpx); }
|
||||
}
|
||||
|
||||
@keyframes egReshuffleSpin {
|
||||
from { transform: rotate(0); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media screen and (max-height: 760px) {
|
||||
.eg-task-card { padding-top: 13rpx; padding-bottom: 13rpx; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -98,17 +98,15 @@
|
||||
<view v-if="chartUseMockData" class="st-chart-foot">
|
||||
<text class="st-chart-mock-hint">示例数据,累计记录满 7 天后显示您的真实趋势</text>
|
||||
</view>
|
||||
<view class="st-game-entry-list">
|
||||
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
|
||||
<view class="st-game-entry-icon">
|
||||
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||||
</view>
|
||||
<view class="st-game-entry-body">
|
||||
<text class="st-game-entry-title">识糖小课堂</text>
|
||||
<text class="st-game-entry-sub">三消认食物,学习哪些食物含糖高</text>
|
||||
</view>
|
||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
|
||||
<view class="st-game-entry-icon">
|
||||
<TongjiIcon name="activity" size="sm" color="#006c49" />
|
||||
</view>
|
||||
<view class="st-game-entry-body">
|
||||
<text class="st-game-entry-title">识糖小课堂</text>
|
||||
<text class="st-game-entry-sub">认一认:哪些食物含糖高</text>
|
||||
</view>
|
||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="st-training-section">
|
||||
@@ -1514,19 +1512,15 @@ async function goMorePage() {
|
||||
uni.navigateTo({ url: buildMorePageUrl() })
|
||||
}
|
||||
|
||||
function openGamePage(url) {
|
||||
function goGamePage() {
|
||||
uni.navigateTo({
|
||||
url,
|
||||
url: '/tongji/pages/game',
|
||||
fail() {
|
||||
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goEndlessGamePage() {
|
||||
openGamePage('/tongji/endless-game/index')
|
||||
}
|
||||
|
||||
function navToUser() {
|
||||
uni.redirectTo({ url: '/pages/user/user' })
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -177,7 +177,7 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 游戏入口 */
|
||||
/* 糖分突袭游戏入口 */
|
||||
.weekly-page .st-game-entry {
|
||||
|
||||
margin-top: 24rpx;
|
||||
@@ -388,15 +388,8 @@
|
||||
}
|
||||
|
||||
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
|
||||
.weekly-page .st-chart-card .st-game-entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin: 12rpx 20rpx 0;
|
||||
}
|
||||
|
||||
.weekly-page .st-chart-card .st-game-entry--in-card {
|
||||
margin-top: 0;
|
||||
margin-top: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
gap: 16rpx;
|
||||
border-radius: 24rpx;
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import Optimization from '@uni-ku/bundle-optimizer'
|
||||
|
||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
uni(),
|
||||
Optimization({
|
||||
enable: {
|
||||
optimization: true,
|
||||
'async-import': false,
|
||||
'async-component': false,
|
||||
},
|
||||
dts: false,
|
||||
logger: false,
|
||||
}),
|
||||
],
|
||||
plugins: [uni()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
|
||||
+1
-284
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 将标准双 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)' : ''}`)
|
||||
@@ -1,281 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface MyPatientListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
status_filter?: '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientLists(params: MyPatientListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/lists', params })
|
||||
}
|
||||
|
||||
export interface MyPatientOrderListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
prescription_audit_status?: '' | number
|
||||
payment_slip_audit_status?: '' | number
|
||||
fulfillment_status?: '' | number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientOrderLists(params: MyPatientOrderListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/orders', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderDetail(params: { id: number }) {
|
||||
return request.get({ url: '/firstvisit.myPatient/orderDetail', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderEdit(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderEdit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAuditPrescription(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAuditPrescription', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRevokeRxAudit(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRevokeRxAudit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAuditPayment(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAuditPayment', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRevokePayAudit(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRevokePayAudit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderDdcode(params: {
|
||||
id: number
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderDdcode', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderShip(params: {
|
||||
id: number
|
||||
ship_mode?: 'gancao' | 'direct'
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderShip', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAddPayOrder(params: {
|
||||
id: number
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
pay_create_type?: 'fubei' | 'express_cod'
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAddPayOrder', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderComplete', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRefund', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderWithdraw(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderWithdraw', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderUploadToPharmacy(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderUploadToPharmacy', params })
|
||||
}
|
||||
|
||||
export interface MyPatientProgressListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
status?: '' | 1 | 3 | 4
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientProgressLists(params: MyPatientProgressListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/progress', params })
|
||||
}
|
||||
|
||||
export function myPatientCreateAppointment(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.myPatient/createAppointment', params })
|
||||
}
|
||||
|
||||
export function myPatientCancelAppointment(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/cancelAppointment', params })
|
||||
}
|
||||
|
||||
export function myPatientAssistants() {
|
||||
return request.get({ url: '/firstvisit.myPatient/assistants' })
|
||||
}
|
||||
|
||||
export function myPatientAssign(params: { id: number; assistant_id: number; is_inherit?: 0 | 1 }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/assign', params })
|
||||
}
|
||||
|
||||
export function myPatientFillIdCard(params: { id: number; id_card: string }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/fillIdCard', params })
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionParams {
|
||||
time_type: 'today' | 'yesterday' | 'week' | 'month' | 'quarter' | 'year'
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
media_channel_code?: string
|
||||
}
|
||||
|
||||
/** 一诊综合数据转化:服务端按当前角色 DataScope 与所选部门/员工取交集。 */
|
||||
export function firstVisitConversionOverview(params: FirstVisitConversionParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.conversion/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitRegistrationStatsParams {
|
||||
time_type: 'today' | 'week' | 'month'
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
}
|
||||
|
||||
/** 一诊挂号统计:部门和员工参数只会在服务端 DataScope 权限范围内继续收窄。 */
|
||||
export function firstVisitRegistrationStatsOverview(params: FirstVisitRegistrationStatsParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.registrationStats/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitDoctorDashboardParams {
|
||||
time_type: 'today' | 'week' | 'month'
|
||||
dept_id?: number
|
||||
doctor_id?: number
|
||||
active_only?: 0 | 1
|
||||
alert_threshold?: number
|
||||
}
|
||||
|
||||
/** 一诊医生看板:医生与经手医助范围均由服务端根据当前角色和部门权限计算。 */
|
||||
export function firstVisitDoctorDashboardOverview(params: FirstVisitDoctorDashboardParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.doctorDashboard/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function wecomPromotionOverview() {
|
||||
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
|
||||
}
|
||||
|
||||
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionCheckApiPermission() {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
|
||||
}
|
||||
|
||||
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
|
||||
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeleteRemoteLink(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deleteRemoteLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionToggleLink(params: { id: number; status: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/toggleLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeleteLink(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
|
||||
}
|
||||
|
||||
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
|
||||
|
||||
export interface WecomPromotionCustomerStatsParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
promotion_link_id?: number
|
||||
userid?: string
|
||||
chat_status?: 0 | 1 | 2
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatsSummary {
|
||||
customer_count: number
|
||||
messaged_customer_count: number
|
||||
message_customer_rate: number
|
||||
received_message_count: number
|
||||
message_count_known_count: number
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatRow {
|
||||
id?: number
|
||||
external_userid_masked?: string
|
||||
customer_id_masked?: string
|
||||
customer_name_masked?: string
|
||||
link_id?: number | string
|
||||
link_name?: string
|
||||
member_id?: number
|
||||
member_name?: string
|
||||
department_name?: string
|
||||
dept_name?: string
|
||||
has_messaged?: boolean | number
|
||||
chat_status?: 0 | 1 | 2
|
||||
message_count_known?: boolean | number
|
||||
received_message_count?: number
|
||||
last_synced_at?: string
|
||||
last_message_at?: string
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatsResult {
|
||||
summary?: Partial<WecomPromotionCustomerStatsSummary>
|
||||
lists?: WecomPromotionCustomerStatRow[]
|
||||
total?: number
|
||||
link_options?: Array<{ id: number | string; name: string }>
|
||||
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
|
||||
meta?: { last_synced_at?: string }
|
||||
}
|
||||
|
||||
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
|
||||
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.wecomPromotion/customerStatistics', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function wecomPromotionSyncCustomers(params: { promotion_link_id?: number } = {}) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/syncCustomers', params, timeout: 120000 })
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface MedicineMappingQuery {
|
||||
page_no: number
|
||||
page_size: number
|
||||
local_name?: string
|
||||
remote_keyword?: string
|
||||
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
|
||||
}
|
||||
|
||||
export interface MedicineMappingRow {
|
||||
local_medicine_id: number
|
||||
local_name: string
|
||||
local_unit: string
|
||||
local_status: number
|
||||
mapping_id: number | null
|
||||
mapping_status: number
|
||||
medicine_code: string | null
|
||||
remote_name: string | null
|
||||
remote_brand: string | null
|
||||
remote_unit: string | null
|
||||
settlement_price: string | number | null
|
||||
retail_price: string | number | null
|
||||
catalog_version: number | null
|
||||
remote_status: number | null
|
||||
remote_deleted: number | null
|
||||
operator_name: string | null
|
||||
mapping_update_time: number | null
|
||||
}
|
||||
|
||||
export interface CatalogOption {
|
||||
medicine_code: string
|
||||
name: string
|
||||
brand: string
|
||||
unit: string
|
||||
settlement_price: string | number
|
||||
retail_price: string | number
|
||||
catalog_version: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface PharmacySyncStatus {
|
||||
sync_enabled: boolean
|
||||
cursor: number
|
||||
last_success_time: number
|
||||
last_failure_time: number
|
||||
last_error_summary: string
|
||||
is_syncing: boolean
|
||||
catalog_total: number
|
||||
catalog_active: number
|
||||
unmapped_local: number
|
||||
}
|
||||
|
||||
export interface PharmacySyncResult {
|
||||
pages: number
|
||||
pulled: number
|
||||
received: number
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
deactivated: number
|
||||
cursor: number
|
||||
}
|
||||
|
||||
export function medicineMappingLists(params: MedicineMappingQuery) {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
|
||||
}
|
||||
|
||||
export function medicineMappingStatus() {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/status' })
|
||||
}
|
||||
|
||||
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
|
||||
}
|
||||
|
||||
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/save', params })
|
||||
}
|
||||
|
||||
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
|
||||
}
|
||||
|
||||
export function medicineCatalogSync() {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/sync' })
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/** 角色数据驾驶舱:服务端统一按当前管理员的数据范围聚合。 */
|
||||
export function performanceDashboardOverview(params?: { ranking_dept_id?: number; _t?: number }) {
|
||||
return request.get(
|
||||
{ url: '/stats.performanceDashboard/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function getConversionStatsOverview(params: any) {
|
||||
return request.get({ url: '/stats.conversion/overview', params })
|
||||
}
|
||||
@@ -235,16 +227,6 @@ export function revisitRateVisitOrderLines(params: {
|
||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||
}
|
||||
|
||||
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
|
||||
export function autoAssignLogLists(params: Record<string, any>) {
|
||||
return request.get({ url: '/stats.autoAssignLog/lists', params })
|
||||
}
|
||||
|
||||
/** 批量回退自动分配:将诊单医助撤回到自动分配前的原医助 */
|
||||
export function autoAssignLogRollback(data: { ids: number[] }) {
|
||||
return request.post({ url: '/stats.autoAssignLog/rollback', data })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
|
||||
@@ -61,14 +61,6 @@ export function tcmDiagnosisDetail(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/detail', params })
|
||||
}
|
||||
|
||||
/** 设置复诊接诊率统计起始偏移(统计诊次=实单序号+偏移;1=二诊起,2=三诊起) */
|
||||
export function tcmDiagnosisSetRevisitSlotStartOffset(params: {
|
||||
id: number
|
||||
revisit_slot_start_offset: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosis/setRevisitSlotStartOffset', params })
|
||||
}
|
||||
|
||||
/** 诊单挂号 / 取消挂号 操作日志 */
|
||||
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
||||
@@ -424,15 +416,6 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
||||
}
|
||||
|
||||
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
|
||||
export function prescriptionOrderDdcode(params: {
|
||||
id: number
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/ddcode', params })
|
||||
}
|
||||
|
||||
/** 业务订单详情:仅修改关联处方的患者姓名与手机号 */
|
||||
export function prescriptionOrderPatchPrescriptionPatient(params: {
|
||||
id: number
|
||||
@@ -442,17 +425,6 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderPatchPrescriptionUsage(params: {
|
||||
id: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderAuditPrescription(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
@@ -537,21 +509,6 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
||||
}
|
||||
|
||||
/** 按业务订单 ship_mode 上传:gancao 走甘草,direct 走洛阳药房 ERP */
|
||||
export function prescriptionOrderUploadToPharmacy(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/uploadToPharmacy', params })
|
||||
}
|
||||
|
||||
/** 人工核对甘草不确定提交结果。 */
|
||||
export function prescriptionOrderConfirmGancaoSubmission(params: {
|
||||
id: number
|
||||
resolution: 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED'
|
||||
remote_order_no?: string
|
||||
note: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/confirmGancaoSubmission', params })
|
||||
}
|
||||
|
||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
||||
id: number
|
||||
@@ -566,18 +523,6 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||
}
|
||||
|
||||
/** 手工新增操作日志(可选调整处方/支付单审核状态) */
|
||||
export function prescriptionOrderAddLog(params: {
|
||||
id: number
|
||||
summary: string
|
||||
prescription_audit_status?: number | ''
|
||||
payment_slip_audit_status?: number | ''
|
||||
prescription_audit_remark?: string
|
||||
payment_slip_audit_remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
||||
}
|
||||
|
||||
/** 修改订单金额 */
|
||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<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>
|
||||
@@ -0,0 +1,60 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="admin-page-actions">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
@@ -17,14 +17,18 @@ defineProps({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.footer-btns {
|
||||
height: 60px;
|
||||
height: 64px;
|
||||
|
||||
&__content {
|
||||
bottom: 0;
|
||||
height: 60px;
|
||||
height: 64px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 99;
|
||||
@apply flex justify-center items-center shadow bg-body;
|
||||
padding: 0 20px;
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '—' }}</span>
|
||||
<el-select
|
||||
v-else
|
||||
:model-value="selectedMedicineId"
|
||||
:model-value="modelValue"
|
||||
class="medicine-name-select w-full"
|
||||
filterable
|
||||
remote
|
||||
@@ -14,14 +14,7 @@
|
||||
@visible-change="onVisibleChange"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
|
||||
<div class="medicine-name-option">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="medicine-name-option__meta">
|
||||
{{ item.id > 0 ? [item.supplier, item.unit, `ID ${item.id}`].filter(Boolean).join(' · ') : '历史名称,请重新选择' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.name" />
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +24,6 @@ import { medicineLists } from '@/api/medicine'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
medicineId?: number | null
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: false }
|
||||
@@ -39,30 +31,18 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'update:medicineId': [value: number | undefined]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
type MedicineOption = { id: number; name: string; supplier?: string; unit?: string }
|
||||
const options = ref<MedicineOption[]>([])
|
||||
|
||||
const selectedMedicineId = computed<number | ''>(() => {
|
||||
const id = Number(props.medicineId)
|
||||
if (Number.isInteger(id) && id > 0) {
|
||||
return id
|
||||
}
|
||||
return (props.modelValue || '').trim() ? 0 : ''
|
||||
})
|
||||
const options = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
function ensureCurrentInOptions() {
|
||||
const v = (props.modelValue || '').trim()
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
const id = Number(props.medicineId)
|
||||
const currentId = Number.isInteger(id) && id > 0 ? id : 0
|
||||
if (!options.value.some((o) => o.id === currentId)) {
|
||||
options.value = [{ id: currentId, name: v }, ...options.value]
|
||||
if (!options.value.some((o) => o.name === v)) {
|
||||
options.value = [{ id: 0, name: v }, ...options.value]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +56,7 @@ const remoteMethod = async (query: string) => {
|
||||
page_size: 100,
|
||||
status: 1
|
||||
})
|
||||
options.value = (res.lists || []) as MedicineOption[]
|
||||
options.value = res.lists || []
|
||||
ensureCurrentInOptions()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -92,19 +72,13 @@ const onVisibleChange = (open: boolean) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdate = (val: number | string) => {
|
||||
const id = Number(val)
|
||||
const selected = id > 0 ? options.value.find((item) => item.id === id) : undefined
|
||||
emit('update:modelValue', selected?.name || '')
|
||||
emit('update:medicineId', selected?.id)
|
||||
const onUpdate = (val: string) => {
|
||||
emit('update:modelValue', val || '')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.medicineId],
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (!(props.modelValue || '').trim() && Number(props.medicineId) > 0) {
|
||||
emit('update:medicineId', undefined)
|
||||
}
|
||||
ensureCurrentInOptions()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -119,23 +93,4 @@ watch(
|
||||
.medicine-name-select.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.medicine-name-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 60%);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.medicine-name-option__meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<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>
|
||||
|
||||
@@ -139,12 +139,7 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
|
||||
<MedicineNameSelect
|
||||
v-model="formData.herbs[row.index].name"
|
||||
v-model:medicine-id="formData.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="herb-editor-card__select"
|
||||
/>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -181,12 +176,7 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">辅方</div>
|
||||
<MedicineNameSelect
|
||||
v-model="formData.herbs[row.index].name"
|
||||
v-model:medicine-id="formData.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="herb-editor-card__select"
|
||||
/>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -1055,7 +1045,6 @@ import { Search } from '@element-plus/icons-vue'
|
||||
type FormulaType = '主方' | '辅方'
|
||||
|
||||
interface Herb {
|
||||
medicine_id?: number
|
||||
name: string
|
||||
dosage: number
|
||||
formula_type?: FormulaType
|
||||
@@ -1082,10 +1071,6 @@ function normalizeHerbRow(raw: any): Herb {
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
|
||||
if (Number.isInteger(medicineId) && medicineId > 0) {
|
||||
row.medicine_id = medicineId
|
||||
}
|
||||
if (raw?.locked === true || raw?.locked === 1) {
|
||||
row.locked = true
|
||||
}
|
||||
@@ -1593,8 +1578,8 @@ function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
|
||||
return herbs
|
||||
}
|
||||
|
||||
/** 仅有一条完全同名记录时返回稳定药材身份,否则不猜测。 */
|
||||
async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
|
||||
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测) */
|
||||
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
|
||||
const q = rawName.trim()
|
||||
if (!q) return null
|
||||
try {
|
||||
@@ -1605,10 +1590,8 @@ async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: n
|
||||
status: 1
|
||||
})
|
||||
const lists = (res.lists || []) as { id: number; name: string }[]
|
||||
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
|
||||
return exact.length === 1
|
||||
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
|
||||
: null
|
||||
const exact = lists.find((item) => (item.name ?? '').trim() === q)
|
||||
return exact ? exact.name.trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -1629,12 +1612,12 @@ async function handlePasteRecipeImport() {
|
||||
const resolved: Herb[] = []
|
||||
const skippedNames: string[] = []
|
||||
for (const row of parsed) {
|
||||
const medicine = await resolveHerbFromLibrary(row.name)
|
||||
if (!medicine) {
|
||||
const name = await resolveHerbNameFromLibrary(row.name)
|
||||
if (!name) {
|
||||
skippedNames.push(row.name.trim())
|
||||
continue
|
||||
}
|
||||
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
|
||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||
}
|
||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||
if (resolved.length === 0) {
|
||||
|
||||
+20
-20
@@ -1,28 +1,28 @@
|
||||
const defaultSetting = {
|
||||
showCrumb: true, // 是否显示面包屑
|
||||
showLogo: false, // 是否显示logo
|
||||
isUniqueOpened: true, //只展开一个一级菜单
|
||||
sideWidth: 183, //侧边栏宽度
|
||||
sideTheme: 'dark', //侧边栏主题
|
||||
sideDarkColor: '#1d2124', //侧边栏深色主题颜色
|
||||
openMultipleTabs: true, // 是否开启多标签tab栏
|
||||
theme: '#4A5DFF', //主题色
|
||||
successTheme: '#67c23a', //成功主题色
|
||||
warningTheme: '#e6a23c', //警告主题色
|
||||
dangerTheme: '#f56c6c', //危险主题色
|
||||
errorTheme: '#f56c6c', //错误主题色
|
||||
infoTheme: '#909399' //信息主题色
|
||||
showCrumb: false,
|
||||
showLogo: true,
|
||||
isUniqueOpened: true,
|
||||
sideWidth: 248,
|
||||
sideTheme: 'dark',
|
||||
sideDarkColor: '#060d18',
|
||||
openMultipleTabs: true,
|
||||
theme: '#06b6d4',
|
||||
successTheme: '#10b981',
|
||||
warningTheme: '#f59e0b',
|
||||
dangerTheme: '#ef4444',
|
||||
errorTheme: '#ef4444',
|
||||
infoTheme: '#6366f1'
|
||||
}
|
||||
|
||||
/** 本地 setting 缓存结构版本。提升后仅对低于该版本的老缓存执行 SETTING_SCHEMA_MIGRATIONS */
|
||||
export const SETTING_SCHEMA_VERSION = 1
|
||||
export const SETTING_SCHEMA_VERSION = 6
|
||||
|
||||
/**
|
||||
* 按版本写入 defaultSetting 中的键(老用户 localStorage 会长期盖住 config 默认值)。
|
||||
* 以后若要再推一批新默认值:把 SETTING_SCHEMA_VERSION +1,并为本版本追加一条迁移键列表。
|
||||
*/
|
||||
export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = {
|
||||
1: ['sideTheme', 'sideDarkColor']
|
||||
1: ['sideTheme', 'sideDarkColor'],
|
||||
2: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', '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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-breadcrumb class="app-breadcrumb">
|
||||
<el-breadcrumb class="app-breadcrumb" separator="/">
|
||||
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||||
{{ item.meta.title }}
|
||||
</el-breadcrumb-item>
|
||||
@@ -21,22 +21,24 @@ useWatchRoute((route) => {
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-breadcrumb {
|
||||
:deep(.el-breadcrumb__item) {
|
||||
.el-breadcrumb__inner {
|
||||
color: #303133;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 500;
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
|
||||
&:last-child .el-breadcrumb__inner {
|
||||
color: #303133;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:deep(.el-breadcrumb__separator) {
|
||||
color: #606266;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="navbar">
|
||||
<div class="flex-1 flex">
|
||||
<div class="flex-1 flex items-center gap-1 min-w-0">
|
||||
<div class="navbar-item">
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
@@ -17,11 +17,14 @@
|
||||
<refresh />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center px-2" v-if="!isMobile && settingStore.showCrumb">
|
||||
<div
|
||||
class="hidden md:flex items-center min-w-0 px-2"
|
||||
v-if="settingStore.showCrumb && breadcrumbs.length"
|
||||
>
|
||||
<breadcrumb />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="navbar-item" v-if="!isMobile">
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
@@ -36,12 +39,7 @@
|
||||
<user-drop-down />
|
||||
</div>
|
||||
<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 />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
@@ -52,8 +50,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationMatched } from 'vue-router'
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
|
||||
import { useWatchRoute } from '@/hooks/useWatchRoute'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import useSettingStore from '@/stores/modules/setting'
|
||||
|
||||
@@ -70,14 +70,20 @@ const isMobile = computed(() => appStore.isMobile)
|
||||
const isCollapsed = computed(() => appStore.isCollapsed)
|
||||
const settingStore = useSettingStore()
|
||||
const { isFullscreen } = useFullscreen()
|
||||
|
||||
const breadcrumbs = ref<RouteLocationMatched[]>([])
|
||||
useWatchRoute((route) => {
|
||||
breadcrumbs.value = route.matched.filter((item) => item.meta && item.meta.title)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.navbar {
|
||||
height: var(--navbar-height);
|
||||
@apply flex px-2 bg-body;
|
||||
@apply flex px-3 bg-body;
|
||||
|
||||
.navbar-item {
|
||||
@apply h-full flex justify-center items-center hover:bg-page;
|
||||
@apply h-full flex justify-center items-center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="app-tabs pl-4 flex bg-body">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="app-tabs flex bg-body">
|
||||
<div class="flex-1 min-w-0 pl-3">
|
||||
<el-tabs
|
||||
:model-value="currentTab"
|
||||
:closable="tabsLists.length > 1"
|
||||
@@ -13,7 +13,7 @@
|
||||
</el-tabs>
|
||||
</div>
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="flex items-center px-3">
|
||||
<span class="tabs-more-btn flex items-center px-3">
|
||||
<icon :size="16" name="el-icon-arrow-down" />
|
||||
</span>
|
||||
<template #dropdown>
|
||||
@@ -60,61 +60,84 @@ const handleCommand = (command: any) => {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.app-tabs {
|
||||
@apply border-t border-br;
|
||||
height: var(--tabs-height);
|
||||
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) {
|
||||
height: 40px;
|
||||
height: var(--tabs-height);
|
||||
|
||||
.el-tabs {
|
||||
&__header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__nav-next,
|
||||
&__nav-prev {
|
||||
@apply text-xl;
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
&__nav-wrap::after {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&__item {
|
||||
font-weight: normal;
|
||||
padding: 0 15px !important;
|
||||
font-weight: 600;
|
||||
font-size: var(--el-font-size-small);
|
||||
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;
|
||||
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 {
|
||||
color: var(--el-text-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--el-color-primary);
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
color: var(--admin-brand-primary-dark);
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-bottom-color: transparent;
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
display: block;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: var(--el-color-primary);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.is-icon-close {
|
||||
color: var(--el-text-color-regular);
|
||||
color: var(--el-text-color-placeholder);
|
||||
vertical-align: -2px;
|
||||
border-radius: var(--admin-radius-sm);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-white);
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__active-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<el-dropdown class="px-2" @command="handleCommand">
|
||||
<div class="flex items-center">
|
||||
<el-avatar :size="34" :src="userInfo.avatar" />
|
||||
<div class="ml-3 mr-1">{{ userInfo.name }}</div>
|
||||
<icon name="el-icon-ArrowDown" />
|
||||
<el-dropdown class="user-dropdown px-1" @command="handleCommand">
|
||||
<div class="user-trigger flex items-center gap-2.5 px-2 py-1 rounded-md cursor-pointer">
|
||||
<el-avatar :size="32" :src="userInfo.avatar" />
|
||||
<span class="user-name max-w-[120px] truncate text-sm font-medium text-tx-primary">{{
|
||||
userInfo.name
|
||||
}}</span>
|
||||
<icon name="el-icon-ArrowDown" :size="14" />
|
||||
</div>
|
||||
|
||||
<template #dropdown>
|
||||
@@ -41,3 +43,13 @@ const handleCommand = async (command: string) => {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-trigger {
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,56 @@
|
||||
<template>
|
||||
<main class="main-wrap h-full bg-page">
|
||||
|
||||
<main class="main-wrap h-full">
|
||||
|
||||
<el-scrollbar>
|
||||
<div class="px-2 py-4">
|
||||
<router-view v-if="isRouteShow" v-slot="{ Component, route }">
|
||||
<keep-alive :include="includeList" :max="20">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
|
||||
<div class="main-stage">
|
||||
|
||||
<page-shell v-if="isRouteShow">
|
||||
|
||||
<router-view v-slot="{ Component, route }">
|
||||
|
||||
<keep-alive :include="includeList" :max="20">
|
||||
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
|
||||
</keep-alive>
|
||||
|
||||
</router-view>
|
||||
|
||||
</page-shell>
|
||||
|
||||
</div>
|
||||
|
||||
</el-scrollbar>
|
||||
|
||||
</main>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import PageShell from '@/components/page-shell/index.vue'
|
||||
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
|
||||
import useTabsStore from '@/stores/modules/multipleTabs'
|
||||
|
||||
import useSettingStore from '@/stores/modules/setting'
|
||||
|
||||
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const tabsStore = useTabsStore()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isRouteShow = computed(() => appStore.isRouteShow)
|
||||
|
||||
const includeList = computed(() => (settingStore.openMultipleTabs ? tabsStore.getCacheTabList : []))
|
||||
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -100,7 +100,7 @@ import theme_light from '@/assets/images/theme_white.png'
|
||||
import useSettingStore from '@/stores/modules/setting'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const predefineColors = ref(['#409EFF', '#28C76F', '#EA5455', '#FF9F43', '#01CFE8', '#4A5DFF'])
|
||||
const predefineColors = ref(['#06b6d4', '#10b981', '#0891b2', '#6366f1', '#f59e0b', '#ef4444', '#64748b'])
|
||||
const sideThemeList = [
|
||||
{
|
||||
type: 'dark',
|
||||
|
||||
@@ -68,32 +68,42 @@ const themeClass = computed(() => `theme-${props.theme}`)
|
||||
.el-menu {
|
||||
:deep(.el-menu-item) {
|
||||
&.is-active {
|
||||
@apply bg-primary border-primary;
|
||||
@apply bg-primary;
|
||||
box-shadow: inset 3px 0 0 var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu--collapse) {
|
||||
.el-sub-menu.is-active .el-sub-menu__title {
|
||||
@apply bg-primary #{!important};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.theme-light {
|
||||
:deep(.el-menu) {
|
||||
.el-menu-item {
|
||||
border-color: transparent;
|
||||
|
||||
&.is-active {
|
||||
@apply bg-primary-light-9 border-r-2 border-primary;
|
||||
@apply bg-primary-light-9;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 3px 0 0 var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item:hover,
|
||||
.el-sub-menu__title:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
|
||||
&:not(.el-menu--collapse) {
|
||||
width: var(--aside-width);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<template>
|
||||
<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-menu
|
||||
:routes="routes"
|
||||
@@ -25,17 +35,19 @@ const appStore = useAppStore()
|
||||
const isCollapsed = computed(() => {
|
||||
if (appStore.isMobile) {
|
||||
return false
|
||||
} else {
|
||||
return appStore.isCollapsed
|
||||
}
|
||||
return appStore.isCollapsed
|
||||
})
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sideTheme = computed(() => settingStore.sideTheme)
|
||||
const userStore = useUserStore()
|
||||
const config = computed(() => appStore.config)
|
||||
|
||||
const routes = computed(() => userStore.routes)
|
||||
|
||||
const showBrandStrip = computed(() => !settingStore.showLogo && !isCollapsed.value)
|
||||
|
||||
const sideStyle = computed(() => {
|
||||
return sideTheme.value == 'dark'
|
||||
? {
|
||||
@@ -43,6 +55,7 @@ const sideStyle = computed(() => {
|
||||
}
|
||||
: ''
|
||||
})
|
||||
|
||||
const menuProp = computed(() => {
|
||||
return {
|
||||
backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : '',
|
||||
@@ -50,6 +63,7 @@ const menuProp = computed(() => {
|
||||
activeTextColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleSelect = () => {
|
||||
if (appStore.isMobile) {
|
||||
appStore.toggleCollapsed(true)
|
||||
@@ -61,7 +75,8 @@ const handleSelect = () => {
|
||||
.side {
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
@apply border-r border-br-light h-full flex flex-col;
|
||||
background-color: var(--side-dark-color, var(--el-bg-color));
|
||||
@apply h-full flex flex-col;
|
||||
border-right: 1px solid var(--admin-sidebar-border);
|
||||
background-color: var(--side-dark-color, var(--sidebar-dark-bg));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
+46
-31
@@ -1,43 +1,58 @@
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
--table-header-bg-color: var(--el-bg-color);
|
||||
--el-bg-color-page: #0a0a0a;
|
||||
--el-bg-color: #1d2124;
|
||||
--el-bg-color-overlay: #1d1e1f;
|
||||
--el-text-color-primary: #e5eaf3;
|
||||
--el-text-color-regular: #cfd3dc;
|
||||
--el-text-color-secondary: #a3a6ad;
|
||||
--el-text-color-placeholder: #8d9095;
|
||||
--el-text-color-disabled: #6c6e72;
|
||||
--el-border-color-darker: #636466;
|
||||
--el-border-color-dark: #58585b;
|
||||
--el-border-color: #4c4d4f;
|
||||
--el-border-color-light: #414243;
|
||||
--el-border-color-lighter: #363637;
|
||||
--el-border-color-extra-light: #2b2b2c;
|
||||
--el-fill-color-darker: #424243;
|
||||
--el-fill-color-dark: #39393a;
|
||||
--el-fill-color: #303030;
|
||||
--el-fill-color-light: #262727;
|
||||
--el-fill-color-lighter: #1d1d1d;
|
||||
--el-fill-color-extra-light: #191919;
|
||||
|
||||
--table-header-bg-color: rgba(6, 182, 212, 0.12);
|
||||
--sidebar-dark-bg: #030712;
|
||||
--sidebar-dark-hover: rgba(255, 255, 255, 0.05);
|
||||
--sidebar-dark-active: rgba(6, 182, 212, 0.2);
|
||||
|
||||
--admin-surface-elevated: #111827;
|
||||
--admin-surface-muted: rgba(17, 24, 39, 0.88);
|
||||
--admin-surface-glass: rgba(17, 24, 39, 0.86);
|
||||
|
||||
--el-bg-color-page: #030712;
|
||||
--el-bg-color: #0f172a;
|
||||
--el-bg-color-overlay: #1e293b;
|
||||
--el-text-color-primary: #f1f5f9;
|
||||
--el-text-color-regular: #cbd5e1;
|
||||
--el-text-color-secondary: #94a3b8;
|
||||
--el-text-color-placeholder: #64748b;
|
||||
--el-text-color-disabled: #475569;
|
||||
--el-border-color-darker: #64748b;
|
||||
--el-border-color-dark: #475569;
|
||||
--el-border-color: rgba(6, 182, 212, 0.18);
|
||||
--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-mask-color: rgba(0, 0, 0, 0.8);
|
||||
--el-mask-color-extra-light: rgba(0, 0, 0, 0.3);
|
||||
--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.36), 0px 8px 20px rgba(0, 0, 0, 0.72);
|
||||
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.72);
|
||||
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.72);
|
||||
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.72), 0px 12px 32px #000000,
|
||||
0px 8px 16px -8px #000000 !important;
|
||||
/* wangeditor主题 */
|
||||
|
||||
--el-mask-color: rgba(2, 6, 23, 0.78);
|
||||
--el-mask-color-extra-light: rgba(2, 6, 23, 0.42);
|
||||
|
||||
--el-box-shadow: 0 4px 24px rgba(0, 0, 0, 0.28);
|
||||
--el-box-shadow-light: 0 2px 16px rgba(0, 0, 0, 0.22);
|
||||
--el-box-shadow-lighter: 0 1px 4px rgba(0, 0, 0, 0.16);
|
||||
--el-box-shadow-dark: 0 16px 48px rgba(0, 0, 0, 0.36);
|
||||
|
||||
--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-color: var(--el-text-color-primary);
|
||||
--w-e-textarea-border-color: var(--el-border-color);
|
||||
--w-e-textarea-slight-border-color: var(--el-border-color-light);
|
||||
--w-e-textarea-slight-color: var(--el-border-color);
|
||||
--w-e-textarea-slight-bg-color: var(--el-bg-color-page);
|
||||
/* --w-e-textarea-selected-border-color: #b4d5ff;
|
||||
--w-e-textarea-handler-bg-color: #4290f7; */
|
||||
--w-e-toolbar-color: var(--el-text-color-primary);
|
||||
--w-e-toolbar-bg-color: var(--el-bg-color);
|
||||
--w-e-toolbar-active-color: var(--el-text-color-primary);
|
||||
|
||||
+246
-26
@@ -1,14 +1,20 @@
|
||||
:root {
|
||||
// 确保消息提示在最上层
|
||||
/* Messages & notifications */
|
||||
.el-message {
|
||||
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 {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -19,11 +25,14 @@
|
||||
.el-dialog {
|
||||
--el-dialog-content-font-size: var(--el-font-size-base);
|
||||
--el-dialog-margin-top: 50px;
|
||||
max-width: calc(100vw - 30px);
|
||||
--el-dialog-border-radius: var(--admin-radius-lg);
|
||||
max-width: calc(100vw - 32px);
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--admin-radius-lg);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow-dark);
|
||||
|
||||
&.body-padding .el-dialog__body {
|
||||
padding: 0;
|
||||
@@ -31,50 +40,158 @@
|
||||
|
||||
.el-dialog__body {
|
||||
flex: 1;
|
||||
padding: 15px 20px;
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
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 {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
.el-drawer {
|
||||
--el-drawer-padding-primary: 16px;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 0;
|
||||
padding: 13px 16px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@apply text-tx-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-header-text-color: var(--el-text-color-primary);
|
||||
--el-table-header-bg-color: var(--table-header-bg-color);
|
||||
font-size: var(--el-font-size-base);
|
||||
|
||||
thead {
|
||||
th {
|
||||
font-weight: 400;
|
||||
}
|
||||
&__body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.el-table {
|
||||
--el-table-header-text-color: var(--el-text-color-primary);
|
||||
--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);
|
||||
border-radius: var(--admin-radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
thead {
|
||||
th {
|
||||
font-weight: 600;
|
||||
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 {
|
||||
background-color: var(--el-fill-color-blank);
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.el-checkbox {
|
||||
--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 {
|
||||
&.theme-light {
|
||||
.el-menu {
|
||||
@@ -83,12 +200,14 @@
|
||||
@apply bg-primary-light-9 border-primary border-r-2;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item:hover,
|
||||
.el-sub-menu__title:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.theme-dark {
|
||||
.el-menu {
|
||||
.el-menu-item {
|
||||
@@ -101,52 +220,120 @@
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
--el-messagebox-width: 350px;
|
||||
--el-messagebox-width: 380px;
|
||||
--el-messagebox-border-radius: var(--admin-radius-lg);
|
||||
}
|
||||
|
||||
.el-date-editor {
|
||||
--el-date-editor-datetimerange-width: 380px;
|
||||
|
||||
.el-range-input {
|
||||
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-hover-link-text-color: var(--el-color-success-light-3);
|
||||
}
|
||||
|
||||
.el-button--info {
|
||||
--el-button-hover-link-text-color: var(--el-color-info-light-3);
|
||||
}
|
||||
|
||||
.el-button--warning {
|
||||
--el-button-hover-link-text-color: var(--el-color-warning-light-3);
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
--el-button-hover-link-text-color: var(--el-color-danger-light-3);
|
||||
}
|
||||
|
||||
.el-image__error {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-tabs__nav-wrap::after {
|
||||
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 {
|
||||
&__breadcrumb {
|
||||
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-select,
|
||||
.el-textarea {
|
||||
@apply shadow-primary-light-8;
|
||||
|
||||
box-shadow: 0 0 0 0 var(--tw-shadow-color);
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--tw-shadow-color);
|
||||
border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
|
||||
transition: box-shadow ease 0.1s;
|
||||
transition: box-shadow ease 0.15s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +343,7 @@
|
||||
border-radius: var(--el-checkbox-border-radius);
|
||||
|
||||
box-shadow: 0 0 0 0 var(--tw-shadow-color);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 2px var(--tw-shadow-color);
|
||||
transition: box-shadow ease 0s;
|
||||
@@ -173,29 +361,61 @@
|
||||
.el-form-item.is-error .el-checkbox {
|
||||
@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) {
|
||||
.el-pagination > .el-pagination__jump {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.el-pagination > .el-pagination__sizes {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button {
|
||||
// 防止被tailwindcss默认样式覆盖
|
||||
background-color: var(--el-button-bg-color, var(--el-color-white));
|
||||
|
||||
//覆盖el-button的点击样式
|
||||
&:focus {
|
||||
color: var(--el-button-text-color);
|
||||
border-color: var(--el-button-border-color);
|
||||
background-color: var(--el-button-bg-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-button-hover-text-color);
|
||||
border-color: var(--el-button-hover-border-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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@import 'element.scss';
|
||||
@import 'dark.css';
|
||||
@import 'var.css';
|
||||
@import 'dark.css';
|
||||
@import 'tailwind.css';
|
||||
@import 'element.scss';
|
||||
@import 'admin-shell.scss';
|
||||
@import 'admin-pages.scss';
|
||||
@import 'public.scss';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
body {
|
||||
@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 {
|
||||
@apply text-tx-secondary text-xs leading-6 mt-1;
|
||||
}
|
||||
@@ -12,7 +16,51 @@ body {
|
||||
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 .bar {
|
||||
@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;
|
||||
}
|
||||
|
||||
+85
-35
@@ -1,49 +1,99 @@
|
||||
:root {
|
||||
/* Typography */
|
||||
--el-font-family: theme(fontFamily.sans);
|
||||
--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-menu-base-level-padding: 16px;
|
||||
--el-menu-level-padding: 26px;
|
||||
--el-font-size-large: 16px;
|
||||
--el-font-size-medium: 15px;
|
||||
--el-font-size-base: 14px;
|
||||
--el-font-size-small: 13px;
|
||||
--el-font-size-extra-small: 12px;
|
||||
|
||||
/* 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-page: #f6f6f6;
|
||||
--el-bg-color-page: #eef6fb;
|
||||
--el-bg-color-overlay: #ffffff;
|
||||
--el-text-color-primary: #333333;
|
||||
--el-text-color-regular: #666666;
|
||||
--el-text-color-secondary: #999999;
|
||||
--el-text-color-placeholder: #a8abb2;
|
||||
--el-text-color-disabled: #c0c4cc;
|
||||
--el-border-color: #dcdfe6;
|
||||
--el-border-color-light: #e4e7ed;
|
||||
--el-border-color-lighter: #ebeef5;
|
||||
--el-border-color-extra-light: #f2f2f2;
|
||||
--el-border-color-dark: #d4d7de;
|
||||
--el-border-color-darker: #cdd0d6;
|
||||
--el-fill-color: #f0f2f5;
|
||||
--el-fill-color-light: #f8f8f8;
|
||||
--el-fill-color-lighter: #fafafa;
|
||||
--el-fill-color-extra-light: #fafcff;
|
||||
--el-fill-color-dark: #ebedf0;
|
||||
--el-fill-color-darker: #e6e8eb;
|
||||
--el-text-color-primary: #0b1220;
|
||||
--el-text-color-regular: #334155;
|
||||
--el-text-color-secondary: #64748b;
|
||||
--el-text-color-placeholder: #94a3b8;
|
||||
--el-text-color-disabled: #cbd5e1;
|
||||
--el-border-color: rgba(6, 182, 212, 0.12);
|
||||
--el-border-color-light: rgba(6, 182, 212, 0.08);
|
||||
--el-border-color-lighter: rgba(15, 23, 42, 0.06);
|
||||
--el-border-color-extra-light: rgba(15, 23, 42, 0.04);
|
||||
--el-border-color-dark: #cbd5e1;
|
||||
--el-border-color-darker: #94a3b8;
|
||||
--el-fill-color: #f1f5f9;
|
||||
--el-fill-color-light: #f8fafc;
|
||||
--el-fill-color-lighter: #fafbfc;
|
||||
--el-fill-color-extra-light: #fcfdfe;
|
||||
--el-fill-color-dark: #e2e8f0;
|
||||
--el-fill-color-darker: #cbd5e1;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
/* 过亮会盖住抽屉/弹窗下的内容;Element Loading 与部分蒙层共用此变量 */
|
||||
--el-mask-color: rgba(255, 255, 255, 0.5);
|
||||
--el-mask-color-extra-light: rgba(255, 255, 255, 0.22);
|
||||
-el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08);
|
||||
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
|
||||
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
|
||||
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12),
|
||||
0px 8px 16px -8px rgba(0, 0, 0, 0.16);
|
||||
|
||||
--el-mask-color: rgba(6, 13, 24, 0.55);
|
||||
--el-mask-color-extra-light: rgba(6, 13, 24, 0.1);
|
||||
|
||||
--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: 0 2px 16px rgba(6, 182, 212, 0.1);
|
||||
--el-box-shadow-lighter: 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
--el-box-shadow-dark: 0 16px 48px rgba(6, 182, 212, 0.14), 0 32px 64px rgba(15, 23, 42, 0.1);
|
||||
|
||||
--admin-header-border: rgba(6, 182, 212, 0.1);
|
||||
--admin-sidebar-border: rgba(255, 255, 255, 0.06);
|
||||
--admin-stage-bg: transparent;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="change-password flex flex-col">
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="change-password-card bg-body rounded-md px-10 py-10 w-[480px]">
|
||||
<div class="text-center text-2xl font-medium mb-2">首次登录</div>
|
||||
<div class="text-center text-gray-500 text-sm mb-8">为了您的账号安全,请修改初始密码</div>
|
||||
<div class="change-password-backdrop" aria-hidden="true"></div>
|
||||
<div class="flex-1 flex items-center justify-center relative z-[1] px-4">
|
||||
<div class="change-password-card">
|
||||
<div class="text-center text-2xl font-semibold mb-2 text-tx-primary">首次登录</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-item prop="password">
|
||||
@@ -117,7 +118,25 @@ const { isLock, lockFn: lockSubmit } = useLockFn(handleSubmit)
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.change-password {
|
||||
background-image: url('./images/login_bg.png');
|
||||
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
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>
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
<template>
|
||||
<div class="login flex flex-col">
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="login-card flex rounded-md overflow-hidden">
|
||||
<div class="flex-1 h-full hidden md:inline-block">
|
||||
<image-contain :src="config.login_image" :width="400" height="100%" />
|
||||
<div class="login-v2">
|
||||
<div class="login-v2__mesh" aria-hidden="true"></div>
|
||||
<div class="login-v2__layout">
|
||||
<aside class="login-v2__brand">
|
||||
<div class="login-v2__brand-inner">
|
||||
<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
|
||||
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>
|
||||
</aside>
|
||||
|
||||
<!-- 企业微信自动授权中 -->
|
||||
<div v-if="wxWorkAutoLogin" class="text-center py-10">
|
||||
<section class="login-v2__panel">
|
||||
<div class="login-v2__glass">
|
||||
<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)">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<div class="text-gray-500">企业微信授权登录中...</div>
|
||||
<div class="text-tx-secondary">企业微信授权登录中...</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 登录方式切换标签 -->
|
||||
<div v-if="wxWorkEnabled" class="flex justify-center mb-6">
|
||||
<div v-if="wxWorkEnabled" class="login-v2__mode">
|
||||
<el-radio-group v-model="loginMode" size="large">
|
||||
<el-radio-button value="account">账号登录</el-radio-button>
|
||||
<el-radio-button value="wxwork">企业微信</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录 -->
|
||||
<template v-if="loginMode === 'account'">
|
||||
<el-form ref="formRef" :model="formData" size="large" :rules="rules">
|
||||
<el-form-item prop="account">
|
||||
@@ -58,29 +66,34 @@
|
||||
<div class="mb-5">
|
||||
<el-checkbox v-model="remAccount" label="记住账号"></el-checkbox>
|
||||
</div>
|
||||
<el-button type="primary" size="large" :loading="isLock" @click="lockLogin">
|
||||
登录
|
||||
<el-button
|
||||
class="login-v2__submit"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="isLock"
|
||||
@click="lockLogin"
|
||||
>
|
||||
进入工作台
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 企业微信扫码登录(非企业微信内浏览器) -->
|
||||
<template v-if="loginMode === 'wxwork'">
|
||||
<div class="wxwork-qrcode-wrap">
|
||||
<div v-if="wxWorkLoading" class="text-center py-10">
|
||||
<el-icon class="is-loading" :size="32" color="var(--el-color-primary)">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<div class="mt-2 text-gray-400 text-sm">加载企业微信扫码...</div>
|
||||
<div class="mt-2 text-tx-secondary text-sm">加载企业微信扫码...</div>
|
||||
</div>
|
||||
<div v-else id="wxwork_qrcode_container" class="wxwork-qrcode"></div>
|
||||
</div>
|
||||
<div class="text-center text-sm text-gray-400 mt-4">
|
||||
<div class="text-center text-sm text-tx-secondary mt-4">
|
||||
请使用企业微信扫描二维码登录
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<layout-footer />
|
||||
</div>
|
||||
@@ -286,31 +299,178 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login {
|
||||
background-image: url('./images/login_bg.png');
|
||||
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
||||
.login-card {
|
||||
height: auto;
|
||||
min-height: 400px;
|
||||
.login-v2 {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #030712;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.wxwork-qrcode {
|
||||
width: 340px;
|
||||
height: 400px;
|
||||
width: 320px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(iframe) {
|
||||
width: 340px !important;
|
||||
height: 400px !important;
|
||||
width: 320px !important;
|
||||
height: 360px !important;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.login-v2__glass {
|
||||
background: #ffffff;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="温馨提示:用于管理网站的分类,只可添加到一级"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button
|
||||
class="mb-4"
|
||||
v-perms="['article.articleCate/add']"
|
||||
type="primary"
|
||||
@click="handleAdd()"
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="栏目名称" prop="name" min-width="120" />
|
||||
<el-table-column label="文章数" prop="article_count" min-width="120" />
|
||||
@@ -58,10 +58,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="article-lists">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="文章标题">
|
||||
<el-input
|
||||
@@ -33,24 +33,25 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<router-link
|
||||
v-perms="['article.article/add', 'article.article/add:edit']"
|
||||
:to="{
|
||||
path: getRoutePath('article.article/add:edit')
|
||||
}"
|
||||
>
|
||||
<el-button type="primary" class="mb-4">
|
||||
<el-button type="primary">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
发布文章
|
||||
</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
</template>
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="ID" prop="id" min-width="80" />
|
||||
<el-table-column label="封面" min-width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -116,10 +117,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="articleLists">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="asset-resource-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="标题">
|
||||
<el-input
|
||||
@@ -28,25 +28,25 @@
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<admin-page-data-panel v-loading="loading">
|
||||
<template #toolbar>
|
||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="图片" name="1"></el-tab-pane>
|
||||
<el-tab-pane label="视频" name="2"></el-tab-pane>
|
||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
|
||||
<el-table :data="tableData" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="title" label="标题" min-width="80" />
|
||||
@@ -71,7 +71,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<template #footer>
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
@@ -80,8 +80,8 @@
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
|
||||
<!-- 新增/编辑资源弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="asset-user-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="手机号">
|
||||
<el-input
|
||||
@@ -16,15 +16,15 @@
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<admin-page-data-panel v-loading="loading">
|
||||
<template #toolbar>
|
||||
<el-button type="primary" @click="handleAdd">新增账号</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column label="备注" min-width="200">
|
||||
@@ -63,7 +63,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<template #footer>
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
@@ -72,8 +72,8 @@
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
|
||||
<!-- 编辑/新增弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="用户信息">
|
||||
<el-input
|
||||
@@ -37,8 +37,9 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel>
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="头像" min-width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -67,10 +68,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="consumerLists">
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
<template>
|
||||
<template v-if="needsReconcile">
|
||||
<el-button
|
||||
v-perms="['tcm.prescriptionOrder/confirmGancaoSubmission']"
|
||||
type="danger"
|
||||
size="small"
|
||||
plain
|
||||
@click="openDialog"
|
||||
>核对甘草提交</el-button
|
||||
>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="人工核对甘草提交"
|
||||
width="min(92vw, 520px)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
title="请先在甘草后台核对。本操作会写入不可变更的审计记录。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px" @submit.prevent="submit">
|
||||
<el-form-item label="核对结果" required>
|
||||
<el-radio-group v-model="form.resolution">
|
||||
<el-radio label="CONFIRM_SUCCESS">确认已创建</el-radio>
|
||||
<el-radio label="CONFIRM_NOT_CREATED">确认未创建</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="form.resolution === 'CONFIRM_SUCCESS'"
|
||||
label="甘草单号"
|
||||
required
|
||||
>
|
||||
<el-input v-model="form.remote_order_no" maxlength="64" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="核对依据" required>
|
||||
<el-input
|
||||
v-model="form.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="例如:核对时间、甘草后台查询条件及结果"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit"
|
||||
>确认并记录</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { prescriptionOrderConfirmGancaoSubmission } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const props = defineProps<{ order: Record<string, any> | null | undefined }>()
|
||||
const emit = defineEmits<{ resolved: [] }>()
|
||||
|
||||
const needsReconcile = computed(() => {
|
||||
const target = String(props.order?.pharmacy_claim_target || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const status = String(props.order?.pharmacy_claim_status || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const leaseExpiresAt = Number(props.order?.pharmacy_claim_lease_expires_at || 0)
|
||||
const expiredPending =
|
||||
status === 'PENDING' &&
|
||||
leaseExpiresAt > 0 &&
|
||||
leaseExpiresAt <= Math.floor(Date.now() / 1000)
|
||||
return (
|
||||
target === 'gancao' && (expiredPending || ['UNKNOWN', 'PENDING_RECONCILE'].includes(status))
|
||||
)
|
||||
})
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
resolution: 'CONFIRM_SUCCESS' as 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED',
|
||||
remote_order_no: '',
|
||||
note: ''
|
||||
})
|
||||
|
||||
function openDialog() {
|
||||
form.resolution = 'CONFIRM_SUCCESS'
|
||||
form.remote_order_no = ''
|
||||
form.note = ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const id = Number(props.order?.id)
|
||||
if (!id) return
|
||||
if (form.resolution === 'CONFIRM_SUCCESS' && !form.remote_order_no.trim()) {
|
||||
feedback.msgError('请填写甘草药方单号')
|
||||
return
|
||||
}
|
||||
if (!form.note.trim()) {
|
||||
feedback.msgError('请填写甘草后台核对依据')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await prescriptionOrderConfirmGancaoSubmission({
|
||||
id,
|
||||
resolution: form.resolution,
|
||||
remote_order_no:
|
||||
form.resolution === 'CONFIRM_SUCCESS' ? form.remote_order_no.trim() : '',
|
||||
note: form.note.trim()
|
||||
})
|
||||
feedback.msgSuccess('甘草提交核对已记录')
|
||||
visible.value = false
|
||||
emit('resolved')
|
||||
} catch {
|
||||
// Request interceptor presents the server-side reconciliation error.
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+38
-435
@@ -30,13 +30,6 @@
|
||||
round
|
||||
class="ml-2"
|
||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
<el-tag
|
||||
v-if="detailData?.ej_pharmacy_order_no"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
round
|
||||
class="ml-2"
|
||||
>洛阳 {{ detailData.ej_pharmacy_order_no }}</el-tag>
|
||||
<!-- 完整版:发货类型切换等 -->
|
||||
<slot v-if="detailData" name="header-extra" :detail="detailData" />
|
||||
</div>
|
||||
@@ -334,27 +327,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>服用方式</span>
|
||||
<el-button
|
||||
v-if="
|
||||
!readonly &&
|
||||
detailData.prescription_id &&
|
||||
detailPrescription &&
|
||||
!String(detailData.prescription_detail_error || '').trim()
|
||||
"
|
||||
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openPatchUsageDialog"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions-item label="服用方式">
|
||||
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div>
|
||||
@@ -658,7 +631,7 @@
|
||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="showInternalCost" label="内部成本">
|
||||
@@ -682,7 +655,7 @@
|
||||
|
||||
<!-- 物流轨迹 -->
|
||||
<el-card
|
||||
v-if="loadRelatedData && String(detailData.tracking_number || '').trim()"
|
||||
v-if="String(detailData.tracking_number || '').trim()"
|
||||
shadow="never"
|
||||
class="po-panel po-panel-logistics"
|
||||
:class="
|
||||
@@ -802,24 +775,12 @@
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<el-card
|
||||
v-if="loadRelatedData"
|
||||
v-perms="['tcm.prescriptionOrder/logs']"
|
||||
shadow="never"
|
||||
class="po-panel border-gray-100 mt-4"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-[15px]">操作日志</span>
|
||||
<el-button
|
||||
v-if="canAddPrescriptionOrderLog()"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="openAddLogDialog"
|
||||
>
|
||||
新增日志
|
||||
</el-button>
|
||||
</div>
|
||||
<span class="font-medium text-[15px]">操作日志</span>
|
||||
</template>
|
||||
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
|
||||
<el-timeline-item
|
||||
@@ -839,182 +800,19 @@
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无操作日志" :image-size="64" />
|
||||
</el-card>
|
||||
|
||||
<!-- 新增操作日志 -->
|
||||
<el-dialog
|
||||
v-model="addLogVisible"
|
||||
title="新增操作日志"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
@closed="resetAddLogForm"
|
||||
>
|
||||
<el-form ref="addLogFormRef" :model="addLogForm" :rules="addLogRules" label-width="108px">
|
||||
<el-form-item label="日志内容" prop="summary">
|
||||
<el-input
|
||||
v-model="addLogForm.summary"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="记录本次操作说明、沟通结果等"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canSetRxAuditOnAddLog" label="处方审核">
|
||||
<el-select v-model="addLogForm.prescription_audit_status" class="w-full" clearable placeholder="不修改">
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已驳回" :value="2" />
|
||||
</el-select>
|
||||
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
|
||||
当前:{{ auditStatusText(detailData.prescription_audit_status) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="canSetRxAuditOnAddLog && addLogForm.prescription_audit_status !== '' && addLogForm.prescription_audit_status !== null && addLogForm.prescription_audit_status !== undefined"
|
||||
label="处方审核意见"
|
||||
>
|
||||
<el-input
|
||||
v-model="addLogForm.prescription_audit_remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canSetPayAuditOnAddLog" label="支付单审核">
|
||||
<el-select v-model="addLogForm.payment_slip_audit_status" class="w-full" clearable placeholder="不修改">
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已驳回" :value="2" />
|
||||
</el-select>
|
||||
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
|
||||
当前:{{ auditStatusText(detailData.payment_slip_audit_status) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="canSetPayAuditOnAddLog && addLogForm.payment_slip_audit_status !== '' && addLogForm.payment_slip_audit_status !== null && addLogForm.payment_slip_audit_status !== undefined"
|
||||
label="支付审核意见"
|
||||
>
|
||||
<el-input
|
||||
v-model="addLogForm.payment_slip_audit_remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addLogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="addLogSaving" @click="submitAddLog">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改服用参数:主方 / 辅方 / 订单设置 -->
|
||||
<el-dialog
|
||||
v-model="patchUsageVisible"
|
||||
title="修改服用参数"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="resetPatchUsageForm"
|
||||
>
|
||||
<el-form
|
||||
ref="patchUsageFormRef"
|
||||
:model="patchUsageForm"
|
||||
:rules="patchUsageRules"
|
||||
label-width="108px"
|
||||
>
|
||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||
<el-form-item label="每天次数" prop="times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.times_per_day"
|
||||
:min="1"
|
||||
:max="6"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="处方开立" prop="usage_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.usage_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_times_per_day"
|
||||
:min="1"
|
||||
:max="6"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="处方开立" prop="aux_usage_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_usage_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
|
||||
<el-form-item label="服用天数" prop="medication_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.medication_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="patchUsageVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="PrescriptionOrderDetailDrawer">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Refresh, Loading, Search, Van } from '@element-plus/icons-vue'
|
||||
import {
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderAddLog,
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderLogisticsJdUpdate,
|
||||
prescriptionOrderPaidPayOrders,
|
||||
prescriptionOrderPatchPrescriptionUsage
|
||||
prescriptionOrderPaidPayOrders
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
@@ -1033,7 +831,6 @@ import {
|
||||
consumerRxAuditTag,
|
||||
expressCompanyLabel,
|
||||
logActionText,
|
||||
auditStatusText,
|
||||
formatPayOrderSource,
|
||||
normalizeBizPhone,
|
||||
recipientVsPrescriptionPhoneMismatch,
|
||||
@@ -1044,10 +841,7 @@ import {
|
||||
analyzeLogisticsPayloadUrgent,
|
||||
parseLogisticsTracePayload,
|
||||
canUpdateAmount,
|
||||
formatDietaryTaboo,
|
||||
type ServicePackageOption,
|
||||
normalizeServicePackageOptions,
|
||||
formatServicePackageLabels
|
||||
formatDietaryTaboo
|
||||
} from './prescription-order-utils'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -1058,17 +852,11 @@ const props = withDefaults(
|
||||
gancaoPreviewLoading?: boolean
|
||||
/** 嵌套在其他抽屉内时需要 append-to-body */
|
||||
appendToBody?: boolean
|
||||
/** 自定义详情请求;用于在有独立行级权限边界的页面安全复用抽屉。 */
|
||||
detailLoader?: (params: { id: number }) => Promise<any>
|
||||
/** 是否加载原订单页的物流、日志和未关联支付单等附加接口。 */
|
||||
loadRelatedData?: boolean
|
||||
}>(),
|
||||
{
|
||||
readonly: false,
|
||||
gancaoPreviewLoading: false,
|
||||
appendToBody: false,
|
||||
detailLoader: undefined,
|
||||
loadRelatedData: true
|
||||
appendToBody: false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1085,7 +873,6 @@ const emit = defineEmits<{
|
||||
(e: 'view-prescription'): void
|
||||
(e: 'test-gancao-preview'): void
|
||||
(e: 'view-patient'): void
|
||||
(e: 'detail-changed'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
@@ -1301,24 +1088,36 @@ const detailFullAddress = computed(() => {
|
||||
})
|
||||
|
||||
// ─── 服务套餐字典 ───
|
||||
const servicePackageOptions = ref<ServicePackageOption[]>([])
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
async function loadServicePackageOptions() {
|
||||
if (servicePackageOptions.value.length > 0) return
|
||||
try {
|
||||
const data: any = await getDictData({ type: 'server_order' })
|
||||
const opts = normalizeServicePackageOptions(data?.server_order)
|
||||
if (opts.length > 0) {
|
||||
servicePackageOptions.value = opts
|
||||
}
|
||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
||||
} catch {
|
||||
/* 请求被同参数请求取消或失败时保留现值,open() 时会重试 */
|
||||
servicePackageOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const detailServicePackageText = computed(() =>
|
||||
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
||||
)
|
||||
function formatServicePackage(value: any): string {
|
||||
if (!value) return '—'
|
||||
|
||||
let packages: string[] = []
|
||||
if (Array.isArray(value)) {
|
||||
packages = value
|
||||
} else if (typeof value === 'string') {
|
||||
packages = value.split(',').filter((v) => v.trim() !== '')
|
||||
}
|
||||
|
||||
if (packages.length === 0) return '—'
|
||||
|
||||
const names = packages.map((val) => {
|
||||
const option = servicePackageOptions.value.find((opt) => opt.value === val)
|
||||
return option ? option.name : val
|
||||
})
|
||||
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadServicePackageOptions()
|
||||
@@ -1343,196 +1142,6 @@ async function fetchLogs(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasPerm(perm: string) {
|
||||
const p = userStore.perms || []
|
||||
return p.includes('*') || p.includes(perm)
|
||||
}
|
||||
|
||||
function canAddPrescriptionOrderLog() {
|
||||
return hasPerm('tcm.prescriptionOrder/addLog')
|
||||
}
|
||||
|
||||
/** 与后端 canAuditPrescriptionOrder 同档:超管或 prescription_audit_roles */
|
||||
const canSetRxAuditOnAddLog = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||
})
|
||||
|
||||
/** 与后端 canAuditPaymentSlipOrder 同档:超管或 prescription_order_payment_audit_roles 默认 0,3 */
|
||||
const canSetPayAuditOnAddLog = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ids.some((id) => [0, 3].includes(id))
|
||||
})
|
||||
|
||||
const addLogVisible = ref(false)
|
||||
const addLogSaving = ref(false)
|
||||
const addLogFormRef = ref<FormInstance>()
|
||||
const addLogForm = reactive({
|
||||
summary: '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
prescription_audit_remark: '',
|
||||
payment_slip_audit_remark: ''
|
||||
})
|
||||
const addLogRules: FormRules = {
|
||||
summary: [{ required: true, message: '请填写日志内容', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const patchUsageVisible = ref(false)
|
||||
const patchUsageSaving = ref(false)
|
||||
const patchUsageFormRef = ref<FormInstance>()
|
||||
const patchUsageForm = reactive({
|
||||
times_per_day: 3 as number | undefined,
|
||||
usage_days: 7 as number | undefined,
|
||||
aux_times_per_day: 3 as number | undefined,
|
||||
aux_usage_days: 7 as number | undefined,
|
||||
medication_days: undefined as number | undefined
|
||||
})
|
||||
const patchUsageRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {
|
||||
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
|
||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||
}
|
||||
return rules
|
||||
})
|
||||
|
||||
function openPatchUsageDialog() {
|
||||
const rx = detailPrescription.value
|
||||
const ord = detailData.value
|
||||
if (!rx || !ord?.id || !ord.prescription_id) {
|
||||
feedback.msgWarning('无处方数据')
|
||||
return
|
||||
}
|
||||
const aux = detailAuxUsage.value
|
||||
patchUsageForm.times_per_day =
|
||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||
patchUsageForm.usage_days =
|
||||
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageForm.aux_times_per_day =
|
||||
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
|
||||
patchUsageForm.aux_usage_days =
|
||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||
const md = Number(ord.medication_days)
|
||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageVisible.value = true
|
||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function resetPatchUsageForm() {
|
||||
patchUsageForm.times_per_day = 3
|
||||
patchUsageForm.usage_days = 7
|
||||
patchUsageForm.aux_times_per_day = 3
|
||||
patchUsageForm.aux_usage_days = 7
|
||||
patchUsageForm.medication_days = undefined
|
||||
}
|
||||
|
||||
async function submitPatchUsage() {
|
||||
const form = patchUsageFormRef.value
|
||||
if (!form) return
|
||||
try {
|
||||
await form.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const ordId = detailData.value?.id
|
||||
if (!ordId) return
|
||||
patchUsageSaving.value = true
|
||||
try {
|
||||
const payload: {
|
||||
id: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
} = {
|
||||
id: ordId,
|
||||
times_per_day: Number(patchUsageForm.times_per_day),
|
||||
usage_days: Number(patchUsageForm.usage_days),
|
||||
medication_days: Number(patchUsageForm.medication_days)
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||
}
|
||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
patchUsageVisible.value = false
|
||||
await refresh()
|
||||
emit('detail-changed')
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
patchUsageSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAddLogForm() {
|
||||
addLogForm.summary = ''
|
||||
addLogForm.prescription_audit_status = ''
|
||||
addLogForm.payment_slip_audit_status = ''
|
||||
addLogForm.prescription_audit_remark = ''
|
||||
addLogForm.payment_slip_audit_remark = ''
|
||||
addLogFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openAddLogDialog() {
|
||||
if (!detailData.value?.id) return
|
||||
resetAddLogForm()
|
||||
const d = detailData.value
|
||||
addLogForm.prescription_audit_remark = String(d.prescription_audit_remark || '')
|
||||
addLogForm.payment_slip_audit_remark = String(d.payment_slip_audit_remark || '')
|
||||
addLogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAddLog() {
|
||||
if (!addLogFormRef.value || !detailData.value?.id) return
|
||||
await addLogFormRef.value.validate()
|
||||
addLogSaving.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: detailData.value.id,
|
||||
summary: addLogForm.summary.trim()
|
||||
}
|
||||
if (
|
||||
canSetRxAuditOnAddLog.value &&
|
||||
addLogForm.prescription_audit_status !== '' &&
|
||||
addLogForm.prescription_audit_status !== null &&
|
||||
addLogForm.prescription_audit_status !== undefined
|
||||
) {
|
||||
payload.prescription_audit_status = addLogForm.prescription_audit_status
|
||||
payload.prescription_audit_remark = addLogForm.prescription_audit_remark
|
||||
}
|
||||
if (
|
||||
canSetPayAuditOnAddLog.value &&
|
||||
addLogForm.payment_slip_audit_status !== '' &&
|
||||
addLogForm.payment_slip_audit_status !== null &&
|
||||
addLogForm.payment_slip_audit_status !== undefined
|
||||
) {
|
||||
payload.payment_slip_audit_status = addLogForm.payment_slip_audit_status
|
||||
payload.payment_slip_audit_remark = addLogForm.payment_slip_audit_remark
|
||||
}
|
||||
await prescriptionOrderAddLog(payload as any)
|
||||
feedback.msgSuccess('日志已添加')
|
||||
addLogVisible.value = false
|
||||
await refresh()
|
||||
emit('detail-changed')
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
addLogSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 未关联支付单 ───
|
||||
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
|
||||
if (!diagnosisId) {
|
||||
@@ -1656,13 +1265,7 @@ async function updateJdLogistics() {
|
||||
}
|
||||
|
||||
// ─── 打开 / 刷新 ───
|
||||
function requestDetail(id: number) {
|
||||
return props.detailLoader ? props.detailLoader({ id }) : prescriptionOrderDetail({ id })
|
||||
}
|
||||
|
||||
async function open(id: number) {
|
||||
// 页面级同参数字典请求会取消抽屉挂载时的那次(axios 去重取消),打开时兜底重试
|
||||
void loadServicePackageOptions()
|
||||
// 显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
@@ -1674,22 +1277,22 @@ async function open(id: number) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res: any = await requestDetail(id)
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const d = res?.data ?? res ?? null
|
||||
detailData.value = d
|
||||
if (d) {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (props.loadRelatedData && String(d.tracking_number || '').trim()) {
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace()
|
||||
}
|
||||
if (props.loadRelatedData) fetchLogs(id)
|
||||
fetchLogs(id)
|
||||
|
||||
// 加载未关联的支付单
|
||||
const diagId = d.diagnosis_id
|
||||
const linkedIds = d.pay_order_ids || []
|
||||
if (props.loadRelatedData && diagId) {
|
||||
if (diagId) {
|
||||
void loadDetailUnlinkedPayOrders(diagId, id, linkedIds)
|
||||
}
|
||||
}
|
||||
@@ -1706,20 +1309,20 @@ async function refresh() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
try {
|
||||
const res: any = await requestDetail(id)
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const d = res?.data ?? res ?? null
|
||||
if (!d) return
|
||||
const prevTracking = String(detailData.value?.tracking_number || '').trim()
|
||||
detailData.value = d
|
||||
const nextTracking = String(d.tracking_number || '').trim()
|
||||
if (props.loadRelatedData && nextTracking && nextTracking !== prevTracking) {
|
||||
if (nextTracking && nextTracking !== prevTracking) {
|
||||
bumpLogisticsTraceRequestToken()
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
if (props.loadRelatedData) void fetchLogs(id)
|
||||
void fetchLogs(id)
|
||||
const diagId = d.diagnosis_id
|
||||
if (props.loadRelatedData && diagId) {
|
||||
if (diagId) {
|
||||
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -11,45 +11,6 @@ export const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!row) return false
|
||||
if (String(row.gancao_reciperl_order_no || '').trim()) return true
|
||||
if (String(row.ej_pharmacy_order_no || '').trim()) return true
|
||||
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
|
||||
return true
|
||||
}
|
||||
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
|
||||
String(row.pharmacy_claim_status || '').toUpperCase()
|
||||
)
|
||||
}
|
||||
|
||||
export type SupplyMode = 'gancao' | 'direct' | 'self'
|
||||
|
||||
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
|
||||
if (
|
||||
String(row?.ship_mode || '')
|
||||
.trim()
|
||||
.toLowerCase() === 'direct'
|
||||
)
|
||||
return 'direct'
|
||||
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
|
||||
return 'self'
|
||||
}
|
||||
|
||||
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
|
||||
const mode = supplyModeKey(row)
|
||||
if (mode === 'direct') return '洛阳直发'
|
||||
return mode === 'gancao' ? '甘草' : '自营'
|
||||
}
|
||||
|
||||
export function supplyModeTagType(
|
||||
row: Record<string, unknown> | null | undefined
|
||||
): 'success' | 'warning' | 'info' {
|
||||
const mode = supplyModeKey(row)
|
||||
if (mode === 'direct') return 'warning'
|
||||
return mode === 'gancao' ? 'success' : 'info'
|
||||
}
|
||||
|
||||
export function formatTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||
@@ -173,28 +134,14 @@ export function logActionText(act: string) {
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
ej_pharmacy_submit: '洛阳药房下单',
|
||||
ej_pharmacy_callback: '洛阳药房状态',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
patch_rx_usage: '服用参数',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
refund: '退款',
|
||||
manual_log: '手工备注',
|
||||
assign_assistant: '改派医助',
|
||||
add_pay_order: '补齐支付单',
|
||||
set_ship_mode: '发货类型'
|
||||
refund: '退款'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
|
||||
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
|
||||
export function auditStatusText(s: number | undefined) {
|
||||
if (s === 1) return '已通过'
|
||||
if (s === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
|
||||
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||
const createType = String(row?.create_type || '')
|
||||
@@ -410,85 +357,3 @@ export function formatDietaryTaboo(raw: unknown): string {
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 服务套餐 dict:server_order */
|
||||
export type ServicePackageOption = { name: string; value: string; status?: number }
|
||||
|
||||
export function normalizeServicePackageValue(v: unknown): string {
|
||||
return String(v ?? '').trim()
|
||||
}
|
||||
|
||||
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
|
||||
export function parseServicePackageValues(raw: unknown): string[] {
|
||||
if (raw == null || raw === '') return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(normalizeServicePackageValue).filter(Boolean)
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return raw.split(',').map((v) => v.trim()).filter(Boolean)
|
||||
}
|
||||
const one = normalizeServicePackageValue(raw)
|
||||
return one ? [one] : []
|
||||
}
|
||||
|
||||
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
|
||||
const sa = normalizeServicePackageValue(a)
|
||||
const sb = normalizeServicePackageValue(b)
|
||||
if (!sa || !sb) return false
|
||||
if (sa === sb) return true
|
||||
const na = Number(sa)
|
||||
const nb = Number(sb)
|
||||
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
|
||||
}
|
||||
|
||||
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((item: any) => ({
|
||||
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
|
||||
value: normalizeServicePackageValue(item?.value),
|
||||
status: Number(item?.status ?? 1)
|
||||
}))
|
||||
.filter((item) => item.value !== '')
|
||||
}
|
||||
|
||||
export function findServicePackageOption(
|
||||
options: ServicePackageOption[],
|
||||
value: unknown
|
||||
): ServicePackageOption | undefined {
|
||||
const key = normalizeServicePackageValue(value)
|
||||
if (!key) return undefined
|
||||
return options.find((opt) => servicePackageValueEquals(opt.value, key))
|
||||
}
|
||||
|
||||
/** 展示用:value → 字典 name,多选用「、」连接 */
|
||||
export function formatServicePackageLabels(
|
||||
value: unknown,
|
||||
options: ServicePackageOption[],
|
||||
emptyText = '—'
|
||||
): string {
|
||||
const packages = parseServicePackageValues(value)
|
||||
if (packages.length === 0) return emptyText
|
||||
const names = packages.map((val) => {
|
||||
const option = findServicePackageOption(options, val)
|
||||
return option?.name || val
|
||||
})
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
|
||||
export function mergeServicePackageSelectOptions(
|
||||
options: ServicePackageOption[],
|
||||
selected: unknown[]
|
||||
): ServicePackageOption[] {
|
||||
const known = new Set(options.map((o) => o.value))
|
||||
const extras: ServicePackageOption[] = []
|
||||
for (const raw of selected) {
|
||||
const val = normalizeServicePackageValue(raw)
|
||||
if (!val || known.has(val)) continue
|
||||
const matched = findServicePackageOption(options, val)
|
||||
extras.push(matched ?? { name: val, value: val, status: 0 })
|
||||
known.add(val)
|
||||
}
|
||||
return extras.length ? [...options, ...extras] : options
|
||||
}
|
||||
|
||||
@@ -696,12 +696,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect
|
||||
v-model="editForm.herbs[row.index].name"
|
||||
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="w-full"
|
||||
/>
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -737,12 +732,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect
|
||||
v-model="editForm.herbs[row.index].name"
|
||||
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="w-full"
|
||||
/>
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -1304,14 +1294,6 @@
|
||||
|
||||
<div v-show="createOrderStep === 1" class="create-order-step-panel">
|
||||
<el-row :gutter="20">
|
||||
<el-col v-if="canSelectShipMode" :span="24">
|
||||
<el-form-item label="发货药房" prop="ship_mode">
|
||||
<el-radio-group v-model="createOrderForm.ship_mode">
|
||||
<el-radio-button label="gancao">甘草药房</el-radio-button>
|
||||
<el-radio-button label="direct">洛阳药房</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="复诊">
|
||||
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
|
||||
@@ -1715,7 +1697,6 @@ import { roleAll } from '@/api/perms/role'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
@@ -1728,7 +1709,7 @@ import jsPDF from 'jspdf'
|
||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
type FormulaType = '主方' | '辅方'
|
||||
type HerbRow = { medicine_id?: number; name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||
|
||||
type AuxUsageForm = {
|
||||
dosage_amount?: number
|
||||
@@ -1833,10 +1814,6 @@ function normalizeHerbRow(raw: any): HerbRow {
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
|
||||
if (Number.isInteger(medicineId) && medicineId > 0) {
|
||||
row.medicine_id = medicineId
|
||||
}
|
||||
if (raw?.locked === true || raw?.locked === 1) {
|
||||
row.locked = true
|
||||
}
|
||||
@@ -2029,7 +2006,6 @@ const createOrderForm = reactive({
|
||||
service_package: [] as string[],
|
||||
express_company: 'auto',
|
||||
tracking_number: '',
|
||||
ship_mode: 'gancao' as 'gancao' | 'direct',
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
internal_cost: undefined as number | undefined,
|
||||
@@ -2038,10 +2014,6 @@ const createOrderForm = reactive({
|
||||
pay_order_ids: [] as number[]
|
||||
})
|
||||
|
||||
// 只有具备「设置发货类型」权限的账号才可在创建业务订单时选择洛阳药房;
|
||||
// 无权限账号保持历史默认逻辑,固定走甘草药房。
|
||||
const canSelectShipMode = computed(() => hasPermission(['tcm.prescriptionOrder/setShipMode']))
|
||||
|
||||
// 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据)
|
||||
const regionOptions = ref([])
|
||||
|
||||
@@ -2280,7 +2252,6 @@ function resetCreateOrderForm() {
|
||||
createOrderForm.service_package = []
|
||||
createOrderForm.express_company = 'auto'
|
||||
createOrderForm.tracking_number = ''
|
||||
createOrderForm.ship_mode = 'gancao'
|
||||
createOrderForm.fee_type = 3
|
||||
createOrderForm.amount = 0
|
||||
createOrderForm.internal_cost = undefined
|
||||
@@ -2454,7 +2425,6 @@ async function submitCreateOrderFromPrescription() {
|
||||
: '',
|
||||
express_company: createOrderForm.express_company || 'auto',
|
||||
tracking_number: createOrderForm.tracking_number || '',
|
||||
ship_mode: createOrderForm.ship_mode,
|
||||
fee_type: createOrderForm.fee_type,
|
||||
amount: createOrderForm.amount,
|
||||
remark_extra: createOrderForm.remark_extra || '',
|
||||
@@ -3631,7 +3601,7 @@ function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }
|
||||
return herbs
|
||||
}
|
||||
|
||||
async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
|
||||
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
|
||||
const q = rawName.trim()
|
||||
if (!q) return null
|
||||
try {
|
||||
@@ -3642,10 +3612,8 @@ async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_
|
||||
status: 1
|
||||
})
|
||||
const lists = (res.lists || []) as { id: number; name: string }[]
|
||||
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
|
||||
return exact.length === 1
|
||||
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
|
||||
: null
|
||||
const exact = lists.find((item) => (item.name ?? '').trim() === q)
|
||||
return exact ? exact.name.trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -3666,12 +3634,12 @@ async function handlePasteRecipeImport() {
|
||||
const resolved: HerbRow[] = []
|
||||
const skippedNames: string[] = []
|
||||
for (const row of parsed) {
|
||||
const medicine = await resolvePasteHerbFromLibrary(row.name)
|
||||
if (!medicine) {
|
||||
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
||||
if (!name) {
|
||||
skippedNames.push(row.name.trim())
|
||||
continue
|
||||
}
|
||||
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
|
||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||
}
|
||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||
if (resolved.length === 0) {
|
||||
|
||||
@@ -157,11 +157,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect
|
||||
v-model="row.name"
|
||||
v-model:medicine-id="row.medicine_id"
|
||||
:disabled="editMode === 'view'"
|
||||
/>
|
||||
<MedicineNameSelect v-model="row.name" :disabled="editMode === 'view'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="150">
|
||||
@@ -261,7 +257,7 @@ const editForm = reactive({
|
||||
id: 0,
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
is_public: 0,
|
||||
disable_edit: 0
|
||||
})
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
{{ listStats.periodLine }}
|
||||
</p>
|
||||
<!-- <p class="relative mt-1.5 text-xs text-slate-400 leading-relaxed">
|
||||
业绩 = 排除业务订单已取消(4)、拒收(9)、退款(10)后的关联支付金额;下方为合计与排除项明细
|
||||
业绩 = 除业务订单已取消(4)外关联支付金额;下方为合计与已取消明细
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,7 +298,7 @@
|
||||
:fetch-fun="prescriptionOrderExport"
|
||||
:params="prescriptionOrderExportParams"
|
||||
:page-size="pager.size"
|
||||
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「处方」导出主方/辅方药材明细;「主方/辅方服用方式、天数」与详情侧栏同口径(主方/辅方天数分别取处方 usage_days、辅方 aux_usage.usage_days;「天数」列为订单 medication_days)。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
|
||||
export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示。「关联收款记录」与详情侧栏同源(已支付/已退款/待审核),每笔两行展示(摘要行+明细行),多笔空行分隔,单元格自动换行。「签收日期」与详情/业绩看板同源,仅读物流库(轨迹/签收时间):导出不再实时查快递100,速度只取决于数据库;签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库,刚发货尚未同步的单子会暂时为空,待下次回填/定时任务刷新后显示。"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -373,9 +373,9 @@
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="supplyModeTagType(row)"
|
||||
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
|
||||
>
|
||||
{{ supplyModeLabel(row) }}
|
||||
{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}
|
||||
</el-tag>
|
||||
<span class="text-gray-400">#{{ row.id }}</span>
|
||||
</div>
|
||||
@@ -594,13 +594,13 @@
|
||||
@click="confirmWithdraw(row)"
|
||||
>撤回</el-button>
|
||||
<el-button
|
||||
v-if="canUploadPharmacyRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
v-if="canUploadGancaoRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
|
||||
type="warning"
|
||||
link
|
||||
:loading="pharmacySubmitId === row.id"
|
||||
@click="confirmUploadToPharmacy(row)"
|
||||
>上传药房</el-button>
|
||||
:loading="gancaoSubmitId === row.id"
|
||||
@click="confirmSubmitGancaoRecipel(row)"
|
||||
>上传药方</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -623,7 +623,6 @@
|
||||
@view-prescription="detailData && openPrescriptionView(detailData)"
|
||||
@test-gancao-preview="testGancaoPreviewFromDetail"
|
||||
@view-patient="openDiagnosisPatientDetailFromOrder"
|
||||
@detail-changed="getLists"
|
||||
>
|
||||
<template #header-extra="{ detail }">
|
||||
<div class="flex items-center gap-2 ml-4 shrink-0">
|
||||
@@ -638,7 +637,7 @@
|
||||
<el-radio-button label="gancao">甘草药房</el-radio-button>
|
||||
<el-radio-button
|
||||
label="direct"
|
||||
:disabled="isShipModeLocked(detail)"
|
||||
:disabled="isShipModeLockedToGancao(detail)"
|
||||
>洛阳药房</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-tag
|
||||
@@ -650,19 +649,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions="{ detail }">
|
||||
<gancao-submission-reconcile-button
|
||||
:order="detail"
|
||||
@resolved="handleGancaoSubmissionResolved"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canUploadPharmacyRow(detail)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
type="warning"
|
||||
size="small"
|
||||
plain
|
||||
:loading="pharmacySubmitId === detail.id"
|
||||
@click="confirmUploadToPharmacy(detail)"
|
||||
>上传药房</el-button>
|
||||
<el-button
|
||||
v-if="canRxAudit(detail)"
|
||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
||||
@@ -1038,11 +1024,10 @@
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in editServicePackageSelectOptions"
|
||||
v-for="item in servicePackageOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
:disabled="item.status === 0"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -2218,7 +2203,6 @@ import { useRoute } from 'vue-router'
|
||||
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||
import {
|
||||
TCM_ASSISTANT_ROLE_ID,
|
||||
PRESCRIPTION_AUDIT_ROLE_IDS,
|
||||
@@ -2241,21 +2225,13 @@ import {
|
||||
canUpdateAmount,
|
||||
formatDietaryTaboo,
|
||||
type SlipFormulaType,
|
||||
type SlipAuxUsageForm,
|
||||
type ServicePackageOption,
|
||||
normalizeServicePackageOptions,
|
||||
parseServicePackageValues,
|
||||
mergeServicePackageSelectOptions,
|
||||
isRemoteSnapshotLocked,
|
||||
supplyModeLabel,
|
||||
supplyModeTagType
|
||||
type SlipAuxUsageForm
|
||||
} from './components/prescription-order-utils'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
prescriptionOrderAuditPrescription,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderDdcode,
|
||||
prescriptionOrderEdit,
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderExport,
|
||||
@@ -2274,7 +2250,7 @@ import {
|
||||
prescriptionOrderBatchAssignAssistant,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderUploadToPharmacy,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
prescriptionOrderPreviewGancaoRecipel,
|
||||
prescriptionDetail,
|
||||
prescriptionLibraryLists,
|
||||
@@ -2438,8 +2414,8 @@ async function submitReassign() {
|
||||
// 省市区数据
|
||||
const regionOptions = ref([])
|
||||
|
||||
// 服务套餐选项(含已停用项,便于编辑时回显历史值)
|
||||
const servicePackageOptions = ref<ServicePackageOption[]>([])
|
||||
// 服务套餐选项
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
|
||||
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
@@ -2560,7 +2536,8 @@ const loadRegionData = async () => {
|
||||
const loadServicePackageOptions = async () => {
|
||||
try {
|
||||
const data = await getDictData({ type: 'server_order' })
|
||||
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
|
||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
||||
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
|
||||
} catch (error) {
|
||||
console.error('加载服务套餐选项失败:', error)
|
||||
servicePackageOptions.value = []
|
||||
@@ -2594,13 +2571,12 @@ function handleFulfillmentStatusTabClick(value: number | '') {
|
||||
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
||||
|
||||
const supplyModeTabs = [
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
|
||||
{ label: '甘草', value: 'gancao' as const },
|
||||
{ label: '洛阳直发', value: 'direct' as const },
|
||||
{ label: '自营', value: 'self' as const }
|
||||
]
|
||||
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
|
||||
queryParams.supply_mode = value
|
||||
resetPage()
|
||||
}
|
||||
@@ -2634,8 +2610,8 @@ const queryParams = reactive({
|
||||
fulfillment_status: '' as number | '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
|
||||
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
||||
service_channel: '' as '' | '0',
|
||||
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
|
||||
@@ -2920,7 +2896,7 @@ const listStats = computed(() => {
|
||||
const oC = hasOrderSplit ? n(ex?.stats_order_amount_cancelled) : 0
|
||||
const pNc = hasPaySplit ? n(ex?.stats_linked_pay_amount_not_cancelled) : n(pay)
|
||||
const pC = hasPaySplit ? n(ex?.stats_linked_pay_amount_cancelled) : 0
|
||||
// 业绩 = 排除履约已取消(4)、拒收(9)、退款(10)后的订单金额;优先用后端显式字段,兜底到 not_cancelled
|
||||
// 业绩 = 除履约已取消(fulfillment_status=4)外的全部订单金额;优先用后端显式字段,兜底到 not_cancelled
|
||||
const oPerf = ex?.stats_order_amount_performance !== undefined
|
||||
? n(ex?.stats_order_amount_performance)
|
||||
: oNc
|
||||
@@ -2960,7 +2936,7 @@ const listStats = computed(() => {
|
||||
payHeading: `${headPrefix}业绩(关联实付)`,
|
||||
periodLine,
|
||||
scopeHint,
|
||||
orderSplitHint: '业绩 = 排除履约已取消(4)、拒收(9)、退款(10)后的订单金额;下方为合计与排除项明细'
|
||||
orderSplitHint: '业绩 = 除履约已取消(4)外全部订单金额;下方为合计与已取消明细'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3090,7 +3066,14 @@ function canEditRow(row: {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
|
||||
// 甘草已提交:仅允许在待双审/履约中/已发货/进行中下修改快递(已签收 6 不可改单号;后端 edit 亦拦截)
|
||||
if (gcLocked) {
|
||||
return [1, 2, 5, 7].includes(fs)
|
||||
}
|
||||
|
||||
// 未提交甘草:仅待双审(1)、履约中(2)可全量编辑
|
||||
return fs === 1 || fs === 2
|
||||
@@ -3118,7 +3101,6 @@ function canRevokeRxAudit(row: {
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
}) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4 || fs === 6) return false
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
@@ -3141,7 +3123,6 @@ function canRevokePayAudit(row: {
|
||||
}
|
||||
|
||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
// 只有「待双审通过」可撤回,已发货后不可撤回
|
||||
return Number(row.fulfillment_status) === 1
|
||||
}
|
||||
@@ -3166,13 +3147,13 @@ function shipModeLabel(v: unknown): string {
|
||||
return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房'
|
||||
}
|
||||
|
||||
function isShipModeLocked(row: { gancao_reciperl_order_no?: string | null; ej_pharmacy_order_no?: string | null }) {
|
||||
return isRemoteSnapshotLocked(row as Record<string, unknown>)
|
||||
function isShipModeLockedToGancao(row: { gancao_reciperl_order_no?: string | null }) {
|
||||
return String(row.gancao_reciperl_order_no || '').trim() !== ''
|
||||
}
|
||||
|
||||
function canEditShipMode(row: { fulfillment_status?: number; gancao_reciperl_order_no?: string; ej_pharmacy_order_no?: string }) {
|
||||
function canEditShipMode(row: { fulfillment_status?: number }) {
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4 && !isShipModeLocked(row)
|
||||
return fs !== 3 && fs !== 4
|
||||
}
|
||||
|
||||
const shipModeSaving = ref(false)
|
||||
@@ -3214,22 +3195,10 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGancaoSubmissionResolved() {
|
||||
await refreshCurrentPrescriptionOrderDetail()
|
||||
getLists()
|
||||
}
|
||||
|
||||
function canAddPayOrderRow(row: {
|
||||
fulfillment_status?: number
|
||||
amount?: number | string
|
||||
linked_pay_paid_total?: number | string
|
||||
}) {
|
||||
// 已发货(5) / 已签收(6) 状态可补齐支付单;总金额已付清则不允许
|
||||
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
|
||||
// 已发货(5) / 已签收(6) 状态可补齐支付单
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs !== 5 && fs !== 6) return false
|
||||
const orderAmount = Math.round((Number(row.amount) || 0) * 100) / 100
|
||||
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
|
||||
return paidTotal < orderAmount
|
||||
return fs === 5 || fs === 6
|
||||
}
|
||||
|
||||
function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
|
||||
@@ -3244,31 +3213,19 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
|
||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
|
||||
return true
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
// 仅已发货(5) 可修改快递单号;已签收(6) 不可再改
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadPharmacyRow(row: {
|
||||
function canUploadGancaoRow(row: {
|
||||
prescription_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
ship_mode?: string
|
||||
gancao_reciperl_order_no?: string
|
||||
ej_pharmacy_order_no?: string
|
||||
ej_pharmacy_status?: string
|
||||
ej_pharmacy_review_status?: string
|
||||
can_upload_pharmacy?: boolean
|
||||
gancao_reciperl_order_no?: string
|
||||
}) {
|
||||
if (row.can_upload_pharmacy === false) return false
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const fs = Number(row.fulfillment_status)
|
||||
const ejRejected = ['REJECTED'].includes(String(row.ej_pharmacy_status || '').toUpperCase())
|
||||
|| ['REJECTED'].includes(String(row.ej_pharmacy_review_status || '').toUpperCase())
|
||||
if (rxStatus !== 1 || ([3, 4, 8, 9, 10, 11, 12].includes(fs) && !(fs === 9 && ejRejected))) return false
|
||||
if (normalizeShipMode(row.ship_mode) === 'direct') {
|
||||
return (String(row.ej_pharmacy_order_no || '').trim() === '' || ejRejected)
|
||||
&& String(row.gancao_reciperl_order_no || '').trim() === ''
|
||||
}
|
||||
return String(row.gancao_reciperl_order_no || '').trim() === '' && String(row.ej_pharmacy_order_no || '').trim() === ''
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
}
|
||||
|
||||
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
|
||||
@@ -3492,11 +3449,6 @@ const editForm = reactive({
|
||||
diagnosis_creator_dept_path: ''
|
||||
})
|
||||
|
||||
/** 编辑弹窗下拉:字典项 + 当前已选但字典中缺失的兜底项 */
|
||||
const editServicePackageSelectOptions = computed(() =>
|
||||
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
|
||||
)
|
||||
|
||||
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
|
||||
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
|
||||
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
|
||||
@@ -3729,7 +3681,18 @@ async function openEdit(row: {
|
||||
editForm.dose_unit = d.dose_unit || '剂'
|
||||
editForm.prev_staff = d.prev_staff || ''
|
||||
editForm.service_channel = d.service_channel || ''
|
||||
editForm.service_package = parseServicePackageValues(d.service_package)
|
||||
// 处理服务套餐:如果是字符串,转换为数组
|
||||
if (d.service_package) {
|
||||
if (Array.isArray(d.service_package)) {
|
||||
editForm.service_package = d.service_package
|
||||
} else if (typeof d.service_package === 'string') {
|
||||
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
|
||||
} else {
|
||||
editForm.service_package = []
|
||||
}
|
||||
} else {
|
||||
editForm.service_package = []
|
||||
}
|
||||
editForm.express_company = String(d.express_company || 'auto') || 'auto'
|
||||
editForm.tracking_number = d.tracking_number || ''
|
||||
editForm.fee_type = Number(d.fee_type) || 3
|
||||
@@ -3894,7 +3857,7 @@ async function confirmWithdraw(row: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const pharmacySubmitId = ref(0)
|
||||
const gancaoSubmitId = ref(0)
|
||||
const gancaoPreviewDrawerVisible = ref(false)
|
||||
const gancaoPreviewLoading = ref(false)
|
||||
const gancaoPreviewData = ref<any>(null)
|
||||
@@ -4033,24 +3996,18 @@ async function testGancaoPreviewFromDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
|
||||
const isLuoyang = normalizeShipMode(row.ship_mode) === 'direct'
|
||||
const pharmacyName = isLuoyang ? '洛阳药房' : '甘草药房'
|
||||
async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将把本单关联处方提交至${pharmacyName},提交成功后不可切换发货药房。确定继续?`,
|
||||
'上传药房',
|
||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
||||
'上传甘草药方',
|
||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||
)
|
||||
pharmacySubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
|
||||
gancaoSubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
||||
const d = res?.data ?? res
|
||||
const no = d?.pharmacy_order_no != null
|
||||
? String(d.pharmacy_order_no)
|
||||
: d?.recipel_order_no != null
|
||||
? String(d.recipel_order_no)
|
||||
: ''
|
||||
feedback.msgSuccess(no ? `上传成功,${pharmacyName}单号:${no}` : '上传成功')
|
||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
||||
getLists()
|
||||
await detailDrawerRef.value?.refreshIfCurrent(row.id)
|
||||
} catch (e: any) {
|
||||
@@ -4058,7 +4015,7 @@ async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string })
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
} finally {
|
||||
pharmacySubmitId.value = 0
|
||||
gancaoSubmitId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4094,8 +4051,33 @@ async function submitQuickTrack() {
|
||||
}
|
||||
quickTrackSaving.value = true
|
||||
try {
|
||||
await prescriptionOrderDdcode({
|
||||
id: quickTrackRowId.value,
|
||||
// 先拉详情,获取所有必填字段,再只覆盖快递信息
|
||||
const res: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
|
||||
const d = res?.data ?? res
|
||||
if (!d) {
|
||||
feedback.msgError('加载订单数据失败')
|
||||
return
|
||||
}
|
||||
await prescriptionOrderEdit({
|
||||
id: d.id,
|
||||
recipient_name: d.recipient_name || '',
|
||||
recipient_phone: d.recipient_phone || '',
|
||||
shipping_province: d.shipping_province || '',
|
||||
shipping_city: d.shipping_city || '',
|
||||
shipping_district: d.shipping_district || '',
|
||||
shipping_address: d.shipping_address || '',
|
||||
is_follow_up: d.is_follow_up ? 1 : 0,
|
||||
medication_days: (d.medication_days != null && String(d.medication_days).trim() !== '') ? d.medication_days : '',
|
||||
prev_staff: d.prev_staff || '',
|
||||
service_channel: d.service_channel || '',
|
||||
service_package: d.service_package || '',
|
||||
fee_type: Number(d.fee_type) || 3,
|
||||
amount: Number(d.amount) || 0,
|
||||
remark_extra: d.remark_extra || '',
|
||||
remark_assistant: d.remark_assistant || '',
|
||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
||||
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||
// 仅覆盖快递字段
|
||||
express_company: quickTrackForm.express_company || 'auto',
|
||||
tracking_number: quickTrackForm.tracking_number.trim()
|
||||
})
|
||||
@@ -4104,6 +4086,17 @@ async function submitQuickTrack() {
|
||||
getLists()
|
||||
// 若详情 Drawer 打开且是同一订单,同步刷新
|
||||
await detailDrawerRef.value?.refreshIfCurrent(quickTrackRowId.value)
|
||||
// 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗
|
||||
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
|
||||
'确认发货',
|
||||
{ type: 'success', confirmButtonText: '确认发货', cancelButtonText: '稍后再说' }
|
||||
)
|
||||
openShip({ id: quickTrackRowId.value, tracking_number: quickTrackForm.tracking_number, express_company: quickTrackForm.express_company })
|
||||
} catch { /* 用户点了「稍后」 */ }
|
||||
}
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
@@ -4516,18 +4509,7 @@ async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: n
|
||||
}
|
||||
}
|
||||
|
||||
function openAddPayOrder(row: {
|
||||
id: number
|
||||
diagnosis_id?: number
|
||||
pay_order_ids?: number[]
|
||||
fulfillment_status?: number
|
||||
amount?: number | string
|
||||
linked_pay_paid_total?: number | string
|
||||
}) {
|
||||
if (!canAddPayOrderRow(row)) {
|
||||
feedback.msgWarning('订单总金额与已付金额一致,无需补齐支付单')
|
||||
return
|
||||
}
|
||||
function openAddPayOrder(row: { id: number; diagnosis_id?: number; pay_order_ids?: number[] }) {
|
||||
addPayOrderRowId.value = row.id
|
||||
addPayOrderForm.add_mode = 'create'
|
||||
addPayOrderForm.order_type = 3
|
||||
|
||||
@@ -365,8 +365,8 @@
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="supplyModeTagType(row)"
|
||||
>{{ supplyModeLabel(row) }}</el-tag>
|
||||
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
|
||||
>{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}</el-tag>
|
||||
<span class="po-card__id">#{{ row.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,11 +500,7 @@
|
||||
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
|
||||
<span class="text-red-500">退款</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="canUploadPharmacyRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
command="uploadPharmacy"
|
||||
>上传药房</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
|
||||
<span class="text-red-500">撤回订单</span>
|
||||
</el-dropdown-item>
|
||||
@@ -548,10 +544,6 @@
|
||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
</div>
|
||||
<div v-if="detailData" class="po-detail-drawer-actions flex flex-wrap items-center gap-2">
|
||||
<gancao-submission-reconcile-button
|
||||
:order="detailData"
|
||||
@resolved="handleGancaoSubmissionResolved"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canRxAudit(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
||||
@@ -866,94 +858,31 @@
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用量">
|
||||
<div class="flex flex-col gap-1 text-sm leading-relaxed">
|
||||
<div>
|
||||
<span v-if="detailHasAuxHerbs" class="text-gray-500 mr-1">主方:</span>
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
<div v-if="detailHasAuxHerbs && detailAuxUsage">
|
||||
<span class="text-gray-500 mr-1">辅方:</span>
|
||||
<template v-if="detailAuxUsage.dosage_amount != null && detailAuxUsage.dosage_amount !== 0">
|
||||
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
|
||||
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<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 v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服用方式">
|
||||
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div>
|
||||
<span class="text-gray-500 mr-1">主方:</span>
|
||||
每天
|
||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '— 次' }}
|
||||
· 处方开立
|
||||
{{
|
||||
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
|
||||
? detailPrescription.usage_days + ' 天'
|
||||
: '— 天'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="detailAuxUsage">
|
||||
<span class="text-gray-500 mr-1">辅方:</span>
|
||||
每天
|
||||
{{ detailAuxUsage.times_per_day ? detailAuxUsage.times_per_day + ' 次' : '— 次' }}
|
||||
· 处方开立
|
||||
{{
|
||||
detailAuxUsage.usage_days != null && Number(detailAuxUsage.usage_days) > 0
|
||||
? detailAuxUsage.usage_days + ' 天'
|
||||
: '— 天'
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<span class="text-gray-500">每天次数:</span>
|
||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处方开立:</span>
|
||||
{{
|
||||
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
|
||||
? detailPrescription.usage_days + ' 天'
|
||||
: '—'
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<span class="text-gray-500">每天次数:</span>
|
||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处方开立:</span>
|
||||
{{
|
||||
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
|
||||
? detailPrescription.usage_days + ' 天'
|
||||
: '—'
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">订单设置:</span>
|
||||
{{
|
||||
@@ -1210,7 +1139,7 @@
|
||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本">
|
||||
@@ -1596,11 +1525,10 @@
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in editServicePackageSelectOptions"
|
||||
v-for="item in servicePackageOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
:disabled="item.status === 0"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -1893,12 +1821,10 @@
|
||||
/>
|
||||
<el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip">
|
||||
<el-form-item label="发货方式">
|
||||
<el-tag
|
||||
:type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'"
|
||||
effect="plain"
|
||||
size="default"
|
||||
>{{ shipDialogModeDisplay }}</el-tag>
|
||||
<span class="text-xs text-gray-400 ml-2">请在订单详情顶部「发货类型」中设置,此处不可修改</span>
|
||||
<el-radio-group v-model="shipForm.ship_mode">
|
||||
<el-radio label="gancao">甘草药方发</el-radio>
|
||||
<el-radio label="direct">药房直发</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="shipForm.express_company" class="w-full">
|
||||
@@ -2225,93 +2151,6 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改服用参数:主方 / 辅方 / 订单设置 -->
|
||||
<el-dialog
|
||||
v-model="patchUsageVisible"
|
||||
title="修改服用参数"
|
||||
width="92%"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
class="po-h5-dialog"
|
||||
@closed="resetPatchUsageForm"
|
||||
>
|
||||
<el-form
|
||||
ref="patchUsageFormRef"
|
||||
:model="patchUsageForm"
|
||||
:rules="patchUsageRules"
|
||||
label-width="96px"
|
||||
>
|
||||
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
|
||||
<el-form-item label="每天次数" prop="times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.times_per_day"
|
||||
:min="1"
|
||||
:max="6"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="处方开立" prop="usage_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.usage_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="detailHasAuxHerbs">
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
|
||||
<el-form-item label="每天次数" prop="aux_times_per_day">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_times_per_day"
|
||||
:min="1"
|
||||
:max="6"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="处方开立" prop="aux_usage_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.aux_usage_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
|
||||
<el-form-item label="服用天数" prop="medication_days">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<el-input-number
|
||||
v-model="patchUsageForm.medication_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="patchUsageVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 处方详情查看(处方单样式) -->
|
||||
<el-drawer
|
||||
v-model="prescriptionViewVisible"
|
||||
@@ -2707,13 +2546,11 @@ import {
|
||||
User
|
||||
} from '@element-plus/icons-vue'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
prescriptionOrderAuditPrescription,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderDdcode,
|
||||
prescriptionOrderEdit,
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderPaidPayOrders,
|
||||
@@ -2728,28 +2565,15 @@ import {
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
prescriptionOrderRevokePayAudit,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
prescriptionOrderPatchPrescriptionUsage,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderRequestCompletion,
|
||||
prescriptionOrderUploadToPharmacy,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
prescriptionOrderPreviewGancaoRecipel,
|
||||
prescriptionDetail,
|
||||
getDoctors,
|
||||
getAssistants
|
||||
} from '@/api/tcm'
|
||||
import {
|
||||
formatDietaryTaboo,
|
||||
type ServicePackageOption,
|
||||
normalizeServicePackageOptions,
|
||||
parseServicePackageValues,
|
||||
mergeServicePackageSelectOptions,
|
||||
formatServicePackageLabels,
|
||||
normalizeSlipAuxUsageForm,
|
||||
prescriptionHasAuxFormula,
|
||||
isRemoteSnapshotLocked,
|
||||
supplyModeLabel,
|
||||
supplyModeTagType
|
||||
} from './components/prescription-order-utils'
|
||||
import { formatDietaryTaboo } from './components/prescription-order-utils'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -2845,7 +2669,7 @@ const canViewFinanceFields = () => {
|
||||
const regionOptions = ref([])
|
||||
|
||||
// 服务套餐选项
|
||||
const servicePackageOptions = ref<ServicePackageOption[]>([])
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
|
||||
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
@@ -2966,7 +2790,8 @@ const loadRegionData = async () => {
|
||||
const loadServicePackageOptions = async () => {
|
||||
try {
|
||||
const data = await getDictData({ type: 'server_order' })
|
||||
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
|
||||
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
|
||||
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
|
||||
} catch (error) {
|
||||
console.error('加载服务套餐选项失败:', error)
|
||||
servicePackageOptions.value = []
|
||||
@@ -2999,13 +2824,12 @@ function handleFulfillmentStatusTabClick(value: number | '') {
|
||||
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
||||
|
||||
const supplyModeTabs = [
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
|
||||
{ label: '甘草', value: 'gancao' as const },
|
||||
{ label: '洛阳直发', value: 'direct' as const },
|
||||
{ label: '自营', value: 'self' as const }
|
||||
]
|
||||
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
|
||||
queryParams.supply_mode = value
|
||||
resetPage()
|
||||
}
|
||||
@@ -3039,8 +2863,8 @@ const queryParams = reactive({
|
||||
fulfillment_status: '' as number | '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
|
||||
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||
audit_admin_id: '' as number | ''
|
||||
})
|
||||
@@ -3375,6 +3199,28 @@ function feeTypeText(t: number | undefined) {
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
// 格式化服务套餐显示
|
||||
function formatServicePackage(value: any): string {
|
||||
if (!value) return '—'
|
||||
|
||||
let packages: string[] = []
|
||||
if (Array.isArray(value)) {
|
||||
packages = value
|
||||
} else if (typeof value === 'string') {
|
||||
packages = value.split(',').filter(v => v.trim() !== '')
|
||||
}
|
||||
|
||||
if (packages.length === 0) return '—'
|
||||
|
||||
// 将值转换为名称
|
||||
const names = packages.map(val => {
|
||||
const option = servicePackageOptions.value.find(opt => opt.value === val)
|
||||
return option ? option.name : val
|
||||
})
|
||||
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
function auditStatusText(s: number | undefined) {
|
||||
if (s === 1) return '已通过'
|
||||
if (s === 2) return '已驳回'
|
||||
@@ -3475,7 +3321,13 @@ function canEditRow(row: {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
|
||||
if (gcLocked) {
|
||||
return [1, 2, 5, 7].includes(fs)
|
||||
}
|
||||
|
||||
return fs === 1 || fs === 2
|
||||
}
|
||||
@@ -3502,7 +3354,6 @@ function canRevokeRxAudit(row: {
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
}) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4 || fs === 6) return false
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
@@ -3525,7 +3376,6 @@ function canRevokePayAudit(row: {
|
||||
}
|
||||
|
||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
// 只有「待双审通过」可撤回,已发货后不可撤回
|
||||
return Number(row.fulfillment_status) === 1
|
||||
}
|
||||
@@ -3552,30 +3402,18 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
|
||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
|
||||
return true
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadPharmacyRow(row: {
|
||||
function canUploadGancaoRow(row: {
|
||||
prescription_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
gancao_reciperl_order_no?: string
|
||||
ej_pharmacy_order_no?: string
|
||||
ej_pharmacy_status?: string
|
||||
ej_pharmacy_review_status?: string
|
||||
can_upload_pharmacy?: boolean
|
||||
gancao_reciperl_order_no?: string
|
||||
}) {
|
||||
if (row.can_upload_pharmacy === false) return false
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const fulfillmentStatus = Number(row.fulfillment_status)
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const ejOrderNo = String(row.ej_pharmacy_order_no || '').trim()
|
||||
const ejRejected = String(row.ej_pharmacy_status || '').toUpperCase() === 'REJECTED'
|
||||
|| String(row.ej_pharmacy_review_status || '').toUpperCase() === 'REJECTED'
|
||||
return rxStatus === 1
|
||||
&& (![3, 4, 8, 9, 10, 11, 12].includes(fulfillmentStatus) || (fulfillmentStatus === 9 && ejRejected))
|
||||
&& gancaoOrderNo === ''
|
||||
&& (ejOrderNo === '' || ejRejected)
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
}
|
||||
|
||||
function orderStatusText(s: number | undefined) {
|
||||
@@ -3613,7 +3451,7 @@ const h5ActiveFilterCount = computed(() => {
|
||||
|
||||
// H5: 判断列表卡片「更多」下拉是否需要展示
|
||||
function hasMoreCardActions(row: Record<string, any>) {
|
||||
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadPharmacyRow(row) || canWithdrawRow(row)
|
||||
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
|
||||
}
|
||||
|
||||
// H5: 处理列表卡片「更多」下拉指令
|
||||
@@ -3621,7 +3459,7 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
||||
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
|
||||
else if (cmd === 'complete') confirmComplete(row as any)
|
||||
else if (cmd === 'refund') openRefundOrder(row as any)
|
||||
else if (cmd === 'uploadPharmacy') confirmUploadToPharmacy(row as any)
|
||||
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any)
|
||||
else if (cmd === 'withdraw') confirmWithdraw(row as any)
|
||||
}
|
||||
|
||||
@@ -3635,10 +3473,6 @@ const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<Record<string, any> | null>(null)
|
||||
|
||||
const detailServicePackageText = computed(() =>
|
||||
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
||||
)
|
||||
|
||||
// --- 订单可视化审批履约流程逻辑 ---
|
||||
const workflowActiveStep = computed(() => {
|
||||
if (!detailData.value) return 0
|
||||
@@ -3795,16 +3629,6 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
|
||||
|
||||
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
|
||||
|
||||
const detailHasAuxHerbs = computed(() => prescriptionHasAuxFormula(detailPrescription.value as any))
|
||||
|
||||
const detailAuxUsage = computed(() => {
|
||||
const rx = detailPrescription.value as any
|
||||
if (!rx || !prescriptionHasAuxFormula(rx)) return null
|
||||
const raw = rx.aux_usage
|
||||
if (raw == null || raw === '' || (Array.isArray(raw) && raw.length === 0)) return null
|
||||
return normalizeSlipAuxUsageForm(raw, rx.prescription_type || '浓缩水丸')
|
||||
})
|
||||
|
||||
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
|
||||
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
|
||||
|
||||
@@ -3953,8 +3777,6 @@ function logActionText(act: string) {
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
ej_pharmacy_submit: '洛阳药房下单',
|
||||
ej_pharmacy_callback: '洛阳药房状态',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
@@ -4033,101 +3855,6 @@ const patchRxPatientRules: FormRules = {
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const patchUsageVisible = ref(false)
|
||||
const patchUsageSaving = ref(false)
|
||||
const patchUsageFormRef = ref<FormInstance>()
|
||||
const patchUsageForm = reactive({
|
||||
times_per_day: 3 as number | undefined,
|
||||
usage_days: 7 as number | undefined,
|
||||
aux_times_per_day: 3 as number | undefined,
|
||||
aux_usage_days: 7 as number | undefined,
|
||||
medication_days: undefined as number | undefined
|
||||
})
|
||||
const patchUsageRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {
|
||||
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
|
||||
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
|
||||
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
|
||||
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
|
||||
}
|
||||
return rules
|
||||
})
|
||||
|
||||
function openPatchUsageDialog() {
|
||||
const rx = detailPrescription.value
|
||||
const ord = detailData.value
|
||||
if (!rx || !ord?.id || !ord.prescription_id) {
|
||||
feedback.msgWarning('无处方数据')
|
||||
return
|
||||
}
|
||||
const aux = detailAuxUsage.value
|
||||
patchUsageForm.times_per_day =
|
||||
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
|
||||
patchUsageForm.usage_days =
|
||||
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageForm.aux_times_per_day =
|
||||
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
|
||||
patchUsageForm.aux_usage_days =
|
||||
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
|
||||
const md = Number(ord.medication_days)
|
||||
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
|
||||
patchUsageVisible.value = true
|
||||
nextTick(() => patchUsageFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function resetPatchUsageForm() {
|
||||
patchUsageForm.times_per_day = 3
|
||||
patchUsageForm.usage_days = 7
|
||||
patchUsageForm.aux_times_per_day = 3
|
||||
patchUsageForm.aux_usage_days = 7
|
||||
patchUsageForm.medication_days = undefined
|
||||
}
|
||||
|
||||
async function submitPatchUsage() {
|
||||
const form = patchUsageFormRef.value
|
||||
if (!form) return
|
||||
try {
|
||||
await form.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const ordId = detailData.value?.id
|
||||
if (!ordId) return
|
||||
patchUsageSaving.value = true
|
||||
try {
|
||||
const payload: {
|
||||
id: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
} = {
|
||||
id: ordId,
|
||||
times_per_day: Number(patchUsageForm.times_per_day),
|
||||
usage_days: Number(patchUsageForm.usage_days),
|
||||
medication_days: Number(patchUsageForm.medication_days)
|
||||
}
|
||||
if (detailHasAuxHerbs.value) {
|
||||
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
|
||||
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
|
||||
}
|
||||
await prescriptionOrderPatchPrescriptionUsage(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
patchUsageVisible.value = false
|
||||
await refreshCurrentPrescriptionOrderDetail()
|
||||
await fetchLogs(ordId)
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
patchUsageSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPatchRxPatientDialog() {
|
||||
const rx = detailPrescription.value
|
||||
const ord = detailData.value
|
||||
@@ -4333,10 +4060,6 @@ const editForm = reactive({
|
||||
diagnosis_creator_dept_path: ''
|
||||
})
|
||||
|
||||
const editServicePackageSelectOptions = computed(() =>
|
||||
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
|
||||
)
|
||||
|
||||
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
|
||||
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
|
||||
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
|
||||
@@ -4587,7 +4310,18 @@ async function openEdit(row: {
|
||||
editForm.dose_unit = d.dose_unit || '剂'
|
||||
editForm.prev_staff = d.prev_staff || ''
|
||||
editForm.service_channel = d.service_channel || ''
|
||||
editForm.service_package = parseServicePackageValues(d.service_package)
|
||||
// 处理服务套餐:如果是字符串,转换为数组
|
||||
if (d.service_package) {
|
||||
if (Array.isArray(d.service_package)) {
|
||||
editForm.service_package = d.service_package
|
||||
} else if (typeof d.service_package === 'string') {
|
||||
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
|
||||
} else {
|
||||
editForm.service_package = []
|
||||
}
|
||||
} else {
|
||||
editForm.service_package = []
|
||||
}
|
||||
editForm.express_company = String(d.express_company || 'auto') || 'auto'
|
||||
editForm.tracking_number = d.tracking_number || ''
|
||||
editForm.fee_type = Number(d.fee_type) || 3
|
||||
@@ -4747,7 +4481,7 @@ async function confirmWithdraw(row: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const pharmacySubmitId = ref(0)
|
||||
const gancaoSubmitId = ref(0)
|
||||
const gancaoPreviewDrawerVisible = ref(false)
|
||||
const gancaoPreviewLoading = ref(false)
|
||||
const gancaoPreviewData = ref<any>(null)
|
||||
@@ -4839,19 +4573,18 @@ async function testGancaoPreviewFromDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
|
||||
async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
try {
|
||||
const target = String(row.ship_mode || 'gancao') === 'direct' ? '洛阳药房' : '甘草药房'
|
||||
await ElMessageBox.confirm(
|
||||
`确认将本单关联处方上传至${target}?`,
|
||||
'上传药房',
|
||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
||||
'上传甘草药方',
|
||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||
)
|
||||
pharmacySubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
|
||||
gancaoSubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
||||
const d = res?.data ?? res
|
||||
const no = String(d?.pharmacy_order_no || d?.recipel_order_no || '').trim()
|
||||
feedback.msgSuccess(no ? `上传成功,药房单号:${no}` : '上传成功')
|
||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
||||
getLists()
|
||||
if (detailVisible.value && Number(detailData.value?.id) === row.id) {
|
||||
try {
|
||||
@@ -4867,7 +4600,7 @@ async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string })
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
} finally {
|
||||
pharmacySubmitId.value = 0
|
||||
gancaoSubmitId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4901,8 +4634,33 @@ async function submitQuickTrack() {
|
||||
}
|
||||
quickTrackSaving.value = true
|
||||
try {
|
||||
await prescriptionOrderDdcode({
|
||||
id: quickTrackRowId.value,
|
||||
// 先拉详情,获取所有必填字段,再只覆盖快递信息
|
||||
const res: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
|
||||
const d = res?.data ?? res
|
||||
if (!d) {
|
||||
feedback.msgError('加载订单数据失败')
|
||||
return
|
||||
}
|
||||
await prescriptionOrderEdit({
|
||||
id: d.id,
|
||||
recipient_name: d.recipient_name || '',
|
||||
recipient_phone: d.recipient_phone || '',
|
||||
shipping_province: d.shipping_province || '',
|
||||
shipping_city: d.shipping_city || '',
|
||||
shipping_district: d.shipping_district || '',
|
||||
shipping_address: d.shipping_address || '',
|
||||
is_follow_up: d.is_follow_up ? 1 : 0,
|
||||
medication_days: (d.medication_days != null && String(d.medication_days).trim() !== '') ? d.medication_days : '',
|
||||
prev_staff: d.prev_staff || '',
|
||||
service_channel: d.service_channel || '',
|
||||
service_package: d.service_package || '',
|
||||
fee_type: Number(d.fee_type) || 3,
|
||||
amount: Number(d.amount) || 0,
|
||||
remark_extra: d.remark_extra || '',
|
||||
remark_assistant: d.remark_assistant || '',
|
||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
||||
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||
// 仅覆盖快递字段
|
||||
express_company: quickTrackForm.express_company || 'auto',
|
||||
tracking_number: quickTrackForm.tracking_number.trim()
|
||||
})
|
||||
@@ -4915,9 +4673,19 @@ async function submitQuickTrack() {
|
||||
const r: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
|
||||
const nd = r?.data ?? r ?? null
|
||||
if (nd) detailData.value = nd
|
||||
await fetchLogs(quickTrackRowId.value)
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
// 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗
|
||||
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
|
||||
'确认发货',
|
||||
{ type: 'success', confirmButtonText: '确认发货', cancelButtonText: '稍后再说' }
|
||||
)
|
||||
openShip({ id: quickTrackRowId.value, tracking_number: quickTrackForm.tracking_number, express_company: quickTrackForm.express_company })
|
||||
} catch { /* 用户点了「稍后」 */ }
|
||||
}
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
@@ -4935,10 +4703,6 @@ const shipForm = reactive({
|
||||
tracking_number: ''
|
||||
})
|
||||
|
||||
const shipDialogModeDisplay = computed(() =>
|
||||
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
|
||||
)
|
||||
|
||||
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
||||
shipRowId.value = row.id
|
||||
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
|
||||
@@ -4978,13 +4742,6 @@ async function submitShip() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGancaoSubmissionResolved() {
|
||||
getLists()
|
||||
if (detailData.value?.id) {
|
||||
await openDetail(Number(detailData.value.id))
|
||||
}
|
||||
}
|
||||
|
||||
const completeOrderStatusOptions = [
|
||||
{ value: 3, label: '已完成' },
|
||||
{ value: 7, label: '进行中' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="code-generation">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="mb-[-16px]" :model="formData" inline>
|
||||
<el-form-item class="w-[280px]" label="表名称">
|
||||
<el-input v-model="formData.table_name" clearable @keyup.enter="resetPage" />
|
||||
@@ -13,8 +13,8 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel>
|
||||
<div class="flex">
|
||||
<data-table
|
||||
v-perms="['tools.generator/selectTable']"
|
||||
@@ -126,10 +126,10 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<code-preview
|
||||
v-if="previewState.show"
|
||||
v-model="previewState.show"
|
||||
|
||||
@@ -866,7 +866,7 @@ onUnmounted(() => {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #6366f1 0%, #8b5cf6 100%);
|
||||
background: linear-gradient(145deg, #0d9488 0%, #0891b2 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="error">
|
||||
<div>
|
||||
<div class="error-panel">
|
||||
<slot name="content">
|
||||
<div class="error-code">{{ code }}</div>
|
||||
</slot>
|
||||
<div class="text-lg text-tx-secondary mt-7 mb-7">{{ title }}</div>
|
||||
<el-button v-if="showBtn" type="primary" @click="router.go(-1)">
|
||||
<div class="error-title">{{ title }}</div>
|
||||
<el-button v-if="showBtn" type="primary" size="large" @click="router.go(-1)">
|
||||
{{ second }} 秒后返回上一页
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -43,16 +43,38 @@ onUnmounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
.error {
|
||||
text-align: center;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.error-code {
|
||||
@apply text-primary;
|
||||
font-size: 150px;
|
||||
}
|
||||
.el-button {
|
||||
width: 176px;
|
||||
}
|
||||
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 {
|
||||
@apply text-primary;
|
||||
font-size: 96px;
|
||||
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 {
|
||||
min-width: 176px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="fans-management">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="ls-form" :model="formData" inline>
|
||||
<el-form-item class="w-[280px]" label="姓名">
|
||||
<el-input
|
||||
@@ -36,14 +36,14 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button type="primary" @click="handleAdd">新增粉丝</el-button>
|
||||
</div>
|
||||
<el-table :data="pager.lists" size="large" v-loading="pager.loading">
|
||||
</template>
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" width="70" />
|
||||
<el-table-column label="姓名" prop="name" min-width="100" />
|
||||
<el-table-column label="手机号" prop="phone" min-width="130" />
|
||||
@@ -81,10 +81,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex mt-4 justify-end">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
|
||||
<!-- 新增/编辑粉丝弹窗 -->
|
||||
<el-dialog
|
||||
|
||||
@@ -3704,7 +3704,7 @@ function getYejiDeptComboOption(tb: YejiTable) {
|
||||
const cats = rows.map(r =>
|
||||
r.dept_name.length > 10 ? `${r.dept_name.slice(0, 10)}…` : r.dept_name
|
||||
)
|
||||
const palette = ['#64748b', '#38bdf8', '#6366f1']
|
||||
const palette = ['#64748b', '#38bdf8', '#0d9488']
|
||||
const series: any[] = [
|
||||
{
|
||||
name: '进线',
|
||||
@@ -4029,8 +4029,8 @@ onMounted(async () => {
|
||||
|
||||
.yeji-page {
|
||||
min-width: 0;
|
||||
--yj-brand: #6366f1;
|
||||
--yj-brand-soft: #eef2ff;
|
||||
--yj-brand: #0d9488;
|
||||
--yj-brand-soft: #f0fdfa;
|
||||
--yj-teal: #0ea5e9;
|
||||
--yj-teal-soft: #f0f9ff;
|
||||
--yj-accent: #f43f5e;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="温馨提示:用户账户变动记录"
|
||||
@@ -38,9 +38,9 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="用户账号" prop="account" min-width="100" />
|
||||
<el-table-column label="用户昵称" min-width="160">
|
||||
<template #default="{ row }">
|
||||
@@ -71,10 +71,10 @@
|
||||
<el-table-column label="来源单号" prop="source_sn" min-width="100" />
|
||||
<el-table-column label="记录时间" prop="create_time" min-width="120" />
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="balanceDetail">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="温馨提示:用户充值记录"
|
||||
@@ -54,9 +54,9 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="用户信息" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center">
|
||||
@@ -104,10 +104,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="rechargeRecord">
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px] mt-[16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="退款单号">
|
||||
<el-input
|
||||
@@ -69,8 +69,8 @@
|
||||
/> -->
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel>
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane
|
||||
v-for="(item, index) in tabLists"
|
||||
@@ -78,7 +78,7 @@
|
||||
:name="index"
|
||||
:key="index"
|
||||
>
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="退款单号" prop="sn" min-width="190" />
|
||||
<el-table-column label="用户信息" min-width="160">
|
||||
<template #default="{ row }">
|
||||
@@ -140,10 +140,10 @@
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<refund-log v-model="showRefundLog" :refund-id="selectRefundId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
<template>
|
||||
<div class="conversion-page" v-loading="loading" element-loading-text="正在汇总权限范围内的数据">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>综合数据转化</h1>
|
||||
<p>{{ scopeDescription }}</p>
|
||||
</div>
|
||||
<div class="heading-meta">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item filter-item--time">
|
||||
<span class="filter-label">时间范围</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">搜索员工</span>
|
||||
<el-select
|
||||
v-model="query.assistant_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部员工"
|
||||
class="employee-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.assistants"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDeptChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">渠道</span>
|
||||
<el-select
|
||||
v-model="query.media_channel_code"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部渠道"
|
||||
class="channel-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.media_channels"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<span class="range-text">{{ dashboard.meta.start_date }} 至 {{ dashboard.meta.end_date }}</span>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="综合转化指标">
|
||||
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门订单量占比</h2>
|
||||
<p>按订单创建人归属,排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.orders.length" class="bar-list">
|
||||
<div v-for="item in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 单</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门金额占比</h2>
|
||||
<p>仅统计未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="item in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel detail-panel">
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/预约,接诊率=接诊诊单/加粉</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="dashboard.rows"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
class="detail-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门 / 人员" min-width="250" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
|
||||
{{ row.name }}
|
||||
</strong>
|
||||
<el-tag v-if="row.type === 'member' && row.role" size="small" type="info" effect="plain" class="role-tag">
|
||||
{{ row.role }}{{ row.is_leader ? ' · 组长' : '' }}
|
||||
</el-tag>
|
||||
<el-tag v-else-if="row.type === 'unbound'" size="small" type="warning" effect="plain" class="role-tag">
|
||||
未绑定账号
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
|
||||
<el-table-column label="接诊金额" min-width="120" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开口率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊接诊率" min-width="116" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接诊率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ROI" min-width="86" align="right">
|
||||
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel target-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>一诊诊金目标追踪</h2>
|
||||
<p>{{ dashboard.target.year }} 年 · 按当前角色、部门及员工筛选范围计算</p>
|
||||
</div>
|
||||
<span>{{ dashboard.target.department_count }} 个目标部门</span>
|
||||
</div>
|
||||
<div class="target-layout">
|
||||
<div class="target-progress-list">
|
||||
<div class="target-summary">
|
||||
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
|
||||
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
|
||||
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-copy">
|
||||
<span>年度范围目标</span>
|
||||
<b>{{ nullablePercent(dashboard.target.completion_rate) }}</b>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.completion_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="12"
|
||||
color="#0f9185"
|
||||
/>
|
||||
<small>已完成 {{ formatMoney(dashboard.target.actual_amount) }} / 目标 {{ formatMoney(dashboard.target.target_amount) }}</small>
|
||||
</div>
|
||||
|
||||
<div class="progress-block progress-block--month">
|
||||
<div class="progress-copy">
|
||||
<span>本月范围目标</span>
|
||||
<b>{{ nullablePercent(dashboard.target.current_month_rate) }}</b>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.current_month_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="12"
|
||||
color="#2f78df"
|
||||
/>
|
||||
<small>已完成 {{ formatMoney(dashboard.target.current_month_actual) }} / 目标 {{ formatMoney(dashboard.target.current_month_target) }}</small>
|
||||
</div>
|
||||
|
||||
<div v-if="Number(dashboard.target.target_amount) <= 0" class="target-empty-note">
|
||||
当前可见部门尚未维护本年度月度目标,实际诊单金额仍会正常统计。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-wrap">
|
||||
<div class="trend-title">
|
||||
<span>年度累计趋势</span>
|
||||
<div><i class="legend-line is-actual" />实际完成 <i class="legend-line is-target" />目标值</div>
|
||||
</div>
|
||||
<v-charts class="target-chart" :option="targetChartOption" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitConversionPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Refresh } from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
|
||||
|
||||
type MetricType = 'count' | 'money' | 'ratio'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[],
|
||||
assistants: [] as Array<{ id: number; name: string }>,
|
||||
media_channels: [] as Array<{ code: string; name: string }>
|
||||
},
|
||||
summary: {} as Record<string, any>,
|
||||
rankings: { orders: [] as any[], amounts: [] as any[] },
|
||||
rows: [] as any[],
|
||||
target: {
|
||||
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0, completion_rate: null as number | null,
|
||||
current_month_target: 0, current_month_actual: 0, current_month_rate: null as number | null,
|
||||
department_count: 0, months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
|
||||
}
|
||||
})
|
||||
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const loading = ref(false)
|
||||
const query = reactive<FirstVisitConversionParams>({
|
||||
time_type: 'today',
|
||||
dept_id: undefined,
|
||||
assistant_id: undefined,
|
||||
media_channel_code: ''
|
||||
})
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '昨天', value: 'yesterday' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' },
|
||||
{ label: '本季度', value: 'quarter' },
|
||||
{ label: '本年', value: 'year' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
|
||||
{ key: 'avg_unit_price', label: '平均客单价', type: 'money', hint: '诊单金额 / 接诊诊单' },
|
||||
{ key: 'account_cost', label: '现金成本', type: 'money', hint: '当前范围内实际投放成本' },
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', hint: '诊单金额 / 投放成本' }
|
||||
]
|
||||
|
||||
const scopeDescription = computed(() => {
|
||||
const parts = [`${dashboard.meta.time_label || '当前区间'}数据`, dashboard.meta.scope_label || '当前权限范围']
|
||||
if (dashboard.meta.selected_dept_name) parts.push(dashboard.meta.selected_dept_name)
|
||||
if (dashboard.meta.selected_assistant_name) parts.push(dashboard.meta.selected_assistant_name)
|
||||
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
|
||||
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
color: ['#0f9185', '#2f78df'],
|
||||
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
|
||||
grid: { left: 62, right: 24, top: 28, bottom: 36 },
|
||||
xAxis: { type: 'category', boundaryGap: false, data: dashboard.target.months, axisLine: { lineStyle: { color: '#d9e1e9' } }, axisLabel: { color: '#718096' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#718096', formatter: (value: number) => compactNumber(value) }, splitLine: { lineStyle: { color: '#edf1f5' } } },
|
||||
series: [
|
||||
{ name: '实际完成', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 3 }, areaStyle: { color: 'rgba(15,145,133,.08)' }, data: dashboard.target.actual_cumulative },
|
||||
{ name: '目标值', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 2, type: 'dashed' }, data: dashboard.target.target_cumulative }
|
||||
]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: any = await firstVisitConversionOverview(query)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '综合数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeptChange() {
|
||||
query.assistant_id = undefined
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatMetric(key: string, type: MetricType) {
|
||||
const value = dashboard.summary[key]
|
||||
if (type === 'money') return formatMoney(value)
|
||||
if (type === 'ratio') return formatRatio(value)
|
||||
return formatNumber(value)
|
||||
}
|
||||
|
||||
function formatNumber(value: any) {
|
||||
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatMoney(value: any) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function nullablePercent(value: any) {
|
||||
return value === null || value === undefined ? '未设置' : formatPercent(value)
|
||||
}
|
||||
|
||||
function formatRatio(value: any) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function compactNumber(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(0)}万`
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function barWidth(value: any, maximum: number) {
|
||||
if (maximum <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%`
|
||||
}
|
||||
|
||||
function progressValue(value: any) {
|
||||
return Math.max(0, Math.min(100, Number(value || 0)))
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 15px 18px;
|
||||
border-radius: 10px;
|
||||
|
||||
h1 { margin: 0; font-size: 20px; font-weight: 750; }
|
||||
p { margin: 5px 0 0; color: #8590a2; font-size: 12px; }
|
||||
}
|
||||
|
||||
.heading-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #8994a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #0d8077;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label, .range-text { color: #748094; font-size: 12px; white-space: nowrap; }
|
||||
.range-text { margin-left: auto; }
|
||||
.employee-select { width: 190px; }
|
||||
.dept-select { width: 220px; }
|
||||
.channel-select { width: 180px; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 100px;
|
||||
padding: 15px 17px;
|
||||
border-radius: 10px;
|
||||
|
||||
span, small { display: block; color: #748094; font-size: 12px; }
|
||||
strong { display: block; margin: 9px 0 7px; color: #111b2f; font-size: 25px; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
small { color: #a0a9b6; font-size: 11px; }
|
||||
}
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { padding: 16px; border-radius: 10px; }
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 { margin: 0; font-size: 15px; font-weight: 700; }
|
||||
p { margin: 4px 0 0; color: #929dac; font-size: 11px; }
|
||||
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.bar-list { display: grid; gap: 13px; }
|
||||
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; }
|
||||
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
|
||||
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
|
||||
.bar-track i.is-teal { background: #15998d; }
|
||||
.bar-track i.is-blue { background: #307bdf; }
|
||||
|
||||
.detail-panel { padding-bottom: 10px; }
|
||||
.detail-table {
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
|
||||
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
|
||||
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
|
||||
strong.is-parent { color: #172033; font-weight: 700; }
|
||||
strong.is-member { color: #314158; font-weight: 600; }
|
||||
.role-tag { margin-left: 7px; vertical-align: middle; }
|
||||
}
|
||||
|
||||
.target-panel { padding-bottom: 18px; }
|
||||
.target-layout { display: grid; grid-template-columns: minmax(390px, .82fr) minmax(500px, 1.18fr); gap: 22px; }
|
||||
.target-progress-list { padding-right: 20px; border-right: 1px solid #e4e9ef; }
|
||||
.target-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
.target-summary div { display: grid; gap: 5px; }
|
||||
.target-summary span { color: #8590a2; font-size: 11px; }
|
||||
.target-summary strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.target-summary strong.is-teal { color: #0f9185; }
|
||||
.progress-block + .progress-block { margin-top: 18px; }
|
||||
.progress-copy { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||
.progress-copy b { color: #263246; font-size: 13px; }
|
||||
.progress-block small { display: block; margin-top: 7px; color: #8a95a5; font-size: 11px; }
|
||||
.target-empty-note { margin-top: 16px; padding: 9px 11px; border-radius: 7px; color: #a36b1e; background: #fff7e8; font-size: 11px; }
|
||||
.trend-title { display: flex; align-items: center; justify-content: space-between; color: #2e3a4d; font-size: 12px; font-weight: 600; }
|
||||
.trend-title > div { display: flex; align-items: center; gap: 7px; color: #7f8a9b; font-size: 10px; font-weight: 400; }
|
||||
.legend-line { width: 18px; border-top: 2px solid; }
|
||||
.legend-line.is-actual { border-color: #0f9185; }
|
||||
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
|
||||
.target-chart { width: 100%; height: 260px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.range-text { margin-left: 0; }
|
||||
.target-layout { grid-template-columns: 1fr; }
|
||||
.target-progress-list { padding-right: 0; padding-bottom: 18px; border-right: 0; border-bottom: 1px solid #e4e9ef; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.conversion-page { padding: 10px; }
|
||||
.page-heading, .heading-meta { align-items: flex-start; flex-direction: column; }
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
|
||||
.employee-select, .dept-select, .channel-select { width: 100%; }
|
||||
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
|
||||
.target-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,709 +0,0 @@
|
||||
<template>
|
||||
<div class="doctor-dashboard" v-loading="loading" element-loading-text="正在汇总医生经营数据">
|
||||
<header class="page-heading">
|
||||
<div class="heading-copy">
|
||||
<span class="heading-mark"><el-icon><DataLine /></el-icon></span>
|
||||
<div>
|
||||
<h1>医生看板</h1>
|
||||
<p>从挂号、预约、面诊到接诊成交,统一观察医生经营表现</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
<el-button :icon="Download" @click="exportRows">导出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item filter-item--time">
|
||||
<span>时间范围</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div v-if="dashboard.filters.can_filter_department" class="filter-item">
|
||||
<span>所属部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDepartmentChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span>医生</span>
|
||||
<el-select
|
||||
v-model="query.doctor_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部医生"
|
||||
class="doctor-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="doctor in dashboard.filters.doctors"
|
||||
:key="doctor.id"
|
||||
:value="Number(doctor.id)"
|
||||
:label="`${doctor.name}${Number(doctor.disable) === 1 ? '(停用)' : ''}`"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item filter-item--status">
|
||||
<span>医生范围</span>
|
||||
<el-segmented v-model="query.active_only" :options="doctorStatusOptions" @change="handleActiveChange" />
|
||||
</div>
|
||||
<div class="view-switch">
|
||||
<button :class="{ active: viewMode === 'overview' }" @click="viewMode = 'overview'">诊断总览</button>
|
||||
<button :class="{ active: viewMode === 'detail' }" @click="viewMode = 'detail'">医生明细</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="医生经营核心指标">
|
||||
<article class="metric-card metric-card--teal">
|
||||
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
|
||||
<div>
|
||||
<span>总挂号</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.registration_total) }}</strong>
|
||||
<small>已支付且实收金额大于 0、低于 10 元的订单</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--green">
|
||||
<div class="metric-icon"><el-icon><User /></el-icon></div>
|
||||
<div>
|
||||
<span>总面诊</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.interview_count) }}</strong>
|
||||
<small>过号 {{ formatNumber(dashboard.summary.missed_count) }} · 取消 {{ formatNumber(dashboard.summary.cancelled_count) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--indigo">
|
||||
<div class="metric-icon"><el-icon><Tickets /></el-icon></div>
|
||||
<div>
|
||||
<span>总诊单</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
|
||||
<small>接诊转化率 {{ formatPercent(dashboard.summary.receive_conversion_rate) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--blue">
|
||||
<div class="metric-icon"><el-icon><Money /></el-icon></div>
|
||||
<div>
|
||||
<span>总成交金额</span>
|
||||
<strong>{{ formatMoney(dashboard.summary.deal_amount) }}</strong>
|
||||
<small>客单价 {{ nullableMoney(dashboard.summary.avg_order_amount) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--orange">
|
||||
<div class="metric-icon"><el-icon><DataLine /></el-icon></div>
|
||||
<div>
|
||||
<span>总接诊率</span>
|
||||
<strong>{{ formatPercent(dashboard.summary.receive_conversion_rate) }}</strong>
|
||||
<small>总接诊 {{ formatNumber(dashboard.summary.order_count) }} ÷ 总面诊 {{ formatNumber(dashboard.summary.interview_count) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--cyan">
|
||||
<div class="metric-icon"><el-icon><CircleCheck /></el-icon></div>
|
||||
<div>
|
||||
<span>预约完成率</span>
|
||||
<strong>{{ formatPercent(dashboard.summary.appointment_completion_rate) }}</strong>
|
||||
<small>总预约 {{ formatNumber(dashboard.summary.appointment_total) }} · {{ dashboard.meta.doctor_count }} 位有数据医生</small>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<template v-if="viewMode === 'overview'">
|
||||
<section class="two-column-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>成交金额 TOP</h2><p>按有效诊单金额从高到低</p></div>
|
||||
<span>{{ dashboard.meta.time_label }}</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.doctor_id}`" class="bar-row">
|
||||
<b>{{ index + 1 }}</b>
|
||||
<span :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmount) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="当前范围暂无成交金额" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>接诊转化率 TOP</h2><p>有效诊单数 ÷ 完成面诊数</p></div>
|
||||
<span>{{ dashboard.meta.time_label }}</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.conversion.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.conversion" :key="`rate-${item.doctor_id}`" class="bar-row">
|
||||
<b>{{ index + 1 }}</b>
|
||||
<span :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: percentageWidth(item.value) }" /></div>
|
||||
<strong>{{ formatPercent(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="当前范围暂无转化数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="analytics-grid">
|
||||
<article class="panel trend-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>近 30 天成交金额趋势</h2><p>{{ dashboard.trend.start_date }} 至 {{ dashboard.trend.end_date }}</p></div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<v-charts class="trend-chart" :option="trendChartOption" autoresize />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel alert-panel" :class="{ 'has-alerts': dashboard.alerts.length }">
|
||||
<div class="panel-heading alert-heading">
|
||||
<div>
|
||||
<h2><el-icon><WarningFilled /></el-icon>需关注医生</h2>
|
||||
<p>仅对完成过面诊的医生计算接诊转化预警</p>
|
||||
</div>
|
||||
<div class="threshold-control">
|
||||
<span>接诊转化率低于</span>
|
||||
<el-select v-model="query.alert_threshold" class="threshold-select" @change="loadDashboard">
|
||||
<el-option v-for="value in thresholdOptions" :key="value" :label="`${value}%`" :value="value" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="dashboard.alerts.length" class="alert-list">
|
||||
<div v-for="item in dashboard.alerts" :key="item.doctor_id" class="alert-row">
|
||||
<span class="alert-icon">!</span>
|
||||
<div class="alert-doctor"><strong>{{ item.doctor_name }}</strong><small>{{ item.department_name }}</small></div>
|
||||
<span class="severity" :class="`is-${item.severity}`">{{ item.severity === 'high' ? '重点关注' : '低于阈值' }}</span>
|
||||
<span class="alert-data">面诊 {{ item.interview_count }} / 接诊 {{ item.order_count }}</span>
|
||||
<p>{{ item.suggestion }}</p>
|
||||
<strong class="alert-rate">{{ formatPercent(item.rate) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="alert-empty">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
当前范围内暂无低于 {{ query.alert_threshold }}% 的医生
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section v-else class="panel detail-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>医生明细 · 按成交金额排序</h2><p>低于接诊率预警线的医生整行标红,过号与取消分别展示</p></div>
|
||||
<span>{{ businessRows.length }} 位有数据医生</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="visibleDetailRows"
|
||||
class="detail-table"
|
||||
:default-sort="{ prop: 'deal_amount', order: 'descending' }"
|
||||
:row-class-name="detailRowClassName"
|
||||
>
|
||||
<el-table-column prop="doctor_name" label="医生" min-width="130" fixed="left" sortable>
|
||||
<template #default="{ row }"><strong>{{ row.doctor_name }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" min-width="90" sortable>
|
||||
<template #default="{ row }">
|
||||
<span class="status-pill" :class="{ disabled: row.status === 'disabled' }">
|
||||
{{ row.status === 'disabled' ? '停用' : '活跃' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appointment_total" label="预约" min-width="90" sortable />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="90" sortable />
|
||||
<el-table-column prop="order_count" label="接诊" min-width="90" sortable />
|
||||
<el-table-column prop="receive_conversion_rate" label="接诊率" min-width="110" sortable>
|
||||
<template #default="{ row }">
|
||||
<span :class="conversionClass(row.receive_conversion_rate)">
|
||||
<span v-if="isLowConversion(row)" class="warning-mark">△</span>{{ formatPercent(row.receive_conversion_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deal_amount" label="成交金额" min-width="135" sortable>
|
||||
<template #default="{ row }"><strong class="money-text">{{ formatDetailMoney(row.deal_amount) }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过号/取消" min-width="110">
|
||||
<template #default="{ row }">{{ formatNumber(row.appointment_missed) }}/{{ formatNumber(row.appointment_cancelled) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button class="detail-action" type="primary" link @click="openDoctorDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限和筛选范围内暂无医生数据" /></template>
|
||||
</el-table>
|
||||
<button
|
||||
v-if="!query.doctor_id && zeroDataRows.length"
|
||||
type="button"
|
||||
class="zero-doctor-toggle"
|
||||
@click="showZeroRows = !showZeroRows"
|
||||
>
|
||||
<span>{{ showZeroRows ? '▾' : '▸' }}</span>
|
||||
{{ showZeroRows ? '收起' : '已隐藏' }} {{ zeroDataRows.length }} 位无业务数据医生{{ showZeroRows ? '' : '(点击展开)' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<footer class="data-note">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ dashboard.meta.registration_rule }};{{ dashboard.meta.appointment_rule }};{{ dashboard.meta.performance_rule }}。</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitDoctorDashboardPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Calendar,
|
||||
CircleCheck,
|
||||
DataLine,
|
||||
Download,
|
||||
InfoFilled,
|
||||
Lock,
|
||||
Money,
|
||||
Refresh,
|
||||
Tickets,
|
||||
User,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import {
|
||||
firstVisitDoctorDashboardOverview,
|
||||
type FirstVisitDoctorDashboardParams
|
||||
} from '@/api/first_visit'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'month', time_label: '本月', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', scope_kind: '', selected_dept_name: '', selected_doctor_name: '',
|
||||
doctor_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[], doctors: [] as Array<{ id: number; name: string; disable: number }>,
|
||||
can_filter_department: true
|
||||
},
|
||||
summary: {
|
||||
registration_total: 0, appointment_total: 0, interview_count: 0, order_count: 0, deal_amount: 0,
|
||||
avg_order_amount: null as number | null, appointment_completion_rate: null as number | null,
|
||||
receive_conversion_rate: null as number | null, missed_count: 0, cancelled_count: 0
|
||||
},
|
||||
rankings: { amounts: [] as any[], conversion: [] as any[] },
|
||||
trend: { start_date: '', end_date: '', dates: [] as string[], labels: [] as string[], amounts: [] as number[] },
|
||||
alerts: [] as any[],
|
||||
alert_threshold: 15,
|
||||
rows: [] as any[]
|
||||
})
|
||||
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const loading = ref(false)
|
||||
const viewMode = ref<'overview' | 'detail'>('overview')
|
||||
const showZeroRows = ref(false)
|
||||
const query = reactive<FirstVisitDoctorDashboardParams>({
|
||||
time_type: 'month',
|
||||
active_only: 1,
|
||||
alert_threshold: 15
|
||||
})
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' }
|
||||
]
|
||||
const doctorStatusOptions = [
|
||||
{ label: '仅活跃医生', value: 1 },
|
||||
{ label: '全部医生', value: 0 }
|
||||
]
|
||||
const thresholdOptions = [10, 15, 20, 30]
|
||||
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
|
||||
|
||||
const maxAmount = computed(() => Math.max(0, ...dashboard.rankings.amounts.map((item: any) => Number(item.value) || 0)))
|
||||
const businessRows = computed(() => dashboard.rows.filter((row: any) => hasBusinessData(row)))
|
||||
const zeroDataRows = computed(() => dashboard.rows.filter((row: any) => !hasBusinessData(row)))
|
||||
const visibleDetailRows = computed(() => {
|
||||
if (query.doctor_id || showZeroRows.value) return dashboard.rows
|
||||
return businessRows.value
|
||||
})
|
||||
const trendChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
grid: { left: 20, right: 20, top: 18, bottom: 18, containLabel: true },
|
||||
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
|
||||
xAxis: {
|
||||
type: 'category', boundaryGap: false, data: dashboard.trend.labels,
|
||||
axisLine: { lineStyle: { color: '#dfe5eb' } }, axisTick: { show: false },
|
||||
axisLabel: { color: '#7e8a9b', fontSize: 10, interval: 4 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', splitNumber: 4,
|
||||
axisLabel: { color: '#8793a2', fontSize: 10, formatter: (value: number) => compactMoney(value) },
|
||||
splitLine: { lineStyle: { color: '#eef2f5' } }
|
||||
},
|
||||
series: [{
|
||||
name: '成交金额', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
|
||||
data: dashboard.trend.amounts,
|
||||
lineStyle: { color: '#139a8c', width: 3 },
|
||||
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
|
||||
areaStyle: { color: 'rgba(19,154,140,.08)' }
|
||||
}]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: FirstVisitDoctorDashboardParams = {
|
||||
time_type: query.time_type,
|
||||
active_only: query.active_only ?? 1,
|
||||
alert_threshold: Number(query.alert_threshold || 15)
|
||||
}
|
||||
if (query.dept_id) params.dept_id = Number(query.dept_id)
|
||||
if (query.doctor_id) params.doctor_id = Number(query.doctor_id)
|
||||
const result: any = await firstVisitDoctorDashboardOverview(params)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '医生看板加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDepartmentChange() {
|
||||
delete query.doctor_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function handleActiveChange() {
|
||||
delete query.doctor_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatDetailMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function nullableMoney(value: unknown) {
|
||||
return value === null || value === undefined ? '—' : formatMoney(value)
|
||||
}
|
||||
|
||||
function formatPercent(value: unknown) {
|
||||
return value === null || value === undefined ? '—' : `${Number(value).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compactMoney(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
function barWidth(value: unknown, max: number) {
|
||||
if (max <= 0) return '0%'
|
||||
return `${Math.max(3, Math.min(100, Number(value || 0) / max * 100))}%`
|
||||
}
|
||||
|
||||
function percentageWidth(value: unknown) {
|
||||
return `${Math.max(0, Math.min(100, Number(value) || 0))}%`
|
||||
}
|
||||
|
||||
function conversionClass(value: unknown) {
|
||||
if (value === null || value === undefined) return 'conversion-rate is-neutral'
|
||||
return Number(value) < Number(query.alert_threshold || 15) ? 'conversion-rate is-low' : 'conversion-rate is-good'
|
||||
}
|
||||
|
||||
function hasBusinessData(row: any) {
|
||||
return Number(row.appointment_total || 0) > 0
|
||||
|| Number(row.interview_count || 0) > 0
|
||||
|| Number(row.order_count || 0) > 0
|
||||
|| Number(row.deal_amount || 0) > 0
|
||||
|| Number(row.appointment_missed || 0) > 0
|
||||
|| Number(row.appointment_cancelled || 0) > 0
|
||||
}
|
||||
|
||||
function isLowConversion(row: any) {
|
||||
return Number(row.interview_count || 0) > 0
|
||||
&& Number(row.receive_conversion_rate || 0) < Number(query.alert_threshold || 15)
|
||||
}
|
||||
|
||||
function detailRowClassName({ row }: { row: any }) {
|
||||
if (isLowConversion(row)) return 'is-conversion-warning'
|
||||
if (!hasBusinessData(row)) return 'is-zero-data'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function openDoctorDetail(row: any) {
|
||||
query.doctor_id = Number(row.doctor_id)
|
||||
viewMode.value = 'overview'
|
||||
showZeroRows.value = false
|
||||
await loadDashboard()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
return `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function exportRows() {
|
||||
if (!dashboard.rows.length) {
|
||||
ElMessage.warning('当前范围暂无可导出的医生数据')
|
||||
return
|
||||
}
|
||||
const headers = ['医生', '状态', '预约', '面诊', '接诊', '接诊率', '成交金额', '过号', '取消']
|
||||
const lines = dashboard.rows.map((row: any) => [
|
||||
row.doctor_name, row.status === 'disabled' ? '停用' : '活跃', row.appointment_total,
|
||||
row.interview_count, row.order_count, formatPercent(row.receive_conversion_rate), row.deal_amount,
|
||||
row.appointment_missed, row.appointment_cancelled
|
||||
].map(csvCell).join(','))
|
||||
const blob = new Blob([`\uFEFF${headers.map(csvCell).join(',')}\n${lines.join('\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `医生看板_${dashboard.meta.start_date}_${dashboard.meta.end_date}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.doctor-dashboard {
|
||||
--ink: #17243a;
|
||||
--muted: #768497;
|
||||
--line: #e2e8ee;
|
||||
--canvas: #f4f6f8;
|
||||
--teal: #139a8c;
|
||||
--blue: #3d78e7;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.metric-card,
|
||||
.panel { border: 1px solid var(--line); background: #fff; }
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-height: 76px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
.heading-copy, .heading-actions, .filter-item, .threshold-control { display: flex; align-items: center; }
|
||||
.heading-copy { gap: 12px; }
|
||||
.heading-mark {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
box-shadow: 0 8px 20px rgba(19, 154, 140, .16);
|
||||
font-size: 20px;
|
||||
}
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 20px; line-height: 1.3; }
|
||||
h2 { font-size: 15px; line-height: 1.4; }
|
||||
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.heading-actions { gap: 10px; color: var(--muted); font-size: 12px; }
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #cce8e3;
|
||||
border-radius: 7px;
|
||||
color: #117f75;
|
||||
background: #f2faf8;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 14px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.filter-item { gap: 8px; color: #68778a; font-size: 12px; }
|
||||
.dept-select { width: 190px; }
|
||||
.doctor-select { width: 160px; }
|
||||
.view-switch { display: flex; margin-left: auto; padding-left: 12px; border-left: 1px solid #e6ebef; }
|
||||
.view-switch button {
|
||||
min-width: 82px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #657488;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.view-switch button.active { border-bottom-color: var(--teal); color: #0e8277; font-weight: 700; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 105px;
|
||||
padding: 16px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.metric-card--teal .metric-icon { color: #0b887c; background: #e8f7f4; }
|
||||
.metric-card--green .metric-icon { color: #37a465; background: #eef8f1; }
|
||||
.metric-card--indigo .metric-icon { color: #556dde; background: #eef0fd; }
|
||||
.metric-card--blue .metric-icon { color: #3976e6; background: #edf3ff; }
|
||||
.metric-card--orange .metric-icon { color: #d77a2c; background: #fff4e9; }
|
||||
.metric-card--cyan .metric-icon { color: #138da1; background: #eaf7f8; }
|
||||
.metric-card span { display: block; color: #748296; font-size: 12px; }
|
||||
.metric-card strong { display: block; margin: 4px 0 3px; font-size: 24px; line-height: 1.1; }
|
||||
.metric-card small { color: #8995a4; font-size: 10px; }
|
||||
|
||||
.two-column-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px 11px;
|
||||
}
|
||||
.panel-heading > span { color: #8a96a5; font-size: 11px; }
|
||||
.ranking-panel { min-height: 284px; }
|
||||
.bar-list { padding: 0 16px 15px; }
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(80px, .35fr) minmax(140px, 1fr) 110px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.bar-row > b { color: #8793a2; text-align: center; font-size: 10px; }
|
||||
.bar-row > span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-row > strong { text-align: right; font-size: 12px; }
|
||||
.bar-track { height: 9px; overflow: hidden; border-radius: 6px; background: #edf1f4; }
|
||||
.bar-track i { display: block; height: 100%; border-radius: inherit; }
|
||||
.bar-track .is-blue { background: var(--blue); }
|
||||
.bar-track .is-teal { background: var(--teal); }
|
||||
|
||||
.analytics-grid { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.analytics-grid .panel { min-height: 300px; }
|
||||
.trend-chart { width: 100%; height: 238px; }
|
||||
|
||||
.alert-panel { border-color: #dfe8e7; }
|
||||
.alert-panel.has-alerts { border-color: #efc4c4; }
|
||||
.alert-heading h2 { display: flex; align-items: center; gap: 6px; }
|
||||
.alert-heading h2 .el-icon { color: #ee5b5b; }
|
||||
.threshold-control { gap: 8px; color: #768497; font-size: 11px; }
|
||||
.threshold-select { width: 88px; }
|
||||
.alert-list { padding: 0 14px 14px; }
|
||||
.alert-row {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(120px, .45fr) 82px 135px minmax(220px, 1fr) 65px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 54px;
|
||||
margin-top: 8px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #f0dddd;
|
||||
border-radius: 9px;
|
||||
background: #fffafa;
|
||||
}
|
||||
.alert-icon { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 50%; color: #fff; background: #ef5a5a; font-weight: 700; }
|
||||
.alert-doctor strong, .alert-doctor small { display: block; }
|
||||
.alert-doctor strong { font-size: 12px; }
|
||||
.alert-doctor small { margin-top: 2px; color: #8d98a6; font-size: 10px; }
|
||||
.severity { padding: 4px 7px; border-radius: 5px; text-align: center; font-size: 10px; }
|
||||
.severity.is-high { color: #d84444; background: #ffe9e9; }
|
||||
.severity.is-medium { color: #c77726; background: #fff1df; }
|
||||
.alert-data { color: #67768a; font-size: 11px; }
|
||||
.alert-row p { color: #7b8797; font-size: 11px; }
|
||||
.alert-rate { color: #e34949; text-align: right; font-size: 15px; }
|
||||
.alert-empty { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 94px; color: #4f8f77; font-size: 12px; }
|
||||
.alert-empty .el-icon { font-size: 20px; }
|
||||
|
||||
.detail-panel { min-height: 360px; }
|
||||
.detail-table { border-top: 1px solid #edf1f4; --el-table-header-bg-color: #f7f9fb; }
|
||||
.detail-table :deep(th.el-table__cell) { height: 43px; color: #68778a; font-weight: 500; }
|
||||
.detail-table :deep(td.el-table__cell) { height: 47px; }
|
||||
.detail-table :deep(.is-conversion-warning > td.el-table__cell) { background: #fff0f0 !important; }
|
||||
.detail-table :deep(.is-zero-data > td.el-table__cell) { color: #98a2af; background: #fafbfc !important; }
|
||||
.money-text { color: #246bd3; }
|
||||
.conversion-rate.is-low { color: #e04d4d; font-weight: 700; }
|
||||
.conversion-rate.is-good { color: #168f72; }
|
||||
.conversion-rate.is-neutral { color: #919cab; }
|
||||
.warning-mark { margin-right: 3px; color: #ec4e4e; font-size: 10px; }
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 11px;
|
||||
color: #16895f;
|
||||
background: #eaf8ef;
|
||||
font-size: 11px;
|
||||
}
|
||||
.status-pill.disabled { color: #9a6570; background: #f4ecee; }
|
||||
.detail-action { font-size: 12px; }
|
||||
.zero-doctor-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 10px 16px 15px;
|
||||
padding: 4px 9px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
color: #6e7d90;
|
||||
background: #eef3f6;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.zero-doctor-toggle:hover { color: #0e8277; background: #e7f4f1; }
|
||||
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.view-switch { margin-left: 0; }
|
||||
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.doctor-dashboard { padding: 10px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
.heading-actions { flex-wrap: wrap; }
|
||||
.update-time { display: none; }
|
||||
.metric-grid, .two-column-grid { grid-template-columns: 1fr; }
|
||||
.metric-card { min-height: 92px; }
|
||||
.filter-item { width: 100%; justify-content: space-between; }
|
||||
.dept-select, .doctor-select { width: calc(100% - 78px); }
|
||||
.filter-item--time, .filter-item--status { justify-content: flex-start; }
|
||||
.view-switch { width: 100%; justify-content: flex-end; border-left: 0; }
|
||||
.alert-row { grid-template-columns: 30px 1fr 80px 65px; padding: 10px; }
|
||||
.alert-data, .alert-row p { grid-column: 2 / -1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,684 +0,0 @@
|
||||
<template>
|
||||
<prescription-order-detail-drawer
|
||||
ref="detailDrawerRef"
|
||||
readonly
|
||||
append-to-body
|
||||
:detail-loader="myPatientOrderDetail"
|
||||
:load-related-data="false"
|
||||
>
|
||||
<template #header-actions="{ detail }">
|
||||
<el-dropdown
|
||||
v-if="myPatientOrderActions(detail).length"
|
||||
trigger="click"
|
||||
@command="(command) => openAction(detail, command as MyPatientOrderAction)"
|
||||
>
|
||||
<el-button type="primary" size="small" plain>
|
||||
订单操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="action in myPatientOrderActions(detail)"
|
||||
:key="action.key"
|
||||
:command="action.key"
|
||||
:class="{ 'danger-menu-item': action.danger }"
|
||||
>
|
||||
{{ action.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</prescription-order-detail-drawer>
|
||||
|
||||
<el-dialog v-model="auditVisible" :title="auditTitle" width="480px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="通过时审核意见可不填;驳回时必须填写明确原因。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-input
|
||||
v-model="auditRemark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入审核意见"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAudit('approve')">同意通过</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="submitAudit('reject')">驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="trackVisible" title="修改快递单号" width="440px" append-to-body destroy-on-close>
|
||||
<el-form label-width="92px" @submit.prevent="submitTrack">
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="trackForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号" required>
|
||||
<el-input v-model="trackForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="trackVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitTrack">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="shipVisible" title="确认发货" width="460px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="确认后订单将进入“已发货”,请先核对发货方式和快递信息。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="92px" @submit.prevent="submitShip">
|
||||
<el-form-item label="发货方式">
|
||||
<el-tag :type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'" effect="plain">
|
||||
{{ shipForm.ship_mode === 'direct' ? '洛阳药房' : '甘草药房' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="shipForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号" required>
|
||||
<el-input v-model="shipForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="shipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑订单" width="680px" append-to-body destroy-on-close>
|
||||
<el-form v-loading="editLoading" label-width="104px" class="order-edit-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="收货人" required>
|
||||
<el-input v-model="editForm.recipient_name" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="收货手机" required>
|
||||
<el-input v-model="editForm.recipient_phone" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="费用类别" required>
|
||||
<el-select v-model="editForm.fee_type" class="w-full">
|
||||
<el-option v-for="item in feeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单金额" required>
|
||||
<el-input-number v-model="editForm.amount" :min="0" :precision="2" :step="10" class="w-full" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="收货地址" required>
|
||||
<el-input v-model="editForm.shipping_address" maxlength="500" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="editForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号">
|
||||
<el-input v-model="editForm.tracking_number" maxlength="80" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="医助备注">
|
||||
<el-input v-model="editForm.remark_assistant" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="药房备注">
|
||||
<el-input v-model="editForm.remark_extra" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
title="保存后会沿用原订单编辑规则;非已发货订单的支付审核将重新变为待审核。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="editLoading" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="payVisible" title="补齐支付单" width="520px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
:title="`订单金额 ¥${money(actionRow.amount)},已关联支付 ¥${money(actionRow.linked_pay_paid_total)}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="108px">
|
||||
<el-form-item label="支付单类型">
|
||||
<el-select v-model="payForm.order_type" class="w-full">
|
||||
<el-option v-for="item in payTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建方式">
|
||||
<el-radio-group v-model="payForm.pay_create_type">
|
||||
<el-radio value="fubei">付呗支付</el-radio>
|
||||
<el-radio value="express_cod">快递代收</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="补齐金额" required>
|
||||
<el-input-number v-model="payForm.pay_amount" :min="0" :precision="2" :step="10" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付备注">
|
||||
<el-input v-model="payForm.pay_remark" type="textarea" :rows="3" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="完单申请">
|
||||
<el-switch v-model="payForm.completion_request" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip">同时提交完单申请</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="payVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitPayOrder">确认新增</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="completeVisible" title="完成订单" width="480px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="请选择真实履约结果。退款必须使用单独的“退款”操作。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="结案状态" required>
|
||||
<el-select v-model="completeStatus" class="w-full">
|
||||
<el-option v-for="item in completeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="completeVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitComplete">确认完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="refundVisible" title="订单退款" width="500px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="退款会同步更新关联支付单和订单金额;退款金额留空时按系统计算的最大可退金额处理。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="退款原因" required>
|
||||
<el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="退款金额">
|
||||
<el-input-number
|
||||
v-model="refundForm.refund_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="10"
|
||||
class="w-full"
|
||||
placeholder="留空则退最大可退金额"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="refundVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="submitRefund">确认退款</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import feedback from '@/utils/feedback'
|
||||
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||
import {
|
||||
myPatientOrderAddPayOrder,
|
||||
myPatientOrderAuditPayment,
|
||||
myPatientOrderAuditPrescription,
|
||||
myPatientOrderComplete,
|
||||
myPatientOrderDdcode,
|
||||
myPatientOrderDetail,
|
||||
myPatientOrderEdit,
|
||||
myPatientOrderRefund,
|
||||
myPatientOrderRevokePayAudit,
|
||||
myPatientOrderRevokeRxAudit,
|
||||
myPatientOrderShip,
|
||||
myPatientOrderUploadToPharmacy,
|
||||
myPatientOrderWithdraw
|
||||
} from '@/api/first_visit'
|
||||
import { myPatientOrderActions, type MyPatientOrderAction } from './order-actions'
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||
const actionRow = ref<Record<string, any>>({})
|
||||
const submitting = ref(false)
|
||||
|
||||
const expressOptions = [
|
||||
{ label: '自动识别', value: 'auto' },
|
||||
{ label: '顺丰速运', value: 'sf' },
|
||||
{ label: '京东快递', value: 'jd' },
|
||||
{ label: '极兔速递', value: 'jt' }
|
||||
]
|
||||
const feeTypeOptions = [
|
||||
{ label: '挂号', value: 1 },
|
||||
{ label: '问诊', value: 2 },
|
||||
{ label: '药品', value: 3 },
|
||||
{ label: '首付', value: 4 },
|
||||
{ label: '尾款', value: 5 },
|
||||
{ label: '其他', value: 6 },
|
||||
{ label: '全部', value: 7 },
|
||||
{ label: '代收', value: 8 }
|
||||
]
|
||||
const payTypeOptions = [
|
||||
{ label: '药品', value: 3 },
|
||||
{ label: '尾款', value: 5 },
|
||||
{ label: '其他', value: 6 }
|
||||
]
|
||||
const completeOptions = [
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '进行中', value: 7 },
|
||||
{ label: '暂不制药', value: 8 },
|
||||
{ label: '拒收', value: 9 },
|
||||
{ label: '保留药方', value: 11 },
|
||||
{ label: '制药缓发', value: 12 }
|
||||
]
|
||||
|
||||
const auditVisible = ref(false)
|
||||
const auditKind = ref<'prescription' | 'payment'>('prescription')
|
||||
const auditRemark = ref('')
|
||||
const auditTitle = computed(() => auditKind.value === 'prescription' ? '处方业务审核' : '关联支付单审核')
|
||||
|
||||
const trackVisible = ref(false)
|
||||
const trackForm = reactive({ express_company: 'auto', tracking_number: '' })
|
||||
|
||||
const shipVisible = ref(false)
|
||||
const shipForm = reactive<{ ship_mode: 'gancao' | 'direct'; express_company: string; tracking_number: string }>({
|
||||
ship_mode: 'gancao',
|
||||
express_company: 'auto',
|
||||
tracking_number: ''
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editSource = ref<Record<string, any>>({})
|
||||
const editForm = reactive({
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
shipping_address: '',
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
express_company: 'auto',
|
||||
tracking_number: '',
|
||||
remark_assistant: '',
|
||||
remark_extra: ''
|
||||
})
|
||||
|
||||
const payVisible = ref(false)
|
||||
const payForm = reactive<{
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark: string
|
||||
completion_request: number
|
||||
pay_create_type: 'fubei' | 'express_cod'
|
||||
}>({ order_type: 3, pay_amount: 0, pay_remark: '', completion_request: 0, pay_create_type: 'fubei' })
|
||||
|
||||
const completeVisible = ref(false)
|
||||
const completeStatus = ref(3)
|
||||
|
||||
const refundVisible = ref(false)
|
||||
const refundForm = reactive<{ reason: string; refund_amount: number | undefined }>({
|
||||
reason: '',
|
||||
refund_amount: undefined
|
||||
})
|
||||
|
||||
function money(value: unknown) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function normalizeShipMode(value: unknown): 'gancao' | 'direct' {
|
||||
return String(value || '').toLowerCase() === 'direct' ? 'direct' : 'gancao'
|
||||
}
|
||||
|
||||
function openDetail(row: Record<string, any>) {
|
||||
detailDrawerRef.value?.open(Number(row.id))
|
||||
}
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
const response: any = await myPatientOrderDetail({ id })
|
||||
return response?.data ?? response ?? null
|
||||
}
|
||||
|
||||
async function openEdit(row: Record<string, any>) {
|
||||
actionRow.value = row
|
||||
editVisible.value = true
|
||||
editLoading.value = true
|
||||
try {
|
||||
const detail = await fetchDetail(Number(row.id))
|
||||
if (!detail) throw new Error('订单详情加载失败')
|
||||
editSource.value = detail
|
||||
editForm.recipient_name = String(detail.recipient_name || '')
|
||||
editForm.recipient_phone = String(detail.recipient_phone || '')
|
||||
editForm.shipping_address = String(detail.shipping_address || '')
|
||||
editForm.fee_type = Number(detail.fee_type || 3)
|
||||
editForm.amount = Number(detail.amount || 0)
|
||||
editForm.express_company = String(detail.express_company || 'auto') || 'auto'
|
||||
editForm.tracking_number = String(detail.tracking_number || '')
|
||||
editForm.remark_assistant = String(detail.remark_assistant || '')
|
||||
editForm.remark_extra = String(detail.remark_extra || '')
|
||||
} catch (error: any) {
|
||||
feedback.msgError(error?.message || '订单详情加载失败')
|
||||
editVisible.value = false
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAction(row: Record<string, any>, action: MyPatientOrderAction) {
|
||||
actionRow.value = row
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
void openEdit(row)
|
||||
break
|
||||
case 'audit_prescription':
|
||||
case 'audit_payment':
|
||||
auditKind.value = action === 'audit_prescription' ? 'prescription' : 'payment'
|
||||
auditRemark.value = ''
|
||||
auditVisible.value = true
|
||||
break
|
||||
case 'revoke_rx_audit':
|
||||
void confirmRevokeAudit('prescription')
|
||||
break
|
||||
case 'revoke_pay_audit':
|
||||
void confirmRevokeAudit('payment')
|
||||
break
|
||||
case 'ddcode':
|
||||
trackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||
trackForm.tracking_number = String(row.tracking_number || '')
|
||||
trackVisible.value = true
|
||||
break
|
||||
case 'ship':
|
||||
shipForm.ship_mode = normalizeShipMode(row.ship_mode)
|
||||
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||
shipForm.tracking_number = String(row.tracking_number || '')
|
||||
shipVisible.value = true
|
||||
break
|
||||
case 'add_pay_order': {
|
||||
const diff = Math.max(0, Number(row.amount || 0) - Number(row.linked_pay_paid_total || 0))
|
||||
payForm.order_type = 3
|
||||
payForm.pay_amount = Number(diff.toFixed(2))
|
||||
payForm.pay_remark = ''
|
||||
payForm.completion_request = 0
|
||||
payForm.pay_create_type = 'fubei'
|
||||
payVisible.value = true
|
||||
break
|
||||
}
|
||||
case 'complete':
|
||||
completeStatus.value = 3
|
||||
completeVisible.value = true
|
||||
break
|
||||
case 'refund':
|
||||
refundForm.reason = ''
|
||||
refundForm.refund_amount = undefined
|
||||
refundVisible.value = true
|
||||
break
|
||||
case 'withdraw':
|
||||
void confirmWithdraw()
|
||||
break
|
||||
case 'upload_pharmacy':
|
||||
void confirmUploadPharmacy()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function changed(message: string) {
|
||||
feedback.msgSuccess(message)
|
||||
emit('changed')
|
||||
await detailDrawerRef.value?.refreshIfCurrent(actionRow.value.id)
|
||||
}
|
||||
|
||||
async function submitAudit(action: 'approve' | 'reject') {
|
||||
if (action === 'reject' && !auditRemark.value.trim()) {
|
||||
feedback.msgError('驳回时必须填写审核意见')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const params = { id: Number(actionRow.value.id), action, remark: auditRemark.value.trim() }
|
||||
if (auditKind.value === 'prescription') await myPatientOrderAuditPrescription(params)
|
||||
else await myPatientOrderAuditPayment(params)
|
||||
auditVisible.value = false
|
||||
await changed(action === 'approve' ? '审核通过成功' : '订单已驳回')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRevokeAudit(kind: 'prescription' | 'payment') {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认撤回${kind === 'prescription' ? '处方' : '支付单'}审核并恢复为待审核吗?`,
|
||||
'撤回审核',
|
||||
{ type: 'warning', confirmButtonText: '确认撤回', cancelButtonText: '取消' }
|
||||
)
|
||||
if (kind === 'prescription') await myPatientOrderRevokeRxAudit({ id: Number(actionRow.value.id) })
|
||||
else await myPatientOrderRevokePayAudit({ id: Number(actionRow.value.id) })
|
||||
await changed('审核已撤回')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTrack() {
|
||||
if (!trackForm.tracking_number.trim()) {
|
||||
feedback.msgError('请填写快递单号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderDdcode({
|
||||
id: Number(actionRow.value.id),
|
||||
express_company: trackForm.express_company || 'auto',
|
||||
tracking_number: trackForm.tracking_number.trim()
|
||||
})
|
||||
trackVisible.value = false
|
||||
await changed('快递单号已保存')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitShip() {
|
||||
if (!shipForm.tracking_number.trim()) {
|
||||
feedback.msgError('请填写快递单号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderShip({
|
||||
id: Number(actionRow.value.id),
|
||||
ship_mode: shipForm.ship_mode,
|
||||
express_company: shipForm.express_company || 'auto',
|
||||
tracking_number: shipForm.tracking_number.trim()
|
||||
})
|
||||
shipVisible.value = false
|
||||
await changed('确认发货成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editForm.recipient_name.trim() || !editForm.recipient_phone.trim() || !editForm.shipping_address.trim()) {
|
||||
feedback.msgError('请完整填写收货人、收货手机和收货地址')
|
||||
return
|
||||
}
|
||||
if (Number(editForm.amount) < 0) {
|
||||
feedback.msgError('订单金额不能为负数')
|
||||
return
|
||||
}
|
||||
const source = editSource.value
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderEdit({
|
||||
id: Number(actionRow.value.id),
|
||||
recipient_name: editForm.recipient_name.trim(),
|
||||
recipient_phone: editForm.recipient_phone.trim(),
|
||||
shipping_province: source.shipping_province || '',
|
||||
shipping_city: source.shipping_city || '',
|
||||
shipping_district: source.shipping_district || '',
|
||||
shipping_address: editForm.shipping_address.trim(),
|
||||
is_follow_up: Number(source.is_follow_up || 0),
|
||||
medication_days: source.medication_days,
|
||||
dose_unit: source.dose_unit || '剂',
|
||||
dose_count: Number(source.dose_count || 1),
|
||||
prev_staff: source.prev_staff || '',
|
||||
service_channel: source.service_channel || '',
|
||||
service_package: source.service_package || '',
|
||||
tracking_number: editForm.tracking_number.trim(),
|
||||
express_company: editForm.express_company || 'auto',
|
||||
fee_type: Number(editForm.fee_type),
|
||||
amount: Number(editForm.amount),
|
||||
remark_extra: editForm.remark_extra.trim(),
|
||||
remark_assistant: editForm.remark_assistant.trim(),
|
||||
pay_order_ids: Array.isArray(source.pay_order_ids) ? source.pay_order_ids : []
|
||||
})
|
||||
editVisible.value = false
|
||||
await changed('订单保存成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPayOrder() {
|
||||
if (!(Number(payForm.pay_amount) > 0)) {
|
||||
feedback.msgError('补齐金额必须大于 0')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderAddPayOrder({
|
||||
id: Number(actionRow.value.id),
|
||||
order_type: Number(payForm.order_type),
|
||||
pay_amount: Number(payForm.pay_amount),
|
||||
pay_remark: payForm.pay_remark.trim(),
|
||||
completion_request: Number(payForm.completion_request),
|
||||
pay_create_type: payForm.pay_create_type
|
||||
})
|
||||
payVisible.value = false
|
||||
await changed('支付单已新增,等待支付审核')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComplete() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderComplete({ id: Number(actionRow.value.id), fulfillment_status: Number(completeStatus.value) })
|
||||
completeVisible.value = false
|
||||
await changed('订单状态已更新')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
if (!refundForm.reason.trim()) {
|
||||
feedback.msgError('请填写退款原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderRefund({
|
||||
id: Number(actionRow.value.id),
|
||||
reason: refundForm.reason.trim(),
|
||||
refund_amount: refundForm.refund_amount === undefined ? undefined : Number(refundForm.refund_amount)
|
||||
})
|
||||
refundVisible.value = false
|
||||
await changed('退款成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmWithdraw() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认撤回该订单吗?撤回后当前订单将结束。', '撤回订单', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认撤回',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await myPatientOrderWithdraw({ id: Number(actionRow.value.id) })
|
||||
await changed('订单已撤回')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUploadPharmacy() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将该订单上传到订单设定的药房吗?', '上传药房', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认上传',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await myPatientOrderUploadToPharmacy({ id: Number(actionRow.value.id) })
|
||||
await changed('药方上传成功')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openDetail, openAction })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:global(.danger-menu-item) {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,720 +0,0 @@
|
||||
<template>
|
||||
<section class="embedded-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h2>订单管理</h2>
|
||||
<p>展示当前患者范围内的处方业务订单,订单创建人不参与数据归属判断</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
|
||||
<el-button :icon="Refresh" :loading="pager.loading" @click="refreshPanel">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-panel">
|
||||
<div class="filter-heading">
|
||||
<div>
|
||||
<h3>订单检索</h3>
|
||||
<p>审核与履约状态直接展示,点击后立即筛选</p>
|
||||
</div>
|
||||
<el-button link :disabled="!hasActiveFilters" @click="resetFilters">清空全部条件</el-button>
|
||||
</div>
|
||||
|
||||
<div class="status-filter-list">
|
||||
<div class="status-filter-row">
|
||||
<span class="filter-label">处方审核</span>
|
||||
<el-radio-group v-model="formData.prescription_audit_status" @change="handleSearch">
|
||||
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="status-filter-row">
|
||||
<span class="filter-label">支付单审核</span>
|
||||
<el-radio-group v-model="formData.payment_slip_audit_status" @change="handleSearch">
|
||||
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="status-filter-row status-filter-row--fulfillment">
|
||||
<span class="filter-label">履约状态</span>
|
||||
<el-radio-group v-model="formData.fulfillment_status" @change="handleSearch">
|
||||
<el-radio-button v-for="item in fulfillmentFilterOptions" :key="String(item.value)" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-filter-row">
|
||||
<label class="filter-field filter-field--keyword">
|
||||
<span>关键词</span>
|
||||
<el-input
|
||||
v-model="formData.keyword"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</label>
|
||||
<label class="filter-field filter-field--date">
|
||||
<span>创建时间</span>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</label>
|
||||
<div class="filter-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>订单数量</span>
|
||||
<strong>{{ summary.orders }}</strong>
|
||||
<small>笔</small>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>有效订单金额</span>
|
||||
<strong>¥{{ money(summary.amount) }}</strong>
|
||||
<small>排除取消、拒收及退款</small>
|
||||
</div>
|
||||
<div class="metric-card metric-warning">
|
||||
<span>待审核</span>
|
||||
<strong>{{ summary.pending }}</strong>
|
||||
<small>任一审核待处理</small>
|
||||
</div>
|
||||
<div class="metric-card metric-success">
|
||||
<span>已完成 / 签收</span>
|
||||
<strong>{{ summary.completed }}</strong>
|
||||
<small>笔</small>
|
||||
</div>
|
||||
<button
|
||||
class="metric-card metric-card--interactive metric-rejected"
|
||||
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
|
||||
type="button"
|
||||
@click="toggleRejectedFilter"
|
||||
>
|
||||
<span>拒收订单</span>
|
||||
<strong>{{ summary.rejected }}</strong>
|
||||
<small>笔 · 点击查看明细</small>
|
||||
</button>
|
||||
<button
|
||||
class="metric-card metric-card--interactive metric-rejected-rate"
|
||||
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
|
||||
type="button"
|
||||
@click="toggleRejectedFilter"
|
||||
>
|
||||
<span>拒收率</span>
|
||||
<strong>{{ percent(summary.rejectionRate) }}</strong>
|
||||
<small>拒收订单 ÷ 同检索范围订单</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
class="embedded-table"
|
||||
:row-class-name="orderRowClassName"
|
||||
>
|
||||
<el-table-column label="订单" min-width="174" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell">
|
||||
<strong>{{ row.order_no || '—' }}</strong>
|
||||
<span>#{{ row.id }} · {{ row.fee_type_text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="患者" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell">
|
||||
<strong>{{ row.patient_name || row.recipient_name || '—' }}</strong>
|
||||
<span>{{ row.patient_phone_masked || row.recipient_phone_masked || '无手机号' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方 / 诊单" min-width="118">
|
||||
<template #default="{ row }">
|
||||
<div class="id-stack">
|
||||
<span>处方 #{{ row.prescription_id || '—' }}</span>
|
||||
<span>诊单 #{{ row.diagnosis_id || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效金额" width="118" align="right">
|
||||
<template #default="{ row }">
|
||||
<strong v-if="row.amount_included" class="amount">¥{{ money(row.effective_amount) }}</strong>
|
||||
<span v-else class="amount-excluded">{{ row.amount_exclusion_text || '不计入' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方审核" width="104" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="auditTagType(row.prescription_audit_status)" effect="light">
|
||||
{{ row.prescription_audit_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付单审核" width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="auditTagType(row.payment_slip_audit_status)" effect="light">
|
||||
{{ row.payment_slip_audit_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="履约状态" width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="fulfillmentTagType(row.fulfillment_status)" effect="light">
|
||||
{{ row.fulfillment_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联支付单" width="100" align="center">
|
||||
<template #default="{ row }">{{ Number(row.linked_pay_order_count) > 0 ? `${row.linked_pay_order_count} 笔` : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assistant_name" label="归属助理" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="doctor_name" label="开方人" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="creator_name" label="创建人" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="create_time_text" label="创建时间" min-width="145" />
|
||||
<el-table-column label="操作" width="190" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="order-actions">
|
||||
<el-button type="primary" link @click="emit('openDiagnosis', row)">诊单</el-button>
|
||||
<el-button
|
||||
v-if="canViewMyPatientOrderDetail()"
|
||||
type="primary"
|
||||
link
|
||||
@click="orderActionHostRef?.openDetail(row)"
|
||||
>详情</el-button>
|
||||
<el-dropdown
|
||||
v-if="myPatientOrderActions(row).length"
|
||||
trigger="click"
|
||||
@command="(command) => openOrderAction(row, command as MyPatientOrderAction)"
|
||||
>
|
||||
<el-button type="primary" link>
|
||||
操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="action in myPatientOrderActions(row)"
|
||||
:key="action.key"
|
||||
:command="action.key"
|
||||
:class="{ 'danger-menu-item': action.danger }"
|
||||
>
|
||||
{{ action.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前范围内暂无订单" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
|
||||
<order-action-host ref="orderActionHostRef" @changed="getLists" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ArrowDown, Lock, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { myPatientOrderLists } from '@/api/first_visit'
|
||||
import OrderActionHost from './OrderActionHost.vue'
|
||||
import {
|
||||
canViewMyPatientOrderDetail,
|
||||
myPatientOrderActions,
|
||||
type MyPatientOrderAction
|
||||
} from './order-actions'
|
||||
|
||||
type SelectValue = '' | number
|
||||
|
||||
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
|
||||
const orderActionHostRef = ref<InstanceType<typeof OrderActionHost>>()
|
||||
const dateRange = ref<string[]>([])
|
||||
const formData = reactive({
|
||||
keyword: '',
|
||||
prescription_audit_status: '' as SelectValue,
|
||||
payment_slip_audit_status: '' as SelectValue,
|
||||
fulfillment_status: '' as SelectValue,
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const auditFilterOptions: Array<{ label: string; value: SelectValue }> = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待审核', value: 0 },
|
||||
{ label: '已通过', value: 1 },
|
||||
{ label: '已驳回', value: 2 }
|
||||
]
|
||||
const fulfillmentFilterOptions: Array<{ label: string; value: SelectValue }> = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待双审通过', value: 1 },
|
||||
{ label: '待发货', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '已取消', value: 4 },
|
||||
{ label: '已发货', value: 5 },
|
||||
{ label: '已签收', value: 6 },
|
||||
{ label: '进行中', value: 7 },
|
||||
{ label: '暂不制药', value: 8 },
|
||||
{ label: '拒收', value: 9 },
|
||||
{ label: '退款', value: 10 },
|
||||
{ label: '保留药方', value: 11 },
|
||||
{ label: '制药缓发', value: 12 }
|
||||
]
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: myPatientOrderLists as any,
|
||||
params: formData,
|
||||
size: 15,
|
||||
firstLoading: true
|
||||
})
|
||||
|
||||
const summary = computed(() => ({
|
||||
orders: Number(pager.extend?.summary?.orders || 0),
|
||||
amount: Number(pager.extend?.summary?.amount || 0),
|
||||
pending: Number(pager.extend?.summary?.pending || 0),
|
||||
completed: Number(pager.extend?.summary?.completed || 0),
|
||||
rejected: Number(pager.extend?.summary?.rejected || 0),
|
||||
rejectionRate: Number(pager.extend?.summary?.rejection_rate || 0)
|
||||
}))
|
||||
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
|
||||
const hasActiveFilters = computed(() => Boolean(
|
||||
formData.keyword.trim()
|
||||
|| formData.prescription_audit_status !== ''
|
||||
|| formData.payment_slip_audit_status !== ''
|
||||
|| formData.fulfillment_status !== ''
|
||||
|| formData.start_date
|
||||
|| formData.end_date
|
||||
))
|
||||
|
||||
function handleSearch() {
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function refreshPanel() {
|
||||
return getLists()
|
||||
}
|
||||
|
||||
function handleDateChange(value: string[] | null) {
|
||||
formData.start_date = value?.[0] || ''
|
||||
formData.end_date = value?.[1] || value?.[0] || ''
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
formData.keyword = ''
|
||||
formData.prescription_audit_status = ''
|
||||
formData.payment_slip_audit_status = ''
|
||||
formData.fulfillment_status = ''
|
||||
formData.start_date = ''
|
||||
formData.end_date = ''
|
||||
dateRange.value = []
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function toggleRejectedFilter() {
|
||||
formData.fulfillment_status = Number(formData.fulfillment_status) === 9 ? '' : 9
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function money(value: number | string) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function percent(value: number | string) {
|
||||
const number = Number(value || 0)
|
||||
return `${Number.isInteger(number) ? number.toFixed(0) : number.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function auditTagType(status: number): 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (Number(status) === 1) return 'success'
|
||||
if (Number(status) === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function fulfillmentTagType(status: number): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
const value = Number(status)
|
||||
if ([3, 6].includes(value)) return 'success'
|
||||
if ([4, 9, 10].includes(value)) return 'danger'
|
||||
if ([2, 5, 7].includes(value)) return 'primary'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function orderRowClassName({ row }: { row: any }) {
|
||||
if ([2].includes(Number(row.prescription_audit_status)) || [2].includes(Number(row.payment_slip_audit_status))) {
|
||||
return 'order-row-risk'
|
||||
}
|
||||
if ([3, 6].includes(Number(row.fulfillment_status))) return 'order-row-done'
|
||||
return ''
|
||||
}
|
||||
|
||||
function openOrderAction(row: Record<string, any>, action: MyPatientOrderAction) {
|
||||
orderActionHostRef.value?.openAction(row, action)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
|
||||
|
||||
onMounted(getLists)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.embedded-panel {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-actions,
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
padding: 16px;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 10px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.filter-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e8edf2;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #273244;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-filter-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid #e8edf2;
|
||||
}
|
||||
|
||||
.status-filter-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:deep(.el-radio-button__inner) {
|
||||
min-width: 72px;
|
||||
padding: 7px 12px;
|
||||
color: #5f6b7d;
|
||||
border: 1px solid #dfe5ec;
|
||||
border-radius: 6px;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
:deep(.el-radio-button:first-child .el-radio-button__inner),
|
||||
:deep(.el-radio-button:last-child .el-radio-button__inner) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.el-radio-button.is-active .el-radio-button__inner) {
|
||||
color: #fff;
|
||||
border-color: #0f9185;
|
||||
background: #0f9185;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
flex: 0 0 76px;
|
||||
padding-top: 7px;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(300px, 360px) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
padding-top: 13px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
> span {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 82px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
span,
|
||||
small {
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 7px 0 3px;
|
||||
color: #172033;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-card--interactive {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: #e5a9a3;
|
||||
box-shadow: 0 7px 18px rgba(132, 45, 40, 0.08);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: #d9685f;
|
||||
box-shadow: 0 0 0 2px rgba(217, 104, 95, 0.11);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-rejected,
|
||||
.metric-rejected-rate {
|
||||
border-color: #f0cbc7;
|
||||
background: #fff9f8;
|
||||
|
||||
strong {
|
||||
color: #c64f47;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-warning {
|
||||
border-color: #f3d8aa;
|
||||
background: #fffcf5;
|
||||
}
|
||||
|
||||
.metric-success {
|
||||
border-color: #b9e2dc;
|
||||
background: #f7fcfb;
|
||||
}
|
||||
|
||||
.embedded-table {
|
||||
width: 100%;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(th.el-table__cell) {
|
||||
height: 44px;
|
||||
color: #5f6b7d;
|
||||
background: #f7f9fb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.order-row-risk > td.el-table__cell) {
|
||||
background: #fff8f7;
|
||||
}
|
||||
|
||||
:deep(.order-row-done > td.el-table__cell) {
|
||||
background: #f8fcfb;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-cell,
|
||||
.id-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
strong {
|
||||
color: #202939;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #8b96a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #d04f3f;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.amount-excluded {
|
||||
color: #9aa4b2;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.danger-menu-item) {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panel-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-filter-row,
|
||||
.search-filter-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
:deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,834 +0,0 @@
|
||||
<template>
|
||||
<div class="progress-board">
|
||||
<section class="board-section overview-section">
|
||||
<div class="section-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>今日面诊概览</h2>
|
||||
<span class="heading-badge">{{ isOwnershipMode ? '按本人归属' : '与排班合并' }}</span>
|
||||
</div>
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<article class="overview-card">
|
||||
<span>{{ isOwnershipMode ? '今日本人面诊' : '今日面诊总号源' }}</span>
|
||||
<strong>{{ todayOverview.totalVisits }}</strong>
|
||||
<small>{{ todayOverview.doctorCount }} 位{{ isOwnershipMode ? '接诊' : '排班' }}医生</small>
|
||||
</article>
|
||||
<article class="overview-card">
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
|
||||
<strong>{{ todayOverview.booked }}</strong>
|
||||
<small>{{ isOwnershipMode ? '本人归属患者的有效挂号' : '按有效挂号占用号源' }}</small>
|
||||
</article>
|
||||
<article :class="['overview-card', isOwnershipMode ? 'overview-card-completed' : 'overview-card-empty']">
|
||||
<span>{{ isOwnershipMode ? '已完成' : '空号' }}</span>
|
||||
<strong>{{ isOwnershipMode ? todayOverview.completed : todayOverview.emptySlots }}</strong>
|
||||
<small>{{ isOwnershipMode ? `已过号 ${todayOverview.missed} 人` : '未被有效挂号占用' }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="board-section schedule-section">
|
||||
<div class="section-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>{{ isOwnershipMode ? '近一周面诊安排' : '近一周排班' }}</h2>
|
||||
<span class="heading-badge">{{ isOwnershipMode ? '本人患者' : '今日起 7 天' }}</span>
|
||||
</div>
|
||||
<span class="schedule-range">{{ scheduleRange }}</span>
|
||||
</div>
|
||||
|
||||
<div class="schedule-grid">
|
||||
<button
|
||||
v-for="day in weekSchedule"
|
||||
:key="day.date"
|
||||
type="button"
|
||||
class="schedule-card"
|
||||
:class="{
|
||||
'is-today': day.date === today,
|
||||
'is-selected': day.date === selectedScheduleDate
|
||||
}"
|
||||
:aria-pressed="day.date === selectedScheduleDate"
|
||||
@click="selectScheduleDay(day.date)"
|
||||
>
|
||||
<span class="schedule-date">{{ day.date_text }} {{ day.weekday }}</span>
|
||||
<strong>{{ isOwnershipMode ? day.total_appointments : day.total_slots }}</strong>
|
||||
<span class="schedule-card-stats">
|
||||
<span>{{ isOwnershipMode ? '待诊' : '已约' }} {{ isOwnershipMode ? day.waiting_appointments : day.booked_slots }}</span>
|
||||
<i>/</i>
|
||||
<span :class="{ 'is-completed': isOwnershipMode }">{{ isOwnershipMode ? '完成' : '空' }} {{ isOwnershipMode ? day.completed_appointments : day.empty_slots }}</span>
|
||||
</span>
|
||||
<span class="schedule-card-action">
|
||||
<span>{{ day.doctor_count }} 位医生</span>
|
||||
<span>{{ day.date === selectedScheduleDate ? '正在查看' : '查看明细' }} ›</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-drilldown">
|
||||
<div class="drilldown-heading">
|
||||
<div>
|
||||
<h3>{{ selectedScheduleLabel }}{{ isOwnershipMode ? '面诊安排' : '排班明细' }}</h3>
|
||||
<p>共 {{ selectedScheduleDay.doctor_count }} 位{{ isOwnershipMode ? '接诊' : '排班' }}医生,点击上方日期可切换</p>
|
||||
</div>
|
||||
<div :class="['drilldown-summary', { 'is-ownership': isOwnershipMode }]">
|
||||
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.total_appointments : selectedScheduleDay.total_slots }}</strong></span>
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.waiting_appointments : selectedScheduleDay.booked_slots }}</strong></span>
|
||||
<span>{{ isOwnershipMode ? '已完成' : '空号' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.completed_appointments : selectedScheduleDay.empty_slots }}</strong></span>
|
||||
<span v-if="isOwnershipMode">已过号 <strong>{{ selectedScheduleDay.missed_appointments }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedScheduleDoctors.length" class="doctor-schedule-list">
|
||||
<article
|
||||
v-for="(doctor, index) in selectedScheduleDoctors"
|
||||
:key="doctor.doctor_id"
|
||||
class="doctor-schedule-row"
|
||||
>
|
||||
<div class="doctor-identity">
|
||||
<span class="doctor-index">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<strong>{{ doctor.doctor_name }}</strong>
|
||||
<small>{{ isOwnershipMode ? '本人患者接诊医生' : '医生排班' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doctor-windows">
|
||||
<span>{{ isOwnershipMode ? '预约时刻' : '排班时段' }}</span>
|
||||
<strong>{{ doctorWindows(doctor) }}</strong>
|
||||
</div>
|
||||
<div class="doctor-metric">
|
||||
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }}</span>
|
||||
<strong>{{ isOwnershipMode ? doctor.total_appointments : doctor.total_slots }}</strong>
|
||||
</div>
|
||||
<div class="doctor-metric doctor-metric-booked">
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
|
||||
<strong>{{ isOwnershipMode ? doctor.waiting_appointments : doctor.booked_slots }}</strong>
|
||||
</div>
|
||||
<div :class="['doctor-metric', isOwnershipMode ? 'doctor-metric-completed' : 'doctor-metric-empty']">
|
||||
<span>{{ isOwnershipMode ? '完成 / 过号' : '空号' }}</span>
|
||||
<strong>{{ isOwnershipMode ? `${doctor.completed_appointments} / ${doctor.missed_appointments}` : doctor.empty_slots }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty
|
||||
v-else
|
||||
:image-size="56"
|
||||
:description="isOwnershipMode ? '该日期暂无本人归属患者的有效挂号' : '该日期暂无当前权限范围内的医生排班'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="board-section queue-section">
|
||||
<div class="section-heading queue-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>候诊列表</h2>
|
||||
<span class="heading-badge">按医生排队</span>
|
||||
<span class="queue-count">共 {{ pager.count }} 人</span>
|
||||
</div>
|
||||
<span class="refresh-time">每 15 秒自动刷新</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
class="queue-table"
|
||||
:row-class-name="tableRowClassName"
|
||||
@row-dblclick="openDiagnosis"
|
||||
>
|
||||
<el-table-column label="排队" width="92">
|
||||
<template #default="{ row }">
|
||||
<span class="queue-number">{{ row.queue_no || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="患者" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<strong>{{ row.patient_name || '未命名患者' }}</strong>
|
||||
<span>{{ row.phone_masked || '无手机号' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="doctor_name" label="医生" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="预约时间" min-width="135">
|
||||
<template #default="{ row }">
|
||||
<span class="appointment-clock">{{ appointmentClock(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="前方等待" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span :class="['waiting-text', `is-${row.queue_status || 'waiting'}`]">
|
||||
{{ waitingText(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" round effect="light" :type="queueTagType(row.queue_status)">
|
||||
{{ row.queue_status_text || '等待中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="当前权限范围内今日暂无候诊患者" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="pager.count > pager.size" class="pagination-wrap">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { myPatientProgressLists } from '@/api/first_visit'
|
||||
|
||||
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
const selectedScheduleDate = ref(today)
|
||||
const formData = reactive({
|
||||
keyword: '',
|
||||
status: 1 as const,
|
||||
start_date: today,
|
||||
end_date: today
|
||||
})
|
||||
|
||||
const { pager, getLists } = usePaging({
|
||||
fetchFun: myPatientProgressLists as any,
|
||||
params: formData,
|
||||
size: 15,
|
||||
firstLoading: true
|
||||
})
|
||||
|
||||
const todayOverview = computed(() => ({
|
||||
totalVisits: Number(pager.extend?.today_overview?.total_visits || 0),
|
||||
booked: Number(pager.extend?.today_overview?.booked || 0),
|
||||
completed: Number(pager.extend?.today_overview?.completed || 0),
|
||||
missed: Number(pager.extend?.today_overview?.missed || 0),
|
||||
emptySlots: Number(pager.extend?.today_overview?.empty_slots || 0),
|
||||
doctorCount: Number(pager.extend?.today_overview?.doctor_count || 0)
|
||||
}))
|
||||
const isOwnershipMode = computed(() => pager.extend?.schedule_mode === 'ownership')
|
||||
|
||||
const weekSchedule = computed(() => {
|
||||
const rows = Array.isArray(pager.extend?.week_schedule) ? pager.extend.week_schedule : []
|
||||
if (rows.length) return rows
|
||||
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = dayjs().add(index, 'day')
|
||||
return {
|
||||
date: date.format('YYYY-MM-DD'),
|
||||
date_text: date.format('MM-DD'),
|
||||
weekday: `周${'日一二三四五六'[date.day()]}`,
|
||||
total_slots: 0,
|
||||
booked_slots: 0,
|
||||
empty_slots: 0,
|
||||
doctor_count: 0,
|
||||
doctors: [],
|
||||
total_appointments: 0,
|
||||
waiting_appointments: 0,
|
||||
completed_appointments: 0,
|
||||
missed_appointments: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const selectedScheduleDay = computed(() => {
|
||||
return weekSchedule.value.find((day: any) => day.date === selectedScheduleDate.value) || weekSchedule.value[0] || {
|
||||
date: today,
|
||||
date_text: dayjs().format('MM-DD'),
|
||||
weekday: `周${'日一二三四五六'[dayjs().day()]}`,
|
||||
total_slots: 0,
|
||||
booked_slots: 0,
|
||||
empty_slots: 0,
|
||||
doctor_count: 0,
|
||||
doctors: [],
|
||||
total_appointments: 0,
|
||||
waiting_appointments: 0,
|
||||
completed_appointments: 0,
|
||||
missed_appointments: 0
|
||||
}
|
||||
})
|
||||
const selectedScheduleDoctors = computed(() => {
|
||||
return Array.isArray(selectedScheduleDay.value?.doctors) ? selectedScheduleDay.value.doctors : []
|
||||
})
|
||||
const selectedScheduleLabel = computed(() => {
|
||||
const day = selectedScheduleDay.value
|
||||
return `${day.date_text || ''} ${day.weekday || ''} `
|
||||
})
|
||||
|
||||
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
|
||||
const scheduleRange = computed(() => {
|
||||
const rows = weekSchedule.value
|
||||
if (!rows.length) return ''
|
||||
return `${rows[0].date_text} 至 ${rows[rows.length - 1].date_text}`
|
||||
})
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function refreshPanel(options?: { silent?: boolean }) {
|
||||
return getLists(options)
|
||||
}
|
||||
|
||||
function selectScheduleDay(date: string) {
|
||||
selectedScheduleDate.value = date
|
||||
}
|
||||
|
||||
function doctorWindows(doctor: Record<string, any>) {
|
||||
const source = isOwnershipMode.value ? doctor.appointment_times : doctor.schedule_windows
|
||||
const windows = Array.isArray(source) ? source.filter(Boolean) : []
|
||||
if (windows.length) return windows.join('、')
|
||||
return isOwnershipMode.value ? '暂无预约时刻' : '未设置具体时段'
|
||||
}
|
||||
|
||||
function appointmentClock(row: Record<string, any>) {
|
||||
const time = String(row.appointment_time || '').slice(0, 5)
|
||||
return time || '—'
|
||||
}
|
||||
|
||||
function waitingText(row: Record<string, any>) {
|
||||
if (row.queue_status === 'consulting') return '0(进行中)'
|
||||
if (row.queue_status === 'next') return '0(待接诊)'
|
||||
const ahead = Math.max(0, Number(row.ahead_count || 0))
|
||||
if (ahead === 0) return '0 位'
|
||||
return `${ahead} 位 · 约 ${Number(row.estimated_wait_minutes || ahead * 15)} 分钟`
|
||||
}
|
||||
|
||||
function queueTagType(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (status === 'consulting') return 'success'
|
||||
if (status === 'next') return 'warning'
|
||||
if (status === 'completed') return 'success'
|
||||
if (status === 'missed') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function tableRowClassName({ row }: { row: Record<string, any> }) {
|
||||
return Number(row.is_self_patient) === 1 ? 'queue-row-self' : ''
|
||||
}
|
||||
|
||||
function openDiagnosis(row: Record<string, any>) {
|
||||
emit('openDiagnosis', row)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
|
||||
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
refreshTimer = setInterval(() => getLists({ silent: true }), 15_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.progress-board {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.board-section {
|
||||
padding: 16px;
|
||||
border: 1px solid #e1e7ee;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.heading-copy,
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heading-copy {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.heading-badge {
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
color: #758195;
|
||||
background: #eef2f6;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
gap: 5px;
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
min-height: 78px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
> span,
|
||||
> small {
|
||||
display: block;
|
||||
color: #778398;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
> strong {
|
||||
display: block;
|
||||
margin: 7px 0 5px;
|
||||
color: #111a2c;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
> small {
|
||||
color: #a0a9b7;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-card-empty > strong {
|
||||
color: #ee4d55;
|
||||
}
|
||||
|
||||
.overview-card-completed > strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.schedule-range,
|
||||
.refresh-time,
|
||||
.queue-count {
|
||||
color: #929dac;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.schedule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
min-width: 0;
|
||||
padding: 11px 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 9px;
|
||||
background: #fbfcfe;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #7ac9c2;
|
||||
background: #f7fcfb;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid rgba(15, 145, 133, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.is-today {
|
||||
border-color: #8fd3cd;
|
||||
background: #f4fbfa;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: #0f9185;
|
||||
background: #effaf8;
|
||||
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1), inset 0 -3px 0 #0f9185;
|
||||
}
|
||||
|
||||
> strong {
|
||||
display: block;
|
||||
margin: 7px 0 5px;
|
||||
color: #131c2d;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-date {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #70809a;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-card-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: #0c9967;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
color: #a5afbd;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: #d8862b;
|
||||
}
|
||||
|
||||
span.is-completed {
|
||||
color: #07886d;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-card-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin: 10px 2px 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e7ecef;
|
||||
color: #7b8799;
|
||||
font-size: 10px;
|
||||
|
||||
span:last-child {
|
||||
color: #0f8077;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-drilldown {
|
||||
margin-top: 14px;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
|
||||
.drilldown-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 11px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8a95a6;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.drilldown-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
color: #7a8698;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
strong {
|
||||
margin-left: 3px;
|
||||
color: #243044;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
span:last-child strong {
|
||||
color: #d17820;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-schedule-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(3, minmax(64px, 0.55fr));
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 58px;
|
||||
padding: 8px 13px;
|
||||
border: 1px solid #e4e9ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.doctor-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: #202b3d;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-top: 2px;
|
||||
color: #9aa4b2;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-index {
|
||||
display: grid;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
color: #0f766e;
|
||||
background: #e7f6f4;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doctor-windows,
|
||||
.doctor-metric {
|
||||
min-width: 0;
|
||||
|
||||
span,
|
||||
strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #98a2b3;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: 3px;
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-windows strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doctor-metric {
|
||||
text-align: right;
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-metric-booked strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.doctor-metric-empty strong {
|
||||
color: #d17820;
|
||||
}
|
||||
|
||||
.doctor-metric-completed strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.queue-heading {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.queue-table {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(th.el-table__cell) {
|
||||
height: 40px;
|
||||
color: #667085;
|
||||
background: #f7f9fb;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(td.el-table__cell) {
|
||||
height: 46px;
|
||||
padding: 6px 0;
|
||||
color: #253044;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.queue-row-self > td.el-table__cell) {
|
||||
background: #fff8e8 !important;
|
||||
}
|
||||
|
||||
:deep(.queue-row-self > td.el-table__cell:first-child) {
|
||||
border-left: 3px solid #f08332;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-number {
|
||||
display: inline-grid;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 7px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: #0f766e;
|
||||
background: #e7f6f4;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.patient-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
strong {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #98a2b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.appointment-clock {
|
||||
color: #263246;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.waiting-text {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
|
||||
&.is-consulting {
|
||||
color: #07886d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.is-next {
|
||||
color: #d17820;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.schedule-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) repeat(3, minmax(56px, 0.5fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.board-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.overview-grid,
|
||||
.schedule-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.heading-copy {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drilldown-heading,
|
||||
.drilldown-summary {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drilldown-summary {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.doctor-identity,
|
||||
.doctor-windows {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.doctor-metric {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,125 +0,0 @@
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { isRemoteSnapshotLocked } from '@/views/consumer/prescription/components/prescription-order-utils'
|
||||
|
||||
export type MyPatientOrderAction =
|
||||
| 'edit'
|
||||
| 'audit_prescription'
|
||||
| 'revoke_rx_audit'
|
||||
| 'audit_payment'
|
||||
| 'revoke_pay_audit'
|
||||
| 'ddcode'
|
||||
| 'ship'
|
||||
| 'add_pay_order'
|
||||
| 'complete'
|
||||
| 'refund'
|
||||
| 'withdraw'
|
||||
| 'upload_pharmacy'
|
||||
|
||||
export interface MyPatientOrderActionItem {
|
||||
key: MyPatientOrderAction
|
||||
label: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
function permitted(permission: string) {
|
||||
return hasPermission([permission])
|
||||
}
|
||||
|
||||
function remoteLocked(row: Record<string, any>) {
|
||||
return isRemoteSnapshotLocked(row as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function canViewMyPatientOrderDetail() {
|
||||
return permitted('tcm.prescriptionOrder/detail')
|
||||
}
|
||||
|
||||
export function myPatientOrderActions(row: Record<string, any>): MyPatientOrderActionItem[] {
|
||||
const actions: MyPatientOrderActionItem[] = []
|
||||
const fulfillment = Number(row.fulfillment_status)
|
||||
const prescriptionAudit = Number(row.prescription_audit_status)
|
||||
const paymentAudit = Number(row.payment_slip_audit_status)
|
||||
const locked = remoteLocked(row)
|
||||
|
||||
if (
|
||||
canViewMyPatientOrderDetail()
|
||||
&& permitted('tcm.prescriptionOrder/edit')
|
||||
&& fulfillment === 1
|
||||
&& !locked
|
||||
) {
|
||||
actions.push({ key: 'edit', label: '编辑订单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPrescription')
|
||||
&& prescriptionAudit === 0
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'audit_prescription', label: '处方审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPrescription')
|
||||
&& [1, 2].includes(prescriptionAudit)
|
||||
&& paymentAudit === 0
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
&& !locked
|
||||
) {
|
||||
actions.push({ key: 'revoke_rx_audit', label: '撤回处方审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPayment')
|
||||
&& prescriptionAudit === 1
|
||||
&& paymentAudit === 0
|
||||
&& ![3, 4].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'audit_payment', label: '支付单审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPayment')
|
||||
&& prescriptionAudit === 1
|
||||
&& [1, 2].includes(paymentAudit)
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'revoke_pay_audit', label: '撤回支付审核' })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/ddcode')) {
|
||||
actions.push({ key: 'ddcode', label: '修改快递单号' })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/ship') && fulfillment === 2) {
|
||||
actions.push({ key: 'ship', label: '确认发货' })
|
||||
}
|
||||
const amount = Math.round((Number(row.amount) || 0) * 100) / 100
|
||||
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/addPayOrder')
|
||||
&& [5, 6].includes(fulfillment)
|
||||
&& paidTotal < amount
|
||||
) {
|
||||
actions.push({ key: 'add_pay_order', label: '补齐支付单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/complete')
|
||||
&& [5, 6].includes(fulfillment)
|
||||
&& paymentAudit === 1
|
||||
) {
|
||||
actions.push({ key: 'complete', label: '完成订单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/refund')
|
||||
&& [3, 5, 6, 9].includes(fulfillment)
|
||||
&& paymentAudit === 1
|
||||
) {
|
||||
actions.push({ key: 'refund', label: '退款', danger: true })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/withdraw') && fulfillment === 1 && !locked) {
|
||||
actions.push({ key: 'withdraw', label: '撤回订单', danger: true })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/uploadToPharmacy')
|
||||
&& row.can_upload_pharmacy !== false
|
||||
&& prescriptionAudit === 1
|
||||
&& ![3, 4, 8, 10, 11, 12].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'upload_pharmacy', label: '上传药房' })
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,646 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="registration-stats"
|
||||
v-loading="loading"
|
||||
element-loading-text="正在汇总权限范围内的挂号与预约数据"
|
||||
>
|
||||
<header class="page-heading">
|
||||
<div class="heading-copy">
|
||||
<span class="heading-mark"><el-icon><Histogram /></el-icon></span>
|
||||
<div>
|
||||
<h1>挂号统计</h1>
|
||||
<p>挂号、预约、诊单与目标数据按当前角色和部门权限实时汇总</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item">
|
||||
<span>部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDepartmentChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item filter-item--time">
|
||||
<span>时间</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span>员工</span>
|
||||
<el-select
|
||||
v-model="query.assistant_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部可见员工"
|
||||
class="employee-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.assistants"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-summary">
|
||||
<strong>{{ dashboard.meta.start_date || '—' }}</strong>
|
||||
<span>至 {{ dashboard.meta.end_date || '—' }} · {{ dashboard.meta.member_count || 0 }} 位员工</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="挂号统计核心指标">
|
||||
<article class="metric-card metric-card--teal">
|
||||
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总挂号</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.registration_count) }}</strong>
|
||||
<small :class="compareClass(dashboard.summary.registration_compare_rate)">
|
||||
{{ compareText(dashboard.summary.registration_compare_rate) }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--blue">
|
||||
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总预约</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.appointment_count) }}</strong>
|
||||
<small :class="compareClass(dashboard.summary.appointment_compare_rate)">
|
||||
{{ compareText(dashboard.summary.appointment_compare_rate) }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--violet">
|
||||
<div class="metric-icon"><el-icon><DocumentChecked /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总诊单</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
|
||||
<small>排除取消、拒收和退款订单</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--amber">
|
||||
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总业绩</span>
|
||||
<strong>{{ formatMoney(dashboard.summary.order_amount) }}</strong>
|
||||
<small>按业务订单创建人归属</small>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel employee-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>本组员工挂号与预约统计</h2>
|
||||
<p>挂号按已支付且实收低于 10 元的支付订单统计;预约按预约记录统计</p>
|
||||
</div>
|
||||
<span class="panel-badge">{{ dashboard.meta.time_label || '当前范围' }}</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="dashboard.employee_rows"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
class="stats-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门 / 员工" min-width="250" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell" :class="`is-${row.row_type}`">
|
||||
<span v-if="row.row_type === 'department'" class="dept-dot" />
|
||||
<el-avatar v-else :size="26">{{ avatarText(row.name) }}</el-avatar>
|
||||
<strong>{{ row.name }}</strong>
|
||||
<em v-if="row.row_type === 'department'">{{ row.member_count }} 人</em>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="day_after_count" label="后日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="order_count" label="诊单" min-width="86" align="right" sortable />
|
||||
<el-table-column label="业绩" min-width="128" align="right" sortable :sort-method="sortAmount">
|
||||
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号较上期" min-width="112" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
|
||||
{{ compactCompare(row.registration_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约较上期" min-width="112" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
|
||||
{{ compactCompare(row.appointment_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="92" align="center">
|
||||
<template #default><span class="status-pill"><i />正常</span></template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限与筛选范围内暂无医助数据" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel target-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>一诊诊金目标追踪</h2>
|
||||
<p>{{ dashboard.target.year }} 年 · {{ dashboard.target.scope_note }}</p>
|
||||
</div>
|
||||
<span class="panel-badge">{{ dashboard.target.department_count || 0 }} 个目标部门</span>
|
||||
</div>
|
||||
<div class="target-layout">
|
||||
<div class="target-summary">
|
||||
<div class="target-numbers">
|
||||
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
|
||||
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
|
||||
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.completion_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="13"
|
||||
color="#139a8c"
|
||||
/>
|
||||
<p v-if="Number(dashboard.target.target_amount) > 0">
|
||||
尚差 {{ formatMoney(Math.max(0, Number(dashboard.target.target_amount) - Number(dashboard.target.actual_amount))) }} 达成年度目标
|
||||
</p>
|
||||
<p v-else class="target-empty">当前范围未维护可用目标,实际业绩仍按统一口径正常统计。</p>
|
||||
</div>
|
||||
<div class="target-chart-wrap">
|
||||
<div class="chart-legend"><i class="actual" />累计完成 <i class="target" />累计目标</div>
|
||||
<v-charts class="target-chart" :option="targetChartOption" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>{{ dashboard.meta.time_label || '今日' }}业绩 TOP</h2><p>按业务订单创建人排序</p></div>
|
||||
<span class="panel-badge">金额</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.performance.length" class="ranking-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.performance" :key="`performance-${item.admin_id}`" class="ranking-row">
|
||||
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
|
||||
<span>{{ item.name }}<small>{{ item.count }} 单</small></span>
|
||||
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxPerformance) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="暂无业绩数据" />
|
||||
</article>
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>{{ dashboard.meta.time_label || '今日' }}挂号 TOP</h2><p>按低于 10 元的已支付订单笔数排序</p></div>
|
||||
<span class="panel-badge">挂号</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.registrations.length" class="ranking-list ranking-list--blue">
|
||||
<div v-for="(item, index) in dashboard.rankings.registrations" :key="`registration-${item.admin_id}`" class="ranking-row">
|
||||
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
|
||||
<span>{{ item.name }}<small>已支付小额订单</small></span>
|
||||
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxRegistrations) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 笔</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="暂无挂号数据" />
|
||||
</article>
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>{{ dashboard.meta.time_label || '今日' }}预约 TOP</h2><p>按有效预约记录数量排序</p></div>
|
||||
<span class="panel-badge">预约</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.appointments.length" class="ranking-list ranking-list--blue">
|
||||
<div v-for="(item, index) in dashboard.rankings.appointments" :key="`appointment-${item.admin_id}`" class="ranking-row">
|
||||
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
|
||||
<span>{{ item.name }}<small>有效预约</small></span>
|
||||
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxAppointments) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 个</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="暂无预约数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel department-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>按部门统计</h2><p>员工按最深层有效归属部门唯一计入,避免多部门重复累计</p></div>
|
||||
<span class="panel-badge">{{ dashboard.departments.length }} 个部门</span>
|
||||
</div>
|
||||
<el-table :data="dashboard.departments" class="stats-table department-table">
|
||||
<el-table-column prop="name" label="部门" min-width="220">
|
||||
<template #default="{ row }"><strong>{{ row.name }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="member_count" label="人数" min-width="90" align="right" sortable />
|
||||
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="order_count" label="诊单" min-width="90" align="right" sortable />
|
||||
<el-table-column label="业绩" min-width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号环比" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
|
||||
{{ compactCompare(row.registration_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约环比" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
|
||||
{{ compactCompare(row.appointment_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前范围暂无部门汇总" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<footer class="data-note">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ dashboard.meta.registration_rule }};{{ dashboard.meta.appointment_rule }};{{ dashboard.meta.performance_rule }}。</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitRegistrationStatsPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Calendar,
|
||||
DocumentChecked,
|
||||
Histogram,
|
||||
InfoFilled,
|
||||
Lock,
|
||||
Refresh,
|
||||
Wallet
|
||||
} from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import {
|
||||
firstVisitRegistrationStatsOverview,
|
||||
type FirstVisitRegistrationStatsParams
|
||||
} from '@/api/first_visit'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
member_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
|
||||
},
|
||||
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
|
||||
summary: {
|
||||
registration_count: 0, registration_compare_count: 0, registration_compare_rate: null as number | null,
|
||||
appointment_count: 0, appointment_compare_count: 0, appointment_compare_rate: null as number | null,
|
||||
order_count: 0, order_amount: 0, range_label: '今日'
|
||||
},
|
||||
employee_rows: [] as any[],
|
||||
rankings: { performance: [] as any[], registrations: [] as any[], appointments: [] as any[] },
|
||||
departments: [] as any[],
|
||||
target: {
|
||||
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0,
|
||||
completion_rate: null as number | null, department_count: 0, scope_note: '',
|
||||
months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const query = reactive<FirstVisitRegistrationStatsParams>({ time_type: 'today' })
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' }
|
||||
]
|
||||
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
|
||||
|
||||
const maxPerformance = computed(() => Math.max(0, ...dashboard.rankings.performance.map((item: any) => Number(item.value) || 0)))
|
||||
const maxRegistrations = computed(() => Math.max(0, ...dashboard.rankings.registrations.map((item: any) => Number(item.value) || 0)))
|
||||
const maxAppointments = computed(() => Math.max(0, ...dashboard.rankings.appointments.map((item: any) => Number(item.value) || 0)))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
grid: { left: 18, right: 18, top: 18, bottom: 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
valueFormatter: (value: number) => formatMoney(value)
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dashboard.target.months,
|
||||
axisLine: { lineStyle: { color: '#d9e1e8' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#7d8b9c', fontSize: 11 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitNumber: 3,
|
||||
axisLabel: { color: '#8b98a8', formatter: (value: number) => compactMoney(value) },
|
||||
splitLine: { lineStyle: { color: '#eef2f5' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '累计完成', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
|
||||
data: dashboard.target.actual_cumulative,
|
||||
lineStyle: { color: '#139a8c', width: 3 },
|
||||
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
|
||||
areaStyle: { color: 'rgba(19,154,140,.08)' }
|
||||
},
|
||||
{
|
||||
name: '累计目标', type: 'line', smooth: true, showSymbol: false,
|
||||
data: dashboard.target.target_cumulative,
|
||||
lineStyle: { color: '#5f86e8', width: 2, type: 'dashed' }
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: FirstVisitRegistrationStatsParams = { time_type: query.time_type }
|
||||
if (query.dept_id) params.dept_id = Number(query.dept_id)
|
||||
if (query.assistant_id) params.assistant_id = Number(query.assistant_id)
|
||||
const result: any = await firstVisitRegistrationStatsOverview(params)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '挂号统计加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDepartmentChange() {
|
||||
delete query.assistant_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function compactMoney(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
function nullablePercent(value: unknown) {
|
||||
return value === null || value === undefined ? '未设置' : `${Number(value).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function progressValue(value: unknown) {
|
||||
return Math.min(100, Math.max(0, Number(value) || 0))
|
||||
}
|
||||
|
||||
function compareText(value: unknown) {
|
||||
if (value === null || value === undefined) return '上期无数据,暂不计算环比'
|
||||
const number = Number(value)
|
||||
if (number === 0) return '与上期持平'
|
||||
return `${number > 0 ? '较上期增长' : '较上期下降'} ${Math.abs(number).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compactCompare(value: unknown) {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const number = Number(value)
|
||||
return `${number > 0 ? '+' : ''}${number.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compareClass(value: unknown) {
|
||||
if (value === null || value === undefined || Number(value) === 0) return 'is-neutral'
|
||||
return Number(value) > 0 ? 'is-up' : 'is-down'
|
||||
}
|
||||
|
||||
function rankWidth(value: unknown, max: number) {
|
||||
if (max <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, Number(value || 0) / max * 100))}%`
|
||||
}
|
||||
|
||||
function avatarText(name: unknown) {
|
||||
const text = String(name || '员').trim()
|
||||
return text.slice(-1)
|
||||
}
|
||||
|
||||
function sortAmount(left: any, right: any) {
|
||||
return Number(left?.order_amount || 0) - Number(right?.order_amount || 0)
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.registration-stats {
|
||||
--ink: #17243a;
|
||||
--muted: #778598;
|
||||
--line: #e3e9ef;
|
||||
--canvas: #f4f6f8;
|
||||
--teal: #139a8c;
|
||||
--blue: #4f78e5;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 76px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.heading-copy,
|
||||
.heading-actions,
|
||||
.filter-item,
|
||||
.name-cell,
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heading-copy { gap: 12px; }
|
||||
.heading-mark {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
box-shadow: 0 8px 20px rgba(19, 154, 140, .18);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 20px; line-height: 1.3; letter-spacing: .01em; }
|
||||
h2 { font-size: 15px; line-height: 1.4; }
|
||||
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.heading-actions { gap: 12px; color: var(--muted); font-size: 12px; }
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #cbe8e3;
|
||||
border-radius: 7px;
|
||||
color: #127f75;
|
||||
background: #f1faf8;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
margin-top: 14px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.filter-item { gap: 9px; color: #68778b; font-size: 13px; }
|
||||
.dept-select { width: 220px; }
|
||||
.employee-select { width: 180px; }
|
||||
.filter-summary { margin-left: auto; text-align: right; }
|
||||
.filter-summary strong { display: block; font-size: 13px; }
|
||||
.filter-summary span { color: var(--muted); font-size: 11px; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
min-height: 114px;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.metric-card--teal .metric-icon { color: #107f75; background: #e9f7f5; }
|
||||
.metric-card--blue .metric-icon { color: #416bd7; background: #edf1ff; }
|
||||
.metric-card--violet .metric-icon { color: #7257c7; background: #f2efff; }
|
||||
.metric-card--amber .metric-icon { color: #bc7428; background: #fff4e7; }
|
||||
.metric-card span { display: block; color: #748296; font-size: 13px; }
|
||||
.metric-card strong { display: block; margin: 4px 0 2px; font-size: 27px; line-height: 1.15; }
|
||||
.metric-card small { color: #8b98a8; font-size: 11px; }
|
||||
.metric-card small.is-up, .rate-text.is-up { color: #17956f; }
|
||||
.metric-card small.is-down, .rate-text.is-down { color: #e25858; }
|
||||
|
||||
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 15px 16px 13px;
|
||||
}
|
||||
.panel-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
color: #758497;
|
||||
background: #f2f5f7;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stats-table { --el-table-header-bg-color: #f7f9fb; --el-table-border-color: #e7ecf1; }
|
||||
.stats-table :deep(th.el-table__cell) { height: 42px; color: #68778b; font-weight: 500; }
|
||||
.stats-table :deep(td.el-table__cell) { height: 47px; }
|
||||
.stats-table :deep(.el-table__row--level-0) { background: #fafcfd; }
|
||||
.name-cell { gap: 9px; }
|
||||
.name-cell em { color: #8d99a7; font-size: 11px; font-style: normal; font-weight: 400; }
|
||||
.name-cell.is-department strong { font-weight: 700; }
|
||||
.name-cell :deep(.el-avatar) { color: #167e75; background: #e8f5f3; font-size: 11px; }
|
||||
.dept-dot { width: 8px; height: 8px; border-radius: 3px; background: var(--teal); }
|
||||
.status-pill { display: inline-flex; align-items: center; gap: 5px; color: #218d71; font-size: 12px; }
|
||||
.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: #34b38d; }
|
||||
.rate-text { font-size: 12px; }
|
||||
.rate-text.is-neutral { color: #8b98a8; }
|
||||
|
||||
.target-layout { display: grid; grid-template-columns: minmax(340px, .8fr) minmax(480px, 1.2fr); border-top: 1px solid #edf1f4; }
|
||||
.target-summary { display: flex; flex-direction: column; justify-content: center; padding: 24px 22px; border-right: 1px solid #edf1f4; }
|
||||
.target-numbers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-bottom: 22px; }
|
||||
.target-numbers span { display: block; color: var(--muted); font-size: 12px; }
|
||||
.target-numbers strong { display: block; margin-top: 5px; font-size: 20px; }
|
||||
.target-numbers .is-teal { color: var(--teal); }
|
||||
.target-summary p { margin-top: 9px; color: var(--muted); font-size: 11px; }
|
||||
.target-summary .target-empty { color: #b17832; }
|
||||
.target-chart-wrap { position: relative; min-height: 238px; padding: 10px 14px 4px; }
|
||||
.target-chart { width: 100%; height: 225px; }
|
||||
.chart-legend { position: absolute; z-index: 2; top: 11px; right: 18px; gap: 6px; color: #778598; font-size: 11px; }
|
||||
.chart-legend i { width: 18px; height: 3px; margin-left: 8px; border-radius: 2px; }
|
||||
.chart-legend .actual { background: var(--teal); }
|
||||
.chart-legend .target { background: repeating-linear-gradient(90deg, var(--blue) 0 5px, transparent 5px 8px); }
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.ranking-panel { min-height: 286px; }
|
||||
.ranking-list { padding: 2px 16px 16px; }
|
||||
.ranking-row { display: grid; grid-template-columns: 30px minmax(86px, .8fr) minmax(64px, 1fr) auto; align-items: center; gap: 10px; min-height: 43px; border-top: 1px solid #eff2f5; }
|
||||
.ranking-row > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 7px; color: #8491a1; background: #f1f4f6; font-size: 11px; }
|
||||
.ranking-row > b.is-top { color: #fff; background: var(--teal); }
|
||||
.ranking-list--blue .ranking-row > b.is-top { background: var(--blue); }
|
||||
.ranking-row > span { overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ranking-row small { margin-left: 6px; color: #939eab; font-size: 10px; font-weight: 400; }
|
||||
.ranking-row > strong { text-align: right; font-size: 12px; }
|
||||
.rank-track { height: 7px; overflow: hidden; border-radius: 5px; background: #edf1f4; }
|
||||
.rank-track i { display: block; height: 100%; border-radius: inherit; background: var(--teal); }
|
||||
.ranking-list--blue .rank-track i { background: var(--blue); }
|
||||
.department-table { border-top: 1px solid #edf1f4; }
|
||||
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.filter-summary { margin-left: 0; }
|
||||
.target-layout { grid-template-columns: 1fr; }
|
||||
.target-summary { border-right: 0; border-bottom: 1px solid #edf1f4; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.registration-stats { padding: 10px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
.heading-actions { flex-wrap: wrap; }
|
||||
.update-time { display: none; }
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item { width: 100%; justify-content: space-between; }
|
||||
.dept-select, .employee-select { width: calc(100% - 52px); }
|
||||
.filter-item--time { justify-content: flex-start; }
|
||||
.target-numbers { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="温馨提示:平台配置在各个场景下的通知发送方式和内容模板"
|
||||
:closable="false"
|
||||
show-icon
|
||||
></el-alert>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<el-tabs v-model="tabsActive" @tab-change="getLists">
|
||||
<el-tab-pane
|
||||
v-for="(item, index) in tabsMap"
|
||||
@@ -18,7 +18,7 @@
|
||||
lazy
|
||||
></el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-table size="large" :data="pager.lists" v-loading="pager.loading">
|
||||
<el-table size="large" :data="pager.lists" >
|
||||
<el-table-column label="通知场景" prop="scene_name" min-width="120" />
|
||||
<el-table-column label="通知类型" prop="type_desc" min-width="160" />
|
||||
<el-table-column label="短信通知" min-width="80">
|
||||
@@ -44,7 +44,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="notice">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never" v-loading="state.loading">
|
||||
<admin-page-data-panel v-loading="state.loading">
|
||||
<el-table size="large" :data="state.lists">
|
||||
<el-table-column label="短信渠道" prop="name" min-width="120" />
|
||||
<el-table-column label="状态" min-width="120">
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup ref="editRef" @success="getLists" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,13 +33,11 @@ import EditPopup from './edit.vue'
|
||||
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
|
||||
// 列表数据
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
lists: []
|
||||
})
|
||||
|
||||
// 获取存储引擎列表数据
|
||||
const getLists = async () => {
|
||||
try {
|
||||
state.loading = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 订单列表 -->
|
||||
<template>
|
||||
<div class="order-list">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<!-- 搜索表单 -->
|
||||
<el-form class="ls-form" :model="queryParams" inline>
|
||||
<el-form-item class="w-[280px]" label="订单号">
|
||||
@@ -106,10 +106,10 @@
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<!-- Tab 筛选 + 今日收益 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<!-- Tab 筛选 + 今日收益 + 数据表格 -->
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<el-tabs v-model="patientAssociationTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="全部" name="" />
|
||||
@@ -122,10 +122,7 @@
|
||||
<span class="text-gray-400">({{ todayRevenue.count }} 笔)</span>
|
||||
</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">
|
||||
<el-button type="primary" :disabled="!selectedOrderIds.length" @click="openAssignDialog">
|
||||
将创建人指给医助
|
||||
@@ -136,7 +133,6 @@
|
||||
</div>
|
||||
<el-table
|
||||
ref="orderTableRef"
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
row-key="id"
|
||||
size="large"
|
||||
@@ -283,10 +279,10 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="department">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="部门名称" prop="name">
|
||||
<el-input
|
||||
@@ -22,22 +22,21 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="loading">
|
||||
<template #toolbar>
|
||||
<el-button v-perms="['dept.dept/add']" type="primary" @click="handleAdd()">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button @click="handleExpand"> 展开/折叠 </el-button>
|
||||
</div>
|
||||
<el-button @click="handleExpand">展开/折叠</el-button>
|
||||
</template>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
class="mt-4"
|
||||
size="large"
|
||||
v-loading="loading"
|
||||
:data="lists"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
@@ -68,7 +67,6 @@
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="排序" prop="sort" min-width="100" />
|
||||
<el-table-column label="更新时间" prop="update_time" min-width="180" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
@@ -101,7 +99,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -120,14 +118,18 @@ let isExpand = false
|
||||
const loading = ref(false)
|
||||
const lists = ref<any[]>([])
|
||||
const queryParams = reactive({
|
||||
status: '',
|
||||
name: ''
|
||||
name: '',
|
||||
status: ''
|
||||
})
|
||||
const showEdit = ref(false)
|
||||
|
||||
const getLists = async () => {
|
||||
loading.value = true
|
||||
lists.value = await deptLists(queryParams)
|
||||
loading.value = false
|
||||
try {
|
||||
lists.value = await deptLists(queryParams)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetParams = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="post-lists">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="岗位编码">
|
||||
<el-input
|
||||
@@ -31,17 +31,18 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button v-perms="['dept.jobs/add']" type="primary" @click="handleAdd()">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
</template>
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="岗位编码" prop="code" min-width="100" />
|
||||
<el-table-column label="岗位名称" prop="name" min-width="100" />
|
||||
<el-table-column label="排序" prop="sort" min-width="100" />
|
||||
@@ -75,10 +76,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1275,7 +1275,7 @@ onUnmounted(() => {
|
||||
|
||||
.float-action.edit {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%);
|
||||
}
|
||||
|
||||
.float-action.edit:hover,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="admin">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="mb-[-16px]" :model="formData" inline>
|
||||
<el-form-item class="w-[280px]" label="管理员账号">
|
||||
<el-input
|
||||
@@ -40,77 +40,78 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
|
||||
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
<div class="mt-4">
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" min-width="60" />>
|
||||
<el-table-column label="头像" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :size="50" :src="row.avatar"></el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号" prop="account" min-width="100" />
|
||||
<el-table-column label="名称" prop="name" min-width="100" />
|
||||
<el-table-column
|
||||
label="角色"
|
||||
prop="role_name"
|
||||
min-width="100"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column
|
||||
label="部门"
|
||||
prop="dept_name"
|
||||
min-width="100"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="180" />
|
||||
<el-table-column label="最近登录时间" prop="login_time" min-width="180" />
|
||||
<el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
|
||||
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="row.root != 1"
|
||||
v-model="row.disable"
|
||||
:active-value="0"
|
||||
:inactive-value="1"
|
||||
@change="changeStatus(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['auth.admin/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.root != 1"
|
||||
v-perms="['auth.admin/delete']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="flex mt-4 justify-end">
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</template>
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" min-width="60" />
|
||||
<el-table-column label="头像" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :size="50" :src="row.avatar"></el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号" prop="account" min-width="100" />
|
||||
<el-table-column label="名称" prop="name" min-width="100" />
|
||||
<el-table-column
|
||||
label="角色"
|
||||
prop="role_name"
|
||||
min-width="100"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column
|
||||
label="部门"
|
||||
prop="dept_name"
|
||||
min-width="100"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="180" />
|
||||
<el-table-column label="最近登录时间" prop="login_time" min-width="180" />
|
||||
<el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
|
||||
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="row.root != 1"
|
||||
v-model="row.disable"
|
||||
:active-value="0"
|
||||
:inactive-value="1"
|
||||
@change="changeStatus(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['auth.admin/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.root != 1"
|
||||
v-perms="['auth.admin/delete']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -125,7 +126,6 @@ import feedback from '@/utils/feedback'
|
||||
import EditPopup from './edit.vue'
|
||||
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
account: '',
|
||||
name: '',
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<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()">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button @click="handleExpand"> 展开/折叠 </el-button>
|
||||
</div>
|
||||
<el-button @click="handleExpand">展开/折叠</el-button>
|
||||
</template>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
class="mt-4"
|
||||
size="large"
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
@@ -87,7 +85,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user