Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62467dc01c | ||
|
|
97fefbae8f | ||
|
|
541fe59ad1 | ||
|
|
b6c5b422c2 | ||
|
|
500038601c | ||
|
|
f6688a740d | ||
|
|
a347e0642c | ||
|
|
13adbac3fd | ||
|
|
333a18c37e | ||
|
|
e3a91a91f7 | ||
|
|
5ca5f06255 | ||
|
|
e01f3e7072 | ||
|
|
ed78803015 | ||
|
|
546138d60e | ||
|
|
fdddfdb3dd | ||
|
|
f5a89b4310 | ||
|
|
a650747fd3 | ||
|
|
46b62229a6 | ||
|
|
0f33f2ad28 | ||
|
|
a677b69e97 | ||
|
|
6432e9990c | ||
|
|
4d6125268a | ||
|
|
7f93cf5480 | ||
|
|
f8f1953a2a | ||
|
|
99eca8b2e5 | ||
|
|
bde396189a | ||
|
|
55e3b5d9e0 | ||
|
|
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 | ||
|
|
cdf8b1f76d | ||
|
|
ff61e815d8 |
@@ -26,10 +26,6 @@ bin-release/
|
||||
/.cursor
|
||||
/.codex
|
||||
/.agents
|
||||
/.claude
|
||||
/.claude
|
||||
/.claude
|
||||
/.kbctx
|
||||
/server/.spool
|
||||
/server/.claude
|
||||
/.spool
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -215,6 +223,15 @@
|
||||
"backgroundColor": "#c7d2fe",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "endless-game/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "控糖消消乐",
|
||||
"backgroundColor": "#eefbf4",
|
||||
"disableScroll": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
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>
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 控糖消消乐独立功能包
|
||||
|
||||
此目录包含“控糖消消乐”无尽三消版的全部运行文件,可以整体复制到另一个 uni-app Vue 3 项目的 `tongji/endless-game/` 下。
|
||||
|
||||
## 目录内容
|
||||
|
||||
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
|
||||
- `game-endless.scss`:完整页面与动画样式。
|
||||
- `components/TongjiIcon.vue`:本功能包使用的图标组件。
|
||||
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
|
||||
- `utils/svgDataUrl.js`:图标编码工具。
|
||||
- `assets/food/`:本游戏使用的全部食品与驼乳粉图片。
|
||||
- `viteAssetOutput.js`:把食品图输出到本功能分包,避免增加微信小程序主包体积。
|
||||
|
||||
## 迁移步骤
|
||||
|
||||
1. 整体复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录。
|
||||
2. 在目标项目的 `vite.config` 中引入素材输出规则,并放到 `build.rollupOptions.output`:
|
||||
|
||||
```js
|
||||
import { endlessGameAssetFileNames } from './tongji/endless-game/viteAssetOutput.js'
|
||||
|
||||
// defineConfig 内
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: { assetFileNames: endlessGameAssetFileNames }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 在目标项目 `pages.json` 的 `tongji` 分包 `pages` 数组中加入:
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "endless-game/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "控糖消消乐",
|
||||
"backgroundColor": "#eefbf4",
|
||||
"disableScroll": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 使用 `/tongji/endless-game/index` 打开游戏。
|
||||
5. 微信小程序后台需要允许音频域名:`https://gz-1349751149.cos.ap-guangzhou.myqcloud.com`。
|
||||
|
||||
## 说明
|
||||
|
||||
- 旧地址 `/tongji/pages/game` 和 `/tongji/pages/game-endless` 只是当前项目的兼容跳转,迁移时不需要复制。
|
||||
- 当前项目原来的“糖分突袭”源码保存在 `tongji/legacy-game/game.vue`,没有注册为页面,不参与打包。
|
||||
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
|
||||
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
|
||||
- 进入游戏时展示“本周 7 人同行榜”界面原型,包含前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。当前榜单明确标注为预览数据,正式用户关系和周榜成绩需要后端接口接入。
|
||||
- 本功能包依赖 uni-app Vue 3 和 `@dcloudio/uni-app`,不依赖当前项目其他业务组件。
|
||||
- 食物风险等级和科普文案集中在 `index.vue` 的 `FOODS` 配置中,正式上线前仍需由医院医生审核。
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
|
||||
<image
|
||||
v-if="!useFallback"
|
||||
class="tj-icon"
|
||||
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
|
||||
:src="iconSrc"
|
||||
mode="aspectFit"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<!-- <text
|
||||
v-else
|
||||
class="tj-icon-fallback"
|
||||
:class="[`tj-icon-fallback--${size}`]"
|
||||
:style="{ color }"
|
||||
>{{ fallbackGlyph }}</text> -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: String, default: 'md' },
|
||||
color: { type: String, default: '#204E2B' }
|
||||
})
|
||||
|
||||
const useFallback = ref(false)
|
||||
|
||||
/** Lucide 风格描边路径 */
|
||||
const ICON_PATHS = {
|
||||
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
|
||||
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
|
||||
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
|
||||
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
|
||||
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
|
||||
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
|
||||
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
minus: '<path d="M5 12h14"/>',
|
||||
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
|
||||
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
|
||||
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
|
||||
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
|
||||
plus: '<path d="M5 12h14M12 5v14"/>',
|
||||
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
|
||||
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
|
||||
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
|
||||
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
|
||||
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
|
||||
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
|
||||
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
|
||||
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
|
||||
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
|
||||
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
|
||||
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
|
||||
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
|
||||
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
|
||||
}
|
||||
|
||||
/** 图片加载失败时的 emoji 回退 */
|
||||
const ICON_FALLBACK = {
|
||||
view: '👁',
|
||||
ticket: '🎫',
|
||||
calendar: '📅',
|
||||
flame: '🔥',
|
||||
'check-circle': '✓',
|
||||
glucose: '💧',
|
||||
heart: '❤',
|
||||
activity: '🏃',
|
||||
users: '👥',
|
||||
user: '👤',
|
||||
person: '👤',
|
||||
send: '➤',
|
||||
'alert-triangle': '⚠',
|
||||
minus: '—',
|
||||
droplet: '💧',
|
||||
sparkles: '✨',
|
||||
trophy: '🏆',
|
||||
share: '↗',
|
||||
plus: '+',
|
||||
refresh: '↻',
|
||||
volume: '🔊',
|
||||
pause: '⏸',
|
||||
play: '▶',
|
||||
info: '!',
|
||||
sun: '☀',
|
||||
moon: '🌙',
|
||||
sunset: '☀',
|
||||
mic: '🎤',
|
||||
camera: '📷',
|
||||
bulb: '💡',
|
||||
leaf: '🥬',
|
||||
utensils: '🍴',
|
||||
egg: '🍳',
|
||||
home: '🏠',
|
||||
'plus-circle': '⊕',
|
||||
'chevron-right': '›',
|
||||
'chevron-left': '‹',
|
||||
check: '✓',
|
||||
settings: '⚙',
|
||||
syringe: '💉',
|
||||
zap: '⚡'
|
||||
}
|
||||
|
||||
const strokeColor = computed(() => {
|
||||
const c = String(props.color || '#204E2B').trim()
|
||||
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
|
||||
})
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
const path = ICON_PATHS[props.name] || ICON_PATHS.view
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
return svgToDataUrl(svg)
|
||||
})
|
||||
|
||||
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
|
||||
|
||||
function onImageError() {
|
||||
useFallback.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tj-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tj-icon-wrap--sm {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
.tj-icon-wrap--md {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
.tj-icon-wrap--lg {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
.tj-icon-wrap--xl {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
.tj-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
|
||||
.tj-icon--send {
|
||||
transform: translate(-10%, 10%);
|
||||
}
|
||||
.tj-icon-fallback {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.tj-icon-fallback--sm {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.tj-icon-fallback--md {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.tj-icon-fallback--lg {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.tj-icon-fallback--xl {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
|
||||
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
|
||||
*/
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'tongji_game_sfx_enabled'
|
||||
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
|
||||
|
||||
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
|
||||
const SRC = {
|
||||
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
|
||||
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
|
||||
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
|
||||
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
|
||||
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
|
||||
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
|
||||
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
|
||||
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
|
||||
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
|
||||
}
|
||||
|
||||
/**
|
||||
* 音效表:src / volume / rate(playbackRate) / pool
|
||||
* rate 不同可在同一素材上听出明显差异
|
||||
*/
|
||||
const SFX = {
|
||||
// —— 交互 ——
|
||||
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
|
||||
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
|
||||
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
|
||||
|
||||
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
|
||||
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
|
||||
|
||||
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
|
||||
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
|
||||
|
||||
// —— 消除(按连数)——
|
||||
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
|
||||
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
|
||||
|
||||
// —— 消除(按 GI 主色)——
|
||||
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
|
||||
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
|
||||
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
|
||||
|
||||
// —— 连消 ——
|
||||
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
|
||||
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
|
||||
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
|
||||
|
||||
// —— 道具 ——
|
||||
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
|
||||
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
|
||||
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
|
||||
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
|
||||
|
||||
// —— 反馈 ——
|
||||
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
|
||||
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
|
||||
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
|
||||
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
|
||||
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
|
||||
|
||||
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
|
||||
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
|
||||
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
|
||||
|
||||
// 兼容旧名
|
||||
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
|
||||
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
|
||||
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
|
||||
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
|
||||
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
|
||||
}
|
||||
|
||||
const WARMUP_SFX = [
|
||||
'select', 'swap', 'swapFail', 'drop', 'dropLight',
|
||||
'match3', 'match4', 'match5', 'combo2', 'combo3', 'comboMega',
|
||||
'insulin', 'sparkle', 'reshuffle', 'lose'
|
||||
]
|
||||
|
||||
function createAudioContext() {
|
||||
if (typeof Audio !== 'undefined') {
|
||||
const audio = new Audio()
|
||||
audio.preload = 'auto'
|
||||
return {
|
||||
get src() { return audio.src },
|
||||
set src(value) { audio.src = value },
|
||||
get volume() { return audio.volume },
|
||||
set volume(value) { audio.volume = value },
|
||||
get playbackRate() { return audio.playbackRate },
|
||||
set playbackRate(value) { audio.playbackRate = value },
|
||||
play() {
|
||||
const promise = audio.play()
|
||||
if (promise?.catch) promise.catch(() => {})
|
||||
},
|
||||
stop() {
|
||||
audio.pause()
|
||||
try { audio.currentTime = 0 } catch (_) {}
|
||||
},
|
||||
seek(time) {
|
||||
try { audio.currentTime = time } catch (_) {}
|
||||
},
|
||||
destroy() {
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
}
|
||||
}
|
||||
}
|
||||
return uni.createInnerAudioContext()
|
||||
}
|
||||
|
||||
class SfxPool {
|
||||
constructor(src, size, volume, rate = 1) {
|
||||
this.src = src
|
||||
this.size = size
|
||||
this.volume = volume
|
||||
this.rate = rate
|
||||
this.list = []
|
||||
this.cursor = 0
|
||||
this.warmedUp = false
|
||||
}
|
||||
|
||||
createOne() {
|
||||
const ctx = createAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
ctx.volume = this.volume
|
||||
try {
|
||||
ctx.playbackRate = this.rate
|
||||
} catch (_) {}
|
||||
this.list.push(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
create() {
|
||||
while (this.list.length < this.size) this.createOne()
|
||||
}
|
||||
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
const ctx = this.list[0] || this.createOne()
|
||||
try {
|
||||
ctx.volume = 0
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.volume
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
if (this.size > 1) this.cursor = 1
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play(scale = 1, rateMul = 1) {
|
||||
if (this.list.length < this.size) this.create()
|
||||
const ctx = this.list[this.cursor % this.list.length]
|
||||
this.cursor += 1
|
||||
const vol = Math.min(1, this.volume * scale)
|
||||
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
|
||||
try {
|
||||
ctx.volume = vol
|
||||
try {
|
||||
ctx.playbackRate = rate
|
||||
} catch (_) {}
|
||||
ctx.stop()
|
||||
ctx.seek(0)
|
||||
ctx.play()
|
||||
} catch (_) {
|
||||
try {
|
||||
ctx.play()
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.destroy()
|
||||
} catch (_) {}
|
||||
})
|
||||
this.list = []
|
||||
this.warmedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
function dominantGi(tiles = []) {
|
||||
const cnt = { low: 0, mid: 0, high: 0 }
|
||||
tiles.forEach((t) => {
|
||||
const g = t?.type?.gi
|
||||
if (g && cnt[g] !== undefined) cnt[g] += 1
|
||||
})
|
||||
let best = 'mid'
|
||||
let max = 0
|
||||
Object.entries(cnt).forEach(([k, v]) => {
|
||||
if (v > max) {
|
||||
max = v
|
||||
best = k
|
||||
}
|
||||
})
|
||||
return best
|
||||
}
|
||||
|
||||
export function useGameSfx() {
|
||||
const enabled = ref(true)
|
||||
const pools = {}
|
||||
|
||||
try {
|
||||
const stored = uni.getStorageSync(STORAGE_KEY)
|
||||
if (stored === false || stored === '0' || stored === 0) {
|
||||
enabled.value = false
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
function ensureAudioOption() {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true
|
||||
})
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function getPool(name) {
|
||||
const cfg = SFX[name]
|
||||
if (!cfg) return null
|
||||
if (!pools[name]) {
|
||||
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
|
||||
}
|
||||
return pools[name]
|
||||
}
|
||||
|
||||
function warmUp() {
|
||||
if (!enabled.value) return
|
||||
ensureAudioOption()
|
||||
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
|
||||
names.forEach((name) => {
|
||||
getPool(name)?.warmUp()
|
||||
})
|
||||
}
|
||||
|
||||
function play(name, scale = 1, rateMul = 1) {
|
||||
if (!enabled.value) return
|
||||
getPool(name)?.play(scale, rateMul)
|
||||
}
|
||||
|
||||
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
|
||||
play(primary)
|
||||
if (secondary) {
|
||||
setTimeout(() => play(secondary, secScale), delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
|
||||
if (!enabled.value) return
|
||||
const giTrack = (() => {
|
||||
const gi = dominantGi(tiles)
|
||||
if (gi === 'low') return 'matchLowGi'
|
||||
if (gi === 'high') return 'matchHighGi'
|
||||
return 'matchMidGi'
|
||||
})()
|
||||
|
||||
if (chain >= 3) {
|
||||
playLayer('comboMega', giTrack, 90, 0.55)
|
||||
return
|
||||
}
|
||||
if (chain === 2) {
|
||||
playLayer('combo3', giTrack, 80, 0.5)
|
||||
return
|
||||
}
|
||||
if (chain === 1) {
|
||||
playLayer('combo2', giTrack, 70, 0.45)
|
||||
return
|
||||
}
|
||||
|
||||
if (matchCount >= 5) {
|
||||
playLayer('match5', 'matchHighGi', 85, 0.5)
|
||||
} else if (matchCount >= 4) {
|
||||
playLayer('match4', giTrack, 65, 0.48)
|
||||
} else {
|
||||
play(giTrack, 1, 1)
|
||||
setTimeout(() => play('match3', 0.85), 40)
|
||||
}
|
||||
}
|
||||
|
||||
function playDrop(count = 1) {
|
||||
if (!enabled.value || count <= 0) return
|
||||
const name = count >= 4 ? 'drop' : 'dropLight'
|
||||
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
|
||||
}
|
||||
|
||||
function playMeter(delta, level) {
|
||||
if (!enabled.value) return
|
||||
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
|
||||
else if (level < 30) play('meterWarn', 0.9, 0.95)
|
||||
else if (delta > 8) play('meterWarn', 0.75)
|
||||
else if (delta < -5) play('meterStable', 1.1)
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
enabled.value = !enabled.value
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
|
||||
} catch (_) {}
|
||||
if (enabled.value) {
|
||||
warmUp()
|
||||
play('select')
|
||||
}
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
Object.keys(pools).forEach((key) => {
|
||||
pools[key]?.destroy()
|
||||
delete pools[key]
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyAll()
|
||||
})
|
||||
|
||||
return {
|
||||
enabled,
|
||||
play,
|
||||
playLayer,
|
||||
playMatch,
|
||||
playDrop,
|
||||
playMeter,
|
||||
warmUp,
|
||||
toggleEnabled,
|
||||
destroyAll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
.eg-page {
|
||||
min-height: 100vh;
|
||||
color: #173b32;
|
||||
background: linear-gradient(180deg, #eefbf4 0%, #f9f3dd 56%, #fffaf1 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
.eg-confetti-layer { position: fixed; z-index: 80; inset: 0; overflow: hidden; pointer-events: none; }
|
||||
.eg-confetti-piece {
|
||||
position: absolute;
|
||||
top: -70rpx;
|
||||
display: block;
|
||||
border-radius: 3rpx;
|
||||
box-shadow: 0 2rpx 4rpx rgba(0,0,0,.12);
|
||||
animation-name: egConfettiFall;
|
||||
animation-timing-function: cubic-bezier(.18,.72,.35,1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.eg-confetti-piece.is-emoji { width: auto !important; height: auto !important; background: transparent !important; box-shadow: none; font-size: 42rpx; }
|
||||
|
||||
.eg-nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 28rpx;
|
||||
padding-bottom: 16rpx;
|
||||
padding-left: 28rpx;
|
||||
}
|
||||
|
||||
.eg-nav-btn {
|
||||
display: flex;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid rgba(21, 94, 75, .12);
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255,255,255,.82);
|
||||
}
|
||||
|
||||
.eg-nav-actions { display: flex; width: 156rpx; justify-content: flex-end; gap: 10rpx; }
|
||||
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
|
||||
|
||||
.eg-title-wrap { position: absolute; left: 50%; display: flex; flex-direction: column; align-items: center; transform: translateX(-50%); }
|
||||
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
|
||||
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
|
||||
.eg-content { padding: 0 16rpx 48rpx; }
|
||||
|
||||
.eg-score-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
border: 2rpx solid rgba(21, 94, 75, .1);
|
||||
border-radius: 28rpx;
|
||||
background: rgba(255,255,255,.9);
|
||||
box-shadow: 0 10rpx 28rpx rgba(32, 78, 43, .08);
|
||||
}
|
||||
|
||||
.eg-stat { display: flex; flex: 1; flex-direction: column; align-items: center; }
|
||||
.eg-stat--main { border-right: 2rpx solid #e2eee8; border-left: 2rpx solid #e2eee8; }
|
||||
.eg-stat-label { color: #71847d; font-size: 24rpx; }
|
||||
.eg-stat-value { margin-top: 4rpx; color: #204e2b; font-size: 38rpx; font-weight: 800; }
|
||||
.eg-stat-score { margin-top: 2rpx; color: #e16f24; font-size: 46rpx; font-weight: 900; }
|
||||
.eg-stat--rank { position: relative; cursor: pointer; }
|
||||
.eg-stat--rank.is-locked { opacity: .52; }
|
||||
.eg-stat-rank-value { margin-top: 1rpx; color: #176b52; font-size: 35rpx; font-weight: 1000; line-height: 1.08; }
|
||||
.eg-stat-rank-hint { margin-top: 2rpx; color: #df7427; font-size: 18rpx; font-weight: 800; }
|
||||
|
||||
.eg-task-card {
|
||||
margin-bottom: 16rpx;
|
||||
padding: 18rpx 22rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 249, 223, .94);
|
||||
box-shadow: 0 8rpx 22rpx rgba(118, 89, 25, .08);
|
||||
}
|
||||
.eg-task-card { position: relative; overflow: visible; }
|
||||
|
||||
.eg-task-reward-flight {
|
||||
position: absolute;
|
||||
z-index: 72;
|
||||
top: 4rpx;
|
||||
left: 42%;
|
||||
display: flex;
|
||||
width: 112rpx;
|
||||
height: 92rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 4rpx solid rgba(255,255,255,.96);
|
||||
border-radius: 24rpx;
|
||||
background: #fff7e8;
|
||||
box-shadow: 0 10rpx 24rpx rgba(159,94,23,.28);
|
||||
animation: egTaskRewardFlight 1.12s cubic-bezier(.18,.78,.22,1) both;
|
||||
pointer-events: none;
|
||||
}
|
||||
.eg-task-reward-flight > image { position: relative; z-index: 2; width: 78rpx; height: 66rpx; }
|
||||
.eg-task-reward-flight > text { position: absolute; z-index: 3; top: -14rpx; right: -16rpx; display: flex; width: 48rpx; height: 48rpx; align-items: center; justify-content: center; border: 4rpx solid #fff; border-radius: 50%; color: #fff; background: #ed7625; box-shadow: 0 5rpx 12rpx rgba(191,75,19,.3); font-size: 25rpx; font-weight: 1000; }
|
||||
.eg-task-reward-glow { position: absolute; z-index: 1; inset: -15rpx; border-radius: 32rpx; background: radial-gradient(circle, rgba(255,210,67,.52), rgba(255,210,67,0) 68%); animation: egRewardGlow .42s ease-in-out infinite alternate; }
|
||||
|
||||
.eg-task-head { display: flex; align-items: center; justify-content: space-between; color: #7c5b1b; font-size: 27rpx; font-weight: 700; }
|
||||
.eg-task-name { display: flex; align-items: center; gap: 10rpx; }
|
||||
.eg-task-count { color: #9a6718; font-size: 29rpx; }
|
||||
.eg-progress { height: 14rpx; margin-top: 12rpx; overflow: hidden; border-radius: 999rpx; background: #eadfbd; }
|
||||
.eg-progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #f0a128, #f4c34f); transition: width .25s; }
|
||||
|
||||
.eg-board-shell {
|
||||
position: relative;
|
||||
padding: 14rpx;
|
||||
border: 2rpx solid rgba(34, 103, 79, .16);
|
||||
border-radius: 34rpx;
|
||||
background: rgba(255,255,255,.92);
|
||||
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
|
||||
}
|
||||
|
||||
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
|
||||
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
|
||||
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
|
||||
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
|
||||
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
|
||||
.eg-risk-dots { display: flex; gap: 5rpx; }
|
||||
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
|
||||
.eg-risk-dot.on { background: #e96b3b; box-shadow: 0 0 0 3rpx rgba(233,107,59,.12); }
|
||||
.eg-board { display: flex; flex-direction: column; gap: 8rpx; opacity: 1; transition: opacity .15s; }
|
||||
.eg-board.is-busy { opacity: .82; }
|
||||
.eg-row { display: flex; gap: 8rpx; }
|
||||
|
||||
.eg-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 166rpx;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 5rpx solid transparent;
|
||||
border-radius: 26rpx;
|
||||
box-shadow: inset 0 -5rpx 0 rgba(0,0,0,.05), 0 5rpx 12rpx rgba(31, 66, 53, .08);
|
||||
transition: transform .16s cubic-bezier(.2,.8,.2,1), border-color .15s, opacity .16s, filter .16s;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.eg-cell.is-dragging { transition: transform .055s linear, border-color .15s; }
|
||||
.eg-cell.is-selected { z-index: 2; border-color: #167d61; transform: scale(1.06); box-shadow: 0 0 0 6rpx rgba(22,125,97,.14); }
|
||||
.eg-cell.is-camel-target { border-color: #f59e0b; animation: egPulse 1s infinite; }
|
||||
.eg-replace-target { position: absolute; z-index: 7; right: 7rpx; bottom: 6rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; background: #1570a6; box-shadow: 0 3rpx 9rpx rgba(20,91,135,.28); font-size: 20rpx; font-weight: 900; }
|
||||
.eg-cell.is-clearing {
|
||||
z-index: 8;
|
||||
border-color: #fff;
|
||||
animation: egHit .22s cubic-bezier(.2,.9,.3,1) forwards;
|
||||
filter: brightness(1.28) saturate(1.25);
|
||||
}
|
||||
.eg-cell.is-yellow,
|
||||
.eg-cell.is-cream,
|
||||
.eg-cell.is-red,
|
||||
.eg-cell.is-purple { background: #fffaf0; }
|
||||
.eg-cell.is-level-low { background: #f4faf6; }
|
||||
.eg-cell.is-level-mid { background: #fff9eb; }
|
||||
.eg-cell.is-orange,
|
||||
.eg-cell.is-level-high { background: #fff0e4; }
|
||||
.eg-cell.is-milk { background: #edf7ff; }
|
||||
.eg-cell.is-high { border-color: #ef7b45; }
|
||||
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
|
||||
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
|
||||
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
|
||||
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
|
||||
.eg-sugar-tag.is-mid { color: #735a15; border-color: rgba(255,255,255,.9); background: #f7e7a8; box-shadow: 0 2rpx 6rpx rgba(137,91,0,.15); }
|
||||
.eg-sugar-tag.is-high { background: #d9431f; box-shadow: 0 4rpx 10rpx rgba(163,45,21,.32); }
|
||||
.eg-sugar-tag.is-prop { background: #2563a8; }
|
||||
.eg-special-mark { position: absolute; z-index: 12; bottom: 5rpx; left: 6rpx; display: flex; width: 40rpx; height: 40rpx; align-items: center; justify-content: center; border: 3rpx solid rgba(255,255,255,.96); border-radius: 50%; color: #fff; background: #6d28d9; box-shadow: 0 4rpx 11rpx rgba(73,31,130,.3); font-size: 23rpx; font-weight: 900; }
|
||||
.eg-special-mark.is-row { width: 58rpx; height: 40rpx; border-radius: 999rpx; background: linear-gradient(135deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
|
||||
.eg-special-mark.is-col { width: 40rpx; height: 58rpx; border-radius: 999rpx; background: linear-gradient(180deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
|
||||
.eg-special-mark.is-burst { width: 48rpx; height: 48rpx; border-color: #fff3a8; border-radius: 15rpx 50% 50%; background: linear-gradient(145deg, #7c3aed 5%, #dd3b70 48%, #ff8a18 100%); box-shadow: 0 4rpx 13rpx rgba(108,35,155,.38), 0 0 12rpx rgba(255,179,38,.3); animation: egLReady 1.25s ease-in-out infinite alternate; }
|
||||
.eg-mini-flame { position: relative; width: 25rpx; height: 30rpx; border-radius: 70% 32% 68% 40%; background: linear-gradient(135deg, #fff8a8 8%, #ffc21f 42%, #f04420 84%); box-shadow: 0 0 14rpx rgba(255,230,92,.95); transform: rotate(43deg); }
|
||||
.eg-mini-flame-core { position: absolute; right: 4rpx; bottom: 3rpx; width: 10rpx; height: 15rpx; border-radius: 70% 35% 70% 42%; background: #fffbd1; box-shadow: 0 0 7rpx rgba(255,255,210,.95); }
|
||||
.eg-l-special { position: relative; width: 29rpx; height: 29rpx; filter: drop-shadow(0 1rpx 2rpx rgba(73,22,105,.3)); }
|
||||
.eg-l-arm { position: absolute; left: 3rpx; bottom: 3rpx; border-radius: 999rpx; background: #fff9b8; box-shadow: 0 0 6rpx rgba(255,249,184,.92); }
|
||||
.eg-l-arm.is-vertical { width: 8rpx; height: 25rpx; }
|
||||
.eg-l-arm.is-horizontal { width: 25rpx; height: 8rpx; }
|
||||
.eg-l-core { position: absolute; left: 1rpx; bottom: 1rpx; width: 12rpx; height: 12rpx; border: 2rpx solid #fff; border-radius: 50%; background: #ff5b25; }
|
||||
.eg-cell.is-fire-clearing { border-color: #ff7a1a; box-shadow: inset 0 0 25rpx rgba(255,102,18,.36), 0 0 18rpx rgba(255,105,20,.48); filter: brightness(1.35) saturate(1.42); }
|
||||
.eg-cell.is-clearing.is-fire-clearing { animation: egFireCellHit .36s cubic-bezier(.2,.78,.22,1) forwards; }
|
||||
.eg-fire-clear { position: absolute; z-index: 14; inset: 2rpx; overflow: hidden; border: 3rpx solid rgba(255,210,71,.92); border-radius: 22rpx; opacity: .96; background: linear-gradient(180deg, rgba(255,230,86,.12), rgba(239,63,27,.22)); pointer-events: none; }
|
||||
.eg-fire-glow { position: absolute; inset: 6%; border-radius: 18rpx; background: radial-gradient(circle, rgba(255,246,177,.74), rgba(255,131,22,.32) 47%, rgba(222,47,23,0) 75%); animation: egFireGlow .34s ease-out both; }
|
||||
.eg-fire-sweep { position: absolute; top: 23%; left: -48%; width: 168%; height: 55%; border-radius: 60% 45% 55% 42%; background: linear-gradient(90deg, rgba(255,184,38,0), rgba(255,238,124,.94) 28%, rgba(255,133,22,.92) 53%, rgba(226,54,25,.82) 72%, rgba(255,184,38,0)); box-shadow: 0 0 27rpx rgba(255,91,17,.72); animation: egFireSweep .34s cubic-bezier(.2,.78,.22,1) both; }
|
||||
.eg-fire-flame { position: absolute; bottom: 13%; width: 25rpx; height: 39rpx; border-radius: 72% 35% 68% 42%; background: linear-gradient(145deg, #fff7a6 4%, #ffc329 39%, #ff7419 66%, #dc321f 100%); box-shadow: 0 0 15rpx rgba(255,124,21,.88); transform: rotate(42deg); animation: egFireFlame .34s ease-out both; }
|
||||
.eg-fire-flame > view { position: absolute; right: 5rpx; bottom: 4rpx; width: 10rpx; height: 18rpx; border-radius: 70% 35% 70% 42%; background: #fffbd6; }
|
||||
.eg-fire-flame.is-left { left: 16%; animation-delay: .01s; }
|
||||
.eg-fire-flame.is-center { left: 43%; bottom: 19%; transform: rotate(42deg) scale(1.18); animation-delay: .035s; }
|
||||
.eg-fire-flame.is-right { right: 15%; animation-delay: .065s; }
|
||||
.eg-fire-spark { position: absolute; width: 10rpx; height: 17rpx; border-radius: 70% 35% 70% 42%; background: #ffe45e; box-shadow: 0 0 11rpx rgba(255,139,28,.9); animation: egFireSpark .34s ease-out both; }
|
||||
.eg-fire-spark.is-one { top: 46%; left: 25%; }
|
||||
.eg-fire-spark.is-two { top: 34%; left: 62%; animation-delay: .035s; }
|
||||
.eg-fire-spark.is-three { top: 52%; left: 78%; animation-delay: .07s; }
|
||||
.eg-board-tip { display: flex; align-items: center; justify-content: center; gap: 8rpx; min-height: 58rpx; margin-top: 12rpx; color: #684817; font-size: 26rpx; font-weight: 700; text-align: center; }
|
||||
.eg-impact-text {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 46%;
|
||||
left: 50%;
|
||||
padding: 10rpx 24rpx;
|
||||
border: 4rpx solid rgba(255,255,255,.9);
|
||||
border-radius: 999rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #f59e0b, #e4572e);
|
||||
box-shadow: 0 10rpx 28rpx rgba(190, 70, 25, .35);
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: egImpact .52s cubic-bezier(.16,.82,.3,1) forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
.eg-impact-text.is-level-2 { font-size: 40rpx; background: linear-gradient(135deg, #8b5cf6, #ec4899); }
|
||||
.eg-impact-text.is-level-3 { font-size: 46rpx; background: linear-gradient(135deg, #ef4444, #f59e0b); box-shadow: 0 12rpx 36rpx rgba(239,68,68,.42); }
|
||||
.eg-jackpot {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 48%;
|
||||
left: 50%;
|
||||
width: 540rpx;
|
||||
padding: 24rpx 22rpx 20rpx;
|
||||
overflow: hidden;
|
||||
border: 7rpx solid #ffd83d;
|
||||
border-radius: 36rpx;
|
||||
color: #fff;
|
||||
background: radial-gradient(circle at 50% 0%, #ef4444, #9f1239 65%, #64102a);
|
||||
box-shadow: 0 0 0 7rpx #fff2a8, 0 22rpx 52rpx rgba(100,16,42,.5);
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: egJackpotIn 1.35s cubic-bezier(.16,.85,.25,1) forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
.eg-jackpot.is-match-4 { background: radial-gradient(circle at 50% 0%, #8b5cf6, #5b21b6 68%, #35106d); }
|
||||
.eg-jackpot.is-match-5 { border-color: #fff36a; background: radial-gradient(circle at 50% 0%, #ff6b24, #d7193f 58%, #760c2a); animation-duration: 1.9s; }
|
||||
.eg-jackpot.is-match-3 {
|
||||
width: 414rpx;
|
||||
padding: 20rpx 22rpx 18rpx;
|
||||
border-width: 4rpx;
|
||||
border-color: #f1c663;
|
||||
color: #31574b;
|
||||
background: linear-gradient(155deg, #f7fff9, #fff5d9);
|
||||
box-shadow: 0 0 0 4rpx rgba(255,255,255,.84), 0 14rpx 34rpx rgba(98,82,39,.2);
|
||||
animation: egTripleIn 1.12s cubic-bezier(.2,.82,.28,1) forwards;
|
||||
}
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-lights { display: none; }
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-kicker { color: #5b8c73; font-size: 21rpx; letter-spacing: 3rpx; text-shadow: none; }
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-title { color: #365b4e; font-size: 31rpx; text-shadow: none; }
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-reward { color: #ad7021; font-size: 25rpx; }
|
||||
.eg-jackpot.is-match-3 .eg-jackpot-note { color: #829087; }
|
||||
.eg-triple-cans { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 12rpx 0 10rpx; }
|
||||
.eg-triple-can { display: flex; width: 78rpx; height: 76rpx; align-items: center; justify-content: center; border: 3rpx solid #f4d996; border-radius: 19rpx; background: rgba(255,255,255,.9); box-shadow: 0 5rpx 12rpx rgba(128,92,30,.12); animation: egTripleCan .42s cubic-bezier(.2,.86,.26,1) both; }
|
||||
.eg-triple-can:nth-child(2) { animation-delay: .07s; }
|
||||
.eg-triple-can:nth-child(3) { animation-delay: .14s; }
|
||||
.eg-triple-can > image { width: 62rpx; height: 58rpx; }
|
||||
|
||||
.eg-jackpot.is-match-4 {
|
||||
width: 548rpx;
|
||||
padding: 25rpx 22rpx 22rpx;
|
||||
border-color: #ffd95b;
|
||||
background: radial-gradient(circle at 50% -10%, #a97cff, #6031b5 58%, #341066);
|
||||
box-shadow: 0 0 0 6rpx #fff0a9, 0 0 36rpx rgba(255,210,76,.5), 0 24rpx 58rpx rgba(53,16,109,.48);
|
||||
animation: egFourIn 1.88s cubic-bezier(.16,.84,.24,1) forwards;
|
||||
}
|
||||
.eg-jackpot.is-match-4 .eg-jackpot-kicker { color: #ffef8d; font-size: 24rpx; letter-spacing: 3rpx; }
|
||||
.eg-jackpot.is-match-4 .eg-jackpot-title { margin-top: 9rpx; color: #fff; font-size: 39rpx; }
|
||||
.eg-jackpot.is-match-4 .eg-jackpot-reward { color: #fff2a8; }
|
||||
.eg-four-teaser { position: relative; margin-top: 13rpx; }
|
||||
.eg-four-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
|
||||
.eg-four-can { display: flex; width: 76rpx; height: 92rpx; align-items: center; justify-content: center; border: 4rpx solid #ffe078; border-radius: 18rpx; background: linear-gradient(180deg, #fffef7, #ffe9a5); box-shadow: inset 0 -5rpx 0 rgba(171,106,17,.12), 0 7rpx 13rpx rgba(34,12,67,.28); animation: egFourCan .48s cubic-bezier(.18,.9,.25,1.2) both; }
|
||||
.eg-four-can:nth-child(2) { animation-delay: .07s; }
|
||||
.eg-four-can:nth-child(3) { animation-delay: .14s; }
|
||||
.eg-four-can:nth-child(4) { animation-delay: .21s; }
|
||||
.eg-four-can > image { width: 64rpx; height: 70rpx; }
|
||||
.eg-four-can.is-locked { border-style: dashed; border-color: rgba(255,241,161,.86); color: #fff6ae; background: rgba(48,18,91,.54); box-shadow: inset 0 0 16rpx rgba(255,225,91,.18), 0 0 18rpx rgba(255,222,91,.36); font-size: 48rpx; font-weight: 1000; animation: egLockedCan .68s .34s ease-in-out infinite alternate; }
|
||||
.eg-four-promise { display: inline-flex; margin-top: 15rpx; padding: 8rpx 18rpx; border: 2rpx solid rgba(255,247,184,.72); border-radius: 999rpx; color: #3d176f; background: linear-gradient(90deg, #fff3a3, #ffd85b); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); font-size: 25rpx; font-weight: 1000; animation: egPromisePulse .72s ease-in-out infinite alternate; }
|
||||
.eg-five-celebration { position: relative; margin: 15rpx 0 10rpx; }
|
||||
.eg-five-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
|
||||
.eg-five-can { display: flex; width: 78rpx; height: 93rpx; align-items: center; justify-content: center; border: 4rpx solid #fff17b; border-radius: 18rpx; background: linear-gradient(180deg, #fff, #fff2ac); box-shadow: inset 0 -5rpx 0 rgba(184,91,12,.13), 0 7rpx 0 #bd5b13, 0 0 16rpx rgba(255,240,101,.5); animation: egFiveCan .56s cubic-bezier(.16,.92,.24,1.2) both; }
|
||||
.eg-five-can:nth-child(2) { animation-delay: .07s; }
|
||||
.eg-five-can:nth-child(3) { animation-delay: .14s; }
|
||||
.eg-five-can:nth-child(4) { animation-delay: .21s; }
|
||||
.eg-five-can:nth-child(5) { animation-delay: .28s; }
|
||||
.eg-five-can > image { width: 65rpx; height: 72rpx; }
|
||||
.eg-five-sevens { display: flex; justify-content: center; gap: 8rpx; margin-top: 13rpx; }
|
||||
.eg-five-sevens > text { display: flex; width: 58rpx; height: 55rpx; align-items: center; justify-content: center; border: 3rpx solid #fff5a7; border-radius: 13rpx; color: #fff36a; background: linear-gradient(155deg, #d7193f, #9f1239); box-shadow: 0 5rpx 0 #70102b, 0 0 13rpx rgba(255,243,106,.45); font-size: 43rpx; font-weight: 1000; line-height: 1; text-shadow: 0 3rpx 0 #8f1733; }
|
||||
.eg-jackpot-lights { position: absolute; inset: 8rpx; border: 4rpx dotted rgba(255,255,255,.88); border-radius: 25rpx; animation: egLights .24s steps(2) infinite; }
|
||||
.eg-jackpot-kicker { position: relative; display: block; color: #fff36a; font-size: 28rpx; font-weight: 900; letter-spacing: 6rpx; text-shadow: 0 3rpx 0 rgba(83,19,25,.45); }
|
||||
.eg-jackpot-reels { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 13rpx 0; }
|
||||
.eg-jackpot-reel { display: flex; width: 112rpx; height: 126rpx; flex-direction: column; align-items: center; justify-content: center; border: 6rpx solid #ffcf31; border-radius: 20rpx; background: linear-gradient(180deg, #fff 0%, #fff7cf 48%, #ffd55e 50%, #fff 53%, #fff8dc 100%); box-shadow: inset 0 0 18rpx rgba(137,78,10,.25), 0 7rpx 0 #a85a0a; animation: egReelStop .52s cubic-bezier(.2,.9,.25,1) both; }
|
||||
.eg-jackpot-reel:nth-child(2) { animation-delay: .09s; }
|
||||
.eg-jackpot-reel:nth-child(3) { animation-delay: .18s; }
|
||||
.eg-jackpot-seven { color: #e11d48; font-size: 61rpx; font-weight: 1000; line-height: .85; text-shadow: 0 3rpx 0 #ffd1d8; }
|
||||
.eg-jackpot-milk { width: 54rpx; height: 36rpx; margin-top: 3rpx; border-radius: 7rpx; }
|
||||
.eg-jackpot-title { position: relative; display: block; font-size: 37rpx; font-weight: 900; text-shadow: 0 4rpx 0 rgba(73,10,30,.5); }
|
||||
.eg-jackpot-points { position: relative; display: block; margin-top: 2rpx; color: #fff36a; font-size: 66rpx; font-weight: 1000; line-height: 1.05; letter-spacing: 2rpx; text-shadow: 0 5rpx 0 #9e2813, 0 0 18rpx rgba(255,243,106,.85); animation: egPointsPop .62s .42s cubic-bezier(.16,.9,.25,1.25) both; }
|
||||
.eg-jackpot-reward { position: relative; display: block; margin-top: 7rpx; color: #fff5a5; font-size: 27rpx; font-weight: 800; line-height: 1.35; }
|
||||
.eg-jackpot-note { position: relative; display: block; margin-top: 8rpx; color: rgba(255,255,255,.8); font-size: 22rpx; }
|
||||
.eg-tools { margin-top: 16rpx; }
|
||||
.eg-tool { display: flex; align-items: center; padding: 16rpx 18rpx; border: 3rpx solid #c9e0d6; border-radius: 26rpx; background: rgba(255,255,255,.92); box-shadow: 0 7rpx 18rpx rgba(24, 84, 63, .07); }
|
||||
.eg-tool.active { border-color: #f59e0b; background: #fffbeb; }
|
||||
.eg-tool.disabled { opacity: .45; }
|
||||
.eg-tool-icon { position: relative; display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; overflow: visible; border-radius: 24rpx; font-size: 46rpx; background: linear-gradient(145deg, #e8f7ff, #d4ecff); box-shadow: inset 0 -4rpx 0 rgba(33,104,151,.08); }
|
||||
.eg-tool-food-image { width: 76rpx; height: 58rpx; border-radius: 14rpx; }
|
||||
.eg-replace-badge { position: absolute; right: -12rpx; bottom: -8rpx; display: flex; min-width: 78rpx; height: 38rpx; align-items: center; justify-content: center; gap: 2rpx; padding: 0 8rpx; border: 3rpx solid #fff; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #2389b9, #145d91); box-shadow: 0 5rpx 12rpx rgba(20,93,145,.3); }
|
||||
.eg-replace-badge .tj-icon-wrap { width: 22rpx; height: 22rpx; }
|
||||
.eg-replace-badge > text { font-size: 20rpx; font-weight: 900; line-height: 1; }
|
||||
.eg-tool-copy { display: flex; flex: 1; flex-direction: column; margin-left: 16rpx; }
|
||||
.eg-tool-name { color: #244a3d; font-size: 29rpx; font-weight: 800; }
|
||||
.eg-tool-desc { margin-top: 5rpx; color: #6c8179; font-size: 25rpx; }
|
||||
.eg-tool-count { color: #155e4b; font-size: 32rpx; font-weight: 900; }
|
||||
.eg-legend { display: flex; justify-content: center; gap: 18rpx; margin-top: 18rpx; color: #53685f; font-size: 25rpx; font-weight: 700; }
|
||||
.eg-dot--safe { color: #4cae7b; }
|
||||
.eg-dot--mid { color: #e2aa19; }
|
||||
.eg-dot--high { color: #e4572e; }
|
||||
|
||||
.eg-weekly-overlay { position: fixed; z-index: 90; inset: 0; display: flex; align-items: center; justify-content: center; padding: 28rpx; background: rgba(16, 55, 43, .66); backdrop-filter: blur(5px); }
|
||||
.eg-weekly-card { width: 100%; max-width: 664rpx; max-height: calc(100vh - 56rpx); overflow-y: auto; padding: 28rpx 26rpx 22rpx; border: 5rpx solid rgba(255,255,255,.96); border-radius: 38rpx; background: linear-gradient(160deg, #f5fff9 0%, #fffdf3 56%, #fff4db 100%); box-shadow: 0 30rpx 80rpx rgba(5,45,32,.38); animation: egWeeklyIn .36s cubic-bezier(.18,.88,.27,1.08) both; }
|
||||
.eg-weekly-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
|
||||
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
|
||||
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
|
||||
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
|
||||
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
|
||||
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
|
||||
.eg-weekly-progress-label { display: block; color: #657b73; font-size: 22rpx; font-weight: 700; }
|
||||
.eg-weekly-progress-value { display: block; margin-top: -2rpx; color: #e06b24; font-size: 43rpx; font-weight: 1000; }
|
||||
.eg-weekly-progress-copy { display: flex; flex-direction: column; align-items: flex-end; color: #235b49; font-size: 24rpx; font-weight: 900; line-height: 1.55; }
|
||||
.eg-weekly-progress-copy text:last-child { color: #b86820; }
|
||||
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
|
||||
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
|
||||
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
|
||||
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
|
||||
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
|
||||
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
|
||||
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
|
||||
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
|
||||
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
|
||||
.eg-weekly-avatar.is-tone-5 { background: #a673bd; }
|
||||
.eg-weekly-avatar.is-tone-6 { background: #448e9c; }
|
||||
.eg-weekly-avatar.is-tone-7 { background: #c27b49; }
|
||||
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
|
||||
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
|
||||
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
|
||||
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
|
||||
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
|
||||
.eg-cheer-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.eg-cheer-source { color: #8e5c18; font-size: 21rpx; font-weight: 900; }
|
||||
.eg-cheer-card.is-family .eg-cheer-source { color: #77539c; }
|
||||
.eg-cheer-change { padding: 5rpx 12rpx; border-radius: 999rpx; color: #176b52; background: rgba(255,255,255,.75); font-size: 19rpx; font-weight: 900; }
|
||||
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
|
||||
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
|
||||
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
|
||||
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
|
||||
|
||||
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
|
||||
.eg-stage-card { width: 100%; max-width: 620rpx; padding: 34rpx 28rpx 30rpx; border: 6rpx solid #fff; border-radius: 38rpx; background: linear-gradient(155deg, #f5fff9, #fff8da); box-shadow: 0 26rpx 70rpx rgba(10,56,41,.3); text-align: center; animation: egStageIn 1.8s cubic-bezier(.16,.86,.28,1) both; }
|
||||
.eg-stage-kicker { display: block; color: #16815f; font-size: 23rpx; font-weight: 900; letter-spacing: 3rpx; }
|
||||
.eg-stage-number { display: block; margin-top: 5rpx; color: #e47722; font-size: 48rpx; font-weight: 1000; line-height: 1.1; }
|
||||
.eg-stage-title { display: block; margin-top: 7rpx; color: #194e3d; font-size: 38rpx; font-weight: 900; }
|
||||
.eg-stage-foods { display: flex; justify-content: center; gap: 9rpx; margin-top: 22rpx; }
|
||||
.eg-stage-food { display: flex; width: 92rpx; min-height: 110rpx; flex-direction: column; align-items: center; justify-content: center; border: 3rpx solid #dbece4; border-radius: 20rpx; color: #315b4e; background: #fff; font-size: 20rpx; font-weight: 800; }
|
||||
.eg-stage-food > image { width: 72rpx; height: 52rpx; border-radius: 12rpx; }
|
||||
.eg-stage-food > text { margin-top: 8rpx; }
|
||||
.eg-stage-food.is-danger { border-color: #ef8b52; color: #a43c20; background: #fff1e6; box-shadow: 0 0 0 4rpx rgba(239,139,82,.12); }
|
||||
.eg-stage-tip { display: block; margin-top: 20rpx; color: #6e776d; font-size: 21rpx; font-weight: 700; }
|
||||
.eg-reshuffle-overlay { position: fixed; z-index: 65; inset: 0; display: flex; align-items: center; justify-content: center; padding: 46rpx; background: rgba(19,55,45,.5); }
|
||||
.eg-reshuffle-card { width: 100%; max-width: 540rpx; padding: 38rpx 30rpx; border: 5rpx solid rgba(255,255,255,.95); border-radius: 36rpx; background: linear-gradient(155deg, #f4fff9, #e6f6ef); box-shadow: 0 24rpx 64rpx rgba(10,48,37,.3); text-align: center; }
|
||||
.eg-reshuffle-icon { display: flex; width: 94rpx; height: 94rpx; align-items: center; justify-content: center; margin: 0 auto 18rpx; border-radius: 50%; background: linear-gradient(135deg, #2aa87b, #126a50); box-shadow: 0 10rpx 24rpx rgba(18,106,80,.28); animation: egReshuffleSpin .85s linear infinite; }
|
||||
.eg-reshuffle-title { display: block; color: #174e3d; font-size: 36rpx; font-weight: 900; }
|
||||
.eg-reshuffle-desc { display: block; margin-top: 10rpx; color: #5b746b; font-size: 24rpx; font-weight: 700; }
|
||||
|
||||
.eg-overlay { position: fixed; z-index: 50; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; padding: 36rpx; background: rgba(20, 48, 40, .62); }
|
||||
.eg-modal { width: 100%; max-width: 650rpx; padding: 34rpx 28rpx 28rpx; border-radius: 38rpx; background: #fffdf8; box-shadow: 0 28rpx 70rpx rgba(0,0,0,.22); }
|
||||
.eg-modal-icon { display: block; font-size: 68rpx; text-align: center; }
|
||||
.eg-modal-title { display: block; margin-top: 6rpx; color: #204e3d; font-size: 38rpx; font-weight: 900; text-align: center; }
|
||||
.eg-modal-sub { display: block; margin: 8rpx 0 22rpx; color: #718078; font-size: 24rpx; text-align: center; }
|
||||
.eg-result-warning { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; margin: 0 auto; border-radius: 50%; color: #fff; background: #e4572e; font-size: 60rpx; font-weight: 900; }
|
||||
.eg-result-food { display: block; margin: 12rpx 0 18rpx; color: #c34b27; font-size: 28rpx; font-weight: 800; text-align: center; }
|
||||
.eg-knowledge-card { padding: 22rpx; border-radius: 24rpx; background: #fff3e7; }
|
||||
.eg-knowledge-title { display: block; color: #8d4427; font-size: 26rpx; font-weight: 800; }
|
||||
.eg-knowledge-copy { display: block; margin-top: 9rpx; color: #5f514a; font-size: 23rpx; line-height: 1.65; }
|
||||
.eg-knowledge-review { display: block; margin-top: 12rpx; color: #9b7768; font-size: 19rpx; }
|
||||
.eg-result-score { display: flex; align-items: center; justify-content: space-between; margin: 20rpx 6rpx; color: #466258; font-size: 25rpx; }
|
||||
.eg-result-score text:last-child { color: #e16f24; font-size: 36rpx; font-weight: 900; }
|
||||
.eg-result-best { display: flex; align-items: center; justify-content: space-between; margin: -12rpx 6rpx 20rpx; color: #75867f; font-size: 22rpx; }
|
||||
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
|
||||
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
|
||||
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
|
||||
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
|
||||
@keyframes egPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
|
||||
50% { box-shadow: 0 0 0 8rpx rgba(245,158,11,.12); }
|
||||
}
|
||||
|
||||
@keyframes egWeeklyIn {
|
||||
0% { opacity: 0; transform: translateY(34rpx) scale(.91); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egHit {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
42% { opacity: 1; transform: scale(1.18) rotate(-3deg); }
|
||||
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
|
||||
}
|
||||
|
||||
@keyframes egTaskRewardFlight {
|
||||
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
|
||||
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
|
||||
72% { opacity: 1; transform: translate3d(270rpx, 900rpx, 0) scale(.68) rotate(8deg); }
|
||||
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egRewardGlow {
|
||||
from { opacity: .4; transform: scale(.86); }
|
||||
to { opacity: .9; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
@keyframes egFireSweep {
|
||||
0% { opacity: 0; transform: translate3d(-38%, 20rpx, 0) scaleX(.6); }
|
||||
28% { opacity: 1; }
|
||||
72% { opacity: .96; }
|
||||
100% { opacity: 0; transform: translate3d(58%, -15rpx, 0) scaleX(1.08); }
|
||||
}
|
||||
|
||||
@keyframes egFireSpark {
|
||||
0% { opacity: 0; transform: translateY(14rpx) scale(.5) rotate(35deg); }
|
||||
38% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(-38rpx) scale(1.05) rotate(58deg); }
|
||||
}
|
||||
|
||||
@keyframes egFireGlow {
|
||||
0% { opacity: 0; transform: scale(.72); }
|
||||
38% { opacity: 1; transform: scale(1.08); }
|
||||
100% { opacity: 0; transform: scale(1.28); }
|
||||
}
|
||||
|
||||
@keyframes egFireCellHit {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
24% { opacity: 1; transform: scale(1.09); }
|
||||
72% { opacity: 1; transform: scale(1.02); }
|
||||
100% { opacity: 0; transform: scale(.48); }
|
||||
}
|
||||
|
||||
@keyframes egFireFlame {
|
||||
0% { opacity: 0; transform: translateY(20rpx) rotate(42deg) scale(.55); }
|
||||
38% { opacity: 1; transform: translateY(0) rotate(42deg) scale(1.05); }
|
||||
100% { opacity: 0; transform: translateY(-31rpx) rotate(48deg) scale(.86); }
|
||||
}
|
||||
|
||||
@keyframes egFlameReady {
|
||||
from { transform: scale(.94); filter: brightness(.96); }
|
||||
to { transform: scale(1.08); filter: brightness(1.18); }
|
||||
}
|
||||
|
||||
@keyframes egLReady {
|
||||
from { transform: scale(.94) rotate(-2deg); filter: brightness(.96); }
|
||||
to { transform: scale(1.06) rotate(2deg); filter: brightness(1.12); }
|
||||
}
|
||||
|
||||
@keyframes egImpact {
|
||||
0% { opacity: 0; transform: translate(-50%, -42%) scale(.55); }
|
||||
35% { opacity: 1; transform: translate(-50%, -50%) scale(1.16); }
|
||||
72% { opacity: 1; transform: translate(-50%, -54%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -76%) scale(.92); }
|
||||
}
|
||||
|
||||
@keyframes egJackpotIn {
|
||||
0% { opacity: 0; transform: translate(-50%, -45%) scale(.48) rotate(-4deg); }
|
||||
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.09) rotate(2deg); }
|
||||
31% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
|
||||
78% { opacity: 1; transform: translate(-50%, -53%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -68%) scale(.92); }
|
||||
}
|
||||
|
||||
@keyframes egTripleIn {
|
||||
0% { opacity: 0; transform: translate(-50%, -44%) scale(.82); }
|
||||
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
|
||||
74% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -60%) scale(.96); }
|
||||
}
|
||||
|
||||
@keyframes egTripleCan {
|
||||
0% { opacity: 0; transform: translateY(18rpx) scale(.72); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egFourIn {
|
||||
0% { opacity: 0; transform: translate(-50%, -44%) scale(.62) rotate(-3deg); }
|
||||
18% { opacity: 1; transform: translate(-50%, -50%) scale(1.08) rotate(1deg); }
|
||||
29% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
|
||||
78% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -65%) scale(.94); }
|
||||
}
|
||||
|
||||
@keyframes egFourCan {
|
||||
0% { opacity: 0; transform: translateY(-22rpx) rotate(-7deg) scale(.65); }
|
||||
75% { opacity: 1; transform: translateY(5rpx) rotate(2deg) scale(1.05); }
|
||||
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egFiveCan {
|
||||
0% { opacity: 0; transform: translateY(-40rpx) scale(.52) rotate(-8deg); filter: brightness(1.5); }
|
||||
70% { opacity: 1; transform: translateY(7rpx) scale(1.08) rotate(2deg); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0); filter: brightness(1); }
|
||||
}
|
||||
|
||||
@keyframes egLockedCan {
|
||||
from { opacity: .62; transform: scale(.92); filter: brightness(.9); }
|
||||
to { opacity: 1; transform: scale(1.06); filter: brightness(1.2); }
|
||||
}
|
||||
|
||||
@keyframes egPromisePulse {
|
||||
from { transform: scale(.97); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); }
|
||||
to { transform: scale(1.03); box-shadow: 0 7rpx 21rpx rgba(255,221,89,.38); }
|
||||
}
|
||||
|
||||
@keyframes egReelStop {
|
||||
0% { opacity: .2; transform: translateY(-48rpx) scaleY(1.4); filter: blur(5rpx); }
|
||||
70% { opacity: 1; transform: translateY(7rpx) scaleY(.94); filter: blur(0); }
|
||||
100% { transform: translateY(0) scaleY(1); }
|
||||
}
|
||||
|
||||
@keyframes egLights {
|
||||
0% { opacity: .35; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes egConfettiFall {
|
||||
0% { opacity: 0; transform: translate3d(0, -8vh, 0) rotate(0deg) scale(.7); }
|
||||
9% { opacity: 1; }
|
||||
48% { transform: translate3d(42rpx, 48vh, 0) rotate(420deg) scale(1); }
|
||||
100% { opacity: .92; transform: translate3d(-28rpx, 112vh, 0) rotate(920deg) scale(.86); }
|
||||
}
|
||||
|
||||
@keyframes egPointsPop {
|
||||
0% { opacity: 0; transform: scale(.35) rotate(-7deg); }
|
||||
55% { opacity: 1; transform: scale(1.28) rotate(3deg); }
|
||||
78% { transform: scale(.92) rotate(0); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egStageIn {
|
||||
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
|
||||
18% { opacity: 1; transform: scale(1.06) translateY(0); }
|
||||
30%, 76% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(.96) translateY(-22rpx); }
|
||||
}
|
||||
|
||||
@keyframes egReshuffleSpin {
|
||||
from { transform: rotate(0); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media screen and (max-height: 760px) {
|
||||
.eg-task-card { padding-top: 13rpx; padding-bottom: 13rpx; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
|
||||
*/
|
||||
|
||||
function utf8ToBytes(str) {
|
||||
const encoded = encodeURIComponent(str)
|
||||
const bytes = []
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
if (encoded.charCodeAt(i) === 37) {
|
||||
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
|
||||
i += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||
return uni.arrayBufferToBase64(bytes.buffer)
|
||||
}
|
||||
if (typeof btoa !== 'undefined') {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
let result = ''
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i]
|
||||
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||
result += chars[a >> 2]
|
||||
result += chars[((a & 3) << 4) | (b >> 4)]
|
||||
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
|
||||
result += i + 2 < bytes.length ? chars[c & 63] : '='
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function svgToDataUrl(svg) {
|
||||
const normalized = String(svg || '').trim()
|
||||
if (!normalized) return ''
|
||||
const base64 = bytesToBase64(utf8ToBytes(normalized))
|
||||
return `data:image/svg+xml;base64,${base64}`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const FOOD_ASSET_NAMES = new Set([
|
||||
'apple.jpg', 'banana.jpg', 'blackCoffee.jpg', 'broccoli.jpg', 'brownRice.jpg',
|
||||
'camel.jpg', 'celery.jpg', 'centuryCongee.jpg', 'chicken.jpg', 'coffee.jpg',
|
||||
'congee.jpg', 'corn.jpg', 'cucumber.jpg', 'egg.jpg', 'eightCongee.jpg',
|
||||
'grainMantou.jpg', 'greenBean.jpg', 'lettuce.jpg', 'mango.jpg', 'milk.jpg',
|
||||
'milkTea.jpg', 'milletCongee.jpg', 'mushroom.jpg', 'noodleSoup.jpg', 'onion.jpg',
|
||||
'orangeJuice.jpg', 'pepper.jpg', 'pineapple.jpg', 'potato.jpg', 'riceNoodleSoup.jpg',
|
||||
'shrimp.jpg', 'soda.jpg', 'strawberry.jpg', 'taro.jpg', 'tea.jpg', 'tomato.jpg',
|
||||
'udon.jpg', 'wonton.jpg'
|
||||
])
|
||||
|
||||
/**
|
||||
* 让游戏食品图进入 tongji 分包,避免增加微信小程序主包体积。
|
||||
* 迁移到其他 uni-app 项目时,在 vite.config 中复用此函数即可。
|
||||
*/
|
||||
export function endlessGameAssetFileNames(assetInfo = {}) {
|
||||
const candidates = [
|
||||
assetInfo.name,
|
||||
...(assetInfo.names || []),
|
||||
assetInfo.originalFileName,
|
||||
...(assetInfo.originalFileNames || [])
|
||||
].filter(Boolean)
|
||||
|
||||
const isEndlessFood = candidates.some((value) => {
|
||||
const normalized = String(value).replace(/\\/g, '/')
|
||||
const basename = normalized.split('/').pop()
|
||||
return normalized.includes('tongji/endless-game/assets/food/') || FOOD_ASSET_NAMES.has(basename)
|
||||
})
|
||||
|
||||
return isEndlessFood
|
||||
? 'tongji/endless-game/assets/food/[name].[hash][extname]'
|
||||
: 'assets/[name].[hash][extname]'
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
@@ -1494,7 +1514,7 @@ async function goMorePage() {
|
||||
|
||||
function goGamePage() {
|
||||
uni.navigateTo({
|
||||
url: '/tongji/pages/game',
|
||||
url: '/tongji/endless-game/index',
|
||||
fail() {
|
||||
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
|
||||
}
|
||||
|
||||
@@ -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,10 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import { endlessGameAssetFileNames } from './tongji/endless-game/viteAssetOutput.js'
|
||||
|
||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: endlessGameAssetFileNames,
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
|
||||
|
After Width: | Height: | Size: 64 KiB |
@@ -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,42 @@ 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 })
|
||||
}
|
||||
|
||||
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
|
||||
export function autoAssignLogLists(params: Record<string, any>) {
|
||||
return request.get({ url: '/stats.autoAssignLog/lists', params })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
|
||||
@@ -61,6 +61,14 @@ export function tcmDiagnosisDetail(params: any) {
|
||||
return request.get({ url: '/tcm.diagnosis/detail', params })
|
||||
}
|
||||
|
||||
/** 设置复诊接诊率统计起始偏移(统计诊次=实单序号+偏移;1=二诊起,2=三诊起) */
|
||||
export function tcmDiagnosisSetRevisitSlotStartOffset(params: {
|
||||
id: number
|
||||
revisit_slot_start_offset: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.diagnosis/setRevisitSlotStartOffset', params })
|
||||
}
|
||||
|
||||
/** 诊单挂号 / 取消挂号 操作日志 */
|
||||
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
||||
@@ -403,6 +411,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 })
|
||||
}
|
||||
@@ -420,6 +433,17 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderPatchPrescriptionUsage(params: {
|
||||
id: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
medication_days: number
|
||||
aux_times_per_day?: number
|
||||
aux_usage_days?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderAuditPrescription(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
@@ -441,6 +465,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 +492,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 })
|
||||
}
|
||||
@@ -511,6 +542,18 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
||||
}
|
||||
|
||||
/** 手工新增操作日志(可选调整处方/支付单审核状态) */
|
||||
export function prescriptionOrderAddLog(params: {
|
||||
id: number
|
||||
summary: string
|
||||
prescription_audit_status?: number | ''
|
||||
payment_slip_audit_status?: number | ''
|
||||
prescription_audit_remark?: string
|
||||
payment_slip_audit_remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
||||
}
|
||||
|
||||
/** 修改订单金额 */
|
||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,12 @@
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||
@@ -42,7 +46,8 @@
|
||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<el-table :data="tableData" v-loading="loading" @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">
|
||||
@@ -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) => {
|
||||
|
||||
@@ -27,6 +27,20 @@
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column label="备注" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<span class="truncate text-gray-600" :title="row.remark || ''">
|
||||
{{ row.remark || '—' }}
|
||||
</span>
|
||||
<el-tooltip content="编辑备注" placement="top">
|
||||
<el-icon class="shrink-0 cursor-pointer text-gray-400 hover:text-primary" @click="openQuickRemark(row)">
|
||||
<Edit />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -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' })
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* 处方业务订单——纯展示/格式化工具
|
||||
*
|
||||
* 唯一数据源: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: '处方患者信息',
|
||||
patch_rx_usage: '服用参数',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
refund: '退款',
|
||||
manual_log: '手工备注',
|
||||
assign_assistant: '改派医助',
|
||||
add_pay_order: '补齐支付单',
|
||||
set_ship_mode: '发货类型'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
|
||||
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
|
||||
export function auditStatusText(s: number | undefined) {
|
||||
if (s === 1) return '已通过'
|
||||
if (s === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
|
||||
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||
const createType = String(row?.create_type || '')
|
||||
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 ''
|
||||
}
|
||||
|
||||
/** 服务套餐 dict:server_order */
|
||||
export type ServicePackageOption = { name: string; value: string; status?: number }
|
||||
|
||||
export function normalizeServicePackageValue(v: unknown): string {
|
||||
return String(v ?? '').trim()
|
||||
}
|
||||
|
||||
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
|
||||
export function parseServicePackageValues(raw: unknown): string[] {
|
||||
if (raw == null || raw === '') return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(normalizeServicePackageValue).filter(Boolean)
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return raw.split(',').map((v) => v.trim()).filter(Boolean)
|
||||
}
|
||||
const one = normalizeServicePackageValue(raw)
|
||||
return one ? [one] : []
|
||||
}
|
||||
|
||||
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
|
||||
const sa = normalizeServicePackageValue(a)
|
||||
const sb = normalizeServicePackageValue(b)
|
||||
if (!sa || !sb) return false
|
||||
if (sa === sb) return true
|
||||
const na = Number(sa)
|
||||
const nb = Number(sb)
|
||||
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
|
||||
}
|
||||
|
||||
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((item: any) => ({
|
||||
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
|
||||
value: normalizeServicePackageValue(item?.value),
|
||||
status: Number(item?.status ?? 1)
|
||||
}))
|
||||
.filter((item) => item.value !== '')
|
||||
}
|
||||
|
||||
export function findServicePackageOption(
|
||||
options: ServicePackageOption[],
|
||||
value: unknown
|
||||
): ServicePackageOption | undefined {
|
||||
const key = normalizeServicePackageValue(value)
|
||||
if (!key) return undefined
|
||||
return options.find((opt) => servicePackageValueEquals(opt.value, key))
|
||||
}
|
||||
|
||||
/** 展示用:value → 字典 name,多选用「、」连接 */
|
||||
export function formatServicePackageLabels(
|
||||
value: unknown,
|
||||
options: ServicePackageOption[],
|
||||
emptyText = '—'
|
||||
): string {
|
||||
const packages = parseServicePackageValues(value)
|
||||
if (packages.length === 0) return emptyText
|
||||
const names = packages.map((val) => {
|
||||
const option = findServicePackageOption(options, val)
|
||||
return option?.name || val
|
||||
})
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
|
||||
export function mergeServicePackageSelectOptions(
|
||||
options: ServicePackageOption[],
|
||||
selected: unknown[]
|
||||
): ServicePackageOption[] {
|
||||
const known = new Set(options.map((o) => o.value))
|
||||
const extras: ServicePackageOption[] = []
|
||||
for (const raw of selected) {
|
||||
const val = normalizeServicePackageValue(raw)
|
||||
if (!val || known.has(val)) continue
|
||||
const matched = findServicePackageOption(options, val)
|
||||
extras.push(matched ?? { name: val, value: val, status: 0 })
|
||||
known.add(val)
|
||||
}
|
||||
return extras.length ? [...options, ...extras] : options
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '接待' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -174,9 +174,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 +220,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 +237,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 +255,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
|
||||
@@ -327,7 +327,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 +434,7 @@
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-3"
|
||||
title="该单为付呗·待审核,请选择实际到账方式后确认「已支付」"
|
||||
title="该单为待审核(付呗/快递代收),请选择实际到账方式后确认「已支付」"
|
||||
/>
|
||||
<el-form-item v-if="!payForm.fubeiPendingAudit" label="支付类型" required>
|
||||
<el-radio-group v-model="payForm.payType">
|
||||
@@ -502,6 +502,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 +525,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 +994,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 +1162,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 +1175,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 +1267,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 +1414,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,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="auto-assign-log-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="log-filter-form">
|
||||
<el-form-item label="执行日期">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
@change="doSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果">
|
||||
<el-select v-model="queryParams.action" clearable placeholder="全部" class="w-[120px]" @change="doSearch">
|
||||
<el-option label="已分配" value="1" />
|
||||
<el-option label="未分配" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="患者/手机号/医助/诊单ID"
|
||||
clearable
|
||||
class="w-[220px]"
|
||||
@keyup.enter="doSearch"
|
||||
@clear="doSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="log-hint">
|
||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70% 每人2条/日,50%~60% 每人1条/日,<50% 不分)。每条待指派诊单一行,含分配 / 不分配原因。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-3" shadow="never">
|
||||
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||
<el-table-column label="记录时间" width="160">
|
||||
<template #default="{ row }">{{ row.create_time_text || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="诊单" width="90" prop="diagnosis_id" />
|
||||
<el-table-column label="患者" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.patient_name || '—' }}</div>
|
||||
<div class="cell-sub">{{ row.patient_phone || '' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="Number(row.action) === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.action_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="医助" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<template v-if="Number(row.assistant_id) > 0">
|
||||
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
||||
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="档位" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="轮次" width="70" align="center">
|
||||
<template #default="{ row }">{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" min-width="360" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.reason }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="统计月" width="90" prop="stat_month" align="center" />
|
||||
<el-table-column label="批次" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-sub">{{ row.batch_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="statsAutoAssignLog">
|
||||
import { autoAssignLogLists } from '@/api/stats'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
action: '' as '' | '0' | '1',
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
watch(dateRange, (val) => {
|
||||
queryParams.start_date = val?.[0] ?? ''
|
||||
queryParams.end_date = val?.[1] ?? ''
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: autoAssignLogLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
const doSearch = () => resetPage()
|
||||
|
||||
const handleReset = () => {
|
||||
dateRange.value = null
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
queryParams.action = ''
|
||||
queryParams.keyword = ''
|
||||
resetPage()
|
||||
}
|
||||
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
|
||||
onMounted(() => getLists())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auto-assign-log-page {
|
||||
.log-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.log-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cell-sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
||||
</p>
|
||||
<p>
|
||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||
</p>
|
||||
<p>
|
||||
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||
</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;
|
||||
|
||||
@@ -83,6 +83,23 @@
|
||||
<el-radio-button value="0">未确认</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">部门</span>
|
||||
<el-tree-select
|
||||
v-model="formData.assistant_dept_id"
|
||||
:data="departmentTreeRaw"
|
||||
class="filter-dept-select"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
:default-expand-all="true"
|
||||
node-key="id"
|
||||
size="small"
|
||||
:props="assistantDeptTreeProps"
|
||||
placeholder="选父级含子级"
|
||||
@change="handleAssistantDeptChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
@@ -542,6 +559,7 @@
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||
import { deptAll } from '@/api/org/department'
|
||||
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { addDoctorNote } from '@/api/patient'
|
||||
@@ -603,10 +621,19 @@ const formData = reactive({
|
||||
end_date: '',
|
||||
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
|
||||
diagnosis_confirmed: '' as '' | '0' | '1', // ''=全部 1=已确认 0=未确认
|
||||
/** 接诊医生 / 诊单医助 / 挂号医助所属部门(选父级含子级) */
|
||||
assistant_dept_id: '' as number | '',
|
||||
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
|
||||
include_status_counts: 0 as 0 | 1
|
||||
})
|
||||
|
||||
const departmentTreeRaw = ref<unknown[]>([])
|
||||
const assistantDeptTreeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
}
|
||||
|
||||
const activeTab = ref('1')
|
||||
const dateCustomVisible = ref(false)
|
||||
const statusCount = ref<Record<number, number>>({
|
||||
@@ -721,6 +748,12 @@ const handleDiagnosisConfirmedChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 部门筛选变更
|
||||
const handleAssistantDeptChange = () => {
|
||||
pager.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 快捷日期变更
|
||||
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
|
||||
const v = String(val || '')
|
||||
@@ -770,6 +803,7 @@ const handleReset = () => {
|
||||
formData.doctor_name = ''
|
||||
formData.date_preset = 'today'
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.assistant_dept_id = ''
|
||||
const t = new Date()
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
|
||||
@@ -1099,7 +1133,13 @@ formData.start_date = `${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${
|
||||
formData.end_date = formData.start_date
|
||||
formData.status = 1
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const deptTree = await deptAll()
|
||||
departmentTreeRaw.value = Array.isArray(deptTree) ? deptTree : []
|
||||
} catch {
|
||||
departmentTreeRaw.value = []
|
||||
}
|
||||
loadData()
|
||||
listPollTimer = setInterval(() => {
|
||||
loadData({ silent: true })
|
||||
@@ -1228,6 +1268,10 @@ onUnmounted(() => {
|
||||
padding: 6px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dept-select {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,43 @@
|
||||
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="diagnosisId > 0" class="po-revisit-offset-bar mb-3">
|
||||
<div class="po-revisit-offset-bar__main">
|
||||
<span class="text-sm text-gray-600">复诊统计起始偏移</span>
|
||||
<el-tooltip placement="top">
|
||||
<template #content>
|
||||
<div class="max-w-xs leading-relaxed">
|
||||
在实单诊次序号上叠加偏移量。设为 0(默认):第 1 笔实单计为一诊;设为 1:第 1 笔实单计为二诊;设为 2:第 1 笔实单计为三诊——若有 5 笔实单且偏移 2,则统计上相当于计至七诊(5+2)。
|
||||
</div>
|
||||
</template>
|
||||
<el-icon class="text-gray-400 align-middle ml-1"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-input-number
|
||||
v-model="revisitSlotStartOffset"
|
||||
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
|
||||
:min="0"
|
||||
:max="20"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
class="w-[120px] ml-3"
|
||||
:disabled="offsetSaving"
|
||||
/>
|
||||
<span class="text-xs text-gray-500 ml-2">
|
||||
第 1 笔实单计为{{ visitSlotStartLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<el-button
|
||||
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="offsetSaving"
|
||||
:disabled="!offsetDirty"
|
||||
@click="saveRevisitSlotStartOffset"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
@@ -12,6 +49,23 @@
|
||||
empty-text="暂无业务订单"
|
||||
>
|
||||
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="全局诊次" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.global_visit_seq">{{ row.global_visit_seq }}诊</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计入统计" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.counts_for_revisit_rate"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>是</el-tag>
|
||||
<el-tag v-else type="info" size="small" effect="plain">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
|
||||
@@ -56,298 +110,23 @@
|
||||
</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 { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import {
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderPaidPayOrders
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { prescriptionOrderLists, tcmDiagnosisDetail, tcmDiagnosisSetRevisitSlotStartOffset } from '@/api/tcm'
|
||||
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
import {
|
||||
formatTime,
|
||||
fulfillmentText,
|
||||
fulfillmentTagType
|
||||
} from '@/views/consumer/prescription/components/prescription-order-utils'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
@@ -368,6 +147,26 @@ const { pager, getLists, resetPage } = usePaging({
|
||||
size: 10
|
||||
})
|
||||
|
||||
const revisitSlotStartOffset = ref(0)
|
||||
const savedRevisitSlotStartOffset = ref(0)
|
||||
const offsetSaving = ref(false)
|
||||
const offsetLoading = ref(false)
|
||||
|
||||
const offsetDirty = computed(
|
||||
() => Number(revisitSlotStartOffset.value) !== Number(savedRevisitSlotStartOffset.value)
|
||||
)
|
||||
|
||||
const visitSlotStartLabel = computed(() => {
|
||||
const raw = Number(revisitSlotStartOffset.value)
|
||||
const offset = Number.isFinite(raw) ? raw : 0
|
||||
const slot = offset + 1
|
||||
const cn = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
if (slot >= 1 && slot <= 10) {
|
||||
return cn[slot] + '诊'
|
||||
}
|
||||
return `第${slot}诊`
|
||||
})
|
||||
|
||||
const buildParams = () => {
|
||||
Object.keys(queryParams).forEach((k) => delete queryParams[k])
|
||||
if (props.diagnosisId > 0) {
|
||||
@@ -379,175 +178,52 @@ 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 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
|
||||
async function loadRevisitSlotStartOffset() {
|
||||
if (props.diagnosisId <= 0) return
|
||||
offsetLoading.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>
|
||||
const res: any = await tcmDiagnosisDetail({ id: props.diagnosisId })
|
||||
const d = res?.data ?? res ?? {}
|
||||
const offset = Number(d.revisit_slot_start_offset)
|
||||
const val = Number.isFinite(offset) && offset >= 0 && offset <= 20 ? offset : 0
|
||||
revisitSlotStartOffset.value = val
|
||||
savedRevisitSlotStartOffset.value = val
|
||||
} catch {
|
||||
logisticsTracePayload.value = null
|
||||
revisitSlotStartOffset.value = 0
|
||||
savedRevisitSlotStartOffset.value = 0
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
offsetLoading.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
|
||||
async function saveRevisitSlotStartOffset() {
|
||||
if (props.diagnosisId <= 0) return
|
||||
const offset = Number(revisitSlotStartOffset.value)
|
||||
if (!Number.isFinite(offset) || offset < 0 || offset > 20) {
|
||||
feedback.msgWarning('起始偏移须在 0~20 之间')
|
||||
return
|
||||
}
|
||||
offsetSaving.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
|
||||
await tcmDiagnosisSetRevisitSlotStartOffset({
|
||||
id: props.diagnosisId,
|
||||
revisit_slot_start_offset: offset
|
||||
})
|
||||
savedRevisitSlotStartOffset.value = offset
|
||||
feedback.msgSuccess('保存成功')
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
offsetSaving.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 = [] }
|
||||
}
|
||||
// ─── 详情抽屉(共享组件,数据拉取/展示全部在组件内) ───
|
||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||
|
||||
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,131 +231,16 @@ 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,
|
||||
() => {
|
||||
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return }
|
||||
if (!patientIdAvailable.value) {
|
||||
pager.lists = []
|
||||
pager.count = 0
|
||||
return
|
||||
}
|
||||
buildParams()
|
||||
void loadRevisitSlotStartOffset()
|
||||
resetPage()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -695,22 +256,21 @@ 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;
|
||||
.po-revisit-offset-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
.po-revisit-offset-bar__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.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,42 @@
|
||||
<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"
|
||||
/>
|
||||
<daterange-picker
|
||||
class="latest-assign-range"
|
||||
v-model:startTime="formData.latest_assign_start_date"
|
||||
v-model:endTime="formData.latest_assign_end_date"
|
||||
picker-type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="最近指派开始"
|
||||
end-placeholder="最近指派结束"
|
||||
@change="handleLatestAssignFilterChange"
|
||||
/>
|
||||
<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>
|
||||
@@ -146,6 +183,7 @@
|
||||
v-loading="pager.loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="goReadonly"
|
||||
@sort-change="handleTableSortChange"
|
||||
:row-class-name="getRowClassName"
|
||||
class="diagnosis-table"
|
||||
stripe
|
||||
@@ -213,6 +251,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>
|
||||
@@ -253,7 +294,14 @@
|
||||
<span v-else class="status-unprescribed">未开方</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="未服务天数" width="110" align="center">
|
||||
<el-table-column
|
||||
label="未服务天数"
|
||||
prop="unserved_days"
|
||||
width="110"
|
||||
align="center"
|
||||
sortable="custom"
|
||||
:sort-orders="['descending', 'ascending']"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.last_blood_record_at"
|
||||
@@ -689,6 +737,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 +777,13 @@ 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,
|
||||
latest_assign_start_date: '' as string,
|
||||
latest_assign_end_date: '' as string,
|
||||
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
||||
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||
appointment_date: '' as string,
|
||||
has_appointment: '' as '' | '0' | '1',
|
||||
@@ -760,6 +816,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()
|
||||
@@ -812,19 +871,50 @@ function resolvePendingAssignOrderMonthForRequest(): string {
|
||||
return dayjs().format('YYYY-MM')
|
||||
}
|
||||
|
||||
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
|
||||
function buildPendingAssignCountPayload(): Record<string, unknown> {
|
||||
return buildTcmDiagnosisListRequestPayload({
|
||||
...formData,
|
||||
/** 除顶部 Tab 专属条件外,与主列表共用的「更多筛选」参数(角标 count 需同步) */
|
||||
function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
|
||||
return {
|
||||
keyword: formData.keyword,
|
||||
diagnosis_type: formData.diagnosis_type,
|
||||
syndrome_type: formData.syndrome_type,
|
||||
assistant_id: formData.assistant_id,
|
||||
diagnosis_confirmed: formData.diagnosis_confirmed,
|
||||
has_appointment: formData.has_appointment,
|
||||
latest_appointment_start_date: formData.latest_appointment_start_date,
|
||||
latest_appointment_end_date: formData.latest_appointment_end_date,
|
||||
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
||||
latest_assign_start_date: formData.latest_assign_start_date,
|
||||
latest_assign_end_date: formData.latest_assign_end_date
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
|
||||
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
page_no: 1,
|
||||
page_size: 1,
|
||||
pending_assign: 1,
|
||||
...buildSharedDiagnosisFilterPayload(),
|
||||
appointment_date: '',
|
||||
has_appointment: '',
|
||||
pending_booking: '',
|
||||
completed_appointment: '',
|
||||
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
|
||||
} as Record<string, unknown>) as Record<string, unknown>
|
||||
pending_assign: '',
|
||||
pending_assign_order_month: '',
|
||||
pending_assign_keyword: '',
|
||||
sort_unserved_days: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
|
||||
function buildPendingAssignCountPayload(): Record<string, unknown> {
|
||||
return buildTcmDiagnosisListRequestPayload(
|
||||
buildDateCountRequestPayload({
|
||||
pending_assign: 1,
|
||||
has_appointment: '',
|
||||
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest(),
|
||||
pending_assign_keyword: formData.pending_assign_keyword
|
||||
}) as Record<string, unknown>
|
||||
) as Record<string, unknown>
|
||||
}
|
||||
|
||||
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
|
||||
@@ -850,6 +940,12 @@ 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.latest_assign_start_date = ''
|
||||
formData.latest_assign_end_date = ''
|
||||
formData.sort_unserved_days = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
activeTab.value = 'all'
|
||||
if (kw1 !== '') {
|
||||
@@ -886,6 +982,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 +998,7 @@ const handlePendingBookingTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = '0'
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
@@ -915,6 +1013,7 @@ const handleCompletedVisitTabClick = async () => {
|
||||
formData.completed_appointment = '1'
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
@@ -926,6 +1025,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')
|
||||
}
|
||||
@@ -947,14 +1047,26 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
||||
const fetchDateCounts = async () => {
|
||||
try {
|
||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
||||
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: dayBeforeStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: todayStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: tomorrowStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ appointment_date: dayAfterStr.value, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(buildDateCountRequestPayload() as any),
|
||||
tcmDiagnosisLists(buildDateCountRequestPayload({ has_appointment: 0 }) as any),
|
||||
tcmDiagnosisLists(
|
||||
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
||||
),
|
||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
||||
])
|
||||
dateCounts.value = {
|
||||
@@ -1047,16 +1159,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 +1194,69 @@ 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 clearLatestAssignFilters = () => {
|
||||
formData.latest_assign_start_date = ''
|
||||
formData.latest_assign_end_date = ''
|
||||
}
|
||||
|
||||
const hasLatestAppointmentFilter = () =>
|
||||
!!(
|
||||
formData.latest_appointment_start_date ||
|
||||
formData.latest_appointment_end_date ||
|
||||
formData.latest_appointment_channel_source
|
||||
)
|
||||
|
||||
const hasLatestAssignFilter = () =>
|
||||
!!(formData.latest_assign_start_date || formData.latest_assign_end_date)
|
||||
|
||||
const handleLatestAppointmentFilterChange = () => {
|
||||
if (hasLatestAppointmentFilter()) {
|
||||
formData.appointment_date = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
if (formData.has_appointment === '0') {
|
||||
formData.has_appointment = ''
|
||||
}
|
||||
}
|
||||
doSearch()
|
||||
}
|
||||
|
||||
const handleLatestAssignFilterChange = () => {
|
||||
doSearch()
|
||||
}
|
||||
|
||||
const handleTableSortChange = ({
|
||||
prop,
|
||||
order
|
||||
}: {
|
||||
prop: string
|
||||
order: 'ascending' | 'descending' | null
|
||||
}) => {
|
||||
if (prop === 'unserved_days') {
|
||||
formData.sort_unserved_days =
|
||||
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||
} else {
|
||||
formData.sort_unserved_days = ''
|
||||
}
|
||||
pager.page = 1
|
||||
getLists()
|
||||
}
|
||||
|
||||
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 +1291,12 @@ 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.latest_assign_start_date = ''
|
||||
formData.latest_assign_end_date = ''
|
||||
formData.sort_unserved_days = ''
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = ''
|
||||
@@ -2190,10 +2384,25 @@ 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-assign-range {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.latest-appointment-channel {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-card {
|
||||
@@ -2363,6 +2572,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 {
|
||||
|
||||
@@ -55,34 +55,59 @@ class AssetResourceController extends BaseAdminController
|
||||
public function add()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
if (empty($params['type']) || empty($params['title']) || empty($params['file_url'])) {
|
||||
$type = $params['type'] ?? 0;
|
||||
$title = trim((string)($params['title'] ?? ''));
|
||||
|
||||
// 兼容单图(字符串)与多图批量上传(数组)
|
||||
$fileUrl = $params['file_url'] ?? '';
|
||||
if (is_array($fileUrl)) {
|
||||
$fileUrls = array_values(array_filter($fileUrl, fn($v) => trim((string)$v) !== ''));
|
||||
} else {
|
||||
$fileUrls = trim((string)$fileUrl) !== '' ? [$fileUrl] : [];
|
||||
}
|
||||
|
||||
if (empty($type) || $title === '' || empty($fileUrls)) {
|
||||
return $this->fail('请填写完整的资源信息');
|
||||
}
|
||||
|
||||
|
||||
// 仅图片支持批量;视频/语音只取首个
|
||||
if ($type != 1) {
|
||||
$fileUrls = [$fileUrls[0]];
|
||||
}
|
||||
|
||||
$multi = count($fileUrls) > 1;
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$resource = AssetResource::create([
|
||||
'type' => $params['type'],
|
||||
'title' => $params['title'],
|
||||
'file_url' => $params['file_url'],
|
||||
'cover_url' => $params['cover_url'] ?? '',
|
||||
]);
|
||||
|
||||
// 绑定用户
|
||||
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
|
||||
$userResources = [];
|
||||
foreach ($params['user_ids'] as $userId) {
|
||||
$userResources[] = [
|
||||
'user_id' => $userId,
|
||||
'resource_id' => $resource->id,
|
||||
'create_time' => time(),
|
||||
];
|
||||
$createdIds = [];
|
||||
$index = 0;
|
||||
foreach ($fileUrls as $url) {
|
||||
$index++;
|
||||
$resource = AssetResource::create([
|
||||
'type' => $type,
|
||||
'title' => $multi ? $title . '_' . $index : $title,
|
||||
'file_url' => $url,
|
||||
'cover_url' => $params['cover_url'] ?? '',
|
||||
]);
|
||||
|
||||
// 绑定用户
|
||||
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
|
||||
$userResources = [];
|
||||
foreach ($params['user_ids'] as $userId) {
|
||||
$userResources[] = [
|
||||
'user_id' => $userId,
|
||||
'resource_id' => $resource->id,
|
||||
'create_time' => time(),
|
||||
];
|
||||
}
|
||||
(new AssetUserResource())->saveAll($userResources);
|
||||
}
|
||||
(new AssetUserResource())->saveAll($userResources);
|
||||
|
||||
$createdIds[] = $resource->id;
|
||||
}
|
||||
|
||||
|
||||
Db::commit();
|
||||
return $this->success('添加并分配成功', ['id' => $resource->id]);
|
||||
return $this->success('添加并分配成功', ['ids' => $createdIds]);
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return $this->fail('操作失败: ' . $e->getMessage());
|
||||
@@ -144,14 +169,19 @@ class AssetResourceController extends BaseAdminController
|
||||
public function delete()
|
||||
{
|
||||
$id = $this->request->post('id');
|
||||
if (empty($id)) {
|
||||
$ids = is_array($id) ? $id : [$id];
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $item): bool {
|
||||
return $item > 0;
|
||||
})));
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->fail('缺少参数');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
AssetResource::destroy($id);
|
||||
AssetUserResource::where('resource_id', $id)->delete();
|
||||
AssetResource::destroy($ids);
|
||||
AssetUserResource::whereIn('resource_id', $ids)->delete();
|
||||
Db::commit();
|
||||
return $this->success('删除成功');
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -57,6 +57,7 @@ class AssetUserController extends BaseAdminController
|
||||
'phone' => $params['phone'],
|
||||
'password' => $passwordHash,
|
||||
'status' => $params['status'] ?? 1,
|
||||
'remark' => trim((string)($params['remark'] ?? '')),
|
||||
]);
|
||||
|
||||
return $this->success('添加成功', ['id' => $user->id]);
|
||||
@@ -92,6 +93,10 @@ class AssetUserController extends BaseAdminController
|
||||
if (isset($params['status'])) {
|
||||
$user->status = $params['status'];
|
||||
}
|
||||
|
||||
if (array_key_exists('remark', $params)) {
|
||||
$user->remark = trim((string)$params['remark']);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
return $this->success('修改成功');
|
||||
|
||||