Compare commits

..
Author SHA1 Message Date
Your Name e76f16193b 更新 2026-07-06 15:00:10 +08:00
long eb0efba739 build 2026-07-03 12:07:34 +08:00
long c7e7022181 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-03 12:04:06 +08:00
long 26e6cc433b Add bulk asset resource deletion 2026-07-03 12:01:49 +08:00
Your Name c22b84cff9 更新 2026-07-03 11:22:07 +08:00
Your Name 4f9486fb3d Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-02 16:35:40 +08:00
Your Name d24b04f9e9 更新 2026-07-02 16:35:24 +08:00
long 465cba3788 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-02 16:30:24 +08:00
long dc590d37e9 修正医生维度接诊率显示 2026-07-02 16:26:24 +08:00
Your Name d1f587fdd7 更新 2026-07-02 16:19:43 +08:00
Your Name e8f68101c4 更新 2026-07-02 16:13:38 +08:00
Your Name cea9392239 Merge branch 'master' into 20260623 2026-07-02 16:08:14 +08:00
Your Name 3ea3c97c42 更新 2026-07-02 14:47:53 +08:00
Your Name efd058bce5 更新 2026-07-02 14:35:20 +08:00
long e538dd03a1 build 2026-07-02 11:57:54 +08:00
long 4a17fe8a0c 调整综合转化统计医生维度口径 2026-07-02 11:55:15 +08:00
Your Name e2a1e62867 更新 2026-06-29 09:28:00 +08:00
Your Name b6cdd20c56 新增 2026-06-29 09:25:29 +08:00
Your Name c1be0aa3e5 更新 2026-06-24 10:07:39 +08:00
Your Name 6c87b72ce4 更新 2026-06-23 11:31:49 +08:00
Your Name e28480af32 更新 2026-06-16 16:16:31 +08:00
Your Name 37b2945c44 更新 2026-06-16 15:47:44 +08:00
Your Name c9c2b4608f 更新 2026-06-16 15:23:59 +08:00
Your Name e61e7f97fa 更新 2026-06-15 10:37:10 +08:00
Your Name 14e5bba9a2 更新 2026-06-15 09:18:59 +08:00
Your Name 6b713a2d6c 更新 2026-06-13 15:00:43 +08:00
Your Name 8ae5b4eead 更新 2026-06-12 18:07:21 +08:00
long e5959fd89a 1 2026-06-11 14:26:41 +08:00
Your Name 08249451a7 i
Merge branch 'master20260610'
2026-06-10 17:30:43 +08:00
Your Name 79ae055bb6 更新 2026-06-10 17:30:05 +08:00
Your Name c0cc23393e 更新 2026-06-10 15:30:38 +08:00
Your Name 470ba419e3 更新 2026-06-10 15:29:07 +08:00
Your Name 1c0cd84355 更新 2026-06-10 15:03:39 +08:00
Your Name f2e2f84e18 i
Merge branch 'master' of https://gitee.com/v_cms/zyt
2026-06-10 14:38:34 +08:00
long f7e52bcafc Merge branch '20260610-long' 2026-06-10 14:37:44 +08:00
long 2e59e883b8 订单详情优化 2026-06-10 14:37:32 +08:00
Your Name 9f1801771e 更新 2026-06-10 14:25:22 +08:00
Your Name 1adf8ccf4b 更新 2026-06-10 10:28:58 +08:00
644 changed files with 9662 additions and 3919 deletions
@@ -1,11 +1,14 @@
<template>
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
<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="aspectFit"
:mode="imageMode"
@error="onImageError"
/>
</view>
@@ -27,12 +30,20 @@ const fallbackIcon = computed(() => {
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,
() => {
@@ -71,6 +82,11 @@ function onImageError() {
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));
@@ -109,6 +125,11 @@ function onImageError() {
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;
@@ -0,0 +1,189 @@
<template>
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
<view class="vsm-body">
<view class="vsm-leaf" aria-hidden="true" />
<view class="vsm-face">
<view class="vsm-eye vsm-eye--l" />
<view class="vsm-eye vsm-eye--r" />
<view class="vsm-blush vsm-blush--l" />
<view class="vsm-blush vsm-blush--r" />
<view class="vsm-mouth" />
</view>
<view v-if="active" class="vsm-waves" aria-hidden="true">
<view class="vsm-wave vsm-wave--1" />
<view class="vsm-wave vsm-wave--2" />
<view class="vsm-wave vsm-wave--3" />
</view>
</view>
</view>
</template>
<script setup>
defineProps({
/** 正在按住说话 / 录音中 */
active: { type: Boolean, default: false },
/** 深色条背景上使用浅色卡通 */
onDark: { type: Boolean, default: false },
size: { type: String, default: 'md' }
})
</script>
<style scoped lang="scss">
.vsm {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vsm--md { width: 72rpx; height: 72rpx; }
.vsm--sm { width: 64rpx; height: 64rpx; }
.vsm-body {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.vsm-leaf {
position: absolute;
top: 2rpx;
left: 50%;
width: 16rpx;
height: 16rpx;
margin-left: -8rpx;
border-radius: 0 100% 0 100%;
background: #34d399;
transform: rotate(-18deg);
z-index: 2;
}
.vsm--on-dark .vsm-leaf {
background: #a7f3d0;
}
.vsm-face {
position: relative;
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #fef9c3;
border: 3rpx solid #047857;
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
z-index: 1;
}
.vsm--sm .vsm-face {
width: 46rpx;
height: 46rpx;
}
.vsm--on-dark .vsm-face {
background: #fffbeb;
border-color: rgba(255, 255, 255, 0.85);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
}
.vsm-eye {
position: absolute;
top: 16rpx;
width: 6rpx;
height: 8rpx;
border-radius: 50%;
background: #064e3b;
}
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
.vsm--on-dark .vsm-eye { background: #134e4a; }
.vsm-eye--l { left: 12rpx; }
.vsm-eye--r { right: 12rpx; }
.vsm--sm .vsm-eye--l { left: 10rpx; }
.vsm--sm .vsm-eye--r { right: 10rpx; }
.vsm-blush {
position: absolute;
top: 24rpx;
width: 10rpx;
height: 6rpx;
border-radius: 50%;
background: rgba(251, 113, 133, 0.45);
opacity: 0.85;
}
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
.vsm-blush--l { left: 6rpx; }
.vsm-blush--r { right: 6rpx; }
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
.vsm-mouth {
position: absolute;
left: 50%;
bottom: 10rpx;
width: 18rpx;
height: 8rpx;
margin-left: -9rpx;
border-radius: 0 0 18rpx 18rpx;
background: #047857;
transform-origin: center top;
}
.vsm--sm .vsm-mouth {
bottom: 9rpx;
width: 16rpx;
height: 7rpx;
margin-left: -8rpx;
}
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
.vsm-waves {
position: absolute;
right: -2rpx;
top: 50%;
display: flex;
align-items: flex-end;
gap: 4rpx;
height: 28rpx;
margin-top: -14rpx;
z-index: 0;
}
.vsm-wave {
width: 5rpx;
border-radius: 4rpx;
background: #047857;
animation: vsm-wave-jump 0.72s ease-in-out infinite;
}
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
.vsm--active .vsm-body {
animation: vsm-bob 0.9s ease-in-out infinite;
}
.vsm--active .vsm-mouth {
animation: vsm-talk 0.32s ease-in-out infinite alternate;
}
.vsm--active .vsm-eye {
animation: vsm-blink 2.4s ease-in-out infinite;
}
@keyframes vsm-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4rpx); }
}
@keyframes vsm-talk {
0% {
transform: scaleY(0.45);
border-radius: 0 0 18rpx 18rpx;
}
100% {
transform: scaleY(1.15);
height: 12rpx;
border-radius: 50%;
}
}
@keyframes vsm-blink {
0%, 42%, 46%, 100% { transform: scaleY(1); }
44% { transform: scaleY(0.15); }
}
@keyframes vsm-wave-jump {
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
50% { transform: scaleY(1); opacity: 1; }
}
</style>
@@ -2,6 +2,27 @@ import { ref, onUnmounted } from 'vue'
const MIN_HOLD_MS = 280
/** 短按/过早松手时插件常见错误,不应打扰用户 */
function isBenignSttError(msg, retcode) {
const m = String(msg || '').toLowerCase()
if (/internal voice data failed/i.test(m)) return true
if (/voice data failed/i.test(m)) return true
if (/recordfailed|record failed|record manager/i.test(m)) return true
if (/please stop after start/i.test(m)) return true
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
const code = Number(retcode)
if (code === -30002 || code === -30003 || code === -30012) return true
return false
}
function friendlySttError(msg) {
const m = String(msg || '').trim()
if (!m || isBenignSttError(m)) return ''
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
return '语音识别失败,请重试'
}
// #ifdef MP-WEIXIN
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
@@ -112,7 +133,8 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
function stopListening() {
// #ifdef MP-WEIXIN
const ownsRecording = sharedRecording && activeCtl === controller
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) {
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
if (wxRecordManager && (sttListening.value || ownsRecording)) {
try {
wxRecordManager.stop()
} catch (e) {}
@@ -128,6 +150,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif
startRequested = false
sttHolding.value = false
sttListening.value = false
}
@@ -171,7 +194,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
}
try {
startRequested = true
sharedRecording = true
wxRecordManager.start({
duration: 60000,
lang: 'zh_CN'
@@ -251,22 +273,28 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif
}
/** 松手结束:手指抬起 */
function endHoldSpeech() {
/** 松手结束:手指抬起force=true 时强制取消尚未真正开始的按住会话 */
function endHoldSpeech(force = false) {
let recording = false
// #ifdef MP-WEIXIN
recording = sharedRecording && activeCtl === controller
// #endif
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) {
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
return
}
sttHolding.value = false
// 松手后不再补开排队的录音
pendingStartSession = 0
if (sttListening.value || startRequested || recording) {
if (sttListening.value || recording) {
stopListening()
return
}
if (startRequested) {
// start 已发出但 onStart 未到:取消会话,勿调 stop()
startRequested = false
holdSessionId += 1
return
}
holdSessionId += 1
}
@@ -276,9 +304,9 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
const controller = {
handleStart() {
if (!sttHolding.value) {
try {
wxRecordManager.stop()
} catch (e) {}
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
startRequested = false
sttListening.value = false
return
}
startRequested = false
@@ -306,6 +334,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
sttListening.value = false
startRequested = false
const msg = (res && res.msg) || ''
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
if (/please stop after start/i.test(msg)) {
try {
@@ -316,12 +345,18 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
}
pendingStartSession = 0
sttHolding.value = false
const heldMs = Date.now() - holdStartTs
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
if (typeof onError === 'function') onError(msg || '')
return
}
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
if (typeof onError === 'function') {
const handled = onError(msg || '')
if (handled === true) return
}
notify(msg || '语音识别失败')
const tip = friendlySttError(msg)
if (tip) notify(tip)
}
}
@@ -411,3 +446,5 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
stopSpeechToText: endHoldSpeech
}
}
export { isBenignSttError, friendlySttError }
+20 -4
View File
@@ -63,7 +63,15 @@ const FOOD_TIP_OVERRIDE = {
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
friedNoodles: '油多又是精面,升糖快',
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝'
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
sandwich: '夹心面包精制碳多,升糖快',
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
milletCongee: '小米粥熬软升糖快,糖友控量',
shandongPancake: '煎饼加油面,升糖中等偏快',
cake: '蛋糕又甜又油,升糖快',
biscuit: '饼干又甜又碎,升糖快',
wonton: '馄饨面皮精白,升糖中等,别多吃',
blackCoffee: '不加糖的黑咖啡,升糖很低'
}
/** 含糖等级通用科普文案 */
@@ -109,7 +117,7 @@ const RAW_FOODS = [
// 中 GI
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
{ 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' },
@@ -126,16 +134,24 @@ const RAW_FOODS = [
{ 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' },
{ 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' },
{ 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' },
+19 -2
View File
@@ -4,10 +4,27 @@
*/
/**
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
* 第一关固定为这 5 种带真实图片素材的食物。
* 第一关固定为这 16 种带真实图片素材的食物。
*/
const FIXED_LEVEL_FOODS = {
1: ['milkOats', 'grainMantou', 'riceNoodleSoup', 'friedNoodles', 'centuryEggCongee']
1: [
'sandwich',
'eightTreasureCongee',
'milkOats',
'milletCongee',
'shandongPancake',
'grainMantou',
'riceNoodleSoup',
'youtiao',
'friedNoodles',
'sweetPotato',
'centuryEggCongee',
'whiteRice',
'cake',
'biscuit',
'wonton',
'blackCoffee'
]
}
export function getLevelConfig(levelId) {
File diff suppressed because it is too large Load Diff
+258 -1
View File
@@ -639,7 +639,8 @@
}
.more-page .mc-tasks-section {
margin-top: 40rpx;
margin-top: 0;
margin-bottom: 32rpx;
}
.more-page .mc-tasks-grid {
@@ -1167,3 +1168,259 @@
font-size: 22rpx;
color: rgba(108, 122, 113, 0.6);
}
.more-page .mc-tree-species-btn {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-left: 8rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(0, 108, 73, 0.08);
font-size: 20rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel,
.more-page .mc-species-panel {
width: calc(100% - 48rpx);
max-width: 680rpx;
margin: 0 auto;
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 78vh;
background: var(--surface);
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
}
.more-page .mc-water-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-title {
font-size: 34rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-water-panel-close {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: var(--surface-container-low);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: var(--outline);
}
.more-page .mc-water-panel-summary {
display: block;
font-size: 26rpx;
line-height: 1.5;
color: var(--on-surface-variant);
margin-bottom: 24rpx;
}
.more-page .mc-water-panel-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 18rpx 16rpx;
border-radius: 20rpx;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.08);
}
.more-page .mc-water-panel-row.is-water {
border-color: rgba(0, 108, 73, 0.22);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-water-panel-row.is-claimed {
opacity: 0.72;
}
.more-page .mc-water-panel-row-main {
flex: 1;
min-width: 0;
}
.more-page .mc-water-panel-row-name {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.more-page .mc-water-panel-row-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-water-panel-row-tag {
flex-shrink: 0;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
color: #b45309;
}
.more-page .mc-water-panel-growth {
padding: 16rpx 18rpx;
border-radius: 16rpx;
background: rgba(0, 108, 73, 0.06);
margin-bottom: 20rpx;
font-size: 24rpx;
line-height: 1.45;
color: var(--primary);
}
.more-page .mc-water-panel-actions {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.more-page .mc-water-panel-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
background: var(--on-surface);
color: var(--surface);
border: none;
}
.more-page .mc-water-panel-btn.primary {
background: var(--primary);
color: var(--on-primary);
}
.more-page .mc-water-panel-btn.ghost {
background: var(--surface-container-low);
color: var(--on-surface);
}
.more-page .mc-species-panel-tip {
display: block;
font-size: 22rpx;
line-height: 1.45;
color: var(--outline);
margin-bottom: 20rpx;
}
.more-page .mc-species-scroll {
max-height: 56vh;
}
.more-page .mc-species-card {
padding: 20rpx;
border-radius: 24rpx;
border: 2rpx solid rgba(0, 108, 73, 0.1);
background: var(--surface-container-low);
margin-bottom: 16rpx;
}
.more-page .mc-species-card.active {
border-color: var(--primary);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-species-card-head {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.more-page .mc-species-card-emoji {
font-size: 56rpx;
line-height: 1;
}
.more-page .mc-species-card-name {
display: block;
font-size: 30rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-species-card-tagline {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-species-stages {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.more-page .mc-species-stage {
width: calc(20% - 8rpx);
min-width: 96rpx;
padding: 10rpx 6rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.7);
text-align: center;
opacity: 0.45;
}
.more-page .mc-species-stage.reached {
opacity: 1;
}
.more-page .mc-species-stage.current {
box-shadow: 0 0 0 2rpx var(--primary);
background: rgba(0, 108, 73, 0.1);
}
.more-page .mc-species-stage-emoji {
display: block;
font-size: 32rpx;
line-height: 1.1;
}
.more-page .mc-species-stage-lv {
display: block;
font-size: 18rpx;
font-weight: 700;
color: var(--primary);
margin-top: 4rpx;
}
.more-page .mc-species-stage-name {
display: block;
font-size: 18rpx;
color: var(--outline);
margin-top: 2rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,75 @@
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
/** 可选树种:每级 emoji 与 treeLevels.js Lv.09 一一对应 */
export const TREE_SPECIES_LIST = [
{
id: 'classic',
name: '稳糖灵树',
tagline: '经典路线,从种子到圆满',
stages: TREE_LEVELS.map((l) => l.emoji)
},
{
id: 'bonsai',
name: '雅韵盆景',
tagline: '小巧精致,书桌旁的温柔陪伴',
stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']
},
{
id: 'pine',
name: '苍劲青松',
tagline: '挺拔向上,越记越稳',
stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']
},
{
id: 'sakura',
name: '樱花小树',
tagline: '坚持打卡,枝头渐开',
stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']
},
{
id: 'ginkgo',
name: '金秋银杏',
tagline: '叶色渐变,见证每一天',
stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']
}
]
export const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'
/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */
export const DAILY_MAX_TASK_POINTS = 40
export function getTreeSpecies(id) {
return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]
}
export function speciesEmojiAtLevel(speciesId, level) {
const sp = getTreeSpecies(speciesId)
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
return sp.stages[lv] || sp.stages[0] || '🌱'
}
/** 当前树种成长路线图(供选择面板展示) */
export function speciesGrowthRoadmap(speciesId) {
const sp = getTreeSpecies(speciesId)
return TREE_LEVELS.map((meta, i) => {
const xpTotal = i * TREE_XP_PER_LEVEL
const daysHint = i === 0
? 0
: Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))
return {
level: i,
name: meta.name,
emoji: sp.stages[i] || meta.emoji,
desc: meta.desc,
xpTotal,
daysHint
}
})
}
/** 全勤打卡大约多少天满级 */
export function estimateDaysToMaxLevel() {
const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL
return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))
}
+1 -1
View File
@@ -29,7 +29,7 @@
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-width: 2;
stroke: #4073fa;
stroke: #06b6d4;
stroke-linecap: round;
}
+158
View File
@@ -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)' : ''}`)
+46
View File
@@ -96,6 +96,21 @@ export function yejiStatsRevisitBreakdown(params: {
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
}
/** 被指派数明细:与看板「被指派数」同口径;部门行传 dept_id,医助排行榜传 assistant_id */
export function yejiStatsAssignLines(params: {
start_date: string
end_date: string
dept_id?: number
assistant_id?: number
dept_ids?: string
channel_code?: string
tag_id?: string
page?: number
page_size?: number
}) {
return request.get({ url: '/stats.yejiStats/assignLines', params })
}
export function doctorDailyStatsOverview(params: {
start_date?: string
end_date?: string
@@ -181,6 +196,37 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
}
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
return request.get({ url: '/stats.revisitRate/overview', params })
}
/** 复诊接诊率:部门下拉(前端组树) */
export function revisitRateDeptOptions() {
return request.get({ url: '/stats.revisitRate/deptOptions' })
}
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
export function revisitRateAssignLines(params: {
month?: string
dept_ids?: string
assistant_id?: number
dept_id?: number
}) {
return request.get({ url: '/stats.revisitRate/assignLines', params })
}
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
export function revisitRateVisitOrderLines(params: {
month?: string
slot: number
dept_ids?: string
assistant_id?: number
dept_id?: number
}) {
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
}
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
+7
View File
@@ -446,6 +446,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
}
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
}
/** 撤回支付单审核 */
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
@@ -468,6 +473,8 @@ export function prescriptionOrderAddPayOrder(params: {
pay_amount: number
pay_remark?: string
completion_request?: number
/** 创建方式:fubei 付呗(默认) / express_cod 快递代收 */
pay_create_type?: 'fubei' | 'express_cod'
}) {
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', 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>
+7 -3
View File
@@ -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>
+228
View File
@@ -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>
+20 -20
View File
@@ -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>
+37 -8
View File
@@ -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>
+298
View File
@@ -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;
}
}
+190
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+4 -2
View File
@@ -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';
+48
View File
@@ -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
View File
@@ -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;
}
+25 -6
View File
@@ -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>
+192 -32
View File
@@ -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>
+9 -9
View File
@@ -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>
+12 -11
View File
@@ -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">
+34 -11
View File
@@ -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,21 +28,26 @@
<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-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>
</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">
<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" />
<el-table-column label="预览" width="100">
@@ -66,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"
@@ -75,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>
@@ -129,8 +134,9 @@ import MaterialPicker from '@/components/material/picker.vue'
import Upload from '@/components/upload/index.vue'
const loading = ref(false)
const tableData = ref([])
const tableData = ref<any[]>([])
const total = ref(0)
const selectedIds = ref<number[]>([])
const searchData = reactive<any>({
title: '',
@@ -198,6 +204,7 @@ const getList = async () => {
const res = await apiAssetResourceList(buildQueryParams())
tableData.value = res.lists
total.value = res.count
selectedIds.value = []
} catch (e) {
console.error(e)
} finally {
@@ -269,6 +276,22 @@ const handleDelete = async (row: any) => {
}
}
const handleSelectionChange = (rows: any[]) => {
selectedIds.value = rows.map((row) => Number(row.id)).filter((id) => id > 0)
}
const handleBatchDelete = async () => {
if (!selectedIds.value.length) return
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个资源吗?用户将无法再看到。`, '提示', { type: 'warning' })
await apiAssetResourceDelete({ id: selectedIds.value })
ElMessage.success('删除成功')
getList()
} catch (e) {
// canceled
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
+10 -10
View File
@@ -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>
+7 -6
View File
@@ -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">
@@ -0,0 +1,359 @@
/**
* 处方业务订单——纯展示/格式化工具
*
* 唯一数据源:order_list.vue(处方业务订单)、PatientOrderList.vue(诊单业务订单 Tab
* 与 PrescriptionOrderDetailDrawer.vue(共享详情抽屉)共用,禁止在页面内重复定义同名函数,
* 否则两个详情页面会再次出现文案/口径漂移。
*/
/** 与诊间医助角色 ID 一致(server 角色表) */
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 formatTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
const d = new Date(v * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
return String(v)
}
export function formatMoney(v: unknown) {
const n = Number(v)
if (Number.isNaN(n)) return '—'
return n.toFixed(2)
}
export function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
export function feeTypeText(t: number | undefined) {
const m: Record<number, string> = {
1: '挂号费',
2: '问诊费',
3: '药品费用',
4: '首付费用',
5: '尾款费用',
6: '其他费用',
7: '全部费用',
8: '驼奶费用'
}
return m[Number(t)] ?? '—'
}
export function fulfillmentText(s: number | undefined) {
const m: Record<number, string> = {
1: '待双审通过',
2: '待发货',
3: '已完成',
4: '已取消',
5: '已发货',
6: '已签收',
7: '进行中',
8: '暂不制药',
9: '拒收',
10: '退款',
11: '保留药方',
12: '制药缓发'
}
return m[Number(s)] ?? '—'
}
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
const n = Number(s)
if (n === 3) return 'success'
if (n === 6) return 'success'
if (n === 5) return 'primary'
if (n === 4) return 'danger'
if (n === 2) return 'warning'
if (n === 7) return 'warning'
if (n === 8 || n === 11 || n === 12) return 'info'
if (n === 9 || n === 10) return 'danger'
return 'info'
}
export function orderStatusText(s: number | undefined) {
const m: Record<number, string> = {
1: '待支付',
2: '已支付',
3: '已取消',
4: '已退款',
5: '待审核'
}
return m[Number(s)] ?? '—'
}
export function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
export function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
export function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
export function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
export function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建',
edit: '编辑',
audit_rx_approve: '处方审核',
audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核',
audit_pay_reject: '支付审核',
fill_tracking: '填快递单',
ship: '确认发货',
withdraw: '撤销',
link_pay_order: '关联支付单',
completion_request: '完单申请',
auto_complete: '自动完成',
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款'
}
return m[act] || act
}
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
wechat_work: '企业微信',
fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账'
}
if (paymentMethod && methodMap[paymentMethod]) {
return methodMap[paymentMethod]
}
return '普通订单'
}
export function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
}
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
const a = normalizeBizPhone(recipient)
const b = normalizeBizPhone(rxPhone)
if (!a || !b) return false
return a !== b
}
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => ({
name: String(x?.name ?? '').trim(),
dosage: Number(x?.dosage) || 0
}))
}
return []
}
export type SlipFormulaType = '主方' | '辅方'
export interface SlipAuxUsageForm {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
}
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
return v === '辅方' ? '辅方' : '主方'
}
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
const base = defaultSlipAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
}
}
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula */
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
if (!rx) return false
const herbs = rx.herbs
if (Array.isArray(herbs)) {
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
}
return Number(rx.has_aux_formula) === 1
}
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
export const LOGISTICS_URGENT_KEYWORDS = [
'拒收',
'拒签',
'退件',
'客户拒收',
'拦截退回',
'退回发件',
'派件退回',
'无人签收',
'退回快件'
]
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
const t = String(s ?? '')
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
}
export function logisticsTraceLineUrgent(context: unknown): boolean {
return logisticsStringHasUrgentKeyword(context)
}
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
export function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
show: boolean
keywords: string[]
lines: string[]
} {
if (!p) {
return { show: false, keywords: [], lines: [] }
}
const traces = Array.isArray(p.traces)
? (p.traces as Array<{ time?: string; context?: string }>)
: []
const chunks: string[] = []
if (p.state_text) chunks.push(String(p.state_text))
if (p.hint) chunks.push(String(p.hint))
for (const row of traces) {
if (row.context) chunks.push(String(row.context))
}
const joined = chunks.join('\n')
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
if (!keywords.length) {
return { show: false, keywords: [], lines: [] }
}
const lines: string[] = []
for (const row of traces) {
const ctx = String(row.context || '')
if (logisticsStringHasUrgentKeyword(ctx)) {
lines.push(ctx)
if (lines.length >= 5) break
}
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
lines.push(`最新状态:${p.state_text}`)
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
lines.push(String(p.hint))
}
return { show: true, keywords, lines }
}
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
export function parseLogisticsTracePayload(
res: unknown,
expectedTrackingNumber: string,
expectedOrderId: number
): Record<string, any> | null {
const raw = (res as { data?: unknown })?.data ?? res
if (!raw || typeof raw !== 'object') return null
const payload = raw as Record<string, any>
const respNum = String(payload.tracking_number || '').trim()
if (respNum && respNum !== expectedTrackingNumber) return null
const respOrderId = Number(payload.order_id)
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
return payload
}
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
if (!row?.id) return false
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
export function formatDietaryTaboo(raw: unknown): string {
if (Array.isArray(raw)) {
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
}
if (typeof raw === 'string' && raw.trim()) {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join('、')
}
return ''
}
@@ -39,7 +39,7 @@
<el-radio-button label="rejected">驳回</el-radio-button>
</el-radio-group>
<span class="pl-toolbar__sep" aria-hidden="true" />
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
<span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
<el-radio-group
v-model="formData.source_filter"
size="small"
@@ -48,7 +48,7 @@
>
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="manual">手工</el-radio-button>
<el-radio-button label="system">系统代开</el-radio-button>
<el-radio-button label="system">空白处方</el-radio-button>
</el-radio-group>
</div>
<div class="pl-toolbar__line pl-toolbar__line--second">
@@ -146,7 +146,7 @@
size="small"
effect="plain"
>
系统代开
空白处方
</el-tag>
<span v-else class="text-gray-400 text-sm">手工</span>
</template>
@@ -290,7 +290,7 @@
type="warning"
size="small"
effect="plain"
>系统代开</el-tag>
>空白处方</el-tag>
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
<el-tag
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
@@ -437,6 +437,7 @@
<p>主方服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">辅方服法{{ rxAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="slipDietaryText">忌口{{ slipDietaryText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
@@ -1856,7 +1857,7 @@ const formData = reactive({
creator_ids: [] as number[],
/** all | passed | not_passed | pending | rejectedall 表示不限,接口传参会转为空) */
audit_filter: 'all' as string,
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */
/** all | manual | system — 与 is_system_auto:手工(0) / 空白处方(1) */
source_filter: 'all' as string,
start_time: '',
end_time: ''
File diff suppressed because it is too large Load Diff
@@ -894,6 +894,9 @@
</div>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="detailDietaryText" label="忌口" :span="2">
{{ detailDietaryText }}
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">
{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}
@@ -1295,7 +1298,7 @@
<el-form-item label="新金额" prop="amount">
<el-input-number
v-model="updateAmountForm.amount"
:min="0.01"
:min="0"
:step="0.01"
:precision="2"
controls-position="right"
@@ -1998,13 +2001,14 @@
<el-form-item label="添加方式" prop="add_mode">
<el-radio-group v-model="addPayOrderForm.add_mode">
<el-radio value="create">付呗</el-radio>
<el-radio value="create_express">快递代收</el-radio>
<el-radio value="link">关联已有</el-radio>
<el-radio value="completion_only">直接完单申请</el-radio>
</el-radio-group>
</el-form-item>
<!-- 付呗手动创建支付单 -->
<template v-if="addPayOrderForm.add_mode === 'create'">
<!-- 付呗 / 快递代收手动创建支付单 -->
<template v-if="addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express'">
<el-form-item label="费用类别" prop="order_type">
<el-select v-model="addPayOrderForm.order_type" class="w-full">
<el-option label="挂号费" :value="1" />
@@ -2310,6 +2314,7 @@
<div class="rx-text">
<p>服法{{ rxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="slipDietaryText">忌口{{ slipDietaryText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
@@ -2568,6 +2573,7 @@ import {
getDoctors,
getAssistants
} from '@/api/tcm'
import { formatDietaryTaboo } from './components/prescription-order-utils'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import { getDictData } from '@/api/app'
@@ -3578,6 +3584,8 @@ const detailPrescription = computed(() => {
return p && typeof p === 'object' ? p : null
})
const detailDietaryText = computed(() => formatDietaryTaboo(detailPrescription.value?.dietary_taboo))
function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
@@ -3725,8 +3733,8 @@ const updateAmountRules: FormRules = {
{
validator: (_rule, value, callback) => {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('订单金额必须大于0'))
if (!Number.isFinite(num) || num < 0) {
callback(new Error('订单金额不能为负数'))
return
}
callback()
@@ -3878,7 +3886,8 @@ function openUpdateAmount() {
return
}
updateAmountForm.id = Number(row?.id) || 0
updateAmountForm.amount = Number(row?.amount) || undefined
const amt = Number(row?.amount)
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate())
}
@@ -4096,12 +4105,14 @@ function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unk
const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
wechat_work: '企业微信',
fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账'
}
if (paymentMethod && methodMap[paymentMethod]) {
@@ -4955,10 +4966,13 @@ const addPayOrderAlertTitle = computed(() => {
if (addPayOrderForm.add_mode === 'completion_only') {
return '不创建或关联支付单,仅提交完单申请,由审核人员在支付审核时处理。'
}
if (addPayOrderForm.add_mode === 'create_express') {
return '通过快递代收创建新支付单(流转同付呗),或关联已存在但未绑定的支付单。'
}
return '可以通过付呗创建新支付单,或关联已存在但未绑定的支付单。'
})
const addPayOrderForm = reactive({
add_mode: 'create' as 'create' | 'link' | 'completion_only',
add_mode: 'create' as 'create' | 'create_express' | 'link' | 'completion_only',
order_type: 3,
pay_amount: undefined as number | undefined,
pay_remark: '',
@@ -4970,7 +4984,7 @@ const addPayOrderRules = computed<FormRules>(() => {
const rules: FormRules = {
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }]
}
if (addPayOrderForm.add_mode === 'create') {
if (addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express') {
rules.order_type = [{ required: true, message: '请选择费用类别', trigger: 'change' }]
rules.pay_amount = [
{ required: true, message: '请输入支付金额', trigger: 'blur' },
@@ -5054,14 +5068,19 @@ async function submitAddPayOrder() {
if (addPayOrderForm.add_mode === 'completion_only') {
await prescriptionOrderRequestCompletion({ id: addPayOrderRowId.value })
feedback.msgSuccess('完单申请已提交,请等待支付审核')
} else if (addPayOrderForm.add_mode === 'create') {
//
} else if (
addPayOrderForm.add_mode === 'create' ||
addPayOrderForm.add_mode === 'create_express'
) {
// /
await prescriptionOrderAddPayOrder({
id: addPayOrderRowId.value,
order_type: addPayOrderForm.order_type,
pay_amount: addPayOrderForm.pay_amount!,
pay_remark: addPayOrderForm.pay_remark || '',
completion_request: addPayOrderForm.completion_request
completion_request: addPayOrderForm.completion_request,
pay_create_type:
addPayOrderForm.add_mode === 'create_express' ? 'express_cod' : 'fubei'
})
feedback.msgSuccess('支付单已新增,请等待审核')
} else {
@@ -5176,12 +5195,7 @@ const slipHerbsList = computed(() => {
return Array.isArray(h) ? h : []
})
const slipDietaryText = computed(() => {
const d = prescriptionViewData.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
if (typeof d === 'string' && d.trim()) return d.trim()
return ''
})
const slipDietaryText = computed(() => formatDietaryTaboo(prescriptionViewData.value?.dietary_taboo))
const slipChuwanVisible = computed(() => {
const d = prescriptionViewData.value
+7 -7
View File
@@ -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"
+1 -1
View File
@@ -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;
}
+3 -5
View File
@@ -231,8 +231,7 @@ import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
import feedback from '@/utils/feedback'
import { adminLists } from '@/api/perms/admin'
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { ElMessageBox } from 'element-plus'
dayjs.extend(isoWeek)
@@ -491,10 +490,9 @@ async function removeSegment(row: RosterSegment) {
const loadDoctors = async () => {
try {
const res = await adminLists({
const res = await doctorLists({
page_no: 1,
page_size: 1000,
role_id: 1
page_size: 1000
})
tableData.value = (res?.lists || []).map((doctor: any) => ({
doctorId: doctor.id,
+33 -11
View File
@@ -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>
+9 -9
View File
@@ -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
+228 -6
View File
@@ -283,7 +283,15 @@
<td class="col-name">{{ r.name }}</td>
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_count) }}</td>
<td class="col-assign">{{ formatLeaderboardInt(r.assign_count ?? 0) }}</td>
<td class="col-assign" @click.stop="onLeaderboardAssignCellClick(r)">
<span
v-if="Number(r.assign_count ?? 0) > 0"
class="yeji-lead-cell--link"
>{{ formatLeaderboardInt(r.assign_count ?? 0) }}</span>
<template v-else>{{
formatLeaderboardInt(r.assign_count ?? 0)
}}</template>
</td>
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
<span
v-if="Number(r.appointment_count ?? 0) > 0"
@@ -390,7 +398,7 @@
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<b>tcm_diagnosis_assign_log</b> 行数成功指派<b>to_assistant_id &gt; 0</b>** <b>related_po_create_time</b>** 落在所选区间内与日志表快照一致诊单手动指派已写入该字段<b>被指派医助</b>归属展示部门<template v-if="tb.channel_name">
<b>tcm_diagnosis_assign_log</b> 成功指派<b>to_assistant_id &gt; 0</b><b>剔除勾选继承的指派</b><b>指派操作时间</b>落在所选区间内医助 × 诊单去重剔除已删诊单复诊接诊率页面同口径<b>被指派医助</b>归属展示部门<template v-if="tb.channel_name">
选定渠道时收窄与{{ tb.channel_name }}业绩同口径标签诊单或 channels EXISTS<br />
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>
</template>
@@ -561,7 +569,13 @@
>{{ formatInt(r.lead_count) }}</span>
<template v-else>{{ formatInt(r.lead_count) }}</template>
</td>
<td>{{ formatInt(r.assign_count ?? 0) }}</td>
<td @click.stop="onAssignCountCellClick($event, tb, r)">
<span
v-if="yejiAssignCountCellClickable(r)"
class="yeji-lead-cell--link"
>{{ formatInt(r.assign_count ?? 0) }}</span>
<template v-else>{{ formatInt(r.assign_count ?? 0) }}</template>
</td>
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
<span
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
@@ -1356,6 +1370,59 @@
/>
</div>
</el-dialog>
<el-dialog
v-model="assignLinesDialogVisible"
width="min(960px, 96vw)"
destroy-on-close
class="yeji-leadlines-dialog"
>
<template #header>
<div class="yeji-unassigned-dialog__head">
<span class="yeji-unassigned-dialog__title">被指派明细</span>
<p class="yeji-unassigned-dialog__sub">{{ assignLinesSubtitle }}</p>
</div>
</template>
<p v-if="assignLinesApiNote" class="yeji-unassigned-dialog__note">{{ assignLinesApiNote }}</p>
<div v-loading="assignLinesLoading" class="yeji-unassigned-dialog__body">
<el-table
v-if="assignLinesRows.length > 0 || assignLinesLoading"
:data="assignLinesRows"
size="small"
stripe
border
max-height="440"
class="yeji-unassigned-dialog__table"
:empty-text="assignLinesLoading ? '加载中…' : '暂无数据'"
>
<el-table-column type="index" label="#" width="46" :index="assignLinesIndexMethod" />
<el-table-column prop="diagnosis_id" label="诊单ID" width="88" />
<el-table-column prop="patient_name" label="患者" min-width="100" show-overflow-tooltip />
<el-table-column prop="patient_phone" label="联系电话" min-width="120" show-overflow-tooltip />
<el-table-column prop="assistant_name" label="被指派医助" min-width="108" show-overflow-tooltip />
<el-table-column prop="assign_count" label="指派次数" width="88" align="right" />
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="156" show-overflow-tooltip />
</el-table>
<el-empty
v-if="!assignLinesLoading && assignLinesRows.length === 0"
description="暂无被指派明细"
/>
<div v-if="assignLinesCount > 0" class="yeji-leadlines-dialog__pager">
<el-pagination
:current-page="assignLinesPage"
:page-size="assignLinesPageSize"
background
layout="total, sizes, prev, pager, next, jumper"
:total="assignLinesCount"
:page-sizes="[10, 20, 50, 100]"
:disabled="assignLinesLoading"
:hide-on-single-page="false"
@current-change="onAssignLinesPageChange"
@size-change="onAssignLinesPageSizeChange"
/>
</div>
</div>
</el-dialog>
</div>
</template>
@@ -1376,6 +1443,7 @@ import {
yejiStatsLeadLines,
yejiStatsAppointmentLines,
yejiStatsRevisitBreakdown,
yejiStatsAssignLines,
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
import { prescriptionOrderLists } from '@/api/tcm'
@@ -1491,6 +1559,17 @@ interface YejiLeadLineRow {
external_contact_name: string
}
interface YejiAssignLineRow {
diagnosis_id: number
patient_name: string
patient_phone: string
assistant_id: number
assistant_name: string
assign_count: number
last_assign_time: number
last_assign_time_text: string
}
interface YejiRow {
dept_id: number
dept_name: string
@@ -1709,6 +1788,31 @@ type AppointmentLinesCtx =
}
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
const assignLinesDialogVisible = ref(false)
const assignLinesLoading = ref(false)
const assignLinesSubtitle = ref('')
const assignLinesApiNote = ref('')
const assignLinesRows = ref<YejiAssignLineRow[]>([])
const assignLinesCount = ref(0)
const assignLinesPage = ref(1)
const assignLinesPageSize = ref(20)
type AssignLinesCtx =
| {
mode: 'assistant'
assistant_id: number
name: string
start_date: string
end_date: string
}
| {
mode: 'dept'
dept_id: number
dept_name: string
start_date: string
end_date: string
}
const assignLinesContext = ref<AssignLinesCtx | null>(null)
const appointmentLinesShowChannelColumn = computed(() =>
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
)
@@ -3193,6 +3297,124 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
void openLeadLinesDialog(tb, row)
}
function yejiAssignCountCellClickable(r: YejiRow): boolean {
return r.dept_id > 0 && Number(r.assign_count ?? 0) > 0
}
function onAssignCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
if (!yejiAssignCountCellClickable(row)) {
return
}
ev.stopPropagation()
void openAssignLinesDialogForDept(tb, row)
}
function assignLinesIndexMethod(index: number) {
return (assignLinesPage.value - 1) * assignLinesPageSize.value + index + 1
}
function buildAssignLinesRequestParams(page: number, pageSize: number): Record<string, string | number> | null {
const ctx = assignLinesContext.value
if (!ctx) {
return null
}
const p: Record<string, string | number> = {
start_date: ctx.start_date,
end_date: ctx.end_date,
page,
page_size: pageSize,
}
if (ctx.mode === 'dept') {
p.dept_id = ctx.dept_id
} else {
p.assistant_id = ctx.assistant_id
}
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
return p
}
async function fetchAssignLinesPage() {
const params = buildAssignLinesRequestParams(assignLinesPage.value, assignLinesPageSize.value)
if (!params) {
return
}
assignLinesLoading.value = true
try {
const res: any = await yejiStatsAssignLines(params as any)
assignLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
assignLinesCount.value = Number(res?.count ?? 0)
assignLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载被指派明细失败')
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesApiNote.value = ''
} finally {
assignLinesLoading.value = false
}
}
async function openAssignLinesDialogForDept(tb: YejiTable, row: YejiRow) {
assignLinesContext.value = {
mode: 'dept',
dept_id: row.dept_id,
dept_name: row.dept_name,
start_date: tb.start_date,
end_date: tb.end_date,
}
assignLinesSubtitle.value = `${row.dept_name} · ${tb.start_date} ${tb.end_date}`
assignLinesApiNote.value = ''
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesPage.value = 1
assignLinesPageSize.value = 20
assignLinesDialogVisible.value = true
await fetchAssignLinesPage()
}
function onLeaderboardAssignCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
return
}
if (Number(r.assign_count ?? 0) <= 0) {
return
}
const lb = leaderboardBlock.value
assignLinesContext.value = {
mode: 'assistant',
assistant_id: r.admin_id,
name: r.name,
start_date: lb.start_date,
end_date: lb.end_date,
}
assignLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ${lb.end_date}`
assignLinesApiNote.value = ''
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesPage.value = 1
assignLinesPageSize.value = 20
assignLinesDialogVisible.value = true
void fetchAssignLinesPage()
}
function onAssignLinesPageChange(p: number) {
assignLinesPage.value = p
void fetchAssignLinesPage()
}
function onAssignLinesPageSizeChange(size: number) {
assignLinesPageSize.value = size
assignLinesPage.value = 1
void fetchAssignLinesPage()
}
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
{ key: 'event_time_text', label: '进线时间' },
{ key: 'reception_admin_name', label: '接待' },
@@ -3482,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: '进线',
@@ -3807,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;
+7 -7
View File
@@ -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">
+7 -7
View File
@@ -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">
+7 -7
View File
@@ -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>
+5 -5
View File
@@ -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
+39 -23
View File
@@ -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"
@@ -220,7 +216,7 @@
编辑
</el-button>
<el-button
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && row.payment_method === 'fubei')"
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
v-perms="['order.order/split']"
type="primary"
link
@@ -237,7 +233,7 @@
小程序码
</el-button>
<el-button
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
v-perms="['order.order/pay']"
type="success"
link
@@ -255,7 +251,7 @@
退款
</el-button>
<el-button
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
v-perms="['order.order/cancel']"
type="danger"
link
@@ -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
@@ -434,7 +430,7 @@
type="warning"
:closable="false"
class="mb-3"
title="该单为付呗·待审核,请选择实际到账方式后确认「已支付」"
title="该单为待审核(付呗/快递代收),请选择实际到账方式后确认「已支付」"
/>
<el-form-item v-if="!payForm.fubeiPendingAudit" label="支付类型" required>
<el-radio-group v-model="payForm.payType">
@@ -502,6 +498,7 @@
<el-radio label="normal">普通订单</el-radio>
<el-radio label="wechat_work">企业微信对外收款</el-radio>
<el-radio label="fubei">付呗</el-radio>
<el-radio label="express_cod">快递代收</el-radio>
</el-radio-group>
</el-form-item>
<el-alert
@@ -524,7 +521,20 @@
<template #title>付呗</template>
通过付呗收款的支付单创建后请按实际对账/审核流程在列表中处理
</el-alert>
<el-form-item v-if="createForm.createType === 'fubei'" label="支付单审核">
<el-alert
v-if="createForm.createType === 'express_cod'"
type="info"
:closable="false"
show-icon
class="mb-4"
>
<template #title>快递代收</template>
通过快递代收的支付单流转与付呗一致审核通过/确认到账后将更新为已支付
</el-alert>
<el-form-item
v-if="createForm.createType === 'fubei' || createForm.createType === 'express_cod'"
label="支付单审核"
>
<el-switch
v-model="createForm.requirePaymentSlipAudit"
active-text="申请审核"
@@ -980,8 +990,8 @@ const patientLoading = ref(false)
const patientList = ref<any[]>([])
const createForm = reactive({
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei',
/** 「付呗」:开启后订单为待审核(5) */
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei' | 'express_cod',
/** 「付呗」「快递代收」:开启后订单为待审核(5) */
requirePaymentSlipAudit: false,
patient_id: '',
order_type: '',
@@ -1167,13 +1177,17 @@ const submitCreateOrder = async () => {
if (createForm.createType === 'fubei') {
params.payment_channel = 'fubei'
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
} else if (createForm.createType === 'express_cod') {
params.payment_channel = 'express_cod'
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
} else {
params.payment_channel = 'normal'
params.require_payment_slip_audit = 0
}
await orderCreate(params)
const tip =
createForm.createType === 'fubei' && createForm.requirePaymentSlipAudit
(createForm.createType === 'fubei' || createForm.createType === 'express_cod') &&
createForm.requirePaymentSlipAudit
? '订单已创建,支付状态为「待审核」'
: '订单创建成功'
feedback.msgSuccess(tip)
@@ -1254,10 +1268,11 @@ const getCreateTypeText = (row: any) => {
const createTypeMap: Record<string, string> = {
normal: '普通订单',
wechat_work: '企业微信对外收款',
fubei: '付呗'
fubei: '付呗',
express_cod: '快递代收'
}
const ct = row?.create_type
if (ct === 'wechat_work' || ct === 'fubei') {
if (ct === 'wechat_work' || ct === 'fubei' || ct === 'express_cod') {
return createTypeMap[ct]
}
if (ct === 'normal' && row?.payment_method) {
@@ -1395,7 +1410,8 @@ const handleDetail = async (row: any) => {
//
const handlePay = (row: any) => {
const fubeiPending = row.status === 5 && row.payment_method === 'fubei'
const fubeiPending =
row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod')
payForm.value = {
order_id: row.id,
order_no: row.order_no,
@@ -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 = () => {
+11 -10
View File
@@ -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>
+1 -1
View File
@@ -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,
+73 -73
View File
@@ -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: '',
+6 -8
View File
@@ -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>
+54 -59
View File
@@ -1,68 +1,64 @@
<template>
<div class="role-lists">
<el-card class="!border-none" shadow="never">
<div>
<div>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button v-perms="['auth.role/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
</div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table :data="pager.lists" size="large">
<el-table-column prop="id" label="ID" min-width="100" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column
prop="desc"
label="备注"
min-width="150"
show-overflow-tooltip
/>
<el-table-column prop="sort" label="排序" min-width="100" />
<el-table-column label="数据范围" min-width="140">
<template #default="{ row }">
{{ dataScopeLabel(row.data_scope) }}
</template>
</el-table-column>
<el-table-column prop="num" label="管理员人数" min-width="100" />
<el-table-column prop="create_time" label="创建时间" min-width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
v-perms="['auth.role/edit']"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
link
type="primary"
v-perms="['auth.role/edit']"
@click="handleAuth(row)"
>
分配权限
</el-button>
<el-button
v-perms="['auth.role/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
</template>
<el-table :data="pager.lists" size="large">
<el-table-column prop="id" label="ID" min-width="100" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column
prop="desc"
label="备注"
min-width="150"
show-overflow-tooltip
/>
<el-table-column prop="sort" label="排序" min-width="100" />
<el-table-column label="数据范围" min-width="140">
<template #default="{ row }">
{{ dataScopeLabel(row.data_scope) }}
</template>
</el-table-column>
<el-table-column prop="num" label="管理员人数" min-width="100" />
<el-table-column prop="create_time" label="创建时间" min-width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
v-perms="['auth.role/edit']"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
link
type="primary"
v-perms="['auth.role/edit']"
@click="handleAuth(row)"
>
分配权限
</el-button>
<el-button
v-perms="['auth.role/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
<auth-popup v-if="showAuth" ref="authRef" @success="getLists" @close="showAuth = false" />
</div>
@@ -115,7 +111,6 @@ const dataScopeLabel = (scope: number | string | null | undefined) => {
)
}
//
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除?')
await roleDelete({ id })
+54 -57
View File
@@ -1,6 +1,6 @@
<template>
<div class="dict-type">
<el-card class="!border-none" shadow="never">
<div>
<admin-page-filter-panel>
<el-page-header class="mb-4" content="数据管理" @back="$router.back()" />
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="字典名称">
@@ -28,9 +28,10 @@
<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>
<el-button
v-perms="['setting.dict.dict_data/add']"
type="primary"
@@ -52,58 +53,54 @@
</template>
删除
</el-button>
</div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table
:data="pager.lists"
size="large"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" />
<el-table-column label="数据名称" prop="name" min-width="120" />
<el-table-column label="数据值" prop="value" min-width="120" />
<el-table-column label="状态">
<template v-slot="{ row }">
<el-tag v-if="row.status == 1">正常</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column
label="备注"
prop="remark"
min-width="120"
show-tooltip-when-overflow
/>
<el-table-column label="排序" prop="sort" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['setting.dict.dict_data/edit']"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['setting.dict.dict_data/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
</template>
<el-table
:data="pager.lists"
size="large"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" />
<el-table-column label="数据名称" prop="name" min-width="120" />
<el-table-column label="数据值" prop="value" min-width="120" />
<el-table-column label="状态">
<template v-slot="{ row }">
<el-tag v-if="row.status == 1">正常</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column
label="备注"
prop="remark"
min-width="120"
show-tooltip-when-overflow
/>
<el-table-column label="排序" prop="sort" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['setting.dict.dict_data/edit']"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['setting.dict.dict_data/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
+65 -69
View File
@@ -1,6 +1,6 @@
<template>
<div class="dict-type">
<el-card class="!border-none" shadow="never">
<div>
<admin-page-filter-panel>
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="字典名称">
<el-input v-model="queryParams.name" clearable @keyup.enter="resetPage" />
@@ -20,9 +20,10 @@
<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>
<el-button
v-perms="['setting.dict.dict_type/add']"
type="primary"
@@ -44,69 +45,65 @@
</template>
删除
</el-button>
</div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table
:data="pager.lists"
size="large"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" />
<el-table-column label="字典名称" prop="name" min-width="120" />
<el-table-column label="字典类型" prop="type" min-width="120" />
<el-table-column label="状态">
<template v-slot="{ row }">
<el-tag v-if="row.status == 1">正常</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" show-tooltip-when-overflow />
<el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['setting.dict.dict_type/edit']"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['setting.dict.dict_data/lists']"
type="primary"
link
>
<router-link
:to="{
path: getRoutePath('setting.dict.dict_data/lists'),
query: {
id: row.id
}
}"
>
数据管理
</router-link>
</el-button>
<el-button
v-perms="['setting.dict.dict_type/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
</template>
<el-table
:data="pager.lists"
size="large"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" />
<el-table-column label="字典名称" prop="name" min-width="120" />
<el-table-column label="字典类型" prop="type" min-width="120" />
<el-table-column label="状态">
<template v-slot="{ row }">
<el-tag v-if="row.status == 1">正常</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" show-tooltip-when-overflow />
<el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['setting.dict.dict_type/edit']"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['setting.dict.dict_data/lists']"
type="primary"
link
>
<router-link
:to="{
path: getRoutePath('setting.dict.dict_data/lists'),
query: {
id: row.id
}
}"
>
数据管理
</router-link>
</el-button>
<el-button
v-perms="['setting.dict.dict_type/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
@@ -151,7 +148,6 @@ const handleEdit = async (data: any) => {
editRef.value?.setFormData(data)
}
//
const handleDelete = async (id: any[] | number) => {
await feedback.confirm('确定要删除?')
await dictTypeDelete({ id })
+154 -61
View File
@@ -1,6 +1,6 @@
<template>
<div class="conversion-stats-page">
<el-card class="!border-none" shadow="never">
<admin-page-filter-panel>
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<el-form-item label="统计维度">
<el-segmented
@@ -89,7 +89,7 @@
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</admin-page-filter-panel>
<div class="stats-kpi-grid">
<div
@@ -129,7 +129,7 @@
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>诊单金额占比</span>
<span>{{ amountPieTitle }}</span>
<span class="card-hint">TOP 10</span>
</div>
</template>
@@ -146,17 +146,17 @@
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>加粉占比</span>
<span>{{ secondaryPieTitle }}</span>
<span class="card-hint">TOP 10</span>
</div>
</template>
<v-charts
v-if="fanPieHasData"
v-if="secondaryPieHasData"
class="stats-chart"
:option="fanPieOption"
:option="secondaryPieOption"
autoresize
/>
<el-empty v-else description="暂无加粉数据" />
<el-empty v-else :description="secondaryPieEmptyText" />
</el-card>
</el-col>
</el-row>
@@ -311,10 +311,13 @@ const overview = reactive<Record<string, any>>({
amounts: [],
order_counts: [],
fan_counts: [],
rois: []
rois: [],
appointment_counts: [],
interview_counts: []
},
amount_share: [],
fan_share: []
fan_share: [],
order_share: []
}
})
@@ -392,7 +395,7 @@ const currentEntityId = computed({
}
})
const summaryCards: MetricCard[] = [
const defaultSummaryCards: MetricCard[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
@@ -410,7 +413,15 @@ const summaryCards: MetricCard[] = [
{ key: 'roi', label: 'ROI', type: 'ratio' }
]
const tableColumns: MetricColumn[] = [
const doctorSummaryCards: MetricCard[] = [
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
{ key: 'interview_count', label: '面诊数', type: 'count' },
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
{ key: 'completed_order_amount', label: '开药金额', type: 'money' },
{ key: 'receive_rate', label: '接诊率', type: 'percent' }
]
const defaultTableColumns: MetricColumn[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
@@ -431,6 +442,18 @@ const tableColumns: MetricColumn[] = [
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
]
const doctorTableColumns: MetricColumn[] = [
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
{ key: 'interview_count', label: '面诊数', type: 'count' },
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
{ key: 'completed_order_amount', label: '开药金额', type: 'money', minWidth: 120 },
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 }
]
const isDoctorDimension = computed(() => queryParams.dimension === 'doctor')
const summaryCards = computed(() => isDoctorDimension.value ? doctorSummaryCards : defaultSummaryCards)
const tableColumns = computed(() => isDoctorDimension.value ? doctorTableColumns : defaultTableColumns)
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
@@ -438,7 +461,14 @@ const dateRangeText = computed(() => {
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
const secondaryPieHasData = computed(() =>
isDoctorDimension.value
? (overview.charts.order_share?.length || 0) > 0
: (overview.charts.fan_share?.length || 0) > 0
)
const amountPieTitle = computed(() => isDoctorDimension.value ? '开药金额占比' : '诊单金额占比')
const secondaryPieTitle = computed(() => isDoctorDimension.value ? '接诊单数占比' : '加粉占比')
const secondaryPieEmptyText = computed(() => isDoctorDimension.value ? '暂无接诊数据' : '暂无加粉数据')
const rankingChartData = computed(() => {
const ranking = overview.charts?.ranking || {}
@@ -448,56 +478,110 @@ const rankingChartData = computed(() => {
return {
names,
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
appointmentCounts: normalize(Array.isArray(ranking.appointment_counts) ? ranking.appointment_counts : []),
interviewCounts: normalize(Array.isArray(ranking.interview_counts) ? ranking.interview_counts : []),
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
}
})
const rankingChartOption = computed(() => ({
tooltip: { trigger: 'axis' },
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
grid: { left: 48, right: 24, top: 48, bottom: 48 },
xAxis: {
type: 'category',
data: rankingChartData.value.names,
axisLabel: {
interval: 0,
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
}
},
yAxis: [
{ type: 'value', name: '金额 / 数量' }
],
series: [
{
name: '诊单金额',
type: 'bar',
barMaxWidth: 36,
data: rankingChartData.value.amounts,
itemStyle: { color: '#4a78ff' }
const rankingChartOption = computed(() => {
const common = {
tooltip: { trigger: 'axis' },
grid: { left: 48, right: 24, top: 48, bottom: 48 },
xAxis: {
type: 'category',
data: rankingChartData.value.names,
axisLabel: {
interval: 0,
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
}
},
{
name: '接诊诊单',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.orderCounts,
itemStyle: { color: '#15b8a6' }
},
{
name: '加粉数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.fanCounts,
itemStyle: { color: '#ff9f43' }
yAxis: [
{ type: 'value', name: '金额 / 数量' }
]
}
if (!isDoctorDimension.value) {
return {
...common,
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
series: [
{
name: '诊单金额',
type: 'bar',
barMaxWidth: 36,
data: rankingChartData.value.amounts,
itemStyle: { color: '#4a78ff' }
},
{
name: '接诊诊单',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.orderCounts,
itemStyle: { color: '#15b8a6' }
},
{
name: '加粉数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.fanCounts,
itemStyle: { color: '#ff9f43' }
}
]
}
]
}))
}
return {
...common,
legend: { data: ['开药金额', '挂号数', '面诊数', '接诊单数'] },
series: [
{
name: '开药金额',
type: 'bar',
barMaxWidth: 36,
data: rankingChartData.value.amounts,
itemStyle: { color: '#4a78ff' }
},
{
name: '挂号数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.appointmentCounts,
itemStyle: { color: '#15b8a6' }
},
{
name: '面诊数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.interviewCounts,
itemStyle: { color: '#ff9f43' }
},
{
name: '接诊单数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.orderCounts,
itemStyle: { color: '#7c3aed' }
}
]
}
})
const amountPieOption = computed(() => ({
tooltip: { trigger: 'item' },
@@ -511,7 +595,7 @@ const amountPieOption = computed(() => ({
},
series: [
{
name: '诊单金额',
name: amountPieTitle.value,
type: 'pie',
radius: ['42%', '70%'],
center: ['50%', '42%'],
@@ -531,7 +615,7 @@ const amountPieOption = computed(() => ({
]
}))
const fanPieOption = computed(() => ({
const secondaryPieOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: {
bottom: 0,
@@ -543,7 +627,7 @@ const fanPieOption = computed(() => ({
},
series: [
{
name: '加粉数',
name: secondaryPieTitle.value,
type: 'pie',
radius: ['42%', '70%'],
center: ['50%', '42%'],
@@ -558,7 +642,7 @@ const fanPieOption = computed(() => ({
labelLayout: {
hideOverlap: true,
},
data: overview.charts.fan_share
data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
}
]
}))
@@ -658,9 +742,18 @@ const handleReset = () => {
fetchOverview()
}
const calcDoctorReceiveRate = (source: Record<string, any>) => {
const completedOrderCount = Number(source?.completed_order_count || 0)
const interviewCount = Number(source?.interview_count || 0)
if (interviewCount <= 0) return 0
return (completedOrderCount / interviewCount) * 100
}
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
if (placeholder) return '—'
const value = source?.[key] ?? 0
const value = isDoctorDimension.value && key === 'receive_rate'
? calcDoctorReceiveRate(source)
: source?.[key] ?? 0
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
if (type === 'ratio') return Number(value || 0).toFixed(2)
@@ -0,0 +1,644 @@
<template>
<div class="revisit-rate-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="rate-filter-form">
<el-form-item label="统计月份">
<el-date-picker
v-model="month"
type="month"
value-format="YYYY-MM"
placeholder="选择月份"
:clearable="false"
:disabled-date="disableFutureMonth"
style="width: 160px"
@change="loadData"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="deptId"
:data="deptTreeOptions"
placeholder="全部部门"
clearable
filterable
check-strictly
node-key="id"
:default-expand-all="true"
:props="deptTreeProps"
style="width: 220px"
@change="loadData"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
<el-button :disabled="loading" @click="handleReset">重置</el-button>
</el-form-item>
<el-form-item>
<el-popover placement="bottom-start" :width="500" trigger="hover">
<template #reference>
<span class="rate-caliber-trigger">
<el-icon><InfoFilled /></el-icon>
统计口径
</span>
</template>
<div class="rate-caliber">
<p>
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重
</p>
<p>
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序的全局序号<b>跨月累计不重置</b> 5 月指派后旗下成交 4 单为二诊~五诊下月再成交即为六诊
</p>
<p>
<b>当月 N 诊单数</b>当月内下单且诊次为 N 的订单数归属下单时点<b>持有该患者的医助</b>指派可在往月释放后不再归属继承指派会转移持有人但不计被指派数
</p>
<p>
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数往月指派当月成交会推高分子比率可能超过 100%医助当月无新指派但旗下有成交时被指派数为 0比率显示
</p>
<p>
医助按人事部门归组选定部门时含其组织下级
</p>
</div>
</el-popover>
</el-form-item>
</el-form>
</el-card>
<div class="rate-kpi-grid">
<div
v-for="card in summaryCards"
:key="card.key"
class="rate-kpi-card"
:class="{ 'is-clickable': card.clickable }"
@click="card.clickable && card.onClick && card.onClick()"
>
<div class="rate-kpi-label">{{ card.label }}</div>
<div class="rate-kpi-value">{{ card.value }}</div>
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
</div>
</div>
<el-card class="!border-none mt-4" shadow="never">
<template #header>
<div class="rate-card-header">
<span>部门 · 医助明细{{ monthText }}</span>
<span class="rate-card-hint">点击被指派数 / N诊单数可查看具体诊单与订单底栏合计的被指派数按诊单去重</span>
</div>
</template>
<el-table
v-loading="loading"
:data="rows"
size="small"
border
row-key="row_key"
:tree-props="{ children: 'children' }"
:default-expand-all="true"
show-summary
:summary-method="getSummaries"
>
<el-table-column label="部门 / 医助" min-width="200" fixed>
<template #default="{ row }">
<span v-if="row.is_dept" class="rate-dept-cell">
{{ row.dept_name }}
<span class="rate-dept-count">{{ row.assistant_count }} </span>
</span>
<span v-else>{{ row.assistant_name }}</span>
</template>
</el-table-column>
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
<template #default="{ row }">
<el-link
v-if="canViewAssignLines && row.assigned_count > 0"
type="primary"
:underline="false"
@click="openAssignDetail(row)"
>
{{ row.assigned_count }}
</el-link>
<span v-else>{{ row.assigned_count }}</span>
</template>
</el-table-column>
<template v-for="slot in visitSlots" :key="slot">
<el-table-column
:prop="`visit${slot}_count`"
:label="`${slotLabel(slot)}单数`"
min-width="96"
align="right"
>
<template #header>
<span>{{ slotLabel(slot) }}单数</span>
<el-tooltip
effect="dark"
placement="top"
:content="`当月内下单、诊次为第 ${slot} 次的订单数(诊次跨月累计;归属下单时点持有患者的医助)`"
>
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
type="primary"
:underline="false"
@click="openOrderDetail(slot, row)"
>
{{ row[`visit${slot}_count`] }}
</el-link>
<span v-else>{{ row[`visit${slot}_count`] }}</span>
</template>
</el-table-column>
<el-table-column
:prop="`visit${slot}_rate`"
:label="`${slotLabel(slot)}接诊率`"
min-width="96"
align="right"
>
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
</el-table-column>
</template>
</el-table>
</el-card>
<!-- 下钻明细 -->
<el-dialog
v-model="detailVisible"
:title="detailTitle"
width="980px"
top="6vh"
destroy-on-close
>
<el-table
v-loading="detailLoading"
:data="detailRows"
size="small"
border
stripe
max-height="60vh"
>
<template v-if="detailType === 'assign'">
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
<el-link
v-if="canViewPatientDetail && row.diagnosis_id"
type="primary"
:underline="false"
@click="openPatientDetail(row.diagnosis_id)"
>
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
</el-link>
<span v-else>{{ row.patient_name || '' }}</span>
</template>
</el-table-column>
<el-table-column prop="patient_phone" label="联系电话" min-width="120">
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
</el-table-column>
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
</template>
<template v-else>
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link
v-if="canViewOrderDetail && row.order_id"
type="primary"
:underline="false"
@click="openOrderInfo(row.order_id)"
>
{{ row.order_no || `#${row.order_id}` }}
</el-link>
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
</template>
</el-table-column>
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
<el-link
v-if="canViewPatientDetail && row.diagnosis_id"
type="primary"
:underline="false"
@click="openPatientDetail(row.diagnosis_id)"
>
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
</el-link>
<span v-else>{{ row.patient_name || '' }}</span>
</template>
</el-table-column>
<el-table-column prop="amount" label="金额" width="100" align="right">
<template #default="{ row }">¥ {{ row.amount }}</template>
</el-table-column>
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
</template>
</el-table>
<div class="rate-detail-footer"> {{ detailRows.length }} </div>
</el-dialog>
<!-- 订单详情抽屉共享组件与处方订单列表详情一致只读 -->
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
<!-- 诊单患者详情编辑页只读模式 -->
<diagnosis-edit-popup ref="diagnosisEditRef" />
</div>
</template>
<script setup lang="ts" name="revisitRateStatsPage">
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
import {
revisitRateAssignLines,
revisitRateDeptOptions,
revisitRateOverview,
revisitRateVisitOrderLines
} from '@/api/stats'
import { hasPermission } from '@/utils/perm'
/** 业务订单详情抽屉(共享组件,readonly 模式,与 order_list.vue 详情同源) */
const OrderDetailDrawer = defineAsyncComponent(
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
)
/** 诊单编辑弹窗(只读打开) */
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
interface MetricPack extends Record<string, any> {
assigned_count: number
}
interface StatsRow extends MetricPack {
row_key: string
is_dept: 0 | 1
dept_id: number
dept_name: string
assistant_id?: number
assistant_name?: string
assistant_count?: number
children?: StatsRow[]
}
interface DeptTreeNode {
id: number
name: string
pid: number
children?: DeptTreeNode[]
}
const canViewAssignLines = hasPermission(['stats.revisitRate/assignLines'])
const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
const visitSlots = ref<number[]>([2, 3, 4])
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const slotLabel = (slot: number) => {
if (slot >= 2 && slot <= 10) {
return `${cnDigits[slot]}`
}
if (slot > 10 && slot < 20) {
return `${cnDigits[slot - 10]}`
}
return `${slot}`
}
const initNow = new Date()
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
const month = ref<string>(currentMonth)
const deptId = ref<number | undefined>(undefined)
const loading = ref(false)
const rows = ref<StatsRow[]>([])
const total = ref<MetricPack | null>(null)
const deptTreeOptions = ref<DeptTreeNode[]>([])
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const monthText = computed(() => {
const [y, m] = month.value.split('-')
return y && m ? `${y}${Number(m)}` : month.value
})
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
const baseQuery = () => ({
month: month.value,
dept_ids: deptId.value ? String(deptId.value) : ''
})
interface SummaryCard {
key: string
label: string
value: string
sub: string
clickable: boolean
onClick?: () => void
}
const summaryCards = computed<SummaryCard[]>(() => {
const t = total.value
const cards: SummaryCard[] = [
{
key: 'assigned',
label: '当月被指派总数',
value: t ? String(t.assigned_count) : '—',
sub: '',
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
onClick: () => openAssignDetail(null)
}
]
for (const slot of visitSlots.value) {
const cnt = t ? t[`visit${slot}_count`] : null
const rate = t ? t[`visit${slot}_rate`] : null
cards.push({
key: `visit${slot}`,
label: `当月${slotLabel(slot)}接诊率`,
value: t ? formatRate(rate) : '—',
sub: t ? `${slotLabel(slot)}下单 ${cnt}` : '',
clickable: !!(canViewOrderLines && cnt > 0),
onClick: () => openOrderDetail(slot, null)
})
}
return cards
})
/** 底栏合计:直接展示后端按诊单去重后的合计,不做各行求和 */
const getSummaries = ({ columns }: { columns: any[] }) => {
const t = total.value
return columns.map((col, index) => {
if (index === 0) {
return '合计'
}
if (!t) {
return ''
}
const prop = String(col.property || '')
if (prop === 'assigned_count') {
return String(t.assigned_count)
}
if (/^visit\d+_count$/.test(prop)) {
return String(t[prop] ?? '')
}
if (/^visit\d+_rate$/.test(prop)) {
return formatRate(t[prop])
}
return ''
})
}
const disableFutureMonth = (date: Date) => {
const now = new Date()
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
}
const loadData = async () => {
loading.value = true
try {
const res = await revisitRateOverview(baseQuery())
rows.value = res?.rows ?? []
total.value = res?.total ?? null
if (Array.isArray(res?.slots) && res.slots.length) {
visitSlots.value = res.slots.map((s: any) => Number(s)).filter((s: number) => s >= 2)
}
if (res?.month) {
month.value = res.month
}
} finally {
loading.value = false
}
}
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
const map = new Map<number, DeptTreeNode>()
for (const d of flat) {
map.set(d.id, { ...d, children: [] })
}
const roots: DeptTreeNode[] = []
for (const d of flat) {
const node = map.get(d.id)!
if (d.pid > 0 && map.has(d.pid)) {
map.get(d.pid)!.children!.push(node)
} else {
roots.push(node)
}
}
const strip = (n: DeptTreeNode) => {
if (n.children?.length) {
n.children.forEach(strip)
} else {
delete n.children
}
}
roots.forEach(strip)
return roots
}
const loadDeptOptions = async () => {
try {
const res = await revisitRateDeptOptions()
deptTreeOptions.value = buildDeptTree(res?.rows ?? [])
} catch {
deptTreeOptions.value = []
}
}
//
const detailVisible = ref(false)
const detailType = ref<'assign' | 'orders'>('assign')
const detailTitle = ref('')
const detailLoading = ref(false)
const detailRows = ref<any[]>([])
/** row 为 null 时表示合计(当前部门筛选范围) */
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
if (!row) {
return { label: '合计' }
}
if (row.is_dept) {
return { dept_id: row.dept_id, label: row.dept_name }
}
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
}
const openAssignDetail = async (row: StatsRow | null) => {
if (!canViewAssignLines) {
return
}
const scope = rowScope(row)
detailType.value = 'assign'
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value}`
detailVisible.value = true
detailLoading.value = true
detailRows.value = []
try {
const res = await revisitRateAssignLines({
...baseQuery(),
assistant_id: scope.assistant_id,
dept_id: scope.dept_id
})
detailRows.value = res?.rows ?? []
} finally {
detailLoading.value = false
}
}
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
if (!canViewOrderLines) {
return
}
const scope = rowScope(row)
detailType.value = 'orders'
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value}`
detailVisible.value = true
detailLoading.value = true
detailRows.value = []
try {
const res = await revisitRateVisitOrderLines({
...baseQuery(),
slot,
assistant_id: scope.assistant_id,
dept_id: scope.dept_id
})
detailRows.value = res?.rows ?? []
} finally {
detailLoading.value = false
}
}
//
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
const openOrderInfo = (orderId: number) => {
if (!canViewOrderDetail || !orderId) {
return
}
orderDetailRef.value?.open(orderId)
}
//
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
const openPatientDetail = (diagnosisId: number) => {
if (!canViewPatientDetail || !diagnosisId) {
return
}
diagnosisEditRef.value?.openViewOnly(diagnosisId)
}
const handleReset = () => {
month.value = currentMonth
deptId.value = undefined
loadData()
}
onMounted(() => {
loadDeptOptions()
loadData()
})
</script>
<style lang="scss" scoped>
.revisit-rate-page {
.rate-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.rate-caliber-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
cursor: help;
}
.rate-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-top: 16px;
}
.rate-kpi-card {
background: var(--el-bg-color);
border-radius: 8px;
padding: 16px 20px;
&.is-clickable {
cursor: pointer;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: var(--el-box-shadow-light);
}
}
.rate-kpi-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.rate-kpi-value {
margin-top: 6px;
font-size: 26px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.rate-kpi-sub {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-card-header {
display: flex;
align-items: baseline;
gap: 10px;
.rate-card-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-dept-cell {
font-weight: 600;
.rate-dept-count {
margin-left: 6px;
font-weight: 400;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-th-hint {
margin-left: 2px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
cursor: help;
}
}
.rate-detail-footer {
margin-top: 10px;
text-align: right;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rate-caliber {
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
p + p {
margin-top: 6px;
}
}
</style>
+6 -1
View File
@@ -556,7 +556,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
}
const validateIdCard = (rule: any, value: any, callback: any) => {
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
if (!value) {
callback()
return
}
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
callback(new Error('身份证号格式不正确'))
return
}
@@ -572,6 +576,7 @@ const formRules = {
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
}
@@ -56,298 +56,21 @@
</div>
</template>
<!-- 业务订单详情抽屉只读 order_list.vue 详情保持一致 -->
<el-drawer
v-model="detailVisible"
size="80%"
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<template #header>
<div class="flex items-center">
<span class="font-semibold text-lg">业务订单详情</span>
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
</div>
</template>
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
<!-- 顶部数据概览 -->
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">总金额</div>
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付总额</div>
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">需代收</div>
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</el-col>
</el-row>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 左侧处方单不含测试价格改患者信息药材明细 -->
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">处方详情</span>
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
开方人<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
</span>
</div>
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
</div>
</template>
<template v-if="detailPrescription">
<el-descriptions :column="2" border size="small" class="bg-white">
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
</el-descriptions-item>
<el-descriptions-item label="处方金额">
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="消费者审核">
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</el-descriptions-item>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
</div>
<div>
<span class="text-gray-500">订单设置</span>
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</template>
<el-empty v-else description="无处方数据" :image-size="72" />
</el-card>
<!-- 右侧未关联 + 已关联支付单 -->
<div class="flex flex-col gap-4 h-full">
<el-card shadow="never" class="border-gray-100">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium text-[15px]">未关联的收款记录</span>
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
</div>
</template>
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="无未关联的收款单" :image-size="60" />
</el-card>
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">关联收款记录</span>
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
</div>
</template>
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="未关联收款单" :image-size="60" />
</el-card>
</div>
</div>
<!-- 履约与收货信息 -->
<el-card shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="业务订单创建人" :span="2">
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="创建人所属部门" :span="3">
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
<el-descriptions-item label="收货手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '' }}</el-descriptions-item>
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 物流轨迹只读 -->
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" class="w-[180px]" @keyup.enter="fetchLogisticsTrace" />
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
<el-option label="自动识别" value="auto" />
<el-option label="顺丰速运" value="sf" />
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
</div>
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
<span class="text-gray-800">状态{{ logisticsTracePayload.state_text }}</span>
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
</div>
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
</div>
</el-card>
<!-- 操作日志 -->
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
</div>
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无操作日志" :image-size="64" />
</el-card>
</div>
</el-drawer>
<!-- 业务订单详情抽屉共享组件的 readonly 受限版展示范围由组件内 v-if="!readonly" 门控 -->
<prescription-order-detail-drawer ref="detailDrawerRef" readonly append-to-body />
</div>
</template>
<script setup lang="ts">
import { computed, watch, ref, onMounted } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists } from '@/api/tcm'
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
import {
prescriptionOrderLists,
prescriptionOrderDetail,
prescriptionOrderLogs,
prescriptionOrderLogisticsTrace,
prescriptionOrderPaidPayOrders
} from '@/api/tcm'
import { getDictData } from '@/api/app'
import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback'
import useUserStore from '@/stores/modules/user'
const userStore = useUserStore()
formatTime,
fulfillmentText,
fulfillmentTagType
} from '@/views/consumer/prescription/components/prescription-order-utils'
const props = defineProps<{
diagnosisId: number
@@ -379,179 +102,11 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit'
}
/** 诊间医助角色(与 DiagnosisLists 等一致) */
const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
// /
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
/**
* 是否展示处方审核相关字段
*/
const showPrescriptionAuditFilter = 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)) : []
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
})
//
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const detailUnlinkedPayOrders = ref<any[]>([])
const detailPrescription = computed(() => {
const p = detailData.value?.prescription
return p && typeof p === 'object' ? p : null
})
const detailLinkedPayOrders = computed(() => {
const arr = detailData.value?.linked_pay_orders
return Array.isArray(arr) ? arr : []
})
const detailAgencyToCollect = computed(() => {
const d = detailData.value
if (!d) return 0
const snap = d.agency_collect_amount
if (snap !== null && snap !== undefined && snap !== '') {
return Number(snap) || 0
}
const total = Number(d.amount) || 0
const paid = Number(d.linked_pay_paid_total) || 0
return Math.round((total - paid) * 100) / 100
})
const detailRecipientRxPhoneMismatch = computed(() => {
const rx = normalizeBizPhone(detailPrescription.value?.phone)
const biz = normalizeBizPhone(detailData.value?.recipient_phone)
if (!rx || !biz) return false
return rx !== biz
})
const detailFullAddress = computed(() => {
const d = detailData.value
if (!d) return '—'
const parts = []
if (d.shipping_province) parts.push(d.shipping_province)
if (d.shipping_city) parts.push(d.shipping_city)
if (d.shipping_district) parts.push(d.shipping_district)
if (d.shipping_address) parts.push(d.shipping_address)
return parts.length > 0 ? parts.join(' ') : '—'
})
const detailOrderCreatorMetaLine = computed(() => {
const d = detailData.value
if (!d) return ''
const account = String(d.creator_account ?? '').trim()
const mobile = String(d.creator_mobile ?? '').trim()
const parts: string[] = []
if (account) parts.push(`登录账号:${account}`)
if (mobile) parts.push(`手机:${mobile}`)
return parts.join(' · ')
})
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
const raw = String(detailData.value?.order_creator_dept_path ?? detailData.value?.diagnosis_creator_dept_path ?? '').trim()
if (!raw) return [] as string[][]
return raw.split('').map((s) => s.trim()).filter(Boolean)
.map((p) => p.includes(' / ') ? p.split(' / ').map((x) => x.trim()).filter(Boolean) : p.split('/').map((x) => x.trim()).filter(Boolean))
.filter((segs) => segs.length > 0)
})
//
const detailLogisticsExpress = ref<string>('auto')
const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
const logisticsTracePhoneTail = ref('')
const logisticsTraceList = computed(() => {
const p = logisticsTracePayload.value
if (!p || !Array.isArray(p.traces)) return []
return p.traces as Array<{ time: string; context: string }>
})
async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id)
if (!id) return
logisticsTraceLoading.value = true
try {
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id,
express_company: detailLogisticsExpress.value
}
if (digits.length >= 4) params.phone_tail = digits
const res: any = await prescriptionOrderLogisticsTrace(params)
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
} catch {
logisticsTracePayload.value = null
} finally {
logisticsTraceLoading.value = false
}
}
//
async function openDetail(id: number) {
detailData.value = null
detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null
detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = ''
detailLogs.value = []
detailVisible.value = true
detailLoading.value = true
try {
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 (String(d.tracking_number || '').trim()) fetchLogisticsTrace()
fetchLogs(id)
if (d.diagnosis_id) {
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
}
}
} catch (e: any) {
feedback.msgError(e?.message || '加载详情失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
try {
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
const lists = res?.lists ?? res?.data?.lists ?? []
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
} catch { detailUnlinkedPayOrders.value = [] }
}
function canViewPrescriptionOrderLogs() {
const p = userStore.perms || []
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
}
async function fetchLogs(id: number) {
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
try {
const res: any = await prescriptionOrderLogs({ id })
detailLogs.value = res?.data ?? res ?? []
} catch { detailLogs.value = [] }
}
//
function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
function openDetail(id: number) {
detailDrawerRef.value?.open(id)
}
const formatAmount = (value: unknown) => {
@@ -559,126 +114,6 @@ const formatAmount = (value: unknown) => {
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
function formatMoney(v: unknown) {
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
const formatTime = (value: unknown) => {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
const d = new Date(value * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
return String(value)
}
function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
function feeTypeText(t: number | undefined) {
const m: Record<number, string> = { 1: '药费', 2: '诊费', 3: '药费+诊费', 4: '其他' }
return m[Number(t)] ?? '—'
}
// order_list.vue dict server_order
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
async function loadServicePackageOptions() {
try {
const data: any = await getDictData({ type: 'server_order' })
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
servicePackageOptions.value = list.filter((item) => item.status !== 0)
} catch {
servicePackageOptions.value = []
}
}
function formatServicePackage(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
let packages: string[] = []
if (Array.isArray(value)) {
packages = value.map((v) => String(v)).filter((v) => v !== '')
} else if (typeof value === 'string') {
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
}
if (packages.length === 0) return '—'
const names = packages.map((val) => {
const opt = servicePackageOptions.value.find((o) => o.value === val)
return opt ? opt.name : val
})
return names.join('、')
}
onMounted(() => {
loadServicePackageOptions()
})
function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
function orderStatusText(s: number | undefined) {
const m: Record<number, string> = { 0: '待支付', 1: '待支付', 2: '已支付', 3: '已取消' }
return m[Number(s)] ?? '—'
}
const fulfillmentText = (s: number | undefined) => {
const m: Record<number, string> = { 1: '待双审通过', 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货', 6: '已签收', 7: '进行中', 8: '暂不制药', 9: '拒收', 10: '退款', 11: '保留药方', 12: '制药缓发' }
return m[Number(s)] ?? '—'
}
const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
const n = Number(s)
if (n === 3 || n === 6) return 'success'
if (n === 5) return 'primary'
if (n === 4 || n === 9 || n === 10) return 'danger'
if (n === 2 || n === 7) return 'warning'
if (n === 8 || n === 11 || n === 12) return 'info'
return 'info'
}
function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建', edit: '编辑', audit_rx_approve: '处方审核', audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核', audit_pay_reject: '支付审核', fill_tracking: '填快递单',
ship: '确认发货', withdraw: '撤销', link_pay_order: '关联支付单',
revoke_rx_audit: '撤回处方审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额', complete: '完成订单'
}
return m[act] || act
}
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
@@ -699,22 +134,4 @@ defineExpose({ refresh: () => getLists() })
.po-empty-tip {
padding: 24px 0;
}
.audit-stamp {
position: absolute;
top: 12px;
right: 16px;
transform: rotate(15deg);
opacity: 0.15;
pointer-events: none;
z-index: 1;
}
.stamp-inner {
border: 3px solid currentColor;
border-radius: 8px;
padding: 4px 12px;
font-size: 18px;
font-weight: bold;
}
.stamp-pass { color: #22c55e; }
.stamp-reject { color: #ef4444; }
</style>
+155 -27
View File
@@ -67,26 +67,66 @@
>
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-alert
v-if="mode === 'edit' && patientBasicLocked && !canEditPatientBasicFields"
type="warning"
:closable="false"
show-icon
class="mb-3"
>
<template v-if="latestPrescriptionOrder">
最近业务订单 #{{ latestPrescriptionOrder.id }} 状态为{{ latestPrescriptionOrder.fulfillment_status_text }}患者基本信息不可修改
</template>
<template v-else>
存在未完成的业务订单患者基本信息不可修改
</template>
</el-alert>
<el-form-item label="诊单ID">
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
</el-form-item>
<fieldset
:disabled="!canEditPatientBasicFields"
class="diagnosis-patient-basic-fieldset border-0 min-w-0 p-0 m-0"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓名" prop="patient_name">
<el-input
v-model="formData.patient_name"
placeholder="请输入姓名"
:disabled="!canEditPatientBasicFields"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="身份证号" prop="id_card">
<template v-if="showIdCardMaskedEdit">
<el-input
:model-value="
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
"
readonly
placeholder="点击可查看完整身份证号"
maxlength="18"
class="cursor-pointer phone-mask-toggle"
@click="toggleIdCardReveal"
/>
<p
v-if="originalIdCard && !idCardRevealUnlocked"
class="text-xs text-gray-400 mt-1"
>
当前为脱敏显示点击输入框可查看完整号码
</p>
</template>
<el-input
v-else
v-model="formData.id_card"
placeholder="请输入身份证号"
maxlength="18"
:disabled="!canEditIdCard"
@focus="handleIdCardFocus"
@blur="handleIdCardBlur"
/>
@@ -120,6 +160,7 @@
v-model="formData.phone"
placeholder="请输入手机号"
maxlength="11"
:disabled="!canEditPatientBasicFields"
@focus="handlePhoneFocus"
@blur="handlePhoneBlur"
/>
@@ -127,7 +168,7 @@
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender">
<el-radio-group v-model="formData.gender" :disabled="!canEditPatientBasicFields">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
@@ -144,11 +185,14 @@
:max="150"
placeholder="请输入年龄"
class="w-full"
:disabled="!canEditPatientBasicFields"
:controls="canEditPatientBasicFields"
/>
</el-form-item>
</el-col>
</el-row>
</fieldset>
<!-- 生命体征 -->
@@ -778,9 +822,40 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
const latestPrescriptionOrder = ref<{
id: number
fulfillment_status: number
fulfillment_status_text: string
} | null>(null)
const canEditPatientBasicFields = computed(() => {
if (viewOnly.value) return false
if (mode.value === 'add') return true
return canEditPatientBasicFromApi.value
})
/** 编辑且无明文权限或基本信息锁定:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => {
if (mode.value !== 'edit') return false
if (!canEditPatientBasicFields.value) return true
return !hasPhonePlainPermission.value
})
const phoneRevealUnlocked = ref(false)
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
const showIdCardMaskedEdit = computed(() => {
if (mode.value !== 'edit') return false
if (!originalIdCard.value) return false
if (!canEditPatientBasicFields.value) return true
return !hasPhonePlainPermission.value
})
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
const canEditIdCard = computed(() => {
if (!canEditPatientBasicFields.value) return false
if (mode.value === 'add') return true
if (!originalIdCard.value) return true
return hasPhonePlainPermission.value
})
const idCardRevealUnlocked = ref(false)
const activeTab = ref('basic')
const drawerTitle = computed(() => {
if (viewOnly.value) return '诊单详情'
@@ -920,6 +995,11 @@ const togglePhoneReveal = () => {
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
}
const toggleIdCardReveal = () => {
if (!originalIdCard.value) return
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
}
const maskIdCard = (idCard: string) => {
if (!idCard) return idCard
if (idCard.length === 15) {
@@ -990,43 +1070,40 @@ const handlePhoneBlur = async () => {
}
}
// -
// -
const handleIdCardFocus = () => {
isIdCardFocused.value = true
if (originalIdCard.value) {
if (!originalIdCard.value) return
if (hasPhonePlainPermission.value) {
formData.value.id_card = originalIdCard.value
return
}
if (mode.value === 'add') {
formData.value.id_card = originalIdCard.value
}
}
// -
//
const handleIdCardBlur = async () => {
isIdCardFocused.value = false
//
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
//
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
//
const id = formData.value.id_card
const birthYear = parseInt(id.substring(6, 10))
const birthMonth = parseInt(id.substring(10, 12))
const birthDay = parseInt(id.substring(12, 14))
const processIdCard = async (idCard: string) => {
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
const today = new Date()
let age = today.getFullYear() - birthYear
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
age--
}
formData.value.age = age
//
try {
const result = await checkIdCard({
id_card: formData.value.id_card,
id_card: idCard,
id: formData.value.id || ''
})
if (result.exists) {
ElMessage.warning(result.message)
}
@@ -1034,8 +1111,24 @@ const handleIdCardBlur = async () => {
console.error('检查身份证号失败:', error)
}
}
//
}
if (hasPhonePlainPermission.value) {
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
await processIdCard(formData.value.id_card)
}
return
}
//
if (mode.value === 'edit' && originalIdCard.value) {
return
}
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
await processIdCard(formData.value.id_card)
formData.value.id_card = maskIdCard(formData.value.id_card)
}
}
@@ -1058,7 +1151,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
const validateIdCard = (rule: any, value: any, callback: any) => {
// 使
const idCardToValidate = originalIdCard.value || value
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
if (!idCardToValidate) {
callback()
return
}
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
callback(new Error('身份证号格式不正确'))
return
}
@@ -1073,6 +1170,7 @@ const formRules = {
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
}
@@ -1167,9 +1265,12 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
originalPhone.value = data.phone || ''
originalIdCard.value = data.id_card || ''
patientBasicLocked.value = !!data.patient_basic_locked
canEditPatientBasicFromApi.value = data.can_edit_patient_basic !== false
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
data.id_card = maskIdCard(data.id_card || '')
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
formData.value = data
formData.value.create_source = data.create_source ?? ''
@@ -1178,12 +1279,20 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
}
const resetPatientBasicLockState = () => {
patientBasicLocked.value = false
canEditPatientBasicFromApi.value = true
latestPrescriptionOrder.value = null
}
const open = async (type: string, id?: number) => {
viewOnly.value = false
mode.value = type
visible.value = true
activeTab.value = 'basic' //
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
//
await getDictOptions()
@@ -1206,6 +1315,8 @@ const openViewOnly = async (id: number) => {
visible.value = true
activeTab.value = 'basic'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
await getDictOptions()
await loadDiagnosisDetailIntoForm(id)
}
@@ -1230,6 +1341,7 @@ const handleSubmit = async () => {
if (result && result.id) {
mode.value = 'edit'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
formData.value.id = result.id
// patient_id
@@ -1244,7 +1356,9 @@ const handleSubmit = async () => {
formData.value.phone = hasPhonePlainPermission.value
? detail.phone || ''
: maskPhone(detail.phone || '')
formData.value.id_card = maskIdCard(detail.id_card || '')
formData.value.id_card = hasPhonePlainPermission.value
? detail.id_card || ''
: maskIdCard(detail.id_card || '')
} catch (error) {
console.error('获取详情失败:', error)
}
@@ -1346,6 +1460,7 @@ const handleClose = () => {
isPhoneFocused.value = false
isIdCardFocused.value = false
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
viewOnly.value = false
visible.value = false
@@ -1370,6 +1485,19 @@ defineExpose({
}
}
.diagnosis-patient-basic-fieldset:disabled {
opacity: 1;
:deep(.el-input__wrapper),
:deep(.el-input-number),
:deep(.el-input-number__decrease),
:deep(.el-input-number__increase),
:deep(.el-radio),
:deep(.el-radio__input) {
cursor: not-allowed;
pointer-events: none;
}
}
:deep(.el-form) {
padding-bottom: 20px;
font-size: 12px;
+103 -68
View File
@@ -18,7 +18,7 @@
</span>
<div>
<div class="wb-section-name">订单统计</div>
<div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '' }}</div>
<div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '-' }}</div>
</div>
</div>
<div class="wb-toolbar">
@@ -821,8 +821,8 @@ onMounted(() => {
<style lang="scss" scoped>
.workbench-page {
min-height: 100%;
padding: 20px 20px 40px;
background: linear-gradient(160deg, #eef2ff 0%, #f8fafc 38%, #f1f5f9 100%);
padding: 4px 0 24px;
background: transparent;
}
.wb-hero {
@@ -830,56 +830,86 @@ onMounted(() => {
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
padding: 22px 26px;
border-radius: 20px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.65) 100%);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06);
gap: 20px;
margin-bottom: 24px;
padding: 28px 28px;
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-glass);
backdrop-filter: blur(16px);
box-shadow: var(--el-box-shadow-light);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(120deg, rgba(6, 182, 212, 0.14) 0%, transparent 45%),
linear-gradient(300deg, rgba(16, 185, 129, 0.1) 0%, transparent 40%);
pointer-events: none;
}
&::after {
content: '';
position: absolute;
inset: 0 0 auto;
height: 3px;
background: var(--admin-brand-gradient);
}
}
.wb-hero-text {
position: relative;
z-index: 1;
}
.wb-hero-title {
margin: 0 0 6px;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.02em;
color: #0f172a;
margin: 0 0 8px;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.03em;
background: var(--admin-brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.wb-hero-desc {
margin: 0;
font-size: 14px;
color: #64748b;
font-size: 15px;
color: var(--el-text-color-secondary);
}
.wb-refresh-btn {
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25);
position: relative;
z-index: 1;
box-shadow: var(--admin-brand-glow);
}
.wb-section {
margin-bottom: 20px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.05);
border-radius: var(--admin-radius-xl);
background: var(--admin-surface-elevated);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
overflow: hidden;
backdrop-filter: blur(10px);
}
.wb-section--diagnosis {
border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #3b82f6, #60a5fa) 1;
border-image: linear-gradient(90deg, #06b6d4, #10b981) 1;
}
.wb-section--order {
border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #8b5cf6, #a78bfa) 1;
border-image: linear-gradient(90deg, #0891b2, #6366f1) 1;
}
.wb-section--trend {
border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #14b8a6, #2dd4bf) 1;
border-image: linear-gradient(90deg, #10b981, #06b6d4) 1;
}
.wb-section-head {
@@ -889,8 +919,8 @@ onMounted(() => {
justify-content: space-between;
gap: 12px;
padding: 18px 22px;
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9) 0%, rgba(255, 255, 255, 0) 100%);
border-bottom: 1px solid var(--el-border-color-extra-light);
background: var(--admin-brand-gradient-soft);
}
.wb-section-title {
@@ -903,38 +933,68 @@ onMounted(() => {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
width: 46px;
height: 46px;
border-radius: 14px;
font-size: 22px;
color: #fff;
}
.wb-section-icon--blue {
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35);
background: var(--admin-brand-gradient);
box-shadow: var(--admin-brand-glow);
}
.wb-section-icon--violet {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.3);
background: linear-gradient(135deg, #0891b2, #6366f1);
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25);
}
.wb-section-icon--teal {
background: linear-gradient(135deg, #14b8a6, #0d9488);
box-shadow: 0 8px 20px rgba(13, 148, 136, 0.3);
background: linear-gradient(135deg, #10b981, #06b6d4);
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.25);
}
.wb-kpi--blue {
background: linear-gradient(145deg, rgba(6, 182, 212, 0.12), rgba(16, 185, 129, 0.06));
border: 1px solid rgba(6, 182, 212, 0.22);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-kpi--violet {
background: linear-gradient(145deg, rgba(99, 102, 241, 0.1), rgba(6, 182, 212, 0.06));
border: 1px solid rgba(99, 102, 241, 0.2);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-kpi--amber {
background: linear-gradient(145deg, rgba(245, 158, 11, 0.12), rgba(251, 191, 36, 0.06));
border: 1px solid rgba(245, 158, 11, 0.24);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-rank-strip {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 112px;
padding: 14px 18px;
border-radius: var(--admin-radius-lg);
background: var(--admin-brand-gradient-soft);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-section-name {
font-size: 17px;
font-size: 16px;
font-weight: 600;
color: #0f172a;
color: var(--el-text-color-primary);
}
.wb-section-sub {
margin-top: 2px;
font-size: 12px;
color: #94a3b8;
color: var(--el-text-color-secondary);
}
.wb-toolbar {
@@ -968,21 +1028,6 @@ onMounted(() => {
box-sizing: border-box;
}
.wb-kpi--blue {
background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid rgba(59, 130, 246, 0.2);
}
.wb-kpi--violet {
background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 100%);
border: 1px solid rgba(139, 92, 246, 0.22);
}
.wb-kpi--amber {
background: linear-gradient(145deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid rgba(245, 158, 11, 0.25);
}
.wb-kpi-label {
font-size: 13px;
color: #64748b;
@@ -1007,17 +1052,6 @@ onMounted(() => {
color: #94a3b8;
}
.wb-rank-strip {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 112px;
padding: 14px 18px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.wb-rank-strip--compact {
min-height: 112px;
}
@@ -1080,9 +1114,10 @@ onMounted(() => {
.wb-chart-card {
height: 100%;
padding: 14px 16px 8px;
border-radius: 16px;
background: #fafbfc;
border: 1px solid #eef0f4;
border-radius: var(--admin-radius-lg);
background: var(--admin-surface-elevated);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-chart-card-head {
+16 -1
View File
@@ -77,7 +77,22 @@ module.exports = {
mask: 'var(--el-mask-color)'
},
fontFamily: {
sans: ['PingFang SC', 'Arial', 'Hiragino Sans GB', 'Microsoft YaHei', 'sans-serif']
sans: [
'PingFang SC',
'SF Pro Text',
'Segoe UI',
'Arial',
'Hiragino Sans GB',
'Microsoft YaHei',
'sans-serif'
]
},
borderRadius: {
sm: 'var(--admin-radius-sm)',
DEFAULT: 'var(--admin-radius-md)',
md: 'var(--admin-radius-md)',
lg: 'var(--admin-radius-lg)',
xl: 'var(--admin-radius-xl)'
},
boxShadow: {
DEFAULT: 'var(--el-box-shadow)',
File diff suppressed because one or more lines are too long
@@ -169,14 +169,19 @@ class AssetResourceController extends BaseAdminController
public function delete()
{
$id = $this->request->post('id');
if (empty($id)) {
$ids = is_array($id) ? $id : [$id];
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $item): bool {
return $item > 0;
})));
if (empty($ids)) {
return $this->fail('缺少参数');
}
Db::startTrans();
try {
AssetResource::destroy($id);
AssetUserResource::where('resource_id', $id)->delete();
AssetResource::destroy($ids);
AssetUserResource::whereIn('resource_id', $ids)->delete();
Db::commit();
return $this->success('删除成功');
} catch (\Exception $e) {
@@ -207,11 +207,17 @@ class OrderController extends BaseAdminController
$params['creator_id'] = $this->adminId;
$params['payment_channel'] = (string)$this->request->post('payment_channel', 'normal');
$params['require_payment_slip_audit'] = (int)$this->request->post('require_payment_slip_audit', 0);
// 创建方式:优先取前端透传的 create_type,否则按 payment_channel 派生(fubei→fubei,其余 normal
// 创建方式:优先取前端透传的 create_type,否则按 payment_channel 派生(fubei→fubeiexpress_cod→express_cod其余 normal
$createTypeReq = (string)$this->request->post('create_type', '');
$params['create_type'] = in_array($createTypeReq, ['normal', 'wechat_work', 'fubei'], true)
? $createTypeReq
: ($params['payment_channel'] === 'fubei' ? 'fubei' : 'normal');
if (in_array($createTypeReq, ['normal', 'wechat_work', 'fubei', 'express_cod'], true)) {
$params['create_type'] = $createTypeReq;
} elseif ($params['payment_channel'] === 'fubei') {
$params['create_type'] = 'fubei';
} elseif ($params['payment_channel'] === 'express_cod') {
$params['create_type'] = 'express_cod';
} else {
$params['create_type'] = 'normal';
}
$result = OrderLogic::create($params);
if (!$result) {
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\stats\RevisitRateLogic;
/**
* 复诊接诊率统计(按月)
*
* - GET stats.revisitRate/overview 当月 N 诊接诊率(部门 医助分组 + 合计)
* - GET stats.revisitRate/deptOptions 部门下拉(前端组树)
* - GET stats.revisitRate/assignLines 「被指派数」明细(按诊单聚合)
* - GET stats.revisitRate/visitOrderLines 「N 诊单数」订单明细
*/
class RevisitRateController extends BaseAdminController
{
public function overview()
{
// 涉及指派日志 + 全量订单序列扫描,放宽执行时间兜底
@set_time_limit(120);
$params = $this->request->get();
return $this->data(RevisitRateLogic::overview($params));
}
public function deptOptions()
{
return $this->data(RevisitRateLogic::deptOptions());
}
/** 「被指派数」点击下钻:诊单维度明细 */
public function assignLines()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(RevisitRateLogic::assignLines($params));
}
/** 「N 诊单数」点击下钻:具体订单明细 */
public function visitOrderLines()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(RevisitRateLogic::visitOrderLines($params));
}
}
@@ -17,6 +17,7 @@ use app\adminapi\logic\stats\YejiStatsLogic;
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
* - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
* - GET stats.yejiStats/assignLines 被指派数明细(医助×诊单去重,与看板同口径)
*/
class YejiStatsController extends BaseAdminController
{
@@ -141,4 +142,13 @@ class YejiStatsController extends BaseAdminController
return $this->data(YejiStatsLogic::revisitDeptAssistantBreakdown($params, $this->adminId, $this->adminInfo));
}
/** 被指派数明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重) */
public function assignLines()
{
@set_time_limit(120);
$params = $this->request->get();
return $this->data(YejiStatsLogic::assignLines($params, $this->adminId, $this->adminInfo));
}
}
@@ -87,7 +87,7 @@ class DiagnosisController extends BaseAdminController
public function edit()
{
$params = (new DiagnosisValidate())->post()->goCheck('edit');
$result = DiagnosisLogic::edit($params);
$result = DiagnosisLogic::edit($params, $this->adminInfo);
if ($result) {
return $this->success('编辑成功', [], 1, 1);
}
@@ -116,7 +116,7 @@ class DiagnosisController extends BaseAdminController
{
$params = (new DiagnosisValidate())->goCheck('id');
$result = DiagnosisLogic::detail($params);
$result = DiagnosisLogic::detail($params, $this->adminInfo);
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
return $this->data($result);
}
@@ -286,6 +286,32 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('已撤回', $result);
}
/**
* @notes 批量将处方业务订单改派给其他医助(写入 creator_id 并逐单记操作日志)
*/
public function batchAssignAssistant()
{
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
return $this->fail('无权限批量改派订单');
}
$params = $this->request->post();
$rawIds = $params['order_ids'] ?? [];
if (!\is_array($rawIds) || $rawIds === []) {
return $this->fail('请选择订单');
}
$assistantId = (int) ($params['assistant_id'] ?? 0);
$result = PrescriptionOrderLogic::batchAssignAssistant($rawIds, $assistantId, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
$msg = '已改派 ' . (int) $result['success'] . ' 单';
if (!empty($result['errors'])) {
$msg .= '' . implode('', $result['errors']);
}
return $this->success($msg, $result);
}
/**
* 获取业务订单操作日志
*/
@@ -9,6 +9,7 @@ use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
use app\common\enum\ExportEnum;
use app\common\cache\ExportCache;
use app\common\lists\ListsExcelInterface;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
@@ -26,6 +27,12 @@ use app\common\service\gancao\GancaoScmRecipelService;
use think\facade\Config;
use think\facade\Db;
use think\db\Query;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface, ListsExcelInterface
{
@@ -108,6 +115,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyExpressKeywordFilter($query);
$this->applyServiceChannelFilter($query);
$this->applySupplyModeFilter($query);
$this->applyHasAuxFormulaFilter($query);
$this->applyAuditAdminFilter($query);
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
@@ -347,6 +355,39 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
}
/**
* 是否含辅方:关联处方 herbs 中是否存在 formula_type=辅方(与 PrescriptionOrderLogic::detail 口径一致)
*
* 入参 has_aux_formula1 含辅方 | 0 不含辅方,空表示不限
*/
private function applyHasAuxFormulaFilter($query): void
{
$raw = $this->params['has_aux_formula'] ?? '';
if ($raw === '' || $raw === null) {
return;
}
$wantAux = (int) $raw === 1;
$poTbl = (new PrescriptionOrder())->getTable();
$rxTbl = (new Prescription())->getTable();
// herbs 为 JSON 字段,ThinkPHP 写入时中文按 Unicode 转义存储(辅方 => \u8f85\u65b9)。
// 用 ESCAPE '~' 让反斜杠按字面匹配;同时兼容极少数原文「辅方」写法。
$auxEsc = '%"formula_type":"\\\\u8f85\\\\u65b9"%';
$auxRaw = '%"formula_type":"辅方"%';
$herbsHasAux = "(IFNULL(rx.`herbs`,'') LIKE '{$auxEsc}' ESCAPE '~'"
. " OR IFNULL(rx.`herbs`,'') LIKE '{$auxRaw}')";
$existsSql = "SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
. " AND rx.`delete_time` IS NULL AND {$herbsHasAux}";
if ($wantAux) {
$query->whereExists($existsSql);
return;
}
$query->whereExists(
"SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
. " AND rx.`delete_time` IS NULL AND NOT ({$herbsHasAux})"
);
}
/**
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则额外包含关联处方开方人为本人的订单。
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
@@ -783,6 +824,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_medication_days' => '天数',
'export_amount' => '总金额',
'export_paid_amount' => '已付金额',
'export_linked_pay_records' => '关联收款记录',
'export_refund_amount' => '退款金额',
'export_agency_collect' => '代收金额',
'export_tracking_number' => '快递单号',
@@ -795,6 +837,133 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
];
}
/**
* 处方业务订单导出:深色表头、斑马纹、长文本自动换行(覆盖默认亮蓝表头)
*
* @param array<string, string> $excelFields
* @param array<int, array<string, mixed>> $lists
*/
public function createExcel($excelFields, $lists): string
{
$title = array_values($excelFields);
$fieldKeys = array_keys($excelFields);
$data = [];
foreach ($lists as $row) {
$temp = [];
foreach ($excelFields as $key => $excelField) {
$fieldData = $row[$key] ?? '';
if (is_numeric($fieldData) && strlen((string) $fieldData) >= 12) {
$fieldData .= "\t";
}
$temp[$key] = $fieldData;
}
$data[] = $temp;
}
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('处方业务订单');
foreach ($title as $key => $value) {
$sheet->setCellValueByColumnAndRow($key + 1, 1, $value);
}
$rowNum = 2;
foreach ($data as $item) {
$column = 1;
foreach ($item as $value) {
$sheet->setCellValueByColumnAndRow($column, $rowNum, $value);
++$column;
}
++$rowNum;
}
$highest = $sheet->getHighestRowAndColumn();
$highestRow = (int) $highest['row'];
$lastColumn = (string) $highest['column'];
$titleScope = 'A1:' . $lastColumn . '1';
$allScope = 'A1:' . $lastColumn . $highestRow;
$bodyScope = 'A2:' . $lastColumn . $highestRow;
$sheet->freezePane('A2');
$sheet->getRowDimension(1)->setRowHeight(26);
$sheet->getStyle($titleScope)->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF334155');
$sheet->getStyle($titleScope)->getFont()->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle($titleScope)->getFont()->setBold(true);
$sheet->getStyle($titleScope)->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle($allScope)->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)
->getColor()->setARGB('FFE2E8F0');
$sheet->getStyle($bodyScope)->getAlignment()
->setVertical(Alignment::VERTICAL_TOP)
->setWrapText(true);
$wrapWideKeys = [
'export_linked_pay_records',
'export_prescription_name',
'export_guahao_channel_source',
'export_assistant_dept',
'export_service_package',
];
$fixedWidths = [
'export_fulfillment_status_text' => 12,
'export_order_time' => 18,
'export_patient_gender' => 6,
'export_patient_age' => 6,
'export_medication_days' => 6,
'export_amount' => 10,
'export_paid_amount' => 10,
'export_refund_amount' => 10,
'export_agency_collect' => 10,
'export_sign_time' => 12,
'export_supply_mode' => 10,
'export_linked_pay_records' => 52,
'export_prescription_name' => 34,
'export_guahao_channel_source' => 22,
'export_assistant_dept' => 24,
'export_service_package' => 18,
'export_tracking_number' => 18,
];
foreach ($fieldKeys as $idx => $fieldKey) {
$colLetter = Coordinate::stringFromColumnIndex($idx + 1);
if (isset($fixedWidths[$fieldKey])) {
$sheet->getColumnDimension($colLetter)->setWidth($fixedWidths[$fieldKey]);
} elseif (!in_array($fieldKey, $wrapWideKeys, true)) {
$sheet->getColumnDimension($colLetter)->setAutoSize(true);
} else {
$sheet->getColumnDimension($colLetter)->setWidth(28);
}
}
for ($r = 2; $r <= $highestRow; ++$r) {
if ($r % 2 === 0) {
$sheet->getStyle('A' . $r . ':' . $lastColumn . $r)->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FFF8FAFC');
}
}
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$exportCache = new ExportCache();
$src = $exportCache->getSrc();
if (!file_exists($src)) {
mkdir($src, 0775, true);
}
$writer->save($src . $this->fileName);
$vars = ['file' => $exportCache->setFile($this->fileName)];
return (string) url('adminapi/download/export', $vars, true, true);
}
/**
* 业绩看板侧栏专用:约诊/接诊条数,与 YejiStatsLogic::applyConsultFiltersAlignedWithAppointmentLists 同口径
* doctor_appointment.status=3appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
+34 -14
View File
@@ -23,9 +23,9 @@ use think\facade\Log;
class OrderLogic
{
/**
* 创建方式统一三值normal 普通订单 / wechat_work 企业微信对外收款 / fubei 付呗
* 创建方式:normal 普通订单 / wechat_work 企业微信对外收款 / fubei 付呗 / express_cod 快递代收
*/
private const CREATE_TYPES = ['normal', 'wechat_work', 'fubei'];
private const CREATE_TYPES = ['normal', 'wechat_work', 'fubei', 'express_cod'];
/**
* @notes 生成订单号
@@ -64,6 +64,15 @@ class OrderLogic
return 'normal';
}
/**
* 是否为「待审核(5)且需人工确认到账」的单:付呗(payment_method=fubei) 快递代收(create_type=express_cod)
*/
private static function isPendingManualAudit(Order $order): bool
{
return (string)($order->payment_method ?? '') === 'fubei'
|| (string)($order->create_type ?? '') === 'express_cod';
}
/**
* @notes 创建订单
* @param array $params
@@ -73,11 +82,16 @@ class OrderLogic
{
try {
$channel = (string)($params['payment_channel'] ?? 'normal');
if (!in_array($channel, ['normal', 'fubei'], true)) {
if (!in_array($channel, ['normal', 'fubei', 'express_cod'], true)) {
$channel = 'normal';
}
$paymentMethod = $channel === 'fubei' ? 'fubei' : null;
$createType = self::normalizeCreateType((string)($params['create_type'] ?? ''), $paymentMethod);
// 快递代收:以 express_cod 标记创建方式(与 fubei 流转一致,但 payment_method 待到账时再写)
$createTypeHint = $channel === 'express_cod' ? 'express_cod' : '';
$createType = self::normalizeCreateType(
(string)($params['create_type'] ?? $createTypeHint),
$paymentMethod
);
$requirePaymentSlipAudit = (int)($params['require_payment_slip_audit'] ?? 0) === 1;
$order = new Order();
@@ -87,10 +101,13 @@ class OrderLogic
$order->order_type = $params['order_type'];
$order->amount = $params['amount'];
$order->create_type = $createType;
// 付呗且申请审核支付单:待审核(5);否则待支付(1)
// 付呗/快递代收且申请审核支付单:待审核(5);否则待支付(1)
if ($channel === 'fubei') {
$order->payment_method = 'fubei';
$order->status = $requirePaymentSlipAudit ? 5 : 1;
} elseif ($channel === 'express_cod') {
// payment_method 留空,待人工确认到账时再写真实支付方式
$order->status = $requirePaymentSlipAudit ? 5 : 1;
} else {
$order->status = 1; // 待支付
}
@@ -387,11 +404,11 @@ class OrderLogic
}
$st = (int)$order->status;
// 1=待支付;5=待审核(付呗+申请支付单审核) 时允许录入手动到账
// 1=待支付;5=待审核(付呗/快递代收+申请支付单审核) 时允许录入手动到账
if ($st === 1) {
// 正常待支付
} elseif ($st === 5 && (string)($order->payment_method ?? '') === 'fubei') {
// 待审核的付呗单,通过人工确认后标记已支付
} elseif ($st === 5 && self::isPendingManualAudit($order)) {
// 待审核的付呗/快递代收单,通过人工确认后标记已支付
} else {
self::setError('订单状态不允许支付');
return false;
@@ -424,8 +441,8 @@ class OrderLogic
}
$st = (int)$order->status;
if ($st !== 1 && !($st === 5 && (string)($order->payment_method ?? '') === 'fubei')) {
self::setError('只有待支付或待审核(付呗)的订单才能取消');
if ($st !== 1 && !($st === 5 && self::isPendingManualAudit($order))) {
self::setError('只有待支付或待审核(付呗/快递代收)的订单才能取消');
return false;
}
@@ -509,13 +526,13 @@ class OrderLogic
}
$st = (int) $order->status;
if ($st === 5 && (string) ($order->payment_method ?? '') !== 'fubei') {
self::setError('待审核订单仅支持付呗渠道拆分');
if ($st === 5 && !self::isPendingManualAudit($order)) {
self::setError('待审核订单仅支持付呗/快递代收渠道拆分');
return false;
}
if (! in_array($st, [1, 5, 2], true)) {
self::setError('仅「待支付」「待审核(付呗)」或「已支付」的订单可拆分');
self::setError('仅「待支付」「待审核(付呗/快递代收)」或「已支付」的订单可拆分');
return false;
}
@@ -586,7 +603,10 @@ class OrderLogic
(string) ($order->remark ?? '')
);
if ($st === 5) {
$n->payment_method = 'fubei';
// 付呗子单沿用 payment_method=fubei;快递代收子单 payment_method 留空,靠 create_type 标记
if ((string) ($order->payment_method ?? '') === 'fubei') {
$n->payment_method = 'fubei';
}
$n->status = 5;
} elseif ($st === 2) {
$n->status = 2;
@@ -48,8 +48,8 @@ class ConversionLogic
$emptyResult = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
@@ -57,8 +57,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
],
];
if ($includeFilters) {
@@ -75,8 +75,8 @@ class ConversionLogic
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
@@ -84,8 +84,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
],
];
if ($includeFilters) {
@@ -124,8 +124,8 @@ class ConversionLogic
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
@@ -133,8 +133,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]),
'charts' => self::buildCharts([]),
'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([], $dimension),
],
];
if ($includeFilters) {
@@ -191,8 +191,8 @@ class ConversionLogic
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows),
'charts' => self::buildCharts($chartRows),
'summary' => self::buildSummary($allRows, $dimension),
'charts' => self::buildCharts($chartRows, $dimension),
'lists' => $pagedRows,
'count' => count($allRows),
'page_no' => $pageNo,
@@ -200,8 +200,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows),
'charts' => self::buildCharts($chartRows),
'summary' => self::buildSummary($allRows, $dimension),
'charts' => self::buildCharts($chartRows, $dimension),
],
];
if ($includeFilters) {
@@ -226,8 +226,8 @@ class ConversionLogic
$result = [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows),
'charts' => self::buildCharts($rows),
'summary' => self::buildSummary($rows, $dimension),
'charts' => self::buildCharts($rows, $dimension),
'lists' => array_slice($rows, $offset, $pageSize),
'count' => $count,
'page_no' => $pageNo,
@@ -235,8 +235,8 @@ class ConversionLogic
'extend' => [
'dimension' => $dimension,
'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows),
'charts' => self::buildCharts($rows),
'summary' => self::buildSummary($rows, $dimension),
'charts' => self::buildCharts($rows, $dimension),
],
];
if ($includeFilters) {
@@ -1226,7 +1226,7 @@ class ConversionLogic
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -1459,7 +1459,7 @@ class ConversionLogic
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$node['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -2010,7 +2010,12 @@ class ConversionLogic
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
'receive_rate' => self::percent($completedOrderCount, $addFansCount),
'receive_rate' => self::receiveRate(
$completedOrderCount,
$addFansCount,
$interviewCount,
'member'
),
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
@@ -2149,7 +2154,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
private static function buildSummary(array $rows): array
private static function buildSummary(array $rows, string $dimension = 'dept'): array
{
$summary = [
'add_fans_count' => 0,
@@ -2181,7 +2186,12 @@ class ConversionLogic
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
$summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_count']);
$summary['receive_rate'] = self::receiveRate(
$summary['completed_order_count'],
$summary['add_fans_count'],
$summary['interview_count'],
$dimension
);
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
@@ -2310,7 +2320,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>
*/
private static function buildCharts(array $rows): array
private static function buildCharts(array $rows, string $dimension = 'dept'): array
{
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
$topRows = array_slice($chartableRows, 0, 10);
@@ -2319,29 +2329,56 @@ class ConversionLogic
'names' => [],
'amounts' => [],
'order_counts' => [],
'fan_counts' => [],
'rois' => [],
];
if ($dimension === 'doctor') {
$ranking['appointment_counts'] = [];
$ranking['interview_counts'] = [];
} else {
$ranking['fan_counts'] = [];
$ranking['rois'] = [];
}
foreach ($topRows as $row) {
$ranking['names'][] = $row['name'];
$ranking['amounts'][] = $row['completed_order_amount'];
$ranking['order_counts'][] = $row['completed_order_count'];
$ranking['fan_counts'][] = $row['add_fans_count'];
$ranking['rois'][] = $row['roi'];
if ($dimension === 'doctor') {
$ranking['appointment_counts'][] = $row['appointment_total_count'];
$ranking['interview_counts'][] = $row['interview_count'];
} else {
$ranking['fan_counts'][] = $row['add_fans_count'];
$ranking['rois'][] = $row['roi'];
}
}
return [
$charts = [
'ranking' => $ranking,
'amount_share' => array_map(
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
),
'fan_share' => array_map(
];
if ($dimension === 'doctor') {
$charts['order_share'] = array_map(
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_count']],
array_filter($topRows, static fn (array $row): bool => (int)$row['completed_order_count'] > 0)
);
} else {
$charts['fan_share'] = array_map(
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
),
];
);
}
return $charts;
}
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
{
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
return self::percent($completedOrderCount, $denominator);
}
private static function percent(int $numerator, int $denominator): float
@@ -0,0 +1,678 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use think\facade\Db;
/**
* 复诊接诊率统计(按月)
*
* 口径说明:
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
* 同一诊单当月被多次指派给同一医助只计 1 次。
* - N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
* - 分档动态产出:N 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;部门筛选(dept_ids,含组织下级)按该归属部门过滤。
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
*/
class RevisitRateLogic
{
/** 至少展示到的复诊序号(二诊/三诊/四诊) */
private const MIN_VISIT_SLOT_CEILING = 4;
/** 复诊序号统计上限(防御脏数据导致列爆炸;诊次跨月累计,上限放宽) */
private const MAX_VISIT_SLOT = 50;
/** 未分配部门的占位分组 */
private const UNASSIGNED_DEPT_NAME = '未分配部门';
/**
* @param array{month?:string,dept_ids?:int[]|string} $params
*
* @return array{
* month:string,start_date:string,end_date:string,
* slots:list<int>,
* total:array<string,int|float|null>,
* rows:list<array<string,mixed>>
* }
*/
public static function overview(array $params): array
{
$ctx = self::buildStatsCore($params);
$month = $ctx['month'];
$minSlots = range(2, self::MIN_VISIT_SLOT_CEILING);
$universe = self::assistantUniverse($ctx);
if ($universe === []) {
return [
'month' => $month,
'start_date' => $ctx['startDate'],
'end_date' => $ctx['endDate'],
'slots' => $minSlots,
'total' => self::buildMetricPack(0, [], $minSlots),
'rows' => [],
];
}
// 分档:2 ~ max(4, 当月命中的最大诊次)
$maxHitSlot = 0;
foreach ($ctx['slotOrdersByAssistant'] as $slotMap) {
foreach ($slotMap as $slot => $_) {
if ((int) $slot > $maxHitSlot) {
$maxHitSlot = (int) $slot;
}
}
}
$slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot));
$nameMap = Db::name('admin')
->whereIn('id', array_keys($universe))
->column('name', 'id');
// 医助行(按部门分组收集)
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
$assistantRowsByDept = [];
/** @var array<int, array<int, true>> $deptDiagSet 部门 => 去重被指派诊单集合 */
$deptDiagSet = [];
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
$deptSlotCounts = [];
/** @var array<int, true> $totalDiagSet */
$totalDiagSet = [];
/** @var array<int, int> $totalSlotCounts */
$totalSlotCounts = [];
foreach ($universe as $aid => $_) {
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
foreach ($diagSet as $did => $_d) {
$deptDiagSet[$deptId][$did] = true;
$totalDiagSet[$did] = true;
}
$slotCounts = [];
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
$cnt = \count($orders);
$slotCounts[$slot] = $cnt;
$deptSlotCounts[$deptId][$slot] = ($deptSlotCounts[$deptId][$slot] ?? 0) + $cnt;
$totalSlotCounts[$slot] = ($totalSlotCounts[$slot] ?? 0) + $cnt;
}
$deptName = $deptId > 0
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
: self::UNASSIGNED_DEPT_NAME;
$assistantRowsByDept[$deptId][] = [
'row_key' => 'a' . $aid,
'is_dept' => 0,
'assistant_id' => (int) $aid,
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
'dept_id' => $deptId,
'dept_name' => $deptName,
] + self::buildMetricPack(\count($diagSet), $slotCounts, $slots);
}
// 部门行 + 子行
$rows = [];
foreach ($assistantRowsByDept as $deptId => $children) {
usort($children, static function (array $a, array $b): int {
if ($a['assigned_count'] !== $b['assigned_count']) {
return $b['assigned_count'] <=> $a['assigned_count'];
}
return strcmp((string) $a['assistant_name'], (string) $b['assistant_name']);
});
$deptName = $deptId > 0
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
: self::UNASSIGNED_DEPT_NAME;
$rows[] = [
'row_key' => 'd' . $deptId,
'is_dept' => 1,
'dept_id' => (int) $deptId,
'dept_name' => $deptName,
'assistant_count' => \count($children),
'children' => $children,
] + self::buildMetricPack(\count($deptDiagSet[$deptId] ?? []), $deptSlotCounts[$deptId] ?? [], $slots);
}
usort($rows, static function (array $a, array $b): int {
if ($a['assigned_count'] !== $b['assigned_count']) {
return $b['assigned_count'] <=> $a['assigned_count'];
}
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
});
return [
'month' => $month,
'start_date' => $ctx['startDate'],
'end_date' => $ctx['endDate'],
'slots' => $slots,
'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots),
'rows' => $rows,
];
}
/**
* 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。
* scopeassistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
*
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
*
* @return array{month:string,count:int,rows:list<array<string,mixed>>}
*/
public static function assignLines(array $params): array
{
$ctx = self::buildStatsCore($params);
$assistantSet = self::applyRowScope($ctx, $params);
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $byDiag */
$byDiag = [];
foreach ($ctx['pairsRaw'] as $p) {
$aid = (int) $p['to_assistant_id'];
$did = (int) $p['diagnosis_id'];
if (!isset($assistantSet[$aid])) {
continue;
}
if (!isset($byDiag[$did])) {
$byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
}
$byDiag[$did]['assistants'][$aid] = true;
$byDiag[$did]['assign_count']++;
$byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']);
}
if ($byDiag === []) {
return ['month' => $ctx['month'], 'count' => 0, 'rows' => []];
}
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
$assistantIds = [];
foreach ($byDiag as $d) {
foreach ($d['assistants'] as $aid => $_) {
$assistantIds[$aid] = true;
}
}
$nameMap = Db::name('admin')
->whereIn('id', array_keys($assistantIds))
->column('name', 'id');
$rows = [];
foreach ($byDiag as $did => $d) {
$names = [];
foreach ($d['assistants'] as $aid => $_) {
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
}
$rows[] = [
'diagnosis_id' => (int) $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'assistant_names' => implode('、', $names),
'assign_count' => (int) $d['assign_count'],
'last_assign_time' => (int) $d['last_time'],
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
];
}
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
return ['month' => $ctx['month'], 'count' => \count($rows), 'rows' => $rows];
}
/**
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
*
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
*
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
*/
public static function visitOrderLines(array $params): array
{
$slot = (int) ($params['slot'] ?? 0);
if ($slot < 2 || $slot > self::MAX_VISIT_SLOT) {
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
}
$ctx = self::buildStatsCore($params);
$assistantSet = self::applyRowScope($ctx, $params);
$orderRows = [];
foreach ($assistantSet as $aid => $_) {
foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $r) {
$r['holder_assistant_id'] = (int) $aid;
$orderRows[] = $r;
}
}
if ($orderRows === []) {
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []];
}
$diagIds = array_values(array_unique(array_map(
static fn (array $r): int => (int) $r['diagnosis_id'],
$orderRows
)));
$diagInfo = self::fetchDiagnosisInfo($diagIds);
$adminIds = [];
foreach ($orderRows as $r) {
if ((int) $r['creator_id'] > 0) {
$adminIds[(int) $r['creator_id']] = true;
}
$adminIds[(int) $r['holder_assistant_id']] = true;
}
$adminNames = $adminIds !== []
? Db::name('admin')->whereIn('id', array_keys($adminIds))->column('name', 'id')
: [];
$rows = [];
foreach ($orderRows as $r) {
$did = (int) $r['diagnosis_id'];
$cid = (int) $r['creator_id'];
$hid = (int) $r['holder_assistant_id'];
$ct = (int) $r['create_time'];
$rows[] = [
'order_id' => (int) $r['id'],
'order_no' => (string) ($r['order_no'] ?? ''),
'diagnosis_id' => $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'amount' => round((float) ($r['amount'] ?? 0), 2),
'create_time' => $ct,
'create_time_text' => $ct > 0 ? date('Y-m-d H:i:s', $ct) : '',
'creator_id' => $cid,
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
'assistant_id' => $hid,
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—',
];
}
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => \count($rows), 'rows' => $rows];
}
/**
* 部门下拉(全量未删除部门,前端组树)。
*
* @return array{rows: list<array{id:int,pid:int,name:string}>}
*/
public static function deptOptions(): array
{
$rows = Db::name('dept')
->whereNull('delete_time')
->field(['id', 'pid', 'name'])
->order('sort', 'desc')
->order('id', 'asc')
->select()
->toArray();
return [
'rows' => array_map(static fn (array $r): array => [
'id' => (int) $r['id'],
'pid' => (int) $r['pid'],
'name' => (string) $r['name'],
], $rows),
];
}
// ─────────────────────────── 内部实现 ───────────────────────────
/**
* 核心统计上下文:
* 1. 全量指派日志(≤ 月末)构建持有时间线;
* 2. 分母:当月非继承指派的「医助 × 诊单」;
* 3. 分子:曾被指派诊单的当月订单按全局序号 ≥2 归属持有医助;
* 4. 应用部门筛选(含组织下级)。
*
* @param array{month?:string,dept_ids?:int[]|string} $params
*
* @return array{
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,create_time:int}>,
* diagsByAssistant:array<int,array<int,true>>,
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
* assistantDept:array<int,int>,
* deptNames:array<int,string>
* }
*/
private static function buildStatsCore(array $params): array
{
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
$startTs = (int) strtotime($month . '-01 00:00:00');
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
$logRows = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', '<=', $endTs)
->where('lg.diagnosis_id', '>', 0)
->field(['lg.id', 'lg.diagnosis_id', 'lg.to_assistant_id', 'lg.create_time', 'lg.is_inherit'])
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
->select()
->toArray();
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序) */
$timeline = [];
$pairsRaw = [];
/** @var array<int, array<int, true>> $diagsByAssistant 分母:医助 => 诊单集合 */
$diagsByAssistant = [];
/** @var array<int, true> $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */
$candidateDiagSet = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$t = (int) ($r['create_time'] ?? 0);
$timeline[$did][] = ['t' => $t, 'to' => $aid];
if ($aid > 0) {
$candidateDiagSet[$did] = true;
if ((int) ($r['is_inherit'] ?? 0) === 0 && $t >= $startTs && $t <= $endTs) {
$pairsRaw[] = ['diagnosis_id' => $did, 'to_assistant_id' => $aid, 'create_time' => $t];
$diagsByAssistant[$aid][$did] = true;
}
}
}
// 分子:曾被指派诊单的当月订单(全局序号 ≥2),归属下单时点的持有医助
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
$slotOrdersByAssistant = [];
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
$orderRows = self::fetchOrderSeqRows(
$chunk,
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
);
$curDid = 0;
$seq = 0;
$ptr = 0;
$holder = 0;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
continue;
}
if ($did !== $curDid) {
$curDid = $did;
$seq = 0;
$ptr = 0;
$holder = 0;
}
$seq++;
$ct = (int) ($r['create_time'] ?? 0);
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
$tl = $timeline[$did] ?? [];
$tlCount = \count($tl);
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
$holder = (int) $tl[$ptr]['to'];
$ptr++;
}
if ($seq < 2 || $seq > self::MAX_VISIT_SLOT) {
continue;
}
if ($ct < $startTs || $ct > $endTs) {
continue;
}
if ($holder > 0) {
$slotOrdersByAssistant[$holder][$seq][] = $r;
}
}
}
// 医助归属部门 + 部门筛选(含组织下级)
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
$deptFilterIds = self::parseDeptIds($params['dept_ids'] ?? null);
if ($deptFilterIds !== []) {
$subtreeSet = self::expandDeptSubtreeSet($deptFilterIds);
foreach ($universeIds as $aid) {
$deptId = (int) ($assistantDept[$aid] ?? 0);
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
unset($diagsByAssistant[$aid], $slotOrdersByAssistant[$aid]);
}
}
}
return [
'month' => $month,
'startDate' => date('Y-m-d', $startTs),
'endDate' => date('Y-m-d', $endTs),
'startTs' => $startTs,
'endTs' => $endTs,
'pairsRaw' => $pairsRaw,
'diagsByAssistant' => $diagsByAssistant,
'slotOrdersByAssistant' => $slotOrdersByAssistant,
'assistantDept' => $assistantDept,
'deptNames' => $deptNames,
];
}
/**
* 统计涉及的医助全集:分母(被指派)∪ 分子(持有成交)。
*
* @param array{diagsByAssistant:array<int,array<int,true>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
*
* @return array<int, true>
*/
private static function assistantUniverse(array $ctx): array
{
$set = [];
foreach (array_keys($ctx['diagsByAssistant']) as $aid) {
$set[(int) $aid] = true;
}
foreach (array_keys($ctx['slotOrdersByAssistant']) as $aid) {
$set[(int) $aid] = true;
}
return $set;
}
/**
* 行级 scopeassistant_id(医助行)优先;其次 dept_id(部门归组行,0=未分配部门);都不传 = 全部(已含部门筛选)。
*
* @return array<int, true> scope 内医助集合
*/
private static function applyRowScope(array $ctx, array $params): array
{
$assistantId = (int) ($params['assistant_id'] ?? 0);
$hasDeptScope = isset($params['dept_id']) && $params['dept_id'] !== '' && $params['dept_id'] !== null;
$deptScopeId = $hasDeptScope ? (int) $params['dept_id'] : -1;
$assistantSet = [];
foreach (self::assistantUniverse($ctx) as $aid => $_) {
if ($assistantId > 0) {
if ((int) $aid === $assistantId) {
$assistantSet[$aid] = true;
}
continue;
}
if ($hasDeptScope) {
if ((int) ($ctx['assistantDept'][$aid] ?? 0) === $deptScopeId) {
$assistantSet[$aid] = true;
}
continue;
}
$assistantSet[$aid] = true;
}
return $assistantSet;
}
/**
* 医助 归属部门 + 部门名称表。
*
* @param list<int> $adminIds
*
* @return array{0: array<int,int>, 1: array<int,string>}
*/
private static function buildAssistantDeptIndex(array $adminIds): array
{
if ($adminIds === []) {
return [[], []];
}
// admin_dept 为 (admin_id, dept_id) 联合主键、无自增 id;取最小 dept_id 作为归属部门保证确定性
$relRows = Db::name('admin_dept')
->whereIn('admin_id', $adminIds)
->order(['admin_id' => 'asc', 'dept_id' => 'asc'])
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$deptNames = Db::name('dept')
->whereNull('delete_time')
->column('name', 'id');
$canonical = [];
foreach ($relRows as $r) {
$aid = (int) ($r['admin_id'] ?? 0);
$deptId = (int) ($r['dept_id'] ?? 0);
if ($aid <= 0 || $deptId <= 0 || isset($canonical[$aid])) {
continue;
}
if (!isset($deptNames[$deptId])) {
continue;
}
$canonical[$aid] = $deptId;
}
$names = [];
foreach ($deptNames as $id => $name) {
$names[(int) $id] = (string) $name;
}
return [$canonical, $names];
}
/**
* @param mixed $raw int[] | 逗号分隔字符串
*
* @return list<int>
*/
private static function parseDeptIds(mixed $raw): array
{
if ($raw === null || $raw === '' || $raw === []) {
return [];
}
$list = \is_array($raw) ? $raw : explode(',', (string) $raw);
return array_values(array_unique(array_filter(
array_map('intval', $list),
static fn (int $v): bool => $v > 0
)));
}
/**
* 选中部门 + 全部组织下级的 id 集合。
*
* @param list<int> $deptIds
*
* @return array<int, true>
*/
private static function expandDeptSubtreeSet(array $deptIds): array
{
$rows = Db::name('dept')
->whereNull('delete_time')
->field(['id', 'pid'])
->select()
->toArray();
$childrenByPid = [];
foreach ($rows as $r) {
$childrenByPid[(int) $r['pid']][] = (int) $r['id'];
}
$set = [];
$queue = $deptIds;
while ($queue !== []) {
$id = (int) array_shift($queue);
if ($id <= 0 || isset($set[$id])) {
continue;
}
$set[$id] = true;
foreach ($childrenByPid[$id] ?? [] as $childId) {
$queue[] = $childId;
}
}
return $set;
}
/**
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
*
* @param list<int> $diagIds
* @param list<string> $fields
*
* @return list<array<string, mixed>>
*/
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
{
$q = Db::name('tcm_prescription_order')
->alias('o')
->whereIn('o.diagnosis_id', $diagIds)
->whereNull('o.delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
return $q
->field($fields)
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
->select()
->toArray();
}
/**
* @param list<int> $diagIds
*
* @return array<int, array{patient_name:string,phone:string}>
*/
private static function fetchDiagnosisInfo(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $diagIds)
->field(['id', 'patient_name', 'phone'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
'phone' => trim((string) ($r['phone'] ?? '')),
];
}
return $out;
}
/**
* @param array<int, int> $slotCounts [slot => n]
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
*
* @return array<string, int|float|null>
*/
private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array
{
$pack = ['assigned_count' => $assigned];
foreach ($slots as $slot) {
$cnt = (int) ($slotCounts[$slot] ?? 0);
$pack['visit' . $slot . '_count'] = $cnt;
$pack['visit' . $slot . '_rate'] = $assigned > 0
? round($cnt / $assigned * 100, 2)
: null;
}
return $pack;
}
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
private static function normalizeMonth(string $month): string
{
$month = trim($month);
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) {
return $month;
}
return date('Y-m');
}
}
@@ -21,7 +21,8 @@ use think\facade\Db;
*
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
* · qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` **to_assistant_id>0** **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` **to_assistant_id>0****剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面「当月被指派总数」完全同口径(见 RevisitRateLogic
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
* - 复诊统计 = **名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
@@ -857,7 +858,7 @@ class YejiStatsLogic
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
$leaderboards = [];
@@ -1777,8 +1778,9 @@ class YejiStatsLogic
}
/**
* 被指派数:统计 `tcm_diagnosis_assign_log`to_assistant_id>0**** **related_po_create_time** 落入区间(须 >0
* 选定字典渠道时 INNER JOIN 诊单以满足挂号 channels EXISTStag 退化口径只过滤日志字段,不强制挂诊单
* 被指派数:统计 `tcm_diagnosis_assign_log`to_assistant_id>0**剔除勾选「继承」的指派 is_inherit=1**
* **指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单
* 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
*
* @param array<int, int> $adminToPrimary
* @param int[]|null $tagDiagIds
@@ -1801,11 +1803,13 @@ class YejiStatsLogic
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->where('lg.related_po_create_time', '>', 0)
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0);
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0)
->where('lg.is_inherit', 0);
if ($channelFilterActive) {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
@@ -1816,8 +1820,6 @@ class YejiStatsLogic
if ($pack === null) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
$query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
@@ -1837,7 +1839,7 @@ class YejiStatsLogic
}
}
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
$query->field(['lg.to_assistant_id', Db::raw('COUNT(DISTINCT lg.diagnosis_id) AS cnt')])
->group('lg.to_assistant_id');
$rows = $query->select()->toArray();
@@ -1869,7 +1871,7 @@ class YejiStatsLogic
}
/**
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间,按 to_assistant_id 汇总(供排行榜)。
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1,按 to_assistant_id 汇总(供排行榜)。
*
* @return array<int, int>
*/
@@ -1883,11 +1885,13 @@ class YejiStatsLogic
?array $dataScopeAdminIds = null,
?array $channelInfo = null
): array {
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->where('lg.related_po_create_time', '>', 0)
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0);
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0)
->where('lg.is_inherit', 0);
if ($channelFilterActive) {
if ($appointmentChannelValues !== []) {
@@ -1899,8 +1903,6 @@ class YejiStatsLogic
if ($pack === null) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
$query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
@@ -1920,7 +1922,7 @@ class YejiStatsLogic
}
}
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
$query->field(['lg.to_assistant_id', Db::raw('COUNT(DISTINCT lg.diagnosis_id) AS cnt')])
->group('lg.to_assistant_id');
$rows = $query->select()->toArray();
@@ -2614,6 +2616,420 @@ class YejiStatsLogic
];
}
/**
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。
*
* @param array{
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
* assistant_id?:int|string,admin_id?:int|string,dept_id?:int|string,
* page?:int|string,page_size?:int|string
* } $params
*
* @return array<string, mixed>
*/
public static function assignLines(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$assistantId = (int) ($params['assistant_id'] ?? $params['admin_id'] ?? 0);
if ($assistantId <= 0 && array_key_exists('dept_id', $params)) {
return self::yejiDeptAssignLinesInner($params, $c);
}
return self::yejiAssistantAssignLinesInner($params, $c, $assistantId);
}
/**
* @param array<string, mixed> $params
* @param array<string, mixed> $c
*
* @return array<string, mixed>
*/
private static function yejiDeptAssignLinesInner(array $params, array $c): array
{
$deptIdParam = (int) ($params['dept_id'] ?? 0);
$channelCode = (string) $c['channelCode'];
$deptName = $deptIdParam === 0
? '未归属中心'
: self::formatYejiDeptRowDisplayName($deptIdParam, $c['deptById']);
$empty = static function (string $note) use ($c, $channelCode, $deptIdParam, $deptName): array {
return self::yejiAssignLinesEmptyPayload($c, $note, 0, '', $deptIdParam, $deptName, $channelCode);
};
if ($deptIdParam <= 0) {
return $empty('未归属中心无被指派明细(看板该行为 0)。');
}
if (!in_array($deptIdParam, $c['tableRowDeptIds'], true)) {
return $empty('该部门不在当前展示部门筛选或数据权限范围内。');
}
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($channelCode !== '' && isset($erCenterDeptSet[$deptIdParam])) {
return $empty('与看板一致:二中心及其组织下级在选定渠道下被指派数计 0,无明细。');
}
$targetAssistantIds = [];
foreach ($c['adminToPrimary'] as $aid => $primary) {
if ((int) $primary === $deptIdParam) {
$targetAssistantIds[] = (int) $aid;
}
}
if ($targetAssistantIds === []) {
return $empty('当前部门下没有可映射的被指派医助,无明细。');
}
return self::yejiAssignLinesFetch($c, $params, $targetAssistantIds, 0, '', $deptIdParam, $deptName);
}
/**
* @param array<string, mixed> $params
* @param array<string, mixed> $c
*
* @return array<string, mixed>
*/
private static function yejiAssistantAssignLinesInner(array $params, array $c, int $assistantId): array
{
$channelCode = (string) $c['channelCode'];
$assistantName = '';
if ($assistantId > 0) {
$assistantName = (string) (Db::name('admin')->where('id', $assistantId)->whereNull('delete_time')->value('name') ?: '');
}
$empty = static function (string $note) use ($c, $assistantId, $assistantName, $channelCode): array {
return self::yejiAssignLinesEmptyPayload($c, $note, $assistantId, $assistantName, 0, '', $channelCode);
};
if ($assistantId <= 0) {
return $empty('请指定有效医助,或传入 dept_id 查看部门汇总明细。');
}
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
if ($dataScopeRestricted && $dataScopeAdminIds !== null) {
$flip = array_flip($dataScopeAdminIds);
if (!isset($flip[$assistantId])) {
return $empty('当前账号数据权限下不可查看该医助被指派明细。');
}
}
if (!isset($c['adminToPrimary'][$assistantId])) {
return $empty('该医助不在当前业绩看板展示范围内。');
}
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
if ($tagDiagIds !== null && $tagDiagIds === []) {
return $empty('当前渠道标签下无关联诊单,无被指派明细。');
}
if ($scopedAssistants !== null && $scopedAssistants === []) {
return $empty('当前渠道标签下无关联医助,无被指派明细。');
}
if ($scopedAssistants !== null && !isset($scopedAssistants[$assistantId])) {
return $empty('当前渠道标签口径下该医助无被指派记录(与排行榜「被指派数」一致)。');
}
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$primaryDept = (int) ($c['adminToPrimary'][$assistantId] ?? 0);
if ($channelCode !== '' && $primaryDept > 0 && isset($erCenterDeptSet[$primaryDept])) {
return $empty('与看板一致:二中心及其组织下级医助在选定渠道下被指派数计 0,无明细。');
}
return self::yejiAssignLinesFetch(
$c,
$params,
[$assistantId],
$assistantId,
$assistantName,
0,
''
);
}
/**
* @param array<string, mixed> $c
* @param array<string, mixed> $params
* @param list<int> $targetAssistantIds
*
* @return array<string, mixed>
*/
private static function yejiAssignLinesFetch(
array $c,
array $params,
array $targetAssistantIds,
int $assistantIdOut,
string $assistantNameOut,
int $deptIdOut,
string $deptNameOut
): array {
$channelCode = (string) $c['channelCode'];
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) {
$visFlip = array_flip($dataScopeAdminIds);
$targetAssistantIds = array_values(array_filter(
$targetAssistantIds,
static fn (int $aid): bool => isset($visFlip[$aid])
));
}
if ($targetAssistantIds === []) {
return self::yejiAssignLinesEmptyPayload(
$c,
'当前数据权限下无可查看的被指派医助。',
$assistantIdOut,
$assistantNameOut,
$deptIdOut,
$deptNameOut,
$channelCode
);
}
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
if ($tagDiagIds !== null && $tagDiagIds === []) {
return self::yejiAssignLinesEmptyPayload(
$c,
'当前渠道标签下无关联诊单,无被指派明细。',
$assistantIdOut,
$assistantNameOut,
$deptIdOut,
$deptNameOut,
$channelCode
);
}
if ($scopedAssistants !== null && $scopedAssistants === []) {
return self::yejiAssignLinesEmptyPayload(
$c,
'当前渠道标签下无关联医助,无被指派明细。',
$assistantIdOut,
$assistantNameOut,
$deptIdOut,
$deptNameOut,
$channelCode
);
}
$buildBaseQuery = static function () use (
$c,
$targetAssistantIds,
$startTs,
$endTs,
$tagDiagIds,
$scopedAssistants
) {
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$q = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0)
->where('lg.is_inherit', 0)
->whereIn('lg.to_assistant_id', $targetAssistantIds);
self::applyYejiAssignLogChannelFilters(
$q,
$tagDiagIds,
$scopedAssistants,
$c['appointmentChannelValues'],
$c['channelFilterActive'],
$c['channelInfo']
);
return $q;
};
$countRows = $buildBaseQuery()
->field(['lg.to_assistant_id', 'lg.diagnosis_id'])
->group('lg.to_assistant_id,lg.diagnosis_id')
->select()
->toArray();
$count = \count($countRows);
$rawList = $buildBaseQuery()
->field([
'lg.to_assistant_id',
'lg.diagnosis_id',
Db::raw('COUNT(*) AS assign_count'),
Db::raw('MAX(lg.create_time) AS last_assign_time'),
])
->group('lg.to_assistant_id,lg.diagnosis_id')
->order('last_assign_time', 'desc')
->order('lg.diagnosis_id', 'desc')
->limit($offset, $limit)
->select()
->toArray();
$diagIds = [];
$assistantIds = [];
foreach ($rawList as $rw) {
$diagIds[(int) ($rw['diagnosis_id'] ?? 0)] = true;
$assistantIds[(int) ($rw['to_assistant_id'] ?? 0)] = true;
}
$diagInfo = self::fetchYejiAssignDiagnosisInfo(array_keys($diagIds));
$nameMap = $assistantIds !== []
? Db::name('admin')->whereIn('id', array_keys($assistantIds))->column('name', 'id')
: [];
$lists = [];
foreach ($rawList as $rw) {
$did = (int) ($rw['diagnosis_id'] ?? 0);
$aid = (int) ($rw['to_assistant_id'] ?? 0);
$lastTs = (int) ($rw['last_assign_time'] ?? 0);
$lists[] = [
'diagnosis_id' => $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'assistant_id' => $aid,
'assistant_name' => $aid > 0 ? (string) ($nameMap[$aid] ?? ('#' . $aid)) : '—',
'assign_count' => (int) ($rw['assign_count'] ?? 0),
'last_assign_time' => $lastTs,
'last_assign_time_text' => $lastTs > 0 ? date('Y-m-d H:i:s', $lastTs) : '',
];
}
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。';
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => $assistantIdOut,
'assistant_name' => $assistantNameOut,
'dept_id' => $deptIdOut,
'dept_name' => $deptNameOut,
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* @param array<string, mixed> $c
*
* @return array<string, mixed>
*/
private static function yejiAssignLinesEmptyPayload(
array $c,
string $note,
int $assistantId,
string $assistantName,
int $deptId,
string $deptName,
string $channelCode
): array {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => $assistantId,
'assistant_name' => $assistantName,
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => $note,
];
}
/**
* @param \think\db\Query $query
*/
private static function applyYejiAssignLogChannelFilters(
$query,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $channelInfo
): void {
if (!$channelFilterActive) {
return;
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$query->whereRaw('0 = 1');
return;
}
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo);
if ($pack === null) {
$query->whereRaw('0 = 1');
return;
}
$query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
$query->whereRaw('0 = 1');
return;
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
$query->whereRaw('0 = 1');
return;
}
if ($tagDiagIds !== null) {
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
}
} else {
$query->whereRaw('0 = 1');
}
}
/**
* @param list<int> $diagIds
*
* @return array<int, array{patient_name:string,phone:string}>
*/
private static function fetchYejiAssignDiagnosisInfo(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $diagIds)
->field(['id', 'patient_name', 'phone'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
'phone' => trim((string) ($r['phone'] ?? '')),
];
}
return $out;
}
/**
* admin 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。
*/
+123 -20
View File
@@ -28,6 +28,7 @@ use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
@@ -73,8 +74,8 @@ class DiagnosisLogic extends BaseLogic
}
}
// 生成患者ID
$params['patient_id'] = self::generatePatientId();
// 新患者先占位 0,写入后 patient_id 与自增 id 对齐
$params['patient_id'] = 0;
// 处理既往史数组
if (isset($params['past_history']) && is_array($params['past_history'])) {
@@ -119,6 +120,7 @@ class DiagnosisLogic extends BaseLogic
}
$model = Diagnosis::create($params);
$model = self::syncPatientIdWithDiagnosisId($model);
// 图片写入 doctor_note 表
if (!empty($newTongueImages) || !empty($newReportFiles)) {
@@ -130,7 +132,7 @@ class DiagnosisLogic extends BaseLogic
}
// 自动为患者创建 TRTC 账号
self::createPatientTrtcAccount($model->id);
self::createPatientTrtcAccount($model->patient_id);
return $model->id;
} catch (\Exception $e) {
@@ -140,16 +142,20 @@ class DiagnosisLogic extends BaseLogic
}
/**
* @notes 生成患者ID
* @return int
* @notes 新患者首张诊单:patient_id 与自增 id 对齐
* @param Diagnosis $model
* @return Diagnosis
*/
private static function generatePatientId(): int
private static function syncPatientIdWithDiagnosisId(Diagnosis $model): Diagnosis
{
// 获取当前最大的患者ID
$maxPatientId = Diagnosis::max('id') ?? 10000000;
// 返回下一个患者ID
return $maxPatientId + 1;
if ((int) $model->patient_id === (int) $model->id) {
return $model;
}
Diagnosis::where('id', $model->id)->update(['patient_id' => $model->id]);
$model->patient_id = (int) $model->id;
return $model;
}
/**
@@ -157,9 +163,23 @@ class DiagnosisLogic extends BaseLogic
* @param array $params
* @return bool
*/
public static function edit(array $params): bool
/** 诊单编辑:患者基本信息字段(姓名/身份证/手机/性别/年龄) */
private const PATIENT_BASIC_FIELDS = ['patient_name', 'id_card', 'phone', 'gender', 'age'];
public static function edit(array $params, array $adminInfo = []): bool
{
try {
if (!empty($params['id']) && $adminInfo !== []) {
$existing = Diagnosis::find((int) $params['id']);
if ($existing && !self::canEditPatientBasicInfo((int) $params['id'], $adminInfo)) {
if (self::patientBasicFieldsChanged($existing, $params)) {
self::setError('最近业务订单未完成,无法修改患者基本信息');
return false;
}
}
}
// 检查手机号是否重复(排除当前记录)
if (!empty($params['phone'])) {
$exists = Diagnosis::where('phone', $params['phone'])
@@ -250,7 +270,7 @@ class DiagnosisLogic extends BaseLogic
* @param $params
* @return array
*/
public static function detail($params): array
public static function detail($params, array $adminInfo = []): array
{
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
@@ -317,7 +337,17 @@ class DiagnosisLogic extends BaseLogic
$diagnosis['local_hospital_visit_date'] = $diagnosis['diagnosis_date'];
}
$diagnosis['is_view'] =DiagnosisViewRecord::where(['diagnosis_id'=> $diagnosis['id'], 'user_id' =>$params['user_id']??0])->find() ? 1 : 0;
$latestPo = self::getLatestPrescriptionOrderRowForDiagnosis((int) ($diagnosis['id'] ?? 0));
$patientBasicLocked = $latestPo !== null && (int) ($latestPo['fulfillment_status'] ?? 0) !== 3;
$diagnosis['patient_basic_locked'] = $patientBasicLocked;
$diagnosis['can_edit_patient_basic'] = self::canEditPatientBasicInfo((int) ($diagnosis['id'] ?? 0), $adminInfo);
$diagnosis['latest_prescription_order'] = $latestPo !== null ? [
'id' => (int) ($latestPo['id'] ?? 0),
'fulfillment_status' => (int) ($latestPo['fulfillment_status'] ?? 0),
'fulfillment_status_text'=> PrescriptionOrderLogic::fulfillmentStatusLabel((int) ($latestPo['fulfillment_status'] ?? 0)),
] : null;
return $diagnosis;
}
@@ -486,11 +516,11 @@ class DiagnosisLogic extends BaseLogic
}
/**
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
* 诊单下「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
*
* @return array{creator_id: int, create_time: int}|null
* @return array<string, mixed>|null
*/
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
private static function getLatestPrescriptionOrderRowForDiagnosis(int $diagnosisId): ?array
{
if ($diagnosisId <= 0) {
return null;
@@ -500,19 +530,88 @@ class DiagnosisLogic extends BaseLogic
->whereNull('delete_time')
->order('create_time', 'desc')
->order('id', 'desc')
->field(['creator_id', 'create_time'])
->field(['id', 'creator_id', 'create_time', 'fulfillment_status'])
->find();
if ($row === null || $row === []) {
return null;
}
return $row;
}
/**
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
*
* @return array{creator_id: int, create_time: int}|null
*/
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
{
$row = self::getLatestPrescriptionOrderRowForDiagnosis($diagnosisId);
if ($row === null) {
return null;
}
return [
'creator_id' => (int) ($row['creator_id'] ?? 0),
'create_time' => (int) ($row['create_time'] ?? 0),
];
}
/**
* 菜单权限 tcm.diagnosis/editPatientBasic:最近业务订单未完成时仍可改患者基本信息
*/
public static function hasEditPatientBasicPermission(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
return in_array('tcm.diagnosis/editPatientBasic', $perms, true);
}
/**
* 是否允许编辑诊单患者基本信息(无业务单 / 最近一单已完成 / 具备 editPatientBasic 权限)
*/
public static function canEditPatientBasicInfo(int $diagnosisId, array $adminInfo): bool
{
$latest = self::getLatestPrescriptionOrderRowForDiagnosis($diagnosisId);
if ($latest === null) {
return true;
}
if ((int) ($latest['fulfillment_status'] ?? 0) === 3) {
return true;
}
return self::hasEditPatientBasicPermission($adminInfo);
}
/**
* @param Diagnosis $existing
*/
private static function patientBasicFieldsChanged($existing, array $params): bool
{
foreach (self::PATIENT_BASIC_FIELDS as $field) {
if (!array_key_exists($field, $params)) {
continue;
}
$old = $existing->{$field};
$new = $params[$field];
if ($field === 'age' || $field === 'gender') {
if ((int) $old !== (int) $new) {
return true;
}
continue;
}
if ((string) $old !== (string) $new) {
return true;
}
}
return false;
}
/**
* 诊单 current assistant_id 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
@@ -3092,8 +3191,9 @@ class DiagnosisLogic extends BaseLogic
unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record
$patientId = $params['patient_id'] ?? 0;
if (!$patientId) {
$params['patient_id'] = self::generatePatientId();
$isNewPatient = !$patientId;
if ($isNewPatient) {
$params['patient_id'] = 0;
}
$params['status'] = 1;
@@ -3123,6 +3223,9 @@ class DiagnosisLogic extends BaseLogic
}
$model = Diagnosis::create($params);
if ($isNewPatient) {
$model = self::syncPatientIdWithDiagnosisId($model);
}
self::createPatientTrtcAccount($model->patient_id);
// 图片写入 doctor_note
@@ -2324,7 +2324,8 @@ class PrescriptionOrderLogic
->where(function ($query) {
$query->where('payment_method', 'manual')
->whereOr('payment_method', 'fubei')
->whereOr('create_type', 'fubei');
->whereOr('create_type', 'fubei')
->whereOr('create_type', 'express_cod');
})
->update([
'status' => 5, // 待审核
@@ -2403,6 +2404,9 @@ class PrescriptionOrderLogic
$amount = round((float) ($params['pay_amount'] ?? 0), 2);
$remark = mb_substr(trim((string) ($params['pay_remark'] ?? '')), 0, 200);
$completionRequest = (int) ($params['completion_request'] ?? 0);
// 创建方式:付呗(默认) 或 快递代收(express_cod)
$payCreateType = (string) ($params['pay_create_type'] ?? 'fubei');
$isExpressCod = $payCreateType === 'express_cod';
if ($amount < 0) {
self::$error = '支付单金额不能为负数';
@@ -2417,8 +2421,13 @@ class PrescriptionOrderLogic
$payOrder->order_type = $orderType;
$payOrder->amount = $amount;
$payOrder->status = 5; // 待审核
$payOrder->payment_method = 'fubei'; // 补齐支付单按「付呗」记账
$payOrder->create_type = 'fubei';
if ($isExpressCod) {
// 快递代收:payment_method 留空,审核通过/到账后再写;用 create_type 标记
$payOrder->create_type = 'express_cod';
} else {
$payOrder->payment_method = 'fubei'; // 补齐支付单按「付呗」记账
$payOrder->create_type = 'fubei';
}
$payOrder->payment_time = null; // 审核通过后再设置支付时间
$payOrder->remark = $remark;
@@ -3152,6 +3161,197 @@ class PrescriptionOrderLogic
return $out;
}
/**
* 导出用:统一解析时间(支持 Unix /毫秒与 Y-m-d H:i:s 字符串,与前端 formatOrderTime 同口径)
*
* @param mixed $value
*/
private static function formatDateTimeForExport($value, string $format = 'Y-m-d H:i'): string
{
if ($value === null || $value === '' || $value === false) {
return '';
}
if (\is_int($value) || \is_float($value)) {
$ts = (int) $value;
if ($ts > 9999999999) {
$ts = (int) floor($ts / 1000);
}
return $ts > 0 ? date($format, $ts) : '';
}
$str = trim((string) $value);
if ($str === '') {
return '';
}
if (preg_match('/^\d+$/', $str)) {
$ts = (int) $str;
if ($ts > 9999999999) {
$ts = (int) floor($ts / 1000);
}
return $ts > 0 ? date($format, $ts) : '';
}
if (str_contains($str, '-') || str_contains($str, '/')) {
$ts = strtotime($str);
return $ts !== false ? date($format, $ts) : $str;
}
return $str;
}
/**
* 导出用:支付单来源/方式(与前端 formatPayOrderSource 同口径)
*
* @param array<string, mixed> $row
*/
private static function formatPayOrderSourceForExport(array $row): string
{
$createType = trim((string) ($row['create_type'] ?? ''));
if ($createType === 'wechat_work') {
return '企业微信对外收款';
}
if ($createType === 'fubei') {
return '付呗';
}
if ($createType === 'express_cod') {
return '快递代收';
}
$paymentMethod = trim((string) ($row['payment_method'] ?? ''));
$methodMap = [
'alipay' => '支付宝',
'wechat' => '微信',
'wechat_work' => '企业微信',
'fubei' => '付呗',
'express_cod' => '快递代收',
'manual' => '手动确认到账',
];
if ($paymentMethod !== '' && isset($methodMap[$paymentMethod])) {
return $methodMap[$paymentMethod];
}
return '普通订单';
}
/**
* 导出用:单条关联收款可读文本(两行结构,便于 Excel 自动换行阅读)
*
* @param array<string, mixed> $pay
* @param array<int|string, string> $creatorNames
*/
private static function formatLinkedPayRecordLineForExport(array $pay, array $creatorNames, int $index = 1): string
{
$typeMap = [
1 => '挂号费',
2 => '问诊费',
3 => '药品费用',
4 => '首付费用',
5 => '尾款费用',
6 => '其他费用',
7 => '全部费用',
8 => '驼奶费用',
];
$statusMap = [
1 => '待支付',
2 => '已支付',
3 => '已取消',
4 => '已退款',
5 => '待审核',
];
$orderNo = trim((string) ($pay['order_no'] ?? ''));
$amount = number_format(round((float) ($pay['amount'] ?? 0), 2), 2, '.', '');
$typeDesc = $typeMap[(int) ($pay['order_type'] ?? 0)] ?? '—';
$statusDesc = $statusMap[(int) ($pay['status'] ?? 0)] ?? '—';
$source = self::formatPayOrderSourceForExport($pay);
$cid = (int) ($pay['creator_id'] ?? 0);
$creator = $cid > 0 ? (string) ($creatorNames[$cid] ?? '') : '—';
$timeStr = self::formatDateTimeForExport($pay['create_time'] ?? '') ?: '—';
$line1 = sprintf('[%d] %s ¥%s %s %s', $index, $orderNo, $amount, $typeDesc, $statusDesc);
$line2 = sprintf(' %s %s %s', $source, $creator, $timeStr);
return $line1 . "\n" . $line2;
}
/**
* 导出用:批量解析业务订单关联收款记录(与详情 linked_pay_orders 同口径:status {2,4,5}
*
* @param int[] $poIds
*
* @return array<int, string> prescription_order_id => 多笔以空行分隔的可读文本
*/
private static function batchLinkedPayRecordsExportTextByPoIds(array $poIds): array
{
$poIds = array_values(array_filter(array_unique(array_map('intval', $poIds)), static fn (int $id): bool => $id > 0));
if ($poIds === []) {
return [];
}
$linksByPo = [];
$allPayIds = [];
$linkRows = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $poIds)
->field(['prescription_order_id', 'pay_order_id'])
->order('id', 'asc')
->select()
->toArray();
foreach ($linkRows as $l) {
$poid = (int) ($l['prescription_order_id'] ?? 0);
$payId = (int) ($l['pay_order_id'] ?? 0);
if ($poid <= 0 || $payId <= 0) {
continue;
}
$linksByPo[$poid][] = $payId;
$allPayIds[$payId] = true;
}
$out = [];
foreach ($poIds as $poid) {
$out[$poid] = '';
}
if ($allPayIds === []) {
return $out;
}
$payRows = Order::whereIn('id', array_keys($allPayIds))
->whereNull('delete_time')
->whereIn('status', [2, 4, 5])
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'payment_method', 'create_type'])
->select()
->toArray();
$payById = [];
$creatorIds = [];
foreach ($payRows as $r) {
$id = (int) ($r['id'] ?? 0);
if ($id <= 0) {
continue;
}
$payById[$id] = $r;
$cid = (int) ($r['creator_id'] ?? 0);
if ($cid > 0) {
$creatorIds[$cid] = true;
}
}
$creatorNames = $creatorIds !== []
? Admin::whereIn('id', array_keys($creatorIds))->column('name', 'id')
: [];
foreach ($poIds as $poid) {
$lines = [];
$seq = 0;
foreach ($linksByPo[$poid] ?? [] as $payId) {
$pay = $payById[$payId] ?? null;
if (!\is_array($pay)) {
continue;
}
++$seq;
$lines[] = self::formatLinkedPayRecordLineForExport($pay, $creatorNames, $seq);
}
$out[$poid] = implode("\n\n", $lines);
}
return $out;
}
/**
* 业务订单列表导出:写入与 PrescriptionOrderLists::setExcelFields 对应的字段(export=2 时由列表类调用)
* 「挂号渠道来源」取值与详情/列表解析一致:处方 appointment_id 否则诊单下 MAX(挂号.id);业绩抽屉带渠道时与列表同源高亮。
@@ -3383,6 +3583,7 @@ class PrescriptionOrderLogic
$firstVisitAssistantByDiag = self::batchFirstVisitAssistantNameByDiagnosis($diagIdList, $diagById);
$assistantDeptCache = [];
$linkedPayExportByPo = self::batchLinkedPayRecordsExportTextByPoIds($poIds);
foreach ($lists as &$item) {
$poId = (int) ($item['id'] ?? 0);
@@ -3455,6 +3656,7 @@ class PrescriptionOrderLogic
$paid = round((float) ($item['linked_pay_paid_total'] ?? 0), 2);
$item['export_amount'] = number_format($amt, 2, '.', '');
$item['export_paid_amount'] = number_format($paid, 2, '.', '');
$item['export_linked_pay_records'] = $linkedPayExportByPo[$poId] ?? '';
$refundStored = round((float) ($item['refund_amount'] ?? 0), 2);
$item['export_refund_amount'] = $refundStored > 0
? number_format($refundStored, 2, '.', '')
@@ -4207,6 +4409,112 @@ class PrescriptionOrderLogic
return true;
}
/**
* 批量将处方业务订单「创建人(医助归属)」改派给其他医助。
* 仅改写 creator_id(业务归属/医助筛选口径),逐单记操作日志;与支付单批量改派口径一致。
*
* @param int[] $orderIds
* @param int $assistantAdminId 目标医助 admin_id(须含医助角色)
* @return array{success:int, fail:int, errors:string[], per_log:array<int,array{order_id:int,summary:string}>}|false
*/
public static function batchAssignAssistant(array $orderIds, int $assistantAdminId, int $adminId, array $adminInfo)
{
self::$error = '';
$assistantAdminId = (int) $assistantAdminId;
if ($assistantAdminId <= 0) {
self::$error = '请选择医助';
return false;
}
// 目标须为医助角色(与支付单批量改派一致)
$assistantRoleId = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
if (
\app\common\model\auth\AdminRole::where('admin_id', $assistantAdminId)
->where('role_id', $assistantRoleId)
->count() === 0
) {
self::$error = '目标账号不是医助角色';
return false;
}
$ids = array_values(array_unique(array_filter(
array_map('intval', $orderIds),
static fn (int $id) => $id > 0
)));
if ($ids === []) {
self::$error = '请选择订单';
return false;
}
if (count($ids) > 200) {
self::$error = '单次最多改派200单';
return false;
}
$targetName = (string) Admin::where('id', $assistantAdminId)->whereNull('delete_time')->value('name');
if ($targetName === '') {
$targetName = (string) $assistantAdminId;
}
$perLog = [];
$errors = [];
$success = 0;
foreach ($ids as $oid) {
$order = PrescriptionOrder::where('id', $oid)->whereNull('delete_time')->find();
if (! $order) {
$errors[] = "订单{$oid}不存在或已删除";
continue;
}
$label = (string) ($order->order_no ?: $oid);
if (! self::canAccessOrder($order, $adminId, $adminInfo)) {
$errors[] = "订单{$label}无操作权限";
continue;
}
$oldCreatorId = (int) $order->creator_id;
if ($oldCreatorId === $assistantAdminId) {
$errors[] = "订单{$label}创建人已是该医助,已跳过";
continue;
}
$oldName = $oldCreatorId > 0
? (string) Admin::where('id', $oldCreatorId)->whereNull('delete_time')->value('name')
: '';
if ($oldName === '' && $oldCreatorId > 0) {
$oldName = (string) $oldCreatorId;
} elseif ($oldName === '') {
$oldName = '—';
}
$order->creator_id = $assistantAdminId;
try {
$order->save();
} catch (\Throwable $e) {
$errors[] = "订单{$label}保存失败";
continue;
}
$summary = '创建人(医助)由「' . $oldName . '」改派为「' . $targetName . '」';
self::writeLog($oid, $adminId, $adminInfo, 'assign_assistant', $summary);
$perLog[] = ['order_id' => $oid, 'summary' => $summary];
$success++;
}
if ($success === 0) {
self::$error = $errors !== [] ? implode('', $errors) : '未能改派任何订单';
return false;
}
return [
'success' => $success,
'fail' => count($errors),
'errors' => $errors,
'per_log' => $perLog,
];
}
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
{
$adminName = $adminInfo['name'] ?? '';
@@ -32,6 +32,7 @@ class DiagnosisValidate extends BaseValidate
'gender' => 'require|in:0,1',
'age' => 'require|number|between:0,150',
'diagnosis_type' => 'require',
'local_hospital_name' => 'require|max:255',
'status' => 'in:0,1',
'show_card' => 'in:0,1',
'create_source' => 'max:32',
@@ -50,6 +51,7 @@ class DiagnosisValidate extends BaseValidate
'patient_name.require' => '请输入患者姓名',
'patient_name.length' => '患者姓名长度须在1-50位字符',
'id_card.length' => '身份证号长度不正确',
'id_card.require' => '请输入身份证号',
'phone.require' => '请输入手机号',
'phone.mobile' => '手机号格式不正确',
'gender.require' => '请选择性别',
@@ -58,6 +60,8 @@ class DiagnosisValidate extends BaseValidate
'age.number' => '年龄必须为数字',
'age.between' => '年龄范围0-150',
'diagnosis_type.require' => '请选择诊断类型',
'local_hospital_name.require' => '请输入当地就诊医院名称',
'local_hospital_name.max' => '当地就诊医院名称最多255个字符',
'status.in' => '状态参数错误',
'create_source.max' => '渠道来源长度不能超过32个字符',
'current_medications.max' => '在用药物最多2000个字符',
@@ -91,6 +91,6 @@ class PrescriptionOrderValidate extends BaseValidate
{
return $this->only(['id', 'amount'])
->append('id', 'require|integer|gt:0')
->append('amount', 'require|float|gt:0');
->append('amount', 'require|float|egt:0');
}
}
@@ -1 +1 @@
import r from"./error-3-P5389h.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Ds3qCU_G.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-D49jsFPW.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-CYLw0J2V.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
import r from"./error-BUBKvVs4.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-B6p-ZV3k.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
@@ -1 +1 @@
import o from"./error-3-P5389h.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Ds3qCU_G.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-D49jsFPW.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-CYLw0J2V.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
import o from"./error-BUBKvVs4.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-B6p-ZV3k.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
View File
View File
View File
View File
View File
View File

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