Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76f16193b | ||
|
|
eb0efba739 | ||
|
|
c7e7022181 | ||
|
|
26e6cc433b | ||
|
|
c22b84cff9 | ||
|
|
4f9486fb3d | ||
|
|
d24b04f9e9 | ||
|
|
465cba3788 | ||
|
|
dc590d37e9 | ||
|
|
d1f587fdd7 | ||
|
|
e8f68101c4 | ||
|
|
cea9392239 | ||
|
|
3ea3c97c42 | ||
|
|
efd058bce5 | ||
|
|
e538dd03a1 | ||
|
|
4a17fe8a0c | ||
|
|
e2a1e62867 | ||
|
|
b6cdd20c56 | ||
|
|
c1be0aa3e5 | ||
|
|
6c87b72ce4 | ||
|
|
e28480af32 | ||
|
|
37b2945c44 | ||
|
|
c9c2b4608f | ||
|
|
e61e7f97fa | ||
|
|
14e5bba9a2 | ||
|
|
6b713a2d6c | ||
|
|
8ae5b4eead | ||
|
|
e5959fd89a | ||
|
|
08249451a7 | ||
|
|
79ae055bb6 | ||
|
|
c0cc23393e | ||
|
|
470ba419e3 | ||
|
|
1c0cd84355 | ||
|
|
f2e2f84e18 | ||
|
|
f7e52bcafc | ||
|
|
2e59e883b8 | ||
|
|
9f1801771e | ||
|
|
1adf8ccf4b | ||
|
|
abc78f4291 | ||
|
|
072c0c3663 | ||
|
|
e9cf65a41c | ||
|
|
af9bd05bb4 | ||
|
|
18f7fb3772 | ||
|
|
29ab45033d | ||
|
|
8a1a9ea0ca | ||
|
|
f8ccd8b0af | ||
|
|
9e019708f2 | ||
|
|
2712c262bd | ||
|
|
bd7002e176 | ||
|
|
0be6d06544 | ||
|
|
7a4973470c | ||
|
|
e79f3190b8 | ||
|
|
1811abc794 | ||
|
|
07abf2abca | ||
|
|
c76bd5416a | ||
|
|
7bb8db4126 | ||
|
|
30b6037752 | ||
|
|
5a235280a1 | ||
|
|
b9d36d8d04 | ||
|
|
f37310ec50 | ||
|
|
a440702c07 | ||
|
|
b9486f32fe | ||
|
|
07018598ad | ||
|
|
c554e08f2e | ||
|
|
e6209c8912 | ||
|
|
e49f806c3a | ||
|
|
b566e60624 | ||
|
|
123e9075dd | ||
|
|
84eef45553 | ||
|
|
9873ac5e87 | ||
|
|
cdf8b1f76d | ||
|
|
b0b78c82ab | ||
|
|
ff61e815d8 | ||
|
|
dd93ffe683 | ||
|
|
ef014e0d9d | ||
|
|
32f8e19bf1 | ||
|
|
26b2cde699 | ||
|
|
5ab3be1fdd | ||
|
|
6b528c34c8 | ||
|
|
2138626519 | ||
|
|
37df208706 |
+1
-4
@@ -26,11 +26,8 @@ bin-release/
|
||||
/.cursor
|
||||
/.codex
|
||||
/.agents
|
||||
/.claude
|
||||
/.claude
|
||||
/.claude
|
||||
/.kbctx
|
||||
/server/.spool
|
||||
/server/.claude
|
||||
/.spool
|
||||
TUICallKit-Vue3/.env
|
||||
/.codegraph
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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"
|
||||
@@ -1,74 +0,0 @@
|
||||
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,5 +1,5 @@
|
||||
import App from './App'
|
||||
var baseUrl ='https://css.zhenyangtang.com.cn/';
|
||||
var baseUrl ='https://admin.zhenyangtang.com.cn/';
|
||||
|
||||
function joinApiUrl(base, path) {
|
||||
const b = String(base || '').replace(/\/+$/, '')
|
||||
|
||||
@@ -52,14 +52,14 @@
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : ["audio"],
|
||||
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
|
||||
@@ -204,7 +204,15 @@
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "日常护理",
|
||||
"backgroundColor": "#f4fbf4",
|
||||
"enablePullDownRefresh": true
|
||||
"enablePullDownRefresh": true,
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 45 KiB |
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
|
||||
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||
<view
|
||||
class="food-tile-icon"
|
||||
:class="[`food-tile-icon--${size}`, { 'is-custom-img': hasCustomImg }]"
|
||||
>
|
||||
<text v-if="showEmojiFallback" class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||
<image
|
||||
v-if="src && !imageFailed"
|
||||
class="food-tile-icon__img"
|
||||
:src="src"
|
||||
:mode="size === 'tile' ? 'aspectFill' : 'aspectFit'"
|
||||
:mode="imageMode"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</view>
|
||||
@@ -27,12 +30,20 @@ const fallbackIcon = computed(() => {
|
||||
return '🍽'
|
||||
})
|
||||
|
||||
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
|
||||
|
||||
const src = computed(() => {
|
||||
if (typeof props.food === 'object' && props.food?.img) return props.food.img
|
||||
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
|
||||
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
|
||||
|
||||
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
|
||||
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
|
||||
|
||||
watch(
|
||||
() => src.value,
|
||||
() => {
|
||||
@@ -66,11 +77,21 @@ function onImageError() {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 58%;
|
||||
height: 58%;
|
||||
width: 75%;
|
||||
height: 75%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.food-tile-icon--tile.is-custom-img .food-tile-icon__img {
|
||||
width: 82%;
|
||||
height: 82%;
|
||||
}
|
||||
|
||||
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
|
||||
.food-tile-icon--tile .food-tile-icon__img {
|
||||
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
|
||||
}
|
||||
|
||||
.food-tile-icon--goal {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
@@ -104,6 +125,11 @@ function onImageError() {
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon--goal.is-custom-img .food-tile-icon__img,
|
||||
.food-tile-icon--tooltip.is-custom-img .food-tile-icon__img {
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon__img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
|
||||
<view class="vsm-body">
|
||||
<view class="vsm-leaf" aria-hidden="true" />
|
||||
<view class="vsm-face">
|
||||
<view class="vsm-eye vsm-eye--l" />
|
||||
<view class="vsm-eye vsm-eye--r" />
|
||||
<view class="vsm-blush vsm-blush--l" />
|
||||
<view class="vsm-blush vsm-blush--r" />
|
||||
<view class="vsm-mouth" />
|
||||
</view>
|
||||
<view v-if="active" class="vsm-waves" aria-hidden="true">
|
||||
<view class="vsm-wave vsm-wave--1" />
|
||||
<view class="vsm-wave vsm-wave--2" />
|
||||
<view class="vsm-wave vsm-wave--3" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 正在按住说话 / 录音中 */
|
||||
active: { type: Boolean, default: false },
|
||||
/** 深色条背景上使用浅色卡通 */
|
||||
onDark: { type: Boolean, default: false },
|
||||
size: { type: String, default: 'md' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vsm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vsm--md { width: 72rpx; height: 72rpx; }
|
||||
.vsm--sm { width: 64rpx; height: 64rpx; }
|
||||
|
||||
.vsm-body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vsm-leaf {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 50%;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
margin-left: -8rpx;
|
||||
border-radius: 0 100% 0 100%;
|
||||
background: #34d399;
|
||||
transform: rotate(-18deg);
|
||||
z-index: 2;
|
||||
}
|
||||
.vsm--on-dark .vsm-leaf {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.vsm-face {
|
||||
position: relative;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
background: #fef9c3;
|
||||
border: 3rpx solid #047857;
|
||||
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
|
||||
z-index: 1;
|
||||
}
|
||||
.vsm--sm .vsm-face {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
.vsm--on-dark .vsm-face {
|
||||
background: #fffbeb;
|
||||
border-color: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.vsm-eye {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
width: 6rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #064e3b;
|
||||
}
|
||||
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
|
||||
.vsm--on-dark .vsm-eye { background: #134e4a; }
|
||||
.vsm-eye--l { left: 12rpx; }
|
||||
.vsm-eye--r { right: 12rpx; }
|
||||
.vsm--sm .vsm-eye--l { left: 10rpx; }
|
||||
.vsm--sm .vsm-eye--r { right: 10rpx; }
|
||||
|
||||
.vsm-blush {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
width: 10rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(251, 113, 133, 0.45);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
|
||||
.vsm-blush--l { left: 6rpx; }
|
||||
.vsm-blush--r { right: 6rpx; }
|
||||
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
|
||||
|
||||
.vsm-mouth {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 10rpx;
|
||||
width: 18rpx;
|
||||
height: 8rpx;
|
||||
margin-left: -9rpx;
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
background: #047857;
|
||||
transform-origin: center top;
|
||||
}
|
||||
.vsm--sm .vsm-mouth {
|
||||
bottom: 9rpx;
|
||||
width: 16rpx;
|
||||
height: 7rpx;
|
||||
margin-left: -8rpx;
|
||||
}
|
||||
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
|
||||
|
||||
.vsm-waves {
|
||||
position: absolute;
|
||||
right: -2rpx;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
height: 28rpx;
|
||||
margin-top: -14rpx;
|
||||
z-index: 0;
|
||||
}
|
||||
.vsm-wave {
|
||||
width: 5rpx;
|
||||
border-radius: 4rpx;
|
||||
background: #047857;
|
||||
animation: vsm-wave-jump 0.72s ease-in-out infinite;
|
||||
}
|
||||
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
|
||||
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
|
||||
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
|
||||
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
|
||||
|
||||
.vsm--active .vsm-body {
|
||||
animation: vsm-bob 0.9s ease-in-out infinite;
|
||||
}
|
||||
.vsm--active .vsm-mouth {
|
||||
animation: vsm-talk 0.32s ease-in-out infinite alternate;
|
||||
}
|
||||
.vsm--active .vsm-eye {
|
||||
animation: vsm-blink 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes vsm-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4rpx); }
|
||||
}
|
||||
@keyframes vsm-talk {
|
||||
0% {
|
||||
transform: scaleY(0.45);
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1.15);
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
@keyframes vsm-blink {
|
||||
0%, 42%, 46%, 100% { transform: scaleY(1); }
|
||||
44% { transform: scaleY(0.15); }
|
||||
}
|
||||
@keyframes vsm-wave-jump {
|
||||
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
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,6 +207,8 @@ 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,22 +2,106 @@ 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
|
||||
|
||||
/**
|
||||
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
||||
*
|
||||
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
|
||||
* @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,由上层自行处理(如自动重试)。
|
||||
*/
|
||||
export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
export function useSpeechToText({ onResult, onSettle, onPartial, onError, 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
|
||||
@@ -31,6 +115,11 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
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('没听清,请再试一次')
|
||||
@@ -43,7 +132,9 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
|
||||
function stopListening() {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (wxRecordManager && (sttListening.value || startRequested)) {
|
||||
const ownsRecording = sharedRecording && activeCtl === controller
|
||||
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
|
||||
if (wxRecordManager && (sttListening.value || ownsRecording)) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
@@ -59,6 +150,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
// #endif
|
||||
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
sttListening.value = false
|
||||
}
|
||||
|
||||
@@ -95,8 +187,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
// #endif
|
||||
}
|
||||
|
||||
function startWechatRecord(session) {
|
||||
// #ifdef MP-WEIXIN
|
||||
// #ifdef MP-WEIXIN
|
||||
function actuallyStartWechat(session) {
|
||||
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||||
return
|
||||
}
|
||||
@@ -108,9 +200,28 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
})
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -123,6 +234,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
}
|
||||
try {
|
||||
startRequested = true
|
||||
h5GotResult = false
|
||||
h5Recognition.start()
|
||||
sttListening.value = true
|
||||
} catch (e) {
|
||||
@@ -146,6 +258,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
sttHolding.value = true
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 把全局录音回调切到当前实例
|
||||
activeCtl = controller
|
||||
ensureRecordAuth(() => startWechatRecord(session))
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
@@ -159,50 +273,95 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 松手结束:手指抬起 */
|
||||
function endHoldSpeech() {
|
||||
if (!sttHolding.value && !sttListening.value && !startRequested) {
|
||||
/** 松手结束:手指抬起;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) {
|
||||
return
|
||||
}
|
||||
sttHolding.value = false
|
||||
if (sttListening.value || startRequested) {
|
||||
// 松手后不再补开排队的录音
|
||||
pendingStartSession = 0
|
||||
if (sttListening.value || recording) {
|
||||
stopListening()
|
||||
return
|
||||
}
|
||||
if (startRequested) {
|
||||
// start 已发出但 onStart 未到:取消会话,勿调 stop()
|
||||
startRequested = false
|
||||
holdSessionId += 1
|
||||
return
|
||||
}
|
||||
holdSessionId += 1
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
const plugin = requirePlugin('WechatSI')
|
||||
wxRecordManager = plugin.getRecordRecognitionManager()
|
||||
sttSupported.value = !!wxRecordManager
|
||||
|
||||
wxRecordManager.onStart = () => {
|
||||
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
|
||||
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
|
||||
const controller = {
|
||||
handleStart() {
|
||||
if (!sttHolding.value) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
|
||||
startRequested = false
|
||||
sttListening.value = false
|
||||
return
|
||||
}
|
||||
startRequested = false
|
||||
sttListening.value = true
|
||||
}
|
||||
wxRecordManager.onStop = (res) => {
|
||||
},
|
||||
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)
|
||||
}
|
||||
wxRecordManager.onError = (res) => {
|
||||
},
|
||||
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
|
||||
notify(res?.msg || '语音识别失败')
|
||||
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)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('WechatSI record recognition not available', e)
|
||||
}
|
||||
|
||||
wxRecordManager = getSharedRecordManager()
|
||||
sttSupported.value = !!wxRecordManager
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
@@ -212,27 +371,49 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
if (SR) {
|
||||
h5Recognition = new SR()
|
||||
h5Recognition.lang = 'zh-CN'
|
||||
h5Recognition.interimResults = false
|
||||
h5Recognition.interimResults = true
|
||||
h5Recognition.continuous = false
|
||||
h5Recognition.maxAlternatives = 1
|
||||
sttSupported.value = true
|
||||
|
||||
h5Recognition.onresult = (event) => {
|
||||
const item = event.results?.[0]?.[0]
|
||||
emitResult(item?.transcript || '')
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
if (event?.error === 'not-allowed') {
|
||||
const err = event?.error || ''
|
||||
if (err === 'aborted') return
|
||||
if (typeof onError === 'function') {
|
||||
const handled = onError(err)
|
||||
if (handled === true) return
|
||||
}
|
||||
if (err === 'not-allowed') {
|
||||
notify('请允许浏览器使用麦克风')
|
||||
} else if (event?.error !== 'aborted') {
|
||||
} else {
|
||||
notify('语音识别失败')
|
||||
}
|
||||
}
|
||||
@@ -246,7 +427,14 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
onUnmounted(() => {
|
||||
sttHolding.value = false
|
||||
holdSessionId += 1
|
||||
pendingStartSession = 0
|
||||
stopListening()
|
||||
// #ifdef MP-WEIXIN
|
||||
// 释放全局回调指向,避免事件继续派发给已销毁的实例
|
||||
if (activeCtl === controller) {
|
||||
activeCtl = null
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -258,3 +446,5 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
stopSpeechToText: endHoldSpeech
|
||||
}
|
||||
}
|
||||
|
||||
export { isBenignSttError, friendlySttError }
|
||||
|
||||
@@ -32,7 +32,8 @@ export function warmUpFoodImgs(foods) {
|
||||
const list = Array.isArray(foods) ? foods : []
|
||||
list.forEach((f) => {
|
||||
const icon = typeof f === 'string' ? f : f?.icon
|
||||
const url = getFoodImgUrl(icon)
|
||||
// 优先预热自定义图片素材,缺省回退 emoji PNG
|
||||
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
|
||||
if (!url) return
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
|
||||
@@ -58,7 +58,20 @@ const FOOD_TIP_OVERRIDE = {
|
||||
apple: '带皮整果,升糖较慢',
|
||||
milk: '低糖,含蛋白,适量喝好',
|
||||
fish: '优质蛋白,升糖低',
|
||||
beef: '蛋白为主,升糖低'
|
||||
beef: '蛋白为主,升糖低',
|
||||
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
|
||||
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
|
||||
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
|
||||
friedNoodles: '油多又是精面,升糖快',
|
||||
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
|
||||
sandwich: '夹心面包精制碳多,升糖快',
|
||||
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
|
||||
milletCongee: '小米粥熬软升糖快,糖友控量',
|
||||
shandongPancake: '煎饼加油面,升糖中等偏快',
|
||||
cake: '蛋糕又甜又油,升糖快',
|
||||
biscuit: '饼干又甜又碎,升糖快',
|
||||
wonton: '馄饨面皮精白,升糖中等,别多吃',
|
||||
blackCoffee: '不加糖的黑咖啡,升糖很低'
|
||||
}
|
||||
|
||||
/** 含糖等级通用科普文案 */
|
||||
@@ -76,6 +89,9 @@ 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' },
|
||||
@@ -101,7 +117,7 @@ const RAW_FOODS = [
|
||||
|
||||
// 中 GI
|
||||
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
|
||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39', img: `${GAMES_IMG_BASE}/%E7%95%AA%E8%96%AF.png` },
|
||||
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
|
||||
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
|
||||
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
|
||||
@@ -118,14 +134,24 @@ const RAW_FOODS = [
|
||||
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
|
||||
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
|
||||
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
|
||||
{ key: 'blackCoffee', icon: '☕', name: '黑咖啡', gi: 'low', val: -1, color: '#4E342E', img: `${GAMES_IMG_BASE}/%E9%BB%91%E5%92%96%E5%95%A1.png` },
|
||||
{ key: 'wonton', icon: '🥟', name: '馄饨', gi: 'mid', val: 5, color: '#E8DCC8', img: `${GAMES_IMG_BASE}/%E9%A6%84%E9%A5%A8.png` },
|
||||
{ key: 'milletCongee', icon: '🥣', name: '小米粥', gi: 'mid', val: 5, color: '#F5E6B8', img: `${GAMES_IMG_BASE}/%E5%B0%8F%E7%B1%B3%E7%B2%A5.png` },
|
||||
{ key: 'shandongPancake', icon: '🥞', name: '山东煎饼', gi: 'high', val: 16, color: '#D4A574', img: `${GAMES_IMG_BASE}/%E5%B1%B1%E4%B8%9C%E7%85%8E%E9%A5%BC.png` },
|
||||
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
|
||||
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
|
||||
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
|
||||
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
|
||||
|
||||
// 高 GI
|
||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
|
||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0', img: `${GAMES_IMG_BASE}/%E7%B1%B3%E9%A5%AD.png` },
|
||||
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
|
||||
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
|
||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
|
||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F', img: `${GAMES_IMG_BASE}/%E6%B2%B9%E6%9D%A1.png` },
|
||||
{ key: 'sandwich', icon: '🥪', name: '三明治', gi: 'high', val: 16, color: '#D9B88F', img: `${GAMES_IMG_BASE}/%E4%B8%89%E6%98%8E%E6%B2%BB.png` },
|
||||
{ key: 'biscuit', icon: '🍪', name: '饼干', gi: 'high', val: 18, color: '#C9A063', img: `${GAMES_IMG_BASE}/%E9%A5%BC%E5%B9%B2.png` },
|
||||
{ key: 'cake', icon: '🎂', name: '蛋糕', gi: 'high', val: 22, color: '#F48FB1', img: `${GAMES_IMG_BASE}/%E8%9B%8B%E7%B3%95.png` },
|
||||
{ key: 'eightTreasureCongee', icon: '🥣', name: '八宝粥', gi: 'high', val: 17, color: '#E8C4A0', img: `${GAMES_IMG_BASE}/%E5%85%AB%E5%AE%9D%E7%B2%A5.png` },
|
||||
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
|
||||
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
|
||||
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
|
||||
@@ -136,7 +162,10 @@ 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: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
|
||||
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
|
||||
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
|
||||
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
|
||||
]
|
||||
|
||||
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||
@@ -149,7 +178,8 @@ export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||
clearVal,
|
||||
giLabel: GI_LABEL[f.gi],
|
||||
get img() {
|
||||
return getFoodImgUrl(f.icon)
|
||||
// 优先使用自定义图片素材,缺省回退到 emoji PNG
|
||||
return f.img || getFoodImgUrl(f.icon)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -170,6 +200,6 @@ export function cloneFoodType(food) {
|
||||
giLabel: food.giLabel,
|
||||
val: food.val,
|
||||
color: food.color,
|
||||
img: getFoodImgUrl(food.icon)
|
||||
img: food.img || getFoodImgUrl(food.icon)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
* 关卡配置:面向 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)
|
||||
@@ -14,6 +39,8 @@ 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,
|
||||
|
||||
@@ -125,30 +125,28 @@
|
||||
</view>
|
||||
|
||||
<!-- Header HUD -->
|
||||
<view class="gg-header gg-glass" :style="headerStyle">
|
||||
<view class="gg-header" :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 ? '#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>
|
||||
<TongjiIcon name="volume" size="md" :color="sfxEnabled ? '#006c4b' : '#94a3b8'" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -156,41 +154,40 @@
|
||||
|
||||
<!-- 主区:棋盘为主,辅助信息弱化 -->
|
||||
<view class="gg-main" :style="mainStyle">
|
||||
<!-- 次:仅展示上次消除的食物与含糖量 -->
|
||||
<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">
|
||||
<!-- 信息横幅:默认为玩法提示;消除后展示该食物的含糖与建议 -->
|
||||
<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">
|
||||
<FoodTileIcon :food="lastClearedFood.food" size="goal" />
|
||||
</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 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>
|
||||
<text class="gg-info-food-tip" :class="'is-' + lastClearedFood.gi">{{ lastClearedFood.tip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="gg-hud-last-food-placeholder">
|
||||
<text>三连消除后,这里显示食物名称与含糖量</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 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">
|
||||
@@ -257,8 +254,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 辅:道具栏(弱化) -->
|
||||
<view class="gg-footer gg-glass gg-footer--sub" :style="footerStyle">
|
||||
<!-- 辅:底部道具栏 -->
|
||||
<view class="gg-footer" :style="footerStyle">
|
||||
<view class="gg-boosters">
|
||||
<view
|
||||
v-for="booster in boosters"
|
||||
@@ -268,9 +265,11 @@
|
||||
:class="{ 'is-disabled': booster.count <= 0 }"
|
||||
@tap="onBooster(booster)"
|
||||
>
|
||||
<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 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>
|
||||
<text class="gg-booster-label">{{ booster.label }}</text>
|
||||
</view>
|
||||
@@ -290,6 +289,7 @@ 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 = 8
|
||||
const GRID_GAP_RPX = 8
|
||||
const GRID_PAD_RPX = 18
|
||||
const GRID_GAP_RPX = 14
|
||||
const SWAP_MS = 280
|
||||
const MATCH_MS = 400
|
||||
const GRAVITY_MS = 300
|
||||
@@ -351,6 +351,21 @@ 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--) {
|
||||
@@ -365,6 +380,38 @@ 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')
|
||||
@@ -554,7 +601,7 @@ const gridStyle = computed(() => {
|
||||
}
|
||||
if (w > 0 && h > 0) {
|
||||
style.width = `${w}px`
|
||||
style.height = `${h}px`
|
||||
style.height = `${h+10}px`
|
||||
}
|
||||
return style
|
||||
})
|
||||
@@ -631,10 +678,6 @@ 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 '血糖过高'
|
||||
@@ -716,7 +759,15 @@ function updateGridMetrics() {
|
||||
|
||||
function randomFood() {
|
||||
const pool = activeFoods.value.length ? activeFoods.value : FOOD_LIBRARY
|
||||
const pick = pool[Math.floor(Math.random() * pool.length)]
|
||||
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)]
|
||||
return cloneFoodType(pick)
|
||||
}
|
||||
|
||||
@@ -731,6 +782,91 @@ 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) {
|
||||
@@ -785,6 +921,11 @@ function initBoard() {
|
||||
}
|
||||
ensureBoardFull(next)
|
||||
commitBoard(next)
|
||||
// 注入本关唯一高糖(解锁前固定 2 个,无法三连)
|
||||
ensureHighPresence()
|
||||
// 注入高糖可能破坏“有解”性:开局静默兜底,避免出现整盘无法消除的死局
|
||||
// (仅打乱类型、保持高糖数量不变)
|
||||
if (!boardHasSolution()) shuffleTypesUntilPlayable()
|
||||
startHintTimer()
|
||||
}
|
||||
|
||||
@@ -846,6 +987,7 @@ function setupLevel(levelId) {
|
||||
highClearAlertVisible.value = false
|
||||
clearedHighFoodName.value = ''
|
||||
lastClearedFood.value = null
|
||||
playerMoves.value = 0
|
||||
tileTouchStart = null
|
||||
clearDragPreview()
|
||||
selectSessionFoods()
|
||||
@@ -1284,6 +1426,38 @@ 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 次。
|
||||
@@ -1292,7 +1466,7 @@ function startHintTimer() {
|
||||
async function reshuffleIfStuck() {
|
||||
if (gamePhase.value !== 'playing') return
|
||||
// 已有解(无现成消除组 + 存在可用移动)则无需重排
|
||||
if (findMatches().length === 0 && findPossibleMoves().length > 0) return
|
||||
if (boardHasSolution()) return
|
||||
|
||||
// 给玩家反馈
|
||||
playSfx('reshuffle', 0.75)
|
||||
@@ -1305,32 +1479,8 @@ async function reshuffleIfStuck() {
|
||||
setTimeout(() => { floatingVals.value = floatingVals.value.filter(v => v.id !== id) }, 1400)
|
||||
} catch (_) {}
|
||||
|
||||
// 收集棋盘上所有有效方块(保持 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) {
|
||||
// 就地洗牌无法洗出可玩布局,回退到全棋盘重新生成
|
||||
// 就地打乱类型直到有解;失败则回退到全棋盘重新生成
|
||||
if (!shuffleTypesUntilPlayable(60)) {
|
||||
initBoard()
|
||||
return
|
||||
}
|
||||
@@ -1439,6 +1589,7 @@ function recordLastClearedFood(groups) {
|
||||
gi,
|
||||
giShort: GI_SHORT[gi] || '低糖',
|
||||
eatHint: formatEatLabel(t),
|
||||
tip: getFoodTip(t),
|
||||
color: GI_COLOR[gi] || '#64748b',
|
||||
food: cloneFoodType(t)
|
||||
}
|
||||
@@ -1485,11 +1636,7 @@ 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',
|
||||
@@ -1612,13 +1759,14 @@ 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
|
||||
}
|
||||
// 所有连消结束后,检查棋盘是否死锁并自动重排
|
||||
await reshuffleIfStuck()
|
||||
// 落子算法已保证“下落即有可消除走法”;此处仅作极端兜底(静默、无横幅)
|
||||
if (!boardHasSolution()) shuffleTypesUntilPlayable()
|
||||
} else {
|
||||
await animateSwapOffset(t1, t2)
|
||||
swapTiles(t1, t2)
|
||||
@@ -1692,9 +1840,134 @@ 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 = []
|
||||
@@ -1722,10 +1995,15 @@ 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()
|
||||
|
||||
@@ -1859,6 +2137,8 @@ async function onBooster(booster) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 恢复本关高糖数量,避免纤维清空高糖直接跳过“8 步”门槛
|
||||
ensureHighPresence()
|
||||
// fiber 改变了棋盘,保险检查是否死锁
|
||||
await reshuffleIfStuck()
|
||||
|
||||
@@ -1975,6 +2255,8 @@ async function onBooster(booster) {
|
||||
await delay(GRAVITY_MS)
|
||||
clearDropFlags()
|
||||
|
||||
// 维持本关高糖数量
|
||||
ensureHighPresence()
|
||||
// meal 自带洗牌已保证有解,但再做一次轻量检查保险
|
||||
await reshuffleIfStuck()
|
||||
|
||||
@@ -2023,7 +2305,7 @@ function initLayout() {
|
||||
paddingTop: `${paddingTop}px`,
|
||||
paddingLeft: '32rpx',
|
||||
paddingRight: '32rpx',
|
||||
paddingBottom: '10rpx'
|
||||
paddingBottom: '18rpx'
|
||||
}
|
||||
navStyle.value = {
|
||||
paddingRight: `${Math.max(0, headerPaddingRight - uni.upx2px(32))}px`
|
||||
@@ -2043,19 +2325,25 @@ 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)
|
||||
const zoneW = sys.windowWidth
|
||||
// gg-main 左右内边距各 20rpx,棋盘可用宽度需相应收窄
|
||||
const sidePad = uni.upx2px(20)
|
||||
const zoneW = sys.windowWidth - sidePad * 2
|
||||
// 预留底部安全间隙,棋盘不贴到道具栏
|
||||
const zoneH = Math.max(0, footerTop - headerBottom - footerGap)
|
||||
const zoneH = Math.max(0, footerTop - boardAreaTop - footerGap)
|
||||
|
||||
// 5 行 × 4 列:宽高分别约束,取较小单格像素
|
||||
cellSizePx.value = calcMaxCellSize(zoneW - inset * 2, zoneH - inset * 2)
|
||||
|
||||
+1644
-104
File diff suppressed because it is too large
Load Diff
@@ -193,19 +193,26 @@
|
||||
<view class="st-float-close" @click="floatAskClosed = true">
|
||||
<text class="st-float-close-icon">×</text>
|
||||
</view>
|
||||
<view class="st-float-inner">
|
||||
<view class="st-float-inner" :class="{ 'is-expanded': dietAiAskExpanded }">
|
||||
<view class="st-float-icon">
|
||||
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||||
</view>
|
||||
<input
|
||||
<textarea
|
||||
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"
|
||||
@@ -519,6 +526,19 @@ 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) {
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
.game-page {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: #f4fbf4; /* VitalMint background */
|
||||
color: #161d19; /* VitalMint text color */
|
||||
/* 参考稿:顶部高光的薄荷渐变背景 */
|
||||
background: #e9f0e9;
|
||||
background-image: radial-gradient(circle at 50% -20%, #ffffff 0%, #e9f0e9 60%, #d5dcd5 100%);
|
||||
color: #171d19;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -24,68 +26,72 @@
|
||||
}
|
||||
|
||||
.game-page .gg-glass {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
/* ===== Header HUD ===== */
|
||||
/* ===== Header / TopAppBar(参考稿玻璃顶栏) ===== */
|
||||
.game-page .gg-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
.game-page .gg-hud-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
height: var(--menu-height, 64rpx);
|
||||
min-height: var(--menu-height, 64rpx);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
/* 玻璃圆形图标按钮 */
|
||||
.game-page .gg-hud-btn {
|
||||
width: var(--menu-height, 64rpx);
|
||||
height: var(--menu-height, 64rpx);
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 108, 73, 0.03);
|
||||
transition: all 0.2s ease;
|
||||
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;
|
||||
}
|
||||
|
||||
.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.45;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-level-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -93,25 +99,29 @@
|
||||
}
|
||||
|
||||
.game-page .gg-hud-level-text {
|
||||
font-size: 30rpx;
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: #475569;
|
||||
color: #006c4b;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 1rpx;
|
||||
letter-spacing: 2rpx;
|
||||
text-shadow: 0 2rpx 6rpx rgba(0, 108, 75, 0.12);
|
||||
}
|
||||
|
||||
.game-page .gg-hud-score {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
gap: 6rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-score-val {
|
||||
font-size: 44rpx;
|
||||
font-weight: 900;
|
||||
color: #006c49;
|
||||
font-size: 68rpx;
|
||||
font-weight: 800;
|
||||
color: #006c4b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
line-height: 1;
|
||||
letter-spacing: -2rpx;
|
||||
text-shadow: 0 2rpx 6rpx rgba(0, 108, 75, 0.12);
|
||||
}
|
||||
|
||||
.game-page .gg-hud-score.is-pop .gg-hud-score-val {
|
||||
@@ -119,44 +129,254 @@
|
||||
}
|
||||
|
||||
.game-page .gg-hud-score-unit {
|
||||
font-size: 26rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
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); }
|
||||
}
|
||||
|
||||
/* ===== 次:上次消除(名称 + 含糖量),GI 三色编码学习卡 ===== */
|
||||
.game-page .gg-hud-last-food {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 750rpx;
|
||||
margin: 0 auto 14rpx;
|
||||
padding: 16rpx 18rpx;
|
||||
border-radius: 22rpx;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
padding: 16rpx 18rpx 18rpx;
|
||||
border-radius: 26rpx;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 108, 73, 0.06);
|
||||
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;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-last-food-kicker {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 12rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-last-food-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 14rpx 16rpx;
|
||||
border-radius: 18rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.08em;
|
||||
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;
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||
animation: gg-last-food-in 0.22s ease-out;
|
||||
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;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -166,35 +386,44 @@
|
||||
}
|
||||
|
||||
.game-page .gg-hud-last-food-card.is-high {
|
||||
background: rgba(254, 242, 242, 0.9);
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
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);
|
||||
}
|
||||
.game-page .gg-hud-last-food-card.is-high::before { background: #dc2626; }
|
||||
|
||||
.game-page .gg-hud-last-food-card.is-mid {
|
||||
background: rgba(255, 251, 235, 0.95);
|
||||
border-color: rgba(234, 88, 12, 0.18);
|
||||
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);
|
||||
}
|
||||
.game-page .gg-hud-last-food-card.is-mid::before { background: #ea580c; }
|
||||
|
||||
.game-page .gg-hud-last-food-card.is-low {
|
||||
background: rgba(240, 253, 244, 0.95);
|
||||
border-color: rgba(22, 163, 74, 0.15);
|
||||
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);
|
||||
}
|
||||
.game-page .gg-hud-last-food-card.is-low::before { background: #16a34a; }
|
||||
|
||||
/* 图标:白底 + GI 彩色描边环 */
|
||||
.game-page .gg-hud-last-food-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16rpx;
|
||||
border-radius: 18rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.06);
|
||||
border: 2rpx solid rgba(15, 23, 42, 0.06);
|
||||
box-shadow: 0 3rpx 10rpx rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.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: 56rpx;
|
||||
height: 56rpx;
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-last-food-body {
|
||||
@@ -206,10 +435,10 @@
|
||||
}
|
||||
|
||||
.game-page .gg-hud-last-food-name {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
color: #0f172a;
|
||||
line-height: 1.25;
|
||||
line-height: 1.2;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -217,42 +446,61 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8rpx 12rpx;
|
||||
gap: 8rpx 14rpx;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-last-food-sugar-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
/* 含糖等级徽标:GI 三色实底 + 白字(保证对比度) */
|
||||
.game-page .gg-hud-last-food-gi {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-last-food-gi {
|
||||
.game-page .gg-hud-last-food-gi-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.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: #334155;
|
||||
color: #475569;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.game-page .gg-hud-last-food-card.is-high .gg-hud-last-food-sugar-hint {
|
||||
color: #991b1b;
|
||||
.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-placeholder {
|
||||
padding: 12rpx 4rpx;
|
||||
.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 text {
|
||||
font-size: 28rpx;
|
||||
font-size: 27rpx;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
line-height: 1.45;
|
||||
@@ -434,8 +682,8 @@
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 8rpx 16rpx 0;
|
||||
overflow: visible;
|
||||
padding: 14rpx 20rpx 0;
|
||||
}
|
||||
|
||||
/* gg-hint-banner 已移除,提示直接显示在棋盘食物上 */
|
||||
@@ -469,17 +717,16 @@
|
||||
.game-page .game-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 36rpx;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 48rpx;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow:
|
||||
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);
|
||||
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);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -503,14 +750,17 @@
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s;
|
||||
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;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
@@ -543,13 +793,13 @@
|
||||
}
|
||||
|
||||
.game-page .game-tile.is-selected {
|
||||
transform: scale(1.12);
|
||||
transform: scale(1.08);
|
||||
z-index: 20;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: 4rpx solid #006c49;
|
||||
outline-offset: -2rpx;
|
||||
border-radius: 8rpx;
|
||||
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);
|
||||
}
|
||||
|
||||
.game-page .game-tile--pressed {
|
||||
@@ -752,31 +1002,39 @@
|
||||
/* ===== 棋子含糖等级常驻角标(认知核心:一眼看出高/中/低糖) ===== */
|
||||
.game-page .gg-tile-gi {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 2rpx;
|
||||
z-index: 3;
|
||||
min-width: 30rpx;
|
||||
height: 30rpx;
|
||||
padding: 0 6rpx;
|
||||
top: -12rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
min-width: 40rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22rpx;
|
||||
font-weight: 900;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
letter-spacing: 1rpx;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.25);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.9);
|
||||
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);
|
||||
}
|
||||
|
||||
.game-page .gg-tile-gi.is-low { background: #16a34a; }
|
||||
.game-page .gg-tile-gi.is-mid { background: #ea580c; }
|
||||
.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-high {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 2rpx 10rpx rgba(220, 38, 38, 0.45);
|
||||
font-size: 22rpx;
|
||||
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);
|
||||
}
|
||||
|
||||
/* ===== 点击食物的大字科普卡 ===== */
|
||||
@@ -869,97 +1127,96 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
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;
|
||||
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;
|
||||
padding-left: 24rpx;
|
||||
padding-right: 24rpx;
|
||||
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;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
}
|
||||
|
||||
.game-page .gg-boosters {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
max-width: 750rpx;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 单个道具:图标卡 + 角标 + 文字 */
|
||||
.game-page .gg-booster-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 22rpx 0;
|
||||
width: 176rpx;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
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);
|
||||
padding: 8rpx 0;
|
||||
width: 184rpx;
|
||||
background: transparent;
|
||||
border: none;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.game-page .gg-booster-btn--pressed {
|
||||
.game-page .gg-booster-tile {
|
||||
position: relative;
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
|
||||
.game-page .gg-booster-btn--pressed .gg-booster-tile {
|
||||
transform: scale(0.92);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.game-page .gg-booster-btn.is-disabled {
|
||||
opacity: 0.35;
|
||||
opacity: 0.4;
|
||||
filter: grayscale(100%);
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.game-page .gg-booster-count {
|
||||
position: absolute;
|
||||
top: -10rpx;
|
||||
right: -10rpx;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
top: -12rpx;
|
||||
right: -12rpx;
|
||||
min-width: 50rpx;
|
||||
height: 50rpx;
|
||||
padding: 0 10rpx;
|
||||
border-radius: 26rpx;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 4rpx solid #fff;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
||||
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);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.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-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-icon {
|
||||
margin-bottom: 8rpx;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.game-page .gg-booster-btn--pressed .gg-booster-icon {
|
||||
transform: scale(0.85);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.game-page .gg-booster-label {
|
||||
margin-top: 14rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #2b3a32;
|
||||
color: #3d4a42;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* ===== 高糖消除红色警示弹窗 ===== */
|
||||
|
||||
@@ -639,7 +639,8 @@
|
||||
}
|
||||
|
||||
.more-page .mc-tasks-section {
|
||||
margin-top: 40rpx;
|
||||
margin-top: 0;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-tasks-grid {
|
||||
@@ -1167,3 +1168,259 @@
|
||||
font-size: 22rpx;
|
||||
color: rgba(108, 122, 113, 0.6);
|
||||
}
|
||||
|
||||
.more-page .mc-tree-species-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
margin-left: 8rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(0, 108, 73, 0.08);
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel,
|
||||
.more-page .mc-species-panel {
|
||||
width: calc(100% - 48rpx);
|
||||
max-width: 680rpx;
|
||||
margin: 0 auto;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 78vh;
|
||||
background: var(--surface);
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-container-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-summary {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
background: var(--surface-container-low);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-water {
|
||||
border-color: rgba(0, 108, 73, 0.22);
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-claimed {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-name {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-growth {
|
||||
padding: 16rpx 18rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.45;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
background: var(--on-surface);
|
||||
color: var(--surface);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn.ghost {
|
||||
background: var(--surface-container-low);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-species-panel-tip {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
color: var(--outline);
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-scroll {
|
||||
max-height: 56vh;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card {
|
||||
padding: 20rpx;
|
||||
border-radius: 24rpx;
|
||||
border: 2rpx solid rgba(0, 108, 73, 0.1);
|
||||
background: var(--surface-container-low);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-emoji {
|
||||
font-size: 56rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-name {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-tagline {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-species-stages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage {
|
||||
width: calc(20% - 8rpx);
|
||||
min-width: 96rpx;
|
||||
padding: 10rpx 6rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage.reached {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage.current {
|
||||
box-shadow: 0 0 0 2rpx var(--primary);
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-emoji {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-lv {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-name {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
color: var(--outline);
|
||||
margin-top: 2rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -740,13 +740,22 @@
|
||||
.weekly-page .st-float-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 16rpx 16rpx 16rpx 40rpx;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 16rpx 16rpx 28rpx;
|
||||
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 {
|
||||
@@ -763,15 +772,30 @@
|
||||
.weekly-page .st-float-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 64rpx;
|
||||
width: 100%;
|
||||
min-height: 64rpx;
|
||||
max-height: 240rpx;
|
||||
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: 28rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-mic {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
|
||||
|
||||
/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 健康数据语音文本解析。
|
||||
*
|
||||
* 把语音识别得到的中文口语文本解析为结构化字段:
|
||||
* - 血糖(空腹 / 餐后 / 其他)
|
||||
* - 血压(高压 / 低压 / 西药 / 胰岛素)
|
||||
* - 饮食(早 / 午 / 晚餐 + 备注)
|
||||
* - 运动(类型 / 时长 / 强度)
|
||||
*
|
||||
* 兼容中文数字(六点五 / 一百二十 / 一百二)与阿拉伯数字(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
@@ -29,7 +29,7 @@
|
||||
stroke-dasharray: 90, 150;
|
||||
stroke-dashoffset: 0;
|
||||
stroke-width: 2;
|
||||
stroke: #4073fa;
|
||||
stroke: #06b6d4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 将标准双 el-card 列表页迁移为 admin-page 架构组件
|
||||
* 用法: node scripts/migrate-admin-pages.mjs [--dry-run] [glob...]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewsRoot = path.join(__dirname, '../src/views')
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
const extraPaths = process.argv.slice(2).filter((a) => !a.startsWith('--'))
|
||||
|
||||
const defaultTargets = [
|
||||
'article/lists/index.vue',
|
||||
'article/column/index.vue',
|
||||
'permission/role/index.vue',
|
||||
'permission/menu/index.vue',
|
||||
'dev_tools/code/index.vue',
|
||||
'setting/dict/type/index.vue',
|
||||
'setting/dict/data/index.vue',
|
||||
'message/notice/index.vue',
|
||||
'message/short_letter/index.vue',
|
||||
'fans/index.vue',
|
||||
'order/index.vue',
|
||||
'asset/user/index.vue',
|
||||
'asset/resource/index.vue',
|
||||
'finance/balance_details.vue',
|
||||
'finance/recharge_record.vue',
|
||||
'finance/refund_record.vue'
|
||||
]
|
||||
|
||||
function migrate(content) {
|
||||
if (content.includes('admin-page-filter-panel')) return null
|
||||
if (!content.includes('el-card class="!border-none"')) return null
|
||||
if (!content.includes('<el-table')) return null
|
||||
|
||||
let out = content
|
||||
|
||||
// 根容器 class 清理
|
||||
out = out.replace(
|
||||
/<div class="[^"]*">\s*\n\s*<el-card class="!border-none" shadow="never">/,
|
||||
'<div>\n <admin-page-filter-panel>'
|
||||
)
|
||||
if (!out.includes('admin-page-filter-panel')) {
|
||||
out = out.replace(
|
||||
/<el-card class="!border-none" shadow="never">/,
|
||||
'<admin-page-filter-panel>'
|
||||
)
|
||||
}
|
||||
|
||||
// 第一个 filter card 结束
|
||||
out = out.replace(
|
||||
/<\/el-form>\s*\n\s*<\/el-card>/,
|
||||
'</el-form>\n </admin-page-filter-panel>'
|
||||
)
|
||||
|
||||
// 第二个 data card 开始 - 提取 v-loading
|
||||
const loadingMatch = out.match(
|
||||
/<el-card([^>]*?)class="[^"]*mt-4[^"]*"[^>]*shadow="never"[^>]*>/
|
||||
)
|
||||
const altLoadingMatch = out.match(
|
||||
/<el-card v-loading="([^"]+)"([^>]*)class="[^"]*mt-4[^"]*"([^>]*)shadow="never"([^>]*)>/
|
||||
)
|
||||
|
||||
if (altLoadingMatch) {
|
||||
out = out.replace(
|
||||
altLoadingMatch[0],
|
||||
`<admin-page-data-panel v-loading="${altLoadingMatch[1]}">`
|
||||
)
|
||||
} else if (loadingMatch) {
|
||||
out = out.replace(loadingMatch[0], '<admin-page-data-panel>')
|
||||
} else {
|
||||
out = out.replace(
|
||||
/<el-card class="!border-none mt-4" shadow="never">/,
|
||||
'<admin-page-data-panel>'
|
||||
)
|
||||
out = out.replace(
|
||||
/<el-card v-loading="([^"]+)" class="mt-4 !border-none" shadow="never">/,
|
||||
'<admin-page-data-panel v-loading="$1">'
|
||||
)
|
||||
out = out.replace(
|
||||
/<el-card v-loading="([^"]+)" class="!border-none mt-4" shadow="never">/,
|
||||
'<admin-page-data-panel v-loading="$1">'
|
||||
)
|
||||
}
|
||||
|
||||
// toolbar: 紧跟 data panel 后的首个 div 包裹按钮
|
||||
out = out.replace(
|
||||
/(<admin-page-data-panel[^>]*>\s*)<div>\s*\n(\s*<el-button[\s\S]*?<\/div>\s*\n)/,
|
||||
'$1<template #toolbar>\n$2</template>\n'
|
||||
)
|
||||
out = out.replace(
|
||||
/(<admin-page-data-panel[^>]*>\s*)<div>\s*\n(\s*<router-link[\s\S]*?<\/div>\s*\n)/,
|
||||
'$1<template #toolbar>\n$2</template>\n'
|
||||
)
|
||||
|
||||
// 移除 table 前的 mt-4 class
|
||||
out = out.replace(/<el-table class="mt-4"/g, '<el-table')
|
||||
out = out.replace(/<el-table\s+class="mt-4"\s+/g, '<el-table ')
|
||||
|
||||
// pagination footer
|
||||
out = out.replace(
|
||||
/<div class="flex(?: mt-4)? justify-end(?: mt-4)?">\s*\n\s*<pagination([^/]*)\/>\s*\n\s*<\/div>\s*\n\s*<\/el-card>/,
|
||||
'<template #footer>\n <pagination$1/>\n </template>\n </admin-page-data-panel>'
|
||||
)
|
||||
out = out.replace(
|
||||
/<div class="flex mt-4 justify-end">\s*\n\s*<pagination([^/]*)\/>\s*\n\s*<\/div>\s*\n\s*<\/el-card>/,
|
||||
'<template #footer>\n <pagination$1/>\n </template>\n </admin-page-data-panel>'
|
||||
)
|
||||
|
||||
// 无 pagination 的 data card 闭合
|
||||
if (out.includes('<admin-page-data-panel') && out.includes('</el-card>')) {
|
||||
out = out.replace(/<\/el-table>\s*\n\s*<\/el-card>/, '</el-table>\n </admin-page-data-panel>')
|
||||
}
|
||||
|
||||
// table 上的 v-loading 移到 panel(若 panel 还没有)
|
||||
out = out.replace(
|
||||
/<admin-page-data-panel>\s*\n(\s*)<el-table([^>]*?)v-loading="([^"]+)"([^>]*)>/,
|
||||
'<admin-page-data-panel v-loading="$3">\n$1<el-table$2$4>'
|
||||
)
|
||||
out = out.replace(/<el-table([^>]*?)v-loading="([^"]+)"([^>]*)>/, '<el-table$1$3>')
|
||||
|
||||
if (out === content || !out.includes('admin-page-data-panel')) return null
|
||||
return out
|
||||
}
|
||||
|
||||
const targets = extraPaths.length
|
||||
? extraPaths.map((p) => path.resolve(p))
|
||||
: defaultTargets.map((p) => path.join(viewsRoot, p))
|
||||
|
||||
let migrated = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const file of targets) {
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn('skip (missing):', path.relative(viewsRoot, file))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const original = fs.readFileSync(file, 'utf8')
|
||||
const result = migrate(original)
|
||||
if (!result) {
|
||||
console.log('skip (no match):', path.relative(viewsRoot, file))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if (dryRun) {
|
||||
console.log('would migrate:', path.relative(viewsRoot, file))
|
||||
} else {
|
||||
fs.writeFileSync(file, result, 'utf8')
|
||||
console.log('migrated:', path.relative(viewsRoot, file))
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
console.log(`\nDone. migrated=${migrated} skipped=${skipped}${dryRun ? ' (dry-run)' : ''}`)
|
||||
@@ -96,6 +96,21 @@ export function yejiStatsRevisitBreakdown(params: {
|
||||
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
|
||||
}
|
||||
|
||||
/** 被指派数明细:与看板「被指派数」同口径;部门行传 dept_id,医助排行榜传 assistant_id */
|
||||
export function yejiStatsAssignLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/assignLines', params })
|
||||
}
|
||||
|
||||
export function doctorDailyStatsOverview(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
@@ -181,6 +196,37 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
||||
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
||||
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:部门下拉(前端组树) */
|
||||
export function revisitRateDeptOptions() {
|
||||
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
||||
export function revisitRateAssignLines(params: {
|
||||
month?: string
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
||||
export function revisitRateVisitOrderLines(params: {
|
||||
month?: string
|
||||
slot: number
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
|
||||
+15
-3
@@ -403,6 +403,11 @@ 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 })
|
||||
}
|
||||
@@ -441,6 +446,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
||||
}
|
||||
|
||||
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
|
||||
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
|
||||
}
|
||||
|
||||
/** 撤回支付单审核 */
|
||||
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
||||
@@ -463,6 +473,8 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
/** 创建方式:fubei 付呗(默认) / express_cod 快递代收 */
|
||||
pay_create_type?: 'fubei' | 'express_cod'
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||
}
|
||||
@@ -482,13 +494,13 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||
}
|
||||
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态,不含退款) */
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」) */
|
||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||
}
|
||||
|
||||
/** 业务订单退款(须填写原因;关联收款单标记已退款、已付总额清零) */
|
||||
export function prescriptionOrderRefund(params: { id: number; reason: string }) {
|
||||
/** 业务订单退款(须填写原因;refund_amount 可选,不传则退满可退上限) */
|
||||
export function prescriptionOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<section class="admin-data-panel" v-loading="loading">
|
||||
<div v-if="$slots.toolbar" class="admin-data-panel__toolbar">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
<div class="admin-data-panel__body">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="admin-data-panel__footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
loading: false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
|
||||
<section class="admin-filter-panel" :class="{ 'is-collapsed': collapsed }">
|
||||
|
||||
<div v-if="collapsible" class="admin-filter-panel__head">
|
||||
|
||||
<span class="admin-filter-panel__label">{{ title }}</span>
|
||||
|
||||
<el-button class="admin-filter-panel__toggle" link type="primary" @click="collapsed = !collapsed">
|
||||
|
||||
{{ collapsed ? '展开筛选' : '收起筛选' }}
|
||||
|
||||
</el-button>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="admin-filter-panel__body">
|
||||
|
||||
<slot />
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
withDefaults(
|
||||
|
||||
defineProps<{
|
||||
|
||||
/** 是否显示收起/展开 */
|
||||
|
||||
collapsible?: boolean
|
||||
|
||||
/** 筛选区标题 */
|
||||
|
||||
title?: string
|
||||
|
||||
}>(),
|
||||
|
||||
{
|
||||
|
||||
collapsible: true,
|
||||
|
||||
title: '筛选条件'
|
||||
|
||||
}
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<section class="admin-form-panel">
|
||||
<header v-if="title || $slots.header" class="admin-form-panel__header">
|
||||
<slot name="header">
|
||||
<h2 v-if="title" class="admin-form-panel__title">{{ title }}</h2>
|
||||
</slot>
|
||||
</header>
|
||||
<div class="admin-form-panel__body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="admin-form-panel__footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="admin-page-actions">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="admin-stat-grid" :class="[`admin-stat-grid--cols-${columns}`]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
columns?: 2 | 3 | 4 | 5
|
||||
}>(),
|
||||
{
|
||||
columns: 4
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<article class="admin-stat-item" :class="[`admin-stat-item--${tone}`]">
|
||||
<div class="admin-stat-item__label">{{ label }}</div>
|
||||
<div class="admin-stat-item__value" :class="{ 'is-money': money }">
|
||||
<slot>{{ value }}</slot>
|
||||
</div>
|
||||
<p v-if="hint" class="admin-stat-item__hint">{{ hint }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value?: string | number
|
||||
hint?: string
|
||||
money?: boolean
|
||||
tone?: 'default' | 'primary' | 'success' | 'warning'
|
||||
}>(),
|
||||
{
|
||||
value: '',
|
||||
hint: '',
|
||||
money: false,
|
||||
tone: 'default'
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -7,6 +7,7 @@
|
||||
:end-placeholder="endPlaceholder"
|
||||
:value-format="valueFormat"
|
||||
clearable
|
||||
@change="emit('change', $event)"
|
||||
></el-date-picker>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +30,7 @@ const props = withDefaults(
|
||||
endPlaceholder: '结束时间'
|
||||
}
|
||||
)
|
||||
const emit = defineEmits(['update:startTime', 'update:endTime'])
|
||||
const emit = defineEmits(['update:startTime', 'update:endTime', 'change'])
|
||||
|
||||
const content = computed<any>({
|
||||
get: () => {
|
||||
|
||||
@@ -17,14 +17,18 @@ defineProps({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.footer-btns {
|
||||
height: 60px;
|
||||
height: 64px;
|
||||
|
||||
&__content {
|
||||
bottom: 0;
|
||||
height: 60px;
|
||||
height: 64px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 99;
|
||||
@apply flex justify-center items-center shadow bg-body;
|
||||
padding: 0 20px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04);
|
||||
@apply flex justify-center items-center gap-3 bg-body;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<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>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
|
||||
<div class="page-stage admin-page" :class="{ 'page-stage--flat': hideChrome }">
|
||||
|
||||
<header v-if="showHeader" class="page-stage__hero">
|
||||
|
||||
<div class="page-stage__hero-glow" aria-hidden="true"></div>
|
||||
|
||||
<div class="page-stage__hero-inner">
|
||||
|
||||
<div class="page-stage__intro">
|
||||
|
||||
<h1 class="page-stage__title">{{ pageTitle }}</h1>
|
||||
|
||||
<p v-if="pageDesc" class="page-stage__desc">{{ pageDesc }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="page-stage__actions">
|
||||
|
||||
<slot name="actions" />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<div class="page-stage__body">
|
||||
|
||||
<slot />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { PageEnum } from '@/enums/pageEnum'
|
||||
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
|
||||
const hideChrome = computed(() => {
|
||||
|
||||
if (route.meta?.hidePageHeader) return true
|
||||
|
||||
if (route.path === PageEnum.INDEX || route.path === `${PageEnum.INDEX}/`) return true
|
||||
|
||||
const title = (route.meta?.title as string) || ''
|
||||
|
||||
return title.includes('工作台')
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
|
||||
const matched = route.matched.filter((item) => item.meta?.title)
|
||||
|
||||
const last = matched[matched.length - 1]
|
||||
|
||||
return (last?.meta?.title as string) || ''
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
const pageDesc = computed(() => (route.meta?.pageDesc as string) || '')
|
||||
|
||||
|
||||
|
||||
const showHeader = computed(() => !hideChrome.value && Boolean(pageTitle.value))
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.page-stage {
|
||||
|
||||
min-height: 100%;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-stage__hero {
|
||||
|
||||
position: relative;
|
||||
|
||||
margin-bottom: 20px;
|
||||
|
||||
padding: 22px 24px;
|
||||
|
||||
border-radius: var(--admin-radius-xl);
|
||||
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
background: var(--admin-surface-glass);
|
||||
|
||||
backdrop-filter: blur(16px) saturate(140%);
|
||||
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-stage__hero-glow {
|
||||
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
|
||||
background:
|
||||
|
||||
linear-gradient(120deg, rgba(6, 182, 212, 0.12) 0%, transparent 42%),
|
||||
|
||||
linear-gradient(300deg, rgba(16, 185, 129, 0.1) 0%, transparent 38%);
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-stage__hero-inner {
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
align-items: flex-start;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 12px 20px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-stage__title {
|
||||
|
||||
margin: 0;
|
||||
|
||||
font-size: 24px;
|
||||
|
||||
font-weight: 800;
|
||||
|
||||
line-height: 1.2;
|
||||
|
||||
letter-spacing: -0.03em;
|
||||
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
background: var(--admin-brand-gradient);
|
||||
|
||||
background-clip: text;
|
||||
|
||||
-webkit-background-clip: text;
|
||||
|
||||
-webkit-text-fill-color: transparent;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-stage__desc {
|
||||
|
||||
margin: 8px 0 0;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
line-height: 1.5;
|
||||
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
max-width: 62ch;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-stage__actions {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-stage__body {
|
||||
|
||||
min-width: 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.page-stage--flat .page-stage__hero {
|
||||
|
||||
display: none;
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -88,13 +88,17 @@
|
||||
共 <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" @click="handleAddHerb('主方')">
|
||||
<el-button type="primary" size="small" :icon="CirclePlus" :disabled="herbsLocked" @click="handleAddHerb('主方')">
|
||||
添加主方
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="handleAddHerb('辅方')">
|
||||
<el-button type="primary" plain size="small" :disabled="herbsLocked" @click="handleAddHerb('辅方')">
|
||||
添加辅方
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -135,7 +139,7 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -148,11 +152,12 @@
|
||||
: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 type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -171,7 +176,7 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">辅方</div>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -184,11 +189,12 @@
|
||||
: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 type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -553,7 +559,11 @@
|
||||
<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>
|
||||
@@ -672,22 +682,30 @@
|
||||
</div>
|
||||
|
||||
<div class="rx-text">
|
||||
<p v-if="prescriptionTabType === 'internal' || savedAuxUsageText">主方服法:{{ rxUsageText }}</p>
|
||||
<p v-else>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="savedAuxUsageText">{{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}:{{ savedAuxUsageText }}</p>
|
||||
<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="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="prescriptionTabType === 'internal' && 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="rxOutPelletText && savedPrescription.prescription_type !== '饮片'" class="rx-text-warn">
|
||||
<p
|
||||
v-if="prescriptionTabType === 'internal' && rxOutPelletText && savedPrescription.prescription_type !== '饮片'"
|
||||
class="rx-text-warn"
|
||||
>
|
||||
出丸:{{ rxOutPelletText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rx-bottom">
|
||||
<div class="rx-bot-row rx-bot-row-1">
|
||||
<div
|
||||
class="rx-bot-row rx-bot-row-1"
|
||||
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
|
||||
>
|
||||
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
|
||||
<div class="rx-bot-label">医师</div>
|
||||
<div class="rx-bot-doctor-body">
|
||||
@@ -697,10 +715,12 @@
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<div class="rx-bot-doctor-name">{{ savedPrescription.doctor_name || '—' }}</div>
|
||||
<div v-if="!savedPrescription.doctor_signature" class="rx-bot-doctor-name">
|
||||
{{ savedPrescription.doctor_name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">类型:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||
</div>
|
||||
@@ -708,7 +728,7 @@
|
||||
<span class="rx-bot-meta-key">天数:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">剂量:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||
</div>
|
||||
@@ -1028,6 +1048,8 @@ interface Herb {
|
||||
name: string
|
||||
dosage: number
|
||||
formula_type?: FormulaType
|
||||
/** 来自「禁用修改」处方库模板:锁定后不可增删改,只能再次导入覆盖 */
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
type AuxUsageForm = {
|
||||
@@ -1044,11 +1066,15 @@ function normalizeFormulaType(v: unknown): FormulaType {
|
||||
}
|
||||
|
||||
function normalizeHerbRow(raw: any): Herb {
|
||||
return {
|
||||
const row: Herb = {
|
||||
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 {
|
||||
@@ -1430,6 +1456,12 @@ 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
|
||||
@@ -2029,10 +2061,18 @@ 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)
|
||||
}
|
||||
|
||||
@@ -2089,10 +2129,18 @@ const handleImportLibrary = (row: any) => {
|
||||
}
|
||||
|
||||
const formulaType = normalizeFormulaType(row.formula_type)
|
||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
|
||||
...normalizeHerbRow(h),
|
||||
formula_type: formulaType
|
||||
}))
|
||||
// 「禁用修改」模板:导入的药材打上锁定标记 → 整张处方锁定(不可增删改,只能再次导入覆盖)
|
||||
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
|
||||
})
|
||||
if (libraryImportMode.value === 'replace') {
|
||||
const kept = formData.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||
formData.herbs = [...kept, ...imported]
|
||||
@@ -2532,6 +2580,17 @@ 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;
|
||||
@@ -2925,6 +2984,10 @@ 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
@@ -1,28 +1,28 @@
|
||||
const defaultSetting = {
|
||||
showCrumb: true, // 是否显示面包屑
|
||||
showLogo: false, // 是否显示logo
|
||||
isUniqueOpened: true, //只展开一个一级菜单
|
||||
sideWidth: 183, //侧边栏宽度
|
||||
sideTheme: 'dark', //侧边栏主题
|
||||
sideDarkColor: '#1d2124', //侧边栏深色主题颜色
|
||||
openMultipleTabs: true, // 是否开启多标签tab栏
|
||||
theme: '#4A5DFF', //主题色
|
||||
successTheme: '#67c23a', //成功主题色
|
||||
warningTheme: '#e6a23c', //警告主题色
|
||||
dangerTheme: '#f56c6c', //危险主题色
|
||||
errorTheme: '#f56c6c', //错误主题色
|
||||
infoTheme: '#909399' //信息主题色
|
||||
showCrumb: false,
|
||||
showLogo: true,
|
||||
isUniqueOpened: true,
|
||||
sideWidth: 248,
|
||||
sideTheme: 'dark',
|
||||
sideDarkColor: '#060d18',
|
||||
openMultipleTabs: true,
|
||||
theme: '#06b6d4',
|
||||
successTheme: '#10b981',
|
||||
warningTheme: '#f59e0b',
|
||||
dangerTheme: '#ef4444',
|
||||
errorTheme: '#ef4444',
|
||||
infoTheme: '#6366f1'
|
||||
}
|
||||
|
||||
/** 本地 setting 缓存结构版本。提升后仅对低于该版本的老缓存执行 SETTING_SCHEMA_MIGRATIONS */
|
||||
export const SETTING_SCHEMA_VERSION = 1
|
||||
export const SETTING_SCHEMA_VERSION = 6
|
||||
|
||||
/**
|
||||
* 按版本写入 defaultSetting 中的键(老用户 localStorage 会长期盖住 config 默认值)。
|
||||
* 以后若要再推一批新默认值:把 SETTING_SCHEMA_VERSION +1,并为本版本追加一条迁移键列表。
|
||||
*/
|
||||
export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = {
|
||||
1: ['sideTheme', 'sideDarkColor']
|
||||
1: ['sideTheme', 'sideDarkColor'],
|
||||
2: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor', 'sideWidth', 'showLogo'],
|
||||
3: ['theme', 'sideDarkColor', 'sideWidth', 'showCrumb', 'showLogo'],
|
||||
4: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor'],
|
||||
5: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor'],
|
||||
6: ['theme', 'successTheme', 'warningTheme', 'dangerTheme', 'errorTheme', 'infoTheme', 'sideDarkColor', 'sideWidth']
|
||||
}
|
||||
|
||||
export default defaultSetting
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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">
|
||||
<el-breadcrumb class="app-breadcrumb" separator="/">
|
||||
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||||
{{ item.meta.title }}
|
||||
</el-breadcrumb-item>
|
||||
@@ -21,22 +21,24 @@ useWatchRoute((route) => {
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-breadcrumb {
|
||||
:deep(.el-breadcrumb__item) {
|
||||
.el-breadcrumb__inner {
|
||||
color: #303133;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 500;
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
|
||||
&:last-child .el-breadcrumb__inner {
|
||||
color: #303133;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:deep(.el-breadcrumb__separator) {
|
||||
color: #606266;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="navbar">
|
||||
<div class="flex-1 flex">
|
||||
<div class="flex-1 flex items-center gap-1 min-w-0">
|
||||
<div class="navbar-item">
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
@@ -17,11 +17,14 @@
|
||||
<refresh />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="flex items-center px-2" v-if="!isMobile && settingStore.showCrumb">
|
||||
<div
|
||||
class="hidden md:flex items-center min-w-0 px-2"
|
||||
v-if="settingStore.showCrumb && breadcrumbs.length"
|
||||
>
|
||||
<breadcrumb />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="navbar-item" v-if="!isMobile">
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
@@ -36,12 +39,7 @@
|
||||
<user-drop-down />
|
||||
</div>
|
||||
<div class="navbar-item">
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
content="主题设置"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-tooltip class="box-item" effect="dark" content="主题设置" placement="bottom">
|
||||
<setting />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
@@ -52,8 +50,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationMatched } from 'vue-router'
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
|
||||
import { useWatchRoute } from '@/hooks/useWatchRoute'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import useSettingStore from '@/stores/modules/setting'
|
||||
|
||||
@@ -70,14 +70,20 @@ const isMobile = computed(() => appStore.isMobile)
|
||||
const isCollapsed = computed(() => appStore.isCollapsed)
|
||||
const settingStore = useSettingStore()
|
||||
const { isFullscreen } = useFullscreen()
|
||||
|
||||
const breadcrumbs = ref<RouteLocationMatched[]>([])
|
||||
useWatchRoute((route) => {
|
||||
breadcrumbs.value = route.matched.filter((item) => item.meta && item.meta.title)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.navbar {
|
||||
height: var(--navbar-height);
|
||||
@apply flex px-2 bg-body;
|
||||
@apply flex px-3 bg-body;
|
||||
|
||||
.navbar-item {
|
||||
@apply h-full flex justify-center items-center hover:bg-page;
|
||||
@apply h-full flex justify-center items-center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="app-tabs pl-4 flex bg-body">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="app-tabs flex bg-body">
|
||||
<div class="flex-1 min-w-0 pl-3">
|
||||
<el-tabs
|
||||
:model-value="currentTab"
|
||||
:closable="tabsLists.length > 1"
|
||||
@@ -13,7 +13,7 @@
|
||||
</el-tabs>
|
||||
</div>
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="flex items-center px-3">
|
||||
<span class="tabs-more-btn flex items-center px-3">
|
||||
<icon :size="16" name="el-icon-arrow-down" />
|
||||
</span>
|
||||
<template #dropdown>
|
||||
@@ -60,61 +60,84 @@ const handleCommand = (command: any) => {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.app-tabs {
|
||||
@apply border-t border-br;
|
||||
height: var(--tabs-height);
|
||||
border-top: 1px solid var(--admin-header-border);
|
||||
background: var(--admin-surface-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
|
||||
.tabs-more-btn {
|
||||
height: var(--tabs-height);
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--admin-brand-primary-dark);
|
||||
background: var(--admin-brand-gradient-soft);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs) {
|
||||
height: 40px;
|
||||
height: var(--tabs-height);
|
||||
|
||||
.el-tabs {
|
||||
&__header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__nav-next,
|
||||
&__nav-prev {
|
||||
@apply text-xl;
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
&__nav-wrap::after {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&__item {
|
||||
font-weight: normal;
|
||||
padding: 0 15px !important;
|
||||
font-weight: 600;
|
||||
font-size: var(--el-font-size-small);
|
||||
padding: 0 16px !important;
|
||||
height: calc(var(--tabs-height) - 8px);
|
||||
margin-top: 4px;
|
||||
border-radius: var(--admin-radius-md) var(--admin-radius-md) 0 0;
|
||||
box-sizing: border-box;
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--admin-brand-primary-dark);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--el-text-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--el-color-primary);
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
color: var(--admin-brand-primary-dark);
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-bottom-color: transparent;
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
display: block;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: var(--el-color-primary);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.is-icon-close {
|
||||
color: var(--el-text-color-regular);
|
||||
color: var(--el-text-color-placeholder);
|
||||
vertical-align: -2px;
|
||||
border-radius: var(--admin-radius-sm);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-white);
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__active-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<el-dropdown class="px-2" @command="handleCommand">
|
||||
<div class="flex items-center">
|
||||
<el-avatar :size="34" :src="userInfo.avatar" />
|
||||
<div class="ml-3 mr-1">{{ userInfo.name }}</div>
|
||||
<icon name="el-icon-ArrowDown" />
|
||||
<el-dropdown class="user-dropdown px-1" @command="handleCommand">
|
||||
<div class="user-trigger flex items-center gap-2.5 px-2 py-1 rounded-md cursor-pointer">
|
||||
<el-avatar :size="32" :src="userInfo.avatar" />
|
||||
<span class="user-name max-w-[120px] truncate text-sm font-medium text-tx-primary">{{
|
||||
userInfo.name
|
||||
}}</span>
|
||||
<icon name="el-icon-ArrowDown" :size="14" />
|
||||
</div>
|
||||
|
||||
<template #dropdown>
|
||||
@@ -41,3 +43,13 @@ const handleCommand = async (command: string) => {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-trigger {
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,56 @@
|
||||
<template>
|
||||
<main class="main-wrap h-full bg-page">
|
||||
|
||||
<main class="main-wrap h-full">
|
||||
|
||||
<el-scrollbar>
|
||||
<div class="px-2 py-4">
|
||||
<router-view v-if="isRouteShow" v-slot="{ Component, route }">
|
||||
<keep-alive :include="includeList" :max="20">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
|
||||
<div class="main-stage">
|
||||
|
||||
<page-shell v-if="isRouteShow">
|
||||
|
||||
<router-view v-slot="{ Component, route }">
|
||||
|
||||
<keep-alive :include="includeList" :max="20">
|
||||
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
|
||||
</keep-alive>
|
||||
|
||||
</router-view>
|
||||
|
||||
</page-shell>
|
||||
|
||||
</div>
|
||||
|
||||
</el-scrollbar>
|
||||
|
||||
</main>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import PageShell from '@/components/page-shell/index.vue'
|
||||
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
|
||||
import useTabsStore from '@/stores/modules/multipleTabs'
|
||||
|
||||
import useSettingStore from '@/stores/modules/setting'
|
||||
|
||||
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const tabsStore = useTabsStore()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isRouteShow = computed(() => appStore.isRouteShow)
|
||||
|
||||
const includeList = computed(() => (settingStore.openMultipleTabs ? tabsStore.getCacheTabList : []))
|
||||
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -100,7 +100,7 @@ import theme_light from '@/assets/images/theme_white.png'
|
||||
import useSettingStore from '@/stores/modules/setting'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const predefineColors = ref(['#409EFF', '#28C76F', '#EA5455', '#FF9F43', '#01CFE8', '#4A5DFF'])
|
||||
const predefineColors = ref(['#06b6d4', '#10b981', '#0891b2', '#6366f1', '#f59e0b', '#ef4444', '#64748b'])
|
||||
const sideThemeList = [
|
||||
{
|
||||
type: 'dark',
|
||||
|
||||
@@ -68,32 +68,42 @@ const themeClass = computed(() => `theme-${props.theme}`)
|
||||
.el-menu {
|
||||
:deep(.el-menu-item) {
|
||||
&.is-active {
|
||||
@apply bg-primary border-primary;
|
||||
@apply bg-primary;
|
||||
box-shadow: inset 3px 0 0 var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu--collapse) {
|
||||
.el-sub-menu.is-active .el-sub-menu__title {
|
||||
@apply bg-primary #{!important};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.theme-light {
|
||||
:deep(.el-menu) {
|
||||
.el-menu-item {
|
||||
border-color: transparent;
|
||||
|
||||
&.is-active {
|
||||
@apply bg-primary-light-9 border-r-2 border-primary;
|
||||
@apply bg-primary-light-9;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 3px 0 0 var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item:hover,
|
||||
.el-sub-menu__title:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
|
||||
&:not(.el-menu--collapse) {
|
||||
width: var(--aside-width);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<template>
|
||||
<div class="side" :style="sideStyle">
|
||||
<div v-if="showBrandStrip" class="sidebar-brand">
|
||||
<div class="sidebar-brand__mark">ZY</div>
|
||||
<overflow-tooltip
|
||||
class="sidebar-brand__name"
|
||||
:content="config.web_name"
|
||||
:teleported="true"
|
||||
placement="bottom"
|
||||
overflo-type="unset"
|
||||
/>
|
||||
</div>
|
||||
<side-logo v-if="settingStore.showLogo" :show-title="!isCollapsed" :theme="sideTheme" />
|
||||
<side-menu
|
||||
:routes="routes"
|
||||
@@ -25,17 +35,19 @@ const appStore = useAppStore()
|
||||
const isCollapsed = computed(() => {
|
||||
if (appStore.isMobile) {
|
||||
return false
|
||||
} else {
|
||||
return appStore.isCollapsed
|
||||
}
|
||||
return appStore.isCollapsed
|
||||
})
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sideTheme = computed(() => settingStore.sideTheme)
|
||||
const userStore = useUserStore()
|
||||
const config = computed(() => appStore.config)
|
||||
|
||||
const routes = computed(() => userStore.routes)
|
||||
|
||||
const showBrandStrip = computed(() => !settingStore.showLogo && !isCollapsed.value)
|
||||
|
||||
const sideStyle = computed(() => {
|
||||
return sideTheme.value == 'dark'
|
||||
? {
|
||||
@@ -43,6 +55,7 @@ const sideStyle = computed(() => {
|
||||
}
|
||||
: ''
|
||||
})
|
||||
|
||||
const menuProp = computed(() => {
|
||||
return {
|
||||
backgroundColor: sideTheme.value == 'dark' ? settingStore.sideDarkColor : '',
|
||||
@@ -50,6 +63,7 @@ const menuProp = computed(() => {
|
||||
activeTextColor: sideTheme.value == 'dark' ? 'var(--el-color-white)' : ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleSelect = () => {
|
||||
if (appStore.isMobile) {
|
||||
appStore.toggleCollapsed(true)
|
||||
@@ -61,7 +75,8 @@ const handleSelect = () => {
|
||||
.side {
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
@apply border-r border-br-light h-full flex flex-col;
|
||||
background-color: var(--side-dark-color, var(--el-bg-color));
|
||||
@apply h-full flex flex-col;
|
||||
border-right: 1px solid var(--admin-sidebar-border);
|
||||
background-color: var(--side-dark-color, var(--sidebar-dark-bg));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Admin 页面架构 v2 - 大改版面板系统
|
||||
*/
|
||||
|
||||
.admin-page .page-stage__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---------- FilterPanel ---------- */
|
||||
.admin-filter-panel {
|
||||
position: relative;
|
||||
border-radius: var(--admin-radius-xl);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--admin-surface-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 3px;
|
||||
background: var(--admin-brand-gradient);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-filter-panel__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 22px 0;
|
||||
}
|
||||
|
||||
.admin-filter-panel__label {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.admin-filter-panel__toggle {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-filter-panel__body {
|
||||
padding: 14px 22px 12px;
|
||||
}
|
||||
|
||||
.admin-filter-panel.is-collapsed .admin-filter-panel__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-filter-panel .el-form--inline {
|
||||
margin-bottom: 0;
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 12px;
|
||||
margin-right: 18px;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- DataPanel ---------- */
|
||||
.admin-data-panel {
|
||||
border-radius: var(--admin-radius-xl);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--admin-surface-elevated);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-data-panel__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
background: var(--admin-brand-gradient-soft);
|
||||
}
|
||||
|
||||
.admin-data-panel__body {
|
||||
padding: 0 22px 18px;
|
||||
}
|
||||
|
||||
.admin-data-panel__toolbar + .admin-data-panel__body {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.admin-data-panel__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 22px 18px;
|
||||
}
|
||||
|
||||
/* ---------- FormPanel ---------- */
|
||||
.admin-form-panel {
|
||||
border-radius: var(--admin-radius-xl);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--admin-surface-elevated);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-form-panel__header {
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
background: var(--admin-brand-gradient-soft);
|
||||
}
|
||||
|
||||
.admin-form-panel__title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.admin-form-panel__body {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.admin-form-panel__footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 16px 22px 22px;
|
||||
border-top: 1px solid var(--el-border-color-extra-light);
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
/* ---------- StatGrid ---------- */
|
||||
.admin-stat-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-stat-grid--cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.admin-stat-grid--cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.admin-stat-grid--cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.admin-stat-grid--cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
|
||||
.admin-stat-item {
|
||||
position: relative;
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--admin-radius-lg);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--admin-surface-elevated);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: var(--el-border-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-stat-item--primary {
|
||||
&::before { background: var(--admin-brand-gradient); }
|
||||
border-color: rgba(6, 182, 212, 0.2);
|
||||
background: linear-gradient(145deg, rgba(6, 182, 212, 0.08), rgba(16, 185, 129, 0.05));
|
||||
}
|
||||
|
||||
.admin-stat-item--success {
|
||||
&::before { background: #10b981; }
|
||||
border-color: rgba(16, 185, 129, 0.22);
|
||||
}
|
||||
|
||||
.admin-stat-item--warning {
|
||||
&::before { background: #f59e0b; }
|
||||
border-color: rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
|
||||
.admin-stat-item__label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.admin-stat-item__value {
|
||||
margin-top: 10px;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--el-text-color-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-stat-item__value.is-money {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.admin-stat-item__hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
.admin-data-panel .el-table,
|
||||
.admin-page .el-card .el-table {
|
||||
--el-table-border-color: transparent;
|
||||
border-radius: var(--admin-radius-md);
|
||||
|
||||
thead th.el-table__cell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--table-header-bg-color) !important;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
font-weight: 700;
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
td.el-table__cell {
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.el-table__row:hover > td.el-table__cell {
|
||||
background-color: rgba(6, 182, 212, 0.04) !important;
|
||||
}
|
||||
|
||||
.el-table__row.current-row > td.el-table__cell {
|
||||
background-color: rgba(6, 182, 212, 0.08) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Legacy cards ---------- */
|
||||
.admin-page .page-stage__body {
|
||||
> div > .el-card:first-child:has(.el-form),
|
||||
> .el-card:first-child:has(.el-form) {
|
||||
border-radius: var(--admin-radius-xl);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
> div > .el-card + .el-card,
|
||||
> .el-card + .el-card {
|
||||
border-radius: var(--admin-radius-xl);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.workbench-page {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.admin-stat-grid--cols-4,
|
||||
.admin-stat-grid--cols-5 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-filter-panel__body,
|
||||
.admin-data-panel__body,
|
||||
.admin-data-panel__toolbar,
|
||||
.admin-data-panel__footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.admin-stat-grid--cols-2,
|
||||
.admin-stat-grid--cols-3,
|
||||
.admin-stat-grid--cols-4,
|
||||
.admin-stat-grid--cols-5 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-stat-item:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.admin-stat-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/* Admin Shell v2 - 互联网医院工作台大改版 */
|
||||
|
||||
.layout-default {
|
||||
background: var(--sidebar-dark-bg);
|
||||
}
|
||||
|
||||
/* ---------- Sidebar ---------- */
|
||||
.app-aside .side {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--sidebar-rail-width);
|
||||
background: var(--admin-brand-gradient);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, var(--admin-brand-mesh-a), transparent 45%),
|
||||
radial-gradient(circle at 100% 100%, var(--admin-brand-mesh-b), transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: calc(var(--navbar-height) + 4px);
|
||||
padding: 0 18px 0 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-brand__mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
color: #ffffff;
|
||||
background: var(--admin-brand-gradient);
|
||||
box-shadow: var(--admin-brand-glow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand__name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu.theme-dark .el-menu {
|
||||
--el-menu-bg-color: transparent;
|
||||
--el-menu-hover-bg-color: var(--sidebar-dark-hover);
|
||||
--el-menu-active-color: #ffffff;
|
||||
--el-menu-text-color: rgba(255, 255, 255, 0.62);
|
||||
padding: 12px 10px 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.menu.theme-dark .el-menu .el-menu-item,
|
||||
.menu.theme-dark .el-menu .el-sub-menu__title {
|
||||
margin: 3px 8px;
|
||||
border-radius: var(--admin-radius-md);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.menu.theme-dark .el-menu .el-menu-item.is-active {
|
||||
background: var(--sidebar-dark-active) !important;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(6, 182, 212, 0.28),
|
||||
0 8px 24px rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
|
||||
.menu.theme-dark .el-menu .el-menu-item:hover,
|
||||
.menu.theme-dark .el-menu .el-sub-menu__title:hover {
|
||||
background: var(--sidebar-dark-hover);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.menu.theme-light .el-menu .el-menu-item.is-active {
|
||||
font-weight: 600;
|
||||
color: var(--admin-brand-primary-dark);
|
||||
background: var(--admin-brand-gradient-soft);
|
||||
box-shadow: inset 3px 0 0 var(--admin-brand-primary);
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
.app-header {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
background: var(--admin-surface-glass);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
border-bottom: 1px solid var(--admin-header-border);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 2px;
|
||||
background: var(--admin-brand-gradient);
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
:root.dark .app-header {
|
||||
background: rgba(15, 23, 42, 0.88);
|
||||
}
|
||||
|
||||
.navbar-tool {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--admin-radius-md);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.12s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--admin-brand-gradient-soft);
|
||||
color: var(--admin-brand-primary-dark);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Main stage (去掉双层白盒) ---------- */
|
||||
.main-wrap {
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, var(--admin-brand-mesh-a), transparent 34%),
|
||||
radial-gradient(circle at 100% 0%, var(--admin-brand-mesh-c), transparent 30%),
|
||||
radial-gradient(circle at 50% 100%, var(--admin-brand-mesh-b), transparent 38%),
|
||||
var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.main-stage {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--stage-padding-y) var(--stage-padding-x);
|
||||
min-height: calc(100vh - var(--navbar-height) - var(--tabs-height, 0px));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--stage-padding-x: 14px;
|
||||
--stage-padding-y: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.navbar-tool,
|
||||
.menu.theme-dark .el-menu .el-menu-item,
|
||||
.menu.theme-dark .el-menu .el-sub-menu__title {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.app-header {
|
||||
backdrop-filter: none;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
}
|
||||
+46
-31
@@ -1,43 +1,58 @@
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
--table-header-bg-color: var(--el-bg-color);
|
||||
--el-bg-color-page: #0a0a0a;
|
||||
--el-bg-color: #1d2124;
|
||||
--el-bg-color-overlay: #1d1e1f;
|
||||
--el-text-color-primary: #e5eaf3;
|
||||
--el-text-color-regular: #cfd3dc;
|
||||
--el-text-color-secondary: #a3a6ad;
|
||||
--el-text-color-placeholder: #8d9095;
|
||||
--el-text-color-disabled: #6c6e72;
|
||||
--el-border-color-darker: #636466;
|
||||
--el-border-color-dark: #58585b;
|
||||
--el-border-color: #4c4d4f;
|
||||
--el-border-color-light: #414243;
|
||||
--el-border-color-lighter: #363637;
|
||||
--el-border-color-extra-light: #2b2b2c;
|
||||
--el-fill-color-darker: #424243;
|
||||
--el-fill-color-dark: #39393a;
|
||||
--el-fill-color: #303030;
|
||||
--el-fill-color-light: #262727;
|
||||
--el-fill-color-lighter: #1d1d1d;
|
||||
--el-fill-color-extra-light: #191919;
|
||||
|
||||
--table-header-bg-color: rgba(6, 182, 212, 0.12);
|
||||
--sidebar-dark-bg: #030712;
|
||||
--sidebar-dark-hover: rgba(255, 255, 255, 0.05);
|
||||
--sidebar-dark-active: rgba(6, 182, 212, 0.2);
|
||||
|
||||
--admin-surface-elevated: #111827;
|
||||
--admin-surface-muted: rgba(17, 24, 39, 0.88);
|
||||
--admin-surface-glass: rgba(17, 24, 39, 0.86);
|
||||
|
||||
--el-bg-color-page: #030712;
|
||||
--el-bg-color: #0f172a;
|
||||
--el-bg-color-overlay: #1e293b;
|
||||
--el-text-color-primary: #f1f5f9;
|
||||
--el-text-color-regular: #cbd5e1;
|
||||
--el-text-color-secondary: #94a3b8;
|
||||
--el-text-color-placeholder: #64748b;
|
||||
--el-text-color-disabled: #475569;
|
||||
--el-border-color-darker: #64748b;
|
||||
--el-border-color-dark: #475569;
|
||||
--el-border-color: rgba(6, 182, 212, 0.18);
|
||||
--el-border-color-light: rgba(6, 182, 212, 0.12);
|
||||
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
|
||||
--el-border-color-extra-light: rgba(255, 255, 255, 0.04);
|
||||
--el-fill-color-darker: #334155;
|
||||
--el-fill-color-dark: #1e293b;
|
||||
--el-fill-color: #1e293b;
|
||||
--el-fill-color-light: #172033;
|
||||
--el-fill-color-lighter: #131c2e;
|
||||
--el-fill-color-extra-light: #0f172a;
|
||||
--el-fill-color-blank: var(--el-bg-color);
|
||||
--el-mask-color: rgba(0, 0, 0, 0.8);
|
||||
--el-mask-color-extra-light: rgba(0, 0, 0, 0.3);
|
||||
--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.36), 0px 8px 20px rgba(0, 0, 0, 0.72);
|
||||
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.72);
|
||||
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.72);
|
||||
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.72), 0px 12px 32px #000000,
|
||||
0px 8px 16px -8px #000000 !important;
|
||||
/* wangeditor主题 */
|
||||
|
||||
--el-mask-color: rgba(2, 6, 23, 0.78);
|
||||
--el-mask-color-extra-light: rgba(2, 6, 23, 0.42);
|
||||
|
||||
--el-box-shadow: 0 4px 24px rgba(0, 0, 0, 0.28);
|
||||
--el-box-shadow-light: 0 2px 16px rgba(0, 0, 0, 0.22);
|
||||
--el-box-shadow-lighter: 0 1px 4px rgba(0, 0, 0, 0.16);
|
||||
--el-box-shadow-dark: 0 16px 48px rgba(0, 0, 0, 0.36);
|
||||
|
||||
--admin-header-border: rgba(6, 182, 212, 0.14);
|
||||
--admin-sidebar-border: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--admin-brand-mesh-a: rgba(6, 182, 212, 0.12);
|
||||
--admin-brand-mesh-b: rgba(16, 185, 129, 0.08);
|
||||
--admin-brand-mesh-c: rgba(99, 102, 241, 0.06);
|
||||
|
||||
--w-e-textarea-bg-color: var(--el-bg-color);
|
||||
--w-e-textarea-color: var(--el-text-color-primary);
|
||||
--w-e-textarea-border-color: var(--el-border-color);
|
||||
--w-e-textarea-slight-border-color: var(--el-border-color-light);
|
||||
--w-e-textarea-slight-color: var(--el-border-color);
|
||||
--w-e-textarea-slight-bg-color: var(--el-bg-color-page);
|
||||
/* --w-e-textarea-selected-border-color: #b4d5ff;
|
||||
--w-e-textarea-handler-bg-color: #4290f7; */
|
||||
--w-e-toolbar-color: var(--el-text-color-primary);
|
||||
--w-e-toolbar-bg-color: var(--el-bg-color);
|
||||
--w-e-toolbar-active-color: var(--el-text-color-primary);
|
||||
|
||||
+246
-26
@@ -1,14 +1,20 @@
|
||||
:root {
|
||||
// 确保消息提示在最上层
|
||||
/* Messages & notifications */
|
||||
.el-message {
|
||||
z-index: 9999 !important;
|
||||
border-radius: var(--admin-radius-md);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
|
||||
|
||||
.el-notification {
|
||||
z-index: 9999 !important;
|
||||
border-radius: var(--admin-radius-lg);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
|
||||
// 弹窗居中
|
||||
|
||||
/* Dialog */
|
||||
.el-overlay-dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -19,11 +25,14 @@
|
||||
.el-dialog {
|
||||
--el-dialog-content-font-size: var(--el-font-size-base);
|
||||
--el-dialog-margin-top: 50px;
|
||||
max-width: calc(100vw - 30px);
|
||||
--el-dialog-border-radius: var(--admin-radius-lg);
|
||||
max-width: calc(100vw - 32px);
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--admin-radius-lg);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow-dark);
|
||||
|
||||
&.body-padding .el-dialog__body {
|
||||
padding: 0;
|
||||
@@ -31,50 +40,158 @@
|
||||
|
||||
.el-dialog__body {
|
||||
flex: 1;
|
||||
padding: 15px 20px;
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
font-size: var(--el-font-size-large);
|
||||
font-weight: 600;
|
||||
padding: 16px 20px 12px;
|
||||
margin-right: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Cards - applies to all list pages */
|
||||
.el-card {
|
||||
--el-card-border-radius: var(--admin-radius-lg);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: none;
|
||||
background: var(--admin-surface-elevated);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&.is-always-shadow,
|
||||
&.is-hover-shadow:hover {
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu */
|
||||
.el-menu {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
.el-drawer {
|
||||
--el-drawer-padding-primary: 16px;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 0;
|
||||
padding: 13px 16px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@apply text-tx-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-header-text-color: var(--el-text-color-primary);
|
||||
--el-table-header-bg-color: var(--table-header-bg-color);
|
||||
font-size: var(--el-font-size-base);
|
||||
|
||||
thead {
|
||||
th {
|
||||
font-weight: 400;
|
||||
}
|
||||
&__body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.el-table {
|
||||
--el-table-header-text-color: var(--el-text-color-primary);
|
||||
--el-table-header-bg-color: var(--table-header-bg-color);
|
||||
--el-table-border-color: var(--el-border-color-extra-light);
|
||||
--el-table-row-hover-bg-color: var(--el-fill-color-lighter);
|
||||
font-size: var(--el-font-size-base);
|
||||
border-radius: var(--admin-radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
thead {
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: var(--el-font-size-small);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
th.el-table__cell {
|
||||
background-color: var(--table-header-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.el-table__cell {
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
.el-input-group__prepend {
|
||||
background-color: var(--el-fill-color-blank);
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.el-checkbox {
|
||||
--el-checkbox-font-size: var(--el-font-size-base);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
--el-border-radius-base: var(--admin-radius-md);
|
||||
font-weight: 500;
|
||||
transition: transform 0.12s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:active:not(.is-disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--primary:not(.is-link):not(.is-text):not(.is-plain) {
|
||||
background: var(--admin-brand-gradient);
|
||||
border: none;
|
||||
box-shadow: var(--admin-brand-glow);
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: transparent;
|
||||
--el-button-hover-bg-color: transparent;
|
||||
--el-button-hover-border-color: transparent;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 12px 32px rgba(6, 182, 212, 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--primary.is-link,
|
||||
.el-button--primary.is-text {
|
||||
--el-button-hover-link-text-color: var(--admin-brand-primary-dark);
|
||||
}
|
||||
|
||||
.el-button--primary.is-plain {
|
||||
--el-button-bg-color: rgba(6, 182, 212, 0.1);
|
||||
--el-button-border-color: rgba(6, 182, 212, 0.35);
|
||||
--el-button-text-color: var(--admin-brand-primary-dark);
|
||||
--el-button-hover-bg-color: rgba(6, 182, 212, 0.16);
|
||||
--el-button-hover-border-color: var(--admin-brand-primary);
|
||||
}
|
||||
|
||||
.el-button--large {
|
||||
--el-border-radius-base: var(--admin-radius-md);
|
||||
}
|
||||
|
||||
.el-button.is-round {
|
||||
--el-border-radius-base: var(--admin-radius-round);
|
||||
}
|
||||
|
||||
/* Popup menus */
|
||||
.el-menu--popup-container {
|
||||
&.theme-light {
|
||||
.el-menu {
|
||||
@@ -83,12 +200,14 @@
|
||||
@apply bg-primary-light-9 border-primary border-r-2;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item:hover,
|
||||
.el-sub-menu__title:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.theme-dark {
|
||||
.el-menu {
|
||||
.el-menu-item {
|
||||
@@ -101,52 +220,120 @@
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
--el-messagebox-width: 350px;
|
||||
--el-messagebox-width: 380px;
|
||||
--el-messagebox-border-radius: var(--admin-radius-lg);
|
||||
}
|
||||
|
||||
.el-date-editor {
|
||||
--el-date-editor-datetimerange-width: 380px;
|
||||
|
||||
.el-range-input {
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
--el-button-hover-link-text-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
.el-button--success {
|
||||
--el-button-hover-link-text-color: var(--el-color-success-light-3);
|
||||
}
|
||||
|
||||
.el-button--info {
|
||||
--el-button-hover-link-text-color: var(--el-color-info-light-3);
|
||||
}
|
||||
|
||||
.el-button--warning {
|
||||
--el-button-hover-link-text-color: var(--el-color-warning-light-3);
|
||||
}
|
||||
|
||||
.el-button--danger {
|
||||
--el-button-hover-link-text-color: var(--el-color-danger-light-3);
|
||||
}
|
||||
|
||||
.el-image__error {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.el-tabs__nav-wrap::after {
|
||||
height: 1px;
|
||||
background: var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.el-tabs__item {
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&.is-active {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
|
||||
.el-tabs__active-bar {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--admin-brand-gradient);
|
||||
}
|
||||
|
||||
.el-page-header {
|
||||
&__breadcrumb {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.el-tag {
|
||||
--el-tag-border-radius: var(--admin-radius-sm);
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.el-pagination {
|
||||
.el-pager li {
|
||||
border-radius: var(--admin-radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
border-radius: var(--admin-radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
.el-alert {
|
||||
border-radius: var(--admin-radius-md);
|
||||
border: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.el-empty {
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
/* Descriptions */
|
||||
.el-descriptions {
|
||||
.el-descriptions__label {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus rings */
|
||||
.el-input,
|
||||
.el-select,
|
||||
.el-textarea {
|
||||
@apply shadow-primary-light-8;
|
||||
|
||||
box-shadow: 0 0 0 0 var(--tw-shadow-color);
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--tw-shadow-color);
|
||||
border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
|
||||
transition: box-shadow ease 0.1s;
|
||||
transition: box-shadow ease 0.15s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +343,7 @@
|
||||
border-radius: var(--el-checkbox-border-radius);
|
||||
|
||||
box-shadow: 0 0 0 0 var(--tw-shadow-color);
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 2px var(--tw-shadow-color);
|
||||
transition: box-shadow ease 0s;
|
||||
@@ -173,29 +361,61 @@
|
||||
.el-form-item.is-error .el-checkbox {
|
||||
@apply shadow-danger-light-8;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.el-dropdown-menu {
|
||||
border-radius: var(--admin-radius-md);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
border-radius: var(--admin-radius-sm);
|
||||
}
|
||||
|
||||
/* Popover / tooltip polish */
|
||||
.el-popover.el-popper {
|
||||
border-radius: var(--admin-radius-md);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.el-pagination > .el-pagination__jump {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.el-pagination > .el-pagination__sizes {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button {
|
||||
// 防止被tailwindcss默认样式覆盖
|
||||
background-color: var(--el-button-bg-color, var(--el-color-white));
|
||||
|
||||
//覆盖el-button的点击样式
|
||||
&:focus {
|
||||
color: var(--el-button-text-color);
|
||||
border-color: var(--el-button-border-color);
|
||||
background-color: var(--el-button-bg-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-button-hover-text-color);
|
||||
border-color: var(--el-button-hover-border-color);
|
||||
background-color: var(--el-button-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline form filter blocks on list pages */
|
||||
.el-card .el-form--inline {
|
||||
.el-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Page section spacing between stacked cards */
|
||||
.el-card + .el-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@import 'element.scss';
|
||||
@import 'dark.css';
|
||||
@import 'var.css';
|
||||
@import 'dark.css';
|
||||
@import 'tailwind.css';
|
||||
@import 'element.scss';
|
||||
@import 'admin-shell.scss';
|
||||
@import 'admin-pages.scss';
|
||||
@import 'public.scss';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
body {
|
||||
@apply text-base text-tx-primary overflow-hidden min-w-[375px];
|
||||
font-feature-settings: 'kern' 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.form-tips {
|
||||
@apply text-tx-secondary text-xs leading-6 mt-1;
|
||||
}
|
||||
@@ -12,7 +16,51 @@ body {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--el-border-color-dark);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
/* NProgress */
|
||||
#nprogress .bar {
|
||||
@apply bg-primary #{!important};
|
||||
height: 2px !important;
|
||||
}
|
||||
|
||||
#nprogress .peg {
|
||||
box-shadow: 0 0 8px var(--el-color-primary), 0 0 4px var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
/* Shared page utilities */
|
||||
.admin-page-title {
|
||||
@apply text-xl font-semibold text-tx-primary tracking-tight;
|
||||
}
|
||||
|
||||
.admin-page-desc {
|
||||
@apply text-sm text-tx-secondary mt-1;
|
||||
}
|
||||
|
||||
.admin-stat-value {
|
||||
@apply text-3xl font-semibold text-tx-primary tabular-nums;
|
||||
}
|
||||
|
||||
.admin-stat-label {
|
||||
@apply text-sm text-tx-secondary;
|
||||
}
|
||||
|
||||
+85
-35
@@ -1,49 +1,99 @@
|
||||
:root {
|
||||
/* Typography */
|
||||
--el-font-family: theme(fontFamily.sans);
|
||||
--el-font-weight-primary: 400;
|
||||
--el-menu-item-height: 46px;
|
||||
--el-menu-sub-item-height: var(--el-menu-item-height);
|
||||
--el-menu-icon-width: 18px;
|
||||
--aside-width: 200px;
|
||||
--navbar-height: 50px;
|
||||
--color-white: #ffffff;
|
||||
--table-header-bg-color: #f8f8f8;
|
||||
--el-font-size-extra-large: 18px;
|
||||
--el-menu-base-level-padding: 16px;
|
||||
--el-menu-level-padding: 26px;
|
||||
--el-font-size-large: 16px;
|
||||
--el-font-size-medium: 15px;
|
||||
--el-font-size-base: 14px;
|
||||
--el-font-size-small: 13px;
|
||||
--el-font-size-extra-small: 12px;
|
||||
|
||||
/* Brand - MedTech 绚丽临床 */
|
||||
--admin-brand-primary: #06b6d4;
|
||||
--admin-brand-primary-dark: #0891b2;
|
||||
--admin-brand-accent: #10b981;
|
||||
--admin-brand-secondary: #6366f1;
|
||||
--admin-brand-success: #10b981;
|
||||
--admin-brand-gradient: linear-gradient(120deg, #06b6d4 0%, #10b981 48%, #0891b2 100%);
|
||||
--admin-brand-gradient-soft: linear-gradient(
|
||||
120deg,
|
||||
rgba(6, 182, 212, 0.16) 0%,
|
||||
rgba(16, 185, 129, 0.1) 50%,
|
||||
rgba(8, 145, 178, 0.08) 100%
|
||||
);
|
||||
--admin-brand-glow: 0 0 24px rgba(6, 182, 212, 0.35);
|
||||
--admin-brand-mesh-a: rgba(6, 182, 212, 0.18);
|
||||
--admin-brand-mesh-b: rgba(16, 185, 129, 0.14);
|
||||
--admin-brand-mesh-c: rgba(99, 102, 241, 0.1);
|
||||
|
||||
/* Layout shell v2 */
|
||||
--aside-width: 248px;
|
||||
--navbar-height: 60px;
|
||||
--tabs-height: 42px;
|
||||
--stage-padding-x: 28px;
|
||||
--stage-padding-y: 24px;
|
||||
--content-max-width: 1680px;
|
||||
--sidebar-rail-width: 3px;
|
||||
|
||||
/* Shape */
|
||||
--admin-radius-sm: 8px;
|
||||
--admin-radius-md: 12px;
|
||||
--admin-radius-lg: 16px;
|
||||
--admin-radius-xl: 22px;
|
||||
--admin-radius-2xl: 28px;
|
||||
--el-border-radius-base: var(--admin-radius-md);
|
||||
--el-border-radius-small: var(--admin-radius-sm);
|
||||
--el-border-radius-round: 999px;
|
||||
|
||||
/* Menu */
|
||||
--el-menu-item-height: 46px;
|
||||
--el-menu-sub-item-height: var(--el-menu-item-height);
|
||||
--el-menu-icon-width: 20px;
|
||||
--el-menu-base-level-padding: 14px;
|
||||
--el-menu-level-padding: 22px;
|
||||
|
||||
/* Surfaces */
|
||||
--color-white: #ffffff;
|
||||
--table-header-bg-color: rgba(6, 182, 212, 0.06);
|
||||
--sidebar-dark-bg: #060d18;
|
||||
--sidebar-dark-hover: rgba(255, 255, 255, 0.05);
|
||||
--sidebar-dark-active: rgba(6, 182, 212, 0.14);
|
||||
--admin-surface-elevated: #ffffff;
|
||||
--admin-surface-muted: rgba(255, 255, 255, 0.72);
|
||||
--admin-surface-glass: rgba(255, 255, 255, 0.82);
|
||||
|
||||
--el-bg-color: var(--color-white);
|
||||
--el-bg-color-page: #f6f6f6;
|
||||
--el-bg-color-page: #eef6fb;
|
||||
--el-bg-color-overlay: #ffffff;
|
||||
--el-text-color-primary: #333333;
|
||||
--el-text-color-regular: #666666;
|
||||
--el-text-color-secondary: #999999;
|
||||
--el-text-color-placeholder: #a8abb2;
|
||||
--el-text-color-disabled: #c0c4cc;
|
||||
--el-border-color: #dcdfe6;
|
||||
--el-border-color-light: #e4e7ed;
|
||||
--el-border-color-lighter: #ebeef5;
|
||||
--el-border-color-extra-light: #f2f2f2;
|
||||
--el-border-color-dark: #d4d7de;
|
||||
--el-border-color-darker: #cdd0d6;
|
||||
--el-fill-color: #f0f2f5;
|
||||
--el-fill-color-light: #f8f8f8;
|
||||
--el-fill-color-lighter: #fafafa;
|
||||
--el-fill-color-extra-light: #fafcff;
|
||||
--el-fill-color-dark: #ebedf0;
|
||||
--el-fill-color-darker: #e6e8eb;
|
||||
--el-text-color-primary: #0b1220;
|
||||
--el-text-color-regular: #334155;
|
||||
--el-text-color-secondary: #64748b;
|
||||
--el-text-color-placeholder: #94a3b8;
|
||||
--el-text-color-disabled: #cbd5e1;
|
||||
--el-border-color: rgba(6, 182, 212, 0.12);
|
||||
--el-border-color-light: rgba(6, 182, 212, 0.08);
|
||||
--el-border-color-lighter: rgba(15, 23, 42, 0.06);
|
||||
--el-border-color-extra-light: rgba(15, 23, 42, 0.04);
|
||||
--el-border-color-dark: #cbd5e1;
|
||||
--el-border-color-darker: #94a3b8;
|
||||
--el-fill-color: #f1f5f9;
|
||||
--el-fill-color-light: #f8fafc;
|
||||
--el-fill-color-lighter: #fafbfc;
|
||||
--el-fill-color-extra-light: #fcfdfe;
|
||||
--el-fill-color-dark: #e2e8f0;
|
||||
--el-fill-color-darker: #cbd5e1;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
/* 过亮会盖住抽屉/弹窗下的内容;Element Loading 与部分蒙层共用此变量 */
|
||||
--el-mask-color: rgba(255, 255, 255, 0.5);
|
||||
--el-mask-color-extra-light: rgba(255, 255, 255, 0.22);
|
||||
-el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08);
|
||||
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.12);
|
||||
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.12);
|
||||
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12),
|
||||
0px 8px 16px -8px rgba(0, 0, 0, 0.16);
|
||||
|
||||
--el-mask-color: rgba(6, 13, 24, 0.55);
|
||||
--el-mask-color-extra-light: rgba(6, 13, 24, 0.1);
|
||||
|
||||
--el-box-shadow: 0 4px 24px rgba(6, 182, 212, 0.08), 0 12px 40px rgba(15, 23, 42, 0.06);
|
||||
--el-box-shadow-light: 0 2px 16px rgba(6, 182, 212, 0.1);
|
||||
--el-box-shadow-lighter: 0 1px 4px rgba(15, 23, 42, 0.04);
|
||||
--el-box-shadow-dark: 0 16px 48px rgba(6, 182, 212, 0.14), 0 32px 64px rgba(15, 23, 42, 0.1);
|
||||
|
||||
--admin-header-border: rgba(6, 182, 212, 0.1);
|
||||
--admin-sidebar-border: rgba(255, 255, 255, 0.06);
|
||||
--admin-stage-bg: transparent;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="change-password flex flex-col">
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="change-password-card bg-body rounded-md px-10 py-10 w-[480px]">
|
||||
<div class="text-center text-2xl font-medium mb-2">首次登录</div>
|
||||
<div class="text-center text-gray-500 text-sm mb-8">为了您的账号安全,请修改初始密码</div>
|
||||
<div class="change-password-backdrop" aria-hidden="true"></div>
|
||||
<div class="flex-1 flex items-center justify-center relative z-[1] px-4">
|
||||
<div class="change-password-card">
|
||||
<div class="text-center text-2xl font-semibold mb-2 text-tx-primary">首次登录</div>
|
||||
<div class="text-center text-tx-secondary text-sm mb-8">为了您的账号安全,请修改初始密码</div>
|
||||
|
||||
<el-form ref="formRef" :model="formData" size="large" :rules="rules">
|
||||
<el-form-item prop="password">
|
||||
@@ -117,7 +118,25 @@ const { isLock, lockFn: lockSubmit } = useLockFn(handleSubmit)
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.change-password {
|
||||
background-image: url('./images/login_bg.png');
|
||||
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.change-password-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(15, 118, 110, 0.35), transparent 42%),
|
||||
linear-gradient(160deg, #0f172a 0%, #111827 48%, #0b1120 100%);
|
||||
}
|
||||
|
||||
.change-password-card {
|
||||
width: min(480px, 100%);
|
||||
padding: 40px 36px;
|
||||
border-radius: var(--admin-radius-xl);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 24px 64px rgba(2, 6, 23, 0.45);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
<template>
|
||||
<div class="login flex flex-col">
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="login-card flex rounded-md overflow-hidden">
|
||||
<div class="flex-1 h-full hidden md:inline-block">
|
||||
<image-contain :src="config.login_image" :width="400" height="100%" />
|
||||
<div class="login-v2">
|
||||
<div class="login-v2__mesh" aria-hidden="true"></div>
|
||||
<div class="login-v2__layout">
|
||||
<aside class="login-v2__brand">
|
||||
<div class="login-v2__brand-inner">
|
||||
<div class="login-v2__mark">ZYT</div>
|
||||
<p class="login-v2__kicker">互联网医院</p>
|
||||
<h1 class="login-v2__headline">医生与医助<br />智慧工作台</h1>
|
||||
<p class="login-v2__lead">问诊、处方、订单与运营数据,一屏协同。</p>
|
||||
<div v-if="config.login_image" class="login-v2__visual hidden xl:block">
|
||||
<image-contain :src="config.login_image" :width="420" height="280" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="login-form bg-body flex flex-col justify-center px-10 py-10 md:w-[420px] w-[380px] flex-none mx-auto"
|
||||
>
|
||||
<div class="text-center text-3xl font-medium mb-8">{{ config.web_name }}</div>
|
||||
</aside>
|
||||
|
||||
<!-- 企业微信自动授权中 -->
|
||||
<div v-if="wxWorkAutoLogin" class="text-center py-10">
|
||||
<section class="login-v2__panel">
|
||||
<div class="login-v2__glass">
|
||||
<div class="login-v2__form-head">
|
||||
<h2 class="login-v2__form-title">{{ config.web_name }}</h2>
|
||||
<p class="login-v2__form-sub">登录后继续你的接诊与管理工作</p>
|
||||
</div>
|
||||
|
||||
<div v-if="wxWorkAutoLogin" class="login-v2__loading">
|
||||
<el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<div class="text-gray-500">企业微信授权登录中...</div>
|
||||
<div class="text-tx-secondary">企业微信授权登录中...</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 登录方式切换标签 -->
|
||||
<div v-if="wxWorkEnabled" class="flex justify-center mb-6">
|
||||
<div v-if="wxWorkEnabled" class="login-v2__mode">
|
||||
<el-radio-group v-model="loginMode" size="large">
|
||||
<el-radio-button value="account">账号登录</el-radio-button>
|
||||
<el-radio-button value="wxwork">企业微信</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录 -->
|
||||
<template v-if="loginMode === 'account'">
|
||||
<el-form ref="formRef" :model="formData" size="large" :rules="rules">
|
||||
<el-form-item prop="account">
|
||||
@@ -58,29 +66,34 @@
|
||||
<div class="mb-5">
|
||||
<el-checkbox v-model="remAccount" label="记住账号"></el-checkbox>
|
||||
</div>
|
||||
<el-button type="primary" size="large" :loading="isLock" @click="lockLogin">
|
||||
登录
|
||||
<el-button
|
||||
class="login-v2__submit"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="isLock"
|
||||
@click="lockLogin"
|
||||
>
|
||||
进入工作台
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 企业微信扫码登录(非企业微信内浏览器) -->
|
||||
<template v-if="loginMode === 'wxwork'">
|
||||
<div class="wxwork-qrcode-wrap">
|
||||
<div v-if="wxWorkLoading" class="text-center py-10">
|
||||
<el-icon class="is-loading" :size="32" color="var(--el-color-primary)">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<div class="mt-2 text-gray-400 text-sm">加载企业微信扫码...</div>
|
||||
<div class="mt-2 text-tx-secondary text-sm">加载企业微信扫码...</div>
|
||||
</div>
|
||||
<div v-else id="wxwork_qrcode_container" class="wxwork-qrcode"></div>
|
||||
</div>
|
||||
<div class="text-center text-sm text-gray-400 mt-4">
|
||||
<div class="text-center text-sm text-tx-secondary mt-4">
|
||||
请使用企业微信扫描二维码登录
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<layout-footer />
|
||||
</div>
|
||||
@@ -286,31 +299,178 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login {
|
||||
background-image: url('./images/login_bg.png');
|
||||
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
||||
.login-card {
|
||||
height: auto;
|
||||
min-height: 400px;
|
||||
.login-v2 {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #030712;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-v2__mesh {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 18%, rgba(6, 182, 212, 0.35), transparent 42%),
|
||||
radial-gradient(circle at 88% 12%, rgba(16, 185, 129, 0.28), transparent 38%),
|
||||
radial-gradient(circle at 70% 88%, rgba(99, 102, 241, 0.18), transparent 40%),
|
||||
linear-gradient(155deg, #030712 0%, #0b1220 45%, #060d18 100%);
|
||||
}
|
||||
|
||||
.login-v2__layout {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
min-height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-v2__layout {
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(420px, 520px);
|
||||
}
|
||||
}
|
||||
|
||||
.login-v2__brand {
|
||||
display: none;
|
||||
padding: 48px 56px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.login-v2__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.login-v2__brand-inner {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.login-v2__mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
background: var(--admin-brand-gradient);
|
||||
box-shadow: var(--admin-brand-glow);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-v2__kicker {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.login-v2__headline {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 4vw, 44px);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.login-v2__lead {
|
||||
margin: 16px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.login-v2__visual {
|
||||
margin-top: 36px;
|
||||
border-radius: var(--admin-radius-xl);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.login-v2__panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
.login-v2__glass {
|
||||
width: min(440px, 100%);
|
||||
padding: 36px 32px;
|
||||
border-radius: var(--admin-radius-2xl);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
box-shadow: var(--el-box-shadow-dark);
|
||||
}
|
||||
|
||||
.login-v2__form-head {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-v2__form-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.login-v2__form-sub {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.login-v2__mode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-v2__submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-v2__loading {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.wxwork-qrcode-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.wxwork-qrcode {
|
||||
width: 340px;
|
||||
height: 400px;
|
||||
width: 320px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(iframe) {
|
||||
width: 340px !important;
|
||||
height: 400px !important;
|
||||
width: 320px !important;
|
||||
height: 360px !important;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.login-v2__glass {
|
||||
background: #ffffff;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="温馨提示:用于管理网站的分类,只可添加到一级"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button
|
||||
class="mb-4"
|
||||
v-perms="['article.articleCate/add']"
|
||||
type="primary"
|
||||
@click="handleAdd()"
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="栏目名称" prop="name" min-width="120" />
|
||||
<el-table-column label="文章数" prop="article_count" min-width="120" />
|
||||
@@ -58,10 +58,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="article-lists">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="文章标题">
|
||||
<el-input
|
||||
@@ -33,24 +33,25 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<router-link
|
||||
v-perms="['article.article/add', 'article.article/add:edit']"
|
||||
:to="{
|
||||
path: getRoutePath('article.article/add:edit')
|
||||
}"
|
||||
>
|
||||
<el-button type="primary" class="mb-4">
|
||||
<el-button type="primary">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
发布文章
|
||||
</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
</template>
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="ID" prop="id" min-width="80" />
|
||||
<el-table-column label="封面" min-width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -116,10 +117,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="articleLists">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="asset-resource-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="标题">
|
||||
<el-input
|
||||
@@ -28,21 +28,26 @@
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<admin-page-data-panel v-loading="loading">
|
||||
<template #toolbar>
|
||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||
</div>
|
||||
|
||||
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} 条</span>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="图片" name="1"></el-tab-pane>
|
||||
<el-tab-pane label="视频" name="2"></el-tab-pane>
|
||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<el-table :data="tableData" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="title" label="标题" min-width="80" />
|
||||
<el-table-column label="预览" width="100">
|
||||
@@ -66,7 +71,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<template #footer>
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
@@ -75,8 +80,8 @@
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
|
||||
<!-- 新增/编辑资源弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
|
||||
@@ -92,7 +97,10 @@
|
||||
<el-input v-model="formData.title" placeholder="请输入资源标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
|
||||
<material-picker v-if="formData.type === 1" v-model="formData.file_url" :limit="1" type="image" />
|
||||
<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>
|
||||
<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>
|
||||
@@ -126,8 +134,9 @@ import MaterialPicker from '@/components/material/picker.vue'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const searchData = reactive<any>({
|
||||
title: '',
|
||||
@@ -156,6 +165,15 @@ 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 {
|
||||
@@ -164,7 +182,7 @@ const computedRules = computed(() => {
|
||||
}
|
||||
return {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
file_url: [{ required: true, message: '请输入或上传文件获取链接', trigger: 'blur' }]
|
||||
file_url: [{ required: true, validator: validateFileUrl, trigger: 'change' }]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,6 +204,7 @@ const getList = async () => {
|
||||
const res = await apiAssetResourceList(buildQueryParams())
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
selectedIds.value = []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
@@ -257,6 +276,22 @@ const handleDelete = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedIds.value = rows.map((row) => Number(row.id)).filter((id) => id > 0)
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (!selectedIds.value.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个资源吗?用户将无法再看到。`, '提示', { type: 'warning' })
|
||||
await apiAssetResourceDelete({ id: selectedIds.value })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="asset-user-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="手机号">
|
||||
<el-input
|
||||
@@ -16,17 +16,31 @@
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<admin-page-data-panel v-loading="loading">
|
||||
<template #toolbar>
|
||||
<el-button type="primary" @click="handleAdd">新增账号</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column label="备注" min-width="200">
|
||||
<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 }">
|
||||
@@ -49,7 +63,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<template #footer>
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
@@ -58,8 +72,8 @@
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
|
||||
<!-- 编辑/新增弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
|
||||
@@ -70,6 +84,16 @@
|
||||
<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>
|
||||
@@ -79,12 +103,30 @@
|
||||
<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)
|
||||
@@ -105,10 +147,17 @@ 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
|
||||
})
|
||||
|
||||
@@ -154,16 +203,41 @@ const handleStatusChange = async (row: any) => {
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = '新增账号'
|
||||
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
|
||||
Object.assign(formData, { id: '', phone: '', password: '', remark: '', status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑账号'
|
||||
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
|
||||
Object.assign(formData, { id: row.id, phone: row.phone, password: '', remark: row.remark || '', 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' })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="用户信息">
|
||||
<el-input
|
||||
@@ -37,8 +37,9 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel>
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="头像" min-width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -67,10 +68,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="consumerLists">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 处方业务订单——纯展示/格式化工具
|
||||
*
|
||||
* 唯一数据源:order_list.vue(处方业务订单)、PatientOrderList.vue(诊单业务订单 Tab)
|
||||
* 与 PrescriptionOrderDetailDrawer.vue(共享详情抽屉)共用,禁止在页面内重复定义同名函数,
|
||||
* 否则两个详情页面会再次出现文案/口径漂移。
|
||||
*/
|
||||
|
||||
/** 与诊间医助角色 ID 一致(server 角色表) */
|
||||
export const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
export function formatTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||
const d = new Date(v * 1000)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
export function formatMoney(v: unknown) {
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return '—'
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatOrderTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'string' && String(v).includes('-')) return String(v)
|
||||
return formatTime(v)
|
||||
}
|
||||
|
||||
export function feeTypeText(t: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '挂号费',
|
||||
2: '问诊费',
|
||||
3: '药品费用',
|
||||
4: '首付费用',
|
||||
5: '尾款费用',
|
||||
6: '其他费用',
|
||||
7: '全部费用',
|
||||
8: '驼奶费用'
|
||||
}
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
export function fulfillmentText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待双审通过',
|
||||
2: '待发货',
|
||||
3: '已完成',
|
||||
4: '已取消',
|
||||
5: '已发货',
|
||||
6: '已签收',
|
||||
7: '进行中',
|
||||
8: '暂不制药',
|
||||
9: '拒收',
|
||||
10: '退款',
|
||||
11: '保留药方',
|
||||
12: '制药缓发'
|
||||
}
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
const n = Number(s)
|
||||
if (n === 3) return 'success'
|
||||
if (n === 6) return 'success'
|
||||
if (n === 5) return 'primary'
|
||||
if (n === 4) return 'danger'
|
||||
if (n === 2) return 'warning'
|
||||
if (n === 7) return 'warning'
|
||||
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||
if (n === 9 || n === 10) return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function orderStatusText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待支付',
|
||||
2: '已支付',
|
||||
3: '已取消',
|
||||
4: '已退款',
|
||||
5: '待审核'
|
||||
}
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
export function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 2) return 'success'
|
||||
if (n === 1) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function consumerRxAuditText(s: number | undefined) {
|
||||
const n = Number(s)
|
||||
if (n === 1) return '已通过'
|
||||
if (n === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
export function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 1) return 'success'
|
||||
if (n === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
export function expressCompanyLabel(v: unknown) {
|
||||
const s = String(v || '').toLowerCase()
|
||||
if (s === 'sf') return '顺丰速运'
|
||||
if (s === 'jd') return '京东快递'
|
||||
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||
return '自动识别'
|
||||
}
|
||||
|
||||
export function logActionText(act: string) {
|
||||
const m: Record<string, string> = {
|
||||
create: '创建',
|
||||
edit: '编辑',
|
||||
audit_rx_approve: '处方审核',
|
||||
audit_rx_reject: '处方审核',
|
||||
audit_pay_approve: '支付审核',
|
||||
audit_pay_reject: '支付审核',
|
||||
fill_tracking: '填快递单',
|
||||
ship: '确认发货',
|
||||
withdraw: '撤销',
|
||||
link_pay_order: '关联支付单',
|
||||
completion_request: '完单申请',
|
||||
auto_complete: '自动完成',
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
refund: '退款'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
|
||||
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
|
||||
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||
const createType = String(row?.create_type || '')
|
||||
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||
if (createType === 'fubei') return '付呗'
|
||||
if (createType === 'express_cod') return '快递代收'
|
||||
const paymentMethod = String(row?.payment_method || '')
|
||||
const methodMap: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信',
|
||||
wechat_work: '企业微信',
|
||||
fubei: '付呗',
|
||||
express_cod: '快递代收',
|
||||
manual: '手动确认到账'
|
||||
}
|
||||
if (paymentMethod && methodMap[paymentMethod]) {
|
||||
return methodMap[paymentMethod]
|
||||
}
|
||||
return '普通订单'
|
||||
}
|
||||
|
||||
export function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
}
|
||||
|
||||
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
|
||||
const a = normalizeBizPhone(recipient)
|
||||
const b = normalizeBizPhone(rxPhone)
|
||||
if (!a || !b) return false
|
||||
return a !== b
|
||||
}
|
||||
|
||||
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((x: any) => ({
|
||||
name: String(x?.name ?? '').trim(),
|
||||
dosage: Number(x?.dosage) || 0
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export type SlipFormulaType = '主方' | '辅方'
|
||||
|
||||
export interface SlipAuxUsageForm {
|
||||
dosage_amount?: number
|
||||
dosage_bag_count: number
|
||||
need_decoction: boolean
|
||||
bags_per_dose: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
}
|
||||
|
||||
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
|
||||
return v === '辅方' ? '辅方' : '主方'
|
||||
}
|
||||
|
||||
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
|
||||
if (prescriptionType === '饮片') {
|
||||
return {
|
||||
dosage_amount: 50,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
if (prescriptionType === '浓缩水丸') {
|
||||
return {
|
||||
dosage_amount: 5,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
return {
|
||||
dosage_amount: 1,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
|
||||
const base = defaultSlipAuxUsage(prescriptionType)
|
||||
if (!raw || typeof raw !== 'object') return { ...base }
|
||||
const o = raw as Record<string, unknown>
|
||||
return {
|
||||
dosage_amount:
|
||||
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
|
||||
? Number(o.dosage_amount)
|
||||
: base.dosage_amount,
|
||||
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
|
||||
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
|
||||
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
|
||||
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
|
||||
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula) */
|
||||
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!rx) return false
|
||||
const herbs = rx.herbs
|
||||
if (Array.isArray(herbs)) {
|
||||
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
|
||||
}
|
||||
return Number(rx.has_aux_formula) === 1
|
||||
}
|
||||
|
||||
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
|
||||
export const LOGISTICS_URGENT_KEYWORDS = [
|
||||
'拒收',
|
||||
'拒签',
|
||||
'退件',
|
||||
'客户拒收',
|
||||
'拦截退回',
|
||||
'退回发件',
|
||||
'派件退回',
|
||||
'无人签收',
|
||||
'退回快件'
|
||||
]
|
||||
|
||||
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
|
||||
const t = String(s ?? '')
|
||||
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
|
||||
}
|
||||
|
||||
export function logisticsTraceLineUrgent(context: unknown): boolean {
|
||||
return logisticsStringHasUrgentKeyword(context)
|
||||
}
|
||||
|
||||
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
|
||||
export function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
|
||||
show: boolean
|
||||
keywords: string[]
|
||||
lines: string[]
|
||||
} {
|
||||
if (!p) {
|
||||
return { show: false, keywords: [], lines: [] }
|
||||
}
|
||||
const traces = Array.isArray(p.traces)
|
||||
? (p.traces as Array<{ time?: string; context?: string }>)
|
||||
: []
|
||||
const chunks: string[] = []
|
||||
if (p.state_text) chunks.push(String(p.state_text))
|
||||
if (p.hint) chunks.push(String(p.hint))
|
||||
for (const row of traces) {
|
||||
if (row.context) chunks.push(String(row.context))
|
||||
}
|
||||
const joined = chunks.join('\n')
|
||||
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
|
||||
if (!keywords.length) {
|
||||
return { show: false, keywords: [], lines: [] }
|
||||
}
|
||||
const lines: string[] = []
|
||||
for (const row of traces) {
|
||||
const ctx = String(row.context || '')
|
||||
if (logisticsStringHasUrgentKeyword(ctx)) {
|
||||
lines.push(ctx)
|
||||
if (lines.length >= 5) break
|
||||
}
|
||||
}
|
||||
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
|
||||
lines.push(`最新状态:${p.state_text}`)
|
||||
}
|
||||
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
|
||||
lines.push(String(p.hint))
|
||||
}
|
||||
return { show: true, keywords, lines }
|
||||
}
|
||||
|
||||
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
|
||||
export function parseLogisticsTracePayload(
|
||||
res: unknown,
|
||||
expectedTrackingNumber: string,
|
||||
expectedOrderId: number
|
||||
): Record<string, any> | null {
|
||||
const raw = (res as { data?: unknown })?.data ?? res
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const payload = raw as Record<string, any>
|
||||
const respNum = String(payload.tracking_number || '').trim()
|
||||
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||
const respOrderId = Number(payload.order_id)
|
||||
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
|
||||
if (!row?.id) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4
|
||||
}
|
||||
|
||||
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
|
||||
export function formatDietaryTaboo(raw: unknown): string {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join('、')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
<el-radio-button label="rejected">驳回</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="pl-toolbar__sep" aria-hidden="true" />
|
||||
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
|
||||
<span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
|
||||
<el-radio-group
|
||||
v-model="formData.source_filter"
|
||||
size="small"
|
||||
@@ -48,7 +48,7 @@
|
||||
>
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="manual">手工</el-radio-button>
|
||||
<el-radio-button label="system">系统代开</el-radio-button>
|
||||
<el-radio-button label="system">空白处方</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="pl-toolbar__line pl-toolbar__line--second">
|
||||
@@ -146,7 +146,7 @@
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
系统代开
|
||||
空白处方
|
||||
</el-tag>
|
||||
<span v-else class="text-gray-400 text-sm">手工</span>
|
||||
</template>
|
||||
@@ -290,7 +290,7 @@
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>系统代开</el-tag>
|
||||
>空白处方</el-tag>
|
||||
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
|
||||
<el-tag
|
||||
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
|
||||
@@ -437,6 +437,7 @@
|
||||
<p>主方服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
@@ -458,7 +459,9 @@
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
|
||||
<div v-if="!slipView.doctor_signature" class="rx-bot-doctor-name">
|
||||
{{ slipView.doctor_name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
@@ -679,17 +682,21 @@
|
||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
|
||||
</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" @click="addHerb('主方')">添加主方药材</el-button>
|
||||
<el-button type="primary" size="small" :disabled="herbsLocked" @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" class="w-full" />
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -699,6 +706,7 @@
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
:disabled="herbsLocked"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
@@ -706,7 +714,8 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
<span v-else class="text-gray-400 text-xs">已锁定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -715,7 +724,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 @click="addHerb('辅方')">
|
||||
<el-button type="primary" size="small" plain :disabled="herbsLocked" @click="addHerb('辅方')">
|
||||
添加辅方药材
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -723,7 +732,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -733,6 +742,7 @@
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
:disabled="herbsLocked"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
@@ -740,7 +750,8 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
<span v-else class="text-gray-400 text-xs">已锁定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -1698,7 +1709,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 }
|
||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||
|
||||
type AuxUsageForm = {
|
||||
dosage_amount?: number
|
||||
@@ -1798,11 +1809,15 @@ function normalizeFormulaType(v: unknown): FormulaType {
|
||||
}
|
||||
|
||||
function normalizeHerbRow(raw: any): HerbRow {
|
||||
return {
|
||||
const row: HerbRow = {
|
||||
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[] {
|
||||
@@ -1842,7 +1857,7 @@ const formData = reactive({
|
||||
creator_ids: [] as number[],
|
||||
/** all | passed | not_passed | pending | rejected(all 表示不限,接口传参会转为空) */
|
||||
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: ''
|
||||
@@ -2481,6 +2496,12 @@ 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('、')
|
||||
@@ -3441,10 +3462,18 @@ const handleImportLibrary = (row: any) => {
|
||||
return
|
||||
}
|
||||
const formulaType = normalizeFormulaType(row.formula_type)
|
||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
|
||||
...normalizeHerbRow(h),
|
||||
formula_type: formulaType
|
||||
}))
|
||||
// 「禁用修改」模板:导入药材打上锁定标记 → 整张处方锁定(不可增删改,只能再次导入覆盖)
|
||||
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
|
||||
})
|
||||
if (libraryImportMode.value === 'replace') {
|
||||
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||
editForm.herbs = [...kept, ...imported]
|
||||
@@ -3650,6 +3679,10 @@ async function handlePasteRecipeImport() {
|
||||
|
||||
// 添加药材(主方 / 辅方)
|
||||
const addHerb = (formulaType: FormulaType = '主方') => {
|
||||
if (herbsLocked.value) {
|
||||
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
|
||||
return
|
||||
}
|
||||
editForm.herbs.push({
|
||||
name: '',
|
||||
dosage: 0,
|
||||
@@ -3659,6 +3692,10 @@ const addHerb = (formulaType: FormulaType = '主方') => {
|
||||
|
||||
// 删除药材
|
||||
const removeHerb = (index: number) => {
|
||||
if (herbsLocked.value) {
|
||||
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
|
||||
return
|
||||
}
|
||||
editForm.herbs.splice(index, 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,13 @@
|
||||
</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">
|
||||
@@ -193,6 +200,21 @@
|
||||
勾选后,所有医生都可以查看和使用此处方模板
|
||||
</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'">
|
||||
@@ -236,7 +258,8 @@ const editForm = reactive({
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
is_public: 0
|
||||
is_public: 0,
|
||||
disable_edit: 0
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
@@ -293,6 +316,7 @@ const resetForm = () => {
|
||||
editForm.formula_type = '主方'
|
||||
editForm.herbs = []
|
||||
editForm.is_public = 0
|
||||
editForm.disable_edit = 0
|
||||
}
|
||||
|
||||
// 新增处方
|
||||
@@ -310,6 +334,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -321,6 +346,7 @@ 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,18 +190,17 @@
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</div>
|
||||
<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 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-item class="w-[260px]" label="订单号">
|
||||
<el-input
|
||||
v-model="queryParams.order_no"
|
||||
@@ -703,49 +702,50 @@
|
||||
</div>
|
||||
|
||||
<!-- 顶部数据概览卡片 -->
|
||||
<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'"
|
||||
<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"
|
||||
>
|
||||
¥{{ 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>
|
||||
修改
|
||||
</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>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<!-- 左侧:处方单 -->
|
||||
@@ -894,6 +894,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detailDietaryText" label="忌口" :span="2">
|
||||
{{ detailDietaryText }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">
|
||||
{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}
|
||||
@@ -962,6 +965,11 @@
|
||||
<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 }">
|
||||
@@ -1020,6 +1028,11 @@
|
||||
<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 }">
|
||||
@@ -1285,7 +1298,7 @@
|
||||
<el-form-item label="新金额" prop="amount">
|
||||
<el-input-number
|
||||
v-model="updateAmountForm.amount"
|
||||
:min="0.01"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
@@ -1555,7 +1568,7 @@
|
||||
<el-option
|
||||
v-for="o in editPaidOrders"
|
||||
:key="o.id"
|
||||
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)}`"
|
||||
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)} · ${formatPayOrderSource(o)}`"
|
||||
:value="o.id"
|
||||
class="!h-auto py-1.5"
|
||||
>
|
||||
@@ -1565,7 +1578,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) }}<template v-if="o.remark"> · {{ o.remark }}</template></span>
|
||||
<span class="truncate pr-2">{{ formatPoCategory(o.order_type) }} · {{ formatPayOrderSource(o) }}<template v-if="o.remark"> · {{ o.remark }}</template></span>
|
||||
<span class="shrink-0">{{ String(o.create_time || '').substring(0, 16) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1845,9 +1858,11 @@
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
class="po-h5-dialog"
|
||||
@closed="completeOrderId = 0"
|
||||
@closed="onCompleteOrderDialogClosed"
|
||||
>
|
||||
<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"
|
||||
@@ -1860,10 +1875,54 @@
|
||||
: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="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
|
||||
确定
|
||||
<el-button
|
||||
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
|
||||
:loading="completeOrderSubmitting"
|
||||
@click="submitCompleteOrder"
|
||||
>
|
||||
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -1882,9 +1941,20 @@
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4 !items-start"
|
||||
title="确认退款后,关联收款记录将全部标记为「已退款」,已付总额清零,且不可撤销。"
|
||||
:title="refundOrderAlertTitle"
|
||||
/>
|
||||
<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"
|
||||
@@ -1914,7 +1984,7 @@
|
||||
@closed="addPayOrderFormRef?.clearValidate()"
|
||||
>
|
||||
<el-alert
|
||||
title="可以手动创建新支付单,或关联已存在但未绑定的支付单。"
|
||||
:title="addPayOrderAlertTitle"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@@ -1930,13 +2000,15 @@
|
||||
>
|
||||
<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">付呗</el-radio>
|
||||
<el-radio value="create_express">快递代收</el-radio>
|
||||
<el-radio value="link">关联已有</el-radio>
|
||||
<el-radio value="completion_only">直接完单申请</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 手动创建模式 -->
|
||||
<template v-if="addPayOrderForm.add_mode === 'create'">
|
||||
<!-- 付呗 / 快递代收(手动创建支付单) -->
|
||||
<template v-if="addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express'">
|
||||
<el-form-item label="费用类别" prop="order_type">
|
||||
<el-select v-model="addPayOrderForm.order_type" class="w-full">
|
||||
<el-option label="挂号费" :value="1" />
|
||||
@@ -1971,6 +2043,20 @@
|
||||
</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">
|
||||
@@ -1986,12 +2072,17 @@
|
||||
<el-option
|
||||
v-for="o in addPayOrderAvailableList"
|
||||
:key="o.id"
|
||||
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)}`"
|
||||
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)} · ${formatPayOrderSource(o)}`"
|
||||
:value="o.id"
|
||||
>
|
||||
<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 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>
|
||||
</el-option>
|
||||
</el-select>
|
||||
@@ -2002,7 +2093,7 @@
|
||||
</template>
|
||||
|
||||
<!-- 完单申请选项 -->
|
||||
<el-form-item label="完单申请">
|
||||
<el-form-item v-if="addPayOrderForm.add_mode !== 'completion_only'" label="完单申请">
|
||||
<el-radio-group v-model="addPayOrderForm.completion_request">
|
||||
<el-radio :value="0">不申请</el-radio>
|
||||
<el-radio :value="1">申请完成订单</el-radio>
|
||||
@@ -2223,6 +2314,7 @@
|
||||
<div class="rx-text">
|
||||
<p>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
@@ -2243,7 +2335,10 @@
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<!-- <span class="rx-bot-doctor-name">{{ prescriptionViewData.doctor_name || '—' }}</span> -->
|
||||
<span
|
||||
v-if="!prescriptionViewData.doctor_signature"
|
||||
class="rx-bot-doctor-name"
|
||||
>{{ prescriptionViewData.doctor_name || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2450,7 +2545,8 @@ import {
|
||||
Wallet,
|
||||
User
|
||||
} from '@element-plus/icons-vue'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
prescriptionOrderAuditPrescription,
|
||||
@@ -2470,12 +2566,14 @@ 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'
|
||||
@@ -2748,9 +2846,6 @@ function handleExpressCompanyTabClick(value: '' | 'sf' | 'jd') {
|
||||
}
|
||||
|
||||
const queryParams = reactive({
|
||||
/** 创建时间区间(与列表筛选一致;不影响「今日」统计) */
|
||||
start_time: '' as string,
|
||||
end_time: '' as string,
|
||||
order_no: '',
|
||||
prescription_id: '' as string | number,
|
||||
/** 诊单患者姓名/手机号(后台关联 diagnosis) */
|
||||
@@ -2829,8 +2924,26 @@ 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 p: Record<string, unknown> = { ...params }
|
||||
const [start_time, end_time] = resolveCreateTimeRange()
|
||||
const p: Record<string, unknown> = { ...params, start_time, end_time }
|
||||
if (p.prescription_id === '' || p.prescription_id === undefined) {
|
||||
delete p.prescription_id
|
||||
} else {
|
||||
@@ -2984,9 +3097,7 @@ const listStats = computed(() => {
|
||||
scopeHint =
|
||||
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
|
||||
}
|
||||
const hasCustomTime =
|
||||
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
|
||||
const headPrefix = hasCustomTime ? '区间' : '今日'
|
||||
const headPrefix = time_type.value === 'all' ? '全部' : time_type.value === 'today' ? '今日' : '区间'
|
||||
return {
|
||||
order: n(o),
|
||||
pay: n(pay),
|
||||
@@ -3057,8 +3168,7 @@ watch(
|
||||
|
||||
function handleReset() {
|
||||
activeFocusKey.value = ''
|
||||
queryParams.start_time = ''
|
||||
queryParams.end_time = ''
|
||||
resetTimeFilter()
|
||||
queryParams.order_no = ''
|
||||
queryParams.prescription_id = ''
|
||||
queryParams.patient_keyword = ''
|
||||
@@ -3288,7 +3398,8 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
|
||||
}
|
||||
|
||||
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
|
||||
return canCompleteRow(row)
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
@@ -3323,11 +3434,11 @@ const h5FilterCollapsed = ref(true)
|
||||
const h5ActiveFilterCount = computed(() => {
|
||||
const q = queryParams as any
|
||||
const keys = [
|
||||
'start_time', 'end_time', 'order_no', 'prescription_id',
|
||||
'order_no', 'prescription_id',
|
||||
'patient_keyword', 'express_keyword', 'doctor_id',
|
||||
'assistant_dept_id', 'assistant_id'
|
||||
]
|
||||
let n = 0
|
||||
let n = time_type.value !== 'all' ? 1 : 0
|
||||
for (const k of keys) {
|
||||
const v = q[k]
|
||||
if (v === null || v === undefined) continue
|
||||
@@ -3421,10 +3532,14 @@ 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
|
||||
@@ -3469,6 +3584,8 @@ const detailPrescription = computed(() => {
|
||||
return p && typeof p === 'object' ? p : null
|
||||
})
|
||||
|
||||
const detailDietaryText = computed(() => formatDietaryTaboo(detailPrescription.value?.dietary_taboo))
|
||||
|
||||
function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
@@ -3616,8 +3733,8 @@ const updateAmountRules: FormRules = {
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
callback(new Error('订单金额必须大于0'))
|
||||
if (!Number.isFinite(num) || num < 0) {
|
||||
callback(new Error('订单金额不能为负数'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
@@ -3769,7 +3886,8 @@ function openUpdateAmount() {
|
||||
return
|
||||
}
|
||||
updateAmountForm.id = Number(row?.id) || 0
|
||||
updateAmountForm.amount = Number(row?.amount) || undefined
|
||||
const amt = Number(row?.amount)
|
||||
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
|
||||
updateAmountVisible.value = true
|
||||
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
||||
}
|
||||
@@ -3967,7 +4085,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 }>>([])
|
||||
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 editPaidOrdersLoading = ref(false)
|
||||
const editDepositMin = ref(0)
|
||||
|
||||
@@ -3983,6 +4101,26 @@ 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
|
||||
@@ -4609,6 +4747,7 @@ const completeOrderStatusOptions = [
|
||||
{ value: 7, label: '进行中' },
|
||||
{ value: 8, label: '暂不制药' },
|
||||
{ value: 9, label: '拒收' },
|
||||
{ value: 10, label: '退款' },
|
||||
{ value: 11, label: '保留药方' },
|
||||
{ value: 12, label: '制药缓发' }
|
||||
] as const
|
||||
@@ -4617,10 +4756,98 @@ 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 })
|
||||
@@ -4645,21 +4872,57 @@ const refundOrderDialogVisible = ref(false)
|
||||
const refundOrderId = ref(0)
|
||||
const refundOrderSubmitting = ref(false)
|
||||
const refundOrderFormRef = ref<FormInstance>()
|
||||
const refundOrderForm = reactive({ reason: '' })
|
||||
const refundOrderRules: FormRules = {
|
||||
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'
|
||||
}
|
||||
],
|
||||
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
|
||||
}
|
||||
}))
|
||||
|
||||
function onRefundOrderDialogClosed() {
|
||||
refundOrderId.value = 0
|
||||
refundOrderForm.reason = ''
|
||||
refundOrderForm.refund_amount = undefined
|
||||
refundOrderMaxAmount.value = 0
|
||||
refundOrderFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openRefundOrder(row: { id: number }) {
|
||||
async 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() {
|
||||
@@ -4669,7 +4932,11 @@ async function submitRefundOrder() {
|
||||
if (!valid) return
|
||||
refundOrderSubmitting.value = true
|
||||
try {
|
||||
await prescriptionOrderRefund({ id, reason: refundOrderForm.reason.trim() })
|
||||
await prescriptionOrderRefund({
|
||||
id,
|
||||
reason: refundOrderForm.reason.trim(),
|
||||
refund_amount: Number(refundOrderForm.refund_amount)
|
||||
})
|
||||
feedback.msgSuccess('退款成功')
|
||||
refundOrderDialogVisible.value = false
|
||||
getLists()
|
||||
@@ -4695,8 +4962,17 @@ 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' | 'link',
|
||||
add_mode: 'create' as 'create' | 'create_express' | 'link' | 'completion_only',
|
||||
order_type: 3,
|
||||
pay_amount: undefined as number | undefined,
|
||||
pay_remark: '',
|
||||
@@ -4704,38 +4980,44 @@ const addPayOrderForm = reactive({
|
||||
completion_request: 0
|
||||
})
|
||||
|
||||
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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return rules
|
||||
})
|
||||
|
||||
async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: number[] = []) {
|
||||
if (!diagnosisId) {
|
||||
@@ -4783,14 +5065,22 @@ async function submitAddPayOrder() {
|
||||
await addPayOrderFormRef.value.validate()
|
||||
addPayOrderSaving.value = true
|
||||
try {
|
||||
if (addPayOrderForm.add_mode === 'create') {
|
||||
// 手动创建新支付单
|
||||
if (addPayOrderForm.add_mode === 'completion_only') {
|
||||
await prescriptionOrderRequestCompletion({ id: addPayOrderRowId.value })
|
||||
feedback.msgSuccess('完单申请已提交,请等待支付审核')
|
||||
} else if (
|
||||
addPayOrderForm.add_mode === 'create' ||
|
||||
addPayOrderForm.add_mode === 'create_express'
|
||||
) {
|
||||
// 手动创建新支付单(付呗 / 快递代收)
|
||||
await prescriptionOrderAddPayOrder({
|
||||
id: addPayOrderRowId.value,
|
||||
order_type: addPayOrderForm.order_type,
|
||||
pay_amount: addPayOrderForm.pay_amount!,
|
||||
pay_remark: addPayOrderForm.pay_remark || '',
|
||||
completion_request: addPayOrderForm.completion_request
|
||||
completion_request: addPayOrderForm.completion_request,
|
||||
pay_create_type:
|
||||
addPayOrderForm.add_mode === 'create_express' ? 'express_cod' : 'fubei'
|
||||
})
|
||||
feedback.msgSuccess('支付单已新增,请等待审核')
|
||||
} else {
|
||||
@@ -4830,7 +5120,10 @@ 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)
|
||||
}
|
||||
|
||||
// ─── 撤回处方审核 ────────────────────────────────────────────
|
||||
@@ -4902,12 +5195,7 @@ const slipHerbsList = computed(() => {
|
||||
return Array.isArray(h) ? h : []
|
||||
})
|
||||
|
||||
const slipDietaryText = computed(() => {
|
||||
const d = prescriptionViewData.value?.dietary_taboo
|
||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||
if (typeof d === 'string' && d.trim()) return d.trim()
|
||||
return ''
|
||||
})
|
||||
const slipDietaryText = computed(() => formatDietaryTaboo(prescriptionViewData.value?.dietary_taboo))
|
||||
|
||||
const slipChuwanVisible = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
@@ -5330,6 +5618,12 @@ async function downloadPrescriptionSlipPdf() {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-order-page {
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="code-generation">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="mb-[-16px]" :model="formData" inline>
|
||||
<el-form-item class="w-[280px]" label="表名称">
|
||||
<el-input v-model="formData.table_name" clearable @keyup.enter="resetPage" />
|
||||
@@ -13,8 +13,8 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never" v-loading="pager.loading">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel>
|
||||
<div class="flex">
|
||||
<data-table
|
||||
v-perms="['tools.generator/selectTable']"
|
||||
@@ -126,10 +126,10 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<code-preview
|
||||
v-if="previewState.show"
|
||||
v-model="previewState.show"
|
||||
|
||||
@@ -866,7 +866,7 @@ onUnmounted(() => {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #6366f1 0%, #8b5cf6 100%);
|
||||
background: linear-gradient(145deg, #0d9488 0%, #0891b2 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -231,8 +231,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { adminLists } from '@/api/perms/admin'
|
||||
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
@@ -491,10 +490,9 @@ async function removeSegment(row: RosterSegment) {
|
||||
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const res = await adminLists({
|
||||
const res = await doctorLists({
|
||||
page_no: 1,
|
||||
page_size: 1000,
|
||||
role_id: 1
|
||||
page_size: 1000
|
||||
})
|
||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||
doctorId: doctor.id,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="error">
|
||||
<div>
|
||||
<div class="error-panel">
|
||||
<slot name="content">
|
||||
<div class="error-code">{{ code }}</div>
|
||||
</slot>
|
||||
<div class="text-lg text-tx-secondary mt-7 mb-7">{{ title }}</div>
|
||||
<el-button v-if="showBtn" type="primary" @click="router.go(-1)">
|
||||
<div class="error-title">{{ title }}</div>
|
||||
<el-button v-if="showBtn" type="primary" size="large" @click="router.go(-1)">
|
||||
{{ second }} 秒后返回上一页
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -43,16 +43,38 @@ onUnmounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
.error {
|
||||
text-align: center;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.error-code {
|
||||
@apply text-primary;
|
||||
font-size: 150px;
|
||||
}
|
||||
.el-button {
|
||||
width: 176px;
|
||||
}
|
||||
background: var(--el-bg-color-page);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.error-panel {
|
||||
width: min(480px, 100%);
|
||||
padding: 48px 32px;
|
||||
border-radius: var(--admin-radius-xl);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
|
||||
.error-code {
|
||||
@apply text-primary;
|
||||
font-size: 96px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
@apply text-tx-secondary;
|
||||
font-size: 18px;
|
||||
margin: 20px 0 28px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
min-width: 176px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="fans-management">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="ls-form" :model="formData" inline>
|
||||
<el-form-item class="w-[280px]" label="姓名">
|
||||
<el-input
|
||||
@@ -36,14 +36,14 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button type="primary" @click="handleAdd">新增粉丝</el-button>
|
||||
</div>
|
||||
<el-table :data="pager.lists" size="large" v-loading="pager.loading">
|
||||
</template>
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" width="70" />
|
||||
<el-table-column label="姓名" prop="name" min-width="100" />
|
||||
<el-table-column label="手机号" prop="phone" min-width="130" />
|
||||
@@ -81,10 +81,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex mt-4 justify-end">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
|
||||
<!-- 新增/编辑粉丝弹窗 -->
|
||||
<el-dialog
|
||||
|
||||
@@ -283,7 +283,15 @@
|
||||
<td class="col-name">{{ r.name }}</td>
|
||||
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
|
||||
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_count) }}</td>
|
||||
<td class="col-assign">{{ formatLeaderboardInt(r.assign_count ?? 0) }}</td>
|
||||
<td class="col-assign" @click.stop="onLeaderboardAssignCellClick(r)">
|
||||
<span
|
||||
v-if="Number(r.assign_count ?? 0) > 0"
|
||||
class="yeji-lead-cell--link"
|
||||
>{{ formatLeaderboardInt(r.assign_count ?? 0) }}</span>
|
||||
<template v-else>{{
|
||||
formatLeaderboardInt(r.assign_count ?? 0)
|
||||
}}</template>
|
||||
</td>
|
||||
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
|
||||
<span
|
||||
v-if="Number(r.appointment_count ?? 0) > 0"
|
||||
@@ -390,7 +398,7 @@
|
||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||
<template #content>
|
||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||
<b>tcm_diagnosis_assign_log</b> 行数(成功指派:<b>to_assistant_id > 0</b>),**仅按 <b>related_po_create_time</b>** 落在所选区间内(与日志表快照一致;诊单手动指派已写入该字段)。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||||
<b>tcm_diagnosis_assign_log</b> 中成功指派(<b>to_assistant_id > 0</b>,<b>剔除勾选「继承」的指派</b>)按<b>指派操作时间</b>落在所选区间内,「医助 × 诊单」去重、剔除已删诊单,与「复诊接诊率」页面同口径。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||||
选定渠道时收窄与「{{ tb.channel_name }}」业绩同口径(标签诊单或 channels EXISTS)。<br />
|
||||
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>。
|
||||
</template>
|
||||
@@ -561,7 +569,13 @@
|
||||
>{{ formatInt(r.lead_count) }}</span>
|
||||
<template v-else>{{ formatInt(r.lead_count) }}</template>
|
||||
</td>
|
||||
<td>{{ formatInt(r.assign_count ?? 0) }}</td>
|
||||
<td @click.stop="onAssignCountCellClick($event, tb, r)">
|
||||
<span
|
||||
v-if="yejiAssignCountCellClickable(r)"
|
||||
class="yeji-lead-cell--link"
|
||||
>{{ formatInt(r.assign_count ?? 0) }}</span>
|
||||
<template v-else>{{ formatInt(r.assign_count ?? 0) }}</template>
|
||||
</td>
|
||||
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
|
||||
<span
|
||||
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
|
||||
@@ -1356,6 +1370,59 @@
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="assignLinesDialogVisible"
|
||||
width="min(960px, 96vw)"
|
||||
destroy-on-close
|
||||
class="yeji-leadlines-dialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="yeji-unassigned-dialog__head">
|
||||
<span class="yeji-unassigned-dialog__title">被指派明细</span>
|
||||
<p class="yeji-unassigned-dialog__sub">{{ assignLinesSubtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="assignLinesApiNote" class="yeji-unassigned-dialog__note">{{ assignLinesApiNote }}</p>
|
||||
<div v-loading="assignLinesLoading" class="yeji-unassigned-dialog__body">
|
||||
<el-table
|
||||
v-if="assignLinesRows.length > 0 || assignLinesLoading"
|
||||
:data="assignLinesRows"
|
||||
size="small"
|
||||
stripe
|
||||
border
|
||||
max-height="440"
|
||||
class="yeji-unassigned-dialog__table"
|
||||
:empty-text="assignLinesLoading ? '加载中…' : '暂无数据'"
|
||||
>
|
||||
<el-table-column type="index" label="#" width="46" :index="assignLinesIndexMethod" />
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="88" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="assistant_name" label="被指派医助" min-width="108" show-overflow-tooltip />
|
||||
<el-table-column prop="assign_count" label="指派次数" width="88" align="right" />
|
||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="156" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-empty
|
||||
v-if="!assignLinesLoading && assignLinesRows.length === 0"
|
||||
description="暂无被指派明细"
|
||||
/>
|
||||
<div v-if="assignLinesCount > 0" class="yeji-leadlines-dialog__pager">
|
||||
<el-pagination
|
||||
:current-page="assignLinesPage"
|
||||
:page-size="assignLinesPageSize"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="assignLinesCount"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:disabled="assignLinesLoading"
|
||||
:hide-on-single-page="false"
|
||||
@current-change="onAssignLinesPageChange"
|
||||
@size-change="onAssignLinesPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1376,6 +1443,7 @@ import {
|
||||
yejiStatsLeadLines,
|
||||
yejiStatsAppointmentLines,
|
||||
yejiStatsRevisitBreakdown,
|
||||
yejiStatsAssignLines,
|
||||
} from '@/api/stats'
|
||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
@@ -1491,6 +1559,17 @@ interface YejiLeadLineRow {
|
||||
external_contact_name: string
|
||||
}
|
||||
|
||||
interface YejiAssignLineRow {
|
||||
diagnosis_id: number
|
||||
patient_name: string
|
||||
patient_phone: string
|
||||
assistant_id: number
|
||||
assistant_name: string
|
||||
assign_count: number
|
||||
last_assign_time: number
|
||||
last_assign_time_text: string
|
||||
}
|
||||
|
||||
interface YejiRow {
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
@@ -1709,6 +1788,31 @@ type AppointmentLinesCtx =
|
||||
}
|
||||
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
|
||||
|
||||
const assignLinesDialogVisible = ref(false)
|
||||
const assignLinesLoading = ref(false)
|
||||
const assignLinesSubtitle = ref('')
|
||||
const assignLinesApiNote = ref('')
|
||||
const assignLinesRows = ref<YejiAssignLineRow[]>([])
|
||||
const assignLinesCount = ref(0)
|
||||
const assignLinesPage = ref(1)
|
||||
const assignLinesPageSize = ref(20)
|
||||
type AssignLinesCtx =
|
||||
| {
|
||||
mode: 'assistant'
|
||||
assistant_id: number
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
| {
|
||||
mode: 'dept'
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
const assignLinesContext = ref<AssignLinesCtx | null>(null)
|
||||
|
||||
const appointmentLinesShowChannelColumn = computed(() =>
|
||||
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
|
||||
)
|
||||
@@ -3193,6 +3297,124 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||||
void openLeadLinesDialog(tb, row)
|
||||
}
|
||||
|
||||
function yejiAssignCountCellClickable(r: YejiRow): boolean {
|
||||
return r.dept_id > 0 && Number(r.assign_count ?? 0) > 0
|
||||
}
|
||||
|
||||
function onAssignCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||||
if (!yejiAssignCountCellClickable(row)) {
|
||||
return
|
||||
}
|
||||
ev.stopPropagation()
|
||||
void openAssignLinesDialogForDept(tb, row)
|
||||
}
|
||||
|
||||
function assignLinesIndexMethod(index: number) {
|
||||
return (assignLinesPage.value - 1) * assignLinesPageSize.value + index + 1
|
||||
}
|
||||
|
||||
function buildAssignLinesRequestParams(page: number, pageSize: number): Record<string, string | number> | null {
|
||||
const ctx = assignLinesContext.value
|
||||
if (!ctx) {
|
||||
return null
|
||||
}
|
||||
const p: Record<string, string | number> = {
|
||||
start_date: ctx.start_date,
|
||||
end_date: ctx.end_date,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (ctx.mode === 'dept') {
|
||||
p.dept_id = ctx.dept_id
|
||||
} else {
|
||||
p.assistant_id = ctx.assistant_id
|
||||
}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
async function fetchAssignLinesPage() {
|
||||
const params = buildAssignLinesRequestParams(assignLinesPage.value, assignLinesPageSize.value)
|
||||
if (!params) {
|
||||
return
|
||||
}
|
||||
assignLinesLoading.value = true
|
||||
try {
|
||||
const res: any = await yejiStatsAssignLines(params as any)
|
||||
assignLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||||
assignLinesCount.value = Number(res?.count ?? 0)
|
||||
assignLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||||
} catch (e: unknown) {
|
||||
if (axios.isCancel(e)) return
|
||||
const any = e as any
|
||||
ElMessage.error(any?.msg || any?.message || '加载被指派明细失败')
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesApiNote.value = ''
|
||||
} finally {
|
||||
assignLinesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openAssignLinesDialogForDept(tb: YejiTable, row: YejiRow) {
|
||||
assignLinesContext.value = {
|
||||
mode: 'dept',
|
||||
dept_id: row.dept_id,
|
||||
dept_name: row.dept_name,
|
||||
start_date: tb.start_date,
|
||||
end_date: tb.end_date,
|
||||
}
|
||||
assignLinesSubtitle.value = `${row.dept_name} · ${tb.start_date} ~ ${tb.end_date}`
|
||||
assignLinesApiNote.value = ''
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesPage.value = 1
|
||||
assignLinesPageSize.value = 20
|
||||
assignLinesDialogVisible.value = true
|
||||
await fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onLeaderboardAssignCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
|
||||
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
|
||||
return
|
||||
}
|
||||
if (Number(r.assign_count ?? 0) <= 0) {
|
||||
return
|
||||
}
|
||||
const lb = leaderboardBlock.value
|
||||
assignLinesContext.value = {
|
||||
mode: 'assistant',
|
||||
assistant_id: r.admin_id,
|
||||
name: r.name,
|
||||
start_date: lb.start_date,
|
||||
end_date: lb.end_date,
|
||||
}
|
||||
assignLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ~ ${lb.end_date}`
|
||||
assignLinesApiNote.value = ''
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesPage.value = 1
|
||||
assignLinesPageSize.value = 20
|
||||
assignLinesDialogVisible.value = true
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onAssignLinesPageChange(p: number) {
|
||||
assignLinesPage.value = p
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onAssignLinesPageSizeChange(size: number) {
|
||||
assignLinesPageSize.value = size
|
||||
assignLinesPage.value = 1
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
|
||||
{ key: 'event_time_text', label: '进线时间' },
|
||||
{ key: 'reception_admin_name', label: '接待' },
|
||||
@@ -3482,7 +3704,7 @@ function getYejiDeptComboOption(tb: YejiTable) {
|
||||
const cats = rows.map(r =>
|
||||
r.dept_name.length > 10 ? `${r.dept_name.slice(0, 10)}…` : r.dept_name
|
||||
)
|
||||
const palette = ['#64748b', '#38bdf8', '#6366f1']
|
||||
const palette = ['#64748b', '#38bdf8', '#0d9488']
|
||||
const series: any[] = [
|
||||
{
|
||||
name: '进线',
|
||||
@@ -3807,8 +4029,8 @@ onMounted(async () => {
|
||||
|
||||
.yeji-page {
|
||||
min-width: 0;
|
||||
--yj-brand: #6366f1;
|
||||
--yj-brand-soft: #eef2ff;
|
||||
--yj-brand: #0d9488;
|
||||
--yj-brand-soft: #f0fdfa;
|
||||
--yj-teal: #0ea5e9;
|
||||
--yj-teal-soft: #f0f9ff;
|
||||
--yj-accent: #f43f5e;
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<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 :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-item label="自媒体渠道">
|
||||
<el-select
|
||||
v-model="queryParams.media_channel_code"
|
||||
@@ -67,11 +67,11 @@
|
||||
v-model="queryParams.remark"
|
||||
placeholder="备注 / 创建人 / 修改人"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -148,7 +148,9 @@
|
||||
<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'
|
||||
|
||||
@@ -172,18 +174,40 @@ const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
const showEdit = ref(false)
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
media_channel_code: '',
|
||||
dept_id: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: accountCostLists,
|
||||
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,
|
||||
})
|
||||
},
|
||||
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 []
|
||||
@@ -258,18 +282,27 @@ const handleDelete = async (id: number) => {
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
resetTimeFilter()
|
||||
queryParams.media_channel_code = ''
|
||||
queryParams.dept_id = ''
|
||||
queryParams.remark = ''
|
||||
resetPage()
|
||||
pager.page = 1
|
||||
pager.size = 15
|
||||
getLists()
|
||||
}
|
||||
|
||||
getLists()
|
||||
onMounted(() => {
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="温馨提示:用户账户变动记录"
|
||||
@@ -38,9 +38,9 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="用户账号" prop="account" min-width="100" />
|
||||
<el-table-column label="用户昵称" min-width="160">
|
||||
<template #default="{ row }">
|
||||
@@ -71,10 +71,10 @@
|
||||
<el-table-column label="来源单号" prop="source_sn" min-width="100" />
|
||||
<el-table-column label="记录时间" prop="create_time" min-width="120" />
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="balanceDetail">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="温馨提示:用户充值记录"
|
||||
@@ -54,9 +54,9 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="用户信息" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center">
|
||||
@@ -104,10 +104,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="rechargeRecord">
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px] mt-[16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="退款单号">
|
||||
<el-input
|
||||
@@ -69,8 +69,8 @@
|
||||
/> -->
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel>
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane
|
||||
v-for="(item, index) in tabLists"
|
||||
@@ -78,7 +78,7 @@
|
||||
:name="index"
|
||||
:key="index"
|
||||
>
|
||||
<el-table size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="退款单号" prop="sn" min-width="190" />
|
||||
<el-table-column label="用户信息" min-width="160">
|
||||
<template #default="{ row }">
|
||||
@@ -140,10 +140,10 @@
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<refund-log v-model="showRefundLog" :refund-id="selectRefundId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-alert
|
||||
type="warning"
|
||||
title="温馨提示:平台配置在各个场景下的通知发送方式和内容模板"
|
||||
:closable="false"
|
||||
show-icon
|
||||
></el-alert>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
</admin-page-filter-panel>
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<el-tabs v-model="tabsActive" @tab-change="getLists">
|
||||
<el-tab-pane
|
||||
v-for="(item, index) in tabsMap"
|
||||
@@ -18,7 +18,7 @@
|
||||
lazy
|
||||
></el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-table size="large" :data="pager.lists" v-loading="pager.loading">
|
||||
<el-table size="large" :data="pager.lists" >
|
||||
<el-table-column label="通知场景" prop="scene_name" min-width="120" />
|
||||
<el-table-column label="通知类型" prop="type_desc" min-width="160" />
|
||||
<el-table-column label="短信通知" min-width="80">
|
||||
@@ -44,7 +44,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</admin-page-data-panel>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup name="notice">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card class="!border-none" shadow="never" v-loading="state.loading">
|
||||
<admin-page-data-panel v-loading="state.loading">
|
||||
<el-table size="large" :data="state.lists">
|
||||
<el-table-column label="短信渠道" prop="name" min-width="120" />
|
||||
<el-table-column label="状态" min-width="120">
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup ref="editRef" @success="getLists" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,13 +33,11 @@ import EditPopup from './edit.vue'
|
||||
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
|
||||
// 列表数据
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
lists: []
|
||||
})
|
||||
|
||||
// 获取存储引擎列表数据
|
||||
const getLists = async () => {
|
||||
try {
|
||||
state.loading = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 订单列表 -->
|
||||
<template>
|
||||
<div class="order-list">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<!-- 搜索表单 -->
|
||||
<el-form class="ls-form" :model="queryParams" inline>
|
||||
<el-form-item class="w-[280px]" label="订单号">
|
||||
@@ -106,10 +106,10 @@
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<!-- Tab 筛选 + 今日收益 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<!-- Tab 筛选 + 今日收益 + 数据表格 -->
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<el-tabs v-model="patientAssociationTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="全部" name="" />
|
||||
@@ -122,10 +122,7 @@
|
||||
<span class="text-gray-400">({{ todayRevenue.count }} 笔)</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div v-perms="['order.order/zhipai']" class="mb-3 flex items-center gap-3">
|
||||
<el-button type="primary" :disabled="!selectedOrderIds.length" @click="openAssignDialog">
|
||||
将创建人指给医助
|
||||
@@ -136,7 +133,6 @@
|
||||
</div>
|
||||
<el-table
|
||||
ref="orderTableRef"
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
row-key="id"
|
||||
size="large"
|
||||
@@ -174,9 +170,9 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="支付方式" width="100">
|
||||
<el-table-column label="支付方式" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getPaymentMethodText(row.payment_method) }}
|
||||
{{ getCreateTypeText(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -220,7 +216,7 @@
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/split']"
|
||||
type="primary"
|
||||
link
|
||||
@@ -237,7 +233,7 @@
|
||||
小程序码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/pay']"
|
||||
type="success"
|
||||
link
|
||||
@@ -255,7 +251,7 @@
|
||||
退款
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/cancel']"
|
||||
type="danger"
|
||||
link
|
||||
@@ -283,10 +279,10 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
@@ -327,7 +323,7 @@
|
||||
<span class="text-red-500 font-semibold">¥{{ detailData.amount }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付方式">
|
||||
{{ getPaymentMethodText(detailData.payment_method) }}
|
||||
{{ getCreateTypeText(detailData) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付时间">
|
||||
{{ detailData.payment_time || '-' }}
|
||||
@@ -434,7 +430,7 @@
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-3"
|
||||
title="该单为付呗·待审核,请选择实际到账方式后确认「已支付」"
|
||||
title="该单为待审核(付呗/快递代收),请选择实际到账方式后确认「已支付」"
|
||||
/>
|
||||
<el-form-item v-if="!payForm.fubeiPendingAudit" label="支付类型" required>
|
||||
<el-radio-group v-model="payForm.payType">
|
||||
@@ -502,6 +498,7 @@
|
||||
<el-radio label="normal">普通订单</el-radio>
|
||||
<el-radio label="wechat_work">企业微信对外收款</el-radio>
|
||||
<el-radio label="fubei">付呗</el-radio>
|
||||
<el-radio label="express_cod">快递代收</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
@@ -524,7 +521,20 @@
|
||||
<template #title>付呗</template>
|
||||
通过付呗收款的支付单,创建后请按实际对账/审核流程在列表中处理。
|
||||
</el-alert>
|
||||
<el-form-item v-if="createForm.createType === 'fubei'" label="支付单审核">
|
||||
<el-alert
|
||||
v-if="createForm.createType === 'express_cod'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
>
|
||||
<template #title>快递代收</template>
|
||||
通过快递代收的支付单,流转与付呗一致;审核通过/确认到账后将更新为已支付。
|
||||
</el-alert>
|
||||
<el-form-item
|
||||
v-if="createForm.createType === 'fubei' || createForm.createType === 'express_cod'"
|
||||
label="支付单审核"
|
||||
>
|
||||
<el-switch
|
||||
v-model="createForm.requirePaymentSlipAudit"
|
||||
active-text="申请审核"
|
||||
@@ -980,8 +990,8 @@ const patientLoading = ref(false)
|
||||
const patientList = ref<any[]>([])
|
||||
|
||||
const createForm = reactive({
|
||||
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei',
|
||||
/** 仅「付呗」:开启后订单为待审核(5) */
|
||||
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei' | 'express_cod',
|
||||
/** 「付呗」「快递代收」:开启后订单为待审核(5) */
|
||||
requirePaymentSlipAudit: false,
|
||||
patient_id: '',
|
||||
order_type: '',
|
||||
@@ -1148,7 +1158,8 @@ const submitCreateOrder = async () => {
|
||||
patient_id: createForm.patient_id,
|
||||
order_type: createForm.order_type,
|
||||
amount: createForm.amount,
|
||||
remark: createForm.remark
|
||||
remark: createForm.remark,
|
||||
create_type: 'wechat_work'
|
||||
}
|
||||
const res: any = await orderCreateForWechatWork(params)
|
||||
const orderNo = res?.order_no ?? res?.data?.order_no
|
||||
@@ -1160,18 +1171,23 @@ const submitCreateOrder = async () => {
|
||||
patient_id: createForm.patient_id,
|
||||
order_type: createForm.order_type,
|
||||
amount: createForm.amount,
|
||||
remark: createForm.remark
|
||||
remark: createForm.remark,
|
||||
create_type: createForm.createType
|
||||
}
|
||||
if (createForm.createType === 'fubei') {
|
||||
params.payment_channel = 'fubei'
|
||||
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
|
||||
} else if (createForm.createType === 'express_cod') {
|
||||
params.payment_channel = 'express_cod'
|
||||
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
|
||||
} else {
|
||||
params.payment_channel = 'normal'
|
||||
params.require_payment_slip_audit = 0
|
||||
}
|
||||
await orderCreate(params)
|
||||
const tip =
|
||||
createForm.createType === 'fubei' && createForm.requirePaymentSlipAudit
|
||||
(createForm.createType === 'fubei' || createForm.createType === 'express_cod') &&
|
||||
createForm.requirePaymentSlipAudit
|
||||
? '订单已创建,支付状态为「待审核」'
|
||||
: '订单创建成功'
|
||||
feedback.msgSuccess(tip)
|
||||
@@ -1247,6 +1263,27 @@ 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 = {
|
||||
@@ -1373,7 +1410,8 @@ const handleDetail = async (row: any) => {
|
||||
|
||||
// 支付订单
|
||||
const handlePay = (row: any) => {
|
||||
const fubeiPending = row.status === 5 && row.payment_method === 'fubei'
|
||||
const fubeiPending =
|
||||
row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod')
|
||||
payForm.value = {
|
||||
order_id: row.id,
|
||||
order_no: row.order_no,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="department">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="部门名称" prop="name">
|
||||
<el-input
|
||||
@@ -22,22 +22,21 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="loading">
|
||||
<template #toolbar>
|
||||
<el-button v-perms="['dept.dept/add']" type="primary" @click="handleAdd()">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button @click="handleExpand"> 展开/折叠 </el-button>
|
||||
</div>
|
||||
<el-button @click="handleExpand">展开/折叠</el-button>
|
||||
</template>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
class="mt-4"
|
||||
size="large"
|
||||
v-loading="loading"
|
||||
:data="lists"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
@@ -68,7 +67,6 @@
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="排序" prop="sort" min-width="100" />
|
||||
<el-table-column label="更新时间" prop="update_time" min-width="180" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
@@ -101,7 +99,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -120,14 +118,18 @@ let isExpand = false
|
||||
const loading = ref(false)
|
||||
const lists = ref<any[]>([])
|
||||
const queryParams = reactive({
|
||||
status: '',
|
||||
name: ''
|
||||
name: '',
|
||||
status: ''
|
||||
})
|
||||
const showEdit = ref(false)
|
||||
|
||||
const getLists = async () => {
|
||||
loading.value = true
|
||||
lists.value = await deptLists(queryParams)
|
||||
loading.value = false
|
||||
try {
|
||||
lists.value = await deptLists(queryParams)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetParams = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="post-lists">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" :inline="true">
|
||||
<el-form-item class="w-[280px]" label="岗位编码">
|
||||
<el-input
|
||||
@@ -31,17 +31,18 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button v-perms="['dept.jobs/add']" type="primary" @click="handleAdd()">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
</template>
|
||||
<el-table size="large" :data="pager.lists">
|
||||
<el-table-column label="岗位编码" prop="code" min-width="100" />
|
||||
<el-table-column label="岗位名称" prop="name" min-width="100" />
|
||||
<el-table-column label="排序" prop="sort" min-width="100" />
|
||||
@@ -75,10 +76,10 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<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"
|
||||
@@ -95,16 +104,32 @@
|
||||
</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 { ref, watch } from 'vue'
|
||||
import { computed, 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
|
||||
@@ -122,6 +147,13 @@ 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)
|
||||
|
||||
@@ -201,6 +233,30 @@ 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' })
|
||||
@@ -217,6 +273,7 @@ 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;
|
||||
|
||||
@@ -1275,7 +1275,7 @@ onUnmounted(() => {
|
||||
|
||||
.float-action.edit {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%);
|
||||
}
|
||||
|
||||
.float-action.edit:hover,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="admin">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form class="mb-[-16px]" :model="formData" inline>
|
||||
<el-form-item class="w-[280px]" label="管理员账号">
|
||||
<el-input
|
||||
@@ -40,77 +40,78 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
|
||||
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
<div class="mt-4">
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" min-width="60" />>
|
||||
<el-table-column label="头像" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :size="50" :src="row.avatar"></el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号" prop="account" min-width="100" />
|
||||
<el-table-column label="名称" prop="name" min-width="100" />
|
||||
<el-table-column
|
||||
label="角色"
|
||||
prop="role_name"
|
||||
min-width="100"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column
|
||||
label="部门"
|
||||
prop="dept_name"
|
||||
min-width="100"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="180" />
|
||||
<el-table-column label="最近登录时间" prop="login_time" min-width="180" />
|
||||
<el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
|
||||
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="row.root != 1"
|
||||
v-model="row.disable"
|
||||
:active-value="0"
|
||||
:inactive-value="1"
|
||||
@change="changeStatus(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['auth.admin/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.root != 1"
|
||||
v-perms="['auth.admin/delete']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="flex mt-4 justify-end">
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button v-perms="['auth.admin/add']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</template>
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" min-width="60" />
|
||||
<el-table-column label="头像" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :size="50" :src="row.avatar"></el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号" prop="account" min-width="100" />
|
||||
<el-table-column label="名称" prop="name" min-width="100" />
|
||||
<el-table-column
|
||||
label="角色"
|
||||
prop="role_name"
|
||||
min-width="100"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column
|
||||
label="部门"
|
||||
prop="dept_name"
|
||||
min-width="100"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="180" />
|
||||
<el-table-column label="最近登录时间" prop="login_time" min-width="180" />
|
||||
<el-table-column label="最近登录IP" prop="login_ip" min-width="120" />
|
||||
<el-table-column label="状态" min-width="100" v-perms="['auth.admin/edit']">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="row.root != 1"
|
||||
v-model="row.disable"
|
||||
:active-value="0"
|
||||
:inactive-value="1"
|
||||
@change="changeStatus(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['auth.admin/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.root != 1"
|
||||
v-perms="['auth.admin/delete']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -125,7 +126,6 @@ import feedback from '@/utils/feedback'
|
||||
import EditPopup from './edit.vue'
|
||||
|
||||
const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
account: '',
|
||||
name: '',
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<template>
|
||||
<div class="menu-lists">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<div>
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button v-perms="['auth.menu/add']" type="primary" @click="handleAdd()">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button @click="handleExpand"> 展开/折叠 </el-button>
|
||||
</div>
|
||||
<el-button @click="handleExpand">展开/折叠</el-button>
|
||||
</template>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
class="mt-4"
|
||||
size="large"
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
@@ -87,7 +85,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,68 +1,64 @@
|
||||
<template>
|
||||
<div class="role-lists">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<div>
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button v-perms="['auth.role/add']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mt-4" v-loading="pager.loading">
|
||||
<div>
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column prop="id" label="ID" min-width="100" />
|
||||
<el-table-column prop="name" label="名称" min-width="150" />
|
||||
<el-table-column
|
||||
prop="desc"
|
||||
label="备注"
|
||||
min-width="150"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="sort" label="排序" min-width="100" />
|
||||
<el-table-column label="数据范围" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ dataScopeLabel(row.data_scope) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="num" label="管理员人数" min-width="100" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
v-perms="['auth.role/edit']"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
v-perms="['auth.role/edit']"
|
||||
@click="handleAuth(row)"
|
||||
>
|
||||
分配权限
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['auth.role/delete']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column prop="id" label="ID" min-width="100" />
|
||||
<el-table-column prop="name" label="名称" min-width="150" />
|
||||
<el-table-column
|
||||
prop="desc"
|
||||
label="备注"
|
||||
min-width="150"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="sort" label="排序" min-width="100" />
|
||||
<el-table-column label="数据范围" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ dataScopeLabel(row.data_scope) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="num" label="管理员人数" min-width="100" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
v-perms="['auth.role/edit']"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
v-perms="['auth.role/edit']"
|
||||
@click="handleAuth(row)"
|
||||
>
|
||||
分配权限
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['auth.role/delete']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
<auth-popup v-if="showAuth" ref="authRef" @success="getLists" @close="showAuth = false" />
|
||||
</div>
|
||||
@@ -115,7 +111,6 @@ const dataScopeLabel = (scope: number | string | null | undefined) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const handleDelete = async (id: number) => {
|
||||
await feedback.confirm('确定要删除?')
|
||||
await roleDelete({ id })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="dict-type">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-page-header class="mb-4" content="数据管理" @back="$router.back()" />
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
|
||||
<el-form-item class="w-[280px]" label="字典名称">
|
||||
@@ -28,9 +28,10 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_data/add']"
|
||||
type="primary"
|
||||
@@ -52,58 +53,54 @@
|
||||
</template>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mt-4" v-loading="pager.loading">
|
||||
<div>
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
size="large"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column label="ID" prop="id" />
|
||||
<el-table-column label="数据名称" prop="name" min-width="120" />
|
||||
<el-table-column label="数据值" prop="value" min-width="120" />
|
||||
<el-table-column label="状态">
|
||||
<template v-slot="{ row }">
|
||||
<el-tag v-if="row.status == 1">正常</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="备注"
|
||||
prop="remark"
|
||||
min-width="120"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column label="排序" prop="sort" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_data/edit']"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_data/delete']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
size="large"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column label="ID" prop="id" />
|
||||
<el-table-column label="数据名称" prop="name" min-width="120" />
|
||||
<el-table-column label="数据值" prop="value" min-width="120" />
|
||||
<el-table-column label="状态">
|
||||
<template v-slot="{ row }">
|
||||
<el-tag v-if="row.status == 1">正常</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="备注"
|
||||
prop="remark"
|
||||
min-width="120"
|
||||
show-tooltip-when-overflow
|
||||
/>
|
||||
<el-table-column label="排序" prop="sort" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_data/edit']"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_data/delete']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="dict-type">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<div>
|
||||
<admin-page-filter-panel>
|
||||
<el-form ref="formRef" class="mb-[-16px]" :model="queryParams" inline>
|
||||
<el-form-item class="w-[280px]" label="字典名称">
|
||||
<el-input v-model="queryParams.name" clearable @keyup.enter="resetPage" />
|
||||
@@ -20,9 +20,10 @@
|
||||
<el-button @click="resetParams">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<admin-page-data-panel v-loading="pager.loading">
|
||||
<template #toolbar>
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_type/add']"
|
||||
type="primary"
|
||||
@@ -44,69 +45,65 @@
|
||||
</template>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mt-4" v-loading="pager.loading">
|
||||
<div>
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
size="large"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column label="ID" prop="id" />
|
||||
<el-table-column label="字典名称" prop="name" min-width="120" />
|
||||
<el-table-column label="字典类型" prop="type" min-width="120" />
|
||||
<el-table-column label="状态">
|
||||
<template v-slot="{ row }">
|
||||
<el-tag v-if="row.status == 1">正常</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" show-tooltip-when-overflow />
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="180" />
|
||||
<el-table-column label="操作" width="190" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_type/edit']"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_data/lists']"
|
||||
type="primary"
|
||||
link
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
path: getRoutePath('setting.dict.dict_data/lists'),
|
||||
query: {
|
||||
id: row.id
|
||||
}
|
||||
}"
|
||||
>
|
||||
数据管理
|
||||
</router-link>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_type/delete']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
<el-table
|
||||
:data="pager.lists"
|
||||
size="large"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column label="ID" prop="id" />
|
||||
<el-table-column label="字典名称" prop="name" min-width="120" />
|
||||
<el-table-column label="字典类型" prop="type" min-width="120" />
|
||||
<el-table-column label="状态">
|
||||
<template v-slot="{ row }">
|
||||
<el-tag v-if="row.status == 1">正常</el-tag>
|
||||
<el-tag v-else type="danger">停用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" show-tooltip-when-overflow />
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="180" />
|
||||
<el-table-column label="操作" width="190" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_type/edit']"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_data/lists']"
|
||||
type="primary"
|
||||
link
|
||||
>
|
||||
<router-link
|
||||
:to="{
|
||||
path: getRoutePath('setting.dict.dict_data/lists'),
|
||||
query: {
|
||||
id: row.id
|
||||
}
|
||||
}"
|
||||
>
|
||||
数据管理
|
||||
</router-link>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['setting.dict.dict_type/delete']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</template>
|
||||
</admin-page-data-panel>
|
||||
<edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -151,7 +148,6 @@ const handleEdit = async (data: any) => {
|
||||
editRef.value?.setFormData(data)
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const handleDelete = async (id: any[] | number) => {
|
||||
await feedback.confirm('确定要删除?')
|
||||
await dictTypeDelete({ id })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="conversion-stats-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<admin-page-filter-panel>
|
||||
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||
<el-form-item label="统计维度">
|
||||
<el-segmented
|
||||
@@ -89,7 +89,7 @@
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</admin-page-filter-panel>
|
||||
|
||||
<div class="stats-kpi-grid">
|
||||
<div
|
||||
@@ -129,7 +129,7 @@
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>诊单金额占比</span>
|
||||
<span>{{ amountPieTitle }}</span>
|
||||
<span class="card-hint">TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -146,17 +146,17 @@
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>加粉占比</span>
|
||||
<span>{{ secondaryPieTitle }}</span>
|
||||
<span class="card-hint">TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="fanPieHasData"
|
||||
v-if="secondaryPieHasData"
|
||||
class="stats-chart"
|
||||
:option="fanPieOption"
|
||||
:option="secondaryPieOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无加粉数据" />
|
||||
<el-empty v-else :description="secondaryPieEmptyText" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -311,10 +311,13 @@ const overview = reactive<Record<string, any>>({
|
||||
amounts: [],
|
||||
order_counts: [],
|
||||
fan_counts: [],
|
||||
rois: []
|
||||
rois: [],
|
||||
appointment_counts: [],
|
||||
interview_counts: []
|
||||
},
|
||||
amount_share: [],
|
||||
fan_share: []
|
||||
fan_share: [],
|
||||
order_share: []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -392,7 +395,7 @@ const currentEntityId = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const summaryCards: MetricCard[] = [
|
||||
const defaultSummaryCards: MetricCard[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||
@@ -410,7 +413,15 @@ const summaryCards: MetricCard[] = [
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio' }
|
||||
]
|
||||
|
||||
const tableColumns: MetricColumn[] = [
|
||||
const doctorSummaryCards: MetricCard[] = [
|
||||
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||
{ key: 'completed_order_amount', label: '开药金额', type: 'money' },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent' }
|
||||
]
|
||||
|
||||
const defaultTableColumns: MetricColumn[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
|
||||
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
|
||||
@@ -431,6 +442,18 @@ const tableColumns: MetricColumn[] = [
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
|
||||
]
|
||||
|
||||
const doctorTableColumns: MetricColumn[] = [
|
||||
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||
{ key: 'completed_order_amount', label: '开药金额', type: 'money', minWidth: 120 },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 }
|
||||
]
|
||||
|
||||
const isDoctorDimension = computed(() => queryParams.dimension === 'doctor')
|
||||
const summaryCards = computed(() => isDoctorDimension.value ? doctorSummaryCards : defaultSummaryCards)
|
||||
const tableColumns = computed(() => isDoctorDimension.value ? doctorTableColumns : defaultTableColumns)
|
||||
|
||||
const dateRangeText = computed(() => {
|
||||
if (!overview.date_range?.length) return '未选择'
|
||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||
@@ -438,7 +461,14 @@ const dateRangeText = computed(() => {
|
||||
|
||||
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
|
||||
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
|
||||
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
|
||||
const secondaryPieHasData = computed(() =>
|
||||
isDoctorDimension.value
|
||||
? (overview.charts.order_share?.length || 0) > 0
|
||||
: (overview.charts.fan_share?.length || 0) > 0
|
||||
)
|
||||
const amountPieTitle = computed(() => isDoctorDimension.value ? '开药金额占比' : '诊单金额占比')
|
||||
const secondaryPieTitle = computed(() => isDoctorDimension.value ? '接诊单数占比' : '加粉占比')
|
||||
const secondaryPieEmptyText = computed(() => isDoctorDimension.value ? '暂无接诊数据' : '暂无加粉数据')
|
||||
|
||||
const rankingChartData = computed(() => {
|
||||
const ranking = overview.charts?.ranking || {}
|
||||
@@ -448,56 +478,110 @@ const rankingChartData = computed(() => {
|
||||
return {
|
||||
names,
|
||||
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
|
||||
appointmentCounts: normalize(Array.isArray(ranking.appointment_counts) ? ranking.appointment_counts : []),
|
||||
interviewCounts: normalize(Array.isArray(ranking.interview_counts) ? ranking.interview_counts : []),
|
||||
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
|
||||
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
|
||||
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
|
||||
}
|
||||
})
|
||||
|
||||
const rankingChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rankingChartData.value.names,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额 / 数量' }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
const rankingChartOption = computed(() => {
|
||||
const common = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rankingChartData.value.names,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '接诊诊单',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '加粉数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.fanCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额 / 数量' }
|
||||
]
|
||||
}
|
||||
|
||||
if (!isDoctorDimension.value) {
|
||||
return {
|
||||
...common,
|
||||
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
},
|
||||
{
|
||||
name: '接诊诊单',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '加粉数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.fanCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
...common,
|
||||
legend: { data: ['开药金额', '挂号数', '面诊数', '接诊单数'] },
|
||||
series: [
|
||||
{
|
||||
name: '开药金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
},
|
||||
{
|
||||
name: '挂号数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.appointmentCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '面诊数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.interviewCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
},
|
||||
{
|
||||
name: '接诊单数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#7c3aed' }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const amountPieOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
@@ -511,7 +595,7 @@ const amountPieOption = computed(() => ({
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
name: amountPieTitle.value,
|
||||
type: 'pie',
|
||||
radius: ['42%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
@@ -531,7 +615,7 @@ const amountPieOption = computed(() => ({
|
||||
]
|
||||
}))
|
||||
|
||||
const fanPieOption = computed(() => ({
|
||||
const secondaryPieOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
@@ -543,7 +627,7 @@ const fanPieOption = computed(() => ({
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '加粉数',
|
||||
name: secondaryPieTitle.value,
|
||||
type: 'pie',
|
||||
radius: ['42%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
@@ -558,7 +642,7 @@ const fanPieOption = computed(() => ({
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
data: overview.charts.fan_share
|
||||
data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
|
||||
}
|
||||
]
|
||||
}))
|
||||
@@ -658,9 +742,18 @@ const handleReset = () => {
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
const calcDoctorReceiveRate = (source: Record<string, any>) => {
|
||||
const completedOrderCount = Number(source?.completed_order_count || 0)
|
||||
const interviewCount = Number(source?.interview_count || 0)
|
||||
if (interviewCount <= 0) return 0
|
||||
return (completedOrderCount / interviewCount) * 100
|
||||
}
|
||||
|
||||
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
|
||||
if (placeholder) return '—'
|
||||
const value = source?.[key] ?? 0
|
||||
const value = isDoctorDimension.value && key === 'receive_rate'
|
||||
? calcDoctorReceiveRate(source)
|
||||
: source?.[key] ?? 0
|
||||
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
<template>
|
||||
<div class="revisit-rate-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="rate-filter-form">
|
||||
<el-form-item label="统计月份">
|
||||
<el-date-picker
|
||||
v-model="month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择月份"
|
||||
:clearable="false"
|
||||
:disabled-date="disableFutureMonth"
|
||||
style="width: 160px"
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="deptId"
|
||||
:data="deptTreeOptions"
|
||||
placeholder="全部部门"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:props="deptTreeProps"
|
||||
style="width: 220px"
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
||||
<template #reference>
|
||||
<span class="rate-caliber-trigger">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
统计口径
|
||||
</span>
|
||||
</template>
|
||||
<div class="rate-caliber">
|
||||
<p>
|
||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重。
|
||||
</p>
|
||||
<p>
|
||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序的全局序号,<b>跨月累计不重置</b>——如 5 月指派后旗下成交 4 单为二诊~五诊,下月再成交即为六诊。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||
</p>
|
||||
<p>
|
||||
医助按人事部门归组;选定部门时含其组织下级。
|
||||
</p>
|
||||
</div>
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="rate-kpi-grid">
|
||||
<div
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
class="rate-kpi-card"
|
||||
:class="{ 'is-clickable': card.clickable }"
|
||||
@click="card.clickable && card.onClick && card.onClick()"
|
||||
>
|
||||
<div class="rate-kpi-label">{{ card.label }}</div>
|
||||
<div class="rate-kpi-value">{{ card.value }}</div>
|
||||
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="rate-card-header">
|
||||
<span>部门 · 医助明细({{ monthText }})</span>
|
||||
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
size="small"
|
||||
border
|
||||
row-key="row_key"
|
||||
:tree-props="{ children: 'children' }"
|
||||
:default-expand-all="true"
|
||||
show-summary
|
||||
:summary-method="getSummaries"
|
||||
>
|
||||
<el-table-column label="部门 / 医助" min-width="200" fixed>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.is_dept" class="rate-dept-cell">
|
||||
{{ row.dept_name }}
|
||||
<span class="rate-dept-count">{{ row.assistant_count }} 人</span>
|
||||
</span>
|
||||
<span v-else>{{ row.assistant_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row)"
|
||||
>
|
||||
{{ row.assigned_count }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.assigned_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template v-for="slot in visitSlots" :key="slot">
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_count`"
|
||||
:label="`${slotLabel(slot)}单数`"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ slotLabel(slot) }}单数</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`当月内下单、诊次为第 ${slot} 次的订单数(诊次跨月累计;归属下单时点持有患者的医助)`"
|
||||
>
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderDetail(slot, row)"
|
||||
>
|
||||
{{ row[`visit${slot}_count`] }}
|
||||
</el-link>
|
||||
<span v-else>{{ row[`visit${slot}_count`] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_rate`"
|
||||
:label="`${slotLabel(slot)}接诊率`"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 下钻明细 -->
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="detailTitle"
|
||||
width="980px"
|
||||
top="6vh"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-table
|
||||
v-loading="detailLoading"
|
||||
:data="detailRows"
|
||||
size="small"
|
||||
border
|
||||
stripe
|
||||
max-height="60vh"
|
||||
>
|
||||
<template v-if="detailType === 'assign'">
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>
|
||||
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120">
|
||||
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderDetail && row.order_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderInfo(row.order_id)"
|
||||
>
|
||||
{{ row.order_no || `#${row.order_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>
|
||||
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" label="金额" width="100" align="right">
|
||||
<template #default="{ row }">¥ {{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
|
||||
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
|
||||
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
|
||||
</template>
|
||||
</el-table>
|
||||
<div class="rate-detail-footer">共 {{ detailRows.length }} 条</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 订单详情抽屉(共享组件,与处方订单列表详情一致;只读) -->
|
||||
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
|
||||
|
||||
<!-- 诊单(患者)详情:编辑页只读模式 -->
|
||||
<diagnosis-edit-popup ref="diagnosisEditRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="revisitRateStatsPage">
|
||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
|
||||
import {
|
||||
revisitRateAssignLines,
|
||||
revisitRateDeptOptions,
|
||||
revisitRateOverview,
|
||||
revisitRateVisitOrderLines
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
/** 业务订单详情抽屉(共享组件,readonly 模式,与 order_list.vue 详情同源) */
|
||||
const OrderDetailDrawer = defineAsyncComponent(
|
||||
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
|
||||
)
|
||||
/** 诊单编辑弹窗(只读打开) */
|
||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
interface MetricPack extends Record<string, any> {
|
||||
assigned_count: number
|
||||
}
|
||||
|
||||
interface StatsRow extends MetricPack {
|
||||
row_key: string
|
||||
is_dept: 0 | 1
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
assistant_id?: number
|
||||
assistant_name?: string
|
||||
assistant_count?: number
|
||||
children?: StatsRow[]
|
||||
}
|
||||
|
||||
interface DeptTreeNode {
|
||||
id: number
|
||||
name: string
|
||||
pid: number
|
||||
children?: DeptTreeNode[]
|
||||
}
|
||||
|
||||
const canViewAssignLines = hasPermission(['stats.revisitRate/assignLines'])
|
||||
const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||
|
||||
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
||||
const visitSlots = ref<number[]>([2, 3, 4])
|
||||
|
||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
const slotLabel = (slot: number) => {
|
||||
if (slot >= 2 && slot <= 10) {
|
||||
return `${cnDigits[slot]}诊`
|
||||
}
|
||||
if (slot > 10 && slot < 20) {
|
||||
return `十${cnDigits[slot - 10]}诊`
|
||||
}
|
||||
return `${slot}诊`
|
||||
}
|
||||
|
||||
const initNow = new Date()
|
||||
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||
const month = ref<string>(currentMonth)
|
||||
const deptId = ref<number | undefined>(undefined)
|
||||
const loading = ref(false)
|
||||
const rows = ref<StatsRow[]>([])
|
||||
const total = ref<MetricPack | null>(null)
|
||||
|
||||
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
|
||||
const monthText = computed(() => {
|
||||
const [y, m] = month.value.split('-')
|
||||
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||
})
|
||||
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
|
||||
const baseQuery = () => ({
|
||||
month: month.value,
|
||||
dept_ids: deptId.value ? String(deptId.value) : ''
|
||||
})
|
||||
|
||||
interface SummaryCard {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
clickable: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const summaryCards = computed<SummaryCard[]>(() => {
|
||||
const t = total.value
|
||||
const cards: SummaryCard[] = [
|
||||
{
|
||||
key: 'assigned',
|
||||
label: '当月被指派总数',
|
||||
value: t ? String(t.assigned_count) : '—',
|
||||
sub: '',
|
||||
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||
onClick: () => openAssignDetail(null)
|
||||
}
|
||||
]
|
||||
for (const slot of visitSlots.value) {
|
||||
const cnt = t ? t[`visit${slot}_count`] : null
|
||||
const rate = t ? t[`visit${slot}_rate`] : null
|
||||
cards.push({
|
||||
key: `visit${slot}`,
|
||||
label: `当月${slotLabel(slot)}接诊率`,
|
||||
value: t ? formatRate(rate) : '—',
|
||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
||||
clickable: !!(canViewOrderLines && cnt > 0),
|
||||
onClick: () => openOrderDetail(slot, null)
|
||||
})
|
||||
}
|
||||
return cards
|
||||
})
|
||||
|
||||
/** 底栏合计:直接展示后端按诊单去重后的合计,不做各行求和 */
|
||||
const getSummaries = ({ columns }: { columns: any[] }) => {
|
||||
const t = total.value
|
||||
return columns.map((col, index) => {
|
||||
if (index === 0) {
|
||||
return '合计'
|
||||
}
|
||||
if (!t) {
|
||||
return ''
|
||||
}
|
||||
const prop = String(col.property || '')
|
||||
if (prop === 'assigned_count') {
|
||||
return String(t.assigned_count)
|
||||
}
|
||||
if (/^visit\d+_count$/.test(prop)) {
|
||||
return String(t[prop] ?? '')
|
||||
}
|
||||
if (/^visit\d+_rate$/.test(prop)) {
|
||||
return formatRate(t[prop])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
const disableFutureMonth = (date: Date) => {
|
||||
const now = new Date()
|
||||
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await revisitRateOverview(baseQuery())
|
||||
rows.value = res?.rows ?? []
|
||||
total.value = res?.total ?? null
|
||||
if (Array.isArray(res?.slots) && res.slots.length) {
|
||||
visitSlots.value = res.slots.map((s: any) => Number(s)).filter((s: number) => s >= 2)
|
||||
}
|
||||
if (res?.month) {
|
||||
month.value = res.month
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||
const map = new Map<number, DeptTreeNode>()
|
||||
for (const d of flat) {
|
||||
map.set(d.id, { ...d, children: [] })
|
||||
}
|
||||
const roots: DeptTreeNode[] = []
|
||||
for (const d of flat) {
|
||||
const node = map.get(d.id)!
|
||||
if (d.pid > 0 && map.has(d.pid)) {
|
||||
map.get(d.pid)!.children!.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
const strip = (n: DeptTreeNode) => {
|
||||
if (n.children?.length) {
|
||||
n.children.forEach(strip)
|
||||
} else {
|
||||
delete n.children
|
||||
}
|
||||
}
|
||||
roots.forEach(strip)
|
||||
return roots
|
||||
}
|
||||
|
||||
const loadDeptOptions = async () => {
|
||||
try {
|
||||
const res = await revisitRateDeptOptions()
|
||||
deptTreeOptions.value = buildDeptTree(res?.rows ?? [])
|
||||
} catch {
|
||||
deptTreeOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ── 下钻明细 ──────────────────────────────────────────
|
||||
const detailVisible = ref(false)
|
||||
const detailType = ref<'assign' | 'orders'>('assign')
|
||||
const detailTitle = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const detailRows = ref<any[]>([])
|
||||
|
||||
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||
if (!row) {
|
||||
return { label: '合计' }
|
||||
}
|
||||
if (row.is_dept) {
|
||||
return { dept_id: row.dept_id, label: row.dept_name }
|
||||
}
|
||||
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||
}
|
||||
|
||||
const openAssignDetail = async (row: StatsRow | null) => {
|
||||
if (!canViewAssignLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'assign'
|
||||
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailRows.value = []
|
||||
try {
|
||||
const res = await revisitRateAssignLines({
|
||||
...baseQuery(),
|
||||
assistant_id: scope.assistant_id,
|
||||
dept_id: scope.dept_id
|
||||
})
|
||||
detailRows.value = res?.rows ?? []
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||
if (!canViewOrderLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'orders'
|
||||
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailRows.value = []
|
||||
try {
|
||||
const res = await revisitRateVisitOrderLines({
|
||||
...baseQuery(),
|
||||
slot,
|
||||
assistant_id: scope.assistant_id,
|
||||
dept_id: scope.dept_id
|
||||
})
|
||||
detailRows.value = res?.rows ?? []
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 订单详情(复用处方订单列表的详情抽屉) ──────────────
|
||||
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
|
||||
|
||||
const openOrderInfo = (orderId: number) => {
|
||||
if (!canViewOrderDetail || !orderId) {
|
||||
return
|
||||
}
|
||||
orderDetailRef.value?.open(orderId)
|
||||
}
|
||||
|
||||
// ── 患者(诊单)详情:诊单编辑页只读打开 ────────────────
|
||||
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
|
||||
|
||||
const openPatientDetail = (diagnosisId: number) => {
|
||||
if (!canViewPatientDetail || !diagnosisId) {
|
||||
return
|
||||
}
|
||||
diagnosisEditRef.value?.openViewOnly(diagnosisId)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
month.value = currentMonth
|
||||
deptId.value = undefined
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDeptOptions()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revisit-rate-page {
|
||||
.rate-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-caliber-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.rate-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.rate-kpi-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
|
||||
&.is-clickable {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-kpi-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rate-kpi-value {
|
||||
margin-top: 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rate-kpi-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
|
||||
.rate-card-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-dept-cell {
|
||||
font-weight: 600;
|
||||
|
||||
.rate-dept-count {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-th-hint {
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-detail-footer {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rate-caliber {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
p + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,13 +14,14 @@
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<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 :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-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="queryParams.dept_id"
|
||||
@@ -33,7 +34,7 @@
|
||||
clearable
|
||||
filterable
|
||||
class="w-[220px]"
|
||||
@change="resetPage"
|
||||
@change="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自媒体来源">
|
||||
@@ -44,7 +45,7 @@
|
||||
placeholder="全部来源"
|
||||
:allow-create="false"
|
||||
select-class="w-[220px]"
|
||||
@change="resetPage"
|
||||
@change="handleQuery"
|
||||
@visible-change="onMediaSourceDropdownVisible"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -53,11 +54,11 @@
|
||||
v-model="queryParams.remark"
|
||||
placeholder="备注 / 创建人"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
||||
</el-form-item>
|
||||
@@ -146,6 +147,8 @@ 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>>()
|
||||
@@ -173,25 +176,48 @@ 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: personalAccountCostLists,
|
||||
fetchFun: async (params: Record<string, any>) => {
|
||||
const [start_date, end_date] = resolve()
|
||||
return personalAccountCostLists({
|
||||
...params,
|
||||
start_date,
|
||||
end_date,
|
||||
})
|
||||
},
|
||||
params: queryParams,
|
||||
})
|
||||
|
||||
const handleQuery = () => {
|
||||
pager.page = 1
|
||||
getLists()
|
||||
}
|
||||
|
||||
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
|
||||
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
resetTimeFilter()
|
||||
queryParams.media_source = ''
|
||||
queryParams.dept_id = undefined
|
||||
queryParams.remark = ''
|
||||
resetPage()
|
||||
pager.page = 1
|
||||
pager.size = 15
|
||||
getLists()
|
||||
}
|
||||
|
||||
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||
@@ -236,9 +262,10 @@ const loadDeptOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadDeptOptions()
|
||||
loadMediaSourceOptions()
|
||||
await getLists()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -247,6 +274,12 @@ onMounted(() => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-cell {
|
||||
cursor: help;
|
||||
border-bottom: 1px dashed #c0c4cc;
|
||||
|
||||
@@ -556,7 +556,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
}
|
||||
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
if (!value) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
@@ -572,6 +576,7 @@ const formRules = {
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
||||
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
|
||||
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
|
||||
@@ -56,298 +56,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 业务订单详情抽屉(只读,与 order_list.vue 详情保持一致) -->
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
size="80%"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<span class="font-semibold text-lg">业务订单详情</span>
|
||||
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
|
||||
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
|
||||
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
|
||||
|
||||
<!-- 顶部数据概览 -->
|
||||
<el-row :gutter="16" class="mb-5">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">总金额</div>
|
||||
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">已付总额</div>
|
||||
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">需代收</div>
|
||||
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
|
||||
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} 笔</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<!-- 左侧:处方单(不含测试价格、改患者信息、药材明细) -->
|
||||
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
|
||||
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
|
||||
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-[15px]">处方详情</span>
|
||||
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
|
||||
开方人:<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="detailPrescription">
|
||||
<el-descriptions :column="2" border size="small" class="bg-white">
|
||||
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}岁</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">
|
||||
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
|
||||
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
|
||||
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
|
||||
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方金额">
|
||||
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="消费者审核">
|
||||
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服用方式">
|
||||
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
||||
<div>
|
||||
<span class="text-gray-500">每天次数:</span>
|
||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处方开立:</span>
|
||||
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">订单设置:</span>
|
||||
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<el-empty v-else description="无处方数据" :image-size="72" />
|
||||
</el-card>
|
||||
|
||||
<!-- 右侧:未关联 + 已关联支付单 -->
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<el-card shadow="never" class="border-gray-100">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-[15px]">未关联的收款记录</span>
|
||||
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="86" align="right">
|
||||
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="136">
|
||||
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="无未关联的收款单" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
|
||||
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
|
||||
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-[15px]">关联收款记录</span>
|
||||
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="86" align="right">
|
||||
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="136">
|
||||
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="未关联收款单" :image-size="60" />
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 履约与收货信息 -->
|
||||
<el-card shadow="never" class="border-gray-100 mb-4">
|
||||
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务订单创建人" :span="2">
|
||||
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
|
||||
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
|
||||
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人所属部门" :span="3">
|
||||
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
|
||||
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
|
||||
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
|
||||
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货手机">
|
||||
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
|
||||
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
|
||||
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 物流轨迹(只读) -->
|
||||
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
|
||||
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" class="w-[180px]" @keyup.enter="fetchLogisticsTrace" />
|
||||
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
|
||||
<el-option label="自动识别" value="auto" />
|
||||
<el-option label="顺丰速运" value="sf" />
|
||||
<el-option label="京东快递" value="jd" />
|
||||
<el-option label="极兔速递" value="jt" />
|
||||
</el-select>
|
||||
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
|
||||
</div>
|
||||
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
|
||||
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
|
||||
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
|
||||
<span class="text-gray-800">状态:{{ logisticsTracePayload.state_text }}</span>
|
||||
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
|
||||
</div>
|
||||
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
|
||||
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
|
||||
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
|
||||
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
|
||||
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
|
||||
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
|
||||
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
|
||||
</div>
|
||||
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无操作日志" :image-size="64" />
|
||||
</el-card>
|
||||
</div>
|
||||
</el-drawer>
|
||||
<!-- 业务订单详情抽屉:共享组件的 readonly 受限版(展示范围由组件内 v-if="!readonly" 门控) -->
|
||||
<prescription-order-detail-drawer ref="detailDrawerRef" readonly append-to-body />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref, onMounted } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||
import {
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderPaidPayOrders
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
formatTime,
|
||||
fulfillmentText,
|
||||
fulfillmentTagType
|
||||
} from '@/views/consumer/prescription/components/prescription-order-utils'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
@@ -379,175 +102,11 @@ const buildParams = () => {
|
||||
queryParams.scene = 'diagnosis_edit'
|
||||
}
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
// ─── 详情抽屉(共享组件,数据拉取/展示全部在组件内) ───
|
||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」相关字段
|
||||
*/
|
||||
const showPrescriptionAuditFilter = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
|
||||
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||
})
|
||||
|
||||
// ─── 详情抽屉 ───
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<any>(null)
|
||||
const detailLogs = ref<any[]>([])
|
||||
const detailUnlinkedPayOrders = ref<any[]>([])
|
||||
|
||||
const detailPrescription = computed(() => {
|
||||
const p = detailData.value?.prescription
|
||||
return p && typeof p === 'object' ? p : null
|
||||
})
|
||||
|
||||
const detailLinkedPayOrders = computed(() => {
|
||||
const arr = detailData.value?.linked_pay_orders
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const detailAgencyToCollect = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return 0
|
||||
const total = Number(d.amount) || 0
|
||||
const paid = Number(d.linked_pay_paid_total) || 0
|
||||
return Math.round((total - paid) * 100) / 100
|
||||
})
|
||||
|
||||
const detailRecipientRxPhoneMismatch = computed(() => {
|
||||
const rx = normalizeBizPhone(detailPrescription.value?.phone)
|
||||
const biz = normalizeBizPhone(detailData.value?.recipient_phone)
|
||||
if (!rx || !biz) return false
|
||||
return rx !== biz
|
||||
})
|
||||
|
||||
const detailFullAddress = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return '—'
|
||||
const parts = []
|
||||
if (d.shipping_province) parts.push(d.shipping_province)
|
||||
if (d.shipping_city) parts.push(d.shipping_city)
|
||||
if (d.shipping_district) parts.push(d.shipping_district)
|
||||
if (d.shipping_address) parts.push(d.shipping_address)
|
||||
return parts.length > 0 ? parts.join(' ') : '—'
|
||||
})
|
||||
|
||||
const detailOrderCreatorMetaLine = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return ''
|
||||
const account = String(d.creator_account ?? '').trim()
|
||||
const mobile = String(d.creator_mobile ?? '').trim()
|
||||
const parts: string[] = []
|
||||
if (account) parts.push(`登录账号:${account}`)
|
||||
if (mobile) parts.push(`手机:${mobile}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
|
||||
const raw = String(detailData.value?.order_creator_dept_path ?? detailData.value?.diagnosis_creator_dept_path ?? '').trim()
|
||||
if (!raw) return [] as string[][]
|
||||
return raw.split(';').map((s) => s.trim()).filter(Boolean)
|
||||
.map((p) => p.includes(' / ') ? p.split(' / ').map((x) => x.trim()).filter(Boolean) : p.split('/').map((x) => x.trim()).filter(Boolean))
|
||||
.filter((segs) => segs.length > 0)
|
||||
})
|
||||
|
||||
// ─── 物流轨迹 ───
|
||||
const detailLogisticsExpress = ref<string>('auto')
|
||||
const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
|
||||
const logisticsTraceList = computed(() => {
|
||||
const p = logisticsTracePayload.value
|
||||
if (!p || !Array.isArray(p.traces)) return []
|
||||
return p.traces as Array<{ time: string; context: string }>
|
||||
})
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id,
|
||||
express_company: detailLogisticsExpress.value
|
||||
}
|
||||
if (digits.length >= 4) params.phone_tail = digits
|
||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
||||
} catch {
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 打开详情 ───
|
||||
async function openDetail(id: number) {
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
logisticsTracePayload.value = null
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
logisticsTracePhoneTail.value = ''
|
||||
detailLogs.value = []
|
||||
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const d = res?.data ?? res ?? null
|
||||
detailData.value = d
|
||||
if (d) {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (String(d.tracking_number || '').trim()) fetchLogisticsTrace()
|
||||
fetchLogs(id)
|
||||
if (d.diagnosis_id) {
|
||||
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '加载详情失败')
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
|
||||
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
|
||||
try {
|
||||
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
|
||||
const lists = res?.lists ?? res?.data?.lists ?? []
|
||||
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
|
||||
} catch { detailUnlinkedPayOrders.value = [] }
|
||||
}
|
||||
|
||||
function canViewPrescriptionOrderLogs() {
|
||||
const p = userStore.perms || []
|
||||
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
|
||||
}
|
||||
|
||||
async function fetchLogs(id: number) {
|
||||
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
|
||||
try {
|
||||
const res: any = await prescriptionOrderLogs({ id })
|
||||
detailLogs.value = res?.data ?? res ?? []
|
||||
} catch { detailLogs.value = [] }
|
||||
}
|
||||
|
||||
// ─── 工具函数 ───
|
||||
function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
function openDetail(id: number) {
|
||||
detailDrawerRef.value?.open(id)
|
||||
}
|
||||
|
||||
const formatAmount = (value: unknown) => {
|
||||
@@ -555,126 +114,6 @@ const formatAmount = (value: unknown) => {
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
|
||||
}
|
||||
|
||||
function formatMoney(v: unknown) {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
|
||||
}
|
||||
|
||||
const formatTime = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
|
||||
const d = new Date(value * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatOrderTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'string' && String(v).includes('-')) return String(v)
|
||||
return formatTime(v)
|
||||
}
|
||||
|
||||
function feeTypeText(t: number | undefined) {
|
||||
const m: Record<number, string> = { 1: '药费', 2: '诊费', 3: '药费+诊费', 4: '其他' }
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
// 服务套餐字典选项(与 order_list.vue 数据源一致:dict 类型 server_order)
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
async function loadServicePackageOptions() {
|
||||
try {
|
||||
const data: any = await getDictData({ type: 'server_order' })
|
||||
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
|
||||
servicePackageOptions.value = list.filter((item) => item.status !== 0)
|
||||
} catch {
|
||||
servicePackageOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatServicePackage(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
let packages: string[] = []
|
||||
if (Array.isArray(value)) {
|
||||
packages = value.map((v) => String(v)).filter((v) => v !== '')
|
||||
} else if (typeof value === 'string') {
|
||||
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
|
||||
}
|
||||
if (packages.length === 0) return '—'
|
||||
const names = packages.map((val) => {
|
||||
const opt = servicePackageOptions.value.find((o) => o.value === val)
|
||||
return opt ? opt.name : val
|
||||
})
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadServicePackageOptions()
|
||||
})
|
||||
|
||||
function expressCompanyLabel(v: unknown) {
|
||||
const s = String(v || '').toLowerCase()
|
||||
if (s === 'sf') return '顺丰速运'
|
||||
if (s === 'jd') return '京东快递'
|
||||
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||
return '自动识别'
|
||||
}
|
||||
|
||||
function consumerRxAuditText(s: number | undefined) {
|
||||
const n = Number(s)
|
||||
if (n === 1) return '已通过'
|
||||
if (n === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 1) return 'success'
|
||||
if (n === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 2) return 'success'
|
||||
if (n === 1) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function orderStatusText(s: number | undefined) {
|
||||
const m: Record<number, string> = { 0: '待支付', 1: '待支付', 2: '已支付', 3: '已取消' }
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
const fulfillmentText = (s: number | undefined) => {
|
||||
const m: Record<number, string> = { 1: '待双审通过', 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货', 6: '已签收', 7: '进行中', 8: '暂不制药', 9: '拒收', 10: '退款', 11: '保留药方', 12: '制药缓发' }
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
|
||||
const n = Number(s)
|
||||
if (n === 3 || n === 6) return 'success'
|
||||
if (n === 5) return 'primary'
|
||||
if (n === 4 || n === 9 || n === 10) return 'danger'
|
||||
if (n === 2 || n === 7) return 'warning'
|
||||
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function logActionText(act: string) {
|
||||
const m: Record<string, string> = {
|
||||
create: '创建', edit: '编辑', audit_rx_approve: '处方审核', audit_rx_reject: '处方审核',
|
||||
audit_pay_approve: '支付审核', audit_pay_reject: '支付审核', fill_tracking: '填快递单',
|
||||
ship: '确认发货', withdraw: '撤销', link_pay_order: '关联支付单',
|
||||
revoke_rx_audit: '撤回处方审核', revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单', patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额', complete: '完成订单'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.diagnosisId, patientIdNum.value] as const,
|
||||
() => {
|
||||
@@ -695,22 +134,4 @@ defineExpose({ refresh: () => getLists() })
|
||||
.po-empty-tip {
|
||||
padding: 24px 0;
|
||||
}
|
||||
.audit-stamp {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
transform: rotate(15deg);
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.stamp-inner {
|
||||
border: 3px solid currentColor;
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.stamp-pass { color: #22c55e; }
|
||||
.stamp-reject { color: #ef4444; }
|
||||
</style>
|
||||
|
||||
@@ -67,26 +67,66 @@
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
|
||||
<el-alert
|
||||
v-if="mode === 'edit' && patientBasicLocked && !canEditPatientBasicFields"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
>
|
||||
<template v-if="latestPrescriptionOrder">
|
||||
最近业务订单 #{{ latestPrescriptionOrder.id }} 状态为「{{ latestPrescriptionOrder.fulfillment_status_text }}」,患者基本信息不可修改
|
||||
</template>
|
||||
<template v-else>
|
||||
存在未完成的业务订单,患者基本信息不可修改
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-form-item label="诊单ID">
|
||||
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
|
||||
</el-form-item>
|
||||
|
||||
<fieldset
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
class="diagnosis-patient-basic-fieldset border-0 min-w-0 p-0 m-0"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓名" prop="patient_name">
|
||||
<el-input
|
||||
v-model="formData.patient_name"
|
||||
placeholder="请输入姓名"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="身份证号" prop="id_card">
|
||||
<template v-if="showIdCardMaskedEdit">
|
||||
<el-input
|
||||
:model-value="
|
||||
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
|
||||
"
|
||||
readonly
|
||||
placeholder="点击可查看完整身份证号"
|
||||
maxlength="18"
|
||||
class="cursor-pointer phone-mask-toggle"
|
||||
@click="toggleIdCardReveal"
|
||||
/>
|
||||
<p
|
||||
v-if="originalIdCard && !idCardRevealUnlocked"
|
||||
class="text-xs text-gray-400 mt-1"
|
||||
>
|
||||
当前为脱敏显示,点击输入框可查看完整号码
|
||||
</p>
|
||||
</template>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="formData.id_card"
|
||||
placeholder="请输入身份证号"
|
||||
maxlength="18"
|
||||
:disabled="!canEditIdCard"
|
||||
@focus="handleIdCardFocus"
|
||||
@blur="handleIdCardBlur"
|
||||
/>
|
||||
@@ -120,6 +160,7 @@
|
||||
v-model="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
@focus="handlePhoneFocus"
|
||||
@blur="handlePhoneBlur"
|
||||
/>
|
||||
@@ -127,7 +168,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-radio-group v-model="formData.gender">
|
||||
<el-radio-group v-model="formData.gender" :disabled="!canEditPatientBasicFields">
|
||||
<el-radio :label="1">男</el-radio>
|
||||
<el-radio :label="0">女</el-radio>
|
||||
</el-radio-group>
|
||||
@@ -144,11 +185,14 @@
|
||||
:max="150"
|
||||
placeholder="请输入年龄"
|
||||
class="w-full"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
:controls="canEditPatientBasicFields"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
</fieldset>
|
||||
|
||||
|
||||
<!-- 生命体征 -->
|
||||
@@ -778,9 +822,40 @@ const submitting = ref(false)
|
||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
|
||||
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
|
||||
const patientBasicLocked = ref(false)
|
||||
const canEditPatientBasicFromApi = ref(true)
|
||||
const latestPrescriptionOrder = ref<{
|
||||
id: number
|
||||
fulfillment_status: number
|
||||
fulfillment_status_text: string
|
||||
} | null>(null)
|
||||
const canEditPatientBasicFields = computed(() => {
|
||||
if (viewOnly.value) return false
|
||||
if (mode.value === 'add') return true
|
||||
return canEditPatientBasicFromApi.value
|
||||
})
|
||||
/** 编辑且无明文权限或基本信息锁定:只读脱敏,点击切换查看完整号 */
|
||||
const showPhoneMaskedEdit = computed(() => {
|
||||
if (mode.value !== 'edit') return false
|
||||
if (!canEditPatientBasicFields.value) return true
|
||||
return !hasPhonePlainPermission.value
|
||||
})
|
||||
const phoneRevealUnlocked = ref(false)
|
||||
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
|
||||
const showIdCardMaskedEdit = computed(() => {
|
||||
if (mode.value !== 'edit') return false
|
||||
if (!originalIdCard.value) return false
|
||||
if (!canEditPatientBasicFields.value) return true
|
||||
return !hasPhonePlainPermission.value
|
||||
})
|
||||
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
|
||||
const canEditIdCard = computed(() => {
|
||||
if (!canEditPatientBasicFields.value) return false
|
||||
if (mode.value === 'add') return true
|
||||
if (!originalIdCard.value) return true
|
||||
return hasPhonePlainPermission.value
|
||||
})
|
||||
const idCardRevealUnlocked = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
const drawerTitle = computed(() => {
|
||||
if (viewOnly.value) return '诊单详情'
|
||||
@@ -920,6 +995,11 @@ const togglePhoneReveal = () => {
|
||||
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
|
||||
}
|
||||
|
||||
const toggleIdCardReveal = () => {
|
||||
if (!originalIdCard.value) return
|
||||
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
|
||||
}
|
||||
|
||||
const maskIdCard = (idCard: string) => {
|
||||
if (!idCard) return idCard
|
||||
if (idCard.length === 15) {
|
||||
@@ -990,43 +1070,40 @@ const handlePhoneBlur = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证聚焦 - 显示完整数据
|
||||
// 身份证聚焦 - 显示完整数据(无「明文」权限时仅新增模式在失焦后可再次展开编辑)
|
||||
const handleIdCardFocus = () => {
|
||||
isIdCardFocused.value = true
|
||||
if (originalIdCard.value) {
|
||||
if (!originalIdCard.value) return
|
||||
if (hasPhonePlainPermission.value) {
|
||||
formData.value.id_card = originalIdCard.value
|
||||
return
|
||||
}
|
||||
if (mode.value === 'add') {
|
||||
formData.value.id_card = originalIdCard.value
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证失焦 - 恢复脱敏并验证
|
||||
// 身份证失焦:有明文权限则保持明文并校验;新增且无明文权限时仍脱敏展示
|
||||
const handleIdCardBlur = async () => {
|
||||
isIdCardFocused.value = false
|
||||
|
||||
// 保存原始数据并脱敏显示
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
|
||||
// 验证身份证格式并计算年龄
|
||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
||||
// 从身份证提取出生日期计算年龄
|
||||
const id = formData.value.id_card
|
||||
const birthYear = parseInt(id.substring(6, 10))
|
||||
const birthMonth = parseInt(id.substring(10, 12))
|
||||
const birthDay = parseInt(id.substring(12, 14))
|
||||
|
||||
const processIdCard = async (idCard: string) => {
|
||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
|
||||
const birthYear = parseInt(idCard.substring(6, 10))
|
||||
const birthMonth = parseInt(idCard.substring(10, 12))
|
||||
const birthDay = parseInt(idCard.substring(12, 14))
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
||||
age--
|
||||
}
|
||||
formData.value.age = age
|
||||
|
||||
// 验证身份证唯一性
|
||||
|
||||
try {
|
||||
const result = await checkIdCard({
|
||||
id_card: formData.value.id_card,
|
||||
id_card: idCard,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
@@ -1034,8 +1111,24 @@ const handleIdCardBlur = async () => {
|
||||
console.error('检查身份证号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 脱敏显示
|
||||
}
|
||||
|
||||
if (hasPhonePlainPermission.value) {
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
await processIdCard(formData.value.id_card)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 编辑模式下已有身份证号且无明文权限时不处理
|
||||
if (mode.value === 'edit' && originalIdCard.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
await processIdCard(formData.value.id_card)
|
||||
formData.value.id_card = maskIdCard(formData.value.id_card)
|
||||
}
|
||||
}
|
||||
@@ -1058,7 +1151,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
// 使用原始数据进行验证
|
||||
const idCardToValidate = originalIdCard.value || value
|
||||
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||
if (!idCardToValidate) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
@@ -1073,6 +1170,7 @@ const formRules = {
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
@@ -1167,9 +1265,12 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
|
||||
originalPhone.value = data.phone || ''
|
||||
originalIdCard.value = data.id_card || ''
|
||||
patientBasicLocked.value = !!data.patient_basic_locked
|
||||
canEditPatientBasicFromApi.value = data.can_edit_patient_basic !== false
|
||||
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
|
||||
|
||||
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
|
||||
data.id_card = maskIdCard(data.id_card || '')
|
||||
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
|
||||
|
||||
formData.value = data
|
||||
formData.value.create_source = data.create_source ?? ''
|
||||
@@ -1178,12 +1279,20 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
||||
}
|
||||
|
||||
const resetPatientBasicLockState = () => {
|
||||
patientBasicLocked.value = false
|
||||
canEditPatientBasicFromApi.value = true
|
||||
latestPrescriptionOrder.value = null
|
||||
}
|
||||
|
||||
const open = async (type: string, id?: number) => {
|
||||
viewOnly.value = false
|
||||
mode.value = type
|
||||
visible.value = true
|
||||
activeTab.value = 'basic' // 重置到基本信息标签页
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
|
||||
// 加载字典数据
|
||||
await getDictOptions()
|
||||
@@ -1206,6 +1315,8 @@ const openViewOnly = async (id: number) => {
|
||||
visible.value = true
|
||||
activeTab.value = 'basic'
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
await getDictOptions()
|
||||
await loadDiagnosisDetailIntoForm(id)
|
||||
}
|
||||
@@ -1230,6 +1341,7 @@ const handleSubmit = async () => {
|
||||
if (result && result.id) {
|
||||
mode.value = 'edit'
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
formData.value.id = result.id
|
||||
|
||||
// 重新获取详情,确保patient_id等字段正确
|
||||
@@ -1244,7 +1356,9 @@ const handleSubmit = async () => {
|
||||
formData.value.phone = hasPhonePlainPermission.value
|
||||
? detail.phone || ''
|
||||
: maskPhone(detail.phone || '')
|
||||
formData.value.id_card = maskIdCard(detail.id_card || '')
|
||||
formData.value.id_card = hasPhonePlainPermission.value
|
||||
? detail.id_card || ''
|
||||
: maskIdCard(detail.id_card || '')
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
}
|
||||
@@ -1262,7 +1376,7 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
const handleViewCase = (row: any) => {
|
||||
prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true })
|
||||
prescriptionRef.value?.openById(row.id, { slipType: 'user' })
|
||||
}
|
||||
|
||||
// 开方:传入当前诊单数据,包含详细病历(深拷贝避免响应式引用)
|
||||
@@ -1346,6 +1460,7 @@ const handleClose = () => {
|
||||
isPhoneFocused.value = false
|
||||
isIdCardFocused.value = false
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
viewOnly.value = false
|
||||
|
||||
visible.value = false
|
||||
@@ -1370,6 +1485,19 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.diagnosis-patient-basic-fieldset:disabled {
|
||||
opacity: 1;
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-input-number),
|
||||
:deep(.el-input-number__decrease),
|
||||
:deep(.el-input-number__increase),
|
||||
:deep(.el-radio),
|
||||
:deep(.el-radio__input) {
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form) {
|
||||
padding-bottom: 20px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
formData.pending_assign !== '1' &&
|
||||
formData.pending_booking !== '1' &&
|
||||
formData.completed_appointment !== '1' &&
|
||||
!hasLatestAppointmentFilter() &&
|
||||
formData.appointment_date === tab.value
|
||||
}
|
||||
]"
|
||||
@@ -110,6 +111,32 @@
|
||||
<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>
|
||||
@@ -213,6 +240,9 @@
|
||||
<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>
|
||||
@@ -689,6 +719,7 @@ 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'))
|
||||
@@ -728,6 +759,9 @@ 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',
|
||||
@@ -760,6 +794,9 @@ 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()
|
||||
@@ -823,6 +860,9 @@ 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>
|
||||
}
|
||||
@@ -850,6 +890,9 @@ 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 !== '') {
|
||||
@@ -886,6 +929,7 @@ const handleDateTabClick = async (value: string) => {
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
clearLatestAppointmentFilters()
|
||||
formData.appointment_date = value
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
@@ -901,6 +945,7 @@ const handlePendingBookingTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = '0'
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
@@ -915,6 +960,7 @@ const handleCompletedVisitTabClick = async () => {
|
||||
formData.completed_appointment = '1'
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
@@ -926,6 +972,7 @@ 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')
|
||||
}
|
||||
@@ -1047,16 +1094,29 @@ 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, assistants] = await Promise.all([
|
||||
const [diagnosisType, syndromeType, channels, 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)
|
||||
@@ -1069,6 +1129,40 @@ 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,返回"-"
|
||||
@@ -1103,6 +1197,9 @@ 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 = ''
|
||||
@@ -2190,10 +2287,20 @@ 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 {
|
||||
@@ -2363,6 +2470,13 @@ 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 {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</span>
|
||||
<div>
|
||||
<div class="wb-section-name">订单统计</div>
|
||||
<div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '—' }}</div>
|
||||
<div class="wb-section-sub">{{ orderStatsDateRangeText }} · {{ orderStatsData.order_type_name || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wb-toolbar">
|
||||
@@ -821,8 +821,8 @@ onMounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
.workbench-page {
|
||||
min-height: 100%;
|
||||
padding: 20px 20px 40px;
|
||||
background: linear-gradient(160deg, #eef2ff 0%, #f8fafc 38%, #f1f5f9 100%);
|
||||
padding: 4px 0 24px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.wb-hero {
|
||||
@@ -830,56 +830,86 @@ onMounted(() => {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
padding: 22px 26px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.65) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.06);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding: 28px 28px;
|
||||
border-radius: var(--admin-radius-xl);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--admin-surface-glass);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(120deg, rgba(6, 182, 212, 0.14) 0%, transparent 45%),
|
||||
linear-gradient(300deg, rgba(16, 185, 129, 0.1) 0%, transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 3px;
|
||||
background: var(--admin-brand-gradient);
|
||||
}
|
||||
}
|
||||
|
||||
.wb-hero-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wb-hero-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #0f172a;
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: var(--admin-brand-gradient);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.wb-hero-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.wb-refresh-btn {
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: var(--admin-brand-glow);
|
||||
}
|
||||
|
||||
.wb-section {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.05);
|
||||
border-radius: var(--admin-radius-xl);
|
||||
background: var(--admin-surface-elevated);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.wb-section--diagnosis {
|
||||
border-top: 3px solid transparent;
|
||||
border-image: linear-gradient(90deg, #3b82f6, #60a5fa) 1;
|
||||
border-image: linear-gradient(90deg, #06b6d4, #10b981) 1;
|
||||
}
|
||||
|
||||
.wb-section--order {
|
||||
border-top: 3px solid transparent;
|
||||
border-image: linear-gradient(90deg, #8b5cf6, #a78bfa) 1;
|
||||
border-image: linear-gradient(90deg, #0891b2, #6366f1) 1;
|
||||
}
|
||||
|
||||
.wb-section--trend {
|
||||
border-top: 3px solid transparent;
|
||||
border-image: linear-gradient(90deg, #14b8a6, #2dd4bf) 1;
|
||||
border-image: linear-gradient(90deg, #10b981, #06b6d4) 1;
|
||||
}
|
||||
|
||||
.wb-section-head {
|
||||
@@ -889,8 +919,8 @@ onMounted(() => {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.9) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
background: var(--admin-brand-gradient-soft);
|
||||
}
|
||||
|
||||
.wb-section-title {
|
||||
@@ -903,38 +933,68 @@ onMounted(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wb-section-icon--blue {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35);
|
||||
background: var(--admin-brand-gradient);
|
||||
box-shadow: var(--admin-brand-glow);
|
||||
}
|
||||
|
||||
.wb-section-icon--violet {
|
||||
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
|
||||
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.3);
|
||||
background: linear-gradient(135deg, #0891b2, #6366f1);
|
||||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
.wb-section-icon--teal {
|
||||
background: linear-gradient(135deg, #14b8a6, #0d9488);
|
||||
box-shadow: 0 8px 20px rgba(13, 148, 136, 0.3);
|
||||
background: linear-gradient(135deg, #10b981, #06b6d4);
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.wb-kpi--blue {
|
||||
background: linear-gradient(145deg, rgba(6, 182, 212, 0.12), rgba(16, 185, 129, 0.06));
|
||||
border: 1px solid rgba(6, 182, 212, 0.22);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.wb-kpi--violet {
|
||||
background: linear-gradient(145deg, rgba(99, 102, 241, 0.1), rgba(6, 182, 212, 0.06));
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.wb-kpi--amber {
|
||||
background: linear-gradient(145deg, rgba(245, 158, 11, 0.12), rgba(251, 191, 36, 0.06));
|
||||
border: 1px solid rgba(245, 158, 11, 0.24);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.wb-rank-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 112px;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--admin-radius-lg);
|
||||
background: var(--admin-brand-gradient-soft);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.wb-section-name {
|
||||
font-size: 17px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.wb-section-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.wb-toolbar {
|
||||
@@ -968,21 +1028,6 @@ onMounted(() => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wb-kpi--blue {
|
||||
background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.wb-kpi--violet {
|
||||
background: linear-gradient(145deg, #f5f3ff 0%, #ede9fe 100%);
|
||||
border: 1px solid rgba(139, 92, 246, 0.22);
|
||||
}
|
||||
|
||||
.wb-kpi--amber {
|
||||
background: linear-gradient(145deg, #fffbeb 0%, #fef3c7 100%);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.wb-kpi-label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
@@ -1007,17 +1052,6 @@ onMounted(() => {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.wb-rank-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 112px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.wb-rank-strip--compact {
|
||||
min-height: 112px;
|
||||
}
|
||||
@@ -1080,9 +1114,10 @@ onMounted(() => {
|
||||
.wb-chart-card {
|
||||
height: 100%;
|
||||
padding: 14px 16px 8px;
|
||||
border-radius: 16px;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #eef0f4;
|
||||
border-radius: var(--admin-radius-lg);
|
||||
background: var(--admin-surface-elevated);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.wb-chart-card-head {
|
||||
|
||||
@@ -77,7 +77,22 @@ module.exports = {
|
||||
mask: 'var(--el-mask-color)'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['PingFang SC', 'Arial', 'Hiragino Sans GB', 'Microsoft YaHei', 'sans-serif']
|
||||
sans: [
|
||||
'PingFang SC',
|
||||
'SF Pro Text',
|
||||
'Segoe UI',
|
||||
'Arial',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
'sans-serif'
|
||||
]
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 'var(--admin-radius-sm)',
|
||||
DEFAULT: 'var(--admin-radius-md)',
|
||||
md: 'var(--admin-radius-md)',
|
||||
lg: 'var(--admin-radius-lg)',
|
||||
xl: 'var(--admin-radius-xl)'
|
||||
},
|
||||
boxShadow: {
|
||||
DEFAULT: 'var(--el-box-shadow)',
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user