Compare commits

..
Author SHA1 Message Date
Your Name df65344d8f 更新 2026-06-03 14:20:51 +08:00
711 changed files with 5173 additions and 15749 deletions
+4 -1
View File
@@ -26,8 +26,11 @@ bin-release/
/.cursor
/.codex
/.agents
/.claude
/.claude
/.claude
/.kbctx
/server/.spool
/server/.claude
/.spool
TUICallKit-Vue3/.env
/.codegraph
+120
View File
@@ -0,0 +1,120 @@
alias ll='ls -alhG'
# Starship prompt
eval "$(starship init zsh)"
# zsh-autosuggestions 自动补全
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
# NVM 懒加载 - 只在第一次使用相关命令时再初始化
export NVM_DIR="$HOME/.nvm"
_nvm_default_bin() {
local default_alias version_dir
[ -r "$NVM_DIR/alias/default" ] || return 1
default_alias="$(<"$NVM_DIR/alias/default")"
default_alias="${default_alias#"${default_alias%%[![:space:]]*}"}"
default_alias="${default_alias%"${default_alias##*[![:space:]]}"}"
case "$default_alias" in
v[0-9]*)
version_dir="$NVM_DIR/versions/node/$default_alias"
;;
[0-9]*)
version_dir="$(ls -d "$NVM_DIR"/versions/node/v"$default_alias".* 2>/dev/null | tail -n 1)"
;;
node|stable|lts/*)
version_dir="$(ls -d "$NVM_DIR"/versions/node/v* 2>/dev/null | sort -V | tail -n 1)"
;;
*)
return 1
;;
esac
[ -n "$version_dir" ] && [ -x "$version_dir/bin/node" ] || return 1
printf '%s\n' "$version_dir/bin"
}
if _nvm_default_node_bin="$(_nvm_default_bin)"; then
export PATH="$_nvm_default_node_bin:$PATH"
fi
unset _nvm_default_node_bin
_lazy_load_nvm() {
unset -f nvm node npm npx pnpm yarn 2>/dev/null
[ -s "$NVM_DIR/nvm.sh" ] || return 1
\. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
hash -r 2>/dev/null
}
nvm() { _lazy_load_nvm && nvm "$@"; }
node() { _lazy_load_nvm && node "$@"; }
npm() { _lazy_load_nvm && npm "$@"; }
npx() { _lazy_load_nvm && npx "$@"; }
pnpm() { _lazy_load_nvm && pnpm "$@"; }
yarn() { _lazy_load_nvm && yarn "$@"; }
export PATH="/Applications/EServer/bin:$PATH"
function EC_start(){
/Applications/EasyConnect.app/Contents/Resources/bin/EasyMonitor > /dev/null 2>&1 &
/Applications/EasyConnect.app/Contents/MacOS/EasyConnect > /dev/null 2>&1 &
open /Applications/EasyConnect.app
}
function EC_kill(){
pkill EasyMonitor
pkill ECAgent
pkill ECAgentProxy
pkill EasyConnect
}
# The following lines have been added by Docker Desktop to enable Docker CLI completions.
fpath=(/Users/long/.docker/completions $fpath)
autoload -Uz compinit
compinit
# End of Docker CLI completions
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/
# pyenv 懒加载 - 只在第一次使用 Python 相关命令时再初始化
export PYENV_ROOT="$HOME/.pyenv"
_lazy_load_pyenv() {
unset -f pyenv python python3 pip pip3 2>/dev/null
local pyenv_bin
pyenv_bin="$(command -v pyenv 2>/dev/null)"
if [ -z "$pyenv_bin" ] && [ -x /opt/homebrew/bin/pyenv ]; then
pyenv_bin=/opt/homebrew/bin/pyenv
fi
[ -n "$pyenv_bin" ] || return 1
eval "$("$pyenv_bin" init --path)"
eval "$("$pyenv_bin" init - zsh)"
hash -r 2>/dev/null
}
pyenv() { _lazy_load_pyenv && pyenv "$@"; }
python() { _lazy_load_pyenv && python "$@"; }
python3() { _lazy_load_pyenv && python3 "$@"; }
pip() { _lazy_load_pyenv && pip "$@"; }
pip3() { _lazy_load_pyenv && pip3 "$@"; }
# 引入 bash 配置
if [ -f ~/.bash_profile ]; then
source ~/.bash_profile
fi
if [ -f ~/.bashrc ]; then
source ~/.bashrc
fi
# Added by Windsurf
export PATH="/Users/long/.codeium/windsurf/bin:$PATH"
# Added by Antigravity
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
export PATH="$HOME/.local/bin:$PATH"
# MindOS Desktop — CLI (mindos)
export PATH="$HOME/.mindos/bin:$PATH"
source "$HOME/.cargo/env"
# Added by Antigravity
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
+74
View File
@@ -0,0 +1,74 @@
alias ll='ls -alhG'
# Starship prompt
eval "$(starship init zsh)"
# zsh-autosuggestions 自动补全
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
# NVM
export NVM_DIR="$HOME/.nvm"
if [ -s "$NVM_DIR/nvm.sh" ]; then
\. "$NVM_DIR/nvm.sh"
fi
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
export PATH="/Applications/EServer/bin:$PATH"
function EC_start(){
/Applications/EasyConnect.app/Contents/Resources/bin/EasyMonitor > /dev/null 2>&1 &
/Applications/EasyConnect.app/Contents/MacOS/EasyConnect > /dev/null 2>&1 &
open /Applications/EasyConnect.app
}
function EC_kill(){
pkill EasyMonitor
pkill ECAgent
pkill ECAgentProxy
pkill EasyConnect
}
# The following lines have been added by Docker Desktop to enable Docker CLI completions.
fpath=(/Users/long/.docker/completions $fpath)
autoload -Uz compinit
compinit
# End of Docker CLI completions
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/
# pyenv
export PYENV_ROOT="$HOME/.pyenv"
export PATH="/opt/homebrew/bin:$PYENV_ROOT/bin:$PATH"
if command -v pyenv >/dev/null 2>&1; then
eval "$(pyenv init --path)"
eval "$(pyenv init - zsh)"
fi
# 引入 bash 配置
if [ -f ~/.bash_profile ]; then
source ~/.bash_profile
fi
if [ -f ~/.bashrc ]; then
source ~/.bashrc
fi
# Added by Windsurf
export PATH="/Users/long/.codeium/windsurf/bin:$PATH"
# Added by Antigravity
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
if command -v nvm >/dev/null 2>&1; then
_nvm_default_version="$(nvm version default 2>/dev/null)"
if [ -n "$_nvm_default_version" ] && [ -d "$NVM_DIR/versions/node/$_nvm_default_version/bin" ]; then
export PATH="$NVM_DIR/versions/node/$_nvm_default_version/bin:$PATH"
fi
unset _nvm_default_version
fi
export PATH="$HOME/.local/bin:$PATH"
# MindOS Desktop — CLI (mindos)
export PATH="$HOME/.mindos/bin:$PATH"
source "$HOME/.cargo/env"
# Added by Antigravity
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
+1 -1
View File
@@ -1,5 +1,5 @@
import App from './App'
var baseUrl ='https://admin.zhenyangtang.com.cn/';
var baseUrl ='https://css.zhenyangtang.com.cn/';
function joinApiUrl(base, path) {
const b = String(base || '').replace(/\/+$/, '')
+3 -3
View File
@@ -52,14 +52,14 @@
"mp-weixin" : {
"appid" : "wx79b9a0bfbfe7cbcd",
"setting" : {
"urlCheck" : false,
"minified" : true
"urlCheck" : false
},
"optimization" : {
"subPackages" : true
},
"usingComponents" : true,
"requiredBackgroundModes" : [ "audio" ],
"requiredBackgroundModes" : ["audio"],
"plugins" : {
"WechatSI" : {
"version" : "0.3.5",
+1 -9
View File
@@ -204,15 +204,7 @@
"navigationStyle": "custom",
"navigationBarTitleText": "日常护理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
"enablePullDownRefresh": true
}
},
{
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 90 KiB

@@ -1,14 +1,11 @@
<template>
<view
class="food-tile-icon"
:class="[`food-tile-icon--${size}`, { 'is-custom-img': hasCustomImg }]"
>
<text v-if="showEmojiFallback" class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
<image
v-if="src && !imageFailed"
class="food-tile-icon__img"
:src="src"
:mode="imageMode"
:mode="size === 'tile' ? 'aspectFill' : 'aspectFit'"
@error="onImageError"
/>
</view>
@@ -30,20 +27,12 @@ const fallbackIcon = computed(() => {
return '🍽'
})
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
const src = computed(() => {
if (typeof props.food === 'object' && props.food?.img) return props.food.img
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
return ''
})
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
watch(
() => src.value,
() => {
@@ -77,21 +66,11 @@ function onImageError() {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 75%;
height: 75%;
width: 58%;
height: 58%;
z-index: 2;
}
.food-tile-icon--tile.is-custom-img .food-tile-icon__img {
width: 82%;
height: 82%;
}
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
.food-tile-icon--tile .food-tile-icon__img {
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
}
.food-tile-icon--goal {
width: 40rpx;
height: 40rpx;
@@ -125,11 +104,6 @@ function onImageError() {
font-size: 34rpx;
}
.food-tile-icon--goal.is-custom-img .food-tile-icon__img,
.food-tile-icon--tooltip.is-custom-img .food-tile-icon__img {
border-radius: 8rpx;
}
.food-tile-icon__img {
position: absolute;
left: 0;
@@ -1,189 +0,0 @@
<template>
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
<view class="vsm-body">
<view class="vsm-leaf" aria-hidden="true" />
<view class="vsm-face">
<view class="vsm-eye vsm-eye--l" />
<view class="vsm-eye vsm-eye--r" />
<view class="vsm-blush vsm-blush--l" />
<view class="vsm-blush vsm-blush--r" />
<view class="vsm-mouth" />
</view>
<view v-if="active" class="vsm-waves" aria-hidden="true">
<view class="vsm-wave vsm-wave--1" />
<view class="vsm-wave vsm-wave--2" />
<view class="vsm-wave vsm-wave--3" />
</view>
</view>
</view>
</template>
<script setup>
defineProps({
/** 正在按住说话 / 录音中 */
active: { type: Boolean, default: false },
/** 深色条背景上使用浅色卡通 */
onDark: { type: Boolean, default: false },
size: { type: String, default: 'md' }
})
</script>
<style scoped lang="scss">
.vsm {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vsm--md { width: 72rpx; height: 72rpx; }
.vsm--sm { width: 64rpx; height: 64rpx; }
.vsm-body {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.vsm-leaf {
position: absolute;
top: 2rpx;
left: 50%;
width: 16rpx;
height: 16rpx;
margin-left: -8rpx;
border-radius: 0 100% 0 100%;
background: #34d399;
transform: rotate(-18deg);
z-index: 2;
}
.vsm--on-dark .vsm-leaf {
background: #a7f3d0;
}
.vsm-face {
position: relative;
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #fef9c3;
border: 3rpx solid #047857;
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
z-index: 1;
}
.vsm--sm .vsm-face {
width: 46rpx;
height: 46rpx;
}
.vsm--on-dark .vsm-face {
background: #fffbeb;
border-color: rgba(255, 255, 255, 0.85);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
}
.vsm-eye {
position: absolute;
top: 16rpx;
width: 6rpx;
height: 8rpx;
border-radius: 50%;
background: #064e3b;
}
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
.vsm--on-dark .vsm-eye { background: #134e4a; }
.vsm-eye--l { left: 12rpx; }
.vsm-eye--r { right: 12rpx; }
.vsm--sm .vsm-eye--l { left: 10rpx; }
.vsm--sm .vsm-eye--r { right: 10rpx; }
.vsm-blush {
position: absolute;
top: 24rpx;
width: 10rpx;
height: 6rpx;
border-radius: 50%;
background: rgba(251, 113, 133, 0.45);
opacity: 0.85;
}
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
.vsm-blush--l { left: 6rpx; }
.vsm-blush--r { right: 6rpx; }
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
.vsm-mouth {
position: absolute;
left: 50%;
bottom: 10rpx;
width: 18rpx;
height: 8rpx;
margin-left: -9rpx;
border-radius: 0 0 18rpx 18rpx;
background: #047857;
transform-origin: center top;
}
.vsm--sm .vsm-mouth {
bottom: 9rpx;
width: 16rpx;
height: 7rpx;
margin-left: -8rpx;
}
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
.vsm-waves {
position: absolute;
right: -2rpx;
top: 50%;
display: flex;
align-items: flex-end;
gap: 4rpx;
height: 28rpx;
margin-top: -14rpx;
z-index: 0;
}
.vsm-wave {
width: 5rpx;
border-radius: 4rpx;
background: #047857;
animation: vsm-wave-jump 0.72s ease-in-out infinite;
}
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
.vsm--active .vsm-body {
animation: vsm-bob 0.9s ease-in-out infinite;
}
.vsm--active .vsm-mouth {
animation: vsm-talk 0.32s ease-in-out infinite alternate;
}
.vsm--active .vsm-eye {
animation: vsm-blink 2.4s ease-in-out infinite;
}
@keyframes vsm-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4rpx); }
}
@keyframes vsm-talk {
0% {
transform: scaleY(0.45);
border-radius: 0 0 18rpx 18rpx;
}
100% {
transform: scaleY(1.15);
height: 12rpx;
border-radius: 50%;
}
}
@keyframes vsm-blink {
0%, 42%, 46%, 100% { transform: scaleY(1); }
44% { transform: scaleY(0.15); }
}
@keyframes vsm-wave-jump {
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
50% { transform: scaleY(1); opacity: 1; }
}
</style>
@@ -1,203 +0,0 @@
import { ref, computed } from 'vue'
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
const emptyBloodCarePlan = () => ({
summary: '',
blood_advice: '',
tcm_plan: '',
watch_points: [],
next_steps: '',
control_level: '',
control_label: '',
disclaimer: '',
analysis: [],
source: '',
date: ''
})
/** AI 血糖照护建议(累计 7 天有记录后) */
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
const bloodCarePlan = ref(emptyBloodCarePlan())
const bloodCareLoading = ref(false)
const bloodCareStreaming = ref(false)
const bloodCareStreamField = ref('')
let careStreamBuffer = ''
/** 用户点击后才展开照护建议卡片 */
const bloodCarePanelOpen = ref(false)
const bloodCareEligible = computed(() => {
return !!diagnosisId.value && !chartUseMockData.value
})
const hasBloodCareContent = computed(() => {
const p = bloodCarePlan.value
return !!(p.summary || p.blood_advice || p.tcm_plan)
})
const bloodCareAnalysis = computed(() => {
const list = bloodCarePlan.value?.analysis
return Array.isArray(list) ? list : []
})
const bloodCareControlLabel = computed(() => {
const label = String(bloodCarePlan.value?.control_label || '').trim()
return label
})
const bloodCareDisclaimer = computed(() => {
return String(bloodCarePlan.value?.disclaimer || '').trim()
})
function detectCareStreamField(text) {
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[:]\s*([^\n]*)$/)
if (!tail) return 'summary'
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
return map[tail[1]] || 'summary'
}
function mergePartialCareFromStream() {
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
const next = { ...bloodCarePlan.value }
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
if (partial[key] != null) next[key] = partial[key]
}
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
bloodCarePlan.value = next
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
}
function applyBloodCarePayload(payload) {
if (!payload) return
const prevAnalysis = bloodCarePlan.value.analysis
bloodCarePlan.value = {
...emptyBloodCarePlan(),
...payload,
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
analysis: Array.isArray(payload.analysis) && payload.analysis.length
? payload.analysis
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
}
}
function onBloodCareStreamEvent(event, payload) {
if (event === 'analysis') {
if (Array.isArray(payload?.analysis)) {
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
}
} else if (event === 'delta') {
bloodCareStreaming.value = true
if (payload.buffer != null) {
careStreamBuffer = payload.buffer
} else {
careStreamBuffer += payload.text || ''
}
mergePartialCareFromStream()
} else if (event === 'done') {
applyBloodCarePayload(payload)
careStreamBuffer = ''
bloodCareStreamField.value = ''
} else if (event === 'error') {
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
}
}
async function fetchBloodCareAiFallback(refresh) {
const res = await proxy.apiUrl({
url: '/api/tcm/dailyBloodCareAiRecommend',
method: 'GET',
timeout: 35000,
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
}
}, false)
if (res?.code === 1 && res.data) {
applyBloodCarePayload(res.data)
} else if (res?.msg) {
showUserToast(res.msg)
}
}
function closeBloodCarePanel() {
bloodCarePanelOpen.value = false
}
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
async function toggleBloodCareFromChart() {
if (!bloodCareEligible.value || bloodCareLoading.value) return
if (bloodCarePanelOpen.value) {
closeBloodCarePanel()
return
}
bloodCarePanelOpen.value = true
if (!hasBloodCareContent.value) {
await fetchBloodCareAi(false)
}
}
async function fetchBloodCareAi(refresh = false) {
if (!bloodCareEligible.value || bloodCareLoading.value) return
bloodCareLoading.value = true
bloodCareStreaming.value = false
careStreamBuffer = ''
bloodCareStreamField.value = ''
if (refresh) {
applyBloodCarePayload(emptyBloodCarePlan())
}
try {
await requestAiStream({
baseUrl: proxy.$url,
url: '/api/tcm/dailyBloodCareAiRecommendStream',
method: 'GET',
data: {
diagnosis_id: diagnosisId.value,
refresh: refresh ? 1 : 0,
...(refresh ? { _t: Date.now() } : {})
},
onEvent: onBloodCareStreamEvent,
fallback: () => fetchBloodCareAiFallback(refresh)
})
} catch (e) {
try {
await fetchBloodCareAiFallback(refresh)
} catch (e2) {
showUserToast('照护建议加载失败')
}
} finally {
bloodCareLoading.value = false
bloodCareStreaming.value = false
bloodCareStreamField.value = ''
}
}
function refreshBloodCareAi() {
if (bloodCareLoading.value) return
fetchBloodCareAi(true)
}
function resetBloodCarePlan() {
applyBloodCarePayload(emptyBloodCarePlan())
careStreamBuffer = ''
bloodCareStreamField.value = ''
bloodCarePanelOpen.value = false
}
return {
bloodCarePlan,
bloodCareLoading,
bloodCareStreaming,
bloodCareStreamField,
bloodCareEligible,
bloodCarePanelOpen,
hasBloodCareContent,
bloodCareAnalysis,
bloodCareControlLabel,
bloodCareDisclaimer,
fetchBloodCareAi,
toggleBloodCareFromChart,
closeBloodCarePanel,
refreshBloodCareAi,
resetBloodCarePlan
}
}
@@ -207,8 +207,6 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
return
}
if (!diagnosisId.value) return
// 发送后立即清空输入框
dietAiAskText.value = ''
dietAiAsking.value = true
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
try {
@@ -2,106 +2,22 @@ import { ref, onUnmounted } from 'vue'
const MIN_HOLD_MS = 280
/** 短按/过早松手时插件常见错误,不应打扰用户 */
function isBenignSttError(msg, retcode) {
const m = String(msg || '').toLowerCase()
if (/internal voice data failed/i.test(m)) return true
if (/voice data failed/i.test(m)) return true
if (/recordfailed|record failed|record manager/i.test(m)) return true
if (/please stop after start/i.test(m)) return true
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
const code = Number(retcode)
if (code === -30002 || code === -30003 || code === -30012) return true
return false
}
function friendlySttError(msg) {
const m = String(msg || '').trim()
if (!m || isBenignSttError(m)) return ''
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
return '语音识别失败,请重试'
}
// #ifdef MP-WEIXIN
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
// 回调仍指向已销毁页面的闭包,新页面收不到 onStart/onStop
// 其本地状态卡在 recording,表现为一直录制/无法再次语音。
let sharedManager = null
let sharedManagerInited = false
let sharedRecording = false
let activeCtl = null
function getSharedRecordManager() {
if (sharedManagerInited) return sharedManager
sharedManagerInited = true
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
sharedManager = plugin.getRecordRecognitionManager()
} catch (e) {
console.warn('WechatSI record recognition not available', e)
sharedManager = null
return null
}
if (!sharedManager) return null
sharedManager.onStart = () => {
sharedRecording = true
if (activeCtl) activeCtl.handleStart()
}
sharedManager.onRecognize = (res) => {
if (activeCtl) activeCtl.handleRecognize(res)
}
sharedManager.onStop = (res) => {
sharedRecording = false
if (activeCtl) activeCtl.handleStop(res)
}
sharedManager.onError = (res) => {
sharedRecording = false
if (activeCtl) activeCtl.handleError(res)
}
return sharedManager
}
// #endif
/**
* 语音转文字(长按说话):微信小程序 WechatSIH5 Web Speech API。
*
* @param {{
* onResult?: (text: string) => void,
* onSettle?: (text: string, info: { heldMs: number }) => boolean | void,
* onPartial?: (text: string) => void,
* onError?: (msg: string) => boolean | void,
* showToast?: (msg: string) => void
* }} options
*
* onSettle 在每次录音结束时都会触发(含空结果),用于语音一问一答等需要
* 完全接管识别结果的场景。若 onSettle 返回 true,则跳过默认的 onResult 回调
* 与「没听清」提示,避免与上层流程重复处理。
*
* onPartial 在录音过程中实时返回中间识别结果(微信 onRecognize / H5 interim),
* 用于「抢答打断」等场景:检测到用户已开口作答即可提前结束播报/录音。
*
* onError 在录音/识别出错时触发(如 "record manager recordfailed")。
* 若返回 true,则跳过默认的错误 Toast,由上层自行处理(如自动重试)。
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
*/
export function useSpeechToText({ onResult, onSettle, onPartial, onError, showToast } = {}) {
export function useSpeechToText({ onResult, showToast } = {}) {
const sttListening = ref(false)
const sttHolding = ref(false)
const sttSupported = ref(false)
let wxRecordManager = null
let h5Recognition = null
let h5GotResult = false
let holdStartTs = 0
let holdSessionId = 0
let startRequested = false
let recordAuthorized = null
// 快速点按可能残留会话:用排队标记把 start/stop 串行化,
// 避免 "please stop after start"
let pendingStartSession = 0
function notify(msg) {
if (!msg) return
@@ -115,11 +31,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
function emitResult(text) {
const t = String(text || '').trim()
const heldMs = Date.now() - holdStartTs
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
if (typeof onSettle === 'function') {
const handled = onSettle(t, { heldMs })
if (handled === true) return
}
if (!t) {
if (heldMs < MIN_HOLD_MS) return
notify('没听清,请再试一次')
@@ -132,9 +43,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
function stopListening() {
// #ifdef MP-WEIXIN
const ownsRecording = sharedRecording && activeCtl === controller
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
if (wxRecordManager && (sttListening.value || ownsRecording)) {
if (wxRecordManager && (sttListening.value || startRequested)) {
try {
wxRecordManager.stop()
} catch (e) {}
@@ -150,7 +59,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif
startRequested = false
sttHolding.value = false
sttListening.value = false
}
@@ -187,8 +95,8 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif
}
// #ifdef MP-WEIXIN
function actuallyStartWechat(session) {
function startWechatRecord(session) {
// #ifdef MP-WEIXIN
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
return
}
@@ -200,28 +108,9 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
})
} catch (e) {
startRequested = false
sharedRecording = false
sttHolding.value = false
notify('无法开始录音')
}
}
// #endif
function startWechatRecord(session) {
// #ifdef MP-WEIXIN
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
return
}
// 上一段会话还没结束(含全局单例残留):先停止并排队,
// 待 onStop 回调后再真正开始,避免 "please stop after start"
if (sharedRecording || startRequested) {
pendingStartSession = session
try {
wxRecordManager.stop()
} catch (e) {}
return
}
actuallyStartWechat(session)
// #endif
}
@@ -234,7 +123,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
}
try {
startRequested = true
h5GotResult = false
h5Recognition.start()
sttListening.value = true
} catch (e) {
@@ -258,8 +146,6 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
sttHolding.value = true
// #ifdef MP-WEIXIN
// 把全局录音回调切到当前实例
activeCtl = controller
ensureRecordAuth(() => startWechatRecord(session))
// #endif
// #ifdef H5
@@ -273,95 +159,50 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
// #endif
}
/** 松手结束:手指抬起force=true 时强制取消尚未真正开始的按住会话 */
function endHoldSpeech(force = false) {
let recording = false
// #ifdef MP-WEIXIN
recording = sharedRecording && activeCtl === controller
// #endif
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
/** 松手结束:手指抬起 */
function endHoldSpeech() {
if (!sttHolding.value && !sttListening.value && !startRequested) {
return
}
sttHolding.value = false
// 松手后不再补开排队的录音
pendingStartSession = 0
if (sttListening.value || recording) {
if (sttListening.value || startRequested) {
stopListening()
return
}
if (startRequested) {
// start 已发出但 onStart 未到:取消会话,勿调 stop()
startRequested = false
holdSessionId += 1
return
}
holdSessionId += 1
}
// #ifdef MP-WEIXIN
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
const controller = {
handleStart() {
try {
// eslint-disable-next-line no-undef
const plugin = requirePlugin('WechatSI')
wxRecordManager = plugin.getRecordRecognitionManager()
sttSupported.value = !!wxRecordManager
wxRecordManager.onStart = () => {
if (!sttHolding.value) {
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
startRequested = false
sttListening.value = false
try {
wxRecordManager.stop()
} catch (e) {}
return
}
startRequested = false
sttListening.value = true
},
handleRecognize(res) {
if (typeof onPartial !== 'function') return
const t = String(res?.result || '').trim()
if (t) onPartial(t)
},
handleStop(res) {
sttListening.value = false
startRequested = false
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
if (pendingStartSession && pendingStartSession === holdSessionId && sttHolding.value) {
const s = pendingStartSession
pendingStartSession = 0
actuallyStartWechat(s)
return
}
pendingStartSession = 0
emitResult(res?.result)
},
handleError(res) {
sttListening.value = false
startRequested = false
const msg = (res && res.msg) || ''
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
if (/please stop after start/i.test(msg)) {
try {
wxRecordManager.stop()
} catch (e) {}
pendingStartSession = sttHolding.value ? holdSessionId : 0
return
}
pendingStartSession = 0
sttHolding.value = false
const heldMs = Date.now() - holdStartTs
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
if (typeof onError === 'function') onError(msg || '')
return
}
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
if (typeof onError === 'function') {
const handled = onError(msg || '')
if (handled === true) return
}
const tip = friendlySttError(msg)
if (tip) notify(tip)
}
wxRecordManager.onStop = (res) => {
sttListening.value = false
startRequested = false
emitResult(res?.result)
}
wxRecordManager.onError = (res) => {
sttListening.value = false
startRequested = false
sttHolding.value = false
notify(res?.msg || '语音识别失败')
}
} catch (e) {
console.warn('WechatSI record recognition not available', e)
}
wxRecordManager = getSharedRecordManager()
sttSupported.value = !!wxRecordManager
// #endif
// #ifdef H5
@@ -371,49 +212,27 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
if (SR) {
h5Recognition = new SR()
h5Recognition.lang = 'zh-CN'
h5Recognition.interimResults = true
h5Recognition.interimResults = false
h5Recognition.continuous = false
h5Recognition.maxAlternatives = 1
sttSupported.value = true
h5Recognition.onresult = (event) => {
let interim = ''
let finalText = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const r = event.results[i]
const txt = r?.[0]?.transcript || ''
if (r.isFinal) finalText += txt
else interim += txt
}
if (interim && typeof onPartial === 'function') onPartial(interim)
if (finalText) {
h5GotResult = true
emitResult(finalText)
}
const item = event.results?.[0]?.[0]
emitResult(item?.transcript || '')
}
h5Recognition.onend = () => {
sttListening.value = false
startRequested = false
sttHolding.value = false
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
if (!h5GotResult) {
emitResult('')
}
h5GotResult = false
}
h5Recognition.onerror = (event) => {
sttListening.value = false
startRequested = false
sttHolding.value = false
const err = event?.error || ''
if (err === 'aborted') return
if (typeof onError === 'function') {
const handled = onError(err)
if (handled === true) return
}
if (err === 'not-allowed') {
if (event?.error === 'not-allowed') {
notify('请允许浏览器使用麦克风')
} else {
} else if (event?.error !== 'aborted') {
notify('语音识别失败')
}
}
@@ -427,14 +246,7 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
onUnmounted(() => {
sttHolding.value = false
holdSessionId += 1
pendingStartSession = 0
stopListening()
// #ifdef MP-WEIXIN
// 释放全局回调指向,避免事件继续派发给已销毁的实例
if (activeCtl === controller) {
activeCtl = null
}
// #endif
})
return {
@@ -446,5 +258,3 @@ export function useSpeechToText({ onResult, onSettle, onPartial, onError, showTo
stopSpeechToText: endHoldSpeech
}
}
export { isBenignSttError, friendlySttError }
+1 -2
View File
@@ -32,8 +32,7 @@ export function warmUpFoodImgs(foods) {
const list = Array.isArray(foods) ? foods : []
list.forEach((f) => {
const icon = typeof f === 'string' ? f : f?.icon
// 优先预热自定义图片素材,缺省回退 emoji PNG
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
const url = getFoodImgUrl(icon)
if (!url) return
// #ifdef MP-WEIXIN
try {
+7 -37
View File
@@ -58,20 +58,7 @@ const FOOD_TIP_OVERRIDE = {
apple: '带皮整果,升糖较慢',
milk: '低糖,含蛋白,适量喝好',
fish: '优质蛋白,升糖低',
beef: '蛋白为主,升糖低',
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
friedNoodles: '油多又是精面,升糖快',
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
sandwich: '夹心面包精制碳多,升糖快',
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
milletCongee: '小米粥熬软升糖快,糖友控量',
shandongPancake: '煎饼加油面,升糖中等偏快',
cake: '蛋糕又甜又油,升糖快',
biscuit: '饼干又甜又碎,升糖快',
wonton: '馄饨面皮精白,升糖中等,别多吃',
blackCoffee: '不加糖的黑咖啡,升糖很低'
beef: '蛋白为主,升糖低'
}
/** 含糖等级通用科普文案 */
@@ -89,9 +76,6 @@ export function getFoodTip(food) {
return GI_TIP_DEFAULT[food.gi] || ''
}
/** 自定义图片素材目录(腾讯云 COS,优先使用真实图片而非 emoji) */
const GAMES_IMG_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games'
const RAW_FOODS = [
// 低 GI
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
@@ -117,7 +101,7 @@ const RAW_FOODS = [
// 中 GI
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39', img: `${GAMES_IMG_BASE}/%E7%95%AA%E8%96%AF.png` },
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
@@ -134,24 +118,14 @@ const RAW_FOODS = [
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
{ key: 'blackCoffee', icon: '☕', name: '黑咖啡', gi: 'low', val: -1, color: '#4E342E', img: `${GAMES_IMG_BASE}/%E9%BB%91%E5%92%96%E5%95%A1.png` },
{ key: 'wonton', icon: '🥟', name: '馄饨', gi: 'mid', val: 5, color: '#E8DCC8', img: `${GAMES_IMG_BASE}/%E9%A6%84%E9%A5%A8.png` },
{ key: 'milletCongee', icon: '🥣', name: '小米粥', gi: 'mid', val: 5, color: '#F5E6B8', img: `${GAMES_IMG_BASE}/%E5%B0%8F%E7%B1%B3%E7%B2%A5.png` },
{ key: 'shandongPancake', icon: '🥞', name: '山东煎饼', gi: 'high', val: 16, color: '#D4A574', img: `${GAMES_IMG_BASE}/%E5%B1%B1%E4%B8%9C%E7%85%8E%E9%A5%BC.png` },
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
// 高 GI
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0', img: `${GAMES_IMG_BASE}/%E7%B1%B3%E9%A5%AD.png` },
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F', 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: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
@@ -162,10 +136,7 @@ const RAW_FOODS = [
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' }
]
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
@@ -178,8 +149,7 @@ export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
clearVal,
giLabel: GI_LABEL[f.gi],
get img() {
// 优先使用自定义图片素材,缺省回退到 emoji PNG
return f.img || getFoodImgUrl(f.icon)
return getFoodImgUrl(f.icon)
}
}
})
@@ -200,6 +170,6 @@ export function cloneFoodType(food) {
giLabel: food.giLabel,
val: food.val,
color: food.color,
img: food.img || getFoodImgUrl(food.icon)
img: getFoodImgUrl(food.icon)
}
}
-27
View File
@@ -2,31 +2,6 @@
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
*/
/**
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
* 第一关固定为这 16 种带真实图片素材的食物。
*/
const FIXED_LEVEL_FOODS = {
1: [
'sandwich',
'eightTreasureCongee',
'milkOats',
'milletCongee',
'shandongPancake',
'grainMantou',
'riceNoodleSoup',
'youtiao',
'friedNoodles',
'sweetPotato',
'centuryEggCongee',
'whiteRice',
'cake',
'biscuit',
'wonton',
'blackCoffee'
]
}
export function getLevelConfig(levelId) {
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
const tier = Math.floor((lv - 1) / 5)
@@ -39,8 +14,6 @@ export function getLevelConfig(levelId) {
goalTargets: [6 + tier, 8 + tier],
scoreTarget: 2000 + lv * 300,
startGlucose: 50,
// 固定食材(可选):存在时本关只用这些食物
foods: FIXED_LEVEL_FOODS[lv] || null,
boosters: {
insulin: 3,
fiber: 1,
+92 -380
View File
@@ -125,28 +125,30 @@
</view>
<!-- Header HUD -->
<view class="gg-header" :style="headerStyle">
<view class="gg-header gg-glass" :style="headerStyle">
<!-- 顶部导航与全局状态 -->
<view class="gg-hud-nav" :style="navStyle">
<!-- 返回 -->
<view class="gg-hud-btn" hover-class="gg-hud-btn--pressed" @tap="goBack">
<TongjiIcon name="chevron-left" size="md" color="#006c4b" />
</view>
<!-- 绝对居中的关卡 -->
<view class="gg-hud-level-center">
<text class="gg-hud-level-text"> {{ levelConfig.id }} </text>
</view>
<!-- 声音 -->
<!-- 侧控制组 -->
<view class="gg-hud-control-group">
<view class="gg-hud-btn" hover-class="gg-hud-btn--pressed" @tap="goBack">
<TongjiIcon name="chevron-left" size="md" color="#006c49" />
</view>
<view
class="gg-hud-btn"
:class="{ 'is-muted': !sfxEnabled }"
hover-class="gg-hud-btn--pressed"
@tap="onSoundToggle"
>
<TongjiIcon name="volume" size="md" :color="sfxEnabled ? '#006c4b' : '#94a3b8'" />
<TongjiIcon name="volume" size="md" :color="sfxEnabled ? '#006c49' : '#94a3b8'" />
</view>
</view>
<!-- 绝对居中的关卡与得分 -->
<view class="gg-hud-level-center">
<text class="gg-hud-level-text"> {{ levelConfig.id }} </text>
<view class="gg-hud-score" :class="{ 'is-pop': scoreAnimating }">
<text class="gg-hud-score-val">{{ scoreText }}</text>
<text class="gg-hud-score-unit"></text>
</view>
</view>
</view>
@@ -154,40 +156,41 @@
<!-- 主区棋盘为主辅助信息弱化 -->
<view class="gg-main" :style="mainStyle">
<!-- 信息横幅默认为玩法提示消除后展示该食物含糖与建议 -->
<view
v-if="gamePhase === 'playing'"
class="gg-info-banner"
:class="lastClearedFood ? 'is-food is-' + lastClearedFood.gi : ''"
>
<!-- 已消除食物含糖 + 建议 -->
<template v-if="lastClearedFood">
<view class="gg-info-banner-icon is-food" :class="'is-' + lastClearedFood.gi">
<!-- 仅展示上次消除的食物含糖 -->
<view v-if="gamePhase === 'playing'" class="gg-hud-last-food">
<text class="gg-hud-last-food-kicker">上次消除</text>
<view
v-if="lastClearedFood"
class="gg-hud-last-food-card"
:class="'is-' + lastClearedFood.gi"
>
<view class="gg-hud-last-food-icon">
<FoodTileIcon :food="lastClearedFood.food" size="goal" />
</view>
<view class="gg-info-banner-body">
<view class="gg-info-food-head">
<text class="gg-info-food-name">{{ lastClearedFood.name }}</text>
<view class="gg-info-food-gi" :class="'is-' + lastClearedFood.gi">
<text class="gg-info-food-gi-text">含糖{{ lastClearedFood.giShort }}</text>
</view>
<view class="gg-hud-last-food-body">
<text class="gg-hud-last-food-name">{{ lastClearedFood.name }}</text>
<view class="gg-hud-last-food-sugar-row">
<text class="gg-hud-last-food-sugar-label">含糖量</text>
<text
class="gg-hud-last-food-gi"
:class="'is-' + lastClearedFood.gi"
:style="{ backgroundColor: lastClearedFood.color }"
>{{ lastClearedFood.giShort }}</text>
<text class="gg-hud-last-food-sugar-hint">{{ lastClearedFood.eatHint }}</text>
</view>
<text class="gg-info-food-tip" :class="'is-' + lastClearedFood.gi">{{ lastClearedFood.tip }}</text>
</view>
</template>
<!-- 默认玩法提示 -->
<template v-else>
<view class="gg-info-banner-icon">
<TongjiIcon name="info" size="md" color="#ffffff" />
</view>
<view class="gg-info-banner-body">
<text class="gg-info-banner-text"><text class="gg-info-banner-kw">认糖</text>点击食物看含糖量 · 消除红色即过关</text>
</view>
</template>
</view>
<view v-else class="gg-hud-last-food-placeholder">
<text>三连消除后这里显示食物名称与含糖量</text>
</view>
</view>
<!-- 棋盘 -->
<view class="gg-board-hero">
<view v-if="gamePhase === 'playing'" class="gg-board-hint-inline">
<text class="gg-hint-tag">认糖</text>
<text class="gg-board-hint-text">点食物看含糖 · 消掉红色即过关</text>
</view>
<view class="gg-board-stack" :style="boardStackStyle">
<view class="gg-board-zone">
<view v-if="hintTipVisible" class="gg-no-move-tip">
@@ -254,8 +257,8 @@
</view>
</view>
<!-- 底部道具栏 -->
<view class="gg-footer" :style="footerStyle">
<!-- 道具栏弱化 -->
<view class="gg-footer gg-glass gg-footer--sub" :style="footerStyle">
<view class="gg-boosters">
<view
v-for="booster in boosters"
@@ -265,11 +268,9 @@
:class="{ 'is-disabled': booster.count <= 0 }"
@tap="onBooster(booster)"
>
<view class="gg-booster-tile">
<view class="gg-booster-icon">
<TongjiIcon :name="booster.icon" size="xl" :color="booster.color" />
</view>
<view class="gg-booster-count" :class="booster.countClass">{{ booster.count }}</view>
<view class="gg-booster-count" :class="booster.countClass">{{ booster.count }}</view>
<view class="gg-booster-icon">
<TongjiIcon :name="booster.icon" size="xl" :color="booster.color" />
</view>
<text class="gg-booster-label">{{ booster.label }}</text>
</view>
@@ -289,7 +290,6 @@ import {
getClearVal,
getFoodTip,
formatEatLabel,
getFoodByKey,
GI_SHORT,
GI_COLOR
} from '../data/gameFoodLibrary.js'
@@ -327,8 +327,8 @@ const COL_AXES = Array.from({ length: COLS }, (_, i) => i)
const BOARD_INSET_RPX = 4
/** 棋盘与底部道具栏之间的安全间隙,避免最后一行被遮挡 */
const BOARD_FOOTER_GAP_RPX = 28
const GRID_PAD_RPX = 18
const GRID_GAP_RPX = 14
const GRID_PAD_RPX = 8
const GRID_GAP_RPX = 8
const SWAP_MS = 280
const MATCH_MS = 400
const GRAVITY_MS = 300
@@ -351,21 +351,6 @@ const activeFoods = ref([])
const sessionHighFood = ref(null)
const goalDefs = ref([])
/** 解锁高糖三连所需的有效操作次数(约 8 步内才有机会消除红色) */
const HIGH_UNLOCK_MOVES = 7
/** 解锁前棋盘固定保留的高糖数量(<3 无法三连,故无法消除) */
const HIGH_COUNT_LOCKED = 2
/** 解锁后棋盘至少保留的高糖数量(够凑三连) */
const HIGH_COUNT_UNLOCKED = 3
/** 解锁后高糖数量上限,避免刷屏 */
const HIGH_COUNT_MAX = 6
/** 解锁后每次补格生成高糖的概率 */
const HIGH_SPAWN_P = 0.16
/** 非高糖棋子的最小种类数,保证棋盘有足够变化 */
const MIN_NON_HIGH_TYPES = 4
/** 本局玩家有效操作次数(成功消除的交换计为一次) */
const playerMoves = ref(0)
function pickRandom(pool, n) {
const arr = [...pool]
for (let i = arr.length - 1; i > 0; i--) {
@@ -380,38 +365,6 @@ function pickRandom(pool, n) {
* 低/中各若干 + 仅 1 种高糖;再从中挑 2 种作为收集目标。
*/
function selectSessionFoods() {
// 固定食材关卡(如第一关):只用配置里的这些食物
const fixedKeys = levelConfig.value.foods
if (Array.isArray(fixedKeys) && fixedKeys.length) {
const fixed = fixedKeys.map((k) => getFoodByKey(k)).filter(Boolean)
const fixedHighs = fixed.filter((f) => f.gi === 'high')
const fixedNonHighs = fixed.filter((f) => f.gi !== 'high')
// 每关只允许一种高糖:从固定高糖里随机取 1 种
const fixedHighPick = pickRandom(fixedHighs, 1)
sessionHighFood.value = fixedHighPick[0] ? cloneFoodType(fixedHighPick[0]) : null
// 非高糖种类不足时,用随机低糖补足,保证棋盘有变化
const nonHigh = [...fixedNonHighs]
if (nonHigh.length < MIN_NON_HIGH_TYPES) {
const used = new Set([...nonHigh, ...fixedHighPick].map((f) => f.key))
const extraLows = FOOD_LIBRARY.filter((f) => f.gi === 'low' && !used.has(f.key))
nonHigh.push(...pickRandom(extraLows, MIN_NON_HIGH_TYPES - nonHigh.length))
}
const active = [...nonHigh, ...fixedHighPick]
activeFoods.value = active
warmUpFoodImgs(active)
// 收集目标从非高糖里挑(高糖以“消除即过关”驱动)
const goalPool = nonHigh.length ? nonHigh : active
const goalFoods = pickRandom(goalPool, Math.min(2, goalPool.length))
const fixedTargets = levelConfig.value.goalTargets || [10, 12]
goalDefs.value = goalFoods.map((f, i) => ({ key: f.key, food: cloneFoodType(f), target: fixedTargets[i] ?? 10 }))
rebuildGoals()
return
}
const lows = FOOD_LIBRARY.filter((f) => f.gi === 'low')
const mids = FOOD_LIBRARY.filter((f) => f.gi === 'mid')
const highs = FOOD_LIBRARY.filter((f) => f.gi === 'high')
@@ -601,7 +554,7 @@ const gridStyle = computed(() => {
}
if (w > 0 && h > 0) {
style.width = `${w}px`
style.height = `${h+10}px`
style.height = `${h}px`
}
return style
})
@@ -678,6 +631,10 @@ const goalList = computed(() =>
})
)
const allGoalsDone = computed(() =>
goalList.value.length > 0 && goalList.value.every((g) => g.done)
)
const glucoseStatusText = computed(() => {
if (glucoseLevel.value < 30) return '血糖偏低'
if (glucoseLevel.value > 70) return '血糖过高'
@@ -759,15 +716,7 @@ function updateGridMetrics() {
function randomFood() {
const pool = activeFoods.value.length ? activeFoods.value : FOOD_LIBRARY
const highFood = sessionHighFood.value
// 解锁后高糖才会随补格生成;解锁前一律生成非高糖,由 ensureHighPresence 控制高糖数量
const afterUnlock = highFood && playerMoves.value >= HIGH_UNLOCK_MOVES
if (afterUnlock && Math.random() < HIGH_SPAWN_P) {
return cloneFoodType(highFood)
}
const nonHigh = pool.filter((f) => f.gi !== 'high')
const src = nonHigh.length ? nonHigh : pool
const pick = src[Math.floor(Math.random() * src.length)]
const pick = pool[Math.floor(Math.random() * pool.length)]
return cloneFoodType(pick)
}
@@ -782,91 +731,6 @@ function boardHasHighGiFood() {
return false
}
/** 该位置若变为高糖,是否会与现有高糖形成三连(注入时尽量避免,让玩家自己凑) */
function wouldFormHighRun(r, c) {
const isHigh = (rr, cc) => {
const t = board.value[rr]?.[cc]
return t && !t.isRemoved && t.type.gi === 'high'
}
let h = 1
for (let cc = c - 1; cc >= 0 && isHigh(r, cc); cc--) h++
for (let cc = c + 1; cc < COLS && isHigh(r, cc); cc++) h++
if (h >= 3) return true
let v = 1
for (let rr = r - 1; rr >= 0 && isHigh(rr, c); rr--) v++
for (let rr = r + 1; rr < ROWS && isHigh(rr, c); rr++) v++
return v >= 3
}
function shuffleInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/**
* 维持棋盘上高糖(红色)食物的数量,是“约 8 步才能消除红色”的核心:
* - 解锁前(playerMoves < HIGH_UNLOCK_MOVES)固定保留 2 个,永远无法三连 → 无法消除;
* - 解锁后至少保留 3 个(散开、避免直接成行),玩家凑三连即可消除过关;上限 HIGH_COUNT_MAX。
* 高糖只在此注入/回收,randomFood 解锁前不生成高糖。
*/
function ensureHighPresence() {
const highFood = sessionHighFood.value
if (!highFood) return
const before = playerMoves.value < HIGH_UNLOCK_MOVES
const minTarget = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_UNLOCKED
const maxCap = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_MAX
const highTiles = []
const nonHighTiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = board.value[r]?.[c]
if (!t || t.isRemoved) continue
if (t.type.gi === 'high') highTiles.push(t)
else nonHighTiles.push(t)
}
}
if (highTiles.length < minTarget) {
let need = minTarget - highTiles.length
const candidates = shuffleInPlace([...nonHighTiles])
// 优先选不会立即成三连的位置
for (const t of candidates) {
if (need <= 0) break
if (t.type.gi === 'high') continue
if (wouldFormHighRun(t.r, t.c)) continue
t.type = cloneFoodType(highFood)
need--
}
// 仍不够再放宽限制
for (const t of candidates) {
if (need <= 0) break
if (t.type.gi === 'high') continue
t.type = cloneFoodType(highFood)
need--
}
bumpLayout()
} else if (highTiles.length > maxCap) {
const lowMidPool = activeFoods.value.filter((f) => f.gi !== 'high')
let remove = highTiles.length - maxCap
const shuffled = shuffleInPlace([...highTiles])
for (const t of shuffled) {
if (remove <= 0) break
const repl = lowMidPool.length
? lowMidPool[Math.floor(Math.random() * lowMidPool.length)]
: null
if (repl) {
t.type = cloneFoodType(repl)
remove--
}
}
bumpLayout()
}
}
function wouldCreateInitialMatch(r, c, type, currentBoard, currentRow) {
const key = type.key
if (r >= 2 && currentBoard[r - 1][c]?.type.key === key && currentBoard[r - 2][c]?.type.key === key) {
@@ -921,11 +785,6 @@ function initBoard() {
}
ensureBoardFull(next)
commitBoard(next)
// 注入本关唯一高糖(解锁前固定 2 个,无法三连)
ensureHighPresence()
// 注入高糖可能破坏“有解”性:开局静默兜底,避免出现整盘无法消除的死局
// (仅打乱类型、保持高糖数量不变)
if (!boardHasSolution()) shuffleTypesUntilPlayable()
startHintTimer()
}
@@ -987,7 +846,6 @@ function setupLevel(levelId) {
highClearAlertVisible.value = false
clearedHighFoodName.value = ''
lastClearedFood.value = null
playerMoves.value = 0
tileTouchStart = null
clearDragPreview()
selectSessionFoods()
@@ -1426,38 +1284,6 @@ function startHintTimer() {
hintTimer = setTimeout(showOperationHint, HINT_DELAY_MS)
}
/**
* 就地打乱棋盘上所有方块的“食物类型”(位置/对象不变),直到棋盘“有解”:
* 无现成消除组 + 至少存在一个可用移动。仅交换 type,故高糖数量等多重集不变。
* 不带任何 UI 反馈,供初始化等静默场景使用。返回是否成功。
*/
function shuffleTypesUntilPlayable(maxRetry = 80) {
const tiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = board.value[r]?.[c]
if (t && !t.isRemoved) tiles.push(t)
}
}
if (tiles.length < 3) return false
for (let attempt = 0; attempt < maxRetry; attempt++) {
for (let i = tiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tiles[i].type, tiles[j].type] = [tiles[j].type, tiles[i].type]
}
if (findMatches().length === 0 && findPossibleMoves().length > 0) {
bumpLayout()
return true
}
}
return false
}
/** 当前棋盘是否“有解”(无现成消除组且至少有一个可用移动) */
function boardHasSolution() {
return findMatches().length === 0 && findPossibleMoves().length > 0
}
/**
* 棋盘死锁自动被测与修复。
* 使用 Fisher-Yates 打乱食物类型,最多重试 maxRetry 次。
@@ -1466,7 +1292,7 @@ function boardHasSolution() {
async function reshuffleIfStuck() {
if (gamePhase.value !== 'playing') return
// 已有解(无现成消除组 + 存在可用移动)则无需重排
if (boardHasSolution()) return
if (findMatches().length === 0 && findPossibleMoves().length > 0) return
// 给玩家反馈
playSfx('reshuffle', 0.75)
@@ -1479,8 +1305,32 @@ async function reshuffleIfStuck() {
setTimeout(() => { floatingVals.value = floatingVals.value.filter(v => v.id !== id) }, 1400)
} catch (_) {}
// 就地打乱类型直到有解;失败则回退到全棋盘重新生成
if (!shuffleTypesUntilPlayable(60)) {
// 收集棋盘上所有有效方块(保持 tile 对象与位置不变,仅打乱其食物类型)
const tiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = board.value[r]?.[c]
if (t && !t.isRemoved) tiles.push(t)
}
}
// 反复打乱类型并就地试写,直到“有解且无现成三连”;
// tiles 已是 board.value 中的同一批对象,故 findMatches/findPossibleMoves 可直接复用
const MAX_RETRY = 60
let ok = false
for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
for (let i = tiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tiles[i].type, tiles[j].type] = [tiles[j].type, tiles[i].type]
}
if (findMatches().length === 0 && findPossibleMoves().length > 0) {
ok = true
break
}
}
if (!ok) {
// 就地洗牌无法洗出可玩布局,回退到全棋盘重新生成
initBoard()
return
}
@@ -1589,7 +1439,6 @@ function recordLastClearedFood(groups) {
gi,
giShort: GI_SHORT[gi] || '低糖',
eatHint: formatEatLabel(t),
tip: getFoodTip(t),
color: GI_COLOR[gi] || '#64748b',
food: cloneFoodType(t)
}
@@ -1636,7 +1485,11 @@ function showLearnFx(groups) {
function checkGameEnd() {
if (gamePhase.value !== 'playing') return
// 唯一通关条件:消除本关唯一的高糖(红色)食物
// 无失败惩罚:认全目标或已消除场上全部高糖食物即通关
if (allGoalsDone.value) {
endGame('won', { reason: 'goals' })
return
}
if (sessionHighFood.value && !boardHasHighGiFood()) {
endGame('won', {
reason: 'high_cleared',
@@ -1759,14 +1612,13 @@ async function processSwap(t1, t2, opts = {}) {
const matches = findMatches()
if (matches.length > 0) {
movesLeft.value = Math.max(0, movesLeft.value - 1)
playerMoves.value += 1
const endedByHigh = await clearAndRefill()
if (endedByHigh) {
isProcessing.value = false
return
}
// 落子算法已保证“下落即有可消除走法”;此处仅作极端兜底(静默、无横幅)
if (!boardHasSolution()) shuffleTypesUntilPlayable()
// 所有连消结束后,检查棋盘是否死锁并自动重排
await reshuffleIfStuck()
} else {
await animateSwapOffset(t1, t2)
swapTiles(t1, t2)
@@ -1840,134 +1692,9 @@ async function clearAndRefill() {
return false
}
/* ——— 落子前在 next 网格上做的辅助判定(不触动可见的 board.value ——— */
function gridIsHigh(grid, r, c) {
const t = grid[r]?.[c]
return t && !t.isRemoved && t.type.gi === 'high'
}
function gridWouldFormHighRun(grid, r, c) {
let h = 1
for (let cc = c - 1; cc >= 0 && gridIsHigh(grid, r, cc); cc--) h++
for (let cc = c + 1; cc < COLS && gridIsHigh(grid, r, cc); cc++) h++
if (h >= 3) return true
let v = 1
for (let rr = r - 1; rr >= 0 && gridIsHigh(grid, rr, c); rr--) v++
for (let rr = r + 1; rr < ROWS && gridIsHigh(grid, rr, c); rr++) v++
return v >= 3
}
/** 在 grid 上是否存在“现成可消除组” */
function gridHasCurrentMatch(grid) {
const saved = board.value
board.value = grid
const has = findMatches().length > 0
board.value = saved
return has
}
/** 在 grid 上是否存在“可交换出消除的走法” */
function gridHasMove(grid) {
const saved = board.value
board.value = grid
const ok = findPossibleMoves().length > 0
board.value = saved
return ok
}
/**
* 仅在 next 网格上调整本局高糖数量(只动“新生成的方块”,玩家尚未看到,故无可见突变):
* 解锁前固定 2 个、解锁后 3~HIGH_COUNT_MAX 个;注入时避免直接连成三连。
*/
function balanceHighInRefill(next, newTiles) {
const highFood = sessionHighFood.value
if (!highFood) return
const before = playerMoves.value < HIGH_UNLOCK_MOVES
const minTarget = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_UNLOCKED
const maxCap = before ? HIGH_COUNT_LOCKED : HIGH_COUNT_MAX
let high = 0
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) if (gridIsHigh(next, r, c)) high++
}
const lowMidPool = activeFoods.value.filter((f) => f.gi !== 'high')
if (high > maxCap) {
for (const t of shuffleInPlace([...newTiles])) {
if (high <= maxCap) break
if (t.type.gi !== 'high') continue
const repl = lowMidPool.length ? lowMidPool[Math.floor(Math.random() * lowMidPool.length)] : null
if (repl) { t.type = cloneFoodType(repl); high-- }
}
}
if (high < minTarget) {
const cands = shuffleInPlace([...newTiles])
for (const t of cands) {
if (high >= minTarget) break
if (t.type.gi === 'high') continue
if (gridWouldFormHighRun(next, t.r, t.c)) continue
t.type = cloneFoodType(highFood)
high++
}
for (const t of cands) {
if (high >= minTarget) break
if (t.type.gi === 'high') continue
t.type = cloneFoodType(highFood)
high++
}
}
}
/**
* 保证“落下后必有可消除的走法”:只重 roll 新方块里的非高糖类型(落子前完成,无可见重排)。
* 若 next 已有现成消除组(即将触发连锁),则交给连锁处理,最终落子时再保证。
*/
function ensureRefillPlayable(next, newTiles) {
if (gridHasCurrentMatch(next)) return
if (gridHasMove(next)) return
const nonHighPool = activeFoods.value.filter((f) => f.gi !== 'high')
const rerollable = newTiles.filter((t) => t.type.gi !== 'high')
if (nonHighPool.length && rerollable.length) {
for (let attempt = 0; attempt < 100; attempt++) {
rerollable.forEach((t) => {
t.type = cloneFoodType(nonHighPool[Math.floor(Math.random() * nonHighPool.length)])
})
if (gridHasMove(next)) return
}
}
// 极端兜底:静默打乱整盘类型(保持多重集/高糖数量),无任何横幅提示
silentShuffleGrid(next)
}
/** 在 next 网格上静默打乱类型直至“无现成消除组且有可用走法”,仅作极端兜底 */
function silentShuffleGrid(next) {
const tiles = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const t = next[r]?.[c]
if (t && !t.isRemoved) tiles.push(t)
}
}
if (tiles.length < 3) return
const saved = board.value
board.value = next
for (let attempt = 0; attempt < 80; attempt++) {
for (let i = tiles.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tiles[i].type, tiles[j].type] = [tiles[j].type, tiles[i].type]
}
if (findMatches().length === 0 && findPossibleMoves().length > 0) break
}
board.value = saved
}
async function applyGravity() {
const next = Array.from({ length: ROWS }, () => Array(COLS).fill(null))
const dropTiles = []
const newTiles = []
for (let c = 0; c < COLS; c++) {
const surviving = []
@@ -1995,15 +1722,10 @@ async function applyGravity() {
tile.isDropAnimate = false
next[r][c] = tile
dropTiles.push(tile)
newTiles.push(tile)
}
}
ensureBoardFull(next)
// 落子前只调整“新方块”:①按解锁进度维持高糖数量 ②保证落下后必有可消除走法
// 这样棋盘随下落即更新,无需“无解时整盘自动重排”。
balanceHighInRefill(next, newTiles)
ensureRefillPlayable(next, newTiles)
commitBoard(next)
await nextTick()
@@ -2137,8 +1859,6 @@ async function onBooster(booster) {
return
}
}
// 恢复本关高糖数量,避免纤维清空高糖直接跳过“8 步”门槛
ensureHighPresence()
// fiber 改变了棋盘,保险检查是否死锁
await reshuffleIfStuck()
@@ -2255,8 +1975,6 @@ async function onBooster(booster) {
await delay(GRAVITY_MS)
clearDropFlags()
// 维持本关高糖数量
ensureHighPresence()
// meal 自带洗牌已保证有解,但再做一次轻量检查保险
await reshuffleIfStuck()
@@ -2305,7 +2023,7 @@ function initLayout() {
paddingTop: `${paddingTop}px`,
paddingLeft: '32rpx',
paddingRight: '32rpx',
paddingBottom: '18rpx'
paddingBottom: '10rpx'
}
navStyle.value = {
paddingRight: `${Math.max(0, headerPaddingRight - uni.upx2px(32))}px`
@@ -2325,25 +2043,19 @@ function recalcBoardSize() {
const query = uni.createSelectorQuery().in(instance?.proxy)
query.select('.gg-header').boundingClientRect()
query.select('.gg-footer').boundingClientRect()
query.select('.gg-board-hero').boundingClientRect()
query.exec((res) => {
try {
const sys = uni.getSystemInfoSync()
const header = res?.[0]
const footer = res?.[1]
const hero = res?.[2]
const headerBottom = Math.ceil(header?.bottom ?? header?.height ?? uni.upx2px(360))
// 棋盘真正可用区从进度条/信息横幅之下(board-hero 顶部)算起
const boardAreaTop = Math.ceil(hero?.top ?? headerBottom)
// 底部道具栏较高,测量失败时用更保守的兜底高度,避免最后一行被遮挡
const footerTop = Math.floor(footer?.top ?? (sys.windowHeight - uni.upx2px(300)))
const inset = uni.upx2px(BOARD_INSET_RPX)
const footerGap = uni.upx2px(BOARD_FOOTER_GAP_RPX)
// gg-main 左右内边距各 20rpx,棋盘可用宽度需相应收窄
const sidePad = uni.upx2px(20)
const zoneW = sys.windowWidth - sidePad * 2
const zoneW = sys.windowWidth
// 预留底部安全间隙,棋盘不贴到道具栏
const zoneH = Math.max(0, footerTop - boardAreaTop - footerGap)
const zoneH = Math.max(0, footerTop - headerBottom - footerGap)
// 5 行 × 4 列:宽高分别约束,取较小单格像素
cellSizePx.value = calcMaxCellSize(zoneW - inset * 2, zoneH - inset * 2)
File diff suppressed because it is too large Load Diff
+156 -39
View File
@@ -56,27 +56,55 @@
<!-- 2. 血糖趋势无就诊卡或累计录入不足 7 天显示示例曲线 -->
<view class="st-chart-card">
<view class="st-chart-head">
<view style="padding: 0 20rpx;">
<view class="st-chart-head-text">
<text class="st-chart-title">血糖趋势</text>
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
</view>
<view class="st-chart-legend">
<view
class="st-legend-item"
:class="{ inactive: !chartSeriesVisible.fasting }"
@click="toggleChartSeries('fasting')"
>
<view class="st-dot st-dot-fasting" />
<text>空腹</text>
<view class="st-chart-toolbar">
<view class="st-chart-legend">
<view
class="st-legend-item"
:class="{ inactive: !chartSeriesVisible.fasting }"
@click="toggleChartSeries('fasting')"
>
<view class="st-dot st-dot-fasting" />
<text>空腹</text>
</view>
<view
class="st-legend-item"
:class="{ inactive: !chartSeriesVisible.postprandial }"
@click="toggleChartSeries('postprandial')"
>
<view class="st-dot st-dot-post" />
<text>餐后</text>
</view>
</view>
<view
class="st-legend-item"
:class="{ inactive: !chartSeriesVisible.postprandial }"
@click="toggleChartSeries('postprandial')"
v-if="bloodCareEligible"
class="st-chart-ai-chip"
:class="{
active: bloodCarePanelOpen,
loading: bloodCareLoading,
hint: !bloodCarePanelOpen && !bloodCareLoading
}"
role="button"
aria-label="AI 照护建议"
@click.stop="toggleBloodCareFromChart"
>
<view class="st-dot st-dot-post" />
<text>餐后</text>
<view class="st-chart-ai-chip-inner">
<view class="st-chart-ai-chip-icon">
<TongjiIcon
name="sparkles"
size="sm"
:color="bloodCarePanelOpen ? '#ffffff' : '#006c49'"
/>
</view>
<text class="st-chart-ai-chip-label">{{
bloodCareLoading
? '生成中'
: (bloodCarePanelOpen ? '收起建议' : '照护建议')
}}</text>
</view>
</view>
</view>
</view>
@@ -98,6 +126,88 @@
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据累计记录满 7 天后显示您的真实趋势</text>
</view>
<!-- 趋势卡内点击标题栏 AI 按钮后展开 -->
<view v-if="bloodCareEligible && bloodCarePanelOpen" class="st-chart-care-panel">
<view class="st-chart-care-head">
<text class="st-chart-care-title">AI 照护建议</text>
<view class="st-chart-care-actions">
<view
class="st-chart-care-refresh"
:class="{ disabled: bloodCareLoading }"
@click.stop="refreshBloodCareAi"
>
<TongjiIcon name="refresh" size="sm" color="#006c49" />
<text>{{ bloodCareLoading ? '生成中' : '换一换' }}</text>
</view>
<view class="st-chart-care-close" @click.stop="closeBloodCarePanel">
<text>收起</text>
</view>
</view>
</view>
<text class="st-chart-care-sub">基于您累计 7 天血糖 · 监测与中医调理方向非处方</text>
<view v-if="bloodCareControlLabel" class="st-care-badge st-care-badge--in-chart">
<text>{{ bloodCareControlLabel }}</text>
</view>
<view v-if="bloodCareLoading && !hasBloodCareContent" class="st-skeleton st-skeleton--in-chart">
<view class="st-skeleton-line" />
<view class="st-skeleton-line short" />
</view>
<view v-else-if="hasBloodCareContent" class="st-care-cards st-care-cards--in-chart">
<view class="st-meal-glass st-care-block">
<view class="st-meal-head">
<text class="st-meal-label">总评</text>
</view>
<text class="st-meal-text">
{{ bloodCarePlan.summary || '…' }}<text v-if="bloodCareLoading && bloodCareStreamField === 'summary'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="st-meal-glass st-care-block">
<view class="st-meal-head">
<text class="st-meal-label">血糖管理</text>
</view>
<text class="st-meal-text">
{{ bloodCarePlan.blood_advice || '…' }}<text v-if="bloodCareLoading && bloodCareStreamField === 'blood_advice'" class="ai-stream-cursor"></text>
</text>
</view>
<view class="st-meal-glass st-care-block st-care-block--tcm">
<view class="st-meal-head">
<text class="st-meal-label">中医调理</text>
</view>
<text class="st-meal-text">
{{ bloodCarePlan.tcm_plan || '…' }}<text v-if="bloodCareLoading && bloodCareStreamField === 'tcm_plan'" class="ai-stream-cursor"></text>
</text>
</view>
<view v-if="bloodCarePlan.watch_points && bloodCarePlan.watch_points.length" class="st-care-watch">
<text class="st-care-watch-title">留意</text>
<view v-for="(item, wi) in bloodCarePlan.watch_points" :key="wi" class="st-care-watch-item">
<text class="st-care-watch-dot">·</text>
<text class="st-care-watch-text">{{ item }}</text>
</view>
</view>
<view v-if="bloodCarePlan.next_steps" class="st-meal-glass st-care-block st-care-block--next">
<view class="st-meal-head">
<text class="st-meal-label">复诊提醒</text>
</view>
<text class="st-meal-text">
{{ bloodCarePlan.next_steps }}<text v-if="bloodCareLoading && bloodCareStreamField === 'next_steps'" class="ai-stream-cursor"></text>
</text>
</view>
</view>
<view v-if="bloodCareAnalysis.length" class="st-care-stats st-care-stats--in-chart">
<view v-for="(s, si) in bloodCareAnalysis" :key="si" class="st-care-stat-chip">
<text class="st-care-stat-label">{{ s.label }}</text>
<text class="st-care-stat-val">记录 {{ s.record_days }} · 达标 {{ s.compliance }}%</text>
</view>
</view>
<text v-if="bloodCareDisclaimer" class="st-care-disclaimer st-care-disclaimer--in-chart">{{ bloodCareDisclaimer }}</text>
</view>
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
@@ -109,6 +219,7 @@
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<view class="st-training-section">
<dev-training-entry inline tone="weekly" />
</view>
@@ -193,26 +304,19 @@
<view class="st-float-close" @click="floatAskClosed = true">
<text class="st-float-close-icon">×</text>
</view>
<view class="st-float-inner" :class="{ 'is-expanded': dietAiAskExpanded }">
<view class="st-float-inner">
<view class="st-float-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
</view>
<textarea
<input
class="st-float-input"
type="text"
:value="dietAiAskText"
placeholder="输入或语音咨询 AI"
placeholder="长按麦克风说话,或输入文字咨询 AI..."
placeholder-class="st-float-placeholder"
:maxlength="-1"
auto-height
:show-confirm-bar="false"
:disable-default-padding="true"
:cursor-spacing="24"
confirm-type="send"
:confirm-hold="false"
@input="onDietAiAskInput"
@confirm="askDietAiFood"
@focus="onDietAiAskFocus"
@blur="onDietAiAskBlur"
/>
<view
class="st-float-mic"
@@ -421,6 +525,7 @@ import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
import TongjiIcon from '../components/TongjiIcon.vue'
import { useDietAi } from '../composables/useDietAi.js'
import { useBloodCareAi } from '../composables/useBloodCareAi.js'
import { useSpeechToText } from '../composables/useSpeechToText.js'
import TabBarDock from '@/components/app-tab-bar/tab-bar-dock.vue'
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
@@ -526,19 +631,6 @@ const {
/** 底部 AI 浮动卡片是否被用户收起 */
const floatAskClosed = ref(false)
/** AI 输入框是否聚焦:聚焦时输入框变大 */
const dietAiAskFocused = ref(false)
function onDietAiAskFocus() {
dietAiAskFocused.value = true
}
function onDietAiAskBlur() {
dietAiAskFocused.value = false
}
/** 聚焦或已有内容时展开为多行输入(内容多则自动增高) */
const dietAiAskExpanded = computed(
() => dietAiAskFocused.value || String(dietAiAskText.value || '').length > 0
)
/** 键盘高度(px):录入弹窗据此整体上移,避免输入框被键盘遮挡 */
const keyboardHeight = ref(0)
function onKeyboardHeightChange(res) {
@@ -944,6 +1036,27 @@ const chartHasDisplayData = computed(() => {
return bloodHasData.value
})
const {
bloodCarePlan,
bloodCareLoading,
bloodCareStreamField,
bloodCareEligible,
bloodCarePanelOpen,
hasBloodCareContent,
bloodCareAnalysis,
bloodCareControlLabel,
bloodCareDisclaimer,
toggleBloodCareFromChart,
closeBloodCarePanel,
refreshBloodCareAi,
resetBloodCarePlan
} = useBloodCareAi(proxy, {
diagnosisId,
chartUseMockData,
showUserToast,
formatUserMessage
})
const bloodHighDayCount = computed(() => {
return chartDates.value.filter((date) => {
const b = bloodByDate.value[date]
@@ -1400,6 +1513,7 @@ async function onSelectCard(card) {
exerciseRecords.value = []
trackingNotes.value = []
hoverInfo.value = null
resetBloodCarePlan()
await applyCard(card)
fetchAll()
}
@@ -1456,6 +1570,9 @@ async function fetchAll() {
trackingNotes.value = []
}
await fetchDietAiRecommend(false)
if (!bloodCareEligible.value) {
resetBloodCarePlan()
}
} catch (e) {
uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' })
} finally {
+164 -421
View File
@@ -5,10 +5,8 @@
.game-page {
height: 100vh;
height: 100dvh;
/* 参考稿:顶部高光的薄荷渐变背景 */
background: #e9f0e9;
background-image: radial-gradient(circle at 50% -20%, #ffffff 0%, #e9f0e9 60%, #d5dcd5 100%);
color: #171d19;
background: #f4fbf4; /* VitalMint background */
color: #161d19; /* VitalMint text color */
overflow: hidden;
position: relative;
display: flex;
@@ -26,72 +24,68 @@
}
.game-page .gg-glass {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1.5rpx solid rgba(255, 255, 255, 0.6);
}
/* ===== Header / TopAppBar(参考稿玻璃顶栏) ===== */
/* ===== Header HUD ===== */
.game-page .gg-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 50;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-bottom: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.02);
border-radius: 0 0 24rpx 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 108, 73, 0.04);
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1rpx solid rgba(0, 108, 73, 0.06);
}
.game-page .gg-hud-nav {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
min-height: var(--menu-height, 64rpx);
height: var(--menu-height, 64rpx);
margin-bottom: 0;
}
.game-page .gg-hud-control-group {
display: flex;
align-items: center;
gap: 16rpx;
gap: 10rpx;
}
/* 玻璃圆形图标按钮 */
.game-page .gg-hud-btn {
width: 84rpx;
height: 84rpx;
width: var(--menu-height, 64rpx);
height: var(--menu-height, 64rpx);
border-radius: 50%;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.85);
border: 1rpx solid rgba(0, 108, 73, 0.08);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04), inset 0 1px 2px rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: all 0.15s ease;
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.03);
transition: all 0.2s ease;
}
.game-page .gg-hud-btn--pressed {
opacity: 0.75;
transform: scale(0.94);
background: rgba(255, 255, 255, 0.95);
}
.game-page .gg-hud-btn.is-muted {
opacity: 0.55;
opacity: 0.45;
}
.game-page .gg-hud-level-center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
@@ -99,29 +93,25 @@
}
.game-page .gg-hud-level-text {
font-size: 44rpx;
font-size: 30rpx;
font-weight: 800;
color: #006c4b;
color: #475569;
line-height: 1.1;
letter-spacing: 2rpx;
text-shadow: 0 2rpx 6rpx rgba(0, 108, 75, 0.12);
letter-spacing: 1rpx;
}
.game-page .gg-hud-score {
display: flex;
align-items: baseline;
gap: 6rpx;
margin-top: 2rpx;
gap: 4rpx;
}
.game-page .gg-hud-score-val {
font-size: 68rpx;
font-weight: 800;
color: #006c4b;
font-size: 44rpx;
font-weight: 900;
color: #006c49;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -2rpx;
text-shadow: 0 2rpx 6rpx rgba(0, 108, 75, 0.12);
line-height: 1.2;
}
.game-page .gg-hud-score.is-pop .gg-hud-score-val {
@@ -129,254 +119,44 @@
}
.game-page .gg-hud-score-unit {
font-size: 28rpx;
font-weight: 700;
color: #3d4a42;
}
/* ===== 信息横幅(参考稿玻璃卡 + 绿色 info 图标) ===== */
.game-page .gg-info-banner {
flex-shrink: 0;
width: 100%;
max-width: 750rpx;
margin: 0 auto 20rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 20rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.06), inset 0 1px 2px rgba(255, 255, 255, 0.9);
}
.game-page .gg-info-banner-icon {
flex-shrink: 0;
width: 80rpx;
height: 80rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #00a676, #006c4b);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8rpx 18rpx rgba(0, 108, 75, 0.28);
}
.game-page .gg-info-banner-body {
flex: 1;
min-width: 0;
}
.game-page .gg-info-banner-text {
font-size: 28rpx;
font-weight: 500;
color: #171d19;
line-height: 1.5;
}
.game-page .gg-info-banner-kw {
font-size: 32rpx;
font-weight: 800;
color: #006c4b;
letter-spacing: 1rpx;
}
/* —— 消除后:该食物的含糖 + 建议 —— */
.game-page .gg-info-banner.is-food {
gap: 18rpx;
animation: gg-info-food-in 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.game-page .gg-info-banner.is-food.is-low { border-color: rgba(22, 163, 74, 0.28); }
.game-page .gg-info-banner.is-food.is-mid { border-color: rgba(234, 88, 12, 0.28); }
.game-page .gg-info-banner.is-food.is-high { border-color: rgba(220, 38, 38, 0.3); }
/* 食物图标:白底 + GI 彩色描边(覆盖默认绿色渐变) */
.game-page .gg-info-banner-icon.is-food {
background: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.9);
border-width: 2rpx;
border-style: solid;
}
.game-page .gg-info-banner-icon.is-food.is-low { border-color: rgba(22, 163, 74, 0.4); }
.game-page .gg-info-banner-icon.is-food.is-mid { border-color: rgba(234, 88, 12, 0.4); }
.game-page .gg-info-banner-icon.is-food.is-high { border-color: rgba(220, 38, 38, 0.45); }
.game-page .gg-info-banner-icon.is-food .food-tile-icon--goal {
width: 60rpx;
height: 60rpx;
}
.game-page .gg-info-food-head {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 8rpx;
}
.game-page .gg-info-food-name {
font-size: 34rpx;
font-weight: 900;
color: #171d19;
line-height: 1.2;
max-width: 320rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.game-page .gg-info-food-gi {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 4rpx 16rpx;
border-radius: 999rpx;
}
.game-page .gg-info-food-gi-text {
font-size: 24rpx;
font-weight: 800;
color: #ffffff;
letter-spacing: 1rpx;
}
.game-page .gg-info-food-gi.is-low { background: linear-gradient(135deg, #5bdda8, #00a676); box-shadow: 0 2rpx 8rpx rgba(0, 166, 118, 0.32); }
.game-page .gg-info-food-gi.is-mid { background: linear-gradient(135deg, #fbbf24, #d97706); box-shadow: 0 2rpx 8rpx rgba(217, 119, 6, 0.32); }
.game-page .gg-info-food-gi.is-high { background: linear-gradient(135deg, #f87171, #ba1a1a); box-shadow: 0 2rpx 8rpx rgba(186, 26, 26, 0.34); }
.game-page .gg-info-food-tip {
display: block;
font-size: 26rpx;
font-weight: 600;
color: #3d4a42;
line-height: 1.4;
}
.game-page .gg-info-food-tip.is-low { color: #15803d; }
.game-page .gg-info-food-tip.is-mid { color: #c2410c; }
.game-page .gg-info-food-tip.is-high { color: #b91c1c; }
@keyframes gg-info-food-in {
from { opacity: 0.35; transform: translateY(-6rpx); }
to { opacity: 1; transform: translateY(0); }
font-weight: 700;
color: #64748b;
}
/* ===== 次:上次消除(名称 + 含糖量)GI 三色编码学习卡 ===== */
/* ===== 次:上次消除(名称 + 含糖量) ===== */
.game-page .gg-hud-last-food {
flex-shrink: 0;
width: 100%;
max-width: 750rpx;
margin: 0 auto 14rpx;
padding: 16rpx 18rpx 18rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.92);
padding: 16rpx 18rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.88);
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-sizing: border-box;
box-shadow: 0 6rpx 20rpx rgba(0, 108, 73, 0.07);
}
/* 头部:标题 + GI 状态胶囊 */
.game-page .gg-hud-last-food-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 12rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 108, 73, 0.06);
}
.game-page .gg-hud-last-food-kicker {
display: flex;
align-items: center;
display: block;
font-size: 24rpx;
font-weight: 800;
color: #64748b;
letter-spacing: 0.08em;
font-weight: 700;
color: #475569;
letter-spacing: 0.04em;
margin-bottom: 12rpx;
line-height: 1.3;
padding-left: 16rpx;
position: relative;
}
.game-page .gg-hud-last-food-kicker::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 24rpx;
border-radius: 3rpx;
background: #006c49;
}
/* 头部 GI 胶囊 */
.game-page .gg-hud-last-food-chip {
display: flex;
align-items: center;
gap: 8rpx;
padding: 5rpx 16rpx 5rpx 12rpx;
border-radius: 999rpx;
font-weight: 800;
line-height: 1;
border: 1rpx solid transparent;
}
.game-page .gg-hud-last-food-chip-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
flex-shrink: 0;
}
.game-page .gg-hud-last-food-chip-text {
font-size: 24rpx;
font-weight: 800;
}
.game-page .gg-hud-last-food-chip.is-low {
background: rgba(22, 163, 74, 0.12);
border-color: rgba(22, 163, 74, 0.22);
}
.game-page .gg-hud-last-food-chip.is-low .gg-hud-last-food-chip-dot { background: #16a34a; }
.game-page .gg-hud-last-food-chip.is-low .gg-hud-last-food-chip-text { color: #15803d; }
.game-page .gg-hud-last-food-chip.is-mid {
background: rgba(234, 88, 12, 0.12);
border-color: rgba(234, 88, 12, 0.22);
}
.game-page .gg-hud-last-food-chip.is-mid .gg-hud-last-food-chip-dot { background: #ea580c; }
.game-page .gg-hud-last-food-chip.is-mid .gg-hud-last-food-chip-text { color: #c2410c; }
.game-page .gg-hud-last-food-chip.is-high {
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.24);
}
.game-page .gg-hud-last-food-chip.is-high .gg-hud-last-food-chip-dot { background: #dc2626; }
.game-page .gg-hud-last-food-chip.is-high .gg-hud-last-food-chip-text { color: #b91c1c; }
/* 主卡:左侧 GI 强调条 + 图标 + 文案 */
.game-page .gg-hud-last-food-card {
position: relative;
display: flex;
align-items: center;
gap: 18rpx;
padding: 16rpx 18rpx 16rpx 24rpx;
border-radius: 20rpx;
overflow: hidden;
gap: 16rpx;
padding: 14rpx 16rpx;
border-radius: 18rpx;
background: #f8fafc;
border: 1rpx solid rgba(15, 23, 42, 0.06);
animation: gg-last-food-in 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 左侧强调条(GI 三色) */
.game-page .gg-hud-last-food-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8rpx;
border-radius: 8rpx;
background: #cbd5e1;
border: 1rpx solid rgba(0, 108, 73, 0.08);
animation: gg-last-food-in 0.22s ease-out;
}
@media (prefers-reduced-motion: reduce) {
@@ -386,44 +166,35 @@
}
.game-page .gg-hud-last-food-card.is-high {
background: linear-gradient(90deg, rgba(254, 226, 226, 0.85) 0%, rgba(254, 242, 242, 0.6) 60%);
border-color: rgba(220, 38, 38, 0.18);
background: rgba(254, 242, 242, 0.9);
border-color: rgba(220, 38, 38, 0.2);
}
.game-page .gg-hud-last-food-card.is-high::before { background: #dc2626; }
.game-page .gg-hud-last-food-card.is-mid {
background: linear-gradient(90deg, rgba(255, 237, 213, 0.85) 0%, rgba(255, 251, 235, 0.6) 60%);
border-color: rgba(234, 88, 12, 0.16);
background: rgba(255, 251, 235, 0.95);
border-color: rgba(234, 88, 12, 0.18);
}
.game-page .gg-hud-last-food-card.is-mid::before { background: #ea580c; }
.game-page .gg-hud-last-food-card.is-low {
background: linear-gradient(90deg, rgba(220, 252, 231, 0.85) 0%, rgba(240, 253, 244, 0.6) 60%);
border-color: rgba(22, 163, 74, 0.16);
background: rgba(240, 253, 244, 0.95);
border-color: rgba(22, 163, 74, 0.15);
}
.game-page .gg-hud-last-food-card.is-low::before { background: #16a34a; }
/* 图标:白底 + GI 彩色描边环 */
.game-page .gg-hud-last-food-icon {
width: 80rpx;
height: 80rpx;
width: 72rpx;
height: 72rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 18rpx;
border-radius: 16rpx;
background: #fff;
border: 2rpx solid rgba(15, 23, 42, 0.06);
box-shadow: 0 3rpx 10rpx rgba(15, 23, 42, 0.08);
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.06);
}
.game-page .gg-hud-last-food-icon.is-low { border-color: rgba(22, 163, 74, 0.35); }
.game-page .gg-hud-last-food-icon.is-mid { border-color: rgba(234, 88, 12, 0.35); }
.game-page .gg-hud-last-food-icon.is-high { border-color: rgba(220, 38, 38, 0.4); }
.game-page .gg-hud-last-food-icon .food-tile-icon--goal {
width: 62rpx;
height: 62rpx;
width: 56rpx;
height: 56rpx;
}
.game-page .gg-hud-last-food-body {
@@ -435,10 +206,10 @@
}
.game-page .gg-hud-last-food-name {
font-size: 36rpx;
font-weight: 900;
font-size: 34rpx;
font-weight: 800;
color: #0f172a;
line-height: 1.2;
line-height: 1.25;
word-break: break-all;
}
@@ -446,61 +217,42 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8rpx 14rpx;
gap: 8rpx 12rpx;
}
/* 含糖等级徽标:GI 三色实底 + 白字(保证对比度) */
.game-page .gg-hud-last-food-gi {
display: inline-flex;
align-items: center;
padding: 5rpx 16rpx;
border-radius: 999rpx;
.game-page .gg-hud-last-food-sugar-label {
font-size: 26rpx;
font-weight: 700;
color: #475569;
line-height: 1.3;
}
.game-page .gg-hud-last-food-gi-text {
.game-page .gg-hud-last-food-gi {
font-size: 26rpx;
font-weight: 800;
color: #fff;
letter-spacing: 0.02em;
padding: 4rpx 14rpx;
border-radius: 8rpx;
line-height: 1.3;
}
.game-page .gg-hud-last-food-gi.is-low { background: #16a34a; box-shadow: 0 2rpx 8rpx rgba(22, 163, 74, 0.3); }
.game-page .gg-hud-last-food-gi.is-mid { background: #ea580c; box-shadow: 0 2rpx 8rpx rgba(234, 88, 12, 0.3); }
.game-page .gg-hud-last-food-gi.is-high { background: #dc2626; box-shadow: 0 2rpx 8rpx rgba(220, 38, 38, 0.32); }
.game-page .gg-hud-last-food-sugar-hint {
font-size: 26rpx;
font-weight: 700;
color: #475569;
color: #334155;
line-height: 1.35;
}
.game-page .gg-hud-last-food-card.is-high .gg-hud-last-food-sugar-hint { color: #b91c1c; }
.game-page .gg-hud-last-food-card.is-mid .gg-hud-last-food-sugar-hint { color: #c2410c; }
.game-page .gg-hud-last-food-card.is-low .gg-hud-last-food-sugar-hint { color: #15803d; }
/* 空态占位 */
.game-page .gg-hud-last-food-placeholder {
display: flex;
align-items: center;
gap: 12rpx;
padding: 18rpx 16rpx;
border-radius: 20rpx;
background: #f8fafc;
border: 1rpx dashed rgba(15, 23, 42, 0.12);
.game-page .gg-hud-last-food-card.is-high .gg-hud-last-food-sugar-hint {
color: #991b1b;
}
.game-page .gg-hud-last-food-placeholder-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
background: #cbd5e1;
.game-page .gg-hud-last-food-placeholder {
padding: 12rpx 4rpx;
}
.game-page .gg-hud-last-food-placeholder text {
font-size: 27rpx;
font-size: 28rpx;
font-weight: 600;
color: #94a3b8;
line-height: 1.45;
@@ -682,8 +434,8 @@
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
overflow: visible;
padding: 14rpx 20rpx 0;
overflow: hidden;
padding: 8rpx 16rpx 0;
}
/* gg-hint-banner 已移除,提示直接显示在棋盘食物上 */
@@ -717,16 +469,17 @@
.game-page .game-grid {
display: flex;
flex-direction: column;
border-radius: 48rpx;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 12rpx 32rpx rgba(0, 0, 0, 0.06),
inset 0 1px 2px rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.8);
0 20rpx 56rpx rgba(0, 108, 73, 0.14),
0 4rpx 16rpx rgba(15, 23, 42, 0.06),
inset 0 2rpx 12rpx rgba(255, 255, 255, 0.85);
border: 2rpx solid rgba(0, 108, 73, 0.12);
box-sizing: border-box;
overflow: visible;
overflow: hidden;
flex-shrink: 0;
}
@@ -750,17 +503,14 @@
inset: 0;
width: 100%;
height: 100%;
border-radius: 28rpx;
border-radius: 0;
display: flex;
align-items: stretch;
justify-content: stretch;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(244, 251, 245, 0.6));
box-shadow:
0 10rpx 20rpx rgba(0, 108, 75, 0.08),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.8),
inset 0 -2rpx 6rpx rgba(0, 0, 0, 0.02);
border: 1px solid rgba(255, 255, 255, 0.9);
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.25s ease, opacity 0.2s;
background: transparent;
box-shadow: none;
border: none;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s;
z-index: 1;
box-sizing: border-box;
overflow: visible;
@@ -793,13 +543,13 @@
}
.game-page .game-tile.is-selected {
transform: scale(1.08);
transform: scale(1.12);
z-index: 20;
border: 2px solid #00a676;
border-radius: 28rpx;
box-shadow:
0 0 20rpx rgba(0, 166, 118, 0.35),
inset 0 0 10rpx rgba(0, 166, 118, 0.12);
border: none;
box-shadow: none;
outline: 4rpx solid #006c49;
outline-offset: -2rpx;
border-radius: 8rpx;
}
.game-page .game-tile--pressed {
@@ -1002,39 +752,31 @@
/* ===== 棋子含糖等级常驻角标(认知核心:一眼看出高/中/低糖) ===== */
.game-page .gg-tile-gi {
position: absolute;
top: -12rpx;
left: 50%;
transform: translateX(-50%);
z-index: 5;
min-width: 40rpx;
padding: 6rpx 16rpx;
top: 2rpx;
left: 2rpx;
z-index: 3;
min-width: 30rpx;
height: 30rpx;
padding: 0 6rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 800;
font-weight: 900;
color: #ffffff;
line-height: 1;
letter-spacing: 1rpx;
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.6);
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.25);
border: 2rpx solid rgba(255, 255, 255, 0.9);
}
.game-page .gg-tile-gi.is-low {
background: linear-gradient(135deg, rgba(91, 221, 168, 0.95), rgba(0, 166, 118, 0.95));
box-shadow: 0 4rpx 12rpx rgba(0, 166, 118, 0.4), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
}
.game-page .gg-tile-gi.is-mid {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.95), rgba(217, 119, 6, 0.95));
box-shadow: 0 4rpx 12rpx rgba(217, 119, 6, 0.4), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
}
.game-page .gg-tile-gi.is-low { background: #16a34a; }
.game-page .gg-tile-gi.is-mid { background: #ea580c; }
.game-page .gg-tile-gi.is-high {
background: linear-gradient(135deg, rgba(248, 113, 113, 0.95), rgba(186, 26, 26, 0.95));
box-shadow: 0 4rpx 12rpx rgba(186, 26, 26, 0.4), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
background: #dc2626;
box-shadow: 0 2rpx 10rpx rgba(220, 38, 38, 0.45);
font-size: 22rpx;
}
/* ===== 点击食物的大字科普卡 ===== */
@@ -1127,96 +869,97 @@
right: 0;
bottom: 0;
z-index: 40;
border-radius: 64rpx 64rpx 0 0;
border-top: 1px solid #ffffff;
box-shadow: 0 -12rpx 40rpx rgba(0, 0, 0, 0.08);
padding-top: 24rpx;
border-radius: 48rpx 48rpx 0 0;
border-top: 1rpx solid rgba(0, 108, 73, 0.06);
box-shadow: 0 -4rpx 24rpx rgba(0, 108, 73, 0.04);
padding-top: 12rpx;
padding-left: 24rpx;
padding-right: 24rpx;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
background: rgba(255, 255, 255, 0.78);
}
.game-page .gg-footer--sub .gg-booster-btn {
padding: 16rpx 0;
width: 152rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.75);
border-color: rgba(0, 108, 73, 0.08);
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.04);
}
.game-page .gg-footer--sub .gg-booster-label {
font-size: 24rpx;
color: #64748b;
}
.game-page .gg-boosters {
display: flex;
justify-content: space-around;
align-items: flex-start;
align-items: center;
max-width: 750rpx;
margin: 0 auto;
}
/* 单个道具:图标卡 + 角标 + 文字 */
.game-page .gg-booster-btn {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 8rpx 0;
width: 184rpx;
background: transparent;
border: none;
transition: transform 0.2s ease;
}
.game-page .gg-booster-tile {
position: relative;
width: 112rpx;
height: 112rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 22rpx 0;
width: 176rpx;
background: rgba(255, 255, 255, 0.85);
border: 1px solid #ffffff;
box-shadow: 0 8rpx 18rpx rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.9);
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease;
border: 1.5rpx solid rgba(0, 108, 73, 0.12);
border-radius: 40rpx;
box-shadow: 0 8rpx 20rpx rgba(0, 108, 73, 0.03), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.8);
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.game-page .gg-booster-btn--pressed .gg-booster-tile {
.game-page .gg-booster-btn--pressed {
transform: scale(0.92);
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.65);
}
.game-page .gg-booster-btn.is-disabled {
opacity: 0.4;
opacity: 0.35;
filter: grayscale(100%);
pointer-events: none !important;
}
.game-page .gg-booster-count {
position: absolute;
top: -12rpx;
right: -12rpx;
min-width: 50rpx;
height: 50rpx;
padding: 0 10rpx;
border-radius: 26rpx;
top: -10rpx;
right: -10rpx;
width: 52rpx;
height: 52rpx;
border-radius: 50%;
color: #fff;
font-size: 28rpx;
font-weight: 800;
font-size: 30rpx;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid #ffffff;
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.18);
text-shadow: 0 1rpx 1rpx rgba(0, 0, 0, 0.2);
border: 4rpx solid #fff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
z-index: 2;
}
.game-page .gg-booster-count.is-green { background: linear-gradient(135deg, #00a676, #006c4b); box-shadow: 0 4rpx 10rpx rgba(0, 166, 118, 0.3); }
.game-page .gg-booster-count.is-orange { background: linear-gradient(135deg, #f59e0b, #d97706); box-shadow: 0 4rpx 10rpx rgba(217, 119, 6, 0.3); }
.game-page .gg-booster-count.is-rose { background: linear-gradient(135deg, #ef4444, #ba1a1a); box-shadow: 0 4rpx 10rpx rgba(186, 26, 26, 0.3); }
.game-page .gg-booster-count.is-green { background: #10b981; }
.game-page .gg-booster-count.is-orange { background: #f97316; }
.game-page .gg-booster-count.is-rose { background: #f43f5e; }
.game-page .gg-booster-icon {
transition: transform 0.2s ease;
margin-bottom: 8rpx;
transition: transform 0.25s ease;
}
.game-page .gg-booster-btn--pressed .gg-booster-icon {
transform: scale(0.85);
}
.game-page .gg-booster-label {
margin-top: 14rpx;
font-size: 30rpx;
font-weight: 700;
color: #3d4a42;
letter-spacing: 1rpx;
color: #2b3a32;
}
/* ===== 高糖消除红色警示弹窗 ===== */
+1 -258
View File
@@ -639,8 +639,7 @@
}
.more-page .mc-tasks-section {
margin-top: 0;
margin-bottom: 32rpx;
margin-top: 40rpx;
}
.more-page .mc-tasks-grid {
@@ -1168,259 +1167,3 @@
font-size: 22rpx;
color: rgba(108, 122, 113, 0.6);
}
.more-page .mc-tree-species-btn {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-left: 8rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(0, 108, 73, 0.08);
font-size: 20rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel,
.more-page .mc-species-panel {
width: calc(100% - 48rpx);
max-width: 680rpx;
margin: 0 auto;
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 78vh;
background: var(--surface);
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
}
.more-page .mc-water-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-title {
font-size: 34rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-water-panel-close {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: var(--surface-container-low);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: var(--outline);
}
.more-page .mc-water-panel-summary {
display: block;
font-size: 26rpx;
line-height: 1.5;
color: var(--on-surface-variant);
margin-bottom: 24rpx;
}
.more-page .mc-water-panel-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 18rpx 16rpx;
border-radius: 20rpx;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.08);
}
.more-page .mc-water-panel-row.is-water {
border-color: rgba(0, 108, 73, 0.22);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-water-panel-row.is-claimed {
opacity: 0.72;
}
.more-page .mc-water-panel-row-main {
flex: 1;
min-width: 0;
}
.more-page .mc-water-panel-row-name {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.more-page .mc-water-panel-row-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-water-panel-row-tag {
flex-shrink: 0;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
color: #b45309;
}
.more-page .mc-water-panel-growth {
padding: 16rpx 18rpx;
border-radius: 16rpx;
background: rgba(0, 108, 73, 0.06);
margin-bottom: 20rpx;
font-size: 24rpx;
line-height: 1.45;
color: var(--primary);
}
.more-page .mc-water-panel-actions {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.more-page .mc-water-panel-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
background: var(--on-surface);
color: var(--surface);
border: none;
}
.more-page .mc-water-panel-btn.primary {
background: var(--primary);
color: var(--on-primary);
}
.more-page .mc-water-panel-btn.ghost {
background: var(--surface-container-low);
color: var(--on-surface);
}
.more-page .mc-species-panel-tip {
display: block;
font-size: 22rpx;
line-height: 1.45;
color: var(--outline);
margin-bottom: 20rpx;
}
.more-page .mc-species-scroll {
max-height: 56vh;
}
.more-page .mc-species-card {
padding: 20rpx;
border-radius: 24rpx;
border: 2rpx solid rgba(0, 108, 73, 0.1);
background: var(--surface-container-low);
margin-bottom: 16rpx;
}
.more-page .mc-species-card.active {
border-color: var(--primary);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-species-card-head {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.more-page .mc-species-card-emoji {
font-size: 56rpx;
line-height: 1;
}
.more-page .mc-species-card-name {
display: block;
font-size: 30rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-species-card-tagline {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-species-stages {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.more-page .mc-species-stage {
width: calc(20% - 8rpx);
min-width: 96rpx;
padding: 10rpx 6rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.7);
text-align: center;
opacity: 0.45;
}
.more-page .mc-species-stage.reached {
opacity: 1;
}
.more-page .mc-species-stage.current {
box-shadow: 0 0 0 2rpx var(--primary);
background: rgba(0, 108, 73, 0.1);
}
.more-page .mc-species-stage-emoji {
display: block;
font-size: 32rpx;
line-height: 1.1;
}
.more-page .mc-species-stage-lv {
display: block;
font-size: 18rpx;
font-weight: 700;
color: var(--primary);
margin-top: 4rpx;
}
.more-page .mc-species-stage-name {
display: block;
font-size: 18rpx;
color: var(--outline);
margin-top: 2rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+312 -29
View File
@@ -244,11 +244,110 @@
}
.weekly-page .st-chart-head {
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: 12rpx;
gap: 12rpx;
padding: 0 20rpx;
}
.weekly-page .st-chart-head-text {
width: 100%;
}
/* 图例 + AI 胶囊同一行,避免大块图标压过标题 */
.weekly-page .st-chart-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
gap: 16rpx;
width: 100%;
}
.weekly-page .st-chart-ai-chip {
flex-shrink: 0;
min-height: 88rpx;
display: flex;
align-items: center;
}
.weekly-page .st-chart-ai-chip-inner {
display: flex;
align-items: center;
gap: 10rpx;
min-height: 64rpx;
padding: 10rpx 22rpx 10rpx 12rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(236, 253, 245, 0.92) 100%);
border: 1rpx solid rgba(0, 108, 73, 0.14);
box-shadow:
0 2rpx 6rpx rgba(15, 23, 42, 0.04),
inset 0 1rpx 0 rgba(255, 255, 255, 0.9);
transition:
background 0.22s ease,
border-color 0.22s ease,
box-shadow 0.22s ease,
transform 0.15s ease;
}
.weekly-page .st-chart-ai-chip-icon {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: rgba(0, 108, 73, 0.08);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.weekly-page .st-chart-ai-chip-label {
font-size: 26rpx;
font-weight: 700;
color: #0f3d2e;
letter-spacing: 0.02em;
white-space: nowrap;
}
.weekly-page .st-chart-ai-chip.hint .st-chart-ai-chip-inner {
border-color: rgba(0, 108, 73, 0.28);
box-shadow:
0 2rpx 8rpx rgba(0, 108, 73, 0.1),
0 0 0 2rpx rgba(78, 222, 163, 0.22);
}
.weekly-page .st-chart-ai-chip.active .st-chart-ai-chip-inner {
background: linear-gradient(135deg, #1a8f5c 0%, #006c49 100%);
border-color: rgba(0, 108, 73, 0.35);
box-shadow: 0 6rpx 18rpx rgba(0, 108, 73, 0.2);
}
.weekly-page .st-chart-ai-chip.active .st-chart-ai-chip-icon {
background: rgba(255, 255, 255, 0.18);
}
.weekly-page .st-chart-ai-chip.active .st-chart-ai-chip-label {
color: #ffffff;
}
.weekly-page .st-chart-ai-chip.loading .st-chart-ai-chip-inner {
opacity: 0.82;
}
.weekly-page .st-chart-ai-chip:active .st-chart-ai-chip-inner {
transform: scale(0.98);
opacity: 0.92;
}
@media (prefers-reduced-motion: reduce) {
.weekly-page .st-chart-ai-chip-inner {
transition: background 0.01s, border-color 0.01s, opacity 0.01s;
}
.weekly-page .st-chart-ai-chip:active .st-chart-ai-chip-inner {
transform: none;
}
}
.weekly-page .st-chart-title {
@@ -285,7 +384,107 @@
.weekly-page .st-chart-legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--st-inline-gap);
flex: 1;
min-width: 0;
}
/* 趋势卡内展开的 AI 建议 */
.weekly-page .st-chart-care-panel {
margin: 12rpx 20rpx 0;
padding: 28rpx 24rpx 24rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.72);
border: 1rpx solid rgba(221, 228, 221, 0.65);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.95);
}
.weekly-page .st-chart-care-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-bottom: 8rpx;
}
.weekly-page .st-chart-care-title {
font-size: 30rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-chart-care-actions {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.weekly-page .st-chart-care-refresh {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.7);
font-size: 22rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-chart-care-refresh.disabled {
opacity: 0.5;
}
.weekly-page .st-chart-care-close {
min-height: 64rpx;
padding: 10rpx 18rpx;
display: flex;
align-items: center;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.55);
font-size: 22rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-chart-care-close:active {
opacity: 0.75;
}
.weekly-page .st-chart-care-sub {
display: block;
margin-bottom: 16rpx;
font-size: 24rpx;
line-height: 1.4;
color: var(--on-surface-variant);
}
.weekly-page .st-care-badge--in-chart {
margin-bottom: 16rpx;
}
.weekly-page .st-skeleton--in-chart {
padding: 8rpx 0;
}
.weekly-page .st-care-cards--in-chart {
gap: 16rpx;
}
.weekly-page .st-care-cards--in-chart .st-meal-glass {
padding: 24rpx;
}
.weekly-page .st-care-stats--in-chart {
margin-top: 16rpx;
}
.weekly-page .st-care-disclaimer--in-chart {
margin-top: 16rpx;
font-size: 22rpx;
}
.weekly-page .st-legend-item {
@@ -693,6 +892,114 @@
text-align: center;
}
.weekly-page .st-skeleton-line.short {
width: 55%;
}
/* ===== AI 血糖照护(嵌在趋势卡内) ===== */
.weekly-page .st-care-badge {
position: relative;
z-index: 1;
display: inline-flex;
margin-bottom: 24rpx;
padding: 8rpx 20rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.65);
font-size: 24rpx;
font-weight: 600;
color: var(--primary);
}
.weekly-page .st-care-cards {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.weekly-page .st-care-block--tcm {
background: rgba(254, 243, 199, 0.55);
border-color: rgba(217, 119, 6, 0.12);
}
.weekly-page .st-care-block--next {
background: rgba(224, 242, 254, 0.55);
border-color: rgba(14, 165, 233, 0.12);
}
.weekly-page .st-care-watch {
padding: 24rpx 32rpx;
border-radius: var(--st-radius-2xl);
background: rgba(255, 255, 255, 0.75);
border: 1rpx solid rgba(255, 255, 255, 0.9);
}
.weekly-page .st-care-watch-title {
display: block;
margin-bottom: 12rpx;
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.weekly-page .st-care-watch-item {
display: flex;
align-items: flex-start;
gap: 8rpx;
margin-bottom: 8rpx;
}
.weekly-page .st-care-watch-dot {
font-size: 32rpx;
line-height: 1.2;
color: var(--primary);
}
.weekly-page .st-care-watch-text {
flex: 1;
font-size: 28rpx;
line-height: 1.43;
color: var(--on-surface-variant);
}
.weekly-page .st-care-stats {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.weekly-page .st-care-stat-chip {
padding: 12rpx 20rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.55);
}
.weekly-page .st-care-stat-label {
display: block;
font-size: 22rpx;
color: var(--outline);
}
.weekly-page .st-care-stat-val {
font-size: 24rpx;
font-weight: 600;
color: var(--on-surface-variant);
}
.weekly-page .st-care-disclaimer {
position: relative;
z-index: 1;
display: block;
margin-top: 20rpx;
font-size: 24rpx;
line-height: 1.4;
color: var(--outline);
}
/* ===== 底部浮动输入 ===== */
.weekly-page .st-float-ask {
position: fixed;
@@ -740,22 +1047,13 @@
.weekly-page .st-float-inner {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 16rpx 16rpx 28rpx;
gap: 24rpx;
padding: 16rpx 16rpx 16rpx 40rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20rpx);
border: 1rpx solid rgba(0, 108, 73, 0.1);
box-shadow: var(--st-shadow-float);
transition: border-radius 0.22s ease, padding 0.22s ease, border-color 0.22s ease;
}
/* 聚焦/有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
.weekly-page .st-float-inner.is-expanded {
align-items: flex-end;
border-radius: 32rpx;
padding: 18rpx 16rpx 18rpx 32rpx;
border-color: rgba(0, 108, 73, 0.28);
}
.weekly-page .st-float-icon {
@@ -772,30 +1070,15 @@
.weekly-page .st-float-input {
flex: 1;
min-width: 0;
width: 100%;
min-height: 64rpx;
max-height: 240rpx;
height: 64rpx;
font-size: 28rpx;
line-height: 40rpx;
color: var(--on-surface);
background: transparent;
padding: 12rpx 0;
box-sizing: border-box;
transition: min-height 0.2s ease;
}
/* 聚焦时输入框变大;内容超长时由 auto-height 继续向上增高,超过上限可滚动 */
.weekly-page .st-float-inner.is-expanded .st-float-input {
min-height: 104rpx;
}
.weekly-page .st-float-placeholder {
color: rgba(60, 74, 66, 0.6);
font-size: 26rpx;
line-height: 40rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 28rpx;
}
.weekly-page .st-float-mic {
@@ -90,6 +90,49 @@ export function parsePartialDietPlainText(text) {
return out
}
/** 从流式纯文本中提取血糖照护建议(支持未写完的最后一行) */
export function parsePartialBloodCarePlainText(text) {
const out = {}
if (!text) return out
const lineRe = /^(总评|血糖|中医|留意|复诊)[:]\s*(.*)$/gm
let match
while ((match = lineRe.exec(text)) !== null) {
const key = {
'总评': 'summary',
'血糖': 'blood_advice',
'中医': 'tcm_plan',
'留意': 'watch_points',
'复诊': 'next_steps'
}[match[1]]
const val = (match[2] || '').trim()
if (key === 'watch_points') {
out.watch_points = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
} else {
out[key] = val
}
}
const tail = text.match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[:]\s*([^\n]*)$/)
if (tail) {
const key = {
'总评': 'summary',
'血糖': 'blood_advice',
'中医': 'tcm_plan',
'留意': 'watch_points',
'复诊': 'next_steps'
}[tail[1]]
const val = (tail[2] || '').trim()
if (key === 'watch_points') {
if (val) out.watch_points = val.split(/[、,;\s]+/).map((s) => s.trim()).filter(Boolean)
} else {
out[key] = val
}
}
return out
}
/** @deprecated 使用 parsePartialDietPlainText */
export function parsePartialDietJson(text) {
return parsePartialDietPlainText(text)
@@ -1,75 +0,0 @@
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
/** 可选树种:每级 emoji 与 treeLevels.js Lv.09 一一对应 */
export const TREE_SPECIES_LIST = [
{
id: 'classic',
name: '稳糖灵树',
tagline: '经典路线,从种子到圆满',
stages: TREE_LEVELS.map((l) => l.emoji)
},
{
id: 'bonsai',
name: '雅韵盆景',
tagline: '小巧精致,书桌旁的温柔陪伴',
stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']
},
{
id: 'pine',
name: '苍劲青松',
tagline: '挺拔向上,越记越稳',
stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']
},
{
id: 'sakura',
name: '樱花小树',
tagline: '坚持打卡,枝头渐开',
stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']
},
{
id: 'ginkgo',
name: '金秋银杏',
tagline: '叶色渐变,见证每一天',
stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']
}
]
export const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'
/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */
export const DAILY_MAX_TASK_POINTS = 40
export function getTreeSpecies(id) {
return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]
}
export function speciesEmojiAtLevel(speciesId, level) {
const sp = getTreeSpecies(speciesId)
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
return sp.stages[lv] || sp.stages[0] || '🌱'
}
/** 当前树种成长路线图(供选择面板展示) */
export function speciesGrowthRoadmap(speciesId) {
const sp = getTreeSpecies(speciesId)
return TREE_LEVELS.map((meta, i) => {
const xpTotal = i * TREE_XP_PER_LEVEL
const daysHint = i === 0
? 0
: Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))
return {
level: i,
name: meta.name,
emoji: sp.stages[i] || meta.emoji,
desc: meta.desc,
xpTotal,
daysHint
}
})
}
/** 全勤打卡大约多少天满级 */
export function estimateDaysToMaxLevel() {
const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL
return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))
}
-307
View File
@@ -1,307 +0,0 @@
/**
* 健康数据语音文本解析
*
* 把语音识别得到的中文口语文本解析为结构化字段
* - 血糖空腹 / 餐后 / 其他
* - 血压高压 / 低压 / 西药 / 胰岛素
* - 饮食 / / 晚餐 + 备注
* - 运动类型 / 时长 / 强度
*
* 兼容中文数字六点五 / 一百二十 / 一百二与阿拉伯数字6.5 / 120
* 纯前端解析无网络依赖识别失败时返回空对象
*/
const CN_DIGIT = {
: 0, : 0, : 1, : 1, : 1, : 2, : 2, : 2,
: 3, : 3, : 4, : 4, : 5, : 5, : 6, : 6,
: 7, : 7, : 8, : 8, : 9, : 9
}
const CN_UNIT = { : 10, : 10, : 100, : 100, : 1000, : 1000, : 10000, 亿: 100000000 }
/** 中文整数串 → 数字,例如 "一百二十" → 120、"八十" → 80、"十" → 10、"一百二" → 120 */
function cnIntToNumber(s) {
let total = 0
let section = 0
let number = 0
let hadUnit = false
let lastUnit = 0
let sawZeroAfterUnit = false
for (const ch of s) {
if (CN_DIGIT[ch] !== undefined) {
number = CN_DIGIT[ch]
if (number === 0) sawZeroAfterUnit = true
} else if (CN_UNIT[ch] !== undefined) {
hadUnit = true
const unit = CN_UNIT[ch]
lastUnit = unit
sawZeroAfterUnit = false
if (unit >= 10000) {
section = (section + number) * unit
total += section
section = 0
} else {
if (number === 0) number = 1 // 十 = 10
section += number * unit
}
number = 0
}
}
// 没有任何单位且为纯数字串(如 "一二零")时按位拼接更符合口语
if (!hadUnit && s.length > 1) {
let joined = ''
for (const ch of s) {
if (CN_DIGIT[ch] !== undefined) joined += CN_DIGIT[ch]
}
if (joined) return Number(joined)
}
// 口语省略尾部单位:"一百二" → 120、"一千五" → 1500(避开 "一百零五" 这类带零的)
if (number > 0 && lastUnit >= 100 && !sawZeroAfterUnit) {
number = number * (lastUnit / 10)
}
return total + section + number
}
/** 中文数字串(含小数点)→ 数字,例如 "六点五" → 6.5 */
function cnSeqToNumber(seq) {
if (seq.includes('点')) {
const parts = seq.split('点')
const intPart = parts[0]
const decPart = parts.slice(1).join('')
const intVal = intPart ? cnIntToNumber(intPart) : 0
let decStr = ''
for (const ch of decPart) {
if (CN_DIGIT[ch] !== undefined) decStr += CN_DIGIT[ch]
}
if (!decStr) return intVal
return Number(`${intVal}.${decStr}`)
}
return cnIntToNumber(seq)
}
/** 是否包含中文数字字符(用于过滤“点”“重点”等误匹配) */
function hasCnDigit(seq) {
for (const ch of seq) {
if (CN_DIGIT[ch] !== undefined || CN_UNIT[ch] !== undefined) return true
}
return false
}
/** 把文本中的中文数字段落替换为阿拉伯数字 */
export function normalizeNumbers(text) {
const raw = String(text || '')
return raw.replace(/[零〇一壹幺二贰两三叁四肆五伍六陆七柒八捌九玖十拾百佰千仟万亿点]+/g, (m) => {
if (!hasCnDigit(m)) return m
const n = cnSeqToNumber(m)
return Number.isFinite(n) ? String(n) : m
})
}
/** 在文本中找到关键词后紧随的第一个数字 */
function numAfter(text, keys) {
for (const k of keys) {
const i = text.indexOf(k)
if (i >= 0) {
const rest = text.slice(i + k.length)
const m = rest.match(/-?\d+(?:\.\d+)?/)
if (m) return m[0]
}
}
return ''
}
/** 关键词后紧随的一段文本(截止到标点或下一个分隔关键词) */
function textAfter(text, keys, stopKeys = []) {
for (const k of keys) {
const i = text.indexOf(k)
if (i >= 0) {
let rest = text.slice(i + k.length)
// 去掉口语连接词
rest = rest.replace(/^[是为吃了喝了吃的喝的有打了用了::,,、。\s]+/, '')
let end = rest.length
const mStop = rest.match(/[,。.;!?\n]/)
if (mStop && mStop.index < end) end = mStop.index
for (const sk of stopKeys) {
const si = rest.indexOf(sk)
if (si >= 0 && si < end) end = si
}
const seg = rest.slice(0, end).trim()
if (seg) return seg
}
}
return ''
}
/**
* 解析血糖返回 { fasting_blood_sugar, postprandial_blood_sugar, other_blood_sugar }
* "空腹六点五餐后八点二" { fasting:6.5, postprandial:8.2 }
*/
export function parseGlucose(raw) {
const t = normalizeNumbers(raw)
const result = {}
const fasting = numAfter(t, ['空腹'])
const post = numAfter(t, ['餐后', '饭后'])
const other = numAfter(t, ['其他', '随机', '睡前', '凌晨', '夜间', '晚上'])
if (fasting) result.fasting_blood_sugar = fasting
if (post) result.postprandial_blood_sugar = post
if (other) result.other_blood_sugar = other
// 没有关键词但只有一个数字 → 视为“其他血糖”
if (!fasting && !post && !other) {
const nums = t.match(/\d+(?:\.\d+)?/g)
if (nums && nums.length === 1) result.other_blood_sugar = nums[0]
}
return result
}
/**
* 解析血压返回 { systolic_pressure, diastolic_pressure, western_medicine, insulin }
* "高压一百二低压八十" { systolic:120, diastolic:80 }
*/
export function parseBloodPressure(raw) {
const t = normalizeNumbers(raw)
const result = {}
let sys = numAfter(t, ['高压', '收缩压', '收缩'])
let dia = numAfter(t, ['低压', '舒张压', '舒张'])
if (!sys || !dia) {
const nums = (t.match(/\d{2,3}/g) || [])
.map(Number)
.filter((n) => n >= 30 && n <= 300)
if (!sys && !dia && nums.length >= 2) {
sys = String(nums[0])
dia = String(nums[1])
} else if (!sys && nums.length === 1 && nums[0] >= 90) {
sys = String(nums[0])
}
}
// 高压应不低于低压,顺序异常时交换
if (sys && dia && Number(sys) < Number(dia)) {
const tmp = sys
sys = dia
dia = tmp
}
if (sys) result.systolic_pressure = sys
if (dia) result.diastolic_pressure = dia
const insulin = textAfter(raw, ['胰岛素'], ['高压', '低压', '血压', '西药', '备注'])
if (insulin) result.insulin = insulin
const western = textAfter(raw, ['西药', '降糖药', '口服药'], ['胰岛素', '高压', '低压', '血压', '备注'])
if (western) result.western_medicine = western
return result
}
const DIET_MARKERS = [
{ keys: ['早餐', '早饭', '早上', '早点', '早晨'], field: 'breakfast_foods' },
{ keys: ['午餐', '午饭', '中午'], field: 'lunch_foods' },
{ keys: ['晚餐', '晚饭', '晚上', '夜宵', '夜里'], field: 'dinner_foods' }
]
function cleanFoodSeg(seg) {
return String(seg || '')
.replace(/^[是为吃了吃的喝了喝的有::,,、。\s]+/, '')
.replace(/[。.\s]+$/, '')
.trim()
}
/**
* 解析饮食返回 { breakfast_foods, lunch_foods, dinner_foods, note }
* "早餐鸡蛋粥中午米饭炒青菜晚上面条" 分别归位
* 无餐别关键词时整句写入 note
*/
export function parseDiet(raw) {
const t = String(raw || '').trim()
const result = {}
const hits = []
DIET_MARKERS.forEach((m) => {
for (const k of m.keys) {
const idx = t.indexOf(k)
if (idx >= 0) {
hits.push({ idx, field: m.field, klen: k.length })
break
}
}
})
if (!hits.length) {
if (t) result.note = t
return result
}
hits.sort((a, b) => a.idx - b.idx)
hits.forEach((h, i) => {
const start = h.idx + h.klen
const end = i + 1 < hits.length ? hits[i + 1].idx : t.length
const seg = cleanFoodSeg(t.slice(start, end))
if (seg && !result[h.field]) result[h.field] = seg
})
// 第一个餐别关键词之前的内容作为备注
const head = cleanFoodSeg(t.slice(0, hits[0].idx))
if (head) result.note = head
return result
}
const EXERCISE_FILLERS = [
'今天', '我', '做了', '做', '进行了', '进行', '运动了', '运动', '锻炼了', '锻炼',
'了', '大概', '左右', '持续', '差不多', '一共', '总共', '的', '走了', '打了', '跑了'
]
/**
* 解析运动返回 { exercise_type, duration, intensity }
* "散步三十分钟中强度" { type:'散步', duration:30, intensity:2 }
*/
export function parseExercise(raw) {
const t = normalizeNumbers(raw)
const result = {}
const durMatch = t.match(/(\d+(?:\.\d+)?)\s*(个小时|小时|钟头|时|分钟|分)/)
if (durMatch) {
const val = parseFloat(durMatch[1])
const isHour = /个小时|小时|钟头|时/.test(durMatch[2])
result.duration = String(Math.round(isHour ? val * 60 : val))
}
if (/高强度|剧烈|很累|大汗|气喘/.test(t)) result.intensity = 3
else if (/中强度|中等强度|中等|有点累|微微出汗|微汗/.test(t)) result.intensity = 2
else if (/低强度|轻松|轻微|溜达|缓慢/.test(t)) result.intensity = 1
// 运动类型:移除时长、强度、填充词后剩余文本
let type = t
if (durMatch) type = type.replace(durMatch[0], '')
type = type
.replace(/高强度|中强度|中等强度|低强度|剧烈|很累|大汗|气喘|有点累|微微出汗|微汗|轻松|轻微|缓慢/g, '')
.replace(/\d+(?:\.\d+)?/g, '')
.replace(/[,。.;、\s]+/g, '')
EXERCISE_FILLERS.forEach((f) => {
type = type.split(f).join('')
})
type = type.trim()
if (type && type.length <= 20) result.exercise_type = type
return result
}
const FIELD_LABELS = {
fasting_blood_sugar: '空腹',
postprandial_blood_sugar: '餐后',
other_blood_sugar: '其他血糖',
systolic_pressure: '高压',
diastolic_pressure: '低压',
western_medicine: '西药',
insulin: '胰岛素',
breakfast_foods: '早餐',
lunch_foods: '午餐',
dinner_foods: '晚餐',
note: '备注',
exercise_type: '运动',
duration: '时长',
intensity: '强度'
}
const INTENSITY_LABELS = { 1: '低强度', 2: '中强度', 3: '高强度' }
/** 把解析结果转成可读的反馈文案,例如 "空腹6.5 · 餐后8.2" */
export function summarizeParsed(parsed) {
const parts = []
Object.keys(parsed).forEach((k) => {
const label = FIELD_LABELS[k] || k
let val = parsed[k]
if (k === 'intensity') val = INTENSITY_LABELS[val] || val
parts.push(`${label}${val}`)
})
return parts.join(' · ')
}
+1 -1
View File
@@ -29,7 +29,7 @@
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-width: 2;
stroke: #06b6d4;
stroke: #4073fa;
stroke-linecap: round;
}
-158
View File
@@ -1,158 +0,0 @@
/**
* 将标准双 el-card 列表页迁移为 admin-page 架构组件
* 用法: node scripts/migrate-admin-pages.mjs [--dry-run] [glob...]
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const viewsRoot = path.join(__dirname, '../src/views')
const dryRun = process.argv.includes('--dry-run')
const extraPaths = process.argv.slice(2).filter((a) => !a.startsWith('--'))
const defaultTargets = [
'article/lists/index.vue',
'article/column/index.vue',
'permission/role/index.vue',
'permission/menu/index.vue',
'dev_tools/code/index.vue',
'setting/dict/type/index.vue',
'setting/dict/data/index.vue',
'message/notice/index.vue',
'message/short_letter/index.vue',
'fans/index.vue',
'order/index.vue',
'asset/user/index.vue',
'asset/resource/index.vue',
'finance/balance_details.vue',
'finance/recharge_record.vue',
'finance/refund_record.vue'
]
function migrate(content) {
if (content.includes('admin-page-filter-panel')) return null
if (!content.includes('el-card class="!border-none"')) return null
if (!content.includes('<el-table')) return null
let out = content
// 根容器 class 清理
out = out.replace(
/<div class="[^"]*">\s*\n\s*<el-card class="!border-none" shadow="never">/,
'<div>\n <admin-page-filter-panel>'
)
if (!out.includes('admin-page-filter-panel')) {
out = out.replace(
/<el-card class="!border-none" shadow="never">/,
'<admin-page-filter-panel>'
)
}
// 第一个 filter card 结束
out = out.replace(
/<\/el-form>\s*\n\s*<\/el-card>/,
'</el-form>\n </admin-page-filter-panel>'
)
// 第二个 data card 开始 - 提取 v-loading
const loadingMatch = out.match(
/<el-card([^>]*?)class="[^"]*mt-4[^"]*"[^>]*shadow="never"[^>]*>/
)
const altLoadingMatch = out.match(
/<el-card v-loading="([^"]+)"([^>]*)class="[^"]*mt-4[^"]*"([^>]*)shadow="never"([^>]*)>/
)
if (altLoadingMatch) {
out = out.replace(
altLoadingMatch[0],
`<admin-page-data-panel v-loading="${altLoadingMatch[1]}">`
)
} else if (loadingMatch) {
out = out.replace(loadingMatch[0], '<admin-page-data-panel>')
} else {
out = out.replace(
/<el-card class="!border-none mt-4" shadow="never">/,
'<admin-page-data-panel>'
)
out = out.replace(
/<el-card v-loading="([^"]+)" class="mt-4 !border-none" shadow="never">/,
'<admin-page-data-panel v-loading="$1">'
)
out = out.replace(
/<el-card v-loading="([^"]+)" class="!border-none mt-4" shadow="never">/,
'<admin-page-data-panel v-loading="$1">'
)
}
// toolbar: 紧跟 data panel 后的首个 div 包裹按钮
out = out.replace(
/(<admin-page-data-panel[^>]*>\s*)<div>\s*\n(\s*<el-button[\s\S]*?<\/div>\s*\n)/,
'$1<template #toolbar>\n$2</template>\n'
)
out = out.replace(
/(<admin-page-data-panel[^>]*>\s*)<div>\s*\n(\s*<router-link[\s\S]*?<\/div>\s*\n)/,
'$1<template #toolbar>\n$2</template>\n'
)
// 移除 table 前的 mt-4 class
out = out.replace(/<el-table class="mt-4"/g, '<el-table')
out = out.replace(/<el-table\s+class="mt-4"\s+/g, '<el-table ')
// pagination footer
out = out.replace(
/<div class="flex(?: mt-4)? justify-end(?: mt-4)?">\s*\n\s*<pagination([^/]*)\/>\s*\n\s*<\/div>\s*\n\s*<\/el-card>/,
'<template #footer>\n <pagination$1/>\n </template>\n </admin-page-data-panel>'
)
out = out.replace(
/<div class="flex mt-4 justify-end">\s*\n\s*<pagination([^/]*)\/>\s*\n\s*<\/div>\s*\n\s*<\/el-card>/,
'<template #footer>\n <pagination$1/>\n </template>\n </admin-page-data-panel>'
)
// 无 pagination 的 data card 闭合
if (out.includes('<admin-page-data-panel') && out.includes('</el-card>')) {
out = out.replace(/<\/el-table>\s*\n\s*<\/el-card>/, '</el-table>\n </admin-page-data-panel>')
}
// table 上的 v-loading 移到 panel(若 panel 还没有)
out = out.replace(
/<admin-page-data-panel>\s*\n(\s*)<el-table([^>]*?)v-loading="([^"]+)"([^>]*)>/,
'<admin-page-data-panel v-loading="$3">\n$1<el-table$2$4>'
)
out = out.replace(/<el-table([^>]*?)v-loading="([^"]+)"([^>]*)>/, '<el-table$1$3>')
if (out === content || !out.includes('admin-page-data-panel')) return null
return out
}
const targets = extraPaths.length
? extraPaths.map((p) => path.resolve(p))
: defaultTargets.map((p) => path.join(viewsRoot, p))
let migrated = 0
let skipped = 0
for (const file of targets) {
if (!fs.existsSync(file)) {
console.warn('skip (missing):', path.relative(viewsRoot, file))
skipped++
continue
}
const original = fs.readFileSync(file, 'utf8')
const result = migrate(original)
if (!result) {
console.log('skip (no match):', path.relative(viewsRoot, file))
skipped++
continue
}
if (dryRun) {
console.log('would migrate:', path.relative(viewsRoot, file))
} else {
fs.writeFileSync(file, result, 'utf8')
console.log('migrated:', path.relative(viewsRoot, file))
}
migrated++
}
console.log(`\nDone. migrated=${migrated} skipped=${skipped}${dryRun ? ' (dry-run)' : ''}`)
-46
View File
@@ -96,21 +96,6 @@ export function yejiStatsRevisitBreakdown(params: {
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
}
/** 被指派数明细:与看板「被指派数」同口径;部门行传 dept_id,医助排行榜传 assistant_id */
export function yejiStatsAssignLines(params: {
start_date: string
end_date: string
dept_id?: number
assistant_id?: number
dept_ids?: string
channel_code?: string
tag_id?: string
page?: number
page_size?: number
}) {
return request.get({ url: '/stats.yejiStats/assignLines', params })
}
export function doctorDailyStatsOverview(params: {
start_date?: string
end_date?: string
@@ -196,37 +181,6 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
}
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
return request.get({ url: '/stats.revisitRate/overview', params })
}
/** 复诊接诊率:部门下拉(前端组树) */
export function revisitRateDeptOptions() {
return request.get({ url: '/stats.revisitRate/deptOptions' })
}
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
export function revisitRateAssignLines(params: {
month?: string
dept_ids?: string
assistant_id?: number
dept_id?: number
}) {
return request.get({ url: '/stats.revisitRate/assignLines', params })
}
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
export function revisitRateVisitOrderLines(params: {
month?: string
slot: number
dept_ids?: string
assistant_id?: number
dept_id?: number
}) {
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
}
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
+3 -15
View File
@@ -403,11 +403,6 @@ export function prescriptionOrderLogisticsTrace(params: {
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
}
/** 直接调用京东官方物流接口刷新轨迹并落库(绕过快递100缓存) */
export function prescriptionOrderLogisticsJdUpdate(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/logisticsJdUpdate', params })
}
export function prescriptionOrderDetail(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionOrder/detail', params })
}
@@ -446,11 +441,6 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
}
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
}
/** 撤回支付单审核 */
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
@@ -473,8 +463,6 @@ export function prescriptionOrderAddPayOrder(params: {
pay_amount: number
pay_remark?: string
completion_request?: number
/** 创建方式:fubei 付呗(默认) / express_cod 快递代收 */
pay_create_type?: 'fubei' | 'express_cod'
}) {
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
}
@@ -494,13 +482,13 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
}
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款 */
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态,不含退款) */
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
}
/** 业务订单退款(须填写原因;refund_amount 可选,不传则退满可退上限 */
export function prescriptionOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
/** 业务订单退款(须填写原因;关联收款单标记已退款、已付总额清零 */
export function prescriptionOrderRefund(params: { id: number; reason: string }) {
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
}
@@ -1,24 +0,0 @@
<template>
<section class="admin-data-panel" v-loading="loading">
<div v-if="$slots.toolbar" class="admin-data-panel__toolbar">
<slot name="toolbar" />
</div>
<div class="admin-data-panel__body">
<slot />
</div>
<div v-if="$slots.footer" class="admin-data-panel__footer">
<slot name="footer" />
</div>
</section>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
loading?: boolean
}>(),
{
loading: false
}
)
</script>
@@ -1,60 +0,0 @@
<template>
<section class="admin-filter-panel" :class="{ 'is-collapsed': collapsed }">
<div v-if="collapsible" class="admin-filter-panel__head">
<span class="admin-filter-panel__label">{{ title }}</span>
<el-button class="admin-filter-panel__toggle" link type="primary" @click="collapsed = !collapsed">
{{ collapsed ? '展开筛选' : '收起筛选' }}
</el-button>
</div>
<div class="admin-filter-panel__body">
<slot />
</div>
</section>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
/** 是否显示收起/展开 */
collapsible?: boolean
/** 筛选区标题 */
title?: string
}>(),
{
collapsible: true,
title: '筛选条件'
}
)
const collapsed = ref(false)
</script>
@@ -1,24 +0,0 @@
<template>
<section class="admin-form-panel">
<header v-if="title || $slots.header" class="admin-form-panel__header">
<slot name="header">
<h2 v-if="title" class="admin-form-panel__title">{{ title }}</h2>
</slot>
</header>
<div class="admin-form-panel__body">
<slot />
</div>
<footer v-if="$slots.footer" class="admin-form-panel__footer">
<slot name="footer" />
</footer>
</section>
</template>
<script setup lang="ts">
defineProps({
title: {
type: String,
default: ''
}
})
</script>
@@ -1,5 +0,0 @@
<template>
<div class="admin-page-actions">
<slot />
</div>
</template>
@@ -1,16 +0,0 @@
<template>
<div class="admin-stat-grid" :class="[`admin-stat-grid--cols-${columns}`]">
<slot />
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
columns?: 2 | 3 | 4 | 5
}>(),
{
columns: 4
}
)
</script>
@@ -1,27 +0,0 @@
<template>
<article class="admin-stat-item" :class="[`admin-stat-item--${tone}`]">
<div class="admin-stat-item__label">{{ label }}</div>
<div class="admin-stat-item__value" :class="{ 'is-money': money }">
<slot>{{ value }}</slot>
</div>
<p v-if="hint" class="admin-stat-item__hint">{{ hint }}</p>
</article>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
value?: string | number
hint?: string
money?: boolean
tone?: 'default' | 'primary' | 'success' | 'warning'
}>(),
{
value: '',
hint: '',
money: false,
tone: 'default'
}
)
</script>
@@ -7,7 +7,6 @@
:end-placeholder="endPlaceholder"
:value-format="valueFormat"
clearable
@change="emit('change', $event)"
></el-date-picker>
</template>
@@ -30,7 +29,7 @@ const props = withDefaults(
endPlaceholder: '结束时间'
}
)
const emit = defineEmits(['update:startTime', 'update:endTime', 'change'])
const emit = defineEmits(['update:startTime', 'update:endTime'])
const content = computed<any>({
get: () => {
+3 -7
View File
@@ -17,18 +17,14 @@ defineProps({
<style scoped lang="scss">
.footer-btns {
height: 64px;
height: 60px;
&__content {
bottom: 0;
height: 64px;
height: 60px;
right: 0;
left: 0;
z-index: 99;
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;
@apply flex justify-center items-center shadow bg-body;
}
}
</style>
@@ -1,96 +0,0 @@
<template>
<template v-if="variant === 'split'">
<el-form-item :label="label" :class="{ 'is-compact': compact }">
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="timeType === 'custom'">
<el-date-picker
v-model="dateRangeModel"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:size="compact ? 'small' : 'default'"
:style="{ width: pickerWidth }"
@change="emit('custom-change')"
/>
</el-form-item>
</template>
<el-form-item v-else :label="label" :class="{ 'is-compact': compact }">
<div class="list-time-filter-inline">
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="week">最近7天</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="timeType === 'custom'"
v-model="dateRangeModel"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
:size="compact ? 'small' : 'default'"
:style="{ width: pickerWidth }"
@change="emit('custom-change')"
/>
</div>
</el-form-item>
</template>
<script lang="ts" setup>
import type { ListTimeType } from '@/hooks/useListTimeFilter'
withDefaults(
defineProps<{
label?: string
/** split:与自录统计一致,两个 form-item;inline:处方订单筛选用单行 */
variant?: 'split' | 'inline'
pickerWidth?: string
/** 紧凑模式:small 尺寸 radio / 日期 */
compact?: boolean
/** 是否显示「全部」(不限时间)选项 */
showAll?: boolean
}>(),
{
label: '时间范围',
variant: 'split',
pickerWidth: '260px',
compact: false,
showAll: false,
}
)
const timeType = defineModel<ListTimeType>('timeType', { required: true })
const dateRangeModel = defineModel<string[]>('dateRange', { default: () => [] })
const emit = defineEmits<{
'time-type-change': []
'custom-change': []
}>()
</script>
<style scoped lang="scss">
.list-time-filter-inline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
}
:deep(.is-compact.el-form-item) {
margin-bottom: 0;
}
</style>
-228
View File
@@ -1,228 +0,0 @@
<template>
<div class="page-stage admin-page" :class="{ 'page-stage--flat': hideChrome }">
<header v-if="showHeader" class="page-stage__hero">
<div class="page-stage__hero-glow" aria-hidden="true"></div>
<div class="page-stage__hero-inner">
<div class="page-stage__intro">
<h1 class="page-stage__title">{{ pageTitle }}</h1>
<p v-if="pageDesc" class="page-stage__desc">{{ pageDesc }}</p>
</div>
<div v-if="$slots.actions" class="page-stage__actions">
<slot name="actions" />
</div>
</div>
</header>
<div class="page-stage__body">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { PageEnum } from '@/enums/pageEnum'
const route = useRoute()
const hideChrome = computed(() => {
if (route.meta?.hidePageHeader) return true
if (route.path === PageEnum.INDEX || route.path === `${PageEnum.INDEX}/`) return true
const title = (route.meta?.title as string) || ''
return title.includes('工作台')
})
const pageTitle = computed(() => {
const matched = route.matched.filter((item) => item.meta?.title)
const last = matched[matched.length - 1]
return (last?.meta?.title as string) || ''
})
const pageDesc = computed(() => (route.meta?.pageDesc as string) || '')
const showHeader = computed(() => !hideChrome.value && Boolean(pageTitle.value))
</script>
<style lang="scss" scoped>
.page-stage {
min-height: 100%;
}
.page-stage__hero {
position: relative;
margin-bottom: 20px;
padding: 22px 24px;
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-glass);
backdrop-filter: blur(16px) saturate(140%);
box-shadow: var(--el-box-shadow-light);
overflow: hidden;
}
.page-stage__hero-glow {
position: absolute;
inset: 0;
background:
linear-gradient(120deg, rgba(6, 182, 212, 0.12) 0%, transparent 42%),
linear-gradient(300deg, rgba(16, 185, 129, 0.1) 0%, transparent 38%);
pointer-events: none;
}
.page-stage__hero-inner {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 20px;
}
.page-stage__title {
margin: 0;
font-size: 24px;
font-weight: 800;
line-height: 1.2;
letter-spacing: -0.03em;
color: var(--el-text-color-primary);
background: var(--admin-brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.page-stage__desc {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.5;
color: var(--el-text-color-secondary);
max-width: 62ch;
}
.page-stage__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.page-stage__body {
min-width: 0;
}
.page-stage--flat .page-stage__hero {
display: none;
}
</style>
+21 -84
View File
@@ -88,17 +88,13 @@
<strong>{{ formData.herbs.length }}</strong> 主方 {{ mainHerbCount }} / 辅方
{{ auxHerbCount }}· 可手动添加从处方库或粘贴药方批量录入
</p>
<p v-if="herbsLocked" class="rp-toolbar__lock-tip">
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
禁用修改处方库模板药材已锁定不可增删改如需调整请重新从处方库导入覆盖
</p>
</div>
</div>
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
<el-button type="primary" size="small" :icon="CirclePlus" :disabled="herbsLocked" @click="handleAddHerb('主方')">
<el-button type="primary" size="small" :icon="CirclePlus" @click="handleAddHerb('主方')">
添加主方
</el-button>
<el-button type="primary" plain size="small" :disabled="herbsLocked" @click="handleAddHerb('辅方')">
<el-button type="primary" plain size="small" @click="handleAddHerb('辅方')">
添加辅方
</el-button>
<el-button
@@ -139,7 +135,7 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -152,12 +148,11 @@
:min="0.1"
:step="1"
:precision="1"
:disabled="herbsLocked"
placeholder="剂量(克)"
class="herb-editor-card__dose"
/>
<span class="text-gray-500 shrink-0"></span>
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
<el-button type="danger" size="small" link @click="handleRemoveHerb(row.index)">
删除
</el-button>
</div>
@@ -176,7 +171,7 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">辅方</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
<div
v-if="isDuplicateHerbName(row.index)"
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
@@ -189,12 +184,11 @@
:min="0.1"
:step="1"
:precision="1"
:disabled="herbsLocked"
placeholder="剂量(克)"
class="herb-editor-card__dose"
/>
<span class="text-gray-500 shrink-0"></span>
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
<el-button type="danger" size="small" link @click="handleRemoveHerb(row.index)">
删除
</el-button>
</div>
@@ -559,11 +553,7 @@
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
{{
prescriptionTabType === 'user'
? '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
: '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
}}
服药前请核对姓名电话医生等信息以及服法医嘱等要点
</span>
<span class="rx-notice-meta">
<span>日期{{ rxDateText }}</span>
@@ -682,30 +672,22 @@
</div>
<div class="rx-text">
<template v-if="prescriptionTabType === 'internal'">
<p v-if="savedAuxUsageText">主方服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="savedAuxUsageText">辅方服法{{ savedAuxUsageText }}</p>
</template>
<p v-if="prescriptionTabType === 'internal' || savedAuxUsageText">主方服法{{ rxUsageText }}</p>
<p v-else>服法{{ rxUsageText }}</p>
<p v-if="savedAuxUsageText">{{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}{{ savedAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
忌口{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('、') : savedPrescription.dietary_taboo }}
</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">药房备注{{ rxPharmacyRemarkText }}</p>
<p
v-if="prescriptionTabType === 'internal' && rxOutPelletText && savedPrescription.prescription_type !== '饮片'"
class="rx-text-warn"
>
<p v-if="rxOutPelletText && savedPrescription.prescription_type !== '饮片'" class="rx-text-warn">
出丸{{ rxOutPelletText }}
</p>
</div>
<div class="rx-bottom">
<div
class="rx-bot-row rx-bot-row-1"
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
>
<div class="rx-bot-row rx-bot-row-1">
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
<div class="rx-bot-label">医师</div>
<div class="rx-bot-doctor-body">
@@ -715,12 +697,10 @@
alt="医师签名"
class="rx-bot-sign-img"
/>
<div v-if="!savedPrescription.doctor_signature" class="rx-bot-doctor-name">
{{ savedPrescription.doctor_name || '—' }}
</div>
<div class="rx-bot-doctor-name">{{ savedPrescription.doctor_name || '—' }}</div>
</div>
</div>
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">类型:</span>
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
</div>
@@ -728,7 +708,7 @@
<span class="rx-bot-meta-key">天数:</span>
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
</div>
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}</span>
</div>
@@ -1048,8 +1028,6 @@ interface Herb {
name: string
dosage: number
formula_type?: FormulaType
/** 来自「禁用修改」处方库模板:锁定后不可增删改,只能再次导入覆盖 */
locked?: boolean
}
type AuxUsageForm = {
@@ -1066,15 +1044,11 @@ function normalizeFormulaType(v: unknown): FormulaType {
}
function normalizeHerbRow(raw: any): Herb {
const row: Herb = {
return {
name: String(raw?.name ?? '').trim(),
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
return row
}
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
@@ -1456,12 +1430,6 @@ const auxHerbRows = computed(() =>
const mainHerbCount = computed(() => mainHerbRows.value.length)
const auxHerbCount = computed(() => auxHerbRows.value.length)
/**
* 整张处方是否被锁定只要任一药材来自禁用修改模板即锁定
* 锁定后不可增删改任意药材仅可再次从处方库导入覆盖
*/
const herbsLocked = computed(() => formData.herbs.some((h) => (h as Herb).locked === true))
const libraryDoctorId = computed(() => {
const uid = Number(userStore.userInfo?.id)
return uid > 0 ? uid : 0
@@ -2061,18 +2029,10 @@ function findDuplicateHerbNames(): string[] {
}
const handleAddHerb = (formulaType: FormulaType = '主方') => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
return
}
formData.herbs.push({ name: '', dosage: 6, formula_type: formulaType })
}
const handleRemoveHerb = (index: number) => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
return
}
formData.herbs.splice(index, 1)
}
@@ -2129,18 +2089,10 @@ const handleImportLibrary = (row: any) => {
}
const formulaType = normalizeFormulaType(row.formula_type)
//
const isLockedTemplate = Number(row?.disable_edit) === 1
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
const herb = normalizeHerbRow(h)
herb.formula_type = formulaType
if (isLockedTemplate) {
herb.locked = true
} else {
delete herb.locked
}
return herb
})
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
...normalizeHerbRow(h),
formula_type: formulaType
}))
if (libraryImportMode.value === 'replace') {
const kept = formData.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
formData.herbs = [...kept, ...imported]
@@ -2580,17 +2532,6 @@ defineExpose({
color: var(--el-text-color-regular);
}
.rp-toolbar__lock-tip {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin: 6px 0 0;
font-size: 12px;
line-height: 1.55;
color: var(--el-color-danger);
}
.rp-toolbar__actions {
display: flex;
flex-wrap: wrap;
@@ -2984,10 +2925,6 @@ defineExpose({
min-height: 70px;
}
.rx-bot-row-1--user {
grid-template-columns: 1.4fr 0.6fr;
}
.rx-hospital-row {
display: flex;
flex-direction: column;
+20 -20
View File
@@ -1,28 +1,28 @@
const defaultSetting = {
showCrumb: false,
showLogo: true,
isUniqueOpened: true,
sideWidth: 248,
sideTheme: 'dark',
sideDarkColor: '#060d18',
openMultipleTabs: true,
theme: '#06b6d4',
successTheme: '#10b981',
warningTheme: '#f59e0b',
dangerTheme: '#ef4444',
errorTheme: '#ef4444',
infoTheme: '#6366f1'
showCrumb: true, // 是否显示面包屑
showLogo: false, // 是否显示logo
isUniqueOpened: true, //只展开一个一级菜单
sideWidth: 183, //侧边栏宽度
sideTheme: 'dark', //侧边栏主题
sideDarkColor: '#1d2124', //侧边栏深色主题颜色
openMultipleTabs: true, // 是否开启多标签tab栏
theme: '#4A5DFF', //主题色
successTheme: '#67c23a', //成功主题色
warningTheme: '#e6a23c', //警告主题色
dangerTheme: '#f56c6c', //危险主题色
errorTheme: '#f56c6c', //错误主题色
infoTheme: '#909399' //信息主题色
}
export const SETTING_SCHEMA_VERSION = 6
/** 本地 setting 缓存结构版本。提升后仅对低于该版本的老缓存执行 SETTING_SCHEMA_MIGRATIONS */
export const SETTING_SCHEMA_VERSION = 1
/**
* defaultSetting localStorage config
* SETTING_SCHEMA_VERSION +1
*/
export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = {
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']
1: ['sideTheme', 'sideDarkColor']
}
export default defaultSetting
-88
View File
@@ -1,88 +0,0 @@
export type ListTimeType = 'all' | 'today' | 'yesterday' | 'week' | 'month' | 'custom'
export function formatYmd(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/** 列表时间筛选:month 为本自然月 1 日~月末 */
export function resolveListTimeRange(timeType: ListTimeType, dateRange: string[]): [string, string] {
const today = formatYmd(new Date())
switch (timeType) {
case 'all':
return ['', '']
case 'yesterday': {
const d = new Date()
d.setDate(d.getDate() - 1)
const y = formatYmd(d)
return [y, y]
}
case 'week': {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 6)
return [formatYmd(start), formatYmd(end)]
}
case 'month': {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1)
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
return [formatYmd(start), formatYmd(end)]
}
case 'custom': {
let start = dateRange[0] || today
let end = dateRange[1] || today
if (start > end) {
;[start, end] = [end, start]
}
return [start, end]
}
case 'today':
default:
return [today, today]
}
}
export function useListTimeFilter(defaultType: ListTimeType = 'today') {
const time_type = ref<ListTimeType>(defaultType)
const dateRange = ref<string[]>([])
const resolve = () => resolveListTimeRange(time_type.value, dateRange.value)
const handleTimeTypeChange = (onQuery: () => void) => {
if (time_type.value !== 'custom') {
dateRange.value = []
onQuery()
}
}
const handleCustomDateChange = (onQuery: () => void) => {
if (dateRange.value.length === 2) {
onQuery()
}
}
const resetTimeFilter = () => {
time_type.value = defaultType
dateRange.value = []
}
const applyCustomRange = (start: string, end: string) => {
if (!start || !end) return
time_type.value = 'custom'
dateRange.value = [start, end]
}
return {
time_type,
dateRange,
resolve,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
applyCustomRange,
}
}
@@ -1,5 +1,5 @@
<template>
<el-breadcrumb class="app-breadcrumb" separator="/">
<el-breadcrumb class="app-breadcrumb">
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
{{ item.meta.title }}
</el-breadcrumb-item>
@@ -21,24 +21,22 @@ useWatchRoute((route) => {
})
</script>
<style scoped lang="scss">
.app-breadcrumb {
:deep(.el-breadcrumb__item) {
.el-breadcrumb__inner {
color: var(--el-text-color-secondary);
color: #303133;
font-weight: 500;
font-size: var(--el-font-size-small);
}
&:last-child .el-breadcrumb__inner {
color: var(--el-text-color-primary);
font-weight: 600;
color: #303133;
}
}
:deep(.el-breadcrumb__separator) {
color: var(--el-text-color-placeholder);
font-weight: 400;
color: #606266;
}
}
</style>
@@ -1,7 +1,7 @@
<template>
<header class="header">
<div class="navbar">
<div class="flex-1 flex items-center gap-1 min-w-0">
<div class="flex-1 flex">
<div class="navbar-item">
<el-tooltip
class="box-item"
@@ -17,14 +17,11 @@
<refresh />
</el-tooltip>
</div>
<div
class="hidden md:flex items-center min-w-0 px-2"
v-if="settingStore.showCrumb && breadcrumbs.length"
>
<div class="flex items-center px-2" v-if="!isMobile && settingStore.showCrumb">
<breadcrumb />
</div>
</div>
<div class="flex items-center gap-1">
<div class="flex">
<div class="navbar-item" v-if="!isMobile">
<el-tooltip
class="box-item"
@@ -39,7 +36,12 @@
<user-drop-down />
</div>
<div class="navbar-item">
<el-tooltip class="box-item" effect="dark" content="主题设置" placement="bottom">
<el-tooltip
class="box-item"
effect="dark"
content="主题设置"
placement="bottom"
>
<setting />
</el-tooltip>
</div>
@@ -50,10 +52,8 @@
</template>
<script setup lang="ts">
import type { RouteLocationMatched } from 'vue-router'
import { useFullscreen } from '@vueuse/core'
import { useWatchRoute } from '@/hooks/useWatchRoute'
import useAppStore from '@/stores/modules/app'
import useSettingStore from '@/stores/modules/setting'
@@ -70,20 +70,14 @@ const isMobile = computed(() => appStore.isMobile)
const isCollapsed = computed(() => appStore.isCollapsed)
const settingStore = useSettingStore()
const { isFullscreen } = useFullscreen()
const breadcrumbs = ref<RouteLocationMatched[]>([])
useWatchRoute((route) => {
breadcrumbs.value = route.matched.filter((item) => item.meta && item.meta.title)
})
</script>
<style lang="scss">
.navbar {
height: var(--navbar-height);
@apply flex px-3 bg-body;
@apply flex px-2 bg-body;
.navbar-item {
@apply h-full flex justify-center items-center;
@apply h-full flex justify-center items-center hover:bg-page;
}
}
</style>
@@ -1,6 +1,6 @@
<template>
<div class="app-tabs flex bg-body">
<div class="flex-1 min-w-0 pl-3">
<div class="app-tabs pl-4 flex bg-body">
<div class="flex-1 min-w-0">
<el-tabs
:model-value="currentTab"
:closable="tabsLists.length > 1"
@@ -13,7 +13,7 @@
</el-tabs>
</div>
<el-dropdown @command="handleCommand">
<span class="tabs-more-btn flex items-center px-3">
<span class="flex items-center px-3">
<icon :size="16" name="el-icon-arrow-down" />
</span>
<template #dropdown>
@@ -60,84 +60,61 @@ const handleCommand = (command: any) => {
</script>
<style lang="scss" scoped>
.app-tabs {
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);
}
}
@apply border-t border-br;
:deep(.el-tabs) {
height: var(--tabs-height);
height: 40px;
.el-tabs {
&__header {
margin-bottom: 0;
}
&__content {
display: none;
}
&__nav-next,
&__nav-prev {
@apply text-lg;
@apply text-xl;
}
&__nav-wrap::after {
height: 0;
}
&__item {
font-weight: 600;
font-size: var(--el-font-size-small);
padding: 0 16px !important;
height: calc(var(--tabs-height) - 8px);
margin-top: 4px;
border-radius: var(--admin-radius-md) var(--admin-radius-md) 0 0;
font-weight: normal;
padding: 0 15px !important;
box-sizing: border-box;
color: var(--el-text-color-secondary);
transition: color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
color: var(--admin-brand-primary-dark);
}
&.is-active {
color: var(--admin-brand-primary-dark);
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-bottom-color: transparent;
box-shadow: var(--el-box-shadow-lighter);
&::before,
color: var(--el-text-color-primary);
background-color: var(--el-color-primary-light-9);
&::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background-color: var(--el-color-primary);
margin-right: 6px;
border-radius: 50%;
vertical-align: 2px;
}
&::after {
display: none;
position: absolute;
content: '';
display: block;
top: 0;
height: 2px;
left: 0;
width: 100%;
background-color: var(--el-color-primary);
}
}
.is-icon-close {
color: var(--el-text-color-placeholder);
color: var(--el-text-color-regular);
vertical-align: -2px;
border-radius: var(--admin-radius-sm);
&:hover {
color: var(--color-white);
background-color: var(--el-color-danger);
}
}
}
&__active-bar {
display: none;
}
@@ -1,11 +1,9 @@
<template>
<el-dropdown class="user-dropdown px-1" @command="handleCommand">
<div class="user-trigger flex items-center gap-2.5 px-2 py-1 rounded-md cursor-pointer">
<el-avatar :size="32" :src="userInfo.avatar" />
<span class="user-name max-w-[120px] truncate text-sm font-medium text-tx-primary">{{
userInfo.name
}}</span>
<icon name="el-icon-ArrowDown" :size="14" />
<el-dropdown class="px-2" @command="handleCommand">
<div class="flex items-center">
<el-avatar :size="34" :src="userInfo.avatar" />
<div class="ml-3 mr-1">{{ userInfo.name }}</div>
<icon name="el-icon-ArrowDown" />
</div>
<template #dropdown>
@@ -43,13 +41,3 @@ const handleCommand = async (command: string) => {
}
}
</script>
<style scoped lang="scss">
.user-trigger {
transition: background-color 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
}
}
</style>
+8 -37
View File
@@ -1,56 +1,27 @@
<template>
<main class="main-wrap h-full">
<main class="main-wrap h-full bg-page">
<el-scrollbar>
<div class="main-stage">
<page-shell v-if="isRouteShow">
<router-view v-slot="{ Component, route }">
<keep-alive :include="includeList" :max="20">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</page-shell>
<div class="px-2 py-4">
<router-view v-if="isRouteShow" v-slot="{ Component, route }">
<keep-alive :include="includeList" :max="20">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</el-scrollbar>
</main>
</template>
<script setup lang="ts">
import PageShell from '@/components/page-shell/index.vue'
import useAppStore from '@/stores/modules/app'
import useTabsStore from '@/stores/modules/multipleTabs'
import useSettingStore from '@/stores/modules/setting'
const appStore = useAppStore()
const tabsStore = useTabsStore()
const settingStore = useSettingStore()
const isRouteShow = computed(() => appStore.isRouteShow)
const includeList = computed(() => (settingStore.openMultipleTabs ? tabsStore.getCacheTabList : []))
</script>
<style></style>
@@ -100,7 +100,7 @@ import theme_light from '@/assets/images/theme_white.png'
import useSettingStore from '@/stores/modules/setting'
const settingStore = useSettingStore()
const predefineColors = ref(['#06b6d4', '#10b981', '#0891b2', '#6366f1', '#f59e0b', '#ef4444', '#64748b'])
const predefineColors = ref(['#409EFF', '#28C76F', '#EA5455', '#FF9F43', '#01CFE8', '#4A5DFF'])
const sideThemeList = [
{
type: 'dark',
@@ -68,42 +68,32 @@ const themeClass = computed(() => `theme-${props.theme}`)
.el-menu {
:deep(.el-menu-item) {
&.is-active {
@apply bg-primary;
box-shadow: inset 3px 0 0 var(--el-color-primary-light-3);
@apply bg-primary border-primary;
}
}
}
:deep(.el-menu--collapse) {
.el-sub-menu.is-active .el-sub-menu__title {
@apply bg-primary #{!important};
}
}
}
&.theme-light {
:deep(.el-menu) {
.el-menu-item {
border-color: transparent;
&.is-active {
@apply bg-primary-light-9;
color: var(--el-color-primary);
font-weight: 600;
box-shadow: inset 3px 0 0 var(--el-color-primary);
@apply bg-primary-light-9 border-r-2 border-primary;
}
}
.el-menu-item:hover,
.el-sub-menu__title:hover {
color: var(--el-color-primary);
}
}
}
.el-menu {
border-right: none;
&:not(.el-menu--collapse) {
width: var(--aside-width);
}
@@ -1,15 +1,5 @@
<template>
<div class="side" :style="sideStyle">
<div v-if="showBrandStrip" class="sidebar-brand">
<div class="sidebar-brand__mark">ZY</div>
<overflow-tooltip
class="sidebar-brand__name"
:content="config.web_name"
:teleported="true"
placement="bottom"
overflo-type="unset"
/>
</div>
<side-logo v-if="settingStore.showLogo" :show-title="!isCollapsed" :theme="sideTheme" />
<side-menu
:routes="routes"
@@ -35,19 +25,17 @@ const appStore = useAppStore()
const isCollapsed = computed(() => {
if (appStore.isMobile) {
return false
} else {
return appStore.isCollapsed
}
return appStore.isCollapsed
})
const settingStore = useSettingStore()
const sideTheme = computed(() => settingStore.sideTheme)
const userStore = useUserStore()
const config = computed(() => appStore.config)
const routes = computed(() => userStore.routes)
const showBrandStrip = computed(() => !settingStore.showLogo && !isCollapsed.value)
const sideStyle = computed(() => {
return sideTheme.value == 'dark'
? {
@@ -55,7 +43,6 @@ const sideStyle = computed(() => {
}
: ''
})
const menuProp = computed(() => {
return {
backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : '',
@@ -63,7 +50,6 @@ const menuProp = computed(() => {
activeTextColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : ''
}
})
const handleSelect = () => {
if (appStore.isMobile) {
appStore.toggleCollapsed(true)
@@ -75,8 +61,7 @@ const handleSelect = () => {
.side {
position: relative;
z-index: 999;
@apply h-full flex flex-col;
border-right: 1px solid var(--admin-sidebar-border);
background-color: var(--side-dark-color, var(--sidebar-dark-bg));
@apply border-r border-br-light h-full flex flex-col;
background-color: var(--side-dark-color, var(--el-bg-color));
}
</style>
-298
View File
@@ -1,298 +0,0 @@
/**
* Admin 页面架构 v2 - 大改版面板系统
*/
.admin-page .page-stage__body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ---------- FilterPanel ---------- */
.admin-filter-panel {
position: relative;
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-glass);
backdrop-filter: blur(12px);
box-shadow: var(--el-box-shadow-lighter);
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: 3px;
background: var(--admin-brand-gradient);
}
}
.admin-filter-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 22px 0;
}
.admin-filter-panel__label {
font-size: 14px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.admin-filter-panel__toggle {
padding: 0;
font-size: 13px;
font-weight: 600;
}
.admin-filter-panel__body {
padding: 14px 22px 12px;
}
.admin-filter-panel.is-collapsed .admin-filter-panel__body {
display: none;
}
.admin-filter-panel .el-form--inline {
margin-bottom: 0;
.el-form-item {
margin-bottom: 12px;
margin-right: 18px;
}
.el-form-item__label {
font-weight: 600;
color: var(--el-text-color-regular);
}
}
/* ---------- DataPanel ---------- */
.admin-data-panel {
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-elevated);
box-shadow: var(--el-box-shadow);
overflow: hidden;
}
.admin-data-panel__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 16px 22px;
border-bottom: 1px solid var(--el-border-color-extra-light);
background: var(--admin-brand-gradient-soft);
}
.admin-data-panel__body {
padding: 0 22px 18px;
}
.admin-data-panel__toolbar + .admin-data-panel__body {
padding-top: 16px;
}
.admin-data-panel__footer {
display: flex;
justify-content: flex-end;
padding: 0 22px 18px;
}
/* ---------- FormPanel ---------- */
.admin-form-panel {
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-elevated);
box-shadow: var(--el-box-shadow-light);
overflow: hidden;
}
.admin-form-panel__header {
padding: 16px 22px;
border-bottom: 1px solid var(--el-border-color-extra-light);
background: var(--admin-brand-gradient-soft);
}
.admin-form-panel__title {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.admin-form-panel__body {
padding: 22px;
}
.admin-form-panel__footer {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 22px 22px;
border-top: 1px solid var(--el-border-color-extra-light);
background: var(--el-fill-color-lighter);
}
/* ---------- StatGrid ---------- */
.admin-stat-grid {
display: grid;
gap: 14px;
}
.admin-stat-grid--cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.admin-stat-grid--cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.admin-stat-grid--cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.admin-stat-grid--cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.admin-stat-item {
position: relative;
padding: 18px 20px;
border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-elevated);
box-shadow: var(--el-box-shadow-lighter);
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: var(--el-border-color);
}
&:hover {
transform: translateY(-2px);
box-shadow: var(--el-box-shadow-light);
}
}
.admin-stat-item--primary {
&::before { background: var(--admin-brand-gradient); }
border-color: rgba(6, 182, 212, 0.2);
background: linear-gradient(145deg, rgba(6, 182, 212, 0.08), rgba(16, 185, 129, 0.05));
}
.admin-stat-item--success {
&::before { background: #10b981; }
border-color: rgba(16, 185, 129, 0.22);
}
.admin-stat-item--warning {
&::before { background: #f59e0b; }
border-color: rgba(245, 158, 11, 0.22);
}
.admin-stat-item__label {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.admin-stat-item__value {
margin-top: 10px;
font-size: 30px;
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.03em;
color: var(--el-text-color-primary);
font-variant-numeric: tabular-nums;
}
.admin-stat-item__value.is-money {
font-size: 26px;
}
.admin-stat-item__hint {
margin: 8px 0 0;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
/* ---------- Tables ---------- */
.admin-data-panel .el-table,
.admin-page .el-card .el-table {
--el-table-border-color: transparent;
border-radius: var(--admin-radius-md);
thead th.el-table__cell {
position: sticky;
top: 0;
z-index: 2;
background: var(--table-header-bg-color) !important;
border-bottom: 1px solid var(--el-border-color-lighter);
font-weight: 700;
font-size: var(--el-font-size-small);
}
td.el-table__cell {
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.el-table__row:hover > td.el-table__cell {
background-color: rgba(6, 182, 212, 0.04) !important;
}
.el-table__row.current-row > td.el-table__cell {
background-color: rgba(6, 182, 212, 0.08) !important;
}
}
/* ---------- Legacy cards ---------- */
.admin-page .page-stage__body {
> div > .el-card:first-child:has(.el-form),
> .el-card:first-child:has(.el-form) {
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-lighter);
}
> div > .el-card + .el-card,
> .el-card + .el-card {
border-radius: var(--admin-radius-xl);
box-shadow: var(--el-box-shadow);
}
}
.workbench-page {
margin: 0;
}
@media (max-width: 1200px) {
.admin-stat-grid--cols-4,
.admin-stat-grid--cols-5 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.admin-filter-panel__body,
.admin-data-panel__body,
.admin-data-panel__toolbar,
.admin-data-panel__footer {
padding-left: 16px;
padding-right: 16px;
}
.admin-stat-grid--cols-2,
.admin-stat-grid--cols-3,
.admin-stat-grid--cols-4,
.admin-stat-grid--cols-5 {
grid-template-columns: 1fr;
}
.admin-stat-item:hover {
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-stat-item {
transition: none;
}
}
-190
View File
@@ -1,190 +0,0 @@
/* Admin Shell v2 - 互联网医院工作台大改版 */
.layout-default {
background: var(--sidebar-dark-bg);
}
/* ---------- Sidebar ---------- */
.app-aside .side {
position: relative;
&::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: var(--sidebar-rail-width);
background: var(--admin-brand-gradient);
z-index: 2;
pointer-events: none;
}
&::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 0% 0%, var(--admin-brand-mesh-a), transparent 45%),
radial-gradient(circle at 100% 100%, var(--admin-brand-mesh-b), transparent 40%);
pointer-events: none;
}
}
.sidebar-brand {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
min-height: calc(var(--navbar-height) + 4px);
padding: 0 18px 0 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.sidebar-brand__mark {
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
color: #ffffff;
background: var(--admin-brand-gradient);
box-shadow: var(--admin-brand-glow);
flex-shrink: 0;
}
.sidebar-brand__name {
font-size: 15px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu.theme-dark .el-menu {
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: var(--sidebar-dark-hover);
--el-menu-active-color: #ffffff;
--el-menu-text-color: rgba(255, 255, 255, 0.62);
padding: 12px 10px 16px;
position: relative;
z-index: 1;
}
.menu.theme-dark .el-menu .el-menu-item,
.menu.theme-dark .el-menu .el-sub-menu__title {
margin: 3px 8px;
border-radius: var(--admin-radius-md);
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.12s ease;
}
.menu.theme-dark .el-menu .el-menu-item.is-active {
background: var(--sidebar-dark-active) !important;
color: #ffffff;
font-weight: 600;
box-shadow:
inset 0 0 0 1px rgba(6, 182, 212, 0.28),
0 8px 24px rgba(6, 182, 212, 0.15);
}
.menu.theme-dark .el-menu .el-menu-item:hover,
.menu.theme-dark .el-menu .el-sub-menu__title:hover {
background: var(--sidebar-dark-hover);
color: #ffffff;
}
.menu.theme-light .el-menu .el-menu-item.is-active {
font-weight: 600;
color: var(--admin-brand-primary-dark);
background: var(--admin-brand-gradient-soft);
box-shadow: inset 3px 0 0 var(--admin-brand-primary);
}
/* ---------- Header ---------- */
.app-header {
position: relative;
z-index: 20;
background: var(--admin-surface-glass);
backdrop-filter: blur(20px) saturate(160%);
border-bottom: 1px solid var(--admin-header-border);
&::after {
content: '';
position: absolute;
inset: auto 0 0;
height: 2px;
background: var(--admin-brand-gradient);
opacity: 0.75;
pointer-events: none;
}
}
:root.dark .app-header {
background: rgba(15, 23, 42, 0.88);
}
.navbar-tool {
width: 38px;
height: 38px;
border-radius: var(--admin-radius-md);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-regular);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease, transform 0.12s ease, box-shadow 0.2s ease;
&:hover {
background: var(--admin-brand-gradient-soft);
color: var(--admin-brand-primary-dark);
box-shadow: var(--el-box-shadow-lighter);
}
&:active {
transform: scale(0.96);
}
}
/* ---------- Main stage (去掉双层白盒) ---------- */
.main-wrap {
position: relative;
background:
radial-gradient(circle at 0% 0%, var(--admin-brand-mesh-a), transparent 34%),
radial-gradient(circle at 100% 0%, var(--admin-brand-mesh-c), transparent 30%),
radial-gradient(circle at 50% 100%, var(--admin-brand-mesh-b), transparent 38%),
var(--el-bg-color-page);
}
.main-stage {
max-width: var(--content-max-width);
margin: 0 auto;
padding: var(--stage-padding-y) var(--stage-padding-x);
min-height: calc(100vh - var(--navbar-height) - var(--tabs-height, 0px));
}
@media (max-width: 768px) {
:root {
--stage-padding-x: 14px;
--stage-padding-y: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.navbar-tool,
.menu.theme-dark .el-menu .el-menu-item,
.menu.theme-dark .el-menu .el-sub-menu__title {
transition: none;
}
}
@media (prefers-reduced-transparency: reduce) {
.app-header {
backdrop-filter: none;
background: var(--el-bg-color);
}
}
+31 -46
View File
@@ -1,58 +1,43 @@
:root.dark {
color-scheme: dark;
--table-header-bg-color: rgba(6, 182, 212, 0.12);
--sidebar-dark-bg: #030712;
--sidebar-dark-hover: rgba(255, 255, 255, 0.05);
--sidebar-dark-active: rgba(6, 182, 212, 0.2);
--admin-surface-elevated: #111827;
--admin-surface-muted: rgba(17, 24, 39, 0.88);
--admin-surface-glass: rgba(17, 24, 39, 0.86);
--el-bg-color-page: #030712;
--el-bg-color: #0f172a;
--el-bg-color-overlay: #1e293b;
--el-text-color-primary: #f1f5f9;
--el-text-color-regular: #cbd5e1;
--el-text-color-secondary: #94a3b8;
--el-text-color-placeholder: #64748b;
--el-text-color-disabled: #475569;
--el-border-color-darker: #64748b;
--el-border-color-dark: #475569;
--el-border-color: rgba(6, 182, 212, 0.18);
--el-border-color-light: rgba(6, 182, 212, 0.12);
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
--el-border-color-extra-light: rgba(255, 255, 255, 0.04);
--el-fill-color-darker: #334155;
--el-fill-color-dark: #1e293b;
--el-fill-color: #1e293b;
--el-fill-color-light: #172033;
--el-fill-color-lighter: #131c2e;
--el-fill-color-extra-light: #0f172a;
--table-header-bg-color: var(--el-bg-color);
--el-bg-color-page: #0a0a0a;
--el-bg-color: #1d2124;
--el-bg-color-overlay: #1d1e1f;
--el-text-color-primary: #e5eaf3;
--el-text-color-regular: #cfd3dc;
--el-text-color-secondary: #a3a6ad;
--el-text-color-placeholder: #8d9095;
--el-text-color-disabled: #6c6e72;
--el-border-color-darker: #636466;
--el-border-color-dark: #58585b;
--el-border-color: #4c4d4f;
--el-border-color-light: #414243;
--el-border-color-lighter: #363637;
--el-border-color-extra-light: #2b2b2c;
--el-fill-color-darker: #424243;
--el-fill-color-dark: #39393a;
--el-fill-color: #303030;
--el-fill-color-light: #262727;
--el-fill-color-lighter: #1d1d1d;
--el-fill-color-extra-light: #191919;
--el-fill-color-blank: var(--el-bg-color);
--el-mask-color: rgba(2, 6, 23, 0.78);
--el-mask-color-extra-light: rgba(2, 6, 23, 0.42);
--el-box-shadow: 0 4px 24px rgba(0, 0, 0, 0.28);
--el-box-shadow-light: 0 2px 16px rgba(0, 0, 0, 0.22);
--el-box-shadow-lighter: 0 1px 4px rgba(0, 0, 0, 0.16);
--el-box-shadow-dark: 0 16px 48px rgba(0, 0, 0, 0.36);
--admin-header-border: rgba(6, 182, 212, 0.14);
--admin-sidebar-border: rgba(255, 255, 255, 0.04);
--admin-brand-mesh-a: rgba(6, 182, 212, 0.12);
--admin-brand-mesh-b: rgba(16, 185, 129, 0.08);
--admin-brand-mesh-c: rgba(99, 102, 241, 0.06);
--el-mask-color: rgba(0, 0, 0, 0.8);
--el-mask-color-extra-light: rgba(0, 0, 0, 0.3);
--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.36), 0px 8px 20px rgba(0, 0, 0, 0.72);
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.72);
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.72);
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.72), 0px 12px 32px #000000,
0px 8px 16px -8px #000000 !important;
/* wangeditor主题 */
--w-e-textarea-bg-color: var(--el-bg-color);
--w-e-textarea-color: var(--el-text-color-primary);
--w-e-textarea-border-color: var(--el-border-color);
--w-e-textarea-slight-border-color: var(--el-border-color-light);
--w-e-textarea-slight-color: var(--el-border-color);
--w-e-textarea-slight-bg-color: var(--el-bg-color-page);
/* --w-e-textarea-selected-border-color: #b4d5ff;
--w-e-textarea-handler-bg-color: #4290f7; */
--w-e-toolbar-color: var(--el-text-color-primary);
--w-e-toolbar-bg-color: var(--el-bg-color);
--w-e-toolbar-active-color: var(--el-text-color-primary);
+17 -237
View File
@@ -1,20 +1,14 @@
:root {
/* Messages & notifications */
// 确保消息提示在最上层
.el-message {
z-index: 9999 !important;
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
}
.el-notification {
z-index: 9999 !important;
border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
}
/* Dialog */
// 弹窗居中
.el-overlay-dialog {
display: flex;
justify-content: center;
@@ -25,14 +19,11 @@
.el-dialog {
--el-dialog-content-font-size: var(--el-font-size-base);
--el-dialog-margin-top: 50px;
--el-dialog-border-radius: var(--admin-radius-lg);
max-width: calc(100vw - 32px);
max-width: calc(100vw - 30px);
flex: none;
display: flex;
flex-direction: column;
border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-dark);
border-radius: 5px;
&.body-padding .el-dialog__body {
padding: 0;
@@ -40,158 +31,50 @@
.el-dialog__body {
flex: 1;
padding: 16px 20px 20px;
padding: 15px 20px;
}
.el-dialog__header {
font-size: var(--el-font-size-large);
font-weight: 600;
padding: 16px 20px 12px;
margin-right: 0;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.el-dialog__footer {
padding: 12px 20px 16px;
border-top: 1px solid var(--el-border-color-extra-light);
}
}
}
/* Cards - applies to all list pages */
.el-card {
--el-card-border-radius: var(--admin-radius-lg);
border: 1px solid var(--el-border-color-lighter);
box-shadow: none;
background: var(--admin-surface-elevated);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&.is-always-shadow,
&.is-hover-shadow:hover {
box-shadow: var(--el-box-shadow-light);
}
.el-card__header {
padding: 14px 18px;
border-bottom: 1px solid var(--el-border-color-extra-light);
font-weight: 600;
color: var(--el-text-color-primary);
}
.el-card__body {
padding: 18px;
}
}
/* Menu */
.el-menu {
user-select: none;
}
/* Drawer */
.el-drawer {
--el-drawer-padding-primary: 16px;
&__header {
margin-bottom: 0;
padding: 14px 18px;
padding: 13px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
font-weight: 600;
}
&__title {
@apply text-tx-primary;
}
&__body {
padding: 16px 18px;
}
}
/* Table */
.el-table {
--el-table-header-text-color: var(--el-text-color-primary);
--el-table-header-bg-color: var(--table-header-bg-color);
--el-table-border-color: var(--el-border-color-extra-light);
--el-table-row-hover-bg-color: var(--el-fill-color-lighter);
font-size: var(--el-font-size-base);
border-radius: var(--admin-radius-md);
overflow: hidden;
thead {
th {
font-weight: 600;
font-size: var(--el-font-size-small);
letter-spacing: 0.01em;
font-weight: 400;
}
th.el-table__cell {
background-color: var(--table-header-bg-color);
}
}
.el-table__cell {
padding: 12px 0;
}
}
/* Form controls */
.el-input-group__prepend {
background-color: var(--el-fill-color-light);
border-color: var(--el-border-color);
background-color: var(--el-fill-color-blank);
}
.el-checkbox {
--el-checkbox-font-size: var(--el-font-size-base);
}
.el-button {
--el-border-radius-base: var(--admin-radius-md);
font-weight: 500;
transition: transform 0.12s ease, box-shadow 0.2s ease, background-color 0.2s ease;
&:active:not(.is-disabled) {
transform: scale(0.98);
}
}
.el-button--primary:not(.is-link):not(.is-text):not(.is-plain) {
background: var(--admin-brand-gradient);
border: none;
box-shadow: var(--admin-brand-glow);
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
--el-button-hover-bg-color: transparent;
--el-button-hover-border-color: transparent;
&:hover {
filter: brightness(1.05);
box-shadow: 0 12px 32px rgba(6, 182, 212, 0.32);
}
}
.el-button--primary.is-link,
.el-button--primary.is-text {
--el-button-hover-link-text-color: var(--admin-brand-primary-dark);
}
.el-button--primary.is-plain {
--el-button-bg-color: rgba(6, 182, 212, 0.1);
--el-button-border-color: rgba(6, 182, 212, 0.35);
--el-button-text-color: var(--admin-brand-primary-dark);
--el-button-hover-bg-color: rgba(6, 182, 212, 0.16);
--el-button-hover-border-color: var(--admin-brand-primary);
}
.el-button--large {
--el-border-radius-base: var(--admin-radius-md);
}
.el-button.is-round {
--el-border-radius-base: var(--admin-radius-round);
}
/* Popup menus */
.el-menu--popup-container {
&.theme-light {
.el-menu {
@@ -200,14 +83,12 @@
@apply bg-primary-light-9 border-primary border-r-2;
}
}
.el-menu-item:hover,
.el-sub-menu__title:hover {
color: var(--el-color-primary);
}
}
}
&.theme-dark {
.el-menu {
.el-menu-item {
@@ -220,120 +101,52 @@
}
.el-message-box {
--el-messagebox-width: 380px;
--el-messagebox-border-radius: var(--admin-radius-lg);
--el-messagebox-width: 350px;
}
.el-date-editor {
--el-date-editor-datetimerange-width: 380px;
.el-range-input {
font-size: var(--el-font-size-small);
}
}
.el-button--primary {
--el-button-hover-link-text-color: var(--el-color-primary-light-3);
}
.el-button--success {
--el-button-hover-link-text-color: var(--el-color-success-light-3);
}
.el-button--info {
--el-button-hover-link-text-color: var(--el-color-info-light-3);
}
.el-button--warning {
--el-button-hover-link-text-color: var(--el-color-warning-light-3);
}
.el-button--danger {
--el-button-hover-link-text-color: var(--el-color-danger-light-3);
}
.el-image__error {
font-size: 12px;
}
.el-tabs__nav-wrap::after {
height: 1px;
background: var(--el-border-color-extra-light);
}
.el-tabs__item {
font-weight: 500;
transition: color 0.2s ease;
&.is-active {
color: var(--el-color-primary);
font-weight: 600;
}
&:hover {
color: var(--el-color-primary-light-3);
}
}
.el-tabs__active-bar {
height: 3px;
border-radius: 999px;
background: var(--admin-brand-gradient);
}
.el-page-header {
&__breadcrumb {
margin-bottom: 0;
}
}
/* Tags */
.el-tag {
--el-tag-border-radius: var(--admin-radius-sm);
border: none;
font-weight: 500;
}
/* Pagination */
.el-pagination {
.el-pager li {
border-radius: var(--admin-radius-sm);
font-weight: 500;
}
.btn-prev,
.btn-next {
border-radius: var(--admin-radius-sm);
}
}
/* Alert */
.el-alert {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-extra-light);
}
/* Empty state */
.el-empty {
padding: 32px 0;
}
/* Descriptions */
.el-descriptions {
.el-descriptions__label {
font-weight: 500;
color: var(--el-text-color-secondary);
}
}
/* Focus rings */
.el-input,
.el-select,
.el-textarea {
@apply shadow-primary-light-8;
box-shadow: 0 0 0 0 var(--tw-shadow-color);
&:focus-within {
box-shadow: 0 0 0 2px var(--tw-shadow-color);
border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
transition: box-shadow ease 0.15s;
transition: box-shadow ease 0.1s;
}
}
@@ -343,7 +156,6 @@
border-radius: var(--el-checkbox-border-radius);
box-shadow: 0 0 0 0 var(--tw-shadow-color);
&:active {
box-shadow: 0 0 0 2px var(--tw-shadow-color);
transition: box-shadow ease 0s;
@@ -361,61 +173,29 @@
.el-form-item.is-error .el-checkbox {
@apply shadow-danger-light-8;
}
/* Dropdown */
.el-dropdown-menu {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
padding: 4px;
}
.el-dropdown-menu__item {
border-radius: var(--admin-radius-sm);
}
/* Popover / tooltip polish */
.el-popover.el-popper {
border-radius: var(--admin-radius-md);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
}
}
@media (max-width: 768px) {
.el-pagination > .el-pagination__jump {
display: none !important;
}
.el-pagination > .el-pagination__sizes {
display: none !important;
}
}
.el-button {
// 防止被tailwindcss默认样式覆盖
background-color: var(--el-button-bg-color, var(--el-color-white));
//覆盖el-button的点击样式
&:focus {
color: var(--el-button-text-color);
border-color: var(--el-button-border-color);
background-color: var(--el-button-bg-color);
}
&:hover {
color: var(--el-button-hover-text-color);
border-color: var(--el-button-hover-border-color);
background-color: var(--el-button-hover-bg-color);
}
}
/* Inline form filter blocks on list pages */
.el-card .el-form--inline {
.el-form-item {
margin-bottom: 12px;
}
}
/* Page section spacing between stacked cards */
.el-card + .el-card {
margin-top: 16px;
}
+3 -5
View File
@@ -1,7 +1,5 @@
@import 'var.css';
@import 'dark.css';
@import 'tailwind.css';
@import 'element.scss';
@import 'admin-shell.scss';
@import 'admin-pages.scss';
@import 'dark.css';
@import 'var.css';
@import 'tailwind.css';
@import 'public.scss';
-48
View File
@@ -1,10 +1,6 @@
body {
@apply text-base text-tx-primary overflow-hidden min-w-[375px];
font-feature-settings: 'kern' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.form-tips {
@apply text-tx-secondary text-xs leading-6 mt-1;
}
@@ -16,51 +12,7 @@ body {
visibility: hidden;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color-dark);
background-clip: content-box;
}
/* NProgress */
#nprogress .bar {
@apply bg-primary #{!important};
height: 2px !important;
}
#nprogress .peg {
box-shadow: 0 0 8px var(--el-color-primary), 0 0 4px var(--el-color-primary) !important;
}
/* Shared page utilities */
.admin-page-title {
@apply text-xl font-semibold text-tx-primary tracking-tight;
}
.admin-page-desc {
@apply text-sm text-tx-secondary mt-1;
}
.admin-stat-value {
@apply text-3xl font-semibold text-tx-primary tabular-nums;
}
.admin-stat-label {
@apply text-sm text-tx-secondary;
}
+35 -85
View File
@@ -1,99 +1,49 @@
:root {
/* Typography */
--el-font-family: theme(fontFamily.sans);
--el-font-weight-primary: 400;
--el-menu-item-height: 46px;
--el-menu-sub-item-height: var(--el-menu-item-height);
--el-menu-icon-width: 18px;
--aside-width: 200px;
--navbar-height: 50px;
--color-white: #ffffff;
--table-header-bg-color: #f8f8f8;
--el-font-size-extra-large: 18px;
--el-menu-base-level-padding: 16px;
--el-menu-level-padding: 26px;
--el-font-size-large: 16px;
--el-font-size-medium: 15px;
--el-font-size-base: 14px;
--el-font-size-small: 13px;
--el-font-size-extra-small: 12px;
/* Brand - MedTech 绚丽临床 */
--admin-brand-primary: #06b6d4;
--admin-brand-primary-dark: #0891b2;
--admin-brand-accent: #10b981;
--admin-brand-secondary: #6366f1;
--admin-brand-success: #10b981;
--admin-brand-gradient: linear-gradient(120deg, #06b6d4 0%, #10b981 48%, #0891b2 100%);
--admin-brand-gradient-soft: linear-gradient(
120deg,
rgba(6, 182, 212, 0.16) 0%,
rgba(16, 185, 129, 0.1) 50%,
rgba(8, 145, 178, 0.08) 100%
);
--admin-brand-glow: 0 0 24px rgba(6, 182, 212, 0.35);
--admin-brand-mesh-a: rgba(6, 182, 212, 0.18);
--admin-brand-mesh-b: rgba(16, 185, 129, 0.14);
--admin-brand-mesh-c: rgba(99, 102, 241, 0.1);
/* Layout shell v2 */
--aside-width: 248px;
--navbar-height: 60px;
--tabs-height: 42px;
--stage-padding-x: 28px;
--stage-padding-y: 24px;
--content-max-width: 1680px;
--sidebar-rail-width: 3px;
/* Shape */
--admin-radius-sm: 8px;
--admin-radius-md: 12px;
--admin-radius-lg: 16px;
--admin-radius-xl: 22px;
--admin-radius-2xl: 28px;
--el-border-radius-base: var(--admin-radius-md);
--el-border-radius-small: var(--admin-radius-sm);
--el-border-radius-round: 999px;
/* Menu */
--el-menu-item-height: 46px;
--el-menu-sub-item-height: var(--el-menu-item-height);
--el-menu-icon-width: 20px;
--el-menu-base-level-padding: 14px;
--el-menu-level-padding: 22px;
/* Surfaces */
--color-white: #ffffff;
--table-header-bg-color: rgba(6, 182, 212, 0.06);
--sidebar-dark-bg: #060d18;
--sidebar-dark-hover: rgba(255, 255, 255, 0.05);
--sidebar-dark-active: rgba(6, 182, 212, 0.14);
--admin-surface-elevated: #ffffff;
--admin-surface-muted: rgba(255, 255, 255, 0.72);
--admin-surface-glass: rgba(255, 255, 255, 0.82);
--el-bg-color: var(--color-white);
--el-bg-color-page: #eef6fb;
--el-bg-color-page: #f6f6f6;
--el-bg-color-overlay: #ffffff;
--el-text-color-primary: #0b1220;
--el-text-color-regular: #334155;
--el-text-color-secondary: #64748b;
--el-text-color-placeholder: #94a3b8;
--el-text-color-disabled: #cbd5e1;
--el-border-color: rgba(6, 182, 212, 0.12);
--el-border-color-light: rgba(6, 182, 212, 0.08);
--el-border-color-lighter: rgba(15, 23, 42, 0.06);
--el-border-color-extra-light: rgba(15, 23, 42, 0.04);
--el-border-color-dark: #cbd5e1;
--el-border-color-darker: #94a3b8;
--el-fill-color: #f1f5f9;
--el-fill-color-light: #f8fafc;
--el-fill-color-lighter: #fafbfc;
--el-fill-color-extra-light: #fcfdfe;
--el-fill-color-dark: #e2e8f0;
--el-fill-color-darker: #cbd5e1;
--el-text-color-primary: #333333;
--el-text-color-regular: #666666;
--el-text-color-secondary: #999999;
--el-text-color-placeholder: #a8abb2;
--el-text-color-disabled: #c0c4cc;
--el-border-color: #dcdfe6;
--el-border-color-light: #e4e7ed;
--el-border-color-lighter: #ebeef5;
--el-border-color-extra-light: #f2f2f2;
--el-border-color-dark: #d4d7de;
--el-border-color-darker: #cdd0d6;
--el-fill-color: #f0f2f5;
--el-fill-color-light: #f8f8f8;
--el-fill-color-lighter: #fafafa;
--el-fill-color-extra-light: #fafcff;
--el-fill-color-dark: #ebedf0;
--el-fill-color-darker: #e6e8eb;
--el-fill-color-blank: #ffffff;
--el-mask-color: rgba(6, 13, 24, 0.55);
--el-mask-color-extra-light: rgba(6, 13, 24, 0.1);
--el-box-shadow: 0 4px 24px rgba(6, 182, 212, 0.08), 0 12px 40px rgba(15, 23, 42, 0.06);
--el-box-shadow-light: 0 2px 16px rgba(6, 182, 212, 0.1);
--el-box-shadow-lighter: 0 1px 4px rgba(15, 23, 42, 0.04);
--el-box-shadow-dark: 0 16px 48px rgba(6, 182, 212, 0.14), 0 32px 64px rgba(15, 23, 42, 0.1);
--admin-header-border: rgba(6, 182, 212, 0.1);
--admin-sidebar-border: rgba(255, 255, 255, 0.06);
--admin-stage-bg: transparent;
/* 过亮会盖住抽屉/弹窗下的内容;Element Loading 与部分蒙层共用此变量 */
--el-mask-color: rgba(255, 255, 255, 0.5);
--el-mask-color-extra-light: rgba(255, 255, 255, 0.22);
-el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08);
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12),
0px 8px 16px -8px rgba(0, 0, 0, 0.16);
}
+6 -25
View File
@@ -1,10 +1,9 @@
<template>
<div class="change-password flex flex-col">
<div class="change-password-backdrop" aria-hidden="true"></div>
<div class="flex-1 flex items-center justify-center relative z-[1] px-4">
<div class="change-password-card">
<div class="text-center text-2xl font-semibold mb-2 text-tx-primary">首次登录</div>
<div class="text-center text-tx-secondary text-sm mb-8">为了您的账号安全请修改初始密码</div>
<div class="flex-1 flex items-center justify-center">
<div class="change-password-card bg-body rounded-md px-10 py-10 w-[480px]">
<div class="text-center text-2xl font-medium mb-2">首次登录</div>
<div class="text-center text-gray-500 text-sm mb-8">为了您的账号安全请修改初始密码</div>
<el-form ref="formRef" :model="formData" size="large" :rules="rules">
<el-form-item prop="password">
@@ -118,25 +117,7 @@ const { isLock, lockFn: lockSubmit } = useLockFn(handleSubmit)
<style lang="scss" scoped>
.change-password {
position: relative;
min-height: 100vh;
background: #0f172a;
}
.change-password-backdrop {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 15% 20%, rgba(15, 118, 110, 0.35), transparent 42%),
linear-gradient(160deg, #0f172a 0%, #111827 48%, #0b1120 100%);
}
.change-password-card {
width: min(480px, 100%);
padding: 40px 36px;
border-radius: var(--admin-radius-xl);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 64px rgba(2, 6, 23, 0.45);
background: var(--el-bg-color);
background-image: url('./images/login_bg.png');
@apply min-h-screen bg-no-repeat bg-center bg-cover;
}
</style>
+32 -192
View File
@@ -1,41 +1,33 @@
<template>
<div class="login-v2">
<div class="login-v2__mesh" aria-hidden="true"></div>
<div class="login-v2__layout">
<aside class="login-v2__brand">
<div class="login-v2__brand-inner">
<div class="login-v2__mark">ZYT</div>
<p class="login-v2__kicker">互联网医院</p>
<h1 class="login-v2__headline">医生与医助<br />智慧工作台</h1>
<p class="login-v2__lead">问诊处方订单与运营数据一屏协同</p>
<div v-if="config.login_image" class="login-v2__visual hidden xl:block">
<image-contain :src="config.login_image" :width="420" height="280" />
</div>
<div class="login flex flex-col">
<div class="flex-1 flex items-center justify-center">
<div class="login-card flex rounded-md overflow-hidden">
<div class="flex-1 h-full hidden md:inline-block">
<image-contain :src="config.login_image" :width="400" height="100%" />
</div>
</aside>
<div
class="login-form bg-body flex flex-col justify-center px-10 py-10 md:w-[420px] w-[380px] flex-none mx-auto"
>
<div class="text-center text-3xl font-medium mb-8">{{ config.web_name }}</div>
<section class="login-v2__panel">
<div class="login-v2__glass">
<div class="login-v2__form-head">
<h2 class="login-v2__form-title">{{ config.web_name }}</h2>
<p class="login-v2__form-sub">登录后继续你的接诊与管理工作</p>
</div>
<div v-if="wxWorkAutoLogin" class="login-v2__loading">
<!-- 企业微信自动授权中 -->
<div v-if="wxWorkAutoLogin" class="text-center py-10">
<el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
<Loading />
</el-icon>
<div class="text-tx-secondary">企业微信授权登录中...</div>
<div class="text-gray-500">企业微信授权登录中...</div>
</div>
<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-button value="account">账号登录</el-radio-button>
<el-radio-button value="wxwork">企业微信</el-radio-button>
</el-radio-group>
</div>
<!-- 账号密码登录 -->
<template v-if="loginMode === 'account'">
<el-form ref="formRef" :model="formData" size="large" :rules="rules">
<el-form-item prop="account">
@@ -66,34 +58,29 @@
<div class="mb-5">
<el-checkbox v-model="remAccount" label="记住账号"></el-checkbox>
</div>
<el-button
class="login-v2__submit"
type="primary"
size="large"
:loading="isLock"
@click="lockLogin"
>
进入工作台
<el-button type="primary" size="large" :loading="isLock" @click="lockLogin">
登录
</el-button>
</template>
<!-- 企业微信扫码登录非企业微信内浏览器 -->
<template v-if="loginMode === 'wxwork'">
<div class="wxwork-qrcode-wrap">
<div v-if="wxWorkLoading" class="text-center py-10">
<el-icon class="is-loading" :size="32" color="var(--el-color-primary)">
<Loading />
</el-icon>
<div class="mt-2 text-tx-secondary text-sm">加载企业微信扫码...</div>
<div class="mt-2 text-gray-400 text-sm">加载企业微信扫码...</div>
</div>
<div v-else id="wxwork_qrcode_container" class="wxwork-qrcode"></div>
</div>
<div class="text-center text-sm text-tx-secondary mt-4">
<div class="text-center text-sm text-gray-400 mt-4">
请使用企业微信扫描二维码登录
</div>
</template>
</template>
</div>
</section>
</div>
</div>
<layout-footer />
</div>
@@ -299,178 +286,31 @@ onMounted(async () => {
</script>
<style lang="scss" scoped>
.login-v2 {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #030712;
overflow: hidden;
}
.login-v2__mesh {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 12% 18%, rgba(6, 182, 212, 0.35), transparent 42%),
radial-gradient(circle at 88% 12%, rgba(16, 185, 129, 0.28), transparent 38%),
radial-gradient(circle at 70% 88%, rgba(99, 102, 241, 0.18), transparent 40%),
linear-gradient(155deg, #030712 0%, #0b1220 45%, #060d18 100%);
}
.login-v2__layout {
position: relative;
z-index: 1;
flex: 1;
display: grid;
grid-template-columns: 1fr;
min-height: calc(100vh - 48px);
}
@media (min-width: 1024px) {
.login-v2__layout {
grid-template-columns: minmax(0, 1.05fr) minmax(420px, 520px);
.login {
background-image: url('./images/login_bg.png');
@apply min-h-screen bg-no-repeat bg-center bg-cover;
.login-card {
height: auto;
min-height: 400px;
}
}
.login-v2__brand {
display: none;
padding: 48px 56px;
color: #ffffff;
}
@media (min-width: 1024px) {
.login-v2__brand {
display: flex;
align-items: center;
}
}
.login-v2__brand-inner {
max-width: 520px;
}
.login-v2__mark {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
background: var(--admin-brand-gradient);
box-shadow: var(--admin-brand-glow);
margin-bottom: 28px;
}
.login-v2__kicker {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
}
.login-v2__headline {
margin: 0;
font-size: clamp(32px, 4vw, 44px);
font-weight: 800;
line-height: 1.15;
letter-spacing: -0.03em;
}
.login-v2__lead {
margin: 16px 0 0;
font-size: 16px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.72);
max-width: 36ch;
}
.login-v2__visual {
margin-top: 36px;
border-radius: var(--admin-radius-xl);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
}
.login-v2__panel {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 20px;
}
.login-v2__glass {
width: min(440px, 100%);
padding: 36px 32px;
border-radius: var(--admin-radius-2xl);
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24px) saturate(160%);
box-shadow: var(--el-box-shadow-dark);
}
.login-v2__form-head {
margin-bottom: 28px;
}
.login-v2__form-title {
margin: 0;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--el-text-color-primary);
}
.login-v2__form-sub {
margin: 8px 0 0;
font-size: 14px;
color: var(--el-text-color-secondary);
}
.login-v2__mode {
display: flex;
justify-content: center;
margin-bottom: 24px;
}
.login-v2__submit {
width: 100%;
}
.login-v2__loading {
text-align: center;
padding: 40px 0;
}
.wxwork-qrcode-wrap {
display: flex;
justify-content: center;
align-items: center;
min-height: 360px;
min-height: 400px;
}
.wxwork-qrcode {
width: 320px;
height: 360px;
width: 340px;
height: 400px;
overflow: hidden;
:deep(iframe) {
width: 320px !important;
height: 360px !important;
width: 340px !important;
height: 400px !important;
border: none;
}
}
@media (prefers-reduced-transparency: reduce) {
.login-v2__glass {
background: #ffffff;
backdrop-filter: none;
}
}
</style>
+9 -9
View File
@@ -1,17 +1,17 @@
<template>
<div>
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-alert
type="warning"
title="温馨提示:用于管理网站的分类,只可添加到一级"
:closable="false"
show-icon
/>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
</el-card>
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
<div>
<el-button
class="mb-4"
v-perms="['article.articleCate/add']"
type="primary"
@click="handleAdd()"
@@ -21,7 +21,7 @@
</template>
新增
</el-button>
</template>
</div>
<el-table size="large" :data="pager.lists">
<el-table-column label="栏目名称" prop="name" min-width="120" />
<el-table-column label="文章数" prop="article_count" min-width="120" />
@@ -58,10 +58,10 @@
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
+11 -12
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<div class="article-lists">
<el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="文章标题">
<el-input
@@ -33,25 +33,24 @@
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<router-link
v-perms="['article.article/add', 'article.article/add:edit']"
:to="{
path: getRoutePath('article.article/add:edit')
}"
>
<el-button type="primary">
<el-button type="primary" class="mb-4">
<template #icon>
<icon name="el-icon-Plus" />
</template>
发布文章
</el-button>
</router-link>
</template>
<el-table size="large" :data="pager.lists">
</div>
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="ID" prop="id" min-width="80" />
<el-table-column label="封面" min-width="100">
<template #default="{ row }">
@@ -117,10 +116,10 @@
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup name="articleLists">
+13 -48
View File
@@ -1,7 +1,7 @@
<template>
<div class="asset-resource-container">
<!-- 搜索区域 -->
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="标题">
<el-input
@@ -28,26 +28,21 @@
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
</el-card>
<!-- 列表区域 -->
<admin-page-data-panel v-loading="loading">
<template #toolbar>
<el-card class="!border-none mt-4" shadow="never">
<div class="mb-4">
<el-button type="primary" @click="handleAdd">上传资源</el-button>
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
批量删除
</el-button>
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} </span>
</template>
</div>
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
<el-tab-pane label="图片" name="1"></el-tab-pane>
<el-tab-pane label="视频" name="2"></el-tab-pane>
<el-tab-pane label="语音" name="3"></el-tab-pane>
</el-tabs>
<el-table :data="tableData" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="title" label="标题" min-width="80" />
<el-table-column label="预览" width="100">
@@ -71,7 +66,7 @@
</el-table-column>
</el-table>
<template #footer>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size"
@@ -80,8 +75,8 @@
@size-change="getList"
@current-change="getList"
/>
</template>
</admin-page-data-panel>
</div>
</el-card>
<!-- 新增/编辑资源弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
@@ -97,10 +92,7 @@
<el-input v-model="formData.title" placeholder="请输入资源标题" />
</el-form-item>
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
<div v-if="formData.type === 1" class="w-full">
<material-picker v-model="formData.file_url" :limit="9" type="image" />
<div style="font-size: 12px; color: #909399; margin-top: 4px;">支持批量上传多张图片一次最多 9 每张图片将生成一条独立资源多张时标题自动追加序号</div>
</div>
<material-picker v-if="formData.type === 1" v-model="formData.file_url" :limit="1" type="image" />
<upload v-else-if="formData.type === 2" type="video" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
<el-button type="primary">上传视频 (OSS直传)</el-button>
</upload>
@@ -134,9 +126,8 @@ import MaterialPicker from '@/components/material/picker.vue'
import Upload from '@/components/upload/index.vue'
const loading = ref(false)
const tableData = ref<any[]>([])
const tableData = ref([])
const total = ref(0)
const selectedIds = ref<number[]>([])
const searchData = reactive<any>({
title: '',
@@ -165,15 +156,6 @@ const formData = reactive<any>({
user_ids: []
})
const validateFileUrl = (_rule: any, value: any, callback: any) => {
if (Array.isArray(value)) {
if (!value.length) return callback(new Error('请上传资源文件'))
} else if (!value) {
return callback(new Error('请输入或上传文件获取链接'))
}
callback()
}
const computedRules = computed(() => {
if (isEdit.value) {
return {
@@ -182,7 +164,7 @@ const computedRules = computed(() => {
}
return {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
file_url: [{ required: true, validator: validateFileUrl, trigger: 'change' }]
file_url: [{ required: true, message: '请输入或上传文件获取链接', trigger: 'blur' }]
}
})
@@ -204,7 +186,6 @@ const getList = async () => {
const res = await apiAssetResourceList(buildQueryParams())
tableData.value = res.lists
total.value = res.count
selectedIds.value = []
} catch (e) {
console.error(e)
} finally {
@@ -276,22 +257,6 @@ const handleDelete = async (row: any) => {
}
}
const handleSelectionChange = (rows: any[]) => {
selectedIds.value = rows.map((row) => Number(row.id)).filter((id) => id > 0)
}
const handleBatchDelete = async () => {
if (!selectedIds.value.length) return
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个资源吗?用户将无法再看到。`, '提示', { type: 'warning' })
await apiAssetResourceDelete({ id: selectedIds.value })
ElMessage.success('删除成功')
getList()
} catch (e) {
// canceled
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
+12 -86
View File
@@ -1,7 +1,7 @@
<template>
<div class="asset-user-container">
<!-- 搜索区域 -->
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="手机号">
<el-input
@@ -16,31 +16,17 @@
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
</el-card>
<!-- 列表区域 -->
<admin-page-data-panel v-loading="loading">
<template #toolbar>
<el-card class="!border-none mt-4" shadow="never">
<div class="mb-4">
<el-button type="primary" @click="handleAdd">新增账号</el-button>
</template>
<el-table :data="tableData">
</div>
<el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="phone" label="手机号" />
<el-table-column label="备注" min-width="200">
<template #default="{ row }">
<div class="flex items-center gap-1 min-w-0">
<span class="truncate text-gray-600" :title="row.remark || ''">
{{ row.remark || '—' }}
</span>
<el-tooltip content="编辑备注" placement="top">
<el-icon class="shrink-0 cursor-pointer text-gray-400 hover:text-primary" @click="openQuickRemark(row)">
<Edit />
</el-icon>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
@@ -63,7 +49,7 @@
</el-table-column>
</el-table>
<template #footer>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size"
@@ -72,8 +58,8 @@
@size-change="getList"
@current-change="getList"
/>
</template>
</admin-page-data-panel>
</div>
</el-card>
<!-- 编辑/新增弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
@@ -84,16 +70,6 @@
<el-form-item label="密码" prop="password">
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="可填写用户相关信息,仅管理员可见"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
</el-form-item>
@@ -103,30 +79,12 @@
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
<!-- 快捷备注 -->
<el-dialog v-model="remarkDialogVisible" title="快捷备注" width="420px" destroy-on-close>
<div class="text-sm text-gray-500 mb-3">账号{{ remarkTargetPhone }}</div>
<el-input
v-model="remarkDraft"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="可填写用户相关信息,仅管理员可见"
/>
<template #footer>
<el-button @click="remarkDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="remarkSaving" @click="saveQuickRemark">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Edit } from '@element-plus/icons-vue'
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
const loading = ref(false)
@@ -147,17 +105,10 @@ const dialogTitle = ref('')
const submitLoading = ref(false)
const formRef = ref()
const remarkDialogVisible = ref(false)
const remarkSaving = ref(false)
const remarkDraft = ref('')
const remarkTargetId = ref<number | string>('')
const remarkTargetPhone = ref('')
const formData = reactive({
id: '',
phone: '',
password: '',
remark: '',
status: 1
})
@@ -203,41 +154,16 @@ const handleStatusChange = async (row: any) => {
const handleAdd = () => {
dialogTitle.value = '新增账号'
Object.assign(formData, { id: '', phone: '', password: '', remark: '', status: 1 })
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
dialogVisible.value = true
}
const handleEdit = (row: any) => {
dialogTitle.value = '编辑账号'
Object.assign(formData, { id: row.id, phone: row.phone, password: '', remark: row.remark || '', status: row.status })
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
dialogVisible.value = true
}
const openQuickRemark = (row: any) => {
remarkTargetId.value = row.id
remarkTargetPhone.value = row.phone || ''
remarkDraft.value = row.remark || ''
remarkDialogVisible.value = true
}
const saveQuickRemark = async () => {
if (!remarkTargetId.value) return
remarkSaving.value = true
try {
await apiAssetUserEdit({ id: remarkTargetId.value, remark: remarkDraft.value })
const target = tableData.value.find((item: any) => item.id === remarkTargetId.value)
if (target) {
target.remark = remarkDraft.value
}
ElMessage.success('备注已保存')
remarkDialogVisible.value = false
} catch (e) {
console.error(e)
} finally {
remarkSaving.value = false
}
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
+6 -7
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="用户信息">
<el-input
@@ -37,9 +37,8 @@
/>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="头像" min-width="100">
<template #default="{ row }">
@@ -68,10 +67,10 @@
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup name="consumerLists">
@@ -1,359 +0,0 @@
/**
* /
*
* order_list.vuePatientOrderList.vue Tab
* PrescriptionOrderDetailDrawer.vue
* /
*/
/** 与诊间医助角色 ID 一致(server 角色表) */
export const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
export function formatTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
const d = new Date(v * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
return String(v)
}
export function formatMoney(v: unknown) {
const n = Number(v)
if (Number.isNaN(n)) return '—'
return n.toFixed(2)
}
export function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
export function feeTypeText(t: number | undefined) {
const m: Record<number, string> = {
1: '挂号费',
2: '问诊费',
3: '药品费用',
4: '首付费用',
5: '尾款费用',
6: '其他费用',
7: '全部费用',
8: '驼奶费用'
}
return m[Number(t)] ?? '—'
}
export function fulfillmentText(s: number | undefined) {
const m: Record<number, string> = {
1: '待双审通过',
2: '待发货',
3: '已完成',
4: '已取消',
5: '已发货',
6: '已签收',
7: '进行中',
8: '暂不制药',
9: '拒收',
10: '退款',
11: '保留药方',
12: '制药缓发'
}
return m[Number(s)] ?? '—'
}
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
const n = Number(s)
if (n === 3) return 'success'
if (n === 6) return 'success'
if (n === 5) return 'primary'
if (n === 4) return 'danger'
if (n === 2) return 'warning'
if (n === 7) return 'warning'
if (n === 8 || n === 11 || n === 12) return 'info'
if (n === 9 || n === 10) return 'danger'
return 'info'
}
export function orderStatusText(s: number | undefined) {
const m: Record<number, string> = {
1: '待支付',
2: '已支付',
3: '已取消',
4: '已退款',
5: '待审核'
}
return m[Number(s)] ?? '—'
}
export function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
export function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
export function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
export function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
export function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建',
edit: '编辑',
audit_rx_approve: '处方审核',
audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核',
audit_pay_reject: '支付审核',
fill_tracking: '填快递单',
ship: '确认发货',
withdraw: '撤销',
link_pay_order: '关联支付单',
completion_request: '完单申请',
auto_complete: '自动完成',
revoke_rx_audit: '撤回处方审核',
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款'
}
return m[act] || act
}
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
wechat_work: '企业微信',
fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账'
}
if (paymentMethod && methodMap[paymentMethod]) {
return methodMap[paymentMethod]
}
return '普通订单'
}
export function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
}
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
const a = normalizeBizPhone(recipient)
const b = normalizeBizPhone(rxPhone)
if (!a || !b) return false
return a !== b
}
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
if (!raw) return []
if (Array.isArray(raw)) {
return raw.map((x: any) => ({
name: String(x?.name ?? '').trim(),
dosage: Number(x?.dosage) || 0
}))
}
return []
}
export type SlipFormulaType = '主方' | '辅方'
export interface SlipAuxUsageForm {
dosage_amount?: number
dosage_bag_count: number
need_decoction: boolean
bags_per_dose: number
times_per_day: number
usage_days: number
}
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
return v === '辅方' ? '辅方' : '主方'
}
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
if (prescriptionType === '饮片') {
return {
dosage_amount: 50,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
if (prescriptionType === '浓缩水丸') {
return {
dosage_amount: 5,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
return {
dosage_amount: 1,
dosage_bag_count: 1,
need_decoction: false,
bags_per_dose: 1,
times_per_day: 3,
usage_days: 7
}
}
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
const base = defaultSlipAuxUsage(prescriptionType)
if (!raw || typeof raw !== 'object') return { ...base }
const o = raw as Record<string, unknown>
return {
dosage_amount:
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
? Number(o.dosage_amount)
: base.dosage_amount,
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
}
}
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula */
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
if (!rx) return false
const herbs = rx.herbs
if (Array.isArray(herbs)) {
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
}
return Number(rx.has_aux_formula) === 1
}
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
export const LOGISTICS_URGENT_KEYWORDS = [
'拒收',
'拒签',
'退件',
'客户拒收',
'拦截退回',
'退回发件',
'派件退回',
'无人签收',
'退回快件'
]
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
const t = String(s ?? '')
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
}
export function logisticsTraceLineUrgent(context: unknown): boolean {
return logisticsStringHasUrgentKeyword(context)
}
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
export function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
show: boolean
keywords: string[]
lines: string[]
} {
if (!p) {
return { show: false, keywords: [], lines: [] }
}
const traces = Array.isArray(p.traces)
? (p.traces as Array<{ time?: string; context?: string }>)
: []
const chunks: string[] = []
if (p.state_text) chunks.push(String(p.state_text))
if (p.hint) chunks.push(String(p.hint))
for (const row of traces) {
if (row.context) chunks.push(String(row.context))
}
const joined = chunks.join('\n')
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
if (!keywords.length) {
return { show: false, keywords: [], lines: [] }
}
const lines: string[] = []
for (const row of traces) {
const ctx = String(row.context || '')
if (logisticsStringHasUrgentKeyword(ctx)) {
lines.push(ctx)
if (lines.length >= 5) break
}
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
lines.push(`最新状态:${p.state_text}`)
}
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
lines.push(String(p.hint))
}
return { show: true, keywords, lines }
}
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
export function parseLogisticsTracePayload(
res: unknown,
expectedTrackingNumber: string,
expectedOrderId: number
): Record<string, any> | null {
const raw = (res as { data?: unknown })?.data ?? res
if (!raw || typeof raw !== 'object') return null
const payload = raw as Record<string, any>
const respNum = String(payload.tracking_number || '').trim()
if (respNum && respNum !== expectedTrackingNumber) return null
const respOrderId = Number(payload.order_id)
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
return payload
}
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
if (!row?.id) return false
const fs = Number(row.fulfillment_status)
return fs !== 3 && fs !== 4
}
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
export function formatDietaryTaboo(raw: unknown): string {
if (Array.isArray(raw)) {
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
}
if (typeof raw === 'string' && raw.trim()) {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join('、')
}
return ''
}
+18 -55
View File
@@ -39,7 +39,7 @@
<el-radio-button label="rejected">驳回</el-radio-button>
</el-radio-group>
<span class="pl-toolbar__sep" aria-hidden="true" />
<span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
<el-radio-group
v-model="formData.source_filter"
size="small"
@@ -48,7 +48,7 @@
>
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="manual">手工</el-radio-button>
<el-radio-button label="system">空白处方</el-radio-button>
<el-radio-button label="system">系统代开</el-radio-button>
</el-radio-group>
</div>
<div class="pl-toolbar__line pl-toolbar__line--second">
@@ -146,7 +146,7 @@
size="small"
effect="plain"
>
空白处方
系统代开
</el-tag>
<span v-else class="text-gray-400 text-sm">手工</span>
</template>
@@ -290,7 +290,7 @@
type="warning"
size="small"
effect="plain"
>空白处方</el-tag>
>系统代开</el-tag>
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
<el-tag
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
@@ -437,7 +437,6 @@
<p>主方服法{{ rxUsageText }}</p>
<p v-if="rxAuxUsageText">辅方服法{{ rxAuxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="slipDietaryText">忌口{{ slipDietaryText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
@@ -459,9 +458,7 @@
alt="医师签名"
class="rx-bot-sign-img"
/>
<div v-if="!slipView.doctor_signature" class="rx-bot-doctor-name">
{{ slipView.doctor_name || '—' }}
</div>
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
</div>
</div>
<div class="rx-bot-cell rx-bot-meta">
@@ -682,21 +679,17 @@
粘贴文本解析药名与剂量须与药品库名称完全一致才录入导入至主方
</span>
</div>
<div v-if="herbsLocked" class="flex items-center gap-2 flex-wrap mt-2 text-xs text-[var(--el-color-danger)]">
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
禁用修改处方库模板药材已锁定不可增删改如需调整请重新从处方库导入覆盖
</div>
<div class="herb-formula-block mt-3">
<div class="herb-formula-block__head">
<el-tag type="primary" size="small">主方</el-tag>
<el-button type="primary" size="small" :disabled="herbsLocked" @click="addHerb('主方')">添加主方药材</el-button>
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
</div>
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -706,7 +699,6 @@
:min="0"
:precision="1"
:step="0.5"
:disabled="herbsLocked"
placeholder="剂量"
class="w-full"
/>
@@ -714,8 +706,7 @@
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
<span v-else class="text-gray-400 text-xs">已锁定</span>
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -724,7 +715,7 @@
<div class="herb-formula-block mt-4">
<div class="herb-formula-block__head">
<el-tag type="warning" size="small">辅方</el-tag>
<el-button type="primary" size="small" plain :disabled="herbsLocked" @click="addHerb('辅方')">
<el-button type="primary" size="small" plain @click="addHerb('辅方')">
添加辅方药材
</el-button>
</div>
@@ -732,7 +723,7 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -742,7 +733,6 @@
:min="0"
:precision="1"
:step="0.5"
:disabled="herbsLocked"
placeholder="剂量"
class="w-full"
/>
@@ -750,8 +740,7 @@
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
<span v-else class="text-gray-400 text-xs">已锁定</span>
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -1709,7 +1698,7 @@ import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方'
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type HerbRow = { name: string; dosage: number; formula_type: FormulaType }
type AuxUsageForm = {
dosage_amount?: number
@@ -1809,15 +1798,11 @@ function normalizeFormulaType(v: unknown): FormulaType {
}
function normalizeHerbRow(raw: any): HerbRow {
const row: HerbRow = {
return {
name: String(raw?.name ?? '').trim(),
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
return row
}
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
@@ -1857,7 +1842,7 @@ const formData = reactive({
creator_ids: [] as number[],
/** all | passed | not_passed | pending | rejectedall 表示不限,接口传参会转为空) */
audit_filter: 'all' as string,
/** all | manual | system — 与 is_system_auto:手工(0) / 空白处方(1) */
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */
source_filter: 'all' as string,
start_time: '',
end_time: ''
@@ -2496,12 +2481,6 @@ const auxHerbRows = computed(() =>
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
)
/**
* 整张处方是否被锁定只要任一药材来自禁用修改处方库模板即锁定
* 锁定后不可增删改任意药材只能再次从处方库导入覆盖
*/
const herbsLocked = computed(() => editForm.herbs.some((h) => (h as HerbRow).locked === true))
const slipDietaryText = computed(() => {
const d = slipView.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
@@ -3462,18 +3441,10 @@ const handleImportLibrary = (row: any) => {
return
}
const formulaType = normalizeFormulaType(row.formula_type)
//
const isLockedTemplate = Number(row?.disable_edit) === 1
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
const herb = normalizeHerbRow(h)
herb.formula_type = formulaType
if (isLockedTemplate) {
herb.locked = true
} else {
delete herb.locked
}
return herb
})
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
...normalizeHerbRow(h),
formula_type: formulaType
}))
if (libraryImportMode.value === 'replace') {
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
editForm.herbs = [...kept, ...imported]
@@ -3679,10 +3650,6 @@ async function handlePasteRecipeImport() {
// /
const addHerb = (formulaType: FormulaType = '主方') => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
return
}
editForm.herbs.push({
name: '',
dosage: 0,
@@ -3692,10 +3659,6 @@ const addHerb = (formulaType: FormulaType = '主方') => {
//
const removeHerb = (index: number) => {
if (herbsLocked.value) {
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
return
}
editForm.herbs.splice(index, 1)
}
+1 -27
View File
@@ -71,13 +71,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="禁用修改" min-width="100">
<template #default="{ row }">
<el-tag :type="row.disable_edit ? 'danger' : 'info'">
{{ row.disable_edit ? '已禁用' : '可修改' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建人" prop="creator_name" min-width="100" />
<el-table-column label="创建时间" prop="create_time" min-width="160" />
<el-table-column label="操作" width="180" fixed="right">
@@ -200,21 +193,6 @@
勾选后所有医生都可以查看和使用此处方模板
</div>
</el-form-item>
<el-form-item label="禁用修改" prop="disable_edit">
<div class="flex flex-col gap-1 leading-none">
<el-switch
v-model="editForm.disable_edit"
:active-value="1"
:inactive-value="0"
active-text="开启"
inactive-text="关闭"
/>
<div class="text-xs text-gray-400 leading-relaxed">
开启后导入此模板到处方时将锁定整张处方的药材不可增删改只能再次导入覆盖
</div>
</div>
</el-form-item>
</el-form>
<template #footer v-if="editMode !== 'view'">
@@ -258,8 +236,7 @@ const editForm = reactive({
prescription_name: '',
formula_type: '主方',
herbs: [] as Array<{ name: string; dosage: number }>,
is_public: 0,
disable_edit: 0
is_public: 0
})
//
@@ -316,7 +293,6 @@ const resetForm = () => {
editForm.formula_type = '主方'
editForm.herbs = []
editForm.is_public = 0
editForm.disable_edit = 0
}
//
@@ -334,7 +310,6 @@ const handleView = (row: any) => {
editForm.formula_type = row.formula_type || '主方'
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0
editForm.disable_edit = row.disable_edit ?? 0
showEdit.value = true
}
@@ -346,7 +321,6 @@ const handleEdit = (row: any) => {
editForm.formula_type = row.formula_type || '主方'
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
editForm.is_public = row.is_public ?? 0
editForm.disable_edit = row.disable_edit ?? 0
showEdit.value = true
}
File diff suppressed because it is too large Load Diff
@@ -190,17 +190,18 @@
<ArrowDown />
</el-icon>
</div>
<el-form v-show="!h5FilterCollapsed" class="ls-form stats-filter-form" :model="queryParams" inline>
<list-time-filter
v-model:time-type="time_type"
v-model:date-range="dateRange"
label="创建时间"
variant="inline"
show-all
picker-width="260px"
@time-type-change="onTimeTypeChange"
@custom-change="onCustomDateChange"
/>
<el-form v-show="!h5FilterCollapsed" class="ls-form" :model="queryParams" inline>
<el-form-item class="w-[360px] min-w-[240px] max-w-full" label="创建时间">
<DaterangePicker
v-model:startTime="queryParams.start_time"
v-model:endTime="queryParams.end_time"
picker-type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
class="!w-full"
/>
</el-form-item>
<el-form-item class="w-[260px]" label="订单号">
<el-input
v-model="queryParams.order_no"
@@ -702,50 +703,49 @@
</div>
<!-- 顶部数据概览卡片 -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-5">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="flex items-center justify-between mb-1">
<div class="stat-title text-gray-500 text-xs">总金额</div>
<el-button
v-if="canUpdateAmount(detailData)"
v-perms="['tcm.prescriptionOrder/updateAmount']"
type="primary"
size="small"
link
@click="openUpdateAmount"
>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付总额</div>
<div class="stat-value text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">需代收</div>
<div
class="stat-value font-bold text-xl"
:class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'"
>
修改
</el-button>
</div>
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付总额</div>
<div class="stat-value text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">退款金额</div>
<div
class="stat-value font-bold text-xl"
:class="Number(detailData.refund_amount) > 0 ? 'text-rose-500' : 'text-gray-400'"
>
¥{{ formatMoney(detailData.refund_amount || 0) }}
</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">需代收</div>
<div
class="stat-value font-bold text-xl"
:class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'"
>
¥{{ formatMoney(detailAgencyToCollect) }}
</div>
</el-card>
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付笔数</div>
<div class="stat-value text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</div>
¥{{ formatMoney(detailAgencyToCollect) }}
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
<div class="stat-title text-gray-500 text-xs mb-1">已付笔数</div>
<div class="stat-value text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</el-col>
</el-row>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 左侧处方单 -->
@@ -894,9 +894,6 @@
</div>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="detailDietaryText" label="忌口" :span="2">
{{ detailDietaryText }}
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">
{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}
@@ -965,11 +962,6 @@
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column label="方式" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ formatPayOrderSource(row) }}
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">
@@ -1028,11 +1020,6 @@
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
</template>
</el-table-column>
<el-table-column label="方式" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ formatPayOrderSource(row) }}
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">
@@ -1298,7 +1285,7 @@
<el-form-item label="新金额" prop="amount">
<el-input-number
v-model="updateAmountForm.amount"
:min="0"
:min="0.01"
:step="0.01"
:precision="2"
controls-position="right"
@@ -1568,7 +1555,7 @@
<el-option
v-for="o in editPaidOrders"
:key="o.id"
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)} · ${formatPayOrderSource(o)}`"
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)}`"
:value="o.id"
class="!h-auto py-1.5"
>
@@ -1578,7 +1565,7 @@
<span class="text-orange-500 font-medium ml-4">¥{{ o.amount }}</span>
</div>
<div class="flex items-center justify-between text-xs text-gray-400 mt-1">
<span class="truncate pr-2">{{ formatPoCategory(o.order_type) }} · {{ formatPayOrderSource(o) }}<template v-if="o.remark"> · {{ o.remark }}</template></span>
<span class="truncate pr-2">{{ formatPoCategory(o.order_type) }}<template v-if="o.remark"> · {{ o.remark }}</template></span>
<span class="shrink-0">{{ String(o.create_time || '').substring(0, 16) }}</span>
</div>
</div>
@@ -1858,11 +1845,9 @@
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="onCompleteOrderDialogClosed"
@closed="completeOrderId = 0"
>
<p class="text-sm text-gray-600 mb-3">
选择已完成按关联收款汇总实付选择退款将关联收款标记已退款并清零已付
</p>
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态选择已完成实付将按已关联的支付单金额汇总</p>
<el-select
v-model="completeFulfillmentStatus"
class="w-full"
@@ -1875,54 +1860,10 @@
:value="opt.value"
/>
</el-select>
<template v-if="completeFulfillmentStatus === 10">
<el-alert
type="warning"
:closable="false"
show-icon
class="mt-3"
title="关联收款将标记已退款,本单已付清零,不可撤销。"
/>
<p v-if="completeOrderLinkedPays.length" class="text-xs text-amber-800 mt-2">
将退款 {{ completeOrderLinkedPays.length }} 笔关联收款合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
</p>
<el-form
ref="completeRefundFormRef"
:model="completeRefundForm"
:rules="completeRefundRules"
label-width="80px"
class="mt-2"
>
<el-form-item label="退款金额" prop="refund_amount">
<el-input-number
v-model="completeRefundForm.refund_amount"
:min="0.01"
:max="completeOrderRefundMax > 0 ? completeOrderRefundMax : undefined"
:step="0.01"
:precision="2"
class="w-full"
/>
<div class="text-xs text-gray-500 mt-1">可退上限 ¥{{ formatMoney(completeOrderRefundMax) }}</div>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="completeRefundForm.reason"
type="textarea"
:rows="3"
maxlength="500"
placeholder="必填"
/>
</el-form-item>
</el-form>
</template>
<template #footer>
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
<el-button
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
:loading="completeOrderSubmitting"
@click="submitCompleteOrder"
>
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
确定
</el-button>
</template>
</el-dialog>
@@ -1941,20 +1882,9 @@
:closable="false"
show-icon
class="mb-4 !items-start"
:title="refundOrderAlertTitle"
title="确认退款后,关联收款记录将全部标记为「已退款」,已付总额清零,且不可撤销。"
/>
<el-form ref="refundOrderFormRef" :model="refundOrderForm" :rules="refundOrderRules" label-width="88px">
<el-form-item label="退款金额" prop="refund_amount">
<el-input-number
v-model="refundOrderForm.refund_amount"
:min="0.01"
:max="refundOrderMaxAmount > 0 ? refundOrderMaxAmount : undefined"
:step="0.01"
:precision="2"
class="w-full"
/>
<div class="text-xs text-gray-500 mt-1">可退上限 ¥{{ formatMoney(refundOrderMaxAmount) }}</div>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input
v-model="refundOrderForm.reason"
@@ -1984,7 +1914,7 @@
@closed="addPayOrderFormRef?.clearValidate()"
>
<el-alert
:title="addPayOrderAlertTitle"
title="可以手动创建新支付单,或关联已存在但未绑定的支付单。"
type="info"
:closable="false"
show-icon
@@ -2000,15 +1930,13 @@
>
<el-form-item label="添加方式" prop="add_mode">
<el-radio-group v-model="addPayOrderForm.add_mode">
<el-radio value="create">付呗</el-radio>
<el-radio value="create_express">快递代收</el-radio>
<el-radio value="create">手动创建</el-radio>
<el-radio value="link">关联已有</el-radio>
<el-radio value="completion_only">直接完单申请</el-radio>
</el-radio-group>
</el-form-item>
<!-- 付呗 / 快递代收手动创建支付单 -->
<template v-if="addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express'">
<!-- 手动创建模式 -->
<template v-if="addPayOrderForm.add_mode === 'create'">
<el-form-item label="费用类别" prop="order_type">
<el-select v-model="addPayOrderForm.order_type" class="w-full">
<el-option label="挂号费" :value="1" />
@@ -2043,20 +1971,6 @@
</el-form-item>
</template>
<!-- 直接完单申请不创建/关联支付单 -->
<template v-else-if="addPayOrderForm.add_mode === 'completion_only'">
<el-alert
type="warning"
:closable="false"
show-icon
class="!mb-0"
>
<template #title>
不新增或关联支付单仅向审核人员提交完成订单申请提交后支付审核将变为待审核审核通过后将自动结案
</template>
</el-alert>
</template>
<!-- 关联已有模式 -->
<template v-else>
<el-form-item label="选择支付单" prop="link_pay_order_id">
@@ -2072,17 +1986,12 @@
<el-option
v-for="o in addPayOrderAvailableList"
:key="o.id"
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)} · ${formatPayOrderSource(o)}`"
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)}`"
:value="o.id"
>
<div class="flex flex-col gap-0.5 py-0.5">
<div class="flex items-center justify-between">
<span class="text-sm">{{ o.order_no }}</span>
<span class="text-orange-500 font-medium ml-4">¥{{ o.amount }}</span>
</div>
<div class="text-xs text-gray-400">
{{ formatPoCategory(o.order_type) }} · {{ formatPayOrderSource(o) }}
</div>
<div class="flex items-center justify-between">
<span class="text-sm">{{ o.order_no }}</span>
<span class="text-orange-500 font-medium ml-4">¥{{ o.amount }}</span>
</div>
</el-option>
</el-select>
@@ -2093,7 +2002,7 @@
</template>
<!-- 完单申请选项 -->
<el-form-item v-if="addPayOrderForm.add_mode !== 'completion_only'" label="完单申请">
<el-form-item label="完单申请">
<el-radio-group v-model="addPayOrderForm.completion_request">
<el-radio :value="0">不申请</el-radio>
<el-radio :value="1">申请完成订单</el-radio>
@@ -2314,7 +2223,6 @@
<div class="rx-text">
<p>服法{{ rxUsageText }}</p>
<p v-if="rxAdviceText">医嘱{{ rxAdviceText }}</p>
<p v-if="slipDietaryText">忌口{{ slipDietaryText }}</p>
<p v-if="rxRemarkText">备注{{ rxRemarkText }}</p>
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
药房备注{{ rxPharmacyRemarkText }}
@@ -2335,10 +2243,7 @@
alt="医师签名"
class="rx-bot-sign-img"
/>
<span
v-if="!prescriptionViewData.doctor_signature"
class="rx-bot-doctor-name"
>{{ prescriptionViewData.doctor_name || '—' }}</span>
<!-- <span class="rx-bot-doctor-name">{{ prescriptionViewData.doctor_name || '—' }}</span> -->
</div>
</div>
@@ -2545,8 +2450,7 @@ import {
Wallet,
User
} from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import {
prescriptionOrderAuditPayment,
prescriptionOrderAuditPrescription,
@@ -2566,14 +2470,12 @@ import {
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderLinkPayOrder,
prescriptionOrderRequestCompletion,
prescriptionOrderSubmitGancaoRecipel,
prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail,
getDoctors,
getAssistants
} from '@/api/tcm'
import { formatDietaryTaboo } from './components/prescription-order-utils'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import { getDictData } from '@/api/app'
@@ -2846,6 +2748,9 @@ function handleExpressCompanyTabClick(value: '' | 'sf' | 'jd') {
}
const queryParams = reactive({
/** 创建时间区间(与列表筛选一致;不影响「今日」统计) */
start_time: '' as string,
end_time: '' as string,
order_no: '',
prescription_id: '' as string | number,
/** 诊单患者姓名/手机号(后台关联 diagnosis */
@@ -2924,26 +2829,8 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
resetPage()
}
const {
time_type,
dateRange,
resolve: resolveCreateTimeRange,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
} = useListTimeFilter('all')
const handleQuery = () => {
pager.page = 1
getLists()
}
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
async function fetchLists(params: Record<string, unknown>) {
const [start_time, end_time] = resolveCreateTimeRange()
const p: Record<string, unknown> = { ...params, start_time, end_time }
const p: Record<string, unknown> = { ...params }
if (p.prescription_id === '' || p.prescription_id === undefined) {
delete p.prescription_id
} else {
@@ -3097,7 +2984,9 @@ const listStats = computed(() => {
scopeHint =
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
}
const headPrefix = time_type.value === 'all' ? '全部' : time_type.value === 'today' ? '今日' : '区间'
const hasCustomTime =
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
const headPrefix = hasCustomTime ? '区间' : '今日'
return {
order: n(o),
pay: n(pay),
@@ -3168,7 +3057,8 @@ watch(
function handleReset() {
activeFocusKey.value = ''
resetTimeFilter()
queryParams.start_time = ''
queryParams.end_time = ''
queryParams.order_no = ''
queryParams.prescription_id = ''
queryParams.patient_keyword = ''
@@ -3398,8 +3288,7 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
}
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
const fs = Number(row.fulfillment_status)
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
return canCompleteRow(row)
}
function canQuickTrackRow(row: { fulfillment_status?: number }) {
@@ -3434,11 +3323,11 @@ const h5FilterCollapsed = ref(true)
const h5ActiveFilterCount = computed(() => {
const q = queryParams as any
const keys = [
'order_no', 'prescription_id',
'start_time', 'end_time', 'order_no', 'prescription_id',
'patient_keyword', 'express_keyword', 'doctor_id',
'assistant_dept_id', 'assistant_id'
]
let n = time_type.value !== 'all' ? 1 : 0
let n = 0
for (const k of keys) {
const v = q[k]
if (v === null || v === undefined) continue
@@ -3532,14 +3421,10 @@ const detailLinkedPayOrders = computed(() => {
return Array.isArray(arr) ? arr : []
})
/** 需代收:优先用关联时记录的固定快照值;历史单(无快照)回退实时计算 总金额 关联已付总额 */
/** 需代收:业务订单总金额 关联已付总额 */
const detailAgencyToCollect = computed(() => {
const d = detailData.value
if (!d) return 0
const snap = d.agency_collect_amount
if (snap !== null && snap !== undefined && snap !== '') {
return Number(snap) || 0
}
const total = Number(d.amount) || 0
const paid = Number(d.linked_pay_paid_total) || 0
return Math.round((total - paid) * 100) / 100
@@ -3584,8 +3469,6 @@ const detailPrescription = computed(() => {
return p && typeof p === 'object' ? p : null
})
const detailDietaryText = computed(() => formatDietaryTaboo(detailPrescription.value?.dietary_taboo))
function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
@@ -3733,8 +3616,8 @@ const updateAmountRules: FormRules = {
{
validator: (_rule, value, callback) => {
const num = Number(value)
if (!Number.isFinite(num) || num < 0) {
callback(new Error('订单金额不能为负数'))
if (!Number.isFinite(num) || num <= 0) {
callback(new Error('订单金额必须大于0'))
return
}
callback()
@@ -3886,8 +3769,7 @@ function openUpdateAmount() {
return
}
updateAmountForm.id = Number(row?.id) || 0
const amt = Number(row?.amount)
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
updateAmountForm.amount = Number(row?.amount) || undefined
updateAmountVisible.value = true
nextTick(() => updateAmountFormRef.value?.clearValidate())
}
@@ -4085,7 +3967,7 @@ const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
.filter((segs) => segs.length > 0)
})
const editPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string; order_type?: number | string; remark?: string; create_time?: string; payment_method?: string; create_type?: string }>>([])
const editPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string; order_type?: number | string; remark?: string; create_time?: string }>>([])
const editPaidOrdersLoading = ref(false)
const editDepositMin = ref(0)
@@ -4101,26 +3983,6 @@ function formatPoCategory(t: unknown) {
return m[Number(t)] || '未知'
}
function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
const createType = String(row?.create_type || '')
if (createType === 'wechat_work') return '企业微信对外收款'
if (createType === 'fubei') return '付呗'
if (createType === 'express_cod') return '快递代收'
const paymentMethod = String(row?.payment_method || '')
const methodMap: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
wechat_work: '企业微信',
fubei: '付呗',
express_cod: '快递代收',
manual: '手动确认到账'
}
if (paymentMethod && methodMap[paymentMethod]) {
return methodMap[paymentMethod]
}
return '普通订单'
}
const editLinkedPaidTotal = computed(() => {
const ids = new Set(editForm.pay_order_ids.map((x) => Number(x)))
let s = 0
@@ -4747,7 +4609,6 @@ const completeOrderStatusOptions = [
{ value: 7, label: '进行中' },
{ value: 8, label: '暂不制药' },
{ value: 9, label: '拒收' },
{ value: 10, label: '退款' },
{ value: 11, label: '保留药方' },
{ value: 12, label: '制药缓发' }
] as const
@@ -4756,98 +4617,10 @@ const completeOrderDialogVisible = ref(false)
const completeOrderId = ref(0)
const completeFulfillmentStatus = ref<number>(3)
const completeOrderSubmitting = ref(false)
const completeOrderLinkedPays = ref<any[]>([])
const completeRefundFormRef = ref<FormInstance>()
function calcRefundableFromLinkedPays(list: any[]) {
let sum = 0
for (const o of list || []) {
const st = Number(o?.status)
if (st === 2 || st === 5) sum += Number(o?.amount) || 0
}
return Math.round(sum * 100) / 100
}
const completeRefundForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
const completeOrderRefundMax = ref(0)
const completeOrderRefundTotal = computed(() => calcRefundableFromLinkedPays(completeOrderLinkedPays.value))
const completeRefundRules = computed<FormRules>(() => ({
refund_amount: [
{ required: true, message: '请输入退款金额', trigger: 'change' },
{
validator: (_r, v, cb) => {
const n = Number(v)
const cap = completeOrderRefundMax.value
if (!Number.isFinite(n) || n <= 0) return cb(new Error('退款金额须大于 0'))
if (cap > 0 && n > cap + 0.009) return cb(new Error(`不能超过 ¥${formatMoney(cap)}`))
cb()
},
trigger: 'change'
}
],
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}))
function onCompleteOrderDialogClosed() {
completeOrderId.value = 0
completeOrderLinkedPays.value = []
completeRefundForm.reason = ''
completeRefundForm.refund_amount = undefined
completeOrderRefundMax.value = 0
completeRefundFormRef.value?.clearValidate()
}
async function loadCompleteOrderPayContext(orderId: number) {
if (!orderId) {
completeOrderLinkedPays.value = []
return
}
try {
const r: any = await prescriptionOrderDetail({ id: orderId })
const d = r?.data ?? r ?? null
completeOrderLinkedPays.value = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
const linkedMax = calcRefundableFromLinkedPays(completeOrderLinkedPays.value)
const paid = Number(d?.paid) || 0
completeOrderRefundMax.value = linkedMax > 0 ? linkedMax : paid
completeRefundForm.refund_amount = completeOrderRefundMax.value
} catch {
completeOrderLinkedPays.value = []
completeOrderRefundMax.value = 0
}
}
async function submitCompleteOrder() {
const id = completeOrderId.value
if (!id) return
if (completeFulfillmentStatus.value === 10) {
const valid = await completeRefundFormRef.value?.validate().catch(() => false)
if (!valid) return
completeOrderSubmitting.value = true
try {
await prescriptionOrderRefund({
id,
reason: completeRefundForm.reason.trim(),
refund_amount: Number(completeRefundForm.refund_amount)
})
feedback.msgSuccess('退款成功')
completeOrderDialogVisible.value = false
getLists()
if (detailVisible.value && detailData.value?.id === id) {
try {
const r: any = await prescriptionOrderDetail({ id })
const nd = r?.data ?? r ?? null
if (nd) detailData.value = nd
await fetchLogs(id)
} catch { /* 静默 */ }
}
} catch {
/* 拦截器已提示 */
} finally {
completeOrderSubmitting.value = false
}
return
}
completeOrderSubmitting.value = true
try {
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
@@ -4872,57 +4645,21 @@ const refundOrderDialogVisible = ref(false)
const refundOrderId = ref(0)
const refundOrderSubmitting = ref(false)
const refundOrderFormRef = ref<FormInstance>()
const refundOrderMaxAmount = ref(0)
const refundOrderForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
const refundOrderAlertTitle = computed(() => {
const cap = formatMoney(refundOrderMaxAmount.value)
const amt = formatMoney(refundOrderForm.refund_amount)
if (Number(refundOrderForm.refund_amount) >= refundOrderMaxAmount.value - 0.009) {
return `全额退款 ¥${cap}:关联收款标记已退款,已付清零。`
}
return `部分退款 ¥${amt}(上限 ¥${cap}`
})
const refundOrderRules = computed<FormRules>(() => ({
refund_amount: [
{ required: true, message: '请输入退款金额', trigger: 'change' },
{
validator: (_r, v, cb) => {
const n = Number(v)
const cap = refundOrderMaxAmount.value
if (!Number.isFinite(n) || n <= 0) return cb(new Error('退款金额须大于 0'))
if (cap > 0 && n > cap + 0.009) return cb(new Error(`不能超过 ¥${formatMoney(cap)}`))
cb()
},
trigger: 'change'
}
],
const refundOrderForm = reactive({ reason: '' })
const refundOrderRules: FormRules = {
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
}))
}
function onRefundOrderDialogClosed() {
refundOrderId.value = 0
refundOrderForm.reason = ''
refundOrderForm.refund_amount = undefined
refundOrderMaxAmount.value = 0
refundOrderFormRef.value?.clearValidate()
}
async function openRefundOrder(row: { id: number }) {
function openRefundOrder(row: { id: number }) {
refundOrderId.value = row.id
refundOrderForm.reason = ''
refundOrderMaxAmount.value = 0
refundOrderDialogVisible.value = true
try {
const r: any = await prescriptionOrderDetail({ id: row.id })
const d = r?.data ?? r ?? null
const linked = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
const linkedMax = calcRefundableFromLinkedPays(linked)
const paid = Number(d?.paid) || 0
refundOrderMaxAmount.value = linkedMax > 0 ? linkedMax : paid
refundOrderForm.refund_amount = refundOrderMaxAmount.value
} catch {
refundOrderMaxAmount.value = 0
}
}
async function submitRefundOrder() {
@@ -4932,11 +4669,7 @@ async function submitRefundOrder() {
if (!valid) return
refundOrderSubmitting.value = true
try {
await prescriptionOrderRefund({
id,
reason: refundOrderForm.reason.trim(),
refund_amount: Number(refundOrderForm.refund_amount)
})
await prescriptionOrderRefund({ id, reason: refundOrderForm.reason.trim() })
feedback.msgSuccess('退款成功')
refundOrderDialogVisible.value = false
getLists()
@@ -4962,17 +4695,8 @@ const addPayOrderRowId = ref(0)
const addPayOrderFormRef = ref<FormInstance>()
const addPayOrderAvailableLoading = ref(false)
const addPayOrderAvailableList = ref<any[]>([])
const addPayOrderAlertTitle = computed(() => {
if (addPayOrderForm.add_mode === 'completion_only') {
return '不创建或关联支付单,仅提交完单申请,由审核人员在支付审核时处理。'
}
if (addPayOrderForm.add_mode === 'create_express') {
return '通过快递代收创建新支付单(流转同付呗),或关联已存在但未绑定的支付单。'
}
return '可以通过付呗创建新支付单,或关联已存在但未绑定的支付单。'
})
const addPayOrderForm = reactive({
add_mode: 'create' as 'create' | 'create_express' | 'link' | 'completion_only',
add_mode: 'create' as 'create' | 'link',
order_type: 3,
pay_amount: undefined as number | undefined,
pay_remark: '',
@@ -4980,44 +4704,38 @@ const addPayOrderForm = reactive({
completion_request: 0
})
const addPayOrderRules = computed<FormRules>(() => {
const rules: FormRules = {
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }]
}
if (addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express') {
rules.order_type = [{ required: true, message: '请选择费用类别', trigger: 'change' }]
rules.pay_amount = [
{ required: true, message: '请输入支付金额', trigger: 'blur' },
{
validator: (_rule, v, cb) => {
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) {
cb(new Error('金额须大于 0'))
} else {
cb()
}
},
trigger: 'blur'
}
]
} else if (addPayOrderForm.add_mode === 'link') {
rules.link_pay_order_ids = [
{
required: true,
message: '请至少选择一个支付单',
trigger: 'change',
validator: (_rule, v, cb) => {
if (!Array.isArray(v) || v.length === 0) {
cb(new Error('请至少选择一个支付单'))
} else {
cb()
}
const addPayOrderRules: FormRules = {
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }],
order_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }],
pay_amount: [
{ required: true, message: '请输入支付金额', trigger: 'blur' },
{
validator: (_rule, v, cb) => {
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) {
cb(new Error('金额须大于 0'))
} else {
cb()
}
},
trigger: 'blur'
}
],
link_pay_order_ids: [
{
required: true,
message: '请至少选择一个支付单',
trigger: 'change',
validator: (_rule, v, cb) => {
if (!Array.isArray(v) || v.length === 0) {
cb(new Error('请至少选择一个支付单'))
} else {
cb()
}
}
]
}
return rules
})
}
]
}
async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: number[] = []) {
if (!diagnosisId) {
@@ -5065,22 +4783,14 @@ async function submitAddPayOrder() {
await addPayOrderFormRef.value.validate()
addPayOrderSaving.value = true
try {
if (addPayOrderForm.add_mode === 'completion_only') {
await prescriptionOrderRequestCompletion({ id: addPayOrderRowId.value })
feedback.msgSuccess('完单申请已提交,请等待支付审核')
} else if (
addPayOrderForm.add_mode === 'create' ||
addPayOrderForm.add_mode === 'create_express'
) {
// /
if (addPayOrderForm.add_mode === 'create') {
//
await prescriptionOrderAddPayOrder({
id: addPayOrderRowId.value,
order_type: addPayOrderForm.order_type,
pay_amount: addPayOrderForm.pay_amount!,
pay_remark: addPayOrderForm.pay_remark || '',
completion_request: addPayOrderForm.completion_request,
pay_create_type:
addPayOrderForm.add_mode === 'create_express' ? 'express_cod' : 'fubei'
completion_request: addPayOrderForm.completion_request
})
feedback.msgSuccess('支付单已新增,请等待审核')
} else {
@@ -5120,10 +4830,7 @@ async function submitAddPayOrder() {
function confirmComplete(row: { id: number }) {
completeOrderId.value = row.id
completeFulfillmentStatus.value = 3
completeRefundForm.reason = ''
completeOrderLinkedPays.value = []
completeOrderDialogVisible.value = true
void loadCompleteOrderPayContext(row.id)
}
//
@@ -5195,7 +4902,12 @@ const slipHerbsList = computed(() => {
return Array.isArray(h) ? h : []
})
const slipDietaryText = computed(() => formatDietaryTaboo(prescriptionViewData.value?.dietary_taboo))
const slipDietaryText = computed(() => {
const d = prescriptionViewData.value?.dietary_taboo
if (Array.isArray(d)) return d.filter(Boolean).join('、')
if (typeof d === 'string' && d.trim()) return d.trim()
return ''
})
const slipChuwanVisible = computed(() => {
const d = prescriptionViewData.value
@@ -5618,12 +5330,6 @@ async function downloadPrescriptionSlipPdf() {
</script>
<style scoped lang="scss">
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.prescription-order-page {
margin: 0 auto;
+7 -7
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<div class="code-generation">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="表名称">
<el-input v-model="formData.table_name" clearable @keyup.enter="resetPage" />
@@ -13,8 +13,8 @@
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel>
</el-card>
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
<div class="flex">
<data-table
v-perms="['tools.generator/selectTable']"
@@ -126,10 +126,10 @@
</el-table-column>
</el-table>
</div>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
<code-preview
v-if="previewState.show"
v-model="previewState.show"
+1 -1
View File
@@ -866,7 +866,7 @@ onUnmounted(() => {
font-size: 15px;
font-weight: 700;
color: #fff;
background: linear-gradient(145deg, #0d9488 0%, #0891b2 100%);
background: linear-gradient(145deg, #6366f1 0%, #8b5cf6 100%);
flex-shrink: 0;
}
+5 -3
View File
@@ -231,7 +231,8 @@ import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
import feedback from '@/utils/feedback'
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { adminLists } from '@/api/perms/admin'
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { ElMessageBox } from 'element-plus'
dayjs.extend(isoWeek)
@@ -490,9 +491,10 @@ async function removeSegment(row: RosterSegment) {
const loadDoctors = async () => {
try {
const res = await doctorLists({
const res = await adminLists({
page_no: 1,
page_size: 1000
page_size: 1000,
role_id: 1
})
tableData.value = (res?.lists || []).map((doctor: any) => ({
doctorId: doctor.id,
+11 -33
View File
@@ -1,11 +1,11 @@
<template>
<div class="error">
<div class="error-panel">
<div>
<slot name="content">
<div class="error-code">{{ code }}</div>
</slot>
<div class="error-title">{{ title }}</div>
<el-button v-if="showBtn" type="primary" size="large" @click="router.go(-1)">
<div class="text-lg text-tx-secondary mt-7 mb-7">{{ title }}</div>
<el-button v-if="showBtn" type="primary" @click="router.go(-1)">
{{ second }} 秒后返回上一页
</el-button>
</div>
@@ -43,38 +43,16 @@ onUnmounted(() => {
<style lang="scss" scoped>
.error {
text-align: center;
min-height: 100vh;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: var(--el-bg-color-page);
padding: 24px;
}
.error-panel {
width: min(480px, 100%);
padding: 48px 32px;
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
box-shadow: var(--el-box-shadow);
}
.error-code {
@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;
.error-code {
@apply text-primary;
font-size: 150px;
}
.el-button {
width: 176px;
}
}
</style>
+9 -9
View File
@@ -1,7 +1,7 @@
<template>
<div class="fans-management">
<!-- 搜索区域 -->
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="formData" inline>
<el-form-item class="w-[280px]" label="姓名">
<el-input
@@ -36,14 +36,14 @@
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
</el-card>
<!-- 列表区域 -->
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-card class="!border-none mt-4" shadow="never">
<div class="mb-4">
<el-button type="primary" @click="handleAdd">新增粉丝</el-button>
</template>
<el-table :data="pager.lists" size="large">
</div>
<el-table :data="pager.lists" size="large" v-loading="pager.loading">
<el-table-column label="ID" prop="id" width="70" />
<el-table-column label="姓名" prop="name" min-width="100" />
<el-table-column label="手机号" prop="phone" min-width="130" />
@@ -81,10 +81,10 @@
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
<!-- 新增/编辑粉丝弹窗 -->
<el-dialog
+6 -228
View File
@@ -283,15 +283,7 @@
<td class="col-name">{{ r.name }}</td>
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_count) }}</td>
<td class="col-assign" @click.stop="onLeaderboardAssignCellClick(r)">
<span
v-if="Number(r.assign_count ?? 0) > 0"
class="yeji-lead-cell--link"
>{{ formatLeaderboardInt(r.assign_count ?? 0) }}</span>
<template v-else>{{
formatLeaderboardInt(r.assign_count ?? 0)
}}</template>
</td>
<td class="col-assign">{{ formatLeaderboardInt(r.assign_count ?? 0) }}</td>
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
<span
v-if="Number(r.appointment_count ?? 0) > 0"
@@ -398,7 +390,7 @@
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<b>tcm_diagnosis_assign_log</b> 成功指派<b>to_assistant_id &gt; 0</b><b>剔除勾选继承的指派</b><b>指派操作时间</b>落在所选区间内医助 × 诊单去重剔除已删诊单复诊接诊率页面同口径<b>被指派医助</b>归属展示部门<template v-if="tb.channel_name">
<b>tcm_diagnosis_assign_log</b> 行数成功指派<b>to_assistant_id &gt; 0</b>** <b>related_po_create_time</b>** 落在所选区间内与日志表快照一致诊单手动指派已写入该字段<b>被指派医助</b>归属展示部门<template v-if="tb.channel_name">
选定渠道时收窄与{{ tb.channel_name }}业绩同口径标签诊单或 channels EXISTS<br />
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>
</template>
@@ -569,13 +561,7 @@
>{{ formatInt(r.lead_count) }}</span>
<template v-else>{{ formatInt(r.lead_count) }}</template>
</td>
<td @click.stop="onAssignCountCellClick($event, tb, r)">
<span
v-if="yejiAssignCountCellClickable(r)"
class="yeji-lead-cell--link"
>{{ formatInt(r.assign_count ?? 0) }}</span>
<template v-else>{{ formatInt(r.assign_count ?? 0) }}</template>
</td>
<td>{{ formatInt(r.assign_count ?? 0) }}</td>
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
<span
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
@@ -1370,59 +1356,6 @@
/>
</div>
</el-dialog>
<el-dialog
v-model="assignLinesDialogVisible"
width="min(960px, 96vw)"
destroy-on-close
class="yeji-leadlines-dialog"
>
<template #header>
<div class="yeji-unassigned-dialog__head">
<span class="yeji-unassigned-dialog__title">被指派明细</span>
<p class="yeji-unassigned-dialog__sub">{{ assignLinesSubtitle }}</p>
</div>
</template>
<p v-if="assignLinesApiNote" class="yeji-unassigned-dialog__note">{{ assignLinesApiNote }}</p>
<div v-loading="assignLinesLoading" class="yeji-unassigned-dialog__body">
<el-table
v-if="assignLinesRows.length > 0 || assignLinesLoading"
:data="assignLinesRows"
size="small"
stripe
border
max-height="440"
class="yeji-unassigned-dialog__table"
:empty-text="assignLinesLoading ? '加载中…' : '暂无数据'"
>
<el-table-column type="index" label="#" width="46" :index="assignLinesIndexMethod" />
<el-table-column prop="diagnosis_id" label="诊单ID" width="88" />
<el-table-column prop="patient_name" label="患者" min-width="100" show-overflow-tooltip />
<el-table-column prop="patient_phone" label="联系电话" min-width="120" show-overflow-tooltip />
<el-table-column prop="assistant_name" label="被指派医助" min-width="108" show-overflow-tooltip />
<el-table-column prop="assign_count" label="指派次数" width="88" align="right" />
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="156" show-overflow-tooltip />
</el-table>
<el-empty
v-if="!assignLinesLoading && assignLinesRows.length === 0"
description="暂无被指派明细"
/>
<div v-if="assignLinesCount > 0" class="yeji-leadlines-dialog__pager">
<el-pagination
:current-page="assignLinesPage"
:page-size="assignLinesPageSize"
background
layout="total, sizes, prev, pager, next, jumper"
:total="assignLinesCount"
:page-sizes="[10, 20, 50, 100]"
:disabled="assignLinesLoading"
:hide-on-single-page="false"
@current-change="onAssignLinesPageChange"
@size-change="onAssignLinesPageSizeChange"
/>
</div>
</div>
</el-dialog>
</div>
</template>
@@ -1443,7 +1376,6 @@ import {
yejiStatsLeadLines,
yejiStatsAppointmentLines,
yejiStatsRevisitBreakdown,
yejiStatsAssignLines,
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
import { prescriptionOrderLists } from '@/api/tcm'
@@ -1559,17 +1491,6 @@ interface YejiLeadLineRow {
external_contact_name: string
}
interface YejiAssignLineRow {
diagnosis_id: number
patient_name: string
patient_phone: string
assistant_id: number
assistant_name: string
assign_count: number
last_assign_time: number
last_assign_time_text: string
}
interface YejiRow {
dept_id: number
dept_name: string
@@ -1788,31 +1709,6 @@ type AppointmentLinesCtx =
}
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
const assignLinesDialogVisible = ref(false)
const assignLinesLoading = ref(false)
const assignLinesSubtitle = ref('')
const assignLinesApiNote = ref('')
const assignLinesRows = ref<YejiAssignLineRow[]>([])
const assignLinesCount = ref(0)
const assignLinesPage = ref(1)
const assignLinesPageSize = ref(20)
type AssignLinesCtx =
| {
mode: 'assistant'
assistant_id: number
name: string
start_date: string
end_date: string
}
| {
mode: 'dept'
dept_id: number
dept_name: string
start_date: string
end_date: string
}
const assignLinesContext = ref<AssignLinesCtx | null>(null)
const appointmentLinesShowChannelColumn = computed(() =>
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
)
@@ -3297,124 +3193,6 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
void openLeadLinesDialog(tb, row)
}
function yejiAssignCountCellClickable(r: YejiRow): boolean {
return r.dept_id > 0 && Number(r.assign_count ?? 0) > 0
}
function onAssignCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
if (!yejiAssignCountCellClickable(row)) {
return
}
ev.stopPropagation()
void openAssignLinesDialogForDept(tb, row)
}
function assignLinesIndexMethod(index: number) {
return (assignLinesPage.value - 1) * assignLinesPageSize.value + index + 1
}
function buildAssignLinesRequestParams(page: number, pageSize: number): Record<string, string | number> | null {
const ctx = assignLinesContext.value
if (!ctx) {
return null
}
const p: Record<string, string | number> = {
start_date: ctx.start_date,
end_date: ctx.end_date,
page,
page_size: pageSize,
}
if (ctx.mode === 'dept') {
p.dept_id = ctx.dept_id
} else {
p.assistant_id = ctx.assistant_id
}
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
return p
}
async function fetchAssignLinesPage() {
const params = buildAssignLinesRequestParams(assignLinesPage.value, assignLinesPageSize.value)
if (!params) {
return
}
assignLinesLoading.value = true
try {
const res: any = await yejiStatsAssignLines(params as any)
assignLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
assignLinesCount.value = Number(res?.count ?? 0)
assignLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载被指派明细失败')
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesApiNote.value = ''
} finally {
assignLinesLoading.value = false
}
}
async function openAssignLinesDialogForDept(tb: YejiTable, row: YejiRow) {
assignLinesContext.value = {
mode: 'dept',
dept_id: row.dept_id,
dept_name: row.dept_name,
start_date: tb.start_date,
end_date: tb.end_date,
}
assignLinesSubtitle.value = `${row.dept_name} · ${tb.start_date} ${tb.end_date}`
assignLinesApiNote.value = ''
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesPage.value = 1
assignLinesPageSize.value = 20
assignLinesDialogVisible.value = true
await fetchAssignLinesPage()
}
function onLeaderboardAssignCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
return
}
if (Number(r.assign_count ?? 0) <= 0) {
return
}
const lb = leaderboardBlock.value
assignLinesContext.value = {
mode: 'assistant',
assistant_id: r.admin_id,
name: r.name,
start_date: lb.start_date,
end_date: lb.end_date,
}
assignLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ${lb.end_date}`
assignLinesApiNote.value = ''
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesPage.value = 1
assignLinesPageSize.value = 20
assignLinesDialogVisible.value = true
void fetchAssignLinesPage()
}
function onAssignLinesPageChange(p: number) {
assignLinesPage.value = p
void fetchAssignLinesPage()
}
function onAssignLinesPageSizeChange(size: number) {
assignLinesPageSize.value = size
assignLinesPage.value = 1
void fetchAssignLinesPage()
}
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
{ key: 'event_time_text', label: '进线时间' },
{ key: 'reception_admin_name', label: '接待' },
@@ -3704,7 +3482,7 @@ function getYejiDeptComboOption(tb: YejiTable) {
const cats = rows.map(r =>
r.dept_name.length > 10 ? `${r.dept_name.slice(0, 10)}` : r.dept_name
)
const palette = ['#64748b', '#38bdf8', '#0d9488']
const palette = ['#64748b', '#38bdf8', '#6366f1']
const series: any[] = [
{
name: '进线',
@@ -4029,8 +3807,8 @@ onMounted(async () => {
.yeji-page {
min-width: 0;
--yj-brand: #0d9488;
--yj-brand-soft: #f0fdfa;
--yj-brand: #6366f1;
--yj-brand-soft: #eef2ff;
--yj-teal: #0ea5e9;
--yj-teal-soft: #f0f9ff;
--yj-accent: #f43f5e;
+17 -50
View File
@@ -14,13 +14,13 @@
</el-card>
<el-card class="!border-none" shadow="never">
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<list-time-filter
v-model:time-type="time_type"
v-model:date-range="dateRange"
@time-type-change="onTimeTypeChange"
@custom-change="onCustomDateChange"
/>
<el-form class="mb-[-16px]" :model="queryParams" inline>
<el-form-item label="日期范围">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="自媒体渠道">
<el-select
v-model="queryParams.media_channel_code"
@@ -67,11 +67,11 @@
v-model="queryParams.remark"
placeholder="备注 / 创建人 / 修改人"
clearable
@keyup.enter="handleQuery"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
@@ -148,9 +148,7 @@
<script lang="ts" setup name="accountCostList">
import { accountCostDelete, accountCostLists } from '@/api/finance'
import { usePaging } from '@/hooks/usePaging'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import feedback from '@/utils/feedback'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import EditPopup from './edit.vue'
@@ -174,40 +172,18 @@ const editRef = shallowRef<InstanceType<typeof EditPopup>>()
const showEdit = ref(false)
const queryParams = reactive({
start_date: '',
end_date: '',
media_channel_code: '',
dept_id: '',
remark: '',
})
const {
time_type,
dateRange,
resolve,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
} = useListTimeFilter('today')
const { pager, getLists } = usePaging({
fetchFun: async (params: Record<string, any>) => {
const [start_date, end_date] = resolve()
return accountCostLists({
...params,
start_date,
end_date,
})
},
const { pager, getLists, resetPage } = usePaging({
fetchFun: accountCostLists,
params: queryParams,
})
const handleQuery = () => {
pager.page = 1
getLists()
}
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
const options = pager.extend.media_channel_options
if (!Array.isArray(options)) return []
@@ -282,27 +258,18 @@ const handleDelete = async (id: number) => {
}
const handleReset = () => {
resetTimeFilter()
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.media_channel_code = ''
queryParams.dept_id = ''
queryParams.remark = ''
pager.page = 1
pager.size = 15
getLists()
resetPage()
}
onMounted(() => {
getLists()
})
getLists()
</script>
<style scoped lang="scss">
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.account-cost-channel-select {
:deep(.channel-opt-row) {
display: flex;
+7 -7
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-alert
type="warning"
title="温馨提示:用户账户变动记录"
@@ -38,9 +38,9 @@
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
<el-table size="large" :data="pager.lists">
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="用户账号" prop="account" min-width="100" />
<el-table-column label="用户昵称" min-width="160">
<template #default="{ row }">
@@ -71,10 +71,10 @@
<el-table-column label="来源单号" prop="source_sn" min-width="100" />
<el-table-column label="记录时间" prop="create_time" min-width="120" />
</el-table>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup name="balanceDetail">
+7 -7
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-alert
type="warning"
title="温馨提示:用户充值记录"
@@ -54,9 +54,9 @@
/>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
<el-table size="large" :data="pager.lists">
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="用户信息" min-width="160">
<template #default="{ row }">
<div class="flex items-center">
@@ -104,10 +104,10 @@
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup name="rechargeRecord">
+7 -7
View File
@@ -20,7 +20,7 @@
</div>
</div>
</el-card>
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px] mt-[16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="退款单号">
<el-input
@@ -69,8 +69,8 @@
/> -->
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane
v-for="(item, index) in tabLists"
@@ -78,7 +78,7 @@
:name="index"
:key="index"
>
<el-table size="large" :data="pager.lists">
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="退款单号" prop="sn" min-width="190" />
<el-table-column label="用户信息" min-width="160">
<template #default="{ row }">
@@ -140,10 +140,10 @@
</el-tab-pane>
</el-tabs>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
<refund-log v-model="showRefundLog" :refund-id="selectRefundId" />
</div>
</template>
+5 -5
View File
@@ -1,14 +1,14 @@
<template>
<div>
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-alert
type="warning"
title="温馨提示:平台配置在各个场景下的通知发送方式和内容模板"
:closable="false"
show-icon
></el-alert>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<el-tabs v-model="tabsActive" @tab-change="getLists">
<el-tab-pane
v-for="(item, index) in tabsMap"
@@ -18,7 +18,7 @@
lazy
></el-tab-pane>
</el-tabs>
<el-table size="large" :data="pager.lists" >
<el-table size="large" :data="pager.lists" v-loading="pager.loading">
<el-table-column label="通知场景" prop="scene_name" min-width="120" />
<el-table-column label="通知类型" prop="type_desc" min-width="160" />
<el-table-column label="短信通知" min-width="80">
@@ -44,7 +44,7 @@
</template>
</el-table-column>
</el-table>
</admin-page-data-panel>
</el-card>
</div>
</template>
<script lang="ts" setup name="notice">
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-data-panel v-loading="state.loading">
<el-card class="!border-none" shadow="never" v-loading="state.loading">
<el-table size="large" :data="state.lists">
<el-table-column label="短信渠道" prop="name" min-width="120" />
<el-table-column label="状态" min-width="120">
@@ -22,7 +22,7 @@
</template>
</el-table-column>
</el-table>
</admin-page-data-panel>
</el-card>
<edit-popup ref="editRef" @success="getLists" />
</div>
</template>
@@ -33,11 +33,13 @@ import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
//
const state = reactive({
loading: false,
lists: []
})
//
const getLists = async () => {
try {
state.loading = true
+26 -64
View File
@@ -1,7 +1,7 @@
<!-- 订单列表 -->
<template>
<div>
<admin-page-filter-panel>
<div class="order-list">
<el-card class="!border-none" shadow="never">
<!-- 搜索表单 -->
<el-form class="ls-form" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="订单号">
@@ -106,10 +106,10 @@
</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
</el-card>
<!-- Tab 筛选 + 今日收益 + 数据表格 -->
<admin-page-data-panel v-loading="pager.loading">
<!-- Tab 筛选 + 今日收益 -->
<el-card class="!border-none mt-4" shadow="never">
<div class="flex items-center justify-between mb-4">
<el-tabs v-model="patientAssociationTab" @tab-change="handleTabChange">
<el-tab-pane label="全部" name="" />
@@ -122,7 +122,10 @@
<span class="text-gray-400">{{ todayRevenue.count }} </span>
</div>
</div>
</el-card>
<!-- 数据表格 -->
<el-card class="!border-none mt-4" shadow="never">
<div v-perms="['order.order/zhipai']" class="mb-3 flex items-center gap-3">
<el-button type="primary" :disabled="!selectedOrderIds.length" @click="openAssignDialog">
将创建人指给医助
@@ -133,6 +136,7 @@
</div>
<el-table
ref="orderTableRef"
v-loading="pager.loading"
:data="pager.lists"
row-key="id"
size="large"
@@ -170,9 +174,9 @@
</template>
</el-table-column>
<el-table-column label="支付方式" width="120">
<el-table-column label="支付方式" width="100">
<template #default="{ row }">
{{ getCreateTypeText(row) }}
{{ getPaymentMethodText(row.payment_method) }}
</template>
</el-table-column>
@@ -216,7 +220,7 @@
编辑
</el-button>
<el-button
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && row.payment_method === 'fubei')"
v-perms="['order.order/split']"
type="primary"
link
@@ -233,7 +237,7 @@
小程序码
</el-button>
<el-button
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
v-perms="['order.order/pay']"
type="success"
link
@@ -251,7 +255,7 @@
退款
</el-button>
<el-button
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
v-perms="['order.order/cancel']"
type="danger"
link
@@ -279,10 +283,10 @@
</el-table-column>
</el-table>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
<!-- 详情弹窗 -->
<el-dialog
@@ -323,7 +327,7 @@
<span class="text-red-500 font-semibold">¥{{ detailData.amount }}</span>
</el-descriptions-item>
<el-descriptions-item label="支付方式">
{{ getCreateTypeText(detailData) }}
{{ getPaymentMethodText(detailData.payment_method) }}
</el-descriptions-item>
<el-descriptions-item label="支付时间">
{{ detailData.payment_time || '-' }}
@@ -430,7 +434,7 @@
type="warning"
:closable="false"
class="mb-3"
title="该单为待审核(付呗/快递代收),请选择实际到账方式后确认「已支付」"
title="该单为付呗·待审核,请选择实际到账方式后确认「已支付」"
/>
<el-form-item v-if="!payForm.fubeiPendingAudit" label="支付类型" required>
<el-radio-group v-model="payForm.payType">
@@ -498,7 +502,6 @@
<el-radio label="normal">普通订单</el-radio>
<el-radio label="wechat_work">企业微信对外收款</el-radio>
<el-radio label="fubei">付呗</el-radio>
<el-radio label="express_cod">快递代收</el-radio>
</el-radio-group>
</el-form-item>
<el-alert
@@ -521,20 +524,7 @@
<template #title>付呗</template>
通过付呗收款的支付单创建后请按实际对账/审核流程在列表中处理
</el-alert>
<el-alert
v-if="createForm.createType === 'express_cod'"
type="info"
:closable="false"
show-icon
class="mb-4"
>
<template #title>快递代收</template>
通过快递代收的支付单流转与付呗一致审核通过/确认到账后将更新为已支付
</el-alert>
<el-form-item
v-if="createForm.createType === 'fubei' || createForm.createType === 'express_cod'"
label="支付单审核"
>
<el-form-item v-if="createForm.createType === 'fubei'" label="支付单审核">
<el-switch
v-model="createForm.requirePaymentSlipAudit"
active-text="申请审核"
@@ -990,8 +980,8 @@ const patientLoading = ref(false)
const patientList = ref<any[]>([])
const createForm = reactive({
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei' | 'express_cod',
/** 「付呗」「快递代收」:开启后订单为待审核(5) */
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei',
/** 「付呗」:开启后订单为待审核(5) */
requirePaymentSlipAudit: false,
patient_id: '',
order_type: '',
@@ -1158,8 +1148,7 @@ const submitCreateOrder = async () => {
patient_id: createForm.patient_id,
order_type: createForm.order_type,
amount: createForm.amount,
remark: createForm.remark,
create_type: 'wechat_work'
remark: createForm.remark
}
const res: any = await orderCreateForWechatWork(params)
const orderNo = res?.order_no ?? res?.data?.order_no
@@ -1171,23 +1160,18 @@ const submitCreateOrder = async () => {
patient_id: createForm.patient_id,
order_type: createForm.order_type,
amount: createForm.amount,
remark: createForm.remark,
create_type: createForm.createType
remark: createForm.remark
}
if (createForm.createType === 'fubei') {
params.payment_channel = 'fubei'
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
} else if (createForm.createType === 'express_cod') {
params.payment_channel = 'express_cod'
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
} else {
params.payment_channel = 'normal'
params.require_payment_slip_audit = 0
}
await orderCreate(params)
const tip =
(createForm.createType === 'fubei' || createForm.createType === 'express_cod') &&
createForm.requirePaymentSlipAudit
createForm.createType === 'fubei' && createForm.requirePaymentSlipAudit
? '订单已创建,支付状态为「待审核」'
: '订单创建成功'
feedback.msgSuccess(tip)
@@ -1263,27 +1247,6 @@ const getPaymentMethodText = (method: string | null) => {
return methodMap[method] || '未知'
}
// / create_type 退
const getCreateTypeText = (row: any) => {
const createTypeMap: Record<string, string> = {
normal: '普通订单',
wechat_work: '企业微信对外收款',
fubei: '付呗',
express_cod: '快递代收'
}
const ct = row?.create_type
if (ct === 'wechat_work' || ct === 'fubei' || ct === 'express_cod') {
return createTypeMap[ct]
}
if (ct === 'normal' && row?.payment_method) {
return getPaymentMethodText(row.payment_method)
}
if (ct && createTypeMap[ct]) {
return createTypeMap[ct]
}
return getPaymentMethodText(row?.payment_method)
}
//
const handleEditOrder = (row: any) => {
editOrderForm.value = {
@@ -1410,8 +1373,7 @@ const handleDetail = async (row: any) => {
//
const handlePay = (row: any) => {
const fubeiPending =
row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod')
const fubeiPending = row.status === 5 && row.payment_method === 'fubei'
payForm.value = {
order_id: row.id,
order_no: row.order_no,
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<div class="department">
<el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="部门名称" prop="name">
<el-input
@@ -22,21 +22,22 @@
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="loading">
<template #toolbar>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<el-button v-perms="['dept.dept/add']" type="primary" @click="handleAdd()">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
<el-button @click="handleExpand">展开/折叠</el-button>
</template>
<el-button @click="handleExpand"> 展开/折叠 </el-button>
</div>
<el-table
ref="tableRef"
class="mt-4"
size="large"
v-loading="loading"
:data="lists"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
@@ -67,6 +68,7 @@
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="排序" prop="sort" min-width="100" />
<el-table-column label="更新时间" prop="update_time" min-width="180" />
<el-table-column label="操作" width="160" fixed="right">
@@ -99,7 +101,7 @@
</template>
</el-table-column>
</el-table>
</admin-page-data-panel>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
@@ -118,18 +120,14 @@ let isExpand = false
const loading = ref(false)
const lists = ref<any[]>([])
const queryParams = reactive({
name: '',
status: ''
status: '',
name: ''
})
const showEdit = ref(false)
const getLists = async () => {
loading.value = true
try {
lists.value = await deptLists(queryParams)
} finally {
loading.value = false
}
lists.value = await deptLists(queryParams)
loading.value = false
}
const resetParams = () => {
+10 -11
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<div class="post-lists">
<el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
<el-form-item class="w-[280px]" label="岗位编码">
<el-input
@@ -31,18 +31,17 @@
/>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<el-button v-perms="['dept.jobs/add']" type="primary" @click="handleAdd()">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
</template>
<el-table size="large" :data="pager.lists">
</div>
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
<el-table-column label="岗位编码" prop="code" min-width="100" />
<el-table-column label="岗位名称" prop="name" min-width="100" />
<el-table-column label="排序" prop="sort" min-width="100" />
@@ -76,10 +75,10 @@
</template>
</el-table-column>
</el-table>
<template #footer>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
@@ -1,15 +1,6 @@
<template>
<div class="note-timeline-wrap">
<div v-if="!readonly && diagnosisId" class="timeline-actions">
<el-button
v-if="hasAddDoctorNotePermission"
type="primary"
plain
size="small"
@click="showNoteDialog = true"
>
添加备注
</el-button>
<material-picker
v-model="tongueModel"
:limit="99"
@@ -104,32 +95,16 @@
</div>
</div>
<el-empty v-if="!notes.length && readonly" description="暂无备注" :image-size="48" />
<el-dialog v-model="showNoteDialog" title="添加备注" width="480px" append-to-body>
<el-input
v-model="noteContent"
type="textarea"
:rows="4"
placeholder="输入今日备注..."
maxlength="500"
show-word-limit
/>
<template #footer>
<el-button @click="showNoteDialog = false">取消</el-button>
<el-button type="primary" :loading="noteSaving" @click="handleSaveNote">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { CircleClose, Document } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import useAppStore from '@/stores/modules/app'
import { addDoctorNote, deleteDoctorNoteImage } from '@/api/patient'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
export interface DoctorNoteItem {
id: number
@@ -147,13 +122,6 @@ const props = defineProps<{
const emit = defineEmits<{ refresh: [] }>()
/** 与接诊台悬浮「备注」按钮一致 */
const hasAddDoctorNotePermission = computed(() => hasPermission(['doctor.appointment/addDoctorNote']))
const showNoteDialog = ref(false)
const noteContent = ref('')
const noteSaving = ref(false)
const appStore = useAppStore()
const getImageUrl = (url: string) => appStore.getImageUrl(url)
@@ -233,30 +201,6 @@ watch(() => props.notes, () => {
prevReportCount.value = 0
})
const handleSaveNote = async () => {
if (!props.diagnosisId) {
feedback.msgWarning('当前无诊单,无法添加备注')
return
}
const text = noteContent.value.trim()
if (!text) {
feedback.msgWarning('请输入备注内容')
return
}
noteSaving.value = true
try {
await addDoctorNote({ diagnosis_id: props.diagnosisId, content: text })
feedback.msgSuccess('备注保存成功')
noteContent.value = ''
showNoteDialog.value = false
emit('refresh')
} catch (e: any) {
feedback.msgError(e?.message || '保存失败')
} finally {
noteSaving.value = false
}
}
const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'report_files', imagePath: string) => {
try {
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' })
@@ -273,7 +217,6 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
.timeline-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 12px;
+1 -1
View File
@@ -1275,7 +1275,7 @@ onUnmounted(() => {
.float-action.edit {
color: #fff;
background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%);
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
}
.float-action.edit:hover,
+73 -73
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<div class="admin">
<el-card class="!border-none" shadow="never">
<el-form class="mb-[-16px]" :model="formData" inline>
<el-form-item class="w-[280px]" label="管理员账号">
<el-input
@@ -40,78 +40,77 @@
/>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
</template>
<el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" />
<el-table-column label="头像" min-width="100">
<template #default="{ row }">
<el-avatar :size="50" :src="row.avatar"></el-avatar>
</template>
</el-table-column>
<el-table-column label="账号" prop="account" min-width="100" />
<el-table-column label="名称" prop="name" min-width="100" />
<el-table-column
label="角色"
prop="role_name"
min-width="100"
show-tooltip-when-overflow
/>
<el-table-column
label="部门"
prop="dept_name"
min-width="100"
show-tooltip-when-overflow
/>
<el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="最近登录时间" prop="login_time" min-width="180" />
<el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
<template #default="{ row }">
<el-switch
v-if="row.root != 1"
v-model="row.disable"
:active-value="0"
:inactive-value="1"
@change="changeStatus(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['auth.admin/edit']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-if="row.root != 1"
v-perms="['auth.admin/delete']"
type="danger"
link
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
</el-card>
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
<div class="mt-4">
<el-table :data="pager.lists" size="large">
<el-table-column label="ID" prop="id" min-width="60" />>
<el-table-column label="头像" min-width="100">
<template #default="{ row }">
<el-avatar :size="50" :src="row.avatar"></el-avatar>
</template>
</el-table-column>
<el-table-column label="账号" prop="account" min-width="100" />
<el-table-column label="名称" prop="name" min-width="100" />
<el-table-column
label="角色"
prop="role_name"
min-width="100"
show-tooltip-when-overflow
/>
<el-table-column
label="部门"
prop="dept_name"
min-width="100"
show-tooltip-when-overflow
/>
<el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="最近登录时间" prop="login_time" min-width="180" />
<el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
<template #default="{ row }">
<el-switch
v-if="row.root != 1"
v-model="row.disable"
:active-value="0"
:inactive-value="1"
@change="changeStatus(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['auth.admin/edit']"
type="primary"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-if="row.root != 1"
v-perms="['auth.admin/delete']"
type="danger"
link
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex mt-4 justify-end">
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
@@ -126,6 +125,7 @@ import feedback from '@/utils/feedback'
import EditPopup from './edit.vue'
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
//
const formData = reactive({
account: '',
name: '',
+8 -6
View File
@@ -1,18 +1,20 @@
<template>
<div>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<div class="menu-lists">
<el-card class="!border-none" shadow="never">
<div>
<el-button v-perms="['auth.menu/add']" type="primary" @click="handleAdd()">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
<el-button @click="handleExpand">展开/折叠</el-button>
</template>
<el-button @click="handleExpand"> 展开/折叠 </el-button>
</div>
<el-table
ref="tableRef"
class="mt-4"
size="large"
v-loading="pager.loading"
:data="pager.lists"
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
@@ -85,7 +87,7 @@
</template>
</el-table-column>
</el-table>
</admin-page-data-panel>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
+59 -54
View File
@@ -1,64 +1,68 @@
<template>
<div>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
<div class="role-lists">
<el-card class="!border-none" shadow="never">
<div>
<el-button v-perms="['auth.role/add']" type="primary" @click="handleAdd">
<template #icon>
<icon name="el-icon-Plus" />
</template>
新增
</el-button>
</template>
<el-table :data="pager.lists" size="large">
<el-table-column prop="id" label="ID" min-width="100" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column
prop="desc"
label="备注"
min-width="150"
show-overflow-tooltip
/>
<el-table-column prop="sort" label="排序" min-width="100" />
<el-table-column label="数据范围" min-width="140">
<template #default="{ row }">
{{ dataScopeLabel(row.data_scope) }}
</template>
</el-table-column>
<el-table-column prop="num" label="管理员人数" min-width="100" />
<el-table-column prop="create_time" label="创建时间" min-width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
v-perms="['auth.role/edit']"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
link
type="primary"
v-perms="['auth.role/edit']"
@click="handleAuth(row)"
>
分配权限
</el-button>
<el-button
v-perms="['auth.role/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table :data="pager.lists" size="large">
<el-table-column prop="id" label="ID" min-width="100" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column
prop="desc"
label="备注"
min-width="150"
show-overflow-tooltip
/>
<el-table-column prop="sort" label="排序" min-width="100" />
<el-table-column label="数据范围" min-width="140">
<template #default="{ row }">
{{ dataScopeLabel(row.data_scope) }}
</template>
</el-table-column>
<el-table-column prop="num" label="管理员人数" min-width="100" />
<el-table-column prop="create_time" label="创建时间" min-width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
v-perms="['auth.role/edit']"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
link
type="primary"
v-perms="['auth.role/edit']"
@click="handleAuth(row)"
>
分配权限
</el-button>
<el-button
v-perms="['auth.role/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
<auth-popup v-if="showAuth" ref="authRef" @success="getLists" @close="showAuth = false" />
</div>
@@ -111,6 +115,7 @@ const dataScopeLabel = (scope: number | string | null | undefined) => {
)
}
//
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除?')
await roleDelete({ id })
+57 -54
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<div class="dict-type">
<el-card class="!border-none" shadow="never">
<el-page-header class="mb-4" content="数据管理" @back="$router.back()" />
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="字典名称">
@@ -28,10 +28,9 @@
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<el-button
v-perms="['setting.dict.dict_data/add']"
type="primary"
@@ -53,54 +52,58 @@
</template>
删除
</el-button>
</template>
<el-table
:data="pager.lists"
size="large"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" />
<el-table-column label="数据名称" prop="name" min-width="120" />
<el-table-column label="数据值" prop="value" min-width="120" />
<el-table-column label="状态">
<template v-slot="{ row }">
<el-tag v-if="row.status == 1">正常</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column
label="备注"
prop="remark"
min-width="120"
show-tooltip-when-overflow
/>
<el-table-column label="排序" prop="sort" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['setting.dict.dict_data/edit']"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['setting.dict.dict_data/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table
:data="pager.lists"
size="large"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" />
<el-table-column label="数据名称" prop="name" min-width="120" />
<el-table-column label="数据值" prop="value" min-width="120" />
<el-table-column label="状态">
<template v-slot="{ row }">
<el-tag v-if="row.status == 1">正常</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column
label="备注"
prop="remark"
min-width="120"
show-tooltip-when-overflow
/>
<el-table-column label="排序" prop="sort" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['setting.dict.dict_data/edit']"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['setting.dict.dict_data/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
+69 -65
View File
@@ -1,6 +1,6 @@
<template>
<div>
<admin-page-filter-panel>
<div class="dict-type">
<el-card class="!border-none" shadow="never">
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
<el-form-item class="w-[280px]" label="字典名称">
<el-input v-model="queryParams.name" clearable @keyup.enter="resetPage" />
@@ -20,10 +20,9 @@
<el-button @click="resetParams">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
<admin-page-data-panel v-loading="pager.loading">
<template #toolbar>
</el-card>
<el-card class="!border-none mt-4" shadow="never">
<div>
<el-button
v-perms="['setting.dict.dict_type/add']"
type="primary"
@@ -45,65 +44,69 @@
</template>
删除
</el-button>
</template>
<el-table
:data="pager.lists"
size="large"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" />
<el-table-column label="字典名称" prop="name" min-width="120" />
<el-table-column label="字典类型" prop="type" min-width="120" />
<el-table-column label="状态">
<template v-slot="{ row }">
<el-tag v-if="row.status == 1">正常</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" show-tooltip-when-overflow />
<el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['setting.dict.dict_type/edit']"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['setting.dict.dict_data/lists']"
type="primary"
link
>
<router-link
:to="{
path: getRoutePath('setting.dict.dict_data/lists'),
query: {
id: row.id
}
}"
>
数据管理
</router-link>
</el-button>
<el-button
v-perms="['setting.dict.dict_type/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<pagination v-model="pager" @change="getLists" />
</template>
</admin-page-data-panel>
</div>
<div class="mt-4" v-loading="pager.loading">
<div>
<el-table
:data="pager.lists"
size="large"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="id" />
<el-table-column label="字典名称" prop="name" min-width="120" />
<el-table-column label="字典类型" prop="type" min-width="120" />
<el-table-column label="状态">
<template v-slot="{ row }">
<el-tag v-if="row.status == 1">正常</el-tag>
<el-tag v-else type="danger">停用</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" show-tooltip-when-overflow />
<el-table-column label="创建时间" prop="create_time" min-width="180" />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button
v-perms="['setting.dict.dict_type/edit']"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-perms="['setting.dict.dict_data/lists']"
type="primary"
link
>
<router-link
:to="{
path: getRoutePath('setting.dict.dict_data/lists'),
query: {
id: row.id
}
}"
>
数据管理
</router-link>
</el-button>
<el-button
v-perms="['setting.dict.dict_type/delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</div>
</el-card>
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
</div>
</template>
@@ -148,6 +151,7 @@ const handleEdit = async (data: any) => {
editRef.value?.setFormData(data)
}
//
const handleDelete = async (id: any[] | number) => {
await feedback.confirm('确定要删除?')
await dictTypeDelete({ id })
+62 -155
View File
@@ -1,6 +1,6 @@
<template>
<div class="conversion-stats-page">
<admin-page-filter-panel>
<el-card class="!border-none" shadow="never">
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<el-form-item label="统计维度">
<el-segmented
@@ -89,7 +89,7 @@
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</admin-page-filter-panel>
</el-card>
<div class="stats-kpi-grid">
<div
@@ -129,7 +129,7 @@
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>{{ amountPieTitle }}</span>
<span>诊单金额占比</span>
<span class="card-hint">TOP 10</span>
</div>
</template>
@@ -146,17 +146,17 @@
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>{{ secondaryPieTitle }}</span>
<span>加粉占比</span>
<span class="card-hint">TOP 10</span>
</div>
</template>
<v-charts
v-if="secondaryPieHasData"
v-if="fanPieHasData"
class="stats-chart"
:option="secondaryPieOption"
:option="fanPieOption"
autoresize
/>
<el-empty v-else :description="secondaryPieEmptyText" />
<el-empty v-else description="暂无加粉数据" />
</el-card>
</el-col>
</el-row>
@@ -311,13 +311,10 @@ const overview = reactive<Record<string, any>>({
amounts: [],
order_counts: [],
fan_counts: [],
rois: [],
appointment_counts: [],
interview_counts: []
rois: []
},
amount_share: [],
fan_share: [],
order_share: []
fan_share: []
}
})
@@ -395,7 +392,7 @@ const currentEntityId = computed({
}
})
const defaultSummaryCards: MetricCard[] = [
const summaryCards: MetricCard[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
@@ -413,15 +410,7 @@ const defaultSummaryCards: MetricCard[] = [
{ key: 'roi', label: 'ROI', type: 'ratio' }
]
const doctorSummaryCards: MetricCard[] = [
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
{ key: 'interview_count', label: '面诊数', type: 'count' },
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
{ key: 'completed_order_amount', label: '开药金额', type: 'money' },
{ key: 'receive_rate', label: '接诊率', type: 'percent' }
]
const defaultTableColumns: MetricColumn[] = [
const tableColumns: MetricColumn[] = [
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
@@ -442,18 +431,6 @@ const defaultTableColumns: MetricColumn[] = [
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
]
const doctorTableColumns: MetricColumn[] = [
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
{ key: 'interview_count', label: '面诊数', type: 'count' },
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
{ key: 'completed_order_amount', label: '开药金额', type: 'money', minWidth: 120 },
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 }
]
const isDoctorDimension = computed(() => queryParams.dimension === 'doctor')
const summaryCards = computed(() => isDoctorDimension.value ? doctorSummaryCards : defaultSummaryCards)
const tableColumns = computed(() => isDoctorDimension.value ? doctorTableColumns : defaultTableColumns)
const dateRangeText = computed(() => {
if (!overview.date_range?.length) return '未选择'
return `${overview.date_range[0]}${overview.date_range[1]}`
@@ -461,14 +438,7 @@ const dateRangeText = computed(() => {
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
const secondaryPieHasData = computed(() =>
isDoctorDimension.value
? (overview.charts.order_share?.length || 0) > 0
: (overview.charts.fan_share?.length || 0) > 0
)
const amountPieTitle = computed(() => isDoctorDimension.value ? '开药金额占比' : '诊单金额占比')
const secondaryPieTitle = computed(() => isDoctorDimension.value ? '接诊单数占比' : '加粉占比')
const secondaryPieEmptyText = computed(() => isDoctorDimension.value ? '暂无接诊数据' : '暂无加粉数据')
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
const rankingChartData = computed(() => {
const ranking = overview.charts?.ranking || {}
@@ -478,110 +448,56 @@ const rankingChartData = computed(() => {
return {
names,
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
appointmentCounts: normalize(Array.isArray(ranking.appointment_counts) ? ranking.appointment_counts : []),
interviewCounts: normalize(Array.isArray(ranking.interview_counts) ? ranking.interview_counts : []),
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
}
})
const rankingChartOption = computed(() => {
const common = {
tooltip: { trigger: 'axis' },
grid: { left: 48, right: 24, top: 48, bottom: 48 },
xAxis: {
type: 'category',
data: rankingChartData.value.names,
axisLabel: {
interval: 0,
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
}
},
yAxis: [
{ type: 'value', name: '金额 / 数量' }
]
}
if (!isDoctorDimension.value) {
return {
...common,
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
series: [
{
name: '诊单金额',
type: 'bar',
barMaxWidth: 36,
data: rankingChartData.value.amounts,
itemStyle: { color: '#4a78ff' }
},
{
name: '接诊诊单',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.orderCounts,
itemStyle: { color: '#15b8a6' }
},
{
name: '加粉数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.fanCounts,
itemStyle: { color: '#ff9f43' }
}
]
const rankingChartOption = computed(() => ({
tooltip: { trigger: 'axis' },
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
grid: { left: 48, right: 24, top: 48, bottom: 48 },
xAxis: {
type: 'category',
data: rankingChartData.value.names,
axisLabel: {
interval: 0,
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
}
}
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' }
}
]
}
})
},
yAxis: [
{ type: 'value', name: '金额 / 数量' }
],
series: [
{
name: '诊单金额',
type: 'bar',
barMaxWidth: 36,
data: rankingChartData.value.amounts,
itemStyle: { color: '#4a78ff' }
},
{
name: '接诊诊单',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.orderCounts,
itemStyle: { color: '#15b8a6' }
},
{
name: '加粉数',
type: 'line',
smooth: true,
showSymbol: true,
symbolSize: 8,
lineStyle: { width: 3 },
data: rankingChartData.value.fanCounts,
itemStyle: { color: '#ff9f43' }
}
]
}))
const amountPieOption = computed(() => ({
tooltip: { trigger: 'item' },
@@ -595,7 +511,7 @@ const amountPieOption = computed(() => ({
},
series: [
{
name: amountPieTitle.value,
name: '诊单金额',
type: 'pie',
radius: ['42%', '70%'],
center: ['50%', '42%'],
@@ -615,7 +531,7 @@ const amountPieOption = computed(() => ({
]
}))
const secondaryPieOption = computed(() => ({
const fanPieOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: {
bottom: 0,
@@ -627,7 +543,7 @@ const secondaryPieOption = computed(() => ({
},
series: [
{
name: secondaryPieTitle.value,
name: '加粉数',
type: 'pie',
radius: ['42%', '70%'],
center: ['50%', '42%'],
@@ -642,7 +558,7 @@ const secondaryPieOption = computed(() => ({
labelLayout: {
hideOverlap: true,
},
data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
data: overview.charts.fan_share
}
]
}))
@@ -742,18 +658,9 @@ const handleReset = () => {
fetchOverview()
}
const calcDoctorReceiveRate = (source: Record<string, any>) => {
const completedOrderCount = Number(source?.completed_order_count || 0)
const interviewCount = Number(source?.interview_count || 0)
if (interviewCount <= 0) return 0
return (completedOrderCount / interviewCount) * 100
}
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
if (placeholder) return '—'
const value = isDoctorDimension.value && key === 'receive_rate'
? calcDoctorReceiveRate(source)
: source?.[key] ?? 0
const value = source?.[key] ?? 0
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
if (type === 'ratio') return Number(value || 0).toFixed(2)
@@ -1,644 +0,0 @@
<template>
<div class="revisit-rate-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="rate-filter-form">
<el-form-item label="统计月份">
<el-date-picker
v-model="month"
type="month"
value-format="YYYY-MM"
placeholder="选择月份"
:clearable="false"
:disabled-date="disableFutureMonth"
style="width: 160px"
@change="loadData"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="deptId"
:data="deptTreeOptions"
placeholder="全部部门"
clearable
filterable
check-strictly
node-key="id"
:default-expand-all="true"
:props="deptTreeProps"
style="width: 220px"
@change="loadData"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
<el-button :disabled="loading" @click="handleReset">重置</el-button>
</el-form-item>
<el-form-item>
<el-popover placement="bottom-start" :width="500" trigger="hover">
<template #reference>
<span class="rate-caliber-trigger">
<el-icon><InfoFilled /></el-icon>
统计口径
</span>
</template>
<div class="rate-caliber">
<p>
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重
</p>
<p>
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序的全局序号<b>跨月累计不重置</b> 5 月指派后旗下成交 4 单为二诊~五诊下月再成交即为六诊
</p>
<p>
<b>当月 N 诊单数</b>当月内下单且诊次为 N 的订单数归属下单时点<b>持有该患者的医助</b>指派可在往月释放后不再归属继承指派会转移持有人但不计被指派数
</p>
<p>
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数往月指派当月成交会推高分子比率可能超过 100%医助当月无新指派但旗下有成交时被指派数为 0比率显示
</p>
<p>
医助按人事部门归组选定部门时含其组织下级
</p>
</div>
</el-popover>
</el-form-item>
</el-form>
</el-card>
<div class="rate-kpi-grid">
<div
v-for="card in summaryCards"
:key="card.key"
class="rate-kpi-card"
:class="{ 'is-clickable': card.clickable }"
@click="card.clickable && card.onClick && card.onClick()"
>
<div class="rate-kpi-label">{{ card.label }}</div>
<div class="rate-kpi-value">{{ card.value }}</div>
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
</div>
</div>
<el-card class="!border-none mt-4" shadow="never">
<template #header>
<div class="rate-card-header">
<span>部门 · 医助明细{{ monthText }}</span>
<span class="rate-card-hint">点击被指派数 / N诊单数可查看具体诊单与订单底栏合计的被指派数按诊单去重</span>
</div>
</template>
<el-table
v-loading="loading"
:data="rows"
size="small"
border
row-key="row_key"
:tree-props="{ children: 'children' }"
:default-expand-all="true"
show-summary
:summary-method="getSummaries"
>
<el-table-column label="部门 / 医助" min-width="200" fixed>
<template #default="{ row }">
<span v-if="row.is_dept" class="rate-dept-cell">
{{ row.dept_name }}
<span class="rate-dept-count">{{ row.assistant_count }} </span>
</span>
<span v-else>{{ row.assistant_name }}</span>
</template>
</el-table-column>
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
<template #default="{ row }">
<el-link
v-if="canViewAssignLines && row.assigned_count > 0"
type="primary"
:underline="false"
@click="openAssignDetail(row)"
>
{{ row.assigned_count }}
</el-link>
<span v-else>{{ row.assigned_count }}</span>
</template>
</el-table-column>
<template v-for="slot in visitSlots" :key="slot">
<el-table-column
:prop="`visit${slot}_count`"
:label="`${slotLabel(slot)}单数`"
min-width="96"
align="right"
>
<template #header>
<span>{{ slotLabel(slot) }}单数</span>
<el-tooltip
effect="dark"
placement="top"
:content="`当月内下单、诊次为第 ${slot} 次的订单数(诊次跨月累计;归属下单时点持有患者的医助)`"
>
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<el-link
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
type="primary"
:underline="false"
@click="openOrderDetail(slot, row)"
>
{{ row[`visit${slot}_count`] }}
</el-link>
<span v-else>{{ row[`visit${slot}_count`] }}</span>
</template>
</el-table-column>
<el-table-column
:prop="`visit${slot}_rate`"
:label="`${slotLabel(slot)}接诊率`"
min-width="96"
align="right"
>
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
</el-table-column>
</template>
</el-table>
</el-card>
<!-- 下钻明细 -->
<el-dialog
v-model="detailVisible"
:title="detailTitle"
width="980px"
top="6vh"
destroy-on-close
>
<el-table
v-loading="detailLoading"
:data="detailRows"
size="small"
border
stripe
max-height="60vh"
>
<template v-if="detailType === 'assign'">
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
<el-link
v-if="canViewPatientDetail && row.diagnosis_id"
type="primary"
:underline="false"
@click="openPatientDetail(row.diagnosis_id)"
>
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
</el-link>
<span v-else>{{ row.patient_name || '' }}</span>
</template>
</el-table-column>
<el-table-column prop="patient_phone" label="联系电话" min-width="120">
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
</el-table-column>
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
</template>
<template v-else>
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link
v-if="canViewOrderDetail && row.order_id"
type="primary"
:underline="false"
@click="openOrderInfo(row.order_id)"
>
{{ row.order_no || `#${row.order_id}` }}
</el-link>
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
</template>
</el-table-column>
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
<el-table-column prop="patient_name" label="患者" min-width="100">
<template #default="{ row }">
<el-link
v-if="canViewPatientDetail && row.diagnosis_id"
type="primary"
:underline="false"
@click="openPatientDetail(row.diagnosis_id)"
>
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
</el-link>
<span v-else>{{ row.patient_name || '' }}</span>
</template>
</el-table-column>
<el-table-column prop="amount" label="金额" width="100" align="right">
<template #default="{ row }">¥ {{ row.amount }}</template>
</el-table-column>
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
</template>
</el-table>
<div class="rate-detail-footer"> {{ detailRows.length }} </div>
</el-dialog>
<!-- 订单详情抽屉共享组件与处方订单列表详情一致只读 -->
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
<!-- 诊单患者详情编辑页只读模式 -->
<diagnosis-edit-popup ref="diagnosisEditRef" />
</div>
</template>
<script setup lang="ts" name="revisitRateStatsPage">
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
import {
revisitRateAssignLines,
revisitRateDeptOptions,
revisitRateOverview,
revisitRateVisitOrderLines
} from '@/api/stats'
import { hasPermission } from '@/utils/perm'
/** 业务订单详情抽屉(共享组件,readonly 模式,与 order_list.vue 详情同源) */
const OrderDetailDrawer = defineAsyncComponent(
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
)
/** 诊单编辑弹窗(只读打开) */
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
interface MetricPack extends Record<string, any> {
assigned_count: number
}
interface StatsRow extends MetricPack {
row_key: string
is_dept: 0 | 1
dept_id: number
dept_name: string
assistant_id?: number
assistant_name?: string
assistant_count?: number
children?: StatsRow[]
}
interface DeptTreeNode {
id: number
name: string
pid: number
children?: DeptTreeNode[]
}
const canViewAssignLines = hasPermission(['stats.revisitRate/assignLines'])
const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
const visitSlots = ref<number[]>([2, 3, 4])
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const slotLabel = (slot: number) => {
if (slot >= 2 && slot <= 10) {
return `${cnDigits[slot]}`
}
if (slot > 10 && slot < 20) {
return `${cnDigits[slot - 10]}`
}
return `${slot}`
}
const initNow = new Date()
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
const month = ref<string>(currentMonth)
const deptId = ref<number | undefined>(undefined)
const loading = ref(false)
const rows = ref<StatsRow[]>([])
const total = ref<MetricPack | null>(null)
const deptTreeOptions = ref<DeptTreeNode[]>([])
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const monthText = computed(() => {
const [y, m] = month.value.split('-')
return y && m ? `${y}${Number(m)}` : month.value
})
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
const baseQuery = () => ({
month: month.value,
dept_ids: deptId.value ? String(deptId.value) : ''
})
interface SummaryCard {
key: string
label: string
value: string
sub: string
clickable: boolean
onClick?: () => void
}
const summaryCards = computed<SummaryCard[]>(() => {
const t = total.value
const cards: SummaryCard[] = [
{
key: 'assigned',
label: '当月被指派总数',
value: t ? String(t.assigned_count) : '—',
sub: '',
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
onClick: () => openAssignDetail(null)
}
]
for (const slot of visitSlots.value) {
const cnt = t ? t[`visit${slot}_count`] : null
const rate = t ? t[`visit${slot}_rate`] : null
cards.push({
key: `visit${slot}`,
label: `当月${slotLabel(slot)}接诊率`,
value: t ? formatRate(rate) : '—',
sub: t ? `${slotLabel(slot)}下单 ${cnt}` : '',
clickable: !!(canViewOrderLines && cnt > 0),
onClick: () => openOrderDetail(slot, null)
})
}
return cards
})
/** 底栏合计:直接展示后端按诊单去重后的合计,不做各行求和 */
const getSummaries = ({ columns }: { columns: any[] }) => {
const t = total.value
return columns.map((col, index) => {
if (index === 0) {
return '合计'
}
if (!t) {
return ''
}
const prop = String(col.property || '')
if (prop === 'assigned_count') {
return String(t.assigned_count)
}
if (/^visit\d+_count$/.test(prop)) {
return String(t[prop] ?? '')
}
if (/^visit\d+_rate$/.test(prop)) {
return formatRate(t[prop])
}
return ''
})
}
const disableFutureMonth = (date: Date) => {
const now = new Date()
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
}
const loadData = async () => {
loading.value = true
try {
const res = await revisitRateOverview(baseQuery())
rows.value = res?.rows ?? []
total.value = res?.total ?? null
if (Array.isArray(res?.slots) && res.slots.length) {
visitSlots.value = res.slots.map((s: any) => Number(s)).filter((s: number) => s >= 2)
}
if (res?.month) {
month.value = res.month
}
} finally {
loading.value = false
}
}
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
const map = new Map<number, DeptTreeNode>()
for (const d of flat) {
map.set(d.id, { ...d, children: [] })
}
const roots: DeptTreeNode[] = []
for (const d of flat) {
const node = map.get(d.id)!
if (d.pid > 0 && map.has(d.pid)) {
map.get(d.pid)!.children!.push(node)
} else {
roots.push(node)
}
}
const strip = (n: DeptTreeNode) => {
if (n.children?.length) {
n.children.forEach(strip)
} else {
delete n.children
}
}
roots.forEach(strip)
return roots
}
const loadDeptOptions = async () => {
try {
const res = await revisitRateDeptOptions()
deptTreeOptions.value = buildDeptTree(res?.rows ?? [])
} catch {
deptTreeOptions.value = []
}
}
//
const detailVisible = ref(false)
const detailType = ref<'assign' | 'orders'>('assign')
const detailTitle = ref('')
const detailLoading = ref(false)
const detailRows = ref<any[]>([])
/** row 为 null 时表示合计(当前部门筛选范围) */
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
if (!row) {
return { label: '合计' }
}
if (row.is_dept) {
return { dept_id: row.dept_id, label: row.dept_name }
}
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
}
const openAssignDetail = async (row: StatsRow | null) => {
if (!canViewAssignLines) {
return
}
const scope = rowScope(row)
detailType.value = 'assign'
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value}`
detailVisible.value = true
detailLoading.value = true
detailRows.value = []
try {
const res = await revisitRateAssignLines({
...baseQuery(),
assistant_id: scope.assistant_id,
dept_id: scope.dept_id
})
detailRows.value = res?.rows ?? []
} finally {
detailLoading.value = false
}
}
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
if (!canViewOrderLines) {
return
}
const scope = rowScope(row)
detailType.value = 'orders'
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value}`
detailVisible.value = true
detailLoading.value = true
detailRows.value = []
try {
const res = await revisitRateVisitOrderLines({
...baseQuery(),
slot,
assistant_id: scope.assistant_id,
dept_id: scope.dept_id
})
detailRows.value = res?.rows ?? []
} finally {
detailLoading.value = false
}
}
//
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
const openOrderInfo = (orderId: number) => {
if (!canViewOrderDetail || !orderId) {
return
}
orderDetailRef.value?.open(orderId)
}
//
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
const openPatientDetail = (diagnosisId: number) => {
if (!canViewPatientDetail || !diagnosisId) {
return
}
diagnosisEditRef.value?.openViewOnly(diagnosisId)
}
const handleReset = () => {
month.value = currentMonth
deptId.value = undefined
loadData()
}
onMounted(() => {
loadDeptOptions()
loadData()
})
</script>
<style lang="scss" scoped>
.revisit-rate-page {
.rate-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.rate-caliber-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
cursor: help;
}
.rate-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-top: 16px;
}
.rate-kpi-card {
background: var(--el-bg-color);
border-radius: 8px;
padding: 16px 20px;
&.is-clickable {
cursor: pointer;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: var(--el-box-shadow-light);
}
}
.rate-kpi-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.rate-kpi-value {
margin-top: 6px;
font-size: 26px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.rate-kpi-sub {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-card-header {
display: flex;
align-items: baseline;
gap: 10px;
.rate-card-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-dept-cell {
font-weight: 600;
.rate-dept-count {
margin-left: 6px;
font-weight: 400;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.rate-th-hint {
margin-left: 2px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
cursor: help;
}
}
.rate-detail-footer {
margin-top: 10px;
text-align: right;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rate-caliber {
font-size: 13px;
line-height: 1.7;
color: var(--el-text-color-regular);
p + p {
margin-top: 6px;
}
}
</style>
@@ -14,14 +14,13 @@
</el-card>
<el-card class="!border-none" shadow="never">
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
<list-time-filter
v-model:time-type="time_type"
v-model:date-range="dateRange"
@time-type-change="onTimeTypeChange"
@custom-change="onCustomDateChange"
/>
<el-form class="mb-[-16px]" :model="queryParams" inline>
<el-form-item label="日期范围">
<daterange-picker
v-model:startTime="queryParams.start_date"
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
@@ -34,7 +33,7 @@
clearable
filterable
class="w-[220px]"
@change="handleQuery"
@change="resetPage"
/>
</el-form-item>
<el-form-item label="自媒体来源">
@@ -45,7 +44,7 @@
placeholder="全部来源"
:allow-create="false"
select-class="w-[220px]"
@change="handleQuery"
@change="resetPage"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
@@ -54,11 +53,11 @@
v-model="queryParams.remark"
placeholder="备注 / 创建人"
clearable
@keyup.enter="handleQuery"
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
</el-form-item>
@@ -147,8 +146,6 @@ import feedback from '@/utils/feedback'
import CostEditPopup from './cost-edit.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
import ListTimeFilter from '@/components/list-time-filter/index.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
const router = useRouter()
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
@@ -176,48 +173,25 @@ const goSelfInput = () => {
}
const queryParams = reactive({
start_date: '',
end_date: '',
media_source: '',
dept_id: undefined as number | undefined,
remark: '',
})
const {
time_type,
dateRange,
resolve,
handleTimeTypeChange,
handleCustomDateChange,
resetTimeFilter,
} = useListTimeFilter('today')
const { pager, getLists, resetPage } = usePaging({
fetchFun: async (params: Record<string, any>) => {
const [start_date, end_date] = resolve()
return personalAccountCostLists({
...params,
start_date,
end_date,
})
},
fetchFun: personalAccountCostLists,
params: queryParams,
})
const handleQuery = () => {
pager.page = 1
getLists()
}
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
const handleReset = () => {
resetTimeFilter()
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.remark = ''
pager.page = 1
pager.size = 15
getLists()
resetPage()
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
@@ -262,10 +236,9 @@ const loadDeptOptions = async () => {
}
}
onMounted(async () => {
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
await getLists()
})
</script>
@@ -274,12 +247,6 @@ onMounted(async () => {
padding: 20px;
}
.stats-filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.dept-cell {
cursor: help;
border-bottom: 1px dashed #c0c4cc;
+1 -6
View File
@@ -556,11 +556,7 @@ const validatePhone = (rule: any, value: any, callback: any) => {
}
const validateIdCard = (rule: any, value: any, callback: any) => {
if (!value) {
callback()
return
}
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
callback(new Error('身份证号格式不正确'))
return
}
@@ -576,7 +572,6 @@ const formRules = {
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
}
@@ -56,21 +56,298 @@
</div>
</template>
<!-- 业务订单详情抽屉共享组件的 readonly 受限版展示范围由组件内 v-if="!readonly" 门控 -->
<prescription-order-detail-drawer ref="detailDrawerRef" readonly append-to-body />
<!-- 业务订单详情抽屉只读 order_list.vue 详情保持一致 -->
<el-drawer
v-model="detailVisible"
size="80%"
destroy-on-close
:close-on-click-modal="false"
append-to-body
>
<template #header>
<div class="flex items-center">
<span class="font-semibold text-lg">业务订单详情</span>
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
</div>
</template>
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
<!-- 顶部数据概览 -->
<el-row :gutter="16" class="mb-5">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">总金额</div>
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付总额</div>
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">需代收</div>
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} </div>
</el-card>
</el-col>
</el-row>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 左侧处方单不含测试价格改患者信息药材明细 -->
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">处方详情</span>
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
开方人<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
</span>
</div>
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
</div>
</template>
<template v-if="detailPrescription">
<el-descriptions :column="2" border size="small" class="bg-white">
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
</el-descriptions-item>
<el-descriptions-item label="处方金额">
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="消费者审核">
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<template v-if="detailPrescription.dosage_amount">
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</el-descriptions-item>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<div>
<span class="text-gray-500">每天次数</span>
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
</div>
<div>
<span class="text-gray-500">处方开立</span>
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
</div>
<div>
<span class="text-gray-500">订单设置</span>
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</template>
<el-empty v-else description="无处方数据" :image-size="72" />
</el-card>
<!-- 右侧未关联 + 已关联支付单 -->
<div class="flex flex-col gap-4 h-full">
<el-card shadow="never" class="border-gray-100">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium text-[15px]">未关联的收款记录</span>
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
</div>
</template>
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="无未关联的收款单" :image-size="60" />
</el-card>
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
<template #header>
<div class="flex items-center gap-3">
<span class="font-medium text-[15px]">关联收款记录</span>
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
</div>
</template>
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
</el-table-column>
<el-table-column label="金额" width="86" align="right">
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
</el-table-column>
<el-table-column label="状态" width="82" align="center">
<template #default="{ row }">
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
<el-table-column label="创建时间" min-width="136">
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="未关联收款单" :image-size="60" />
</el-card>
</div>
</div>
<!-- 履约与收货信息 -->
<el-card shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="业务订单创建人" :span="2">
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="创建人所属部门" :span="3">
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
<el-descriptions-item label="收货手机">
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
</el-descriptions-item>
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '' }}</el-descriptions-item>
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 物流轨迹只读 -->
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
<div class="flex flex-wrap items-center gap-3 mb-4">
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" class="w-[180px]" @keyup.enter="fetchLogisticsTrace" />
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
<el-option label="自动识别" value="auto" />
<el-option label="顺丰速运" value="sf" />
<el-option label="京东快递" value="jd" />
<el-option label="极兔速递" value="jt" />
</el-select>
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
</div>
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
<span class="text-gray-800">状态{{ logisticsTracePayload.state_text }}</span>
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
</div>
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
</div>
</el-card>
<!-- 操作日志 -->
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
</div>
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无操作日志" :image-size="64" />
</el-card>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, watch, ref, onMounted } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists } from '@/api/tcm'
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
import {
formatTime,
fulfillmentText,
fulfillmentTagType
} from '@/views/consumer/prescription/components/prescription-order-utils'
prescriptionOrderLists,
prescriptionOrderDetail,
prescriptionOrderLogs,
prescriptionOrderLogisticsTrace,
prescriptionOrderPaidPayOrders
} from '@/api/tcm'
import { getDictData } from '@/api/app'
import { hasPermission } from '@/utils/perm'
import feedback from '@/utils/feedback'
import useUserStore from '@/stores/modules/user'
const userStore = useUserStore()
const props = defineProps<{
diagnosisId: number
@@ -102,11 +379,175 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit'
}
// /
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
/** 诊间医助角色(与 DiagnosisLists 等一致) */
const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
function openDetail(id: number) {
detailDrawerRef.value?.open(id)
/**
* 是否展示处方审核相关字段
*/
const showPrescriptionAuditFilter = computed(() => {
const u = userStore.userInfo
if (!u || Number(u.root) === 1) return true
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
})
//
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const detailUnlinkedPayOrders = ref<any[]>([])
const detailPrescription = computed(() => {
const p = detailData.value?.prescription
return p && typeof p === 'object' ? p : null
})
const detailLinkedPayOrders = computed(() => {
const arr = detailData.value?.linked_pay_orders
return Array.isArray(arr) ? arr : []
})
const detailAgencyToCollect = computed(() => {
const d = detailData.value
if (!d) return 0
const total = Number(d.amount) || 0
const paid = Number(d.linked_pay_paid_total) || 0
return Math.round((total - paid) * 100) / 100
})
const detailRecipientRxPhoneMismatch = computed(() => {
const rx = normalizeBizPhone(detailPrescription.value?.phone)
const biz = normalizeBizPhone(detailData.value?.recipient_phone)
if (!rx || !biz) return false
return rx !== biz
})
const detailFullAddress = computed(() => {
const d = detailData.value
if (!d) return '—'
const parts = []
if (d.shipping_province) parts.push(d.shipping_province)
if (d.shipping_city) parts.push(d.shipping_city)
if (d.shipping_district) parts.push(d.shipping_district)
if (d.shipping_address) parts.push(d.shipping_address)
return parts.length > 0 ? parts.join(' ') : '—'
})
const detailOrderCreatorMetaLine = computed(() => {
const d = detailData.value
if (!d) return ''
const account = String(d.creator_account ?? '').trim()
const mobile = String(d.creator_mobile ?? '').trim()
const parts: string[] = []
if (account) parts.push(`登录账号:${account}`)
if (mobile) parts.push(`手机:${mobile}`)
return parts.join(' · ')
})
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
const raw = String(detailData.value?.order_creator_dept_path ?? detailData.value?.diagnosis_creator_dept_path ?? '').trim()
if (!raw) return [] as string[][]
return raw.split('').map((s) => s.trim()).filter(Boolean)
.map((p) => p.includes(' / ') ? p.split(' / ').map((x) => x.trim()).filter(Boolean) : p.split('/').map((x) => x.trim()).filter(Boolean))
.filter((segs) => segs.length > 0)
})
//
const detailLogisticsExpress = ref<string>('auto')
const logisticsTraceLoading = ref(false)
const logisticsTracePayload = ref<Record<string, any> | null>(null)
const logisticsTracePhoneTail = ref('')
const logisticsTraceList = computed(() => {
const p = logisticsTracePayload.value
if (!p || !Array.isArray(p.traces)) return []
return p.traces as Array<{ time: string; context: string }>
})
async function fetchLogisticsTrace() {
const id = Number(detailData.value?.id)
if (!id) return
logisticsTraceLoading.value = true
try {
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
const params: { id: number; express_company?: string; phone_tail?: string } = {
id,
express_company: detailLogisticsExpress.value
}
if (digits.length >= 4) params.phone_tail = digits
const res: any = await prescriptionOrderLogisticsTrace(params)
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
} catch {
logisticsTracePayload.value = null
} finally {
logisticsTraceLoading.value = false
}
}
//
async function openDetail(id: number) {
detailData.value = null
detailUnlinkedPayOrders.value = []
logisticsTracePayload.value = null
detailLogisticsExpress.value = 'auto'
logisticsTracePhoneTail.value = ''
detailLogs.value = []
detailVisible.value = true
detailLoading.value = true
try {
const res: any = await prescriptionOrderDetail({ id })
const d = res?.data ?? res ?? null
detailData.value = d
if (d) {
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
if (String(d.tracking_number || '').trim()) fetchLogisticsTrace()
fetchLogs(id)
if (d.diagnosis_id) {
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
}
}
} catch (e: any) {
feedback.msgError(e?.message || '加载详情失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
try {
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
const lists = res?.lists ?? res?.data?.lists ?? []
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
} catch { detailUnlinkedPayOrders.value = [] }
}
function canViewPrescriptionOrderLogs() {
const p = userStore.perms || []
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
}
async function fetchLogs(id: number) {
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
try {
const res: any = await prescriptionOrderLogs({ id })
detailLogs.value = res?.data ?? res ?? []
} catch { detailLogs.value = [] }
}
//
function normalizeBizPhone(v: unknown): string {
if (v === null || v === undefined) return ''
return String(v).replace(/\s/g, '').trim()
}
const formatAmount = (value: unknown) => {
@@ -114,6 +555,126 @@ const formatAmount = (value: unknown) => {
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
function formatMoney(v: unknown) {
const n = Number(v)
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
const formatTime = (value: unknown) => {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
const d = new Date(value * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
return String(value)
}
function formatOrderTime(v: unknown) {
if (v === null || v === undefined || v === '') return '—'
if (typeof v === 'string' && String(v).includes('-')) return String(v)
return formatTime(v)
}
function feeTypeText(t: number | undefined) {
const m: Record<number, string> = { 1: '药费', 2: '诊费', 3: '药费+诊费', 4: '其他' }
return m[Number(t)] ?? '—'
}
// order_list.vue dict server_order
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
async function loadServicePackageOptions() {
try {
const data: any = await getDictData({ type: 'server_order' })
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
servicePackageOptions.value = list.filter((item) => item.status !== 0)
} catch {
servicePackageOptions.value = []
}
}
function formatServicePackage(value: unknown): string {
if (value === null || value === undefined || value === '') return '—'
let packages: string[] = []
if (Array.isArray(value)) {
packages = value.map((v) => String(v)).filter((v) => v !== '')
} else if (typeof value === 'string') {
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
}
if (packages.length === 0) return '—'
const names = packages.map((val) => {
const opt = servicePackageOptions.value.find((o) => o.value === val)
return opt ? opt.name : val
})
return names.join('、')
}
onMounted(() => {
loadServicePackageOptions()
})
function expressCompanyLabel(v: unknown) {
const s = String(v || '').toLowerCase()
if (s === 'sf') return '顺丰速运'
if (s === 'jd') return '京东快递'
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
return '自动识别'
}
function consumerRxAuditText(s: number | undefined) {
const n = Number(s)
if (n === 1) return '已通过'
if (n === 2) return '已驳回'
return '待审核'
}
function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 1) return 'success'
if (n === 2) return 'danger'
return 'warning'
}
function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
const n = Number(s)
if (n === 2) return 'success'
if (n === 1) return 'warning'
return 'info'
}
function orderStatusText(s: number | undefined) {
const m: Record<number, string> = { 0: '待支付', 1: '待支付', 2: '已支付', 3: '已取消' }
return m[Number(s)] ?? '—'
}
const fulfillmentText = (s: number | undefined) => {
const m: Record<number, string> = { 1: '待双审通过', 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货', 6: '已签收', 7: '进行中', 8: '暂不制药', 9: '拒收', 10: '退款', 11: '保留药方', 12: '制药缓发' }
return m[Number(s)] ?? '—'
}
const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
const n = Number(s)
if (n === 3 || n === 6) return 'success'
if (n === 5) return 'primary'
if (n === 4 || n === 9 || n === 10) return 'danger'
if (n === 2 || n === 7) return 'warning'
if (n === 8 || n === 11 || n === 12) return 'info'
return 'info'
}
function logActionText(act: string) {
const m: Record<string, string> = {
create: '创建', edit: '编辑', audit_rx_approve: '处方审核', audit_rx_reject: '处方审核',
audit_pay_approve: '支付审核', audit_pay_reject: '支付审核', fill_tracking: '填快递单',
ship: '确认发货', withdraw: '撤销', link_pay_order: '关联支付单',
revoke_rx_audit: '撤回处方审核', revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单', patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额', complete: '完成订单'
}
return m[act] || act
}
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
@@ -134,4 +695,22 @@ defineExpose({ refresh: () => getLists() })
.po-empty-tip {
padding: 24px 0;
}
.audit-stamp {
position: absolute;
top: 12px;
right: 16px;
transform: rotate(15deg);
opacity: 0.15;
pointer-events: none;
z-index: 1;
}
.stamp-inner {
border: 3px solid currentColor;
border-radius: 8px;
padding: 4px 12px;
font-size: 18px;
font-weight: bold;
}
.stamp-pass { color: #22c55e; }
.stamp-reject { color: #ef4444; }
</style>
+28 -156
View File
@@ -67,66 +67,26 @@
>
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-alert
v-if="mode === 'edit' && patientBasicLocked && !canEditPatientBasicFields"
type="warning"
:closable="false"
show-icon
class="mb-3"
>
<template v-if="latestPrescriptionOrder">
最近业务订单 #{{ latestPrescriptionOrder.id }} 状态为{{ latestPrescriptionOrder.fulfillment_status_text }}患者基本信息不可修改
</template>
<template v-else>
存在未完成的业务订单患者基本信息不可修改
</template>
</el-alert>
<el-form-item label="诊单ID">
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
</el-form-item>
<fieldset
:disabled="!canEditPatientBasicFields"
class="diagnosis-patient-basic-fieldset border-0 min-w-0 p-0 m-0"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓名" prop="patient_name">
<el-input
v-model="formData.patient_name"
placeholder="请输入姓名"
:disabled="!canEditPatientBasicFields"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="身份证号" prop="id_card">
<template v-if="showIdCardMaskedEdit">
<el-input
:model-value="
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
"
readonly
placeholder="点击可查看完整身份证号"
maxlength="18"
class="cursor-pointer phone-mask-toggle"
@click="toggleIdCardReveal"
/>
<p
v-if="originalIdCard && !idCardRevealUnlocked"
class="text-xs text-gray-400 mt-1"
>
当前为脱敏显示点击输入框可查看完整号码
</p>
</template>
<el-input
v-else
v-model="formData.id_card"
placeholder="请输入身份证号"
maxlength="18"
:disabled="!canEditIdCard"
@focus="handleIdCardFocus"
@blur="handleIdCardBlur"
/>
@@ -160,7 +120,6 @@
v-model="formData.phone"
placeholder="请输入手机号"
maxlength="11"
:disabled="!canEditPatientBasicFields"
@focus="handlePhoneFocus"
@blur="handlePhoneBlur"
/>
@@ -168,7 +127,7 @@
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender" :disabled="!canEditPatientBasicFields">
<el-radio-group v-model="formData.gender">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
@@ -185,14 +144,11 @@
:max="150"
placeholder="请输入年龄"
class="w-full"
:disabled="!canEditPatientBasicFields"
:controls="canEditPatientBasicFields"
/>
</el-form-item>
</el-col>
</el-row>
</fieldset>
<!-- 生命体征 -->
@@ -822,40 +778,9 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
const latestPrescriptionOrder = ref<{
id: number
fulfillment_status: number
fulfillment_status_text: string
} | null>(null)
const canEditPatientBasicFields = computed(() => {
if (viewOnly.value) return false
if (mode.value === 'add') return true
return canEditPatientBasicFromApi.value
})
/** 编辑且无明文权限或基本信息锁定:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => {
if (mode.value !== 'edit') return false
if (!canEditPatientBasicFields.value) return true
return !hasPhonePlainPermission.value
})
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
const phoneRevealUnlocked = ref(false)
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
const showIdCardMaskedEdit = computed(() => {
if (mode.value !== 'edit') return false
if (!originalIdCard.value) return false
if (!canEditPatientBasicFields.value) return true
return !hasPhonePlainPermission.value
})
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
const canEditIdCard = computed(() => {
if (!canEditPatientBasicFields.value) return false
if (mode.value === 'add') return true
if (!originalIdCard.value) return true
return hasPhonePlainPermission.value
})
const idCardRevealUnlocked = ref(false)
const activeTab = ref('basic')
const drawerTitle = computed(() => {
if (viewOnly.value) return '诊单详情'
@@ -995,11 +920,6 @@ const togglePhoneReveal = () => {
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
}
const toggleIdCardReveal = () => {
if (!originalIdCard.value) return
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
}
const maskIdCard = (idCard: string) => {
if (!idCard) return idCard
if (idCard.length === 15) {
@@ -1070,40 +990,43 @@ const handlePhoneBlur = async () => {
}
}
// -
// -
const handleIdCardFocus = () => {
isIdCardFocused.value = true
if (!originalIdCard.value) return
if (hasPhonePlainPermission.value) {
formData.value.id_card = originalIdCard.value
return
}
if (mode.value === 'add') {
if (originalIdCard.value) {
formData.value.id_card = originalIdCard.value
}
}
//
// -
const handleIdCardBlur = async () => {
isIdCardFocused.value = false
const processIdCard = async (idCard: string) => {
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
//
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
//
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
//
const id = formData.value.id_card
const birthYear = parseInt(id.substring(6, 10))
const birthMonth = parseInt(id.substring(10, 12))
const birthDay = parseInt(id.substring(12, 14))
const today = new Date()
let age = today.getFullYear() - birthYear
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
age--
}
formData.value.age = age
//
try {
const result = await checkIdCard({
id_card: idCard,
id_card: formData.value.id_card,
id: formData.value.id || ''
})
if (result.exists) {
ElMessage.warning(result.message)
}
@@ -1111,24 +1034,8 @@ const handleIdCardBlur = async () => {
console.error('检查身份证号失败:', error)
}
}
}
if (hasPhonePlainPermission.value) {
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
await processIdCard(formData.value.id_card)
}
return
}
//
if (mode.value === 'edit' && originalIdCard.value) {
return
}
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
await processIdCard(formData.value.id_card)
//
formData.value.id_card = maskIdCard(formData.value.id_card)
}
}
@@ -1151,11 +1058,7 @@ const validatePhone = (rule: any, value: any, callback: any) => {
const validateIdCard = (rule: any, value: any, callback: any) => {
// 使
const idCardToValidate = originalIdCard.value || value
if (!idCardToValidate) {
callback()
return
}
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
callback(new Error('身份证号格式不正确'))
return
}
@@ -1170,7 +1073,6 @@ const formRules = {
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
}
@@ -1265,12 +1167,9 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
originalPhone.value = data.phone || ''
originalIdCard.value = data.id_card || ''
patientBasicLocked.value = !!data.patient_basic_locked
canEditPatientBasicFromApi.value = data.can_edit_patient_basic !== false
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
data.id_card = maskIdCard(data.id_card || '')
formData.value = data
formData.value.create_source = data.create_source ?? ''
@@ -1279,20 +1178,12 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
}
const resetPatientBasicLockState = () => {
patientBasicLocked.value = false
canEditPatientBasicFromApi.value = true
latestPrescriptionOrder.value = null
}
const open = async (type: string, id?: number) => {
viewOnly.value = false
mode.value = type
visible.value = true
activeTab.value = 'basic' //
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
//
await getDictOptions()
@@ -1315,8 +1206,6 @@ const openViewOnly = async (id: number) => {
visible.value = true
activeTab.value = 'basic'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
await getDictOptions()
await loadDiagnosisDetailIntoForm(id)
}
@@ -1341,7 +1230,6 @@ const handleSubmit = async () => {
if (result && result.id) {
mode.value = 'edit'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
formData.value.id = result.id
// patient_id
@@ -1356,9 +1244,7 @@ const handleSubmit = async () => {
formData.value.phone = hasPhonePlainPermission.value
? detail.phone || ''
: maskPhone(detail.phone || '')
formData.value.id_card = hasPhonePlainPermission.value
? detail.id_card || ''
: maskIdCard(detail.id_card || '')
formData.value.id_card = maskIdCard(detail.id_card || '')
} catch (error) {
console.error('获取详情失败:', error)
}
@@ -1376,7 +1262,7 @@ const handleSubmit = async () => {
}
const handleViewCase = (row: any) => {
prescriptionRef.value?.openById(row.id, { slipType: 'user' })
prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true })
}
//
@@ -1460,7 +1346,6 @@ const handleClose = () => {
isPhoneFocused.value = false
isIdCardFocused.value = false
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
viewOnly.value = false
visible.value = false
@@ -1485,19 +1370,6 @@ defineExpose({
}
}
.diagnosis-patient-basic-fieldset:disabled {
opacity: 1;
:deep(.el-input__wrapper),
:deep(.el-input-number),
:deep(.el-input-number__decrease),
:deep(.el-input-number__increase),
:deep(.el-radio),
:deep(.el-radio__input) {
cursor: not-allowed;
pointer-events: none;
}
}
:deep(.el-form) {
padding-bottom: 20px;
font-size: 12px;
+1 -115
View File
@@ -14,7 +14,6 @@
formData.pending_assign !== '1' &&
formData.pending_booking !== '1' &&
formData.completed_appointment !== '1' &&
!hasLatestAppointmentFilter() &&
formData.appointment_date === tab.value
}
]"
@@ -111,32 +110,6 @@
<el-select v-model="formData.assistant_id" placeholder="医助" clearable filterable size="small" @change="doSearch">
<el-option v-for="item in assistantOptions" :key="item.id" :label="item.name" :value="Number(item.id)" />
</el-select>
<daterange-picker
class="latest-appointment-range"
v-model:startTime="formData.latest_appointment_start_date"
v-model:endTime="formData.latest_appointment_end_date"
picker-type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="最近挂号开始"
end-placeholder="最近挂号结束"
@change="handleLatestAppointmentFilterChange"
/>
<el-select
v-model="formData.latest_appointment_channel_source"
placeholder="最近挂号渠道"
clearable
filterable
size="small"
class="latest-appointment-channel"
@change="handleLatestAppointmentFilterChange"
>
<el-option
v-for="item in channelOptions"
:key="String(item.value)"
:label="item.name"
:value="String(item.value)"
/>
</el-select>
<el-button size="small" @click="handleReset">重置</el-button>
</div>
</el-card>
@@ -240,9 +213,6 @@
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
</template>
<div v-if="latestAppointmentChannelText(row)" class="apt-latest-channel">
最近渠道{{ latestAppointmentChannelText(row) }}
</div>
</template>
<span v-else class="apt-none">未挂号</span>
</div>
@@ -719,7 +689,6 @@ import useUserStore from '@/stores/modules/user'
import { useRoute, useRouter } from 'vue-router'
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import dayjs from 'dayjs'
import DaterangePicker from '@/components/daterange-picker/index.vue'
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
@@ -759,9 +728,6 @@ const formData = reactive({
assistant_id: '',
start_time: '',
end_time: '',
latest_appointment_start_date: '' as string,
latest_appointment_end_date: '' as string,
latest_appointment_channel_source: '' as string,
diagnosis_confirmed: '' as '' | '0' | '1',
appointment_date: '' as string,
has_appointment: '' as '' | '0' | '1',
@@ -794,9 +760,6 @@ const setHasAppointment = (v: string) => {
formData.has_appointment = v as '' | '0' | '1'
formData.pending_booking = ''
formData.completed_appointment = ''
if (v === '0') {
clearLatestAppointmentFilters()
}
pager.page = 1
getLists()
fetchDateCounts()
@@ -860,9 +823,6 @@ function buildPendingAssignCountPayload(): Record<string, unknown> {
has_appointment: '',
pending_booking: '',
completed_appointment: '',
latest_appointment_start_date: '',
latest_appointment_end_date: '',
latest_appointment_channel_source: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
} as Record<string, unknown>) as Record<string, unknown>
}
@@ -890,9 +850,6 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
formData.assistant_id = ''
formData.start_time = ''
formData.end_time = ''
formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
@@ -929,7 +886,6 @@ const handleDateTabClick = async (value: string) => {
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
clearLatestAppointmentFilters()
formData.appointment_date = value
pager.page = 1
await getLists()
@@ -945,7 +901,6 @@ const handlePendingBookingTabClick = async () => {
formData.completed_appointment = ''
formData.appointment_date = ''
formData.has_appointment = '0'
clearLatestAppointmentFilters()
pager.page = 1
await getLists()
fetchDateCounts()
@@ -960,7 +915,6 @@ const handleCompletedVisitTabClick = async () => {
formData.completed_appointment = '1'
formData.has_appointment = ''
formData.appointment_date = ''
clearLatestAppointmentFilters()
pager.page = 1
await getLists()
fetchDateCounts()
@@ -972,7 +926,6 @@ const handlePendingAssignTabClick = async () => {
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
clearLatestAppointmentFilters()
if (!formData.pending_assign_order_month) {
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
}
@@ -1094,29 +1047,16 @@ const handleMoreCommand = (cmd: string) => {
const diagnosisTypeOptions = ref<any[]>([])
const syndromeTypeOptions = ref<any[]>([])
const assistantOptions = ref<any[]>([])
const channelOptions = ref<Array<{ name: string; value: string }>>([])
const getDictOptions = async () => {
try {
const [diagnosisType, syndromeType, channels, assistants] = await Promise.all([
const [diagnosisType, syndromeType, assistants] = await Promise.all([
getDictData({ type: 'diagnosis_type' }),
getDictData({ type: 'syndrome_type' }),
getDictData({ type: 'channels' }),
getAssistants()
])
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
syndromeTypeOptions.value = syndromeType?.syndrome_type || []
channelOptions.value = (channels?.channels || [])
.filter((row: any) => row.status !== 0)
.sort((a: any, b: any) => {
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
if (ds !== 0) return ds
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
})
.map((row: any) => ({
name: String(row?.name ?? row?.value ?? ''),
value: row?.value != null && row.value !== '' ? String(row.value) : ''
}))
assistantOptions.value = assistants || []
} catch (error) {
console.error('获取字典数据失败:', error)
@@ -1129,40 +1069,6 @@ const getDictLabel = (options: any[], value: string) => {
return item ? item.name : value || '-'
}
const clearLatestAppointmentFilters = () => {
formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = ''
}
const hasLatestAppointmentFilter = () =>
!!(
formData.latest_appointment_start_date ||
formData.latest_appointment_end_date ||
formData.latest_appointment_channel_source
)
const handleLatestAppointmentFilterChange = () => {
if (hasLatestAppointmentFilter()) {
formData.appointment_date = ''
formData.pending_booking = ''
formData.completed_appointment = ''
if (formData.has_appointment === '0') {
formData.has_appointment = ''
}
}
doSearch()
}
const latestAppointmentChannelText = (row: any) => {
const desc = String(row?.latest_appointment_channel_source_desc || '').trim()
const raw = String(row?.latest_appointment_channel_source || '').trim()
const detail = String(row?.latest_appointment_channel_source_detail || '').trim()
const base = desc || raw
if (!base) return ''
return detail ? `${base}${detail}` : base
}
//
const getAssistantName = (assistantId: number | string) => {
// assistant_id"-"
@@ -1197,9 +1103,6 @@ const handleReset = () => {
formData.diagnosis_type = ''
formData.syndrome_type = ''
formData.assistant_id = ''
formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = ''
formData.has_appointment = ''
@@ -2287,20 +2190,10 @@ onUnmounted(() => {
.filter-more {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-lighter);
.latest-appointment-range {
width: 260px;
max-width: 100%;
}
.latest-appointment-channel {
width: 170px;
}
}
.list-card {
@@ -2470,13 +2363,6 @@ onUnmounted(() => {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.apt-latest-channel {
margin-top: 6px;
font-size: 12px;
line-height: 1.35;
color: var(--el-text-color-secondary);
}
}
.status-confirmed {
+68 -103
View File
@@ -18,7 +18,7 @@
</span>
<div>
<div class="wb-section-name">订单统计</div>
<div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '-' }}</div>
<div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '' }}</div>
</div>
</div>
<div class="wb-toolbar">
@@ -821,8 +821,8 @@ onMounted(() => {
<style lang="scss" scoped>
.workbench-page {
min-height: 100%;
padding: 4px 0 24px;
background: transparent;
padding: 20px 20px 40px;
background: linear-gradient(160deg, #eef2ff 0%, #f8fafc 38%, #f1f5f9 100%);
}
.wb-hero {
@@ -830,86 +830,56 @@ onMounted(() => {
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 24px;
padding: 28px 28px;
border-radius: var(--admin-radius-xl);
border: 1px solid var(--el-border-color-lighter);
background: var(--admin-surface-glass);
backdrop-filter: blur(16px);
box-shadow: var(--el-box-shadow-light);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(120deg, rgba(6, 182, 212, 0.14) 0%, transparent 45%),
linear-gradient(300deg, rgba(16, 185, 129, 0.1) 0%, transparent 40%);
pointer-events: none;
}
&::after {
content: '';
position: absolute;
inset: 0 0 auto;
height: 3px;
background: var(--admin-brand-gradient);
}
}
.wb-hero-text {
position: relative;
z-index: 1;
gap: 16px;
margin-bottom: 22px;
padding: 22px 26px;
border-radius: 20px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.65) 100%);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06);
}
.wb-hero-title {
margin: 0 0 8px;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.03em;
background: var(--admin-brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0 0 6px;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.02em;
color: #0f172a;
}
.wb-hero-desc {
margin: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
font-size: 14px;
color: #64748b;
}
.wb-refresh-btn {
position: relative;
z-index: 1;
box-shadow: var(--admin-brand-glow);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25);
}
.wb-section {
margin-bottom: 20px;
border-radius: var(--admin-radius-xl);
background: var(--admin-surface-elevated);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow);
border-radius: 20px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.05);
overflow: hidden;
backdrop-filter: blur(10px);
}
.wb-section--diagnosis {
border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #06b6d4, #10b981) 1;
border-image: linear-gradient(90deg, #3b82f6, #60a5fa) 1;
}
.wb-section--order {
border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #0891b2, #6366f1) 1;
border-image: linear-gradient(90deg, #8b5cf6, #a78bfa) 1;
}
.wb-section--trend {
border-top: 3px solid transparent;
border-image: linear-gradient(90deg, #10b981, #06b6d4) 1;
border-image: linear-gradient(90deg, #14b8a6, #2dd4bf) 1;
}
.wb-section-head {
@@ -919,8 +889,8 @@ onMounted(() => {
justify-content: space-between;
gap: 12px;
padding: 18px 22px;
border-bottom: 1px solid var(--el-border-color-extra-light);
background: var(--admin-brand-gradient-soft);
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9) 0%, rgba(255, 255, 255, 0) 100%);
}
.wb-section-title {
@@ -933,68 +903,38 @@ onMounted(() => {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
width: 44px;
height: 44px;
border-radius: 14px;
font-size: 22px;
color: #fff;
}
.wb-section-icon--blue {
background: var(--admin-brand-gradient);
box-shadow: var(--admin-brand-glow);
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35);
}
.wb-section-icon--violet {
background: linear-gradient(135deg, #0891b2, #6366f1);
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25);
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.3);
}
.wb-section-icon--teal {
background: linear-gradient(135deg, #10b981, #06b6d4);
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.25);
}
.wb-kpi--blue {
background: linear-gradient(145deg, rgba(6, 182, 212, 0.12), rgba(16, 185, 129, 0.06));
border: 1px solid rgba(6, 182, 212, 0.22);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-kpi--violet {
background: linear-gradient(145deg, rgba(99, 102, 241, 0.1), rgba(6, 182, 212, 0.06));
border: 1px solid rgba(99, 102, 241, 0.2);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-kpi--amber {
background: linear-gradient(145deg, rgba(245, 158, 11, 0.12), rgba(251, 191, 36, 0.06));
border: 1px solid rgba(245, 158, 11, 0.24);
box-shadow: var(--el-box-shadow-lighter);
}
.wb-rank-strip {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 112px;
padding: 14px 18px;
border-radius: var(--admin-radius-lg);
background: var(--admin-brand-gradient-soft);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-lighter);
background: linear-gradient(135deg, #14b8a6, #0d9488);
box-shadow: 0 8px 20px rgba(13, 148, 136, 0.3);
}
.wb-section-name {
font-size: 16px;
font-size: 17px;
font-weight: 600;
color: var(--el-text-color-primary);
color: #0f172a;
}
.wb-section-sub {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
color: #94a3b8;
}
.wb-toolbar {
@@ -1028,6 +968,21 @@ onMounted(() => {
box-sizing: border-box;
}
.wb-kpi--blue {
background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid rgba(59, 130, 246, 0.2);
}
.wb-kpi--violet {
background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 100%);
border: 1px solid rgba(139, 92, 246, 0.22);
}
.wb-kpi--amber {
background: linear-gradient(145deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid rgba(245, 158, 11, 0.25);
}
.wb-kpi-label {
font-size: 13px;
color: #64748b;
@@ -1052,6 +1007,17 @@ onMounted(() => {
color: #94a3b8;
}
.wb-rank-strip {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 112px;
padding: 14px 18px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.wb-rank-strip--compact {
min-height: 112px;
}
@@ -1114,10 +1080,9 @@ onMounted(() => {
.wb-chart-card {
height: 100%;
padding: 14px 16px 8px;
border-radius: var(--admin-radius-lg);
background: var(--admin-surface-elevated);
border: 1px solid var(--el-border-color-lighter);
box-shadow: var(--el-box-shadow-lighter);
border-radius: 16px;
background: #fafbfc;
border: 1px solid #eef0f4;
}
.wb-chart-card-head {
+1 -16
View File
@@ -77,22 +77,7 @@ module.exports = {
mask: 'var(--el-mask-color)'
},
fontFamily: {
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)'
sans: ['PingFang SC', 'Arial', 'Hiragino Sans GB', 'Microsoft YaHei', 'sans-serif']
},
boxShadow: {
DEFAULT: 'var(--el-box-shadow)',

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