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
615 changed files with 4879 additions and 1423 deletions
@@ -1,11 +1,14 @@
<template> <template>
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]"> <view
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text> 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 <image
v-if="src && !imageFailed" v-if="src && !imageFailed"
class="food-tile-icon__img" class="food-tile-icon__img"
:src="src" :src="src"
mode="aspectFit" :mode="imageMode"
@error="onImageError" @error="onImageError"
/> />
</view> </view>
@@ -27,12 +30,20 @@ const fallbackIcon = computed(() => {
return '🍽' return '🍽'
}) })
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
const src = computed(() => { const src = computed(() => {
if (typeof props.food === 'object' && props.food?.img) return props.food.img 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) if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
return '' return ''
}) })
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
watch( watch(
() => src.value, () => src.value,
() => { () => {
@@ -71,6 +82,11 @@ function onImageError() {
z-index: 2; 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 { .food-tile-icon--tile .food-tile-icon__img {
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16)); filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
@@ -109,6 +125,11 @@ function onImageError() {
font-size: 34rpx; 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 { .food-tile-icon__img {
position: absolute; position: absolute;
left: 0; 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 const MIN_HOLD_MS = 280
/** 短按/过早松手时插件常见错误,不应打扰用户 */
function isBenignSttError(msg, retcode) {
const m = String(msg || '').toLowerCase()
if (/internal voice data failed/i.test(m)) return true
if (/voice data failed/i.test(m)) return true
if (/recordfailed|record failed|record manager/i.test(m)) return true
if (/please stop after start/i.test(m)) return true
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
const code = Number(retcode)
if (code === -30002 || code === -30003 || code === -30012) return true
return false
}
function friendlySttError(msg) {
const m = String(msg || '').trim()
if (!m || isBenignSttError(m)) return ''
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
return '语音识别失败,请重试'
}
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次, // WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后, // 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
@@ -112,7 +133,8 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
function stopListening() { function stopListening() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
const ownsRecording = sharedRecording && activeCtl === controller const ownsRecording = sharedRecording && activeCtl === controller
if (wxRecordManager && (sttListening.value || startRequested || ownsRecording)) { // 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
if (wxRecordManager && (sttListening.value || ownsRecording)) {
try { try {
wxRecordManager.stop() wxRecordManager.stop()
} catch (e) {} } catch (e) {}
@@ -128,6 +150,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif // #endif
startRequested = false startRequested = false
sttHolding.value = false
sttListening.value = false sttListening.value = false
} }
@@ -171,7 +194,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
} }
try { try {
startRequested = true startRequested = true
sharedRecording = true
wxRecordManager.start({ wxRecordManager.start({
duration: 60000, duration: 60000,
lang: 'zh_CN' lang: 'zh_CN'
@@ -251,22 +273,28 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif // #endif
} }
/** 松手结束:手指抬起 */ /** 松手结束:手指抬起force=true 时强制取消尚未真正开始的按住会话 */
function endHoldSpeech() { function endHoldSpeech(force = false) {
let recording = false let recording = false
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
recording = sharedRecording && activeCtl === controller recording = sharedRecording && activeCtl === controller
// #endif // #endif
if (!sttHolding.value && !sttListening.value && !startRequested && !recording) { if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
return return
} }
sttHolding.value = false sttHolding.value = false
// 松手后不再补开排队的录音 // 松手后不再补开排队的录音
pendingStartSession = 0 pendingStartSession = 0
if (sttListening.value || startRequested || recording) { if (sttListening.value || recording) {
stopListening() stopListening()
return return
} }
if (startRequested) {
// start 已发出但 onStart 未到:取消会话,勿调 stop()
startRequested = false
holdSessionId += 1
return
}
holdSessionId += 1 holdSessionId += 1
} }
@@ -276,9 +304,9 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
const controller = { const controller = {
handleStart() { handleStart() {
if (!sttHolding.value) { if (!sttHolding.value) {
try { // 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
wxRecordManager.stop() startRequested = false
} catch (e) {} sttListening.value = false
return return
} }
startRequested = false startRequested = false
@@ -306,6 +334,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
sttListening.value = false sttListening.value = false
startRequested = false startRequested = false
const msg = (res && res.msg) || '' const msg = (res && res.msg) || ''
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户 // 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
if (/please stop after start/i.test(msg)) { if (/please stop after start/i.test(msg)) {
try { try {
@@ -316,12 +345,18 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
} }
pendingStartSession = 0 pendingStartSession = 0
sttHolding.value = false sttHolding.value = false
const heldMs = Date.now() - holdStartTs
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
if (typeof onError === 'function') onError(msg || '')
return
}
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示 // 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
if (typeof onError === 'function') { if (typeof onError === 'function') {
const handled = onError(msg || '') const handled = onError(msg || '')
if (handled === true) return if (handled === true) return
} }
notify(msg || '语音识别失败') const tip = friendlySttError(msg)
if (tip) notify(tip)
} }
} }
@@ -411,3 +446,5 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
stopSpeechToText: endHoldSpeech stopSpeechToText: endHoldSpeech
} }
} }
export { isBenignSttError, friendlySttError }
+20 -4
View File
@@ -63,7 +63,15 @@ const FOOD_TIP_OVERRIDE = {
grainMantou: '杂粮做的,比白馒头稳,仍要控量', grainMantou: '杂粮做的,比白馒头稳,仍要控量',
riceNoodleSoup: '米粉升糖快,汤粉要少吃', riceNoodleSoup: '米粉升糖快,汤粉要少吃',
friedNoodles: '油多又是精面,升糖快', friedNoodles: '油多又是精面,升糖快',
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝' centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
sandwich: '夹心面包精制碳多,升糖快',
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
milletCongee: '小米粥熬软升糖快,糖友控量',
shandongPancake: '煎饼加油面,升糖中等偏快',
cake: '蛋糕又甜又油,升糖快',
biscuit: '饼干又甜又碎,升糖快',
wonton: '馄饨面皮精白,升糖中等,别多吃',
blackCoffee: '不加糖的黑咖啡,升糖很低'
} }
/** 含糖等级通用科普文案 */ /** 含糖等级通用科普文案 */
@@ -109,7 +117,7 @@ const RAW_FOODS = [
// 中 GI // 中 GI
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' }, { 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: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' }, { key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' }, { 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: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' }, { key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' }, { 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: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' }, { 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: '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` }, { 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 // 高 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: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' }, { 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: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' }, { key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' }, { key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
+19 -2
View File
@@ -4,10 +4,27 @@
*/ */
/** /**
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。 * 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
* 第一关固定为这 5 种带真实图片素材的食物。 * 第一关固定为这 16 种带真实图片素材的食物。
*/ */
const FIXED_LEVEL_FOODS = { 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) { 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 { .more-page .mc-tasks-section {
margin-top: 40rpx; margin-top: 0;
margin-bottom: 32rpx;
} }
.more-page .mc-tasks-grid { .more-page .mc-tasks-grid {
@@ -1167,3 +1168,259 @@
font-size: 22rpx; font-size: 22rpx;
color: rgba(108, 122, 113, 0.6); color: rgba(108, 122, 113, 0.6);
} }
.more-page .mc-tree-species-btn {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-left: 8rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(0, 108, 73, 0.08);
font-size: 20rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel,
.more-page .mc-species-panel {
width: calc(100% - 48rpx);
max-width: 680rpx;
margin: 0 auto;
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 78vh;
background: var(--surface);
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
}
.more-page .mc-water-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-title {
font-size: 34rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-water-panel-close {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: var(--surface-container-low);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: var(--outline);
}
.more-page .mc-water-panel-summary {
display: block;
font-size: 26rpx;
line-height: 1.5;
color: var(--on-surface-variant);
margin-bottom: 24rpx;
}
.more-page .mc-water-panel-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 18rpx 16rpx;
border-radius: 20rpx;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.08);
}
.more-page .mc-water-panel-row.is-water {
border-color: rgba(0, 108, 73, 0.22);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-water-panel-row.is-claimed {
opacity: 0.72;
}
.more-page .mc-water-panel-row-main {
flex: 1;
min-width: 0;
}
.more-page .mc-water-panel-row-name {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.more-page .mc-water-panel-row-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-water-panel-row-tag {
flex-shrink: 0;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
color: #b45309;
}
.more-page .mc-water-panel-growth {
padding: 16rpx 18rpx;
border-radius: 16rpx;
background: rgba(0, 108, 73, 0.06);
margin-bottom: 20rpx;
font-size: 24rpx;
line-height: 1.45;
color: var(--primary);
}
.more-page .mc-water-panel-actions {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.more-page .mc-water-panel-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
background: var(--on-surface);
color: var(--surface);
border: none;
}
.more-page .mc-water-panel-btn.primary {
background: var(--primary);
color: var(--on-primary);
}
.more-page .mc-water-panel-btn.ghost {
background: var(--surface-container-low);
color: var(--on-surface);
}
.more-page .mc-species-panel-tip {
display: block;
font-size: 22rpx;
line-height: 1.45;
color: var(--outline);
margin-bottom: 20rpx;
}
.more-page .mc-species-scroll {
max-height: 56vh;
}
.more-page .mc-species-card {
padding: 20rpx;
border-radius: 24rpx;
border: 2rpx solid rgba(0, 108, 73, 0.1);
background: var(--surface-container-low);
margin-bottom: 16rpx;
}
.more-page .mc-species-card.active {
border-color: var(--primary);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-species-card-head {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.more-page .mc-species-card-emoji {
font-size: 56rpx;
line-height: 1;
}
.more-page .mc-species-card-name {
display: block;
font-size: 30rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-species-card-tagline {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-species-stages {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.more-page .mc-species-stage {
width: calc(20% - 8rpx);
min-width: 96rpx;
padding: 10rpx 6rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.7);
text-align: center;
opacity: 0.45;
}
.more-page .mc-species-stage.reached {
opacity: 1;
}
.more-page .mc-species-stage.current {
box-shadow: 0 0 0 2rpx var(--primary);
background: rgba(0, 108, 73, 0.1);
}
.more-page .mc-species-stage-emoji {
display: block;
font-size: 32rpx;
line-height: 1.1;
}
.more-page .mc-species-stage-lv {
display: block;
font-size: 18rpx;
font-weight: 700;
color: var(--primary);
margin-top: 4rpx;
}
.more-page .mc-species-stage-name {
display: block;
font-size: 18rpx;
color: var(--outline);
margin-top: 2rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -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-dasharray: 90, 150;
stroke-dashoffset: 0; stroke-dashoffset: 0;
stroke-width: 2; stroke-width: 2;
stroke: #4073fa; stroke: #06b6d4;
stroke-linecap: round; 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)' : ''}`)
+5
View File
@@ -446,6 +446,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params }) return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
} }
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
}
/** 撤回支付单审核 */ /** 撤回支付单审核 */
export function prescriptionOrderRevokePayAudit(params: { id: number }) { export function prescriptionOrderRevokePayAudit(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params }) return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
@@ -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"> <style scoped lang="scss">
.footer-btns { .footer-btns {
height: 60px; height: 64px;
&__content { &__content {
bottom: 0; bottom: 0;
height: 60px; height: 64px;
right: 0; right: 0;
left: 0; left: 0;
z-index: 99; 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> </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 = { const defaultSetting = {
showCrumb: true, // 是否显示面包屑 showCrumb: false,
showLogo: false, // 是否显示logo showLogo: true,
isUniqueOpened: true, //只展开一个一级菜单 isUniqueOpened: true,
sideWidth: 183, //侧边栏宽度 sideWidth: 248,
sideTheme: 'dark', //侧边栏主题 sideTheme: 'dark',
sideDarkColor: '#1d2124', //侧边栏深色主题颜色 sideDarkColor: '#060d18',
openMultipleTabs: true, // 是否开启多标签tab栏 openMultipleTabs: true,
theme: '#4A5DFF', //主题色 theme: '#06b6d4',
successTheme: '#67c23a', //成功主题色 successTheme: '#10b981',
warningTheme: '#e6a23c', //警告主题色 warningTheme: '#f59e0b',
dangerTheme: '#f56c6c', //危险主题色 dangerTheme: '#ef4444',
errorTheme: '#f56c6c', //错误主题色 errorTheme: '#ef4444',
infoTheme: '#909399' //信息主题色 infoTheme: '#6366f1'
} }
/** 本地 setting 缓存结构版本。提升后仅对低于该版本的老缓存执行 SETTING_SCHEMA_MIGRATIONS */ export const SETTING_SCHEMA_VERSION = 6
export const SETTING_SCHEMA_VERSION = 1
/**
* 按版本写入 defaultSetting 中的键(老用户 localStorage 会长期盖住 config 默认值)。
* 以后若要再推一批新默认值:把 SETTING_SCHEMA_VERSION +1,并为本版本追加一条迁移键列表。
*/
export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = { export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = {
1: ['sideTheme', 'sideDarkColor'] 1: ['sideTheme', 'sideDarkColor'],
2: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor', 'sideWidth', 'showLogo'],
3: ['theme', 'sideDarkColor', 'sideWidth', 'showCrumb', 'showLogo'],
4: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor'],
5: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor'],
6: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor', 'sideWidth']
} }
export default defaultSetting export default defaultSetting
@@ -1,5 +1,5 @@
<template> <template>
<el-breadcrumb class="app-breadcrumb"> <el-breadcrumb class="app-breadcrumb" separator="/">
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path"> <el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
{{ item.meta.title }} {{ item.meta.title }}
</el-breadcrumb-item> </el-breadcrumb-item>
@@ -21,22 +21,24 @@ useWatchRoute((route) => {
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.app-breadcrumb { .app-breadcrumb {
:deep(.el-breadcrumb__item) { :deep(.el-breadcrumb__item) {
.el-breadcrumb__inner { .el-breadcrumb__inner {
color: #303133; color: var(--el-text-color-secondary);
font-weight: 500; font-weight: 500;
font-size: var(--el-font-size-small);
} }
&:last-child .el-breadcrumb__inner { &:last-child .el-breadcrumb__inner {
color: #303133; color: var(--el-text-color-primary);
font-weight: 600;
} }
} }
:deep(.el-breadcrumb__separator) { :deep(.el-breadcrumb__separator) {
color: #606266; color: var(--el-text-color-placeholder);
font-weight: 400;
} }
} }
</style> </style>
@@ -1,7 +1,7 @@
<template> <template>
<header class="header"> <header class="header">
<div class="navbar"> <div class="navbar">
<div class="flex-1 flex"> <div class="flex-1 flex items-center gap-1 min-w-0">
<div class="navbar-item"> <div class="navbar-item">
<el-tooltip <el-tooltip
class="box-item" class="box-item"
@@ -17,11 +17,14 @@
<refresh /> <refresh />
</el-tooltip> </el-tooltip>
</div> </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 /> <breadcrumb />
</div> </div>
</div> </div>
<div class="flex"> <div class="flex items-center gap-1">
<div class="navbar-item" v-if="!isMobile"> <div class="navbar-item" v-if="!isMobile">
<el-tooltip <el-tooltip
class="box-item" class="box-item"
@@ -36,12 +39,7 @@
<user-drop-down /> <user-drop-down />
</div> </div>
<div class="navbar-item"> <div class="navbar-item">
<el-tooltip <el-tooltip class="box-item" effect="dark" content="主题设置" placement="bottom">
class="box-item"
effect="dark"
content="主题设置"
placement="bottom"
>
<setting /> <setting />
</el-tooltip> </el-tooltip>
</div> </div>
@@ -52,8 +50,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RouteLocationMatched } from 'vue-router'
import { useFullscreen } from '@vueuse/core' import { useFullscreen } from '@vueuse/core'
import { useWatchRoute } from '@/hooks/useWatchRoute'
import useAppStore from '@/stores/modules/app' import useAppStore from '@/stores/modules/app'
import useSettingStore from '@/stores/modules/setting' import useSettingStore from '@/stores/modules/setting'
@@ -70,14 +70,20 @@ const isMobile = computed(() => appStore.isMobile)
const isCollapsed = computed(() => appStore.isCollapsed) const isCollapsed = computed(() => appStore.isCollapsed)
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { isFullscreen } = useFullscreen() const { isFullscreen } = useFullscreen()
const breadcrumbs = ref<RouteLocationMatched[]>([])
useWatchRoute((route) => {
breadcrumbs.value = route.matched.filter((item) => item.meta && item.meta.title)
})
</script> </script>
<style lang="scss"> <style lang="scss">
.navbar { .navbar {
height: var(--navbar-height); height: var(--navbar-height);
@apply flex px-2 bg-body; @apply flex px-3 bg-body;
.navbar-item { .navbar-item {
@apply h-full flex justify-center items-center hover:bg-page; @apply h-full flex justify-center items-center;
} }
} }
</style> </style>
@@ -1,6 +1,6 @@
<template> <template>
<div class="app-tabs pl-4 flex bg-body"> <div class="app-tabs flex bg-body">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0 pl-3">
<el-tabs <el-tabs
:model-value="currentTab" :model-value="currentTab"
:closable="tabsLists.length > 1" :closable="tabsLists.length > 1"
@@ -13,7 +13,7 @@
</el-tabs> </el-tabs>
</div> </div>
<el-dropdown @command="handleCommand"> <el-dropdown @command="handleCommand">
<span class="flex items-center px-3"> <span class="tabs-more-btn flex items-center px-3">
<icon :size="16" name="el-icon-arrow-down" /> <icon :size="16" name="el-icon-arrow-down" />
</span> </span>
<template #dropdown> <template #dropdown>
@@ -60,61 +60,84 @@ const handleCommand = (command: any) => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.app-tabs { .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) { :deep(.el-tabs) {
height: 40px; height: var(--tabs-height);
.el-tabs { .el-tabs {
&__header { &__header {
margin-bottom: 0; margin-bottom: 0;
} }
&__content { &__content {
display: none; display: none;
} }
&__nav-next, &__nav-next,
&__nav-prev { &__nav-prev {
@apply text-xl; @apply text-lg;
} }
&__nav-wrap::after { &__nav-wrap::after {
height: 0; height: 0;
} }
&__item { &__item {
font-weight: normal; font-weight: 600;
padding: 0 15px !important; 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; box-sizing: border-box;
color: var(--el-text-color-secondary);
transition: color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
color: var(--admin-brand-primary-dark);
}
&.is-active { &.is-active {
color: var(--el-text-color-primary); color: var(--admin-brand-primary-dark);
background-color: var(--el-color-primary-light-9); background: var(--el-bg-color);
&::before { border: 1px solid var(--el-border-color-lighter);
content: ''; border-bottom-color: transparent;
display: inline-block; box-shadow: var(--el-box-shadow-lighter);
width: 6px;
height: 6px; &::before,
background-color: var(--el-color-primary);
margin-right: 6px;
border-radius: 50%;
vertical-align: 2px;
}
&::after { &::after {
position: absolute; display: none;
content: '';
display: block;
top: 0;
height: 2px;
left: 0;
width: 100%;
background-color: var(--el-color-primary);
} }
} }
.is-icon-close { .is-icon-close {
color: var(--el-text-color-regular); color: var(--el-text-color-placeholder);
vertical-align: -2px; vertical-align: -2px;
border-radius: var(--admin-radius-sm);
&:hover { &:hover {
color: var(--color-white); color: var(--color-white);
background-color: var(--el-color-danger); background-color: var(--el-color-danger);
} }
} }
} }
&__active-bar { &__active-bar {
display: none; display: none;
} }
@@ -1,9 +1,11 @@
<template> <template>
<el-dropdown class="px-2" @command="handleCommand"> <el-dropdown class="user-dropdown px-1" @command="handleCommand">
<div class="flex items-center"> <div class="user-trigger flex items-center gap-2.5 px-2 py-1 rounded-md cursor-pointer">
<el-avatar :size="34" :src="userInfo.avatar" /> <el-avatar :size="32" :src="userInfo.avatar" />
<div class="ml-3 mr-1">{{ userInfo.name }}</div> <span class="user-name max-w-[120px] truncate text-sm font-medium text-tx-primary">{{
<icon name="el-icon-ArrowDown" /> userInfo.name
}}</span>
<icon name="el-icon-ArrowDown" :size="14" />
</div> </div>
<template #dropdown> <template #dropdown>
@@ -41,3 +43,13 @@ const handleCommand = async (command: string) => {
} }
} }
</script> </script>
<style scoped lang="scss">
.user-trigger {
transition: background-color 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
}
}
</style>
+37 -8
View File
@@ -1,27 +1,56 @@
<template> <template>
<main class="main-wrap h-full bg-page">
<main class="main-wrap h-full">
<el-scrollbar> <el-scrollbar>
<div class="px-2 py-4">
<router-view v-if="isRouteShow" v-slot="{ Component, route }"> <div class="main-stage">
<keep-alive :include="includeList" :max="20">
<component :is="Component" :key="route.fullPath" /> <page-shell v-if="isRouteShow">
</keep-alive>
</router-view> <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> </div>
</el-scrollbar> </el-scrollbar>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import PageShell from '@/components/page-shell/index.vue'
import useAppStore from '@/stores/modules/app' import useAppStore from '@/stores/modules/app'
import useTabsStore from '@/stores/modules/multipleTabs' import useTabsStore from '@/stores/modules/multipleTabs'
import useSettingStore from '@/stores/modules/setting' import useSettingStore from '@/stores/modules/setting'
const appStore = useAppStore() const appStore = useAppStore()
const tabsStore = useTabsStore() const tabsStore = useTabsStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const isRouteShow = computed(() => appStore.isRouteShow) const isRouteShow = computed(() => appStore.isRouteShow)
const includeList = computed(() => (settingStore.openMultipleTabs ? tabsStore.getCacheTabList : [])) const includeList = computed(() => (settingStore.openMultipleTabs ? tabsStore.getCacheTabList : []))
</script> </script>
<style></style>
@@ -100,7 +100,7 @@ import theme_light from '@/assets/images/theme_white.png'
import useSettingStore from '@/stores/modules/setting' import useSettingStore from '@/stores/modules/setting'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const predefineColors = ref(['#409EFF', '#28C76F', '#EA5455', '#FF9F43', '#01CFE8', '#4A5DFF']) const predefineColors = ref(['#06b6d4', '#10b981', '#0891b2', '#6366f1', '#f59e0b', '#ef4444', '#64748b'])
const sideThemeList = [ const sideThemeList = [
{ {
type: 'dark', type: 'dark',
@@ -68,32 +68,42 @@ const themeClass = computed(() => `theme-${props.theme}`)
.el-menu { .el-menu {
:deep(.el-menu-item) { :deep(.el-menu-item) {
&.is-active { &.is-active {
@apply bg-primary border-primary; @apply bg-primary;
box-shadow: inset 3px 0 0 var(--el-color-primary-light-3);
} }
} }
} }
:deep(.el-menu--collapse) { :deep(.el-menu--collapse) {
.el-sub-menu.is-active .el-sub-menu__title { .el-sub-menu.is-active .el-sub-menu__title {
@apply bg-primary #{!important}; @apply bg-primary #{!important};
} }
} }
} }
&.theme-light { &.theme-light {
:deep(.el-menu) { :deep(.el-menu) {
.el-menu-item { .el-menu-item {
border-color: transparent; border-color: transparent;
&.is-active { &.is-active {
@apply bg-primary-light-9 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-menu-item:hover,
.el-sub-menu__title:hover { .el-sub-menu__title:hover {
color: var(--el-color-primary); color: var(--el-color-primary);
} }
} }
} }
.el-menu { .el-menu {
border-right: none; border-right: none;
&:not(.el-menu--collapse) { &:not(.el-menu--collapse) {
width: var(--aside-width); width: var(--aside-width);
} }
@@ -1,5 +1,15 @@
<template> <template>
<div class="side" :style="sideStyle"> <div class="side" :style="sideStyle">
<div v-if="showBrandStrip" class="sidebar-brand">
<div class="sidebar-brand__mark">ZY</div>
<overflow-tooltip
class="sidebar-brand__name"
:content="config.web_name"
:teleported="true"
placement="bottom"
overflo-type="unset"
/>
</div>
<side-logo v-if="settingStore.showLogo" :show-title="!isCollapsed" :theme="sideTheme" /> <side-logo v-if="settingStore.showLogo" :show-title="!isCollapsed" :theme="sideTheme" />
<side-menu <side-menu
:routes="routes" :routes="routes"
@@ -25,17 +35,19 @@ const appStore = useAppStore()
const isCollapsed = computed(() => { const isCollapsed = computed(() => {
if (appStore.isMobile) { if (appStore.isMobile) {
return false return false
} else {
return appStore.isCollapsed
} }
return appStore.isCollapsed
}) })
const settingStore = useSettingStore() const settingStore = useSettingStore()
const sideTheme = computed(() => settingStore.sideTheme) const sideTheme = computed(() => settingStore.sideTheme)
const userStore = useUserStore() const userStore = useUserStore()
const config = computed(() => appStore.config)
const routes = computed(() => userStore.routes) const routes = computed(() => userStore.routes)
const showBrandStrip = computed(() => !settingStore.showLogo && !isCollapsed.value)
const sideStyle = computed(() => { const sideStyle = computed(() => {
return sideTheme.value == 'dark' return sideTheme.value == 'dark'
? { ? {
@@ -43,6 +55,7 @@ const sideStyle = computed(() => {
} }
: '' : ''
}) })
const menuProp = computed(() => { const menuProp = computed(() => {
return { return {
backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : '', backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : '',
@@ -50,6 +63,7 @@ const menuProp = computed(() => {
activeTextColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : '' activeTextColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : ''
} }
}) })
const handleSelect = () => { const handleSelect = () => {
if (appStore.isMobile) { if (appStore.isMobile) {
appStore.toggleCollapsed(true) appStore.toggleCollapsed(true)
@@ -61,7 +75,8 @@ const handleSelect = () => {
.side { .side {
position: relative; position: relative;
z-index: 999; z-index: 999;
@apply border-r border-br-light h-full flex flex-col; @apply h-full flex flex-col;
background-color: var(--side-dark-color, var(--el-bg-color)); border-right: 1px solid var(--admin-sidebar-border);
background-color: var(--side-dark-color, var(--sidebar-dark-bg));
} }
</style> </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 { :root.dark {
color-scheme: dark; color-scheme: dark;
--table-header-bg-color: var(--el-bg-color);
--el-bg-color-page: #0a0a0a; --table-header-bg-color: rgba(6, 182, 212, 0.12);
--el-bg-color: #1d2124; --sidebar-dark-bg: #030712;
--el-bg-color-overlay: #1d1e1f; --sidebar-dark-hover: rgba(255, 255, 255, 0.05);
--el-text-color-primary: #e5eaf3; --sidebar-dark-active: rgba(6, 182, 212, 0.2);
--el-text-color-regular: #cfd3dc;
--el-text-color-secondary: #a3a6ad; --admin-surface-elevated: #111827;
--el-text-color-placeholder: #8d9095; --admin-surface-muted: rgba(17, 24, 39, 0.88);
--el-text-color-disabled: #6c6e72; --admin-surface-glass: rgba(17, 24, 39, 0.86);
--el-border-color-darker: #636466;
--el-border-color-dark: #58585b; --el-bg-color-page: #030712;
--el-border-color: #4c4d4f; --el-bg-color: #0f172a;
--el-border-color-light: #414243; --el-bg-color-overlay: #1e293b;
--el-border-color-lighter: #363637; --el-text-color-primary: #f1f5f9;
--el-border-color-extra-light: #2b2b2c; --el-text-color-regular: #cbd5e1;
--el-fill-color-darker: #424243; --el-text-color-secondary: #94a3b8;
--el-fill-color-dark: #39393a; --el-text-color-placeholder: #64748b;
--el-fill-color: #303030; --el-text-color-disabled: #475569;
--el-fill-color-light: #262727; --el-border-color-darker: #64748b;
--el-fill-color-lighter: #1d1d1d; --el-border-color-dark: #475569;
--el-fill-color-extra-light: #191919; --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-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-mask-color: rgba(2, 6, 23, 0.78);
--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.36), 0px 8px 20px rgba(0, 0, 0, 0.72); --el-mask-color-extra-light: rgba(2, 6, 23, 0.42);
--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: 0 4px 24px rgba(0, 0, 0, 0.28);
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.72), 0px 12px 32px #000000, --el-box-shadow-light: 0 2px 16px rgba(0, 0, 0, 0.22);
0px 8px 16px -8px #000000 !important; --el-box-shadow-lighter: 0 1px 4px rgba(0, 0, 0, 0.16);
/* wangeditor主题 */ --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-bg-color: var(--el-bg-color);
--w-e-textarea-color: var(--el-text-color-primary); --w-e-textarea-color: var(--el-text-color-primary);
--w-e-textarea-border-color: var(--el-border-color); --w-e-textarea-border-color: var(--el-border-color);
--w-e-textarea-slight-border-color: var(--el-border-color-light); --w-e-textarea-slight-border-color: var(--el-border-color-light);
--w-e-textarea-slight-color: var(--el-border-color); --w-e-textarea-slight-color: var(--el-border-color);
--w-e-textarea-slight-bg-color: var(--el-bg-color-page); --w-e-textarea-slight-bg-color: var(--el-bg-color-page);
/* --w-e-textarea-selected-border-color: #b4d5ff;
--w-e-textarea-handler-bg-color: #4290f7; */
--w-e-toolbar-color: var(--el-text-color-primary); --w-e-toolbar-color: var(--el-text-color-primary);
--w-e-toolbar-bg-color: var(--el-bg-color); --w-e-toolbar-bg-color: var(--el-bg-color);
--w-e-toolbar-active-color: var(--el-text-color-primary); --w-e-toolbar-active-color: var(--el-text-color-primary);
+246 -26
View File
@@ -1,14 +1,20 @@
:root { :root {
// 确保消息提示在最上层 /* Messages & notifications */
.el-message { .el-message {
z-index: 9999 !important; z-index: 9999 !important;
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
} }
.el-notification { .el-notification {
z-index: 9999 !important; z-index: 9999 !important;
border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
} }
// 弹窗居中 /* Dialog */
.el-overlay-dialog { .el-overlay-dialog {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -19,11 +25,14 @@
.el-dialog { .el-dialog {
--el-dialog-content-font-size: var(--el-font-size-base); --el-dialog-content-font-size: var(--el-font-size-base);
--el-dialog-margin-top: 50px; --el-dialog-margin-top: 50px;
max-width: calc(100vw - 30px); --el-dialog-border-radius: var(--admin-radius-lg);
max-width: calc(100vw - 32px);
flex: none; flex: none;
display: flex; display: flex;
flex-direction: column; 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 { &.body-padding .el-dialog__body {
padding: 0; padding: 0;
@@ -31,50 +40,158 @@
.el-dialog__body { .el-dialog__body {
flex: 1; flex: 1;
padding: 15px 20px; padding: 16px 20px 20px;
} }
.el-dialog__header { .el-dialog__header {
font-size: var(--el-font-size-large); font-size: var(--el-font-size-large);
font-weight: 600;
padding: 16px 20px 12px;
margin-right: 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.el-dialog__footer {
padding: 12px 20px 16px;
border-top: 1px solid var(--el-border-color-extra-light);
} }
} }
} }
/* Cards - applies to all list pages */
.el-card {
--el-card-border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
box-shadow: none;
background: var(--admin-surface-elevated);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&.is-always-shadow,
&.is-hover-shadow:hover {
box-shadow: var(--el-box-shadow-light);
}
.el-card__header {
padding: 14px 18px;
border-bottom: 1px solid var(--el-border-color-extra-light);
font-weight: 600;
color: var(--el-text-color-primary);
}
.el-card__body {
padding: 18px;
}
}
/* Menu */
.el-menu { .el-menu {
user-select: none; user-select: none;
} }
/* Drawer */
.el-drawer { .el-drawer {
--el-drawer-padding-primary: 16px; --el-drawer-padding-primary: 16px;
&__header { &__header {
margin-bottom: 0; margin-bottom: 0;
padding: 13px 16px; padding: 14px 18px;
border-bottom: 1px solid var(--el-border-color-lighter); border-bottom: 1px solid var(--el-border-color-lighter);
font-weight: 600;
} }
&__title { &__title {
@apply text-tx-primary; @apply text-tx-primary;
} }
}
.el-table { &__body {
--el-table-header-text-color: var(--el-text-color-primary); padding: 16px 18px;
--el-table-header-bg-color: var(--table-header-bg-color);
font-size: var(--el-font-size-base);
thead {
th {
font-weight: 400;
}
} }
} }
/* 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 { .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 {
--el-checkbox-font-size: var(--el-font-size-base); --el-checkbox-font-size: var(--el-font-size-base);
} }
.el-button {
--el-border-radius-base: var(--admin-radius-md);
font-weight: 500;
transition: transform 0.12s ease, box-shadow 0.2s ease, background-color 0.2s ease;
&:active:not(.is-disabled) {
transform: scale(0.98);
}
}
.el-button--primary:not(.is-link):not(.is-text):not(.is-plain) {
background: var(--admin-brand-gradient);
border: none;
box-shadow: var(--admin-brand-glow);
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
--el-button-hover-bg-color: transparent;
--el-button-hover-border-color: transparent;
&:hover {
filter: brightness(1.05);
box-shadow: 0 12px 32px rgba(6, 182, 212, 0.32);
}
}
.el-button--primary.is-link,
.el-button--primary.is-text {
--el-button-hover-link-text-color: var(--admin-brand-primary-dark);
}
.el-button--primary.is-plain {
--el-button-bg-color: rgba(6, 182, 212, 0.1);
--el-button-border-color: rgba(6, 182, 212, 0.35);
--el-button-text-color: var(--admin-brand-primary-dark);
--el-button-hover-bg-color: rgba(6, 182, 212, 0.16);
--el-button-hover-border-color: var(--admin-brand-primary);
}
.el-button--large {
--el-border-radius-base: var(--admin-radius-md);
}
.el-button.is-round {
--el-border-radius-base: var(--admin-radius-round);
}
/* Popup menus */
.el-menu--popup-container { .el-menu--popup-container {
&.theme-light { &.theme-light {
.el-menu { .el-menu {
@@ -83,12 +200,14 @@
@apply bg-primary-light-9 border-primary border-r-2; @apply bg-primary-light-9 border-primary border-r-2;
} }
} }
.el-menu-item:hover, .el-menu-item:hover,
.el-sub-menu__title:hover { .el-sub-menu__title:hover {
color: var(--el-color-primary); color: var(--el-color-primary);
} }
} }
} }
&.theme-dark { &.theme-dark {
.el-menu { .el-menu {
.el-menu-item { .el-menu-item {
@@ -101,52 +220,120 @@
} }
.el-message-box { .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 {
--el-date-editor-datetimerange-width: 380px; --el-date-editor-datetimerange-width: 380px;
.el-range-input { .el-range-input {
font-size: var(--el-font-size-small); font-size: var(--el-font-size-small);
} }
} }
.el-button--primary {
--el-button-hover-link-text-color: var(--el-color-primary-light-3);
}
.el-button--success { .el-button--success {
--el-button-hover-link-text-color: var(--el-color-success-light-3); --el-button-hover-link-text-color: var(--el-color-success-light-3);
} }
.el-button--info { .el-button--info {
--el-button-hover-link-text-color: var(--el-color-info-light-3); --el-button-hover-link-text-color: var(--el-color-info-light-3);
} }
.el-button--warning { .el-button--warning {
--el-button-hover-link-text-color: var(--el-color-warning-light-3); --el-button-hover-link-text-color: var(--el-color-warning-light-3);
} }
.el-button--danger { .el-button--danger {
--el-button-hover-link-text-color: var(--el-color-danger-light-3); --el-button-hover-link-text-color: var(--el-color-danger-light-3);
} }
.el-image__error { .el-image__error {
font-size: 12px; font-size: 12px;
} }
.el-tabs__nav-wrap::after { .el-tabs__nav-wrap::after {
height: 1px; height: 1px;
background: var(--el-border-color-extra-light);
} }
.el-tabs__item {
font-weight: 500;
transition: color 0.2s ease;
&.is-active {
color: var(--el-color-primary);
font-weight: 600;
}
&:hover {
color: var(--el-color-primary-light-3);
}
}
.el-tabs__active-bar {
height: 3px;
border-radius: 999px;
background: var(--admin-brand-gradient);
}
.el-page-header { .el-page-header {
&__breadcrumb { &__breadcrumb {
margin-bottom: 0; margin-bottom: 0;
} }
} }
/* Tags */
.el-tag {
--el-tag-border-radius: var(--admin-radius-sm);
border: none;
font-weight: 500;
}
/* Pagination */
.el-pagination {
.el-pager li {
border-radius: var(--admin-radius-sm);
font-weight: 500;
}
.btn-prev,
.btn-next {
border-radius: var(--admin-radius-sm);
}
}
/* Alert */
.el-alert {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-extra-light);
}
/* Empty state */
.el-empty {
padding: 32px 0;
}
/* Descriptions */
.el-descriptions {
.el-descriptions__label {
font-weight: 500;
color: var(--el-text-color-secondary);
}
}
/* Focus rings */
.el-input, .el-input,
.el-select, .el-select,
.el-textarea { .el-textarea {
@apply shadow-primary-light-8; @apply shadow-primary-light-8;
box-shadow: 0 0 0 0 var(--tw-shadow-color); box-shadow: 0 0 0 0 var(--tw-shadow-color);
&:focus-within { &:focus-within {
box-shadow: 0 0 0 2px var(--tw-shadow-color); box-shadow: 0 0 0 2px var(--tw-shadow-color);
border-radius: var(--el-input-border-radius, var(--el-border-radius-base)); border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
transition: box-shadow ease 0.1s; transition: box-shadow ease 0.15s;
} }
} }
@@ -156,6 +343,7 @@
border-radius: var(--el-checkbox-border-radius); border-radius: var(--el-checkbox-border-radius);
box-shadow: 0 0 0 0 var(--tw-shadow-color); box-shadow: 0 0 0 0 var(--tw-shadow-color);
&:active { &:active {
box-shadow: 0 0 0 2px var(--tw-shadow-color); box-shadow: 0 0 0 2px var(--tw-shadow-color);
transition: box-shadow ease 0s; transition: box-shadow ease 0s;
@@ -173,29 +361,61 @@
.el-form-item.is-error .el-checkbox { .el-form-item.is-error .el-checkbox {
@apply shadow-danger-light-8; @apply shadow-danger-light-8;
} }
/* Dropdown */
.el-dropdown-menu {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
padding: 4px;
}
.el-dropdown-menu__item {
border-radius: var(--admin-radius-sm);
}
/* Popover / tooltip polish */
.el-popover.el-popper {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.el-pagination > .el-pagination__jump { .el-pagination > .el-pagination__jump {
display: none !important; display: none !important;
} }
.el-pagination > .el-pagination__sizes { .el-pagination > .el-pagination__sizes {
display: none !important; display: none !important;
} }
} }
.el-button { .el-button {
// 防止被tailwindcss默认样式覆盖
background-color: var(--el-button-bg-color, var(--el-color-white)); background-color: var(--el-button-bg-color, var(--el-color-white));
//覆盖el-button的点击样式
&:focus { &:focus {
color: var(--el-button-text-color); color: var(--el-button-text-color);
border-color: var(--el-button-border-color); border-color: var(--el-button-border-color);
background-color: var(--el-button-bg-color); background-color: var(--el-button-bg-color);
} }
&:hover { &:hover {
color: var(--el-button-hover-text-color); color: var(--el-button-hover-text-color);
border-color: var(--el-button-hover-border-color); border-color: var(--el-button-hover-border-color);
background-color: var(--el-button-hover-bg-color); background-color: var(--el-button-hover-bg-color);
} }
} }
/* Inline form filter blocks on list pages */
.el-card .el-form--inline {
.el-form-item {
margin-bottom: 12px;
}
}
/* Page section spacing between stacked cards */
.el-card + .el-card {
margin-top: 16px;
}
+4 -2
View File
@@ -1,5 +1,7 @@
@import 'element.scss';
@import 'dark.css';
@import 'var.css'; @import 'var.css';
@import 'dark.css';
@import 'tailwind.css'; @import 'tailwind.css';
@import 'element.scss';
@import 'admin-shell.scss';
@import 'admin-pages.scss';
@import 'public.scss'; @import 'public.scss';
+48
View File
@@ -1,6 +1,10 @@
body { body {
@apply text-base text-tx-primary overflow-hidden min-w-[375px]; @apply text-base text-tx-primary overflow-hidden min-w-[375px];
font-feature-settings: 'kern' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
.form-tips { .form-tips {
@apply text-tx-secondary text-xs leading-6 mt-1; @apply text-tx-secondary text-xs leading-6 mt-1;
} }
@@ -12,7 +16,51 @@ body {
visibility: hidden; visibility: hidden;
} }
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color-dark);
background-clip: content-box;
}
/* NProgress */ /* NProgress */
#nprogress .bar { #nprogress .bar {
@apply bg-primary #{!important}; @apply bg-primary #{!important};
height: 2px !important;
}
#nprogress .peg {
box-shadow: 0 0 8px var(--el-color-primary), 0 0 4px var(--el-color-primary) !important;
}
/* Shared page utilities */
.admin-page-title {
@apply text-xl font-semibold text-tx-primary tracking-tight;
}
.admin-page-desc {
@apply text-sm text-tx-secondary mt-1;
}
.admin-stat-value {
@apply text-3xl font-semibold text-tx-primary tabular-nums;
}
.admin-stat-label {
@apply text-sm text-tx-secondary;
} }
+85 -35
View File
@@ -1,49 +1,99 @@
:root { :root {
/* Typography */
--el-font-family: theme(fontFamily.sans); --el-font-family: theme(fontFamily.sans);
--el-font-weight-primary: 400; --el-font-weight-primary: 400;
--el-menu-item-height: 46px;
--el-menu-sub-item-height: var(--el-menu-item-height);
--el-menu-icon-width: 18px;
--aside-width: 200px;
--navbar-height: 50px;
--color-white: #ffffff;
--table-header-bg-color: #f8f8f8;
--el-font-size-extra-large: 18px; --el-font-size-extra-large: 18px;
--el-menu-base-level-padding: 16px;
--el-menu-level-padding: 26px;
--el-font-size-large: 16px; --el-font-size-large: 16px;
--el-font-size-medium: 15px; --el-font-size-medium: 15px;
--el-font-size-base: 14px; --el-font-size-base: 14px;
--el-font-size-small: 13px; --el-font-size-small: 13px;
--el-font-size-extra-small: 12px; --el-font-size-extra-small: 12px;
/* Brand - MedTech 绚丽临床 */
--admin-brand-primary: #06b6d4;
--admin-brand-primary-dark: #0891b2;
--admin-brand-accent: #10b981;
--admin-brand-secondary: #6366f1;
--admin-brand-success: #10b981;
--admin-brand-gradient: linear-gradient(120deg, #06b6d4 0%, #10b981 48%, #0891b2 100%);
--admin-brand-gradient-soft: linear-gradient(
120deg,
rgba(6, 182, 212, 0.16) 0%,
rgba(16, 185, 129, 0.1) 50%,
rgba(8, 145, 178, 0.08) 100%
);
--admin-brand-glow: 0 0 24px rgba(6, 182, 212, 0.35);
--admin-brand-mesh-a: rgba(6, 182, 212, 0.18);
--admin-brand-mesh-b: rgba(16, 185, 129, 0.14);
--admin-brand-mesh-c: rgba(99, 102, 241, 0.1);
/* Layout shell v2 */
--aside-width: 248px;
--navbar-height: 60px;
--tabs-height: 42px;
--stage-padding-x: 28px;
--stage-padding-y: 24px;
--content-max-width: 1680px;
--sidebar-rail-width: 3px;
/* Shape */
--admin-radius-sm: 8px;
--admin-radius-md: 12px;
--admin-radius-lg: 16px;
--admin-radius-xl: 22px;
--admin-radius-2xl: 28px;
--el-border-radius-base: var(--admin-radius-md);
--el-border-radius-small: var(--admin-radius-sm);
--el-border-radius-round: 999px;
/* Menu */
--el-menu-item-height: 46px;
--el-menu-sub-item-height: var(--el-menu-item-height);
--el-menu-icon-width: 20px;
--el-menu-base-level-padding: 14px;
--el-menu-level-padding: 22px;
/* Surfaces */
--color-white: #ffffff;
--table-header-bg-color: rgba(6, 182, 212, 0.06);
--sidebar-dark-bg: #060d18;
--sidebar-dark-hover: rgba(255, 255, 255, 0.05);
--sidebar-dark-active: rgba(6, 182, 212, 0.14);
--admin-surface-elevated: #ffffff;
--admin-surface-muted: rgba(255, 255, 255, 0.72);
--admin-surface-glass: rgba(255, 255, 255, 0.82);
--el-bg-color: var(--color-white); --el-bg-color: var(--color-white);
--el-bg-color-page: #f6f6f6; --el-bg-color-page: #eef6fb;
--el-bg-color-overlay: #ffffff; --el-bg-color-overlay: #ffffff;
--el-text-color-primary: #333333; --el-text-color-primary: #0b1220;
--el-text-color-regular: #666666; --el-text-color-regular: #334155;
--el-text-color-secondary: #999999; --el-text-color-secondary: #64748b;
--el-text-color-placeholder: #a8abb2; --el-text-color-placeholder: #94a3b8;
--el-text-color-disabled: #c0c4cc; --el-text-color-disabled: #cbd5e1;
--el-border-color: #dcdfe6; --el-border-color: rgba(6, 182, 212, 0.12);
--el-border-color-light: #e4e7ed; --el-border-color-light: rgba(6, 182, 212, 0.08);
--el-border-color-lighter: #ebeef5; --el-border-color-lighter: rgba(15, 23, 42, 0.06);
--el-border-color-extra-light: #f2f2f2; --el-border-color-extra-light: rgba(15, 23, 42, 0.04);
--el-border-color-dark: #d4d7de; --el-border-color-dark: #cbd5e1;
--el-border-color-darker: #cdd0d6; --el-border-color-darker: #94a3b8;
--el-fill-color: #f0f2f5; --el-fill-color: #f1f5f9;
--el-fill-color-light: #f8f8f8; --el-fill-color-light: #f8fafc;
--el-fill-color-lighter: #fafafa; --el-fill-color-lighter: #fafbfc;
--el-fill-color-extra-light: #fafcff; --el-fill-color-extra-light: #fcfdfe;
--el-fill-color-dark: #ebedf0; --el-fill-color-dark: #e2e8f0;
--el-fill-color-darker: #e6e8eb; --el-fill-color-darker: #cbd5e1;
--el-fill-color-blank: #ffffff; --el-fill-color-blank: #ffffff;
/* 过亮会盖住抽屉/弹窗下的内容;Element Loading 与部分蒙层共用此变量 */
--el-mask-color: rgba(255, 255, 255, 0.5); --el-mask-color: rgba(6, 13, 24, 0.55);
--el-mask-color-extra-light: rgba(255, 255, 255, 0.22); --el-mask-color-extra-light: rgba(6, 13, 24, 0.1);
-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: 0 4px 24px rgba(6, 182, 212, 0.08), 0 12px 40px rgba(15, 23, 42, 0.06);
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12); --el-box-shadow-light: 0 2px 16px rgba(6, 182, 212, 0.1);
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12), --el-box-shadow-lighter: 0 1px 4px rgba(15, 23, 42, 0.04);
0px 8px 16px -8px rgba(0, 0, 0, 0.16); --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> <template>
<div class="change-password flex flex-col"> <div class="change-password flex flex-col">
<div class="flex-1 flex items-center justify-center"> <div class="change-password-backdrop" aria-hidden="true"></div>
<div class="change-password-card bg-body rounded-md px-10 py-10 w-[480px]"> <div class="flex-1 flex items-center justify-center relative z-[1] px-4">
<div class="text-center text-2xl font-medium mb-2">首次登录</div> <div class="change-password-card">
<div class="text-center text-gray-500 text-sm mb-8">为了您的账号安全请修改初始密码</div> <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 ref="formRef" :model="formData" size="large" :rules="rules">
<el-form-item prop="password"> <el-form-item prop="password">
@@ -117,7 +118,25 @@ const { isLock, lockFn: lockSubmit } = useLockFn(handleSubmit)
<style lang="scss" scoped> <style lang="scss" scoped>
.change-password { .change-password {
background-image: url('./images/login_bg.png'); position: relative;
@apply min-h-screen bg-no-repeat bg-center bg-cover; 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> </style>
+192 -32
View File
@@ -1,33 +1,41 @@
<template> <template>
<div class="login flex flex-col"> <div class="login-v2">
<div class="flex-1 flex items-center justify-center"> <div class="login-v2__mesh" aria-hidden="true"></div>
<div class="login-card flex rounded-md overflow-hidden"> <div class="login-v2__layout">
<div class="flex-1 h-full hidden md:inline-block"> <aside class="login-v2__brand">
<image-contain :src="config.login_image" :width="400" height="100%" /> <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>
<div </aside>
class="login-form bg-body flex flex-col justify-center px-10 py-10 md:w-[420px] w-[380px] flex-none mx-auto"
>
<div class="text-center text-3xl font-medium mb-8">{{ config.web_name }}</div>
<!-- 企业微信自动授权中 --> <section class="login-v2__panel">
<div v-if="wxWorkAutoLogin" class="text-center py-10"> <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)"> <el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
<Loading /> <Loading />
</el-icon> </el-icon>
<div class="text-gray-500">企业微信授权登录中...</div> <div class="text-tx-secondary">企业微信授权登录中...</div>
</div> </div>
<template v-else> <template v-else>
<!-- 登录方式切换标签 --> <div v-if="wxWorkEnabled" class="login-v2__mode">
<div v-if="wxWorkEnabled" class="flex justify-center mb-6">
<el-radio-group v-model="loginMode" size="large"> <el-radio-group v-model="loginMode" size="large">
<el-radio-button value="account">账号登录</el-radio-button> <el-radio-button value="account">账号登录</el-radio-button>
<el-radio-button value="wxwork">企业微信</el-radio-button> <el-radio-button value="wxwork">企业微信</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
<!-- 账号密码登录 -->
<template v-if="loginMode === 'account'"> <template v-if="loginMode === 'account'">
<el-form ref="formRef" :model="formData" size="large" :rules="rules"> <el-form ref="formRef" :model="formData" size="large" :rules="rules">
<el-form-item prop="account"> <el-form-item prop="account">
@@ -58,29 +66,34 @@
<div class="mb-5"> <div class="mb-5">
<el-checkbox v-model="remAccount" label="记住账号"></el-checkbox> <el-checkbox v-model="remAccount" label="记住账号"></el-checkbox>
</div> </div>
<el-button type="primary" size="large" :loading="isLock" @click="lockLogin"> <el-button
登录 class="login-v2__submit"
type="primary"
size="large"
:loading="isLock"
@click="lockLogin"
>
进入工作台
</el-button> </el-button>
</template> </template>
<!-- 企业微信扫码登录非企业微信内浏览器 -->
<template v-if="loginMode === 'wxwork'"> <template v-if="loginMode === 'wxwork'">
<div class="wxwork-qrcode-wrap"> <div class="wxwork-qrcode-wrap">
<div v-if="wxWorkLoading" class="text-center py-10"> <div v-if="wxWorkLoading" class="text-center py-10">
<el-icon class="is-loading" :size="32" color="var(--el-color-primary)"> <el-icon class="is-loading" :size="32" color="var(--el-color-primary)">
<Loading /> <Loading />
</el-icon> </el-icon>
<div class="mt-2 text-gray-400 text-sm">加载企业微信扫码...</div> <div class="mt-2 text-tx-secondary text-sm">加载企业微信扫码...</div>
</div> </div>
<div v-else id="wxwork_qrcode_container" class="wxwork-qrcode"></div> <div v-else id="wxwork_qrcode_container" class="wxwork-qrcode"></div>
</div> </div>
<div class="text-center text-sm text-gray-400 mt-4"> <div class="text-center text-sm text-tx-secondary mt-4">
请使用企业微信扫描二维码登录 请使用企业微信扫描二维码登录
</div> </div>
</template> </template>
</template> </template>
</div> </div>
</div> </section>
</div> </div>
<layout-footer /> <layout-footer />
</div> </div>
@@ -286,31 +299,178 @@ onMounted(async () => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.login { .login-v2 {
background-image: url('./images/login_bg.png'); position: relative;
@apply min-h-screen bg-no-repeat bg-center bg-cover; min-height: 100vh;
.login-card { display: flex;
height: auto; flex-direction: column;
min-height: 400px; 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 { .wxwork-qrcode-wrap {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 400px; min-height: 360px;
} }
.wxwork-qrcode { .wxwork-qrcode {
width: 340px; width: 320px;
height: 400px; height: 360px;
overflow: hidden; overflow: hidden;
:deep(iframe) { :deep(iframe) {
width: 340px !important; width: 320px !important;
height: 400px !important; height: 360px !important;
border: none; border: none;
} }
} }
@media (prefers-reduced-transparency: reduce) {
.login-v2__glass {
background: #ffffff;
backdrop-filter: none;
}
}
</style> </style>
+9 -9
View File
@@ -1,17 +1,17 @@
<template> <template>
<div> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<el-alert <el-alert
type="warning" type="warning"
title="温馨提示:用于管理网站的分类,只可添加到一级" title="温馨提示:用于管理网站的分类,只可添加到一级"
:closable="false" :closable="false"
show-icon show-icon
/> />
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
<div> <admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button <el-button
class="mb-4"
v-perms="['article.articleCate/add']" v-perms="['article.articleCate/add']"
type="primary" type="primary"
@click="handleAdd()" @click="handleAdd()"
@@ -21,7 +21,7 @@
</template> </template>
新增 新增
</el-button> </el-button>
</div> </template>
<el-table size="large" :data="pager.lists"> <el-table size="large" :data="pager.lists">
<el-table-column label="栏目名称" prop="name" min-width="120" /> <el-table-column label="栏目名称" prop="name" min-width="120" />
<el-table-column label="文章数" prop="article_count" min-width="120" /> <el-table-column label="文章数" prop="article_count" min-width="120" />
@@ -58,10 +58,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
+12 -11
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="article-lists"> <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 ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="文章标题"> <el-form-item class="w-[280px]" label="文章标题">
<el-input <el-input
@@ -33,24 +33,25 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never">
<div> <admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<router-link <router-link
v-perms="['article.article/add', 'article.article/add:edit']" v-perms="['article.article/add', 'article.article/add:edit']"
:to="{ :to="{
path: getRoutePath('article.article/add:edit') path: getRoutePath('article.article/add:edit')
}" }"
> >
<el-button type="primary" class="mb-4"> <el-button type="primary">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
发布文章 发布文章
</el-button> </el-button>
</router-link> </router-link>
</div> </template>
<el-table size="large" v-loading="pager.loading" :data="pager.lists"> <el-table size="large" :data="pager.lists">
<el-table-column label="ID" prop="id" min-width="80" /> <el-table-column label="ID" prop="id" min-width="80" />
<el-table-column label="封面" min-width="100"> <el-table-column label="封面" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
@@ -116,10 +117,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
</div> </div>
</template> </template>
<script lang="ts" setup name="articleLists"> <script lang="ts" setup name="articleLists">
+34 -11
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="asset-resource-container"> <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 class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="标题"> <el-form-item class="w-[280px]" label="标题">
<el-input <el-input
@@ -28,21 +28,26 @@
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<!-- 列表区域 --> <!-- 列表区域 -->
<el-card class="!border-none mt-4" shadow="never"> <admin-page-data-panel v-loading="loading">
<div class="mb-4"> <template #toolbar>
<el-button type="primary" @click="handleAdd">上传资源</el-button> <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-tabs v-model="queryParams.type" @tab-change="handleTabChange">
<el-tab-pane label="图片" name="1"></el-tab-pane> <el-tab-pane label="图片" name="1"></el-tab-pane>
<el-tab-pane label="视频" name="2"></el-tab-pane> <el-tab-pane label="视频" name="2"></el-tab-pane>
<el-tab-pane label="语音" name="3"></el-tab-pane> <el-tab-pane label="语音" name="3"></el-tab-pane>
</el-tabs> </el-tabs>
<el-table :data="tableData" v-loading="loading"> <el-table :data="tableData" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<!-- <el-table-column prop="id" label="ID" width="80" /> --> <!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="title" label="标题" min-width="80" /> <el-table-column prop="title" label="标题" min-width="80" />
<el-table-column label="预览" width="100"> <el-table-column label="预览" width="100">
@@ -66,7 +71,7 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="mt-4 flex justify-end"> <template #footer>
<el-pagination <el-pagination
v-model:current-page="queryParams.page_no" v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size" v-model:page-size="queryParams.page_size"
@@ -75,8 +80,8 @@
@size-change="getList" @size-change="getList"
@current-change="getList" @current-change="getList"
/> />
</div> </template>
</el-card> </admin-page-data-panel>
<!-- 新增/编辑资源弹窗 --> <!-- 新增/编辑资源弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close> <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' import Upload from '@/components/upload/index.vue'
const loading = ref(false) const loading = ref(false)
const tableData = ref([]) const tableData = ref<any[]>([])
const total = ref(0) const total = ref(0)
const selectedIds = ref<number[]>([])
const searchData = reactive<any>({ const searchData = reactive<any>({
title: '', title: '',
@@ -198,6 +204,7 @@ const getList = async () => {
const res = await apiAssetResourceList(buildQueryParams()) const res = await apiAssetResourceList(buildQueryParams())
tableData.value = res.lists tableData.value = res.lists
total.value = res.count total.value = res.count
selectedIds.value = []
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
@@ -269,6 +276,22 @@ const handleDelete = async (row: any) => {
} }
} }
const handleSelectionChange = (rows: any[]) => {
selectedIds.value = rows.map((row) => Number(row.id)).filter((id) => id > 0)
}
const handleBatchDelete = async () => {
if (!selectedIds.value.length) return
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个资源吗?用户将无法再看到。`, '提示', { type: 'warning' })
await apiAssetResourceDelete({ id: selectedIds.value })
ElMessage.success('删除成功')
getList()
} catch (e) {
// canceled
}
}
const submitForm = async () => { const submitForm = async () => {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => { await formRef.value.validate(async (valid: boolean) => {
+10 -10
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="asset-user-container"> <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 class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="手机号"> <el-form-item class="w-[280px]" label="手机号">
<el-input <el-input
@@ -16,15 +16,15 @@
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<!-- 列表区域 --> <!-- 列表区域 -->
<el-card class="!border-none mt-4" shadow="never"> <admin-page-data-panel v-loading="loading">
<div class="mb-4"> <template #toolbar>
<el-button type="primary" @click="handleAdd">新增账号</el-button> <el-button type="primary" @click="handleAdd">新增账号</el-button>
</div> </template>
<el-table :data="tableData" v-loading="loading"> <el-table :data="tableData">
<!-- <el-table-column prop="id" label="ID" width="80" /> --> <!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="phone" label="手机号" /> <el-table-column prop="phone" label="手机号" />
<el-table-column label="备注" min-width="200"> <el-table-column label="备注" min-width="200">
@@ -63,7 +63,7 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="mt-4 flex justify-end"> <template #footer>
<el-pagination <el-pagination
v-model:current-page="queryParams.page_no" v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size" v-model:page-size="queryParams.page_size"
@@ -72,8 +72,8 @@
@size-change="getList" @size-change="getList"
@current-change="getList" @current-change="getList"
/> />
</div> </template>
</el-card> </admin-page-data-panel>
<!-- 编辑/新增弹窗 --> <!-- 编辑/新增弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
+7 -6
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <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 ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="用户信息"> <el-form-item class="w-[280px]" label="用户信息">
<el-input <el-input
@@ -37,8 +37,9 @@
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never">
<admin-page-data-panel>
<el-table size="large" v-loading="pager.loading" :data="pager.lists"> <el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="头像" min-width="100"> <el-table-column label="头像" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
@@ -67,10 +68,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
</div> </div>
</template> </template>
<script lang="ts" setup name="consumerLists"> <script lang="ts" setup name="consumerLists">
@@ -142,17 +142,19 @@ export function logActionText(act: string) {
return m[act] || act return m[act] || act
} }
/** 支付单来源/方式:企微对外收款、付呗等创建链路 + 支付方式回退 */ /** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) { export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
const createType = String(row?.create_type || '') const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款' if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗' if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '') const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = { const methodMap: Record<string, string> = {
alipay: '支付宝', alipay: '支付宝',
wechat: '微信', wechat: '微信',
wechat_work: '企业微信', wechat_work: '企业微信',
fubei: '付呗', fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账' manual: '手动确认到账'
} }
if (paymentMethod && methodMap[paymentMethod]) { if (paymentMethod && methodMap[paymentMethod]) {
@@ -39,7 +39,7 @@
<el-radio-button label="rejected">驳回</el-radio-button> <el-radio-button label="rejected">驳回</el-radio-button>
</el-radio-group> </el-radio-group>
<span class="pl-toolbar__sep" aria-hidden="true" /> <span class="pl-toolbar__sep" aria-hidden="true" />
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span> <span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
<el-radio-group <el-radio-group
v-model="formData.source_filter" v-model="formData.source_filter"
size="small" size="small"
@@ -48,7 +48,7 @@
> >
<el-radio-button label="all">全部</el-radio-button> <el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="manual">手工</el-radio-button> <el-radio-button label="manual">手工</el-radio-button>
<el-radio-button label="system">系统代开</el-radio-button> <el-radio-button label="system">空白处方</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
<div class="pl-toolbar__line pl-toolbar__line--second"> <div class="pl-toolbar__line pl-toolbar__line--second">
@@ -146,7 +146,7 @@
size="small" size="small"
effect="plain" effect="plain"
> >
系统代开 空白处方
</el-tag> </el-tag>
<span v-else class="text-gray-400 text-sm">手工</span> <span v-else class="text-gray-400 text-sm">手工</span>
</template> </template>
@@ -290,7 +290,7 @@
type="warning" type="warning"
size="small" size="small"
effect="plain" effect="plain"
>系统代开</el-tag> >空白处方</el-tag>
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag> <el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
<el-tag <el-tag
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1" v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
@@ -1857,7 +1857,7 @@ const formData = reactive({
creator_ids: [] as number[], creator_ids: [] as number[],
/** all | passed | not_passed | pending | rejectedall 表示不限,接口传参会转为空) */ /** all | passed | not_passed | pending | rejectedall 表示不限,接口传参会转为空) */
audit_filter: 'all' as string, audit_filter: 'all' as string,
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */ /** all | manual | system — 与 is_system_auto:手工(0) / 空白处方(1) */
source_filter: 'all' as string, source_filter: 'all' as string,
start_time: '', start_time: '',
end_time: '' end_time: ''
@@ -283,6 +283,12 @@
<el-option label="未指派" value="0" /> <el-option label="未指派" value="0" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item class="w-[140px]" label="辅方">
<el-select v-model="queryParams.has_aux_formula" clearable placeholder="全部" class="!w-full">
<el-option label="含辅方" value="1" />
<el-option label="不含辅方" value="0" />
</el-select>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button> <el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
@@ -303,6 +309,17 @@
<div class="po-table-toolbar__title-wrap"> <div class="po-table-toolbar__title-wrap">
<span class="po-table-title text-gray-800">订单列表</span> <span class="po-table-title text-gray-800">订单列表</span>
<span class="po-table-hint text-gray-400"> {{ pager.count }} </span> <span class="po-table-hint text-gray-400"> {{ pager.count }} </span>
<el-button
v-if="canBatchReassign"
type="primary"
plain
size="small"
class="ml-3"
:disabled="selectedOrders.length === 0"
@click="openReassignDialog"
>
批量改派医助{{ selectedOrders.length ? `${selectedOrders.length}` : '' }}
</el-button>
</div> </div>
<div class="po-focus-board"> <div class="po-focus-board">
<div <div
@@ -339,7 +356,15 @@
</div> </div>
</div> </div>
</div> </div>
<el-table :data="pager.lists" size="default" stripe class="po-data-table" :row-class-name="orderRowClassName"> <el-table
:data="pager.lists"
size="default"
stripe
class="po-data-table"
:row-class-name="orderRowClassName"
@selection-change="onOrderSelectionChange"
>
<el-table-column v-if="canBatchReassign" type="selection" width="44" :selectable="() => true" />
<el-table-column label="订单号" min-width="178" show-overflow-tooltip> <el-table-column label="订单号" min-width="178" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<div class="po-order-no-cell"> <div class="po-order-no-cell">
@@ -406,16 +431,41 @@
</div> </div>
<div v-if="canViewFinanceFields() && row.internal_cost != null && row.internal_cost !== ''" class="flex justify-between"> <div v-if="canViewFinanceFields() && row.internal_cost != null && row.internal_cost !== ''" class="flex justify-between">
<span class="text-gray-500">内部成本</span> <span class="text-gray-500">内部成本</span>
<span class="font-medium text-orange-600">¥{{ formatMoney(row.internal_cost) }}</span> <span
class="font-medium cursor-pointer select-none"
:class="internalCostVisible ? 'text-orange-600' : 'text-gray-400'"
title="点击显示/隐藏"
@click.stop="internalCostVisible = !internalCostVisible"
>
{{ internalCostVisible ? `¥${formatMoney(row.internal_cost)}` : '****' }}
</span>
</div> </div>
</div> </div>
</el-popover> </el-popover>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="canViewFinanceFields()" label="内部成本" width="92"> <el-table-column v-if="canViewFinanceFields()" width="100">
<template #header>
<span
class="cursor-pointer select-none hover:text-primary"
title="点击显示/隐藏内部成本"
@click.stop="internalCostVisible = !internalCostVisible"
>
内部成本
</span>
</template>
<template #default="{ row }"> <template #default="{ row }">
{{ row.internal_cost != null && row.internal_cost !== '' ? `¥${row.internal_cost}` : '—' }} <span
v-if="row.internal_cost != null && row.internal_cost !== ''"
class="cursor-pointer select-none"
:class="internalCostVisible ? 'text-orange-600 font-medium' : 'text-gray-400'"
title="点击显示/隐藏"
@click.stop="internalCostVisible = !internalCostVisible"
>
{{ internalCostVisible ? `¥${row.internal_cost}` : '****' }}
</span>
<span v-else></span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="处方审核" width="110"> <el-table-column label="处方审核" width="110">
@@ -704,7 +754,7 @@
<el-form-item label="新金额" prop="amount"> <el-form-item label="新金额" prop="amount">
<el-input-number <el-input-number
v-model="updateAmountForm.amount" v-model="updateAmountForm.amount"
:min="0.01" :min="0"
:step="0.01" :step="0.01"
:precision="2" :precision="2"
controls-position="right" controls-position="right"
@@ -2114,6 +2164,34 @@
</div> </div>
</el-drawer> </el-drawer>
<!-- 批量改派医助 -->
<el-dialog v-model="reassignVisible" title="批量改派医助" width="440px" append-to-body>
<div class="text-sm text-gray-600 mb-3">
已选 <strong class="text-primary">{{ selectedOrders.length }}</strong> 将订单创建人(医助归属)改派为
</div>
<el-select
v-model="reassignAssistantId"
clearable
filterable
placeholder="选择目标医助"
class="!w-full"
>
<el-option
v-for="a in assistantOptions"
:key="a.id"
:label="a.name"
:value="a.id"
/>
</el-select>
<p class="text-xs text-gray-400 mt-2 leading-relaxed">
仅变更订单的业务归属医助筛选 / 业绩口径并写入操作日志不影响处方支付单及审核状态已是该医助的订单会自动跳过
</p>
<template #footer>
<el-button @click="reassignVisible = false">取消</el-button>
<el-button type="primary" :loading="reassignSubmitting" @click="submitReassign">确定改派</el-button>
</template>
</el-dialog>
<!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 --> <!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" /> <TcmDiagnosisEditView ref="diagnosisViewRef" />
</div> </div>
@@ -2169,6 +2247,7 @@ import {
prescriptionOrderRefund, prescriptionOrderRefund,
prescriptionOrderRevokeRxAudit, prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit, prescriptionOrderRevokePayAudit,
prescriptionOrderBatchAssignAssistant,
prescriptionOrderPatchPrescriptionPatient, prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderLinkPayOrder, prescriptionOrderLinkPayOrder,
prescriptionOrderSubmitGancaoRecipel, prescriptionOrderSubmitGancaoRecipel,
@@ -2268,6 +2347,70 @@ const canViewFinanceFields = () => {
return FINANCE_ROLE_IDS.some((rid) => ids.includes(rid)) return FINANCE_ROLE_IDS.some((rid) => ids.includes(rid))
} }
/** 内部成本默认隐藏,点击表头或单元格切换显示 */
const internalCostVisible = ref(false)
/** 是否可批量改派医助(与后端 canSeeAllPrescriptionOrders 一致:超管 / 全量编辑角色) */
const canBatchReassign = computed(() => {
const u = userStore.userInfo
if (!u) return false
if (Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
return ORDER_EDIT_ALL_ROLE_IDS.some((rid) => ids.includes(rid))
})
/** 批量改派:表格多选 */
const selectedOrders = ref<Array<{ id: number; order_no?: string; creator_id?: number }>>([])
function onOrderSelectionChange(rows: Array<{ id: number; order_no?: string; creator_id?: number }>) {
selectedOrders.value = Array.isArray(rows) ? rows : []
}
/** 批量改派弹窗 */
const reassignVisible = ref(false)
const reassignAssistantId = ref<number | ''>('')
const reassignSubmitting = ref(false)
function openReassignDialog() {
if (selectedOrders.value.length === 0) {
feedback.msgWarning('请先勾选要改派的订单')
return
}
reassignAssistantId.value = ''
reassignVisible.value = true
}
async function submitReassign() {
const assistantId = Number(reassignAssistantId.value)
if (!assistantId) {
feedback.msgWarning('请选择目标医助')
return
}
const orderIds = selectedOrders.value.map((r) => Number(r.id)).filter((id) => id > 0)
if (orderIds.length === 0) {
feedback.msgWarning('请先勾选要改派的订单')
return
}
reassignSubmitting.value = true
try {
const res: any = await prescriptionOrderBatchAssignAssistant({
order_ids: orderIds,
assistant_id: assistantId
})
const ok = Number(res?.success ?? 0)
const errs: string[] = Array.isArray(res?.errors) ? res.errors : []
let msg = `已改派 ${ok}`
if (errs.length) msg += `${errs.join('')}`
feedback.msgSuccess(msg)
reassignVisible.value = false
selectedOrders.value = []
getLists()
} catch {
// request 拦截器已弹错误提示
} finally {
reassignSubmitting.value = false
}
}
// 省市区数据 // 省市区数据
const regionOptions = ref([]) const regionOptions = ref([])
@@ -2471,6 +2614,8 @@ const queryParams = reactive({
supply_mode: '' as '' | 'gancao' | 'self', supply_mode: '' as '' | 'gancao' | 'self',
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0' */ /** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0' */
service_channel: '' as '' | '0', service_channel: '' as '' | '0',
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
has_aux_formula: '' as '' | '0' | '1',
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */ /** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
exclude_fulfillment_cancelled: 0 as number, exclude_fulfillment_cancelled: 0 as number,
/** 下单人(关联操作日志 audit_rx_* / audit_pay_* */ /** 下单人(关联操作日志 audit_rx_* / audit_pay_* */
@@ -2618,6 +2763,11 @@ function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): R
p.service_channel = String(p.service_channel).trim() p.service_channel = String(p.service_channel).trim()
if (p.service_channel === '') delete p.service_channel if (p.service_channel === '') delete p.service_channel
} }
if (p.has_aux_formula === '' || p.has_aux_formula === undefined || p.has_aux_formula === null) {
delete p.has_aux_formula
} else {
p.has_aux_formula = Number(p.has_aux_formula)
}
if (Number(p.exclude_fulfillment_cancelled) !== 1) delete p.exclude_fulfillment_cancelled if (Number(p.exclude_fulfillment_cancelled) !== 1) delete p.exclude_fulfillment_cancelled
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) { if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
delete p.start_time delete p.start_time
@@ -2856,6 +3006,7 @@ function handleReset() {
queryParams.payment_slip_audit_status = '' queryParams.payment_slip_audit_status = ''
queryParams.supply_mode = '' queryParams.supply_mode = ''
queryParams.service_channel = '' queryParams.service_channel = ''
queryParams.has_aux_formula = ''
queryParams.audit_admin_id = '' queryParams.audit_admin_id = ''
queryParams.exclude_fulfillment_cancelled = 0 queryParams.exclude_fulfillment_cancelled = 0
resetParams() resetParams()
@@ -3106,8 +3257,8 @@ const updateAmountRules: FormRules = {
{ {
validator: (_rule, value, callback) => { validator: (_rule, value, callback) => {
const num = Number(value) const num = Number(value)
if (!Number.isFinite(num) || num <= 0) { if (!Number.isFinite(num) || num < 0) {
callback(new Error('订单金额必须大于0')) callback(new Error('订单金额不能为负数'))
return return
} }
callback() callback()
@@ -3164,7 +3315,8 @@ function openUpdateAmount() {
return return
} }
updateAmountForm.id = Number(row?.id) || 0 updateAmountForm.id = Number(row?.id) || 0
updateAmountForm.amount = Number(row?.amount) || undefined const amt = Number(row?.amount)
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
updateAmountVisible.value = true updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate()) nextTick(() => updateAmountFormRef.value?.clearValidate())
} }
@@ -1298,7 +1298,7 @@
<el-form-item label="新金额" prop="amount"> <el-form-item label="新金额" prop="amount">
<el-input-number <el-input-number
v-model="updateAmountForm.amount" v-model="updateAmountForm.amount"
:min="0.01" :min="0"
:step="0.01" :step="0.01"
:precision="2" :precision="2"
controls-position="right" controls-position="right"
@@ -3733,8 +3733,8 @@ const updateAmountRules: FormRules = {
{ {
validator: (_rule, value, callback) => { validator: (_rule, value, callback) => {
const num = Number(value) const num = Number(value)
if (!Number.isFinite(num) || num <= 0) { if (!Number.isFinite(num) || num < 0) {
callback(new Error('订单金额必须大于0')) callback(new Error('订单金额不能为负数'))
return return
} }
callback() callback()
@@ -3886,7 +3886,8 @@ function openUpdateAmount() {
return return
} }
updateAmountForm.id = Number(row?.id) || 0 updateAmountForm.id = Number(row?.id) || 0
updateAmountForm.amount = Number(row?.amount) || undefined const amt = Number(row?.amount)
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
updateAmountVisible.value = true updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate()) nextTick(() => updateAmountFormRef.value?.clearValidate())
} }
@@ -4104,12 +4105,14 @@ function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unk
const createType = String(row?.create_type || '') const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款' if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗' if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '') const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = { const methodMap: Record<string, string> = {
alipay: '支付宝', alipay: '支付宝',
wechat: '微信', wechat: '微信',
wechat_work: '企业微信', wechat_work: '企业微信',
fubei: '付呗', fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账' manual: '手动确认到账'
} }
if (paymentMethod && methodMap[paymentMethod]) { if (paymentMethod && methodMap[paymentMethod]) {
+7 -7
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="code-generation"> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<el-form class="mb-[-16px]" :model="formData" inline> <el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="表名称"> <el-form-item class="w-[280px]" label="表名称">
<el-input v-model="formData.table_name" clearable @keyup.enter="resetPage" /> <el-input v-model="formData.table_name" clearable @keyup.enter="resetPage" />
@@ -13,8 +13,8 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading"> <admin-page-data-panel>
<div class="flex"> <div class="flex">
<data-table <data-table
v-perms="['tools.generator/selectTable']" v-perms="['tools.generator/selectTable']"
@@ -126,10 +126,10 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
<code-preview <code-preview
v-if="previewState.show" v-if="previewState.show"
v-model="previewState.show" v-model="previewState.show"
+1 -1
View File
@@ -866,7 +866,7 @@ onUnmounted(() => {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
background: linear-gradient(145deg, #6366f1 0%, #8b5cf6 100%); background: linear-gradient(145deg, #0d9488 0%, #0891b2 100%);
flex-shrink: 0; flex-shrink: 0;
} }
+3 -5
View File
@@ -231,8 +231,7 @@ import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek' import isoWeek from 'dayjs/plugin/isoWeek'
import feedback from '@/utils/feedback' import feedback from '@/utils/feedback'
import { adminLists } from '@/api/perms/admin' import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
dayjs.extend(isoWeek) dayjs.extend(isoWeek)
@@ -491,10 +490,9 @@ async function removeSegment(row: RosterSegment) {
const loadDoctors = async () => { const loadDoctors = async () => {
try { try {
const res = await adminLists({ const res = await doctorLists({
page_no: 1, page_no: 1,
page_size: 1000, page_size: 1000
role_id: 1
}) })
tableData.value = (res?.lists || []).map((doctor: any) => ({ tableData.value = (res?.lists || []).map((doctor: any) => ({
doctorId: doctor.id, doctorId: doctor.id,
+33 -11
View File
@@ -1,11 +1,11 @@
<template> <template>
<div class="error"> <div class="error">
<div> <div class="error-panel">
<slot name="content"> <slot name="content">
<div class="error-code">{{ code }}</div> <div class="error-code">{{ code }}</div>
</slot> </slot>
<div class="text-lg text-tx-secondary mt-7 mb-7">{{ title }}</div> <div class="error-title">{{ title }}</div>
<el-button v-if="showBtn" type="primary" @click="router.go(-1)"> <el-button v-if="showBtn" type="primary" size="large" @click="router.go(-1)">
{{ second }} 秒后返回上一页 {{ second }} 秒后返回上一页
</el-button> </el-button>
</div> </div>
@@ -43,16 +43,38 @@ onUnmounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.error { .error {
text-align: center; text-align: center;
height: 100vh; min-height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
.error-code { background: var(--el-bg-color-page);
@apply text-primary; padding: 24px;
font-size: 150px; }
}
.el-button { .error-panel {
width: 176px; 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> </style>
+9 -9
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="fans-management"> <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 class="ls-form" :model="formData" inline>
<el-form-item class="w-[280px]" label="姓名"> <el-form-item class="w-[280px]" label="姓名">
<el-input <el-input
@@ -36,14 +36,14 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<!-- 列表区域 --> <!-- 列表区域 -->
<el-card class="!border-none mt-4" shadow="never"> <admin-page-data-panel v-loading="pager.loading">
<div class="mb-4"> <template #toolbar>
<el-button type="primary" @click="handleAdd">新增粉丝</el-button> <el-button type="primary" @click="handleAdd">新增粉丝</el-button>
</div> </template>
<el-table :data="pager.lists" size="large" v-loading="pager.loading"> <el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" width="70" /> <el-table-column label="ID" prop="id" width="70" />
<el-table-column label="姓名" prop="name" min-width="100" /> <el-table-column label="姓名" prop="name" min-width="100" />
<el-table-column label="手机号" prop="phone" min-width="130" /> <el-table-column label="手机号" prop="phone" min-width="130" />
@@ -81,10 +81,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="flex mt-4 justify-end"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
<!-- 新增/编辑粉丝弹窗 --> <!-- 新增/编辑粉丝弹窗 -->
<el-dialog <el-dialog
+3 -3
View File
@@ -3704,7 +3704,7 @@ function getYejiDeptComboOption(tb: YejiTable) {
const cats = rows.map(r => const cats = rows.map(r =>
r.dept_name.length > 10 ? `${r.dept_name.slice(0, 10)}` : r.dept_name r.dept_name.length > 10 ? `${r.dept_name.slice(0, 10)}` : r.dept_name
) )
const palette = ['#64748b', '#38bdf8', '#6366f1'] const palette = ['#64748b', '#38bdf8', '#0d9488']
const series: any[] = [ const series: any[] = [
{ {
name: '进线', name: '进线',
@@ -4029,8 +4029,8 @@ onMounted(async () => {
.yeji-page { .yeji-page {
min-width: 0; min-width: 0;
--yj-brand: #6366f1; --yj-brand: #0d9488;
--yj-brand-soft: #eef2ff; --yj-brand-soft: #f0fdfa;
--yj-teal: #0ea5e9; --yj-teal: #0ea5e9;
--yj-teal-soft: #f0f9ff; --yj-teal-soft: #f0f9ff;
--yj-accent: #f43f5e; --yj-accent: #f43f5e;
+7 -7
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<el-alert <el-alert
type="warning" type="warning"
title="温馨提示:用户账户变动记录" title="温馨提示:用户账户变动记录"
@@ -38,9 +38,9 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never"> <admin-page-data-panel v-loading="pager.loading">
<el-table size="large" v-loading="pager.loading" :data="pager.lists"> <el-table size="large" :data="pager.lists">
<el-table-column label="用户账号" prop="account" min-width="100" /> <el-table-column label="用户账号" prop="account" min-width="100" />
<el-table-column label="用户昵称" min-width="160"> <el-table-column label="用户昵称" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
@@ -71,10 +71,10 @@
<el-table-column label="来源单号" prop="source_sn" min-width="100" /> <el-table-column label="来源单号" prop="source_sn" min-width="100" />
<el-table-column label="记录时间" prop="create_time" min-width="120" /> <el-table-column label="记录时间" prop="create_time" min-width="120" />
</el-table> </el-table>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
</div> </div>
</template> </template>
<script lang="ts" setup name="balanceDetail"> <script lang="ts" setup name="balanceDetail">
+7 -7
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<el-alert <el-alert
type="warning" type="warning"
title="温馨提示:用户充值记录" title="温馨提示:用户充值记录"
@@ -54,9 +54,9 @@
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never"> <admin-page-data-panel v-loading="pager.loading">
<el-table size="large" v-loading="pager.loading" :data="pager.lists"> <el-table size="large" :data="pager.lists">
<el-table-column label="用户信息" min-width="160"> <el-table-column label="用户信息" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<div class="flex items-center"> <div class="flex items-center">
@@ -104,10 +104,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
</div> </div>
</template> </template>
<script lang="ts" setup name="rechargeRecord"> <script lang="ts" setup name="rechargeRecord">
+7 -7
View File
@@ -20,7 +20,7 @@
</div> </div>
</div> </div>
</el-card> </el-card>
<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 ref="formRef" class="mb-[-16px] mt-[16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="退款单号"> <el-form-item class="w-[280px]" label="退款单号">
<el-input <el-input
@@ -69,8 +69,8 @@
/> --> /> -->
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never"> <admin-page-data-panel>
<el-tabs v-model="activeTab" @tab-change="handleTabChange"> <el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane <el-tab-pane
v-for="(item, index) in tabLists" v-for="(item, index) in tabLists"
@@ -78,7 +78,7 @@
:name="index" :name="index"
:key="index" :key="index"
> >
<el-table size="large" 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="退款单号" prop="sn" min-width="190" />
<el-table-column label="用户信息" min-width="160"> <el-table-column label="用户信息" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
@@ -140,10 +140,10 @@
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
<refund-log v-model="showRefundLog" :refund-id="selectRefundId" /> <refund-log v-model="showRefundLog" :refund-id="selectRefundId" />
</div> </div>
</template> </template>
+5 -5
View File
@@ -1,14 +1,14 @@
<template> <template>
<div> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<el-alert <el-alert
type="warning" type="warning"
title="温馨提示:平台配置在各个场景下的通知发送方式和内容模板" title="温馨提示:平台配置在各个场景下的通知发送方式和内容模板"
:closable="false" :closable="false"
show-icon show-icon
></el-alert> ></el-alert>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never"> <admin-page-data-panel v-loading="pager.loading">
<el-tabs v-model="tabsActive" @tab-change="getLists"> <el-tabs v-model="tabsActive" @tab-change="getLists">
<el-tab-pane <el-tab-pane
v-for="(item, index) in tabsMap" v-for="(item, index) in tabsMap"
@@ -18,7 +18,7 @@
lazy lazy
></el-tab-pane> ></el-tab-pane>
</el-tabs> </el-tabs>
<el-table size="large" :data="pager.lists" 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="scene_name" min-width="120" />
<el-table-column label="通知类型" prop="type_desc" min-width="160" /> <el-table-column label="通知类型" prop="type_desc" min-width="160" />
<el-table-column label="短信通知" min-width="80"> <el-table-column label="短信通知" min-width="80">
@@ -44,7 +44,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </admin-page-data-panel>
</div> </div>
</template> </template>
<script lang="ts" setup name="notice"> <script lang="ts" setup name="notice">
@@ -1,6 +1,6 @@
<template> <template>
<div> <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 size="large" :data="state.lists">
<el-table-column label="短信渠道" prop="name" min-width="120" /> <el-table-column label="短信渠道" prop="name" min-width="120" />
<el-table-column label="状态" min-width="120"> <el-table-column label="状态" min-width="120">
@@ -22,7 +22,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </admin-page-data-panel>
<edit-popup ref="editRef" @success="getLists" /> <edit-popup ref="editRef" @success="getLists" />
</div> </div>
</template> </template>
@@ -33,13 +33,11 @@ import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>() const editRef = shallowRef<InstanceType<typeof EditPopup>>()
//
const state = reactive({ const state = reactive({
loading: false, loading: false,
lists: [] lists: []
}) })
//
const getLists = async () => { const getLists = async () => {
try { try {
state.loading = true state.loading = true
+8 -12
View File
@@ -1,7 +1,7 @@
<!-- 订单列表 --> <!-- 订单列表 -->
<template> <template>
<div class="order-list"> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<!-- 搜索表单 --> <!-- 搜索表单 -->
<el-form class="ls-form" :model="queryParams" inline> <el-form class="ls-form" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="订单号"> <el-form-item class="w-[280px]" label="订单号">
@@ -106,10 +106,10 @@
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<!-- Tab 筛选 + 今日收益 --> <!-- Tab 筛选 + 今日收益 + 数据表格 -->
<el-card class="!border-none mt-4" shadow="never"> <admin-page-data-panel v-loading="pager.loading">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<el-tabs v-model="patientAssociationTab" @tab-change="handleTabChange"> <el-tabs v-model="patientAssociationTab" @tab-change="handleTabChange">
<el-tab-pane label="全部" name="" /> <el-tab-pane label="全部" name="" />
@@ -122,10 +122,7 @@
<span class="text-gray-400">{{ todayRevenue.count }} </span> <span class="text-gray-400">{{ todayRevenue.count }} </span>
</div> </div>
</div> </div>
</el-card>
<!-- 数据表格 -->
<el-card class="!border-none mt-4" shadow="never">
<div v-perms="['order.order/zhipai']" class="mb-3 flex items-center gap-3"> <div v-perms="['order.order/zhipai']" class="mb-3 flex items-center gap-3">
<el-button type="primary" :disabled="!selectedOrderIds.length" @click="openAssignDialog"> <el-button type="primary" :disabled="!selectedOrderIds.length" @click="openAssignDialog">
将创建人指给医助 将创建人指给医助
@@ -136,7 +133,6 @@
</div> </div>
<el-table <el-table
ref="orderTableRef" ref="orderTableRef"
v-loading="pager.loading"
:data="pager.lists" :data="pager.lists"
row-key="id" row-key="id"
size="large" size="large"
@@ -283,10 +279,10 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
<!-- 详情弹窗 --> <!-- 详情弹窗 -->
<el-dialog <el-dialog
@@ -1,6 +1,6 @@
<template> <template>
<div class="department"> <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 ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="部门名称" prop="name"> <el-form-item class="w-[280px]" label="部门名称" prop="name">
<el-input <el-input
@@ -22,22 +22,21 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never">
<div> <admin-page-data-panel v-loading="loading">
<template #toolbar>
<el-button v-perms="['dept.dept/add']" type="primary" @click="handleAdd()"> <el-button v-perms="['dept.dept/add']" type="primary" @click="handleAdd()">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
新增 新增
</el-button> </el-button>
<el-button @click="handleExpand"> 展开/折叠 </el-button> <el-button @click="handleExpand">展开/折叠</el-button>
</div> </template>
<el-table <el-table
ref="tableRef" ref="tableRef"
class="mt-4"
size="large" size="large"
v-loading="loading"
:data="lists" :data="lists"
row-key="id" row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
@@ -68,7 +67,6 @@
}}</el-tag> }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="排序" prop="sort" min-width="100" /> <el-table-column label="排序" prop="sort" min-width="100" />
<el-table-column label="更新时间" prop="update_time" min-width="180" /> <el-table-column label="更新时间" prop="update_time" min-width="180" />
<el-table-column label="操作" width="160" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
@@ -101,7 +99,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </admin-page-data-panel>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
@@ -120,14 +118,18 @@ let isExpand = false
const loading = ref(false) const loading = ref(false)
const lists = ref<any[]>([]) const lists = ref<any[]>([])
const queryParams = reactive({ const queryParams = reactive({
status: '', name: '',
name: '' status: ''
}) })
const showEdit = ref(false) const showEdit = ref(false)
const getLists = async () => { const getLists = async () => {
loading.value = true loading.value = true
lists.value = await deptLists(queryParams) try {
loading.value = false lists.value = await deptLists(queryParams)
} finally {
loading.value = false
}
} }
const resetParams = () => { const resetParams = () => {
+11 -10
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="post-lists"> <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 ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="岗位编码"> <el-form-item class="w-[280px]" label="岗位编码">
<el-input <el-input
@@ -31,17 +31,18 @@
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never">
<div> <admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button v-perms="['dept.jobs/add']" type="primary" @click="handleAdd()"> <el-button v-perms="['dept.jobs/add']" type="primary" @click="handleAdd()">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
新增 新增
</el-button> </el-button>
</div> </template>
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists"> <el-table size="large" :data="pager.lists">
<el-table-column label="岗位编码" prop="code" min-width="100" /> <el-table-column label="岗位编码" prop="code" min-width="100" />
<el-table-column label="岗位名称" prop="name" min-width="100" /> <el-table-column label="岗位名称" prop="name" min-width="100" />
<el-table-column label="排序" prop="sort" min-width="100" /> <el-table-column label="排序" prop="sort" min-width="100" />
@@ -75,10 +76,10 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="flex justify-end mt-4"> <template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
+1 -1
View File
@@ -1275,7 +1275,7 @@ onUnmounted(() => {
.float-action.edit { .float-action.edit {
color: #fff; color: #fff;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%);
} }
.float-action.edit:hover, .float-action.edit:hover,
+73 -73
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="admin"> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<el-form class="mb-[-16px]" :model="formData" inline> <el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="管理员账号"> <el-form-item class="w-[280px]" label="管理员账号">
<el-input <el-input
@@ -40,77 +40,78 @@
/> />
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd"> <admin-page-data-panel v-loading="pager.loading">
<template #icon> <template #toolbar>
<icon name="el-icon-Plus" /> <el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
</template> <template #icon>
新增 <icon name="el-icon-Plus" />
</el-button> </template>
<div class="mt-4"> 新增
<el-table :data="pager.lists" size="large"> </el-button>
<el-table-column label="ID" prop="id" min-width="60" />> </template>
<el-table-column label="头像" min-width="100"> <el-table :data="pager.lists" size="large">
<template #default="{ row }"> <el-table-column label="ID" prop="id" min-width="60" />
<el-avatar :size="50" :src="row.avatar"></el-avatar> <el-table-column label="头像" min-width="100">
</template> <template #default="{ row }">
</el-table-column> <el-avatar :size="50" :src="row.avatar"></el-avatar>
<el-table-column label="账号" prop="account" min-width="100" /> </template>
<el-table-column label="名称" prop="name" min-width="100" /> </el-table-column>
<el-table-column <el-table-column label="账号" prop="account" min-width="100" />
label="角色" <el-table-column label="名称" prop="name" min-width="100" />
prop="role_name" <el-table-column
min-width="100" label="角色"
show-tooltip-when-overflow prop="role_name"
/> min-width="100"
<el-table-column show-tooltip-when-overflow
label="部门" />
prop="dept_name" <el-table-column
min-width="100" label="部门"
show-tooltip-when-overflow prop="dept_name"
/> min-width="100"
<el-table-column label="创建时间" prop="create_time" min-width="180" /> show-tooltip-when-overflow
<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="创建时间" prop="create_time" min-width="180" />
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']"> <el-table-column label="最近登录时间" prop="login_time" min-width="180" />
<template #default="{ row }"> <el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
<el-switch <el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
v-if="row.root != 1" <template #default="{ row }">
v-model="row.disable" <el-switch
:active-value="0" v-if="row.root != 1"
:inactive-value="1" v-model="row.disable"
@change="changeStatus(row)" :active-value="0"
/> :inactive-value="1"
</template> @change="changeStatus(row)"
</el-table-column> />
<el-table-column label="操作" width="120" fixed="right"> </template>
<template #default="{ row }"> </el-table-column>
<el-button <el-table-column label="操作" width="120" fixed="right">
v-perms="['auth.admin/edit']" <template #default="{ row }">
type="primary" <el-button
link v-perms="['auth.admin/edit']"
@click="handleEdit(row)" type="primary"
> link
编辑 @click="handleEdit(row)"
</el-button> >
<el-button 编辑
v-if="row.root != 1" </el-button>
v-perms="['auth.admin/delete']" <el-button
type="danger" v-if="row.root != 1"
link v-perms="['auth.admin/delete']"
@click="handleDelete(row.id)" type="danger"
> link
删除 @click="handleDelete(row.id)"
</el-button> >
</template> 删除
</el-table-column> </el-button>
</el-table> </template>
</div> </el-table-column>
<div class="flex mt-4 justify-end"> </el-table>
<template #footer>
<pagination v-model="pager" @change="getLists" /> <pagination v-model="pager" @change="getLists" />
</div> </template>
</el-card> </admin-page-data-panel>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
@@ -125,7 +126,6 @@ import feedback from '@/utils/feedback'
import EditPopup from './edit.vue' import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>() const editRef = shallowRef<InstanceType<typeof EditPopup>>()
//
const formData = reactive({ const formData = reactive({
account: '', account: '',
name: '', name: '',
+6 -8
View File
@@ -1,20 +1,18 @@
<template> <template>
<div class="menu-lists"> <div>
<el-card class="!border-none" shadow="never"> <admin-page-data-panel v-loading="pager.loading">
<div> <template #toolbar>
<el-button v-perms="['auth.menu/add']" type="primary" @click="handleAdd()"> <el-button v-perms="['auth.menu/add']" type="primary" @click="handleAdd()">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
新增 新增
</el-button> </el-button>
<el-button @click="handleExpand"> 展开/折叠 </el-button> <el-button @click="handleExpand">展开/折叠</el-button>
</div> </template>
<el-table <el-table
ref="tableRef" ref="tableRef"
class="mt-4"
size="large" size="large"
v-loading="pager.loading"
:data="pager.lists" :data="pager.lists"
row-key="id" row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
@@ -87,7 +85,7 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </admin-page-data-panel>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
+54 -59
View File
@@ -1,68 +1,64 @@
<template> <template>
<div class="role-lists"> <div>
<el-card class="!border-none" shadow="never"> <admin-page-data-panel v-loading="pager.loading">
<div> <template #toolbar>
<el-button v-perms="['auth.role/add']" type="primary" @click="handleAdd"> <el-button v-perms="['auth.role/add']" type="primary" @click="handleAdd">
<template #icon> <template #icon>
<icon name="el-icon-Plus" /> <icon name="el-icon-Plus" />
</template> </template>
新增 新增
</el-button> </el-button>
</div> </template>
<div class="mt-4" v-loading="pager.loading"> <el-table :data="pager.lists" size="large">
<div> <el-table-column prop="id" label="ID" min-width="100" />
<el-table :data="pager.lists" size="large"> <el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="id" label="ID" min-width="100" /> <el-table-column
<el-table-column prop="name" label="名称" min-width="150" /> prop="desc"
<el-table-column label="备注"
prop="desc" min-width="150"
label="备注" show-overflow-tooltip
min-width="150" />
show-overflow-tooltip <el-table-column prop="sort" label="排序" min-width="100" />
/> <el-table-column label="数据范围" min-width="140">
<el-table-column prop="sort" label="排序" min-width="100" /> <template #default="{ row }">
<el-table-column label="数据范围" min-width="140"> {{ dataScopeLabel(row.data_scope) }}
<template #default="{ row }"> </template>
{{ dataScopeLabel(row.data_scope) }} </el-table-column>
</template> <el-table-column prop="num" label="管理员人数" min-width="100" />
</el-table-column> <el-table-column prop="create_time" label="创建时间" min-width="180" />
<el-table-column prop="num" label="管理员人数" min-width="100" /> <el-table-column label="操作" width="200" fixed="right">
<el-table-column prop="create_time" label="创建时间" min-width="180" /> <template #default="{ row }">
<el-table-column label="操作" width="200" fixed="right"> <el-button
<template #default="{ row }"> link
<el-button type="primary"
link v-perms="['auth.role/edit']"
type="primary" @click="handleEdit(row)"
v-perms="['auth.role/edit']" >
@click="handleEdit(row)" 编辑
> </el-button>
编辑 <el-button
</el-button> link
<el-button type="primary"
link v-perms="['auth.role/edit']"
type="primary" @click="handleAuth(row)"
v-perms="['auth.role/edit']" >
@click="handleAuth(row)" 分配权限
> </el-button>
分配权限 <el-button
</el-button> v-perms="['auth.role/delete']"
<el-button link
v-perms="['auth.role/delete']" type="danger"
link @click="handleDelete(row.id)"
type="danger" >
@click="handleDelete(row.id)" 删除
> </el-button>
删除 </template>
</el-button> </el-table-column>
</template> </el-table>
</el-table-column> <template #footer>
</el-table> <pagination v-model="pager" @change="getLists" />
</div> </template>
<div class="flex justify-end mt-4"> </admin-page-data-panel>
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
<auth-popup v-if="showAuth" ref="authRef" @success="getLists" @close="showAuth = false" /> <auth-popup v-if="showAuth" ref="authRef" @success="getLists" @close="showAuth = false" />
</div> </div>
@@ -115,7 +111,6 @@ const dataScopeLabel = (scope: number | string | null | undefined) => {
) )
} }
//
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除?') await feedback.confirm('确定要删除?')
await roleDelete({ id }) await roleDelete({ id })
+54 -57
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="dict-type"> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<el-page-header class="mb-4" content="数据管理" @back="$router.back()" /> <el-page-header class="mb-4" content="数据管理" @back="$router.back()" />
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline> <el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="字典名称"> <el-form-item class="w-[280px]" label="字典名称">
@@ -28,9 +28,10 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never">
<div> <admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button <el-button
v-perms="['setting.dict.dict_data/add']" v-perms="['setting.dict.dict_data/add']"
type="primary" type="primary"
@@ -52,58 +53,54 @@
</template> </template>
删除 删除
</el-button> </el-button>
</div> </template>
<div class="mt-4" v-loading="pager.loading"> <el-table
<div> :data="pager.lists"
<el-table size="large"
:data="pager.lists" @selection-change="handleSelectionChange"
size="large" >
@selection-change="handleSelectionChange" <el-table-column type="selection" width="55" />
> <el-table-column label="ID" prop="id" />
<el-table-column type="selection" width="55" /> <el-table-column label="数据名称" prop="name" min-width="120" />
<el-table-column label="ID" prop="id" /> <el-table-column label="数据值" prop="value" min-width="120" />
<el-table-column label="数据名称" prop="name" min-width="120" /> <el-table-column label="状态">
<el-table-column label="数据值" prop="value" min-width="120" /> <template v-slot="{ row }">
<el-table-column label="状态"> <el-tag v-if="row.status == 1">正常</el-tag>
<template v-slot="{ row }"> <el-tag v-else type="danger">停用</el-tag>
<el-tag v-if="row.status == 1">正常</el-tag> </template>
<el-tag v-else type="danger">停用</el-tag> </el-table-column>
</template> <el-table-column
</el-table-column> label="备注"
<el-table-column prop="remark"
label="备注" min-width="120"
prop="remark" show-tooltip-when-overflow
min-width="120" />
show-tooltip-when-overflow <el-table-column label="排序" prop="sort" />
/> <el-table-column label="操作" width="120" fixed="right">
<el-table-column label="排序" prop="sort" /> <template #default="{ row }">
<el-table-column label="操作" width="120" fixed="right"> <el-button
<template #default="{ row }"> v-perms="['setting.dict.dict_data/edit']"
<el-button link
v-perms="['setting.dict.dict_data/edit']" type="primary"
link @click="handleEdit(row)"
type="primary" >
@click="handleEdit(row)" 编辑
> </el-button>
编辑 <el-button
</el-button> v-perms="['setting.dict.dict_data/delete']"
<el-button link
v-perms="['setting.dict.dict_data/delete']" type="danger"
link @click="handleDelete(row.id)"
type="danger" >
@click="handleDelete(row.id)" 删除
> </el-button>
删除 </template>
</el-button> </el-table-column>
</template> </el-table>
</el-table-column> <template #footer>
</el-table> <pagination v-model="pager" @change="getLists" />
</div> </template>
<div class="flex justify-end mt-4"> </admin-page-data-panel>
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
+65 -69
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="dict-type"> <div>
<el-card class="!border-none" shadow="never"> <admin-page-filter-panel>
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline> <el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="字典名称"> <el-form-item class="w-[280px]" label="字典名称">
<el-input v-model="queryParams.name" clearable @keyup.enter="resetPage" /> <el-input v-model="queryParams.name" clearable @keyup.enter="resetPage" />
@@ -20,9 +20,10 @@
<el-button @click="resetParams">重置</el-button> <el-button @click="resetParams">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<el-card class="!border-none mt-4" shadow="never">
<div> <admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button <el-button
v-perms="['setting.dict.dict_type/add']" v-perms="['setting.dict.dict_type/add']"
type="primary" type="primary"
@@ -44,69 +45,65 @@
</template> </template>
删除 删除
</el-button> </el-button>
</div> </template>
<div class="mt-4" v-loading="pager.loading"> <el-table
<div> :data="pager.lists"
<el-table size="large"
:data="pager.lists" @selection-change="handleSelectionChange"
size="large" >
@selection-change="handleSelectionChange" <el-table-column type="selection" width="55" />
> <el-table-column label="ID" prop="id" />
<el-table-column type="selection" width="55" /> <el-table-column label="字典名称" prop="name" min-width="120" />
<el-table-column label="ID" prop="id" /> <el-table-column label="字典类型" prop="type" min-width="120" />
<el-table-column label="字典名称" prop="name" min-width="120" /> <el-table-column label="状态">
<el-table-column label="字典类型" prop="type" min-width="120" /> <template v-slot="{ row }">
<el-table-column label="状态"> <el-tag v-if="row.status == 1">正常</el-tag>
<template v-slot="{ row }"> <el-tag v-else type="danger">停用</el-tag>
<el-tag v-if="row.status == 1">正常</el-tag> </template>
<el-tag v-else type="danger">停用</el-tag> </el-table-column>
</template> <el-table-column label="备注" prop="remark" show-tooltip-when-overflow />
</el-table-column> <el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="备注" prop="remark" show-tooltip-when-overflow /> <el-table-column label="操作" width="190" fixed="right">
<el-table-column label="创建时间" prop="create_time" min-width="180" /> <template #default="{ row }">
<el-table-column label="操作" width="190" fixed="right"> <el-button
<template #default="{ row }"> v-perms="['setting.dict.dict_type/edit']"
<el-button link
v-perms="['setting.dict.dict_type/edit']" type="primary"
link @click="handleEdit(row)"
type="primary" >
@click="handleEdit(row)" 编辑
> </el-button>
编辑 <el-button
</el-button> v-perms="['setting.dict.dict_data/lists']"
<el-button type="primary"
v-perms="['setting.dict.dict_data/lists']" link
type="primary" >
link <router-link
> :to="{
<router-link path: getRoutePath('setting.dict.dict_data/lists'),
:to="{ query: {
path: getRoutePath('setting.dict.dict_data/lists'), id: row.id
query: { }
id: row.id }"
} >
}" 数据管理
> </router-link>
数据管理 </el-button>
</router-link> <el-button
</el-button> v-perms="['setting.dict.dict_type/delete']"
<el-button link
v-perms="['setting.dict.dict_type/delete']" type="danger"
link @click="handleDelete(row.id)"
type="danger" >
@click="handleDelete(row.id)" 删除
> </el-button>
删除 </template>
</el-button> </el-table-column>
</template> </el-table>
</el-table-column> <template #footer>
</el-table> <pagination v-model="pager" @change="getLists" />
</div> </template>
<div class="flex justify-end mt-4"> </admin-page-data-panel>
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" /> <edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div> </div>
</template> </template>
@@ -151,7 +148,6 @@ const handleEdit = async (data: any) => {
editRef.value?.setFormData(data) editRef.value?.setFormData(data)
} }
//
const handleDelete = async (id: any[] | number) => { const handleDelete = async (id: any[] | number) => {
await feedback.confirm('确定要删除?') await feedback.confirm('确定要删除?')
await dictTypeDelete({ id }) await dictTypeDelete({ id })
+154 -61
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="conversion-stats-page"> <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 :inline="true" :model="queryParams" class="stats-filter-form">
<el-form-item label="统计维度"> <el-form-item label="统计维度">
<el-segmented <el-segmented
@@ -89,7 +89,7 @@
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </admin-page-filter-panel>
<div class="stats-kpi-grid"> <div class="stats-kpi-grid">
<div <div
@@ -129,7 +129,7 @@
<el-card class="!border-none" shadow="never"> <el-card class="!border-none" shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>诊单金额占比</span> <span>{{ amountPieTitle }}</span>
<span class="card-hint">TOP 10</span> <span class="card-hint">TOP 10</span>
</div> </div>
</template> </template>
@@ -146,17 +146,17 @@
<el-card class="!border-none" shadow="never"> <el-card class="!border-none" shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>加粉占比</span> <span>{{ secondaryPieTitle }}</span>
<span class="card-hint">TOP 10</span> <span class="card-hint">TOP 10</span>
</div> </div>
</template> </template>
<v-charts <v-charts
v-if="fanPieHasData" v-if="secondaryPieHasData"
class="stats-chart" class="stats-chart"
:option="fanPieOption" :option="secondaryPieOption"
autoresize autoresize
/> />
<el-empty v-else description="暂无加粉数据" /> <el-empty v-else :description="secondaryPieEmptyText" />
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
@@ -311,10 +311,13 @@ const overview = reactive<Record<string, any>>({
amounts: [], amounts: [],
order_counts: [], order_counts: [],
fan_counts: [], fan_counts: [],
rois: [] rois: [],
appointment_counts: [],
interview_counts: []
}, },
amount_share: [], amount_share: [],
fan_share: [] fan_share: [],
order_share: []
} }
}) })
@@ -392,7 +395,7 @@ const currentEntityId = computed({
} }
}) })
const summaryCards: MetricCard[] = [ const defaultSummaryCards: MetricCard[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' }, { key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' }, { key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' }, { key: 'free_appointment_count', label: '免费挂号', type: 'count' },
@@ -410,7 +413,15 @@ const summaryCards: MetricCard[] = [
{ key: 'roi', label: 'ROI', type: 'ratio' } { key: 'roi', label: 'ROI', type: 'ratio' }
] ]
const tableColumns: MetricColumn[] = [ const doctorSummaryCards: MetricCard[] = [
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
{ key: 'interview_count', label: '面诊数', type: 'count' },
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
{ key: 'completed_order_amount', label: '开药金额', type: 'money' },
{ key: 'receive_rate', label: '接诊率', type: 'percent' }
]
const defaultTableColumns: MetricColumn[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' }, { key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true }, { key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true }, { key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
@@ -431,6 +442,18 @@ const tableColumns: MetricColumn[] = [
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 } { key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
] ]
const doctorTableColumns: MetricColumn[] = [
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
{ key: 'interview_count', label: '面诊数', type: 'count' },
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
{ key: 'completed_order_amount', label: '开药金额', type: 'money', minWidth: 120 },
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 }
]
const isDoctorDimension = computed(() => queryParams.dimension === 'doctor')
const summaryCards = computed(() => isDoctorDimension.value ? doctorSummaryCards : defaultSummaryCards)
const tableColumns = computed(() => isDoctorDimension.value ? doctorTableColumns : defaultTableColumns)
const dateRangeText = computed(() => { const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择' if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}` return `${overview.date_range[0]}${overview.date_range[1]}`
@@ -438,7 +461,14 @@ const dateRangeText = computed(() => {
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0) const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0) const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0) const secondaryPieHasData = computed(() =>
isDoctorDimension.value
? (overview.charts.order_share?.length || 0) > 0
: (overview.charts.fan_share?.length || 0) > 0
)
const amountPieTitle = computed(() => isDoctorDimension.value ? '开药金额占比' : '诊单金额占比')
const secondaryPieTitle = computed(() => isDoctorDimension.value ? '接诊单数占比' : '加粉占比')
const secondaryPieEmptyText = computed(() => isDoctorDimension.value ? '暂无接诊数据' : '暂无加粉数据')
const rankingChartData = computed(() => { const rankingChartData = computed(() => {
const ranking = overview.charts?.ranking || {} const ranking = overview.charts?.ranking || {}
@@ -448,56 +478,110 @@ const rankingChartData = computed(() => {
return { return {
names, names,
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []), amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
appointmentCounts: normalize(Array.isArray(ranking.appointment_counts) ? ranking.appointment_counts : []),
interviewCounts: normalize(Array.isArray(ranking.interview_counts) ? ranking.interview_counts : []),
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []), orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []), fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
} }
}) })
const rankingChartOption = computed(() => ({ const rankingChartOption = computed(() => {
tooltip: { trigger: 'axis' }, const common = {
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] }, tooltip: { trigger: 'axis' },
grid: { left: 48, right: 24, top: 48, bottom: 48 }, grid: { left: 48, right: 24, top: 48, bottom: 48 },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: rankingChartData.value.names, data: rankingChartData.value.names,
axisLabel: { axisLabel: {
interval: 0, interval: 0,
rotate: rankingChartData.value.names.length > 6 ? 25 : 0 rotate: rankingChartData.value.names.length > 6 ? 25 : 0
} }
},
yAxis: [
{ type: 'value', name: '金额 / 数量' }
],
series: [
{
name: '诊单金额',
type: 'bar',
barMaxWidth: 36,
data: rankingChartData.value.amounts,
itemStyle: { color: '#4a78ff' }
}, },
{ yAxis: [
name: '接诊诊单', { type: 'value', name: '金额 / 数量' }
type: 'line', ]
smooth: true, }
showSymbol: true,
symbolSize: 8, if (!isDoctorDimension.value) {
lineStyle: { width: 3 }, return {
data: rankingChartData.value.orderCounts, ...common,
itemStyle: { color: '#15b8a6' } legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
}, series: [
{ {
name: '加粉数', name: '诊单金额',
type: 'line', type: 'bar',
smooth: true, barMaxWidth: 36,
showSymbol: true, data: rankingChartData.value.amounts,
symbolSize: 8, itemStyle: { color: '#4a78ff' }
lineStyle: { width: 3 }, },
data: rankingChartData.value.fanCounts, {
itemStyle: { color: '#ff9f43' } name: '接诊诊单',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.orderCounts,
itemStyle: { color: '#15b8a6' }
},
{
name: '加粉数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.fanCounts,
itemStyle: { color: '#ff9f43' }
}
]
} }
] }
}))
return {
...common,
legend: { data: ['开药金额', '挂号数', '面诊数', '接诊单数'] },
series: [
{
name: '开药金额',
type: 'bar',
barMaxWidth: 36,
data: rankingChartData.value.amounts,
itemStyle: { color: '#4a78ff' }
},
{
name: '挂号数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.appointmentCounts,
itemStyle: { color: '#15b8a6' }
},
{
name: '面诊数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.interviewCounts,
itemStyle: { color: '#ff9f43' }
},
{
name: '接诊单数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.orderCounts,
itemStyle: { color: '#7c3aed' }
}
]
}
})
const amountPieOption = computed(() => ({ const amountPieOption = computed(() => ({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
@@ -511,7 +595,7 @@ const amountPieOption = computed(() => ({
}, },
series: [ series: [
{ {
name: '诊单金额', name: amountPieTitle.value,
type: 'pie', type: 'pie',
radius: ['42%', '70%'], radius: ['42%', '70%'],
center: ['50%', '42%'], center: ['50%', '42%'],
@@ -531,7 +615,7 @@ const amountPieOption = computed(() => ({
] ]
})) }))
const fanPieOption = computed(() => ({ const secondaryPieOption = computed(() => ({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
legend: { legend: {
bottom: 0, bottom: 0,
@@ -543,7 +627,7 @@ const fanPieOption = computed(() => ({
}, },
series: [ series: [
{ {
name: '加粉数', name: secondaryPieTitle.value,
type: 'pie', type: 'pie',
radius: ['42%', '70%'], radius: ['42%', '70%'],
center: ['50%', '42%'], center: ['50%', '42%'],
@@ -558,7 +642,7 @@ const fanPieOption = computed(() => ({
labelLayout: { labelLayout: {
hideOverlap: true, hideOverlap: true,
}, },
data: overview.charts.fan_share data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
} }
] ]
})) }))
@@ -658,9 +742,18 @@ const handleReset = () => {
fetchOverview() fetchOverview()
} }
const calcDoctorReceiveRate = (source: Record<string, any>) => {
const completedOrderCount = Number(source?.completed_order_count || 0)
const interviewCount = Number(source?.interview_count || 0)
if (interviewCount <= 0) return 0
return (completedOrderCount / interviewCount) * 100
}
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => { const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
if (placeholder) return '—' if (placeholder) return '—'
const value = source?.[key] ?? 0 const value = isDoctorDimension.value && key === 'receive_rate'
? calcDoctorReceiveRate(source)
: source?.[key] ?? 0
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}` if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%` if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
if (type === 'ratio') return Number(value || 0).toFixed(2) if (type === 'ratio') return Number(value || 0).toFixed(2)
+6 -1
View File
@@ -556,7 +556,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
} }
const validateIdCard = (rule: any, value: any, callback: any) => { const validateIdCard = (rule: any, value: any, callback: any) => {
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) { if (!value) {
callback()
return
}
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
callback(new Error('身份证号格式不正确')) callback(new Error('身份证号格式不正确'))
return return
} }
@@ -572,6 +576,7 @@ const formRules = {
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }], diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }], syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }], diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }] diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
} }
+88 -25
View File
@@ -103,11 +103,30 @@
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="身份证号" prop="id_card"> <el-form-item label="身份证号" prop="id_card">
<template v-if="showIdCardMaskedEdit">
<el-input
:model-value="
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
"
readonly
placeholder="点击可查看完整身份证号"
maxlength="18"
class="cursor-pointer phone-mask-toggle"
@click="toggleIdCardReveal"
/>
<p
v-if="originalIdCard && !idCardRevealUnlocked"
class="text-xs text-gray-400 mt-1"
>
当前为脱敏显示点击输入框可查看完整号码
</p>
</template>
<el-input <el-input
v-else
v-model="formData.id_card" v-model="formData.id_card"
placeholder="请输入身份证号" placeholder="请输入身份证号"
maxlength="18" maxlength="18"
:disabled="!canEditPatientBasicFields" :disabled="!canEditIdCard"
@focus="handleIdCardFocus" @focus="handleIdCardFocus"
@blur="handleIdCardBlur" @blur="handleIdCardBlur"
/> />
@@ -822,6 +841,21 @@ const showPhoneMaskedEdit = computed(() => {
return !hasPhonePlainPermission.value return !hasPhonePlainPermission.value
}) })
const phoneRevealUnlocked = ref(false) const phoneRevealUnlocked = ref(false)
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
const showIdCardMaskedEdit = computed(() => {
if (mode.value !== 'edit') return false
if (!originalIdCard.value) return false
if (!canEditPatientBasicFields.value) return true
return !hasPhonePlainPermission.value
})
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
const canEditIdCard = computed(() => {
if (!canEditPatientBasicFields.value) return false
if (mode.value === 'add') return true
if (!originalIdCard.value) return true
return hasPhonePlainPermission.value
})
const idCardRevealUnlocked = ref(false)
const activeTab = ref('basic') const activeTab = ref('basic')
const drawerTitle = computed(() => { const drawerTitle = computed(() => {
if (viewOnly.value) return '诊单详情' if (viewOnly.value) return '诊单详情'
@@ -961,6 +995,11 @@ const togglePhoneReveal = () => {
phoneRevealUnlocked.value = !phoneRevealUnlocked.value phoneRevealUnlocked.value = !phoneRevealUnlocked.value
} }
const toggleIdCardReveal = () => {
if (!originalIdCard.value) return
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
}
const maskIdCard = (idCard: string) => { const maskIdCard = (idCard: string) => {
if (!idCard) return idCard if (!idCard) return idCard
if (idCard.length === 15) { if (idCard.length === 15) {
@@ -1031,43 +1070,40 @@ const handlePhoneBlur = async () => {
} }
} }
// - // -
const handleIdCardFocus = () => { const handleIdCardFocus = () => {
isIdCardFocused.value = true isIdCardFocused.value = true
if (originalIdCard.value) { if (!originalIdCard.value) return
if (hasPhonePlainPermission.value) {
formData.value.id_card = originalIdCard.value
return
}
if (mode.value === 'add') {
formData.value.id_card = originalIdCard.value formData.value.id_card = originalIdCard.value
} }
} }
// - //
const handleIdCardBlur = async () => { const handleIdCardBlur = async () => {
isIdCardFocused.value = false isIdCardFocused.value = false
// const processIdCard = async (idCard: string) => {
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) { if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
originalIdCard.value = formData.value.id_card const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
// const birthDay = parseInt(idCard.substring(12, 14))
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
//
const id = formData.value.id_card
const birthYear = parseInt(id.substring(6, 10))
const birthMonth = parseInt(id.substring(10, 12))
const birthDay = parseInt(id.substring(12, 14))
const today = new Date() const today = new Date()
let age = today.getFullYear() - birthYear let age = today.getFullYear() - birthYear
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) { if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
age-- age--
} }
formData.value.age = age formData.value.age = age
//
try { try {
const result = await checkIdCard({ const result = await checkIdCard({
id_card: formData.value.id_card, id_card: idCard,
id: formData.value.id || '' id: formData.value.id || ''
}) })
if (result.exists) { if (result.exists) {
ElMessage.warning(result.message) ElMessage.warning(result.message)
} }
@@ -1075,8 +1111,24 @@ const handleIdCardBlur = async () => {
console.error('检查身份证号失败:', error) console.error('检查身份证号失败:', error)
} }
} }
}
//
if (hasPhonePlainPermission.value) {
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
await processIdCard(formData.value.id_card)
}
return
}
//
if (mode.value === 'edit' && originalIdCard.value) {
return
}
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
await processIdCard(formData.value.id_card)
formData.value.id_card = maskIdCard(formData.value.id_card) formData.value.id_card = maskIdCard(formData.value.id_card)
} }
} }
@@ -1099,7 +1151,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
const validateIdCard = (rule: any, value: any, callback: any) => { const validateIdCard = (rule: any, value: any, callback: any) => {
// 使 // 使
const idCardToValidate = originalIdCard.value || value const idCardToValidate = originalIdCard.value || value
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) { if (!idCardToValidate) {
callback()
return
}
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
callback(new Error('身份证号格式不正确')) callback(new Error('身份证号格式不正确'))
return return
} }
@@ -1114,6 +1170,7 @@ const formRules = {
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }], age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }], fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }], diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }], diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
} }
@@ -1213,7 +1270,7 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
latestPrescriptionOrder.value = data.latest_prescription_order ?? null latestPrescriptionOrder.value = data.latest_prescription_order ?? null
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '') data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
data.id_card = maskIdCard(data.id_card || '') data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
formData.value = data formData.value = data
formData.value.create_source = data.create_source ?? '' formData.value.create_source = data.create_source ?? ''
@@ -1234,6 +1291,7 @@ const open = async (type: string, id?: number) => {
visible.value = true visible.value = true
activeTab.value = 'basic' // activeTab.value = 'basic' //
phoneRevealUnlocked.value = false phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState() resetPatientBasicLockState()
// //
@@ -1257,6 +1315,7 @@ const openViewOnly = async (id: number) => {
visible.value = true visible.value = true
activeTab.value = 'basic' activeTab.value = 'basic'
phoneRevealUnlocked.value = false phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState() resetPatientBasicLockState()
await getDictOptions() await getDictOptions()
await loadDiagnosisDetailIntoForm(id) await loadDiagnosisDetailIntoForm(id)
@@ -1282,6 +1341,7 @@ const handleSubmit = async () => {
if (result && result.id) { if (result && result.id) {
mode.value = 'edit' mode.value = 'edit'
phoneRevealUnlocked.value = false phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
formData.value.id = result.id formData.value.id = result.id
// patient_id // patient_id
@@ -1296,7 +1356,9 @@ const handleSubmit = async () => {
formData.value.phone = hasPhonePlainPermission.value formData.value.phone = hasPhonePlainPermission.value
? detail.phone || '' ? detail.phone || ''
: maskPhone(detail.phone || '') : maskPhone(detail.phone || '')
formData.value.id_card = maskIdCard(detail.id_card || '') formData.value.id_card = hasPhonePlainPermission.value
? detail.id_card || ''
: maskIdCard(detail.id_card || '')
} catch (error) { } catch (error) {
console.error('获取详情失败:', error) console.error('获取详情失败:', error)
} }
@@ -1398,6 +1460,7 @@ const handleClose = () => {
isPhoneFocused.value = false isPhoneFocused.value = false
isIdCardFocused.value = false isIdCardFocused.value = false
phoneRevealUnlocked.value = false phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
viewOnly.value = false viewOnly.value = false
visible.value = false visible.value = false
+103 -68
View File
@@ -18,7 +18,7 @@
</span> </span>
<div> <div>
<div class="wb-section-name">订单统计</div> <div class="wb-section-name">订单统计</div>
<div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '' }}</div> <div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '-' }}</div>
</div> </div>
</div> </div>
<div class="wb-toolbar"> <div class="wb-toolbar">
@@ -821,8 +821,8 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.workbench-page { .workbench-page {
min-height: 100%; min-height: 100%;
padding: 20px 20px 40px; padding: 4px 0 24px;
background: linear-gradient(160deg, #eef2ff 0%, #f8fafc 38%, #f1f5f9 100%); background: transparent;
} }
.wb-hero { .wb-hero {
@@ -830,56 +830,86 @@ onMounted(() => {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 20px;
margin-bottom: 22px; margin-bottom: 24px;
padding: 22px 26px; padding: 28px 28px;
border-radius: 20px; border-radius: var(--admin-radius-xl);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.65) 100%); border: 1px solid var(--el-border-color-lighter);
border: 1px solid rgba(255, 255, 255, 0.95); background: var(--admin-surface-glass);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06); 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 { .wb-hero-title {
margin: 0 0 6px; margin: 0 0 8px;
font-size: 26px; font-size: 28px;
font-weight: 700; font-weight: 800;
letter-spacing: 0.02em; letter-spacing: -0.03em;
color: #0f172a; background: var(--admin-brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
} }
.wb-hero-desc { .wb-hero-desc {
margin: 0; margin: 0;
font-size: 14px; font-size: 15px;
color: #64748b; color: var(--el-text-color-secondary);
} }
.wb-refresh-btn { .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 { .wb-section {
margin-bottom: 20px; margin-bottom: 20px;
border-radius: 20px; border-radius: var(--admin-radius-xl);
background: rgba(255, 255, 255, 0.88); background: var(--admin-surface-elevated);
border: 1px solid rgba(255, 255, 255, 0.9); border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.05); box-shadow: var(--el-box-shadow);
overflow: hidden; overflow: hidden;
backdrop-filter: blur(10px);
} }
.wb-section--diagnosis { .wb-section--diagnosis {
border-top: 3px solid transparent; border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #3b82f6, #60a5fa) 1; border-image: linear-gradient(90deg, #06b6d4, #10b981) 1;
} }
.wb-section--order { .wb-section--order {
border-top: 3px solid transparent; border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #8b5cf6, #a78bfa) 1; border-image: linear-gradient(90deg, #0891b2, #6366f1) 1;
} }
.wb-section--trend { .wb-section--trend {
border-top: 3px solid transparent; border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #14b8a6, #2dd4bf) 1; border-image: linear-gradient(90deg, #10b981, #06b6d4) 1;
} }
.wb-section-head { .wb-section-head {
@@ -889,8 +919,8 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 18px 22px; padding: 18px 22px;
border-bottom: 1px solid rgba(226, 232, 240, 0.9); border-bottom: 1px solid var(--el-border-color-extra-light);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9) 0%, rgba(255, 255, 255, 0) 100%); background: var(--admin-brand-gradient-soft);
} }
.wb-section-title { .wb-section-title {
@@ -903,38 +933,68 @@ onMounted(() => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 44px; width: 46px;
height: 44px; height: 46px;
border-radius: 14px; border-radius: 14px;
font-size: 22px; font-size: 22px;
color: #fff; color: #fff;
} }
.wb-section-icon--blue { .wb-section-icon--blue {
background: linear-gradient(135deg, #3b82f6, #2563eb); background: var(--admin-brand-gradient);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35); box-shadow: var(--admin-brand-glow);
} }
.wb-section-icon--violet { .wb-section-icon--violet {
background: linear-gradient(135deg, #8b5cf6, #7c3aed); background: linear-gradient(135deg, #0891b2, #6366f1);
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.3); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25);
} }
.wb-section-icon--teal { .wb-section-icon--teal {
background: linear-gradient(135deg, #14b8a6, #0d9488); background: linear-gradient(135deg, #10b981, #06b6d4);
box-shadow: 0 8px 20px rgba(13, 148, 136, 0.3); 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 { .wb-section-name {
font-size: 17px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #0f172a; color: var(--el-text-color-primary);
} }
.wb-section-sub { .wb-section-sub {
margin-top: 2px; margin-top: 2px;
font-size: 12px; font-size: 12px;
color: #94a3b8; color: var(--el-text-color-secondary);
} }
.wb-toolbar { .wb-toolbar {
@@ -968,21 +1028,6 @@ onMounted(() => {
box-sizing: border-box; box-sizing: border-box;
} }
.wb-kpi--blue {
background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid rgba(59, 130, 246, 0.2);
}
.wb-kpi--violet {
background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 100%);
border: 1px solid rgba(139, 92, 246, 0.22);
}
.wb-kpi--amber {
background: linear-gradient(145deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid rgba(245, 158, 11, 0.25);
}
.wb-kpi-label { .wb-kpi-label {
font-size: 13px; font-size: 13px;
color: #64748b; color: #64748b;
@@ -1007,17 +1052,6 @@ onMounted(() => {
color: #94a3b8; color: #94a3b8;
} }
.wb-rank-strip {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 112px;
padding: 14px 18px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.wb-rank-strip--compact { .wb-rank-strip--compact {
min-height: 112px; min-height: 112px;
} }
@@ -1080,9 +1114,10 @@ onMounted(() => {
.wb-chart-card { .wb-chart-card {
height: 100%; height: 100%;
padding: 14px 16px 8px; padding: 14px 16px 8px;
border-radius: 16px; border-radius: var(--admin-radius-lg);
background: #fafbfc; background: var(--admin-surface-elevated);
border: 1px solid #eef0f4; border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-lighter);
} }
.wb-chart-card-head { .wb-chart-card-head {
+16 -1
View File
@@ -77,7 +77,22 @@ module.exports = {
mask: 'var(--el-mask-color)' mask: 'var(--el-mask-color)'
}, },
fontFamily: { 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: { boxShadow: {
DEFAULT: 'var(--el-box-shadow)', 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() public function delete()
{ {
$id = $this->request->post('id'); $id = $this->request->post('id');
if (empty($id)) { $ids = is_array($id) ? $id : [$id];
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $item): bool {
return $item > 0;
})));
if (empty($ids)) {
return $this->fail('缺少参数'); return $this->fail('缺少参数');
} }
Db::startTrans(); Db::startTrans();
try { try {
AssetResource::destroy($id); AssetResource::destroy($ids);
AssetUserResource::where('resource_id', $id)->delete(); AssetUserResource::whereIn('resource_id', $ids)->delete();
Db::commit(); Db::commit();
return $this->success('删除成功'); return $this->success('删除成功');
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -286,6 +286,32 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('已撤回', $result); return $this->success('已撤回', $result);
} }
/**
* @notes 批量将处方业务订单改派给其他医助(写入 creator_id 并逐单记操作日志)
*/
public function batchAssignAssistant()
{
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
return $this->fail('无权限批量改派订单');
}
$params = $this->request->post();
$rawIds = $params['order_ids'] ?? [];
if (!\is_array($rawIds) || $rawIds === []) {
return $this->fail('请选择订单');
}
$assistantId = (int) ($params['assistant_id'] ?? 0);
$result = PrescriptionOrderLogic::batchAssignAssistant($rawIds, $assistantId, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
$msg = '已改派 ' . (int) $result['success'] . ' 单';
if (!empty($result['errors'])) {
$msg .= '' . implode('', $result['errors']);
}
return $this->success($msg, $result);
}
/** /**
* 获取业务订单操作日志 * 获取业务订单操作日志
*/ */
@@ -115,6 +115,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$this->applyExpressKeywordFilter($query); $this->applyExpressKeywordFilter($query);
$this->applyServiceChannelFilter($query); $this->applyServiceChannelFilter($query);
$this->applySupplyModeFilter($query); $this->applySupplyModeFilter($query);
$this->applyHasAuxFormulaFilter($query);
$this->applyAuditAdminFilter($query); $this->applyAuditAdminFilter($query);
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) { if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」 // 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
@@ -354,6 +355,39 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
} }
} }
/**
* 是否含辅方:关联处方 herbs 中是否存在 formula_type=辅方(与 PrescriptionOrderLogic::detail 口径一致)
*
* 入参 has_aux_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 则额外包含关联处方开方人为本人的订单。 * 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则额外包含关联处方开方人为本人的订单。
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。 * 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
@@ -48,8 +48,8 @@ class ConversionLogic
$emptyResult = [ $emptyResult = [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]), 'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([]), 'charts' => self::buildCharts([], $dimension),
'lists' => [], 'lists' => [],
'count' => 0, 'count' => 0,
'page_no' => $pageNo, 'page_no' => $pageNo,
@@ -57,8 +57,8 @@ class ConversionLogic
'extend' => [ 'extend' => [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]), 'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([]), 'charts' => self::buildCharts([], $dimension),
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
@@ -75,8 +75,8 @@ class ConversionLogic
$result = [ $result = [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]), 'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([]), 'charts' => self::buildCharts([], $dimension),
'lists' => [], 'lists' => [],
'count' => 0, 'count' => 0,
'page_no' => $pageNo, 'page_no' => $pageNo,
@@ -84,8 +84,8 @@ class ConversionLogic
'extend' => [ 'extend' => [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]), 'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([]), 'charts' => self::buildCharts([], $dimension),
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
@@ -124,8 +124,8 @@ class ConversionLogic
$result = [ $result = [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]), 'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([]), 'charts' => self::buildCharts([], $dimension),
'lists' => [], 'lists' => [],
'count' => 0, 'count' => 0,
'page_no' => $pageNo, 'page_no' => $pageNo,
@@ -133,8 +133,8 @@ class ConversionLogic
'extend' => [ 'extend' => [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary([]), 'summary' => self::buildSummary([], $dimension),
'charts' => self::buildCharts([]), 'charts' => self::buildCharts([], $dimension),
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
@@ -191,8 +191,8 @@ class ConversionLogic
$result = [ $result = [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows), 'summary' => self::buildSummary($allRows, $dimension),
'charts' => self::buildCharts($chartRows), 'charts' => self::buildCharts($chartRows, $dimension),
'lists' => $pagedRows, 'lists' => $pagedRows,
'count' => count($allRows), 'count' => count($allRows),
'page_no' => $pageNo, 'page_no' => $pageNo,
@@ -200,8 +200,8 @@ class ConversionLogic
'extend' => [ 'extend' => [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($allRows), 'summary' => self::buildSummary($allRows, $dimension),
'charts' => self::buildCharts($chartRows), 'charts' => self::buildCharts($chartRows, $dimension),
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
@@ -226,8 +226,8 @@ class ConversionLogic
$result = [ $result = [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows), 'summary' => self::buildSummary($rows, $dimension),
'charts' => self::buildCharts($rows), 'charts' => self::buildCharts($rows, $dimension),
'lists' => array_slice($rows, $offset, $pageSize), 'lists' => array_slice($rows, $offset, $pageSize),
'count' => $count, 'count' => $count,
'page_no' => $pageNo, 'page_no' => $pageNo,
@@ -235,8 +235,8 @@ class ConversionLogic
'extend' => [ 'extend' => [
'dimension' => $dimension, 'dimension' => $dimension,
'date_range' => [$startDate, $endDate], 'date_range' => [$startDate, $endDate],
'summary' => self::buildSummary($rows), 'summary' => self::buildSummary($rows, $dimension),
'charts' => self::buildCharts($rows), 'charts' => self::buildCharts($rows, $dimension),
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
@@ -1226,7 +1226,7 @@ class ConversionLogic
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); $entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); $entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount); $entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); $entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -1459,7 +1459,7 @@ class ConversionLogic
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount); $node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount); $node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount); $node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$node['receive_rate'] = self::percent($completedOrderCount, $addFansCount); $node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount); $node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount); $node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount); $node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
@@ -2010,7 +2010,12 @@ class ConversionLogic
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount), 'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount), 'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount), 'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
'receive_rate' => self::percent($completedOrderCount, $addFansCount), 'receive_rate' => self::receiveRate(
$completedOrderCount,
$addFansCount,
$interviewCount,
'member'
),
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount), 'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount), 'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount), 'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
@@ -2149,7 +2154,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $rows * @param array<int, array<string, mixed>> $rows
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private static function buildSummary(array $rows): array private static function buildSummary(array $rows, string $dimension = 'dept'): array
{ {
$summary = [ $summary = [
'add_fans_count' => 0, 'add_fans_count' => 0,
@@ -2181,7 +2186,12 @@ class ConversionLogic
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']); $summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']); $summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']); $summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
$summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_count']); $summary['receive_rate'] = self::receiveRate(
$summary['completed_order_count'],
$summary['add_fans_count'],
$summary['interview_count'],
$dimension
);
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']); $summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']); $summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']); $summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
@@ -2310,7 +2320,7 @@ class ConversionLogic
* @param array<int, array<string, mixed>> $rows * @param array<int, array<string, mixed>> $rows
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private static function buildCharts(array $rows): array private static function buildCharts(array $rows, string $dimension = 'dept'): array
{ {
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false)))); $chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
$topRows = array_slice($chartableRows, 0, 10); $topRows = array_slice($chartableRows, 0, 10);
@@ -2319,29 +2329,56 @@ class ConversionLogic
'names' => [], 'names' => [],
'amounts' => [], 'amounts' => [],
'order_counts' => [], 'order_counts' => [],
'fan_counts' => [],
'rois' => [],
]; ];
if ($dimension === 'doctor') {
$ranking['appointment_counts'] = [];
$ranking['interview_counts'] = [];
} else {
$ranking['fan_counts'] = [];
$ranking['rois'] = [];
}
foreach ($topRows as $row) { foreach ($topRows as $row) {
$ranking['names'][] = $row['name']; $ranking['names'][] = $row['name'];
$ranking['amounts'][] = $row['completed_order_amount']; $ranking['amounts'][] = $row['completed_order_amount'];
$ranking['order_counts'][] = $row['completed_order_count']; $ranking['order_counts'][] = $row['completed_order_count'];
$ranking['fan_counts'][] = $row['add_fans_count']; if ($dimension === 'doctor') {
$ranking['rois'][] = $row['roi']; $ranking['appointment_counts'][] = $row['appointment_total_count'];
$ranking['interview_counts'][] = $row['interview_count'];
} else {
$ranking['fan_counts'][] = $row['add_fans_count'];
$ranking['rois'][] = $row['roi'];
}
} }
return [ $charts = [
'ranking' => $ranking, 'ranking' => $ranking,
'amount_share' => array_map( 'amount_share' => array_map(
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']], static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0) array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
), ),
'fan_share' => array_map( ];
if ($dimension === 'doctor') {
$charts['order_share'] = array_map(
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_count']],
array_filter($topRows, static fn (array $row): bool => (int)$row['completed_order_count'] > 0)
);
} else {
$charts['fan_share'] = array_map(
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']], static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0) array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
), );
]; }
return $charts;
}
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
{
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
return self::percent($completedOrderCount, $denominator);
} }
private static function percent(int $numerator, int $denominator): float private static function percent(int $numerator, int $denominator): float
@@ -3214,12 +3214,16 @@ class PrescriptionOrderLogic
if ($createType === 'fubei') { if ($createType === 'fubei') {
return '付呗'; return '付呗';
} }
if ($createType === 'express_cod') {
return '快递代收';
}
$paymentMethod = trim((string) ($row['payment_method'] ?? '')); $paymentMethod = trim((string) ($row['payment_method'] ?? ''));
$methodMap = [ $methodMap = [
'alipay' => '支付宝', 'alipay' => '支付宝',
'wechat' => '微信', 'wechat' => '微信',
'wechat_work' => '企业微信', 'wechat_work' => '企业微信',
'fubei' => '付呗', 'fubei' => '付呗',
'express_cod' => '快递代收',
'manual' => '手动确认到账', 'manual' => '手动确认到账',
]; ];
if ($paymentMethod !== '' && isset($methodMap[$paymentMethod])) { if ($paymentMethod !== '' && isset($methodMap[$paymentMethod])) {
@@ -4405,6 +4409,112 @@ class PrescriptionOrderLogic
return true; return true;
} }
/**
* 批量将处方业务订单「创建人(医助归属)」改派给其他医助。
* 仅改写 creator_id(业务归属/医助筛选口径),逐单记操作日志;与支付单批量改派口径一致。
*
* @param int[] $orderIds
* @param int $assistantAdminId 目标医助 admin_id(须含医助角色)
* @return array{success:int, fail:int, errors:string[], per_log:array<int,array{order_id:int,summary:string}>}|false
*/
public static function batchAssignAssistant(array $orderIds, int $assistantAdminId, int $adminId, array $adminInfo)
{
self::$error = '';
$assistantAdminId = (int) $assistantAdminId;
if ($assistantAdminId <= 0) {
self::$error = '请选择医助';
return false;
}
// 目标须为医助角色(与支付单批量改派一致)
$assistantRoleId = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
if (
\app\common\model\auth\AdminRole::where('admin_id', $assistantAdminId)
->where('role_id', $assistantRoleId)
->count() === 0
) {
self::$error = '目标账号不是医助角色';
return false;
}
$ids = array_values(array_unique(array_filter(
array_map('intval', $orderIds),
static fn (int $id) => $id > 0
)));
if ($ids === []) {
self::$error = '请选择订单';
return false;
}
if (count($ids) > 200) {
self::$error = '单次最多改派200单';
return false;
}
$targetName = (string) Admin::where('id', $assistantAdminId)->whereNull('delete_time')->value('name');
if ($targetName === '') {
$targetName = (string) $assistantAdminId;
}
$perLog = [];
$errors = [];
$success = 0;
foreach ($ids as $oid) {
$order = PrescriptionOrder::where('id', $oid)->whereNull('delete_time')->find();
if (! $order) {
$errors[] = "订单{$oid}不存在或已删除";
continue;
}
$label = (string) ($order->order_no ?: $oid);
if (! self::canAccessOrder($order, $adminId, $adminInfo)) {
$errors[] = "订单{$label}无操作权限";
continue;
}
$oldCreatorId = (int) $order->creator_id;
if ($oldCreatorId === $assistantAdminId) {
$errors[] = "订单{$label}创建人已是该医助,已跳过";
continue;
}
$oldName = $oldCreatorId > 0
? (string) Admin::where('id', $oldCreatorId)->whereNull('delete_time')->value('name')
: '';
if ($oldName === '' && $oldCreatorId > 0) {
$oldName = (string) $oldCreatorId;
} elseif ($oldName === '') {
$oldName = '—';
}
$order->creator_id = $assistantAdminId;
try {
$order->save();
} catch (\Throwable $e) {
$errors[] = "订单{$label}保存失败";
continue;
}
$summary = '创建人(医助)由「' . $oldName . '」改派为「' . $targetName . '」';
self::writeLog($oid, $adminId, $adminInfo, 'assign_assistant', $summary);
$perLog[] = ['order_id' => $oid, 'summary' => $summary];
$success++;
}
if ($success === 0) {
self::$error = $errors !== [] ? implode('', $errors) : '未能改派任何订单';
return false;
}
return [
'success' => $success,
'fail' => count($errors),
'errors' => $errors,
'per_log' => $perLog,
];
}
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
{ {
$adminName = $adminInfo['name'] ?? ''; $adminName = $adminInfo['name'] ?? '';
@@ -32,6 +32,7 @@ class DiagnosisValidate extends BaseValidate
'gender' => 'require|in:0,1', 'gender' => 'require|in:0,1',
'age' => 'require|number|between:0,150', 'age' => 'require|number|between:0,150',
'diagnosis_type' => 'require', 'diagnosis_type' => 'require',
'local_hospital_name' => 'require|max:255',
'status' => 'in:0,1', 'status' => 'in:0,1',
'show_card' => 'in:0,1', 'show_card' => 'in:0,1',
'create_source' => 'max:32', 'create_source' => 'max:32',
@@ -50,6 +51,7 @@ class DiagnosisValidate extends BaseValidate
'patient_name.require' => '请输入患者姓名', 'patient_name.require' => '请输入患者姓名',
'patient_name.length' => '患者姓名长度须在1-50位字符', 'patient_name.length' => '患者姓名长度须在1-50位字符',
'id_card.length' => '身份证号长度不正确', 'id_card.length' => '身份证号长度不正确',
'id_card.require' => '请输入身份证号',
'phone.require' => '请输入手机号', 'phone.require' => '请输入手机号',
'phone.mobile' => '手机号格式不正确', 'phone.mobile' => '手机号格式不正确',
'gender.require' => '请选择性别', 'gender.require' => '请选择性别',
@@ -58,6 +60,8 @@ class DiagnosisValidate extends BaseValidate
'age.number' => '年龄必须为数字', 'age.number' => '年龄必须为数字',
'age.between' => '年龄范围0-150', 'age.between' => '年龄范围0-150',
'diagnosis_type.require' => '请选择诊断类型', 'diagnosis_type.require' => '请选择诊断类型',
'local_hospital_name.require' => '请输入当地就诊医院名称',
'local_hospital_name.max' => '当地就诊医院名称最多255个字符',
'status.in' => '状态参数错误', 'status.in' => '状态参数错误',
'create_source.max' => '渠道来源长度不能超过32个字符', 'create_source.max' => '渠道来源长度不能超过32个字符',
'current_medications.max' => '在用药物最多2000个字符', 'current_medications.max' => '在用药物最多2000个字符',
@@ -91,6 +91,6 @@ class PrescriptionOrderValidate extends BaseValidate
{ {
return $this->only(['id', 'amount']) return $this->only(['id', 'amount'])
->append('id', 'require|integer|gt:0') ->append('id', 'require|integer|gt:0')
->append('amount', 'require|float|gt:0'); ->append('amount', 'require|float|egt:0');
} }
} }
@@ -1 +1 @@
import r from"./error-CIC7FjML.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DU4wa6E0.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default}; import r from"./error-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-CIC7FjML.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-DU4wa6E0.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default}; import o from"./error-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
View File
View File
View File
View File
View File
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