Compare commits

..
143 Commits
Author SHA1 Message Date
Your Name de990a921b 更新 2026-08-11 10:02:40 +08:00
Your Name cfe4c82c90 更新 2026-08-11 09:12:51 +08:00
Your Name c3ceb0dd0f 更新 2026-08-10 17:46:04 +08:00
Your Name 9add23e019 更新 2026-08-10 17:29:05 +08:00
Your Name 2199887c07 更新
:i# Please enter the commit message for your changes. Lines starting
2026-08-08 18:08:23 +08:00
Your Name 56dbd7f115 新增 2026-08-08 15:46:32 +08:00
Your Name d10f213573 更新 2026-08-08 15:42:45 +08:00
Your Name a968945057 更新 2026-08-07 15:25:09 +08:00
Your Name 971a627288 更新 2026-08-07 15:13:36 +08:00
Your Name c37d5abac4 更新 2026-08-07 15:08:55 +08:00
Your Name ebc463864b 更新 2026-08-07 15:07:17 +08:00
Your Name ab140ab05e Merge branch 'yz-0803' 2026-08-07 15:02:36 +08:00
Your Name 894f52e875 更新 2026-08-07 15:02:20 +08:00
Your Name a133d5d85d 更新 2026-08-07 14:57:33 +08:00
Your Name 2c238d6599 Merge branch 'yz-0803' 2026-08-07 14:54:48 +08:00
Your Name 6ce58bcd85 更新 2026-08-07 14:54:05 +08:00
Your Name 1ca87f8d72 新增 2026-08-07 14:38:25 +08:00
Your Name 5bf6ea01f6 Merge branch 'yz-0803' 2026-08-07 14:33:50 +08:00
Your Name 58a7197d3e 新增功能 2026-08-07 14:32:35 +08:00
Your Name 90be4bf60b 更新 2026-08-07 09:24:50 +08:00
Your Name 1c7b22a72f Merge branch 'yz-0803' 2026-08-07 09:17:18 +08:00
Your Name 16d301f302 更新 2026-08-07 09:16:15 +08:00
Your Name 00ce6d6875 更新 2026-08-06 16:36:37 +08:00
Your Name bdd8e69ec8 Merge branch 'yz-0803' 2026-08-06 16:30:05 +08:00
Your Name 8bbd6f7885 更新 2026-08-06 16:29:08 +08:00
Your Name 245c52075d 更新 2026-08-06 14:52:40 +08:00
Your Name c836084fd3 Merge branch 'yz-0803'
GENG
2026-08-06 14:49:29 +08:00
Your Name a010483bdc 更新 2026-08-06 14:48:14 +08:00
Your Name e20374e5d8 更新 2026-08-06 14:13:54 +08:00
Your Name 037e5ba450 更新 2026-08-06 14:11:52 +08:00
Your Name d5b0ab4709 更新 2026-08-06 12:03:58 +08:00
Your Name 079e50006d 更新 2026-08-06 10:57:35 +08:00
Your Name 2c0b9c5afa 更新 2026-08-05 14:37:29 +08:00
Your Name dd28bba354 更新 2026-08-05 14:14:55 +08:00
Your Name a797743aa4 更新 2026-08-05 14:06:07 +08:00
Your Name abb4aced1c 更新 2026-08-05 11:55:10 +08:00
Your Name 4ee8a8e98b 更新 2026-08-05 11:54:24 +08:00
Your Name 7ddb4882e3 修复bug 2026-08-05 11:15:14 +08:00
Your Name c2b7018a22 更新 2026-08-05 11:08:02 +08:00
Your Name 57a7c415a1 Merge branch 'bug-08/03' 2026-08-03 11:27:55 +08:00
Your Name 582c7cf0c6 更新 2026-08-03 11:27:15 +08:00
Your Name 2425fb60d0 更新 2026-08-03 09:56:58 +08:00
Your Name 22a371a733 更新 2026-08-03 09:51:53 +08:00
long 81d6a38e26 build 2026-07-28 16:29:32 +08:00
long 64d760af1a debug 2026-07-28 16:28:36 +08:00
long 9ba53b6145 debug 2026-07-28 15:15:14 +08:00
long 30cf33c546 build 2026-07-28 11:04:45 +08:00
long e1fe818dce feat: integrate Luoyang pharmacy workflow 2026-07-22 18:00:17 +08:00
long 31312ae975 feat: add zyt medicine bootstrap 2026-07-22 14:50:34 +08:00
大哥大哥的大哥哥 8a6890767e fix(tongji): 恢复居家训练并统一识糖入口 2026-07-18 11:30:54 +08:00
大哥大哥的大哥哥 d57e60539e Merge remote-tracking branch 'origin/master' into feature/tongji-endless-game
# Conflicts:
#	TUICallKit-Vue3/tongji/endless-game/README.md
#	TUICallKit-Vue3/tongji/endless-game/index.vue
#	TUICallKit-Vue3/vite.config.ts
2026-07-18 11:24:59 +08:00
大哥大哥的大哥哥 78fd00fa05 fix(miniprogram): 优化分包并清理旧版小游戏 2026-07-18 11:21:12 +08:00
大哥大哥的大哥哥 fd7e6071a8 fix(tongji): 修复小游戏平台同步与并发问题 2026-07-17 21:21:43 +08:00
大哥大哥的大哥哥 7192c4db8c feat(tongji): 接入小游戏真实周榜与微信分享 2026-07-17 18:02:00 +08:00
Your Name b02c340348 更新 2026-07-17 14:47:00 +08:00
Your Name 0f21dd4451 更新 2026-07-17 14:12:15 +08:00
Your Name 1ed3e06fdf 更新 2026-07-17 12:50:11 +08:00
Your Name c608145023 i
Merge branch 'bug-0717'
2026-07-17 12:13:30 +08:00
Your Name 4f95d9182e 更新 2026-07-17 12:13:02 +08:00
long f662723a25 debug 2026-07-17 11:37:05 +08:00
long 32370df7fe feat(tongji): 新增控糖消消乐小游戏 2026-07-17 10:24:42 +08:00
大哥大哥的大哥哥 62467dc01c feat(tongji): 新增控糖消消乐小游戏 2026-07-17 09:30:22 +08:00
Your Name 97fefbae8f 更新 2026-07-16 09:43:07 +08:00
Your Name 541fe59ad1 Merge branch 'xuhao-20260715' 2026-07-16 09:37:30 +08:00
longandCursor b6c5b422c2 test: 空提交验证 Gitea SSH 推送
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 09:25:10 +08:00
Your Name 500038601c 更新 2026-07-16 09:04:40 +08:00
Your Name f6688a740d 更新 2026-07-10 14:19:01 +08:00
Your Name a347e0642c Merge branch 'AI-20260710' 2026-07-10 14:16:51 +08:00
Your Name 13adbac3fd 更新 2026-07-10 14:16:07 +08:00
Your Name 333a18c37e 更新 2026-07-10 14:10:48 +08:00
Your Name e3a91a91f7 i
Merge branch 'AI-20260710'
2026-07-10 14:08:47 +08:00
Your Name 5ca5f06255 更新 2026-07-10 14:08:27 +08:00
Your Name e01f3e7072 更新 2026-07-10 10:58:14 +08:00
Your Name ed78803015 更新 2026-07-10 10:52:36 +08:00
Your Name 546138d60e 更新 2026-07-10 10:28:21 +08:00
Your Name fdddfdb3dd AI 客服 2026-07-10 10:25:51 +08:00
Your Name f5a89b4310 更新 2026-07-09 17:58:02 +08:00
Your Name a650747fd3 更新 2026-07-09 11:27:20 +08:00
Your Name 46b62229a6 i
Merge branch '20260708'
2026-07-09 11:25:25 +08:00
Your Name 0f33f2ad28 更新 2026-07-09 11:24:18 +08:00
Your Name a677b69e97 更新 2026-07-08 15:00:28 +08:00
Your Name 6432e9990c 更新 2026-07-08 14:53:41 +08:00
Your Name 4d6125268a 更新 2026-07-08 14:46:39 +08:00
Your Name 7f93cf5480 更新 2026-07-07 11:06:19 +08:00
Your Name f8f1953a2a Merge branch 'bug-20260706' 2026-07-07 11:04:43 +08:00
Your Name 99eca8b2e5 更新 2026-07-07 09:24:40 +08:00
Your Name bde396189a Please enter the commit message for your changes. Lines starting 2026-07-06 16:58:01 +08:00
Your Name 55e3b5d9e0 更新 2026-07-06 16:34:42 +08:00
long eb0efba739 build 2026-07-03 12:07:34 +08:00
long c7e7022181 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-03 12:04:06 +08:00
long 26e6cc433b Add bulk asset resource deletion 2026-07-03 12:01:49 +08:00
Your Name c22b84cff9 更新 2026-07-03 11:22:07 +08:00
Your Name 4f9486fb3d Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-02 16:35:40 +08:00
Your Name d24b04f9e9 更新 2026-07-02 16:35:24 +08:00
long 465cba3788 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-07-02 16:30:24 +08:00
long dc590d37e9 修正医生维度接诊率显示 2026-07-02 16:26:24 +08:00
Your Name d1f587fdd7 更新 2026-07-02 16:19:43 +08:00
Your Name e8f68101c4 更新 2026-07-02 16:13:38 +08:00
Your Name cea9392239 Merge branch 'master' into 20260623 2026-07-02 16:08:14 +08:00
Your Name 3ea3c97c42 更新 2026-07-02 14:47:53 +08:00
Your Name efd058bce5 更新 2026-07-02 14:35:20 +08:00
long e538dd03a1 build 2026-07-02 11:57:54 +08:00
long 4a17fe8a0c 调整综合转化统计医生维度口径 2026-07-02 11:55:15 +08:00
Your Name e2a1e62867 更新 2026-06-29 09:28:00 +08:00
Your Name b6cdd20c56 新增 2026-06-29 09:25:29 +08:00
Your Name c1be0aa3e5 更新 2026-06-24 10:07:39 +08:00
Your Name 6c87b72ce4 更新 2026-06-23 11:31:49 +08:00
Your Name e28480af32 更新 2026-06-16 16:16:31 +08:00
Your Name 37b2945c44 更新 2026-06-16 15:47:44 +08:00
Your Name c9c2b4608f 更新 2026-06-16 15:23:59 +08:00
Your Name e61e7f97fa 更新 2026-06-15 10:37:10 +08:00
Your Name 14e5bba9a2 更新 2026-06-15 09:18:59 +08:00
Your Name 6b713a2d6c 更新 2026-06-13 15:00:43 +08:00
Your Name 8ae5b4eead 更新 2026-06-12 18:07:21 +08:00
long e5959fd89a 1 2026-06-11 14:26:41 +08:00
Your Name 08249451a7 i
Merge branch 'master20260610'
2026-06-10 17:30:43 +08:00
Your Name 79ae055bb6 更新 2026-06-10 17:30:05 +08:00
Your Name c0cc23393e 更新 2026-06-10 15:30:38 +08:00
Your Name 470ba419e3 更新 2026-06-10 15:29:07 +08:00
Your Name 1c0cd84355 更新 2026-06-10 15:03:39 +08:00
Your Name f2e2f84e18 i
Merge branch 'master' of https://gitee.com/v_cms/zyt
2026-06-10 14:38:34 +08:00
long f7e52bcafc Merge branch '20260610-long' 2026-06-10 14:37:44 +08:00
long 2e59e883b8 订单详情优化 2026-06-10 14:37:32 +08:00
Your Name 9f1801771e 更新 2026-06-10 14:25:22 +08:00
Your Name 1adf8ccf4b 更新 2026-06-10 10:28:58 +08:00
Your Name abc78f4291 更新 2026-06-10 09:49:46 +08:00
Your Name 072c0c3663 Merge branch 'master' into master-6-9 2026-06-09 11:43:25 +08:00
Your Name e9cf65a41c 更新 2026-06-09 11:42:35 +08:00
long af9bd05bb4 update资源分发小程序 2026-06-09 10:31:18 +08:00
Your Name 18f7fb3772 Merge branch 'master' into master-6-9 2026-06-09 10:17:29 +08:00
Your Name 29ab45033d 更新 2026-06-09 10:16:38 +08:00
long 8a1a9ea0ca Merge branch '20260604-long' 2026-06-09 10:13:49 +08:00
Your Name f8ccd8b0af 更新 2026-06-09 10:11:40 +08:00
long 9e019708f2 Merge branch 'master' of https://gitee.com/v_cms/zyt 2026-06-09 10:05:49 +08:00
long 2712c262bd 1 2026-06-09 10:05:44 +08:00
Your Name bd7002e176 更新 2026-06-09 09:42:29 +08:00
Your Name 0be6d06544 更新 2026-06-09 09:31:03 +08:00
long 7a4973470c Merge branch 'codex/latest-appointment-filter' 2026-06-06 15:59:19 +08:00
long e79f3190b8 feat: 支持患者列表按最近挂号筛选
- tcm.diagnosis/lists 新增最近有效挂号时间区间与渠道筛选,lists/count 共用过滤逻辑

- 诊单患者列表更多筛选增加最近挂号时间和渠道,并与顶部挂号快捷筛选互斥

- 明确 daterange-picker change 事件转发,列表补充最近挂号渠道摘要展示
2026-06-06 15:58:23 +08:00
long 1811abc794 完善支付单创建方式链路 2026-06-06 15:25:16 +08:00
long 07abf2abca 支付方式完善 2026-06-05 18:24:41 +08:00
long c76bd5416a 代收金额快照 2026-06-05 17:35:49 +08:00
Your Name 7bb8db4126 更新 2026-06-05 17:22:29 +08:00
1261 changed files with 108308 additions and 11269 deletions
-120
View File
@@ -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"
-74
View File
@@ -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"
+2 -7
View File
@@ -1,7 +1,6 @@
<script setup>
// 2. 获取组件实例,通过 proxy 访问全局属性
import { ref} from "vue";
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
import { getCurrentInstance } from 'vue'
@@ -97,11 +96,7 @@ const video =async (data)=>{
},
}, false)
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
console.log('SDKAppID',SDKAppID)
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
@@ -115,4 +110,4 @@ const video =async (data)=>{
uni.setStorageSync('CallManager', userId)
await uni.CallManager.init(cc);
}
</script>
</script>
+1 -7
View File
@@ -239,7 +239,6 @@
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
@@ -561,17 +560,12 @@ const { proxy } = getCurrentInstance()
},
}, false)
const {userId } = res.data;
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
patient_data.value=res.data.data
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID;
getApp().globalData.secretKey = secretKey;
uni.setStorageSync('CallManager', userId)
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
}
@@ -186,16 +186,8 @@ const inlineItems = computed(() => {
return metro ? [metro, ...rest] : trainingItems
})
const isDevMode = (): boolean => {
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
try {
return process.env.NODE_ENV !== 'production'
} catch (_) {
return false
}
}
const visible = ref(isDevMode())
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
const visible = ref(true)
const expanded = ref(false)
const toggle = () => {
@@ -178,7 +178,6 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getCurrentInstance } from 'vue'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
let userId = ref("");
@@ -379,10 +378,11 @@ const loginHandler = async (patient_id) => {
data: { patient_id: patient_id },
}, false)
const { userId: patientUserId } = res.data
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
userID: patientUserId,
})
const {
userId: patientUserId,
userSig: patientUserSig,
sdkAppId: patientSDKAppID,
} = res.data
console.log('获取签名成功:', patientUserSig)
// 更新ref值
+4 -3
View File
@@ -51,15 +51,16 @@
/* */
"mp-weixin" : {
"appid" : "wx79b9a0bfbfe7cbcd",
"lazyCodeLoading" : "requiredComponents",
"setting" : {
"urlCheck" : false
"urlCheck" : false,
"minified" : true
},
"optimization" : {
"subPackages" : true
},
"usingComponents" : true,
"requiredBackgroundModes" : ["audio"],
"requiredBackgroundModes" : [ "audio" ],
"plugins" : {
"WechatSI" : {
"version" : "0.3.5",
+28 -13
View File
@@ -26,14 +26,6 @@
"navigationBarTitleText": "甄养堂"
}
},
{
"path": "TUICallKit/src/Components/TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
},
{
"path": "pages/order/monad/monad",
"style": {
@@ -76,6 +68,19 @@
}
],
"subPackages": [
{
"root": "TUICallKit/src/Components",
"pages": [
{
"path": "TUICallKit",
"style": {
"navigationBarTitleText": "视频问诊",
"navigationBarHidden": true,
"navigationStyle": "custom"
}
}
]
},
{
"root": "TUIKit",
"pages": [
@@ -204,16 +209,26 @@
"navigationStyle": "custom",
"navigationBarTitleText": "日常护理",
"backgroundColor": "#f4fbf4",
"enablePullDownRefresh": true
"enablePullDownRefresh": true,
"mp-weixin": {
"usingPlugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
}
}
}
},
{
"path": "pages/game",
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "糖分突袭",
"backgroundColor": "#c7d2fe",
"disableScroll": true
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
]
+2 -7
View File
@@ -62,7 +62,6 @@
import { ref,onMounted,getCurrentInstance} from "vue";
// 2. 获取组件实例,通过 proxy 访问全局属性
const { proxy } = getCurrentInstance()
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
let userID = ref("4");
@@ -140,13 +139,9 @@ const loginHandler = async (patient_id) => {
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
console.log('获取签名成功:',userSig);
getApp().globalData.userID = userId.value;
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
getApp().globalData.SDKAppID = SDKAppID;
+1 -5
View File
@@ -52,7 +52,6 @@
<script setup>
import { ref, getCurrentInstance,onMounted } from "vue";
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
let userId = ref("");
@@ -126,10 +125,7 @@ const loginHandler = async (patient_id) => {
data: { patient_id: patient_id },
}, false)
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
console.log('获取签名成功:',userSig);
getApp().globalData.userID = userId;
+12 -17
View File
@@ -370,7 +370,6 @@
import { ref,onMounted,getCurrentInstance } from "vue";
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
// 2. 获取组件实例,通过 proxy 访问全局属性
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
const { proxy } = getCurrentInstance()
@@ -667,25 +666,25 @@ const closePatientModal = async () => {
const granted = await requestAVPermissionsInModal();
if (!granted) return; // 未授权,停止跳转
const app = getApp();
// const userID = app.globalData.userID;
// const userSig = app.globalData.userSig;
// const SDKAppID = app.globalData.SDKAppID;
const doctor = 'doctor_' + doctorId.value;
await confirmDiagnosis(diagnosisId);
if(!diagnosisId.includes('patient')){
diagnosisId='patient_'+diagnosisId
}
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:diagnosisId,
});
const signatureRes = await proxy.apiUrl({
url: '/api/tcm/getPatientSignature',
method: 'GET',
data: { patient_id: diagnosisId },
}, false);
const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
if (!doctorId.value) {
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
return;
}
console.log(app.globalData, diagnosisId);
app.globalData.userID = userId;
app.globalData.userSig = userSig;
app.globalData.SDKAppID = SDKAppID;
console.log(app.globalData, userId);
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(diagnosisId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
showPatientModal.value = false;
uni.redirectTo({
@@ -707,11 +706,7 @@ const video =async (data)=>{
},
}, false)
const {userId } = res.data;
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
userID:userId,
});
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
getApp().globalData.userID = userId;
getApp().globalData.userSig = userSig;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 45 KiB

@@ -1,121 +0,0 @@
<template>
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
<image
v-if="src && !imageFailed"
class="food-tile-icon__img"
:src="src"
mode="aspectFit"
@error="onImageError"
/>
</view>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getFoodImgUrl } from '../data/gameFoodImg.js'
const props = defineProps({
food: { type: [Object, String], default: null },
size: { type: String, default: 'tile' }
})
const imageFailed = ref(false)
const fallbackIcon = computed(() => {
if (typeof props.food === 'object' && props.food?.icon) return props.food.icon
return '🍽'
})
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 ''
})
watch(
() => src.value,
() => {
imageFailed.value = false
}
)
function onImageError() {
imageFailed.value = true
}
</script>
<style lang="scss" scoped>
.food-tile-icon {
position: relative;
box-sizing: border-box;
overflow: hidden;
}
.food-tile-icon--tile {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
font-size: calc(var(--tile-emoji-size, 48px) * 0.6);
}
.food-tile-icon--tile .food-tile-icon__emoji,
.food-tile-icon--tile .food-tile-icon__img {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 75%;
height: 75%;
z-index: 2;
}
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
.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;
display: flex;
align-items: center;
justify-content: center;
}
.food-tile-icon--tooltip {
width: 36rpx;
height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
.food-tile-icon__emoji {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: inherit;
line-height: 1;
z-index: 1;
}
.food-tile-icon--goal .food-tile-icon__emoji,
.food-tile-icon--tooltip .food-tile-icon__emoji {
position: static;
transform: none;
font-size: 34rpx;
}
.food-tile-icon__img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 2;
display: block;
}
</style>
@@ -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 {
@@ -1,188 +0,0 @@
/**
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
*/
import { ref } from 'vue'
let fxSeq = 0
function nextFxId(prefix) {
fxSeq += 1
return `${prefix}-${fxSeq}-${Date.now()}`
}
// 开心消消乐式糖果色板(彩虹爆裂用)
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
// 星屑字形
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
export function useGameFx() {
const floatingTexts = ref([])
const burstParticles = ref([])
const ripples = ref([])
const comboBanner = ref({ show: false, text: '', tier: 1 })
const screenShaking = ref(false)
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
let comboBannerTimer = null
let shakeTimer = null
let flashTimer = null
let flashFadeTimer = null
function spawnFloatingText(payload) {
const id = nextFxId('ft')
floatingTexts.value.push({ id, ...payload })
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
setTimeout(() => {
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
}, duration)
return id
}
/**
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
* @param {number} cx,cy 爆裂中心(屏幕坐标)
* @param {string} color 基础颜色
* @param {number} count 粒子数量
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
*/
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
const {
star = true,
rainbow = false,
gravity = 64,
sizeMin = 8,
sizeMax = 16,
distMin = 36,
distMax = 110,
life = 850
} = opts
const dur = (life / 1000).toFixed(2)
for (let i = 0; i < count; i++) {
const id = nextFxId('p')
const isStar = star && Math.random() < 0.65
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
const angle = Math.random() * Math.PI * 2
const distance = Math.random() * (distMax - distMin) + distMin
const tx = Math.cos(angle) * distance
// 先上抛、再叠加重力下落,形成喷泉抛物线
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
const half = pSize / 2
const pColor = rainbow
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
: color
const rot = Math.random() * 720 - 360
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
const particle = {
id,
left: cx - half,
top: cy - half,
size: pSize,
color: pColor,
char: glyph,
opacity: 1,
transform: 'scale(0.4) rotate(0deg)',
transition: 'none'
}
burstParticles.value.push(particle)
setTimeout(() => {
particle.transition =
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
particle.left = cx - half + tx
particle.top = cy - half + ty
particle.opacity = 0
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
}, 16)
setTimeout(() => {
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
}, life + 80)
}
}
/**
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
* @param {object} opts { thickness, life }
*/
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
const id = nextFxId('rp')
const size = 36 + tier * 18
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
ripples.value.push({
id,
left: cx,
top: cy,
size,
color,
thickness,
opacity: 0.95
})
setTimeout(() => {
ripples.value = ripples.value.filter((r) => r.id !== id)
}, life)
}
/**
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
*/
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
if (flashFadeTimer) clearTimeout(flashFadeTimer)
if (flashTimer) clearTimeout(flashTimer)
flash.value = { active: true, color, opacity: intensity }
flashFadeTimer = setTimeout(() => {
flash.value = { ...flash.value, opacity: 0 }
}, 24)
flashTimer = setTimeout(() => {
flash.value = { active: false, color, opacity: 0 }
}, life)
}
function showComboBanner(chain, points) {
if (chain <= 1) return
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
comboBanner.value = {
show: true,
text: `连消 ×${chain} +${points}`,
tier
}
if (comboBannerTimer) clearTimeout(comboBannerTimer)
comboBannerTimer = setTimeout(() => {
comboBanner.value = { ...comboBanner.value, show: false }
}, 1100)
}
function triggerShake(intensity = 1) {
screenShaking.value = true
if (shakeTimer) clearTimeout(shakeTimer)
shakeTimer = setTimeout(() => {
screenShaking.value = false
}, intensity >= 2 ? 420 : 280)
}
function clearAll() {
floatingTexts.value = []
burstParticles.value = []
ripples.value = []
comboBanner.value = { show: false, text: '', tier: 1 }
screenShaking.value = false
flash.value = { active: false, color: '#ffffff', opacity: 0 }
}
return {
floatingTexts,
burstParticles,
ripples,
comboBanner,
screenShaking,
flash,
spawnFloatingText,
spawnBurst,
spawnRipple,
showComboBanner,
triggerShake,
triggerFlash,
clearAll
}
}
@@ -1,112 +0,0 @@
/**
* 游戏进度持久化:关卡、星级、连胜、每日打卡
*/
import { ref } from 'vue'
import { calcLevelStars } from '../data/gameLevels.js'
const STORAGE_KEY = 'tongji_game_progress_v1'
const DEFAULT = {
currentLevel: 1,
maxUnlocked: 1,
stars: {},
winStreak: 0,
dailyStreak: 0,
lastPlayDate: '',
totalWins: 0,
bestCombo: 0,
totalStars: 0
}
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function sumStars(starsMap) {
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
}
function loadRaw() {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
} catch (_) {}
return { ...DEFAULT }
}
function saveRaw(data) {
try {
uni.setStorageSync(STORAGE_KEY, data)
} catch (_) {}
}
export function useGameProgress() {
const progress = ref(loadRaw())
function persist() {
progress.value.totalStars = sumStars(progress.value.stars)
saveRaw(progress.value)
}
/** 进入游戏时更新每日打卡 */
function touchDailyPlay() {
const today = todayStr()
const last = progress.value.lastPlayDate
if (last === today) return progress.value.dailyStreak
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
if (last === yStr) {
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
} else {
progress.value.dailyStreak = 1
}
progress.value.lastPlayDate = today
persist()
return progress.value.dailyStreak
}
function getStars(levelId) {
return progress.value.stars[String(levelId)] || 0
}
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
const key = String(levelId)
const prev = progress.value.stars[key] || 0
if (stars > prev) progress.value.stars[key] = stars
progress.value.winStreak = (progress.value.winStreak || 0) + 1
progress.value.totalWins = (progress.value.totalWins || 0) + 1
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
const next = levelId + 1
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
progress.value.currentLevel = next
persist()
return { stars, newBest: stars > prev }
}
function recordLoss() {
progress.value.winStreak = 0
persist()
}
function setCurrentLevel(levelId) {
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
persist()
}
return {
progress,
touchDailyPlay,
getStars,
recordWin,
recordLoss,
setCurrentLevel,
persist
}
}
@@ -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
/**
* 语音转文字(长按说话):微信小程序 WechatSIH5 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 }
-654
View File
@@ -1,654 +0,0 @@
/**
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
*/
import { svgToDataUrl } from '../utils/svgDataUrl.js'
function gid(key, suffix) {
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
}
function wrap(key, body) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
}
function shadow(key) {
const id = gid(key, 'sh')
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
}
function shine(x, y, rx, ry) {
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
}
/** 圆形水果 */
function artRound(key, base, dark, light, extra = '') {
const g = gid(key, 'g')
return wrap(key, `
<defs>
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
<stop offset="0%" stop-color="${light}"/>
<stop offset="55%" stop-color="${base}"/>
<stop offset="100%" stop-color="${dark}"/>
</radialGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
${shine(24, 28, 7, 5)}
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
${extra}
</g>`)
}
/** 叶菜 */
function artLeafy(key, light, mid, dark) {
const g1 = gid(key, 'g1')
const g2 = gid(key, 'g2')
return wrap(key, `
<defs>
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
</linearGradient>
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
${shine(26, 24, 6, 8)}
</g>`)
}
/** 西兰花 */
function artBroccoli(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs>
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
</radialGradient>
${shadow(key)}
</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
${shine(28, 20, 5, 4)}
</g>`)
}
/** 长条(黄瓜/香蕉/玉米) */
function artLong(key, base, dark, light, type = 'cucumber') {
const g = gid(key, 'g')
if (type === 'banana') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
${shine(30, 26, 5, 3)}
</g>`)
}
if (type === 'corn') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
Array.from({ length: 4 }, (_, c) =>
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
).join('')
).join('')}</g>
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
${shine(26, 26, 5, 8)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
</g>`)
}
/** 茄子 */
function artEggplant(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
${shine(26, 32, 5, 7)}
</g>`)
}
/** 番茄 */
function artTomato(key) {
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
}
/** 甜椒 */
function artPepper(key, color, dark) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
${shine(28, 26, 5, 6)}
</g>`)
}
/** 洋葱 */
function artOnion(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
${shine(24, 30, 6, 5)}
</g>`)
}
/** 葡萄串 */
function artGrapes(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
${shine(28, 24, 3, 2)}
</g>`)
}
/** 樱桃 */
function artCherry(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
</g>`)
}
/** 草莓 */
function artStrawberry(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
${shine(26, 28, 4, 6)}
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
).join('')}</g>
</g>`)
}
/** 豆类 */
function artBeans(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
</g>`)
}
/** 饮品杯 */
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
const g = gid(key, 'g')
if (type === 'bottle') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
${shine(28, 30, 4, 10)}
</g>`)
}
if (type === 'wine') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
${shine(26, 32, 4, 8)}
</g>`)
}
/** 蘑菇 */
function artMushroom(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
${shine(24, 24, 6, 4)}
</g>`)
}
/** 芹菜 */
function artCelery(key) {
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
}
/** 四季豆 */
function artGreenBean(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
</g>`)
}
/** 饭碗 */
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
${shine(24, 34, 6, 3)}
</g>`)
}
/** 薯类 */
function artRoot(key, base, dark, light) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
${shine(24, 30, 5, 6)}
</g>`)
}
/** 肉类 */
function artMeat(key, base, dark, type = 'steak') {
const g = gid(key, 'g')
if (type === 'chicken') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
${shine(26, 32, 5, 6)}
</g>`)
}
if (type === 'fish') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
${shine(28, 30, 6, 4)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
${shine(26, 28, 6, 4)}
</g>`)
}
/** 虾/蟹 */
function artSeafood(key, type = 'shrimp') {
const g = gid(key, 'g')
if (type === 'crab') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
${shine(30, 28, 5, 4)}
</g>`)
}
/** 菠萝 */
function artPineapple(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
).join('')}</g>
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
${shine(26, 32, 5, 6)}
</g>`)
}
/** 奇异果 */
function artKiwi(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
${shine(26, 28, 4, 3)}
</g>`)
}
/** 蜜瓜 */
function artMelon(key) {
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
}
/** 蜂蜜 */
function artHoney(key) {
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
}
/** 面包类 */
function artBread(key, base, dark, type = 'loaf') {
const g = gid(key, 'g')
if (type === 'donut') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
${shine(24, 28, 5, 4)}
</g>`)
}
if (type === 'mantou') {
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
${shine(24, 32, 6, 4)}
</g>`)
}
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
${shine(22, 28, 8, 5)}
</g>`)
}
/** 面条 */
function artNoodle(key, soup = '#d7ccc8') {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
</g>`)
}
/** 南瓜 */
function artPumpkin(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
${shine(26, 28, 5, 6)}
</g>`)
}
/** 薯条 */
function artFries(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
</g>`)
}
/** 西瓜 */
function artWatermelon(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
${shine(24, 30, 4, 3)}
</g>`)
}
/** 糖果 */
function artCandy(key, base, dark, type = 'lollipop') {
const g = gid(key, 'g')
if (type === 'wrap') {
return wrap(key, `
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</linearGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
</g>`)
}
return wrap(key, `
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
${shine(26, 24, 4, 3)}
</g>`)
}
/** 糯米团 */
function artDango(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<circle cx="22" cy="36" r="8" fill="#fff"/>
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
<circle cx="42" cy="36" r="8" fill="#fff"/>
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
</g>`)
}
/** 爆米花 */
function artPopcorn(key) {
const g = gid(key, 'g')
return wrap(key, `
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
</radialGradient>${shadow(key)}</defs>
<g filter="url(#${gid(key, 'sh')})">
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
</g>`)
}
const FOOD_ART = {
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
broccoli: () => artBroccoli('broccoli'),
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
eggplant: () => artEggplant('eggplant'),
tomato: () => artTomato('tomato'),
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
onion: () => artOnion('onion'),
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
cherry: () => artCherry('cherry'),
grape: () => artGrapes('grape'),
strawberry: () => artStrawberry('strawberry'),
soybean: () => artBeans('soybean'),
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
mushroom: () => artMushroom('mushroom'),
celery: () => artCelery('celery'),
greenBean: () => artGreenBean('greenBean'),
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
shrimp: () => artSeafood('shrimp', 'shrimp'),
crab: () => artSeafood('crab', 'crab'),
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
pineapple: () => artPineapple('pineapple'),
kiwi: () => artKiwi('kiwi'),
melon: () => artMelon('melon'),
honey: () => artHoney('honey'),
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
udon: () => artNoodle('udon', '#d9b88f'),
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
popcorn: () => artPopcorn('popcorn'),
ramen: () => artNoodle('ramen', '#e0a96d'),
pumpkin: () => artPumpkin('pumpkin'),
bakedPotato: () => artFries('bakedPotato'),
watermelon: () => artWatermelon('watermelon'),
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
stickyRice: () => artDango('stickyRice')
}
const imgCache = {}
/** 获取食材插画 data URL(带缓存) */
export function getFoodImg(key) {
if (imgCache[key]) return imgCache[key]
const builder = FOOD_ART[key]
if (!builder) return ''
imgCache[key] = svgToDataUrl(builder())
return imgCache[key]
}
export function warmUpFoodImgs(keys) {
keys.forEach((k) => getFoodImg(k))
}
@@ -1,50 +0,0 @@
/**
* 食材图标 URLTwemoji PNG,小程序 / H5 通用)
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
*/
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
/** emoji → Twemoji PNG 地址 */
export function emojiToTwemojiUrl(emoji) {
if (!emoji) return ''
const parts = []
for (let i = 0; i < emoji.length;) {
const cp = emoji.codePointAt(i)
if (!cp) break
parts.push(cp.toString(16))
i += cp > 0xffff ? 2 : 1
}
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
}
const imgCache = {}
export function getFoodImgUrl(emoji) {
if (!emoji) return ''
if (imgCache[emoji]) return imgCache[emoji]
const url = emojiToTwemojiUrl(emoji)
imgCache[emoji] = url
return url
}
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
export function warmUpFoodImgs(foods) {
const list = Array.isArray(foods) ? foods : []
list.forEach((f) => {
const icon = typeof f === 'string' ? f : f?.icon
// 优先预热自定义图片素材,缺省回退 emoji PNG
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
if (!url) return
// #ifdef MP-WEIXIN
try {
uni.getImageInfo({ src: url, fail: () => {} })
} catch (_) {}
// #endif
// #ifdef H5
try {
const img = new Image()
img.src = url
} catch (_) {}
// #endif
})
}
@@ -1,189 +0,0 @@
/**
* 全量食材库:key 用于消除匹配,img 为 Twemoji PNG(小程序可用)
*/
import { getFoodImgUrl } from './gameFoodImg.js'
export const GI_LABEL = { low: '低血糖', mid: '中血糖', high: '高血糖' }
/** 若实际食用时的升糖幅度(教育提示用,正值=升糖) */
export function getEatVal(food) {
if (food.eatVal != null) return food.eatVal
if (food.gi === 'high') return Math.abs(food.val)
if (food.gi === 'mid') return Math.max(1, food.val)
return Math.max(1, Math.abs(food.val))
}
/** 从餐盘消除该食物后的血糖变化(负值=控糖有益) */
export function getClearVal(food) {
if (food.clearVal != null) return food.clearVal
const eat = getEatVal(food)
if (food.gi === 'high') return -Math.max(8, Math.round(eat * 0.5))
if (food.gi === 'mid') return -1
return 0
}
/** 点击食材时的升糖说明文案 */
export function formatEatLabel(food) {
if (food.gi === 'low') return '升糖低'
return `若食用 +${getEatVal(food)}`
}
/** 含糖等级简称:低糖 / 中糖 / 高糖 */
export const GI_SHORT = { low: '低糖', mid: '中糖', high: '高糖' }
/** 含糖等级对应的提示主色(绿 / 橙 / 红) */
export const GI_COLOR = { low: '#16a34a', mid: '#ea580c', high: '#dc2626' }
/** 部分代表食物的专属科普文案(一句话,便于老人记忆) */
const FOOD_TIP_OVERRIDE = {
whiteRice: '精制主食,升糖快,建议小份',
stickyRice: '糯米黏软,升糖很快,要少吃',
whiteBread: '精制面食,升糖快',
mantou: '白面馒头,升糖快,配菜一起吃更稳',
youtiao: '油炸又高糖,少吃为好',
sugar: '纯糖,升糖最快,尽量不吃',
maltose: '糖分很高,升糖快',
soda: '含糖饮料,升糖很快,不建议喝',
orangeJuice: '果汁含糖高,吃整果更好',
watermelon: '很甜,升糖快,一次别吃太多',
donut: '又甜又油,升糖高',
banana: '偏甜水果,适量吃',
sweetPotato: '粗粮但有糖,可代替部分主食',
corn: '粗粮主食,适量吃',
honey: '本质是糖,别当健康品多吃',
broccoli: '新鲜蔬菜,升糖很慢,可多吃',
cucumber: '清爽蔬菜,几乎不升糖',
lettuce: '绿叶菜,升糖很低,可多吃',
celery: '高纤维蔬菜,升糖低',
apple: '带皮整果,升糖较慢',
milk: '低糖,含蛋白,适量喝好',
fish: '优质蛋白,升糖低',
beef: '蛋白为主,升糖低',
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
friedNoodles: '油多又是精面,升糖快',
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝'
}
/** 含糖等级通用科普文案 */
const GI_TIP_DEFAULT = {
low: '升糖慢,相对放心,可常吃',
mid: '升糖中等,注意分量',
high: '含糖高、升糖快,要少吃'
}
/** 点击食物时展示的一句话科普(认知教育用) */
export function getFoodTip(food) {
if (!food) return ''
if (food.tip) return food.tip
if (FOOD_TIP_OVERRIDE[food.key]) return FOOD_TIP_OVERRIDE[food.key]
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' },
{ key: 'broccoli', icon: '🥦', name: '西兰花', gi: 'low', val: -3, color: '#4E8A3A' },
{ key: 'apple', icon: '🍎', name: '苹果', gi: 'low', val: -2, color: '#E53935' },
{ key: 'cucumber', icon: '🥒', name: '黄瓜', gi: 'low', val: -4, color: '#66A82F' },
{ key: 'eggplant', icon: '🍆', name: '茄子', gi: 'low', val: -3, color: '#7E57C2' },
{ key: 'tomato', icon: '🍅', name: '番茄', gi: 'low', val: -2, color: '#EF5350' },
{ key: 'pepper', icon: '🫑', name: '青椒', gi: 'low', val: -3, color: '#43A047' },
{ key: 'onion', icon: '🧅', name: '洋葱', gi: 'low', val: -2, color: '#CDA06B' },
{ key: 'pear', icon: '🍐', name: '雪梨', gi: 'low', val: -2, color: '#C0CA33' },
{ key: 'orange', icon: '🍊', name: '橙子', gi: 'low', val: -2, color: '#FB8C00' },
{ key: 'peach', icon: '🍑', name: '桃子', gi: 'low', val: -2, color: '#FF8A65' },
{ key: 'cherry', icon: '🍒', name: '樱桃', gi: 'low', val: -2, color: '#C2185B' },
{ key: 'grape', icon: '🍇', name: '葡萄', gi: 'low', val: -1, color: '#8E24AA' },
{ key: 'strawberry', icon: '🍓', name: '草莓', gi: 'low', val: -2, color: '#EC407A' },
{ key: 'soybean', icon: '🫘', name: '黄豆', gi: 'low', val: -3, color: '#C9A063' },
{ key: 'milk', icon: '🥛', name: '牛奶', gi: 'low', val: -2, color: '#B0BEC5' },
{ key: 'tea', icon: '🍵', name: '红茶', gi: 'low', val: -1, color: '#A1573B' },
{ key: 'mushroom', icon: '🍄', name: '香菇', gi: 'low', val: -3, color: '#8D6E63' },
{ key: 'celery', icon: '🌿', name: '芹菜', gi: 'low', val: -4, color: '#7CB342' },
{ key: 'greenBean', icon: '🫛', name: '四季豆', gi: 'low', val: -3, color: '#689F38' },
// 中 GI
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
{ key: 'beef', icon: '🥩', name: '牛肉', gi: 'mid', val: 3, color: '#C0504D' },
{ key: 'leanMeat', icon: '🍖', name: '瘦肉', gi: 'mid', val: 3, color: '#B5654D' },
{ key: 'shrimp', icon: '🦐', name: '虾仁', gi: 'mid', val: 2, color: '#FF7043' },
{ key: 'crab', icon: '🦀', name: '螃蟹', gi: 'mid', val: 2, color: '#F4623A' },
{ key: 'banana', icon: '🍌', name: '香蕉', gi: 'mid', val: 6, color: '#FDD835' },
{ key: 'mango', icon: '🥭', name: '芒果', gi: 'mid', val: 6, color: '#FFB300' },
{ key: 'pineapple', icon: '🍍', name: '菠萝', gi: 'mid', val: 6, color: '#FBC02D' },
{ key: 'kiwi', icon: '🥝', name: '奇异果', gi: 'mid', val: 4, color: '#9CCC65' },
{ key: 'melon', icon: '🍈', name: '哈密瓜', gi: 'mid', val: 6, color: '#AED581' },
{ key: 'honey', icon: '🍯', name: '蜂蜜', gi: 'mid', val: 6, color: '#F9A825' },
{ 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: '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: '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: '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' },
{ key: 'pumpkin', icon: '🎃', name: '南瓜', gi: 'high', val: 15, color: '#EF6C00' },
{ key: 'bakedPotato', icon: '🍟', name: '焗薯', gi: 'high', val: 20, color: '#F2C14E' },
{ key: 'watermelon', icon: '🍉', name: '西瓜', gi: 'high', val: 16, color: '#F0506A' },
{ key: 'sugar', icon: '🍭', name: '砂糖', gi: 'high', val: 25, color: '#FF79B0' },
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
]
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
const eatVal = getEatVal(f)
const clearVal = getClearVal({ ...f, eatVal })
return {
id: i + 1,
...f,
eatVal,
clearVal,
giLabel: GI_LABEL[f.gi],
get img() {
// 优先使用自定义图片素材,缺省回退到 emoji PNG
return f.img || getFoodImgUrl(f.icon)
}
}
})
/** 按 key 查找食材(目标栏等用) */
export function getFoodByKey(key) {
return FOOD_LIBRARY.find((f) => f.key === key) || null
}
/** 为棋盘 tile 使用的 plain 对象(img 预计算,避免 getter 在响应式里反复触发) */
export function cloneFoodType(food) {
return {
id: food.id,
key: food.key,
icon: food.icon,
name: food.name,
gi: food.gi,
giLabel: food.giLabel,
val: food.val,
color: food.color,
img: food.img || getFoodImgUrl(food.icon)
}
}
-43
View File
@@ -1,43 +0,0 @@
/**
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
*/
/**
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
* 第一关固定为这 5 种带真实图片素材的食物。
*/
const FIXED_LEVEL_FOODS = {
1: ['milkOats', 'grainMantou', 'riceNoodleSoup', 'friedNoodles', 'centuryEggCongee']
}
export function getLevelConfig(levelId) {
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
const tier = Math.floor((lv - 1) / 5)
return {
id: lv,
// 步数充裕:仅作展示,认全目标即可通关
moves: Math.max(40, 60 - tier * 2),
// 认识目标平缓增长,避免给老人压力
goalTargets: [6 + tier, 8 + tier],
scoreTarget: 2000 + lv * 300,
startGlucose: 50,
// 固定食材(可选):存在时本关只用这些食物
foods: FIXED_LEVEL_FOODS[lv] || null,
boosters: {
insulin: 3,
fiber: 1,
meal: 5
}
}
}
/** 根据本局表现计算 1~3 星:完成即得星,步数越省星越多(不依赖血糖) */
export function calcLevelStars({ movesLeft, levelConfig }) {
const totalMoves = levelConfig?.moves || 1
const ratio = totalMoves > 0 ? movesLeft / totalMoves : 0
let stars = 1
if (ratio >= 0.3) stars = 2
if (ratio >= 0.55) stars = 3
return stars
}
@@ -0,0 +1,40 @@
# 识糖小课堂平台接入说明
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期、性别自动分配最多 7 人的同行组。
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
## 接口
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key``learned_count``score``ended`
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`
## 部署顺序
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php``TcmController.php`
3. 重新构建并上传小程序前端。
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
## 上线前检查
- 用男女各两个测试账号进入,确认被分入对应性别组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
- 周一验证新周重新分组,旧周成绩不带入新周。
@@ -0,0 +1,43 @@
# 识糖小课堂独立功能包
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
## 目录内容
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
- `game-endless.scss`:完整页面与动画样式。
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
## 迁移步骤
1. 复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录,并确保目标项目同时提供 `tongji/components/TongjiIcon.vue``tongji/utils/svgDataUrl.js`
2. 在目标项目 `pages.json``tongji` 分包 `pages` 数组中加入:
```json
{
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "识糖小课堂",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
```
3. 使用 `/tongji/endless-game/index` 打开游戏。
4. 微信小程序后台需要将 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com` 配置为合法的音频与图片下载域名。
## 说明
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`
- 本功能包依赖 uni-app Vue 3、`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
- 食物风险等级和科普文案集中在 `index.vue``FOODS` 配置中,正式上线前仍需由医院医生审核。
@@ -0,0 +1,71 @@
/**
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
*/
export function useGameAuth(proxy) {
let loginPromise = null
function hasToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearToken() {
uni.removeStorageSync('token')
}
function wxLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
fail: reject
})
})
}
async function loginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res?.code !== 1 || !res.data?.token) {
clearToken()
throw new Error(res?.msg || '登录失败')
}
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return true
}
async function verifyOrLogin() {
if (hasToken()) {
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res?.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
if (res?.code !== -1) return true
} catch (_) {
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
return true
}
clearToken()
}
const code = await wxLoginCode()
return loginWithCode(code)
}
async function ensureLoggedIn() {
if (loginPromise) return loginPromise
loginPromise = verifyOrLogin()
.then(ok => !!ok)
.catch(() => false)
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
.finally(() => { loginPromise = null })
return loginPromise
}
return { ensureLoggedIn }
}
@@ -0,0 +1,251 @@
import { ref } from 'vue'
const EMPTY_BOARD = {
week_start: '',
week_end: '',
sex_label: '同行',
group_size: 7,
member_count: 0,
players: [],
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
invite_code: ''
}
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
function createSessionKey() {
const random = Math.random().toString(36).slice(2, 12)
return `game_${Date.now().toString(36)}_${random}`
}
function readPendingPayloads() {
try {
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
if (!Array.isArray(stored)) return []
return stored.filter(item => (
item
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
&& Number(item.learned_count) >= 0
)).slice(-8)
} catch (_) {
return []
}
}
/**
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
*/
export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
let sessionKey = createSessionKey()
let syncTimer = null
let syncing = false
let queuedPayloads = readPendingPayloads()
let connectPromise = null
function persistPendingPayloads() {
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
}
async function api(request) {
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
return proxy.apiUrl(request, false)
}
function applyLeaderboard(data, confirmedForSession = '') {
if (!data || !Array.isArray(data.players)) return
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
if (!inactiveSessionResponse) {
leaderboard.value = {
...EMPTY_BOARD,
...data,
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
players: data.players
}
}
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
}
connected.value = true
syncStatus.value = 'synced'
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET'
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data)
return res.data
}
async function acceptShare(inviteCode) {
const code = String(inviteCode || '').trim().toUpperCase()
if (!code) return
try {
await api({
url: '/api/tcm/gameAcceptShare',
method: 'POST',
data: { invite_code: code }
})
} catch (_) {
// 分享关系是辅助能力,不阻断进入游戏。
}
}
async function connect(inviteCode = '') {
if (connectPromise) return connectPromise
connectPromise = (async () => {
loading.value = true
try {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('登录失败')
await acceptShare(inviteCode)
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
return false
} finally {
loading.value = false
connectPromise = null
}
})()
return connectPromise
}
function beginSession() {
if (syncTimer) clearTimeout(syncTimer)
sessionKey = createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
learned_count: Math.max(0, Number(learnedCount) || 0),
score: Math.max(0, Number(score) || 0),
ended: ended ? 1 : 0
}
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
if (existingIndex >= 0) {
const existing = queuedPayloads[existingIndex]
queuedPayloads[existingIndex] = {
...nextPayload,
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
score: Math.max(existing.score, nextPayload.score),
ended: Math.max(existing.ended, nextPayload.ended)
}
} else {
queuedPayloads.push(nextPayload)
}
persistPendingPayloads()
syncStatus.value = 'pending'
if (syncTimer) clearTimeout(syncTimer)
if (ended) {
flushProgress()
} else {
syncTimer = setTimeout(flushProgress, 1000)
}
}
async function flushProgress() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
if (syncing || !queuedPayloads.length) return
const payload = queuedPayloads[0]
syncing = true
syncStatus.value = 'syncing'
try {
if (!connected.value) {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('未登录')
}
const res = await api({
url: '/api/tcm/gameSubmitProgress',
method: 'POST',
data: payload
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '成绩保存失败')
}
applyLeaderboard(res.data, payload.session_key)
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
queuedPayloads = queuedPayloads.filter(item => (
item.session_key !== payload.session_key
|| Number(item.learned_count) > Number(payload.learned_count)
|| Number(item.score) > Number(payload.score)
|| Number(item.ended) > Number(payload.ended)
))
persistPendingPayloads()
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
syncing = false
if (queuedPayloads.length && connected.value) {
setTimeout(flushProgress, 80)
}
}
}
async function syncAndRefresh(learnedCount, score) {
queueProgress(learnedCount, score, false)
await flushProgress()
if (!connected.value) {
await connect()
if (queuedPayloads.length) await flushProgress()
} else {
try { await refreshLeaderboard() } catch (_) {}
}
}
async function recordShare() {
try {
if (!connected.value && !(await connect())) return
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
if (res?.code === 1 && res.data?.invite_code) {
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
}
} catch (_) {
// 分享本身仍可进行,统计失败不影响用户。
}
}
function dispose() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
persistPendingPayloads()
}
return {
connected,
loading,
syncStatus,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
queueProgress,
flushProgress,
syncAndRefresh,
refreshLeaderboard,
recordShare,
dispose
}
}
@@ -25,6 +25,13 @@ const SRC = {
* rate 不同可在同一素材上听出明显差异
*/
const SFX = {
// —— 基础素材别名(供分层音效直接调用)——
tap: { src: SRC.tap, volume: 0.32, rate: 1, pool: 3 },
pop: { src: SRC.pop, volume: 0.3, rate: 1, pool: 4 },
fanfare: { src: SRC.fanfare, volume: 0.56, rate: 1, pool: 2 },
blast: { src: SRC.blast, volume: 0.62, rate: 1, pool: 2 },
sparkle: { src: SRC.sparkle, volume: 0.5, rate: 1, pool: 2 },
// —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
@@ -77,6 +84,43 @@ const SFX = {
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
@@ -88,40 +132,42 @@ class SfxPool {
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() {
for (let i = 0; i < this.size; i++) {
const ctx = uni.createInnerAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.volume
try {
ctx.playbackRate = this.rate
} catch (_) {}
this.list.push(ctx)
}
while (this.list.length < this.size) this.createOne()
}
warmUp() {
if (this.warmedUp) return
if (!this.list.length) this.create()
this.list.forEach((ctx) => {
try {
ctx.volume = 0
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = this.volume
} catch (_) {}
}, 60)
} catch (_) {}
})
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.create()
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)
@@ -204,7 +250,8 @@ export function useGameSfx() {
function warmUp() {
if (!enabled.value) return
ensureAudioOption()
Object.keys(SFX).forEach((name) => {
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
names.forEach((name) => {
getPool(name)?.warmUp()
})
}
@@ -0,0 +1,570 @@
.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: grid;
box-sizing: border-box;
grid-template-columns: 156rpx minmax(0, 1fr) 156rpx;
align-items: center;
column-gap: 12rpx;
padding-right: 28rpx;
padding-bottom: 16rpx;
padding-left: 28rpx;
}
.eg-nav-leading { display: flex; width: 156rpx; justify-content: flex-start; }
.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; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
.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-preview.is-offline { color: #69766f; background: #e8eeeb; }
.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-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
.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-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-weekly-share::after { border: 0; }
.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-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-share-btn::after { border: 0; }
.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; }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40 -14
View File
@@ -98,15 +98,17 @@
<view v-if="chartUseMockData" class="st-chart-foot">
<text class="st-chart-mock-hint">示例数据累计记录满 7 天后显示您的真实趋势</text>
</view>
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
<view class="st-game-entry-icon">
<TongjiIcon name="activity" size="sm" color="#006c49" />
<view class="st-game-entry-list">
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
<view class="st-game-entry-icon">
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">识糖小课堂</text>
<text class="st-game-entry-sub">三消认食物学习哪些食物含糖高</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
<view class="st-game-entry-body">
<text class="st-game-entry-title">识糖小课堂</text>
<text class="st-game-entry-sub">认一认哪些食物含糖高</text>
</view>
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
</view>
</view>
<view class="st-training-section">
@@ -193,19 +195,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 +528,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) {
@@ -1492,15 +1514,19 @@ async function goMorePage() {
uni.navigateTo({ url: buildMorePageUrl() })
}
function goGamePage() {
function openGamePage(url) {
uni.navigateTo({
url: '/tongji/pages/game',
url,
fail() {
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
}
})
}
function goEndlessGamePage() {
openGamePage('/tongji/endless-game/index')
}
function navToUser() {
uni.redirectTo({ url: '/pages/user/user' })
}
File diff suppressed because it is too large Load Diff
+258 -1
View File
@@ -639,7 +639,8 @@
}
.more-page .mc-tasks-section {
margin-top: 40rpx;
margin-top: 0;
margin-bottom: 32rpx;
}
.more-page .mc-tasks-grid {
@@ -1167,3 +1168,259 @@
font-size: 22rpx;
color: rgba(108, 122, 113, 0.6);
}
.more-page .mc-tree-species-btn {
display: inline-flex;
align-items: center;
gap: 4rpx;
margin-left: 8rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(0, 108, 73, 0.08);
font-size: 20rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel,
.more-page .mc-species-panel {
width: calc(100% - 48rpx);
max-width: 680rpx;
margin: 0 auto;
position: absolute;
left: 0;
right: 0;
bottom: 0;
max-height: 78vh;
background: var(--surface);
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
}
.more-page .mc-water-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-title {
font-size: 34rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-water-panel-close {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: var(--surface-container-low);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: var(--outline);
}
.more-page .mc-water-panel-summary {
display: block;
font-size: 26rpx;
line-height: 1.5;
color: var(--on-surface-variant);
margin-bottom: 24rpx;
}
.more-page .mc-water-panel-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 20rpx;
}
.more-page .mc-water-panel-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 18rpx 16rpx;
border-radius: 20rpx;
background: var(--surface-container-low);
border: 1rpx solid rgba(0, 108, 73, 0.08);
}
.more-page .mc-water-panel-row.is-water {
border-color: rgba(0, 108, 73, 0.22);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-water-panel-row.is-claimed {
opacity: 0.72;
}
.more-page .mc-water-panel-row-main {
flex: 1;
min-width: 0;
}
.more-page .mc-water-panel-row-name {
display: block;
font-size: 28rpx;
font-weight: 700;
color: var(--on-surface);
}
.more-page .mc-water-panel-row-sub {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-water-panel-row-tag {
flex-shrink: 0;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
}
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
color: #b45309;
}
.more-page .mc-water-panel-growth {
padding: 16rpx 18rpx;
border-radius: 16rpx;
background: rgba(0, 108, 73, 0.06);
margin-bottom: 20rpx;
font-size: 24rpx;
line-height: 1.45;
color: var(--primary);
}
.more-page .mc-water-panel-actions {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.more-page .mc-water-panel-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 700;
background: var(--on-surface);
color: var(--surface);
border: none;
}
.more-page .mc-water-panel-btn.primary {
background: var(--primary);
color: var(--on-primary);
}
.more-page .mc-water-panel-btn.ghost {
background: var(--surface-container-low);
color: var(--on-surface);
}
.more-page .mc-species-panel-tip {
display: block;
font-size: 22rpx;
line-height: 1.45;
color: var(--outline);
margin-bottom: 20rpx;
}
.more-page .mc-species-scroll {
max-height: 56vh;
}
.more-page .mc-species-card {
padding: 20rpx;
border-radius: 24rpx;
border: 2rpx solid rgba(0, 108, 73, 0.1);
background: var(--surface-container-low);
margin-bottom: 16rpx;
}
.more-page .mc-species-card.active {
border-color: var(--primary);
background: rgba(0, 108, 73, 0.06);
}
.more-page .mc-species-card-head {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.more-page .mc-species-card-emoji {
font-size: 56rpx;
line-height: 1;
}
.more-page .mc-species-card-name {
display: block;
font-size: 30rpx;
font-weight: 800;
color: var(--on-surface);
}
.more-page .mc-species-card-tagline {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: var(--outline);
}
.more-page .mc-species-stages {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.more-page .mc-species-stage {
width: calc(20% - 8rpx);
min-width: 96rpx;
padding: 10rpx 6rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.7);
text-align: center;
opacity: 0.45;
}
.more-page .mc-species-stage.reached {
opacity: 1;
}
.more-page .mc-species-stage.current {
box-shadow: 0 0 0 2rpx var(--primary);
background: rgba(0, 108, 73, 0.1);
}
.more-page .mc-species-stage-emoji {
display: block;
font-size: 32rpx;
line-height: 1.1;
}
.more-page .mc-species-stage-lv {
display: block;
font-size: 18rpx;
font-weight: 700;
color: var(--primary);
margin-top: 4rpx;
}
.more-page .mc-species-stage-name {
display: block;
font-size: 18rpx;
color: var(--outline);
margin-top: 2rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -177,7 +177,7 @@
transform: scale(0.98);
}
/* 糖分突袭游戏入口 */
/* 游戏入口 */
.weekly-page .st-game-entry {
margin-top: 24rpx;
@@ -388,8 +388,15 @@
}
/* 识糖入口嵌在趋势卡内:轻量分隔,避免大块留白 */
.weekly-page .st-chart-card .st-game-entry-list {
display: flex;
flex-direction: column;
gap: 12rpx;
margin: 12rpx 20rpx 0;
}
.weekly-page .st-chart-card .st-game-entry--in-card {
margin-top: 12rpx;
margin-top: 0;
padding: 16rpx 20rpx;
gap: 16rpx;
border-radius: 24rpx;
@@ -740,13 +747,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 +779,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.09 一一对应 */
export const TREE_SPECIES_LIST = [
{
id: 'classic',
name: '稳糖灵树',
tagline: '经典路线,从种子到圆满',
stages: TREE_LEVELS.map((l) => l.emoji)
},
{
id: 'bonsai',
name: '雅韵盆景',
tagline: '小巧精致,书桌旁的温柔陪伴',
stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']
},
{
id: 'pine',
name: '苍劲青松',
tagline: '挺拔向上,越记越稳',
stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']
},
{
id: 'sakura',
name: '樱花小树',
tagline: '坚持打卡,枝头渐开',
stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']
},
{
id: 'ginkgo',
name: '金秋银杏',
tagline: '叶色渐变,见证每一天',
stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']
}
]
export const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'
/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */
export const DAILY_MAX_TASK_POINTS = 40
export function getTreeSpecies(id) {
return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]
}
export function speciesEmojiAtLevel(speciesId, level) {
const sp = getTreeSpecies(speciesId)
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
return sp.stages[lv] || sp.stages[0] || '🌱'
}
/** 当前树种成长路线图(供选择面板展示) */
export function speciesGrowthRoadmap(speciesId) {
const sp = getTreeSpecies(speciesId)
return TREE_LEVELS.map((meta, i) => {
const xpTotal = i * TREE_XP_PER_LEVEL
const daysHint = i === 0
? 0
: Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))
return {
level: i,
name: meta.name,
emoji: sp.stages[i] || meta.emoji,
desc: meta.desc,
xpTotal,
daysHint
}
})
}
/** 全勤打卡大约多少天满级 */
export function estimateDaysToMaxLevel() {
const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL
return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))
}
+307
View File
@@ -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(' · ')
}
+13 -1
View File
@@ -1,10 +1,22 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import Optimization from '@uni-ku/bundle-optimizer'
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
// 编译产物默认输出到 ./dist/<mode>/<platform>
export default defineConfig({
plugins: [uni()],
plugins: [
uni(),
Optimization({
enable: {
optimization: true,
'async-import': false,
'async-component': false,
},
dts: false,
logger: false,
}),
],
css: {
preprocessorOptions: {
scss: {
+283
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+285
View File
@@ -0,0 +1,285 @@
import request from '@/utils/request'
export interface MyPatientListParams {
page_no: number
page_size: number
keyword?: string
status_filter?: '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
start_date?: string
end_date?: string
}
export function myPatientLists(params: MyPatientListParams) {
return request.get({ url: '/firstvisit.myPatient/lists', params })
}
export interface MyPatientOrderListParams {
page_no: number
page_size: number
keyword?: string
prescription_audit_status?: '' | number
payment_slip_audit_status?: '' | number
fulfillment_status?: '' | number
start_date?: string
end_date?: string
}
export function myPatientOrderLists(params: MyPatientOrderListParams) {
return request.get({ url: '/firstvisit.myPatient/orders', params })
}
export function myPatientOrderDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.myPatient/orderDetail', params })
}
export function myPatientOrderEdit(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.myPatient/orderEdit', params })
}
export function myPatientOrderAuditPrescription(params: {
id: number
action: 'approve' | 'reject'
remark?: string
}) {
return request.post({ url: '/firstvisit.myPatient/orderAuditPrescription', params })
}
export function myPatientOrderRevokeRxAudit(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/orderRevokeRxAudit', params })
}
export function myPatientOrderAuditPayment(params: {
id: number
action: 'approve' | 'reject'
remark?: string
}) {
return request.post({ url: '/firstvisit.myPatient/orderAuditPayment', params })
}
export function myPatientOrderRevokePayAudit(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/orderRevokePayAudit', params })
}
export function myPatientOrderDdcode(params: {
id: number
express_company: string
tracking_number: string
}) {
return request.post({ url: '/firstvisit.myPatient/orderDdcode', params })
}
export function myPatientOrderShip(params: {
id: number
ship_mode?: 'gancao' | 'direct'
express_company: string
tracking_number: string
}) {
return request.post({ url: '/firstvisit.myPatient/orderShip', params })
}
export function myPatientOrderAddPayOrder(params: {
id: number
order_type: number
pay_amount: number
pay_remark?: string
completion_request?: number
pay_create_type?: 'fubei' | 'express_cod'
}) {
return request.post({ url: '/firstvisit.myPatient/orderAddPayOrder', params })
}
export function myPatientOrderComplete(params: { id: number; fulfillment_status: number }) {
return request.post({ url: '/firstvisit.myPatient/orderComplete', params })
}
export function myPatientOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
return request.post({ url: '/firstvisit.myPatient/orderRefund', params })
}
export function myPatientOrderWithdraw(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/orderWithdraw', params })
}
export function myPatientOrderUploadToPharmacy(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/orderUploadToPharmacy', params })
}
export interface MyPatientProgressListParams {
page_no: number
page_size: number
keyword?: string
status?: '' | 1 | 3 | 4
start_date?: string
end_date?: string
}
export function myPatientProgressLists(params: MyPatientProgressListParams) {
return request.get({ url: '/firstvisit.myPatient/progress', params })
}
export function myPatientCreateAppointment(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.myPatient/createAppointment', params })
}
export function myPatientCancelAppointment(params: { id: number }) {
return request.post({ url: '/firstvisit.myPatient/cancelAppointment', params })
}
export function myPatientAssistants() {
return request.get({ url: '/firstvisit.myPatient/assistants' })
}
export function myPatientAssign(params: { id: number; assistant_id: number; is_inherit?: 0 | 1 }) {
return request.post({ url: '/firstvisit.myPatient/assign', params })
}
export function myPatientFillIdCard(params: { id: number; id_card: string }) {
return request.post({ url: '/firstvisit.myPatient/fillIdCard', params })
}
export interface FirstVisitConversionParams {
time_type: 'today' | 'yesterday' | 'week' | 'month' | 'quarter' | 'year'
dept_id?: number
assistant_id?: number
media_channel_code?: string
}
/** 一诊综合数据转化:服务端按当前角色 DataScope 与所选部门/员工取交集。 */
export function firstVisitConversionOverview(params: FirstVisitConversionParams) {
return request.get(
{ url: '/firstvisit.conversion/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export interface FirstVisitRegistrationStatsParams {
time_type: 'today' | 'week' | 'month'
dept_id?: number
assistant_id?: number
}
/** 一诊挂号统计:部门和员工参数只会在服务端 DataScope 权限范围内继续收窄。 */
export function firstVisitRegistrationStatsOverview(params: FirstVisitRegistrationStatsParams) {
return request.get(
{ url: '/firstvisit.registrationStats/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export interface FirstVisitDoctorDashboardParams {
time_type: 'today' | 'week' | 'month'
dept_id?: number
doctor_id?: number
active_only?: 0 | 1
alert_threshold?: number
}
/** 一诊医生看板:医生与经手医助范围均由服务端根据当前角色和部门权限计算。 */
export function firstVisitDoctorDashboardOverview(params: FirstVisitDoctorDashboardParams) {
return request.get(
{ url: '/firstvisit.doctorDashboard/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export function wecomPromotionOverview() {
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
}
export function wecomPromotionDeletePool(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionCheckApiPermission() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
}
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
}
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
}
export function wecomPromotionDeleteRemoteLink(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deleteRemoteLink', params })
}
export function wecomPromotionToggleLink(params: { id: number; status: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/toggleLink', params })
}
export function wecomPromotionDeleteLink(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
}
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
export interface WecomPromotionCustomerStatsParams {
page_no: number
page_size: number
promotion_link_id?: number
userid?: string
chat_status?: 0 | 1 | 2
}
export interface WecomPromotionCustomerStatsSummary {
customer_count: number
messaged_customer_count: number
message_customer_rate: number
received_message_count: number
message_count_known_count: number
}
export interface WecomPromotionCustomerStatRow {
id?: number
external_userid_masked?: string
customer_id_masked?: string
customer_name_masked?: string
link_id?: number | string
link_name?: string
member_id?: number
member_name?: string
department_name?: string
dept_name?: string
has_messaged?: boolean | number
chat_status?: 0 | 1 | 2
message_count_known?: boolean | number
received_message_count?: number
last_synced_at?: string
last_message_at?: string
}
export interface WecomPromotionCustomerStatsResult {
summary?: Partial<WecomPromotionCustomerStatsSummary>
lists?: WecomPromotionCustomerStatRow[]
total?: number
link_options?: Array<{ id: number | string; name: string }>
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
meta?: { last_synced_at?: string }
}
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
return request.get(
{ url: '/firstvisit.wecomPromotion/customerStatistics', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export function wecomPromotionSyncCustomers(params: { promotion_link_id?: number } = {}) {
return request.post({ url: '/firstvisit.wecomPromotion/syncCustomers', params, timeout: 120000 })
}
+87
View File
@@ -0,0 +1,87 @@
import request from '@/utils/request'
export interface MedicineMappingQuery {
page_no: number
page_size: number
local_name?: string
remote_keyword?: string
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
}
export interface MedicineMappingRow {
local_medicine_id: number
local_name: string
local_unit: string
local_status: number
mapping_id: number | null
mapping_status: number
medicine_code: string | null
remote_name: string | null
remote_brand: string | null
remote_unit: string | null
settlement_price: string | number | null
retail_price: string | number | null
catalog_version: number | null
remote_status: number | null
remote_deleted: number | null
operator_name: string | null
mapping_update_time: number | null
}
export interface CatalogOption {
medicine_code: string
name: string
brand: string
unit: string
settlement_price: string | number
retail_price: string | number
catalog_version: number
status: number
}
export interface PharmacySyncStatus {
sync_enabled: boolean
cursor: number
last_success_time: number
last_failure_time: number
last_error_summary: string
is_syncing: boolean
catalog_total: number
catalog_active: number
unmapped_local: number
}
export interface PharmacySyncResult {
pages: number
pulled: number
received: number
created: number
updated: number
unchanged: number
deactivated: number
cursor: number
}
export function medicineMappingLists(params: MedicineMappingQuery) {
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
}
export function medicineMappingStatus() {
return request.get({ url: '/pharmacy.medicineMapping/status' })
}
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
}
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
return request.post({ url: '/pharmacy.medicineMapping/save', params })
}
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
}
export function medicineCatalogSync() {
return request.post({ url: '/pharmacy.medicineMapping/sync' })
}
+64
View File
@@ -1,5 +1,13 @@
import request from '@/utils/request'
/** 角色数据驾驶舱:服务端统一按当前管理员的数据范围聚合。 */
export function performanceDashboardOverview(params?: { ranking_dept_id?: number; _t?: number }) {
return request.get(
{ url: '/stats.performanceDashboard/overview', params, timeout: 120000 },
{ ignoreCancelToken: true }
)
}
export function getConversionStatsOverview(params: any) {
return request.get({ url: '/stats.conversion/overview', params })
}
@@ -96,6 +104,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 +204,47 @@ 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 autoAssignLogRollback(data: { ids: number[] }) {
return request.post({ url: '/stats.autoAssignLog/rollback', data })
}
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
+62
View File
@@ -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 })
@@ -416,6 +424,15 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
}
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
export function prescriptionOrderDdcode(params: {
id: number
express_company: string
tracking_number: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/ddcode', params })
}
/** 业务订单详情:仅修改关联处方的患者姓名与手机号 */
export function prescriptionOrderPatchPrescriptionPatient(params: {
id: number
@@ -425,6 +442,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'
@@ -446,6 +474,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
}
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
}
/** 撤回支付单审核 */
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
@@ -468,6 +501,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 })
}
@@ -502,6 +537,21 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
}
/** 按业务订单 ship_mode 上传:gancao 走甘草,direct 走洛阳药房 ERP */
export function prescriptionOrderUploadToPharmacy(params: { id: number }) {
return request.post({ url: '/tcm.prescriptionOrder/uploadToPharmacy', params })
}
/** 人工核对甘草不确定提交结果。 */
export function prescriptionOrderConfirmGancaoSubmission(params: {
id: number
resolution: 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED'
remote_order_no?: string
note: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/confirmGancaoSubmission', params })
}
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
export function prescriptionOrderPreviewGancaoRecipel(params: {
id: number
@@ -516,6 +566,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: () => {
@@ -2,7 +2,7 @@
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '' }}</span>
<el-select
v-else
:model-value="modelValue"
:model-value="selectedMedicineId"
class="medicine-name-select w-full"
filterable
remote
@@ -14,7 +14,14 @@
@visible-change="onVisibleChange"
@update:model-value="onUpdate"
>
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.name" />
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
<div class="medicine-name-option">
<span>{{ item.name }}</span>
<span class="medicine-name-option__meta">
{{ item.id > 0 ? [item.supplier, item.unit, `ID ${item.id}`].filter(Boolean).join(' · ') : '历史名称,请重新选择' }}
</span>
</div>
</el-option>
</el-select>
</template>
@@ -24,6 +31,7 @@ import { medicineLists } from '@/api/medicine'
const props = withDefaults(
defineProps<{
modelValue: string
medicineId?: number | null
disabled?: boolean
}>(),
{ disabled: false }
@@ -31,18 +39,30 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:medicineId': [value: number | undefined]
}>()
const loading = ref(false)
const options = ref<{ id: number; name: string }[]>([])
type MedicineOption = { id: number; name: string; supplier?: string; unit?: string }
const options = ref<MedicineOption[]>([])
const selectedMedicineId = computed<number | ''>(() => {
const id = Number(props.medicineId)
if (Number.isInteger(id) && id > 0) {
return id
}
return (props.modelValue || '').trim() ? 0 : ''
})
function ensureCurrentInOptions() {
const v = (props.modelValue || '').trim()
if (!v) {
return
}
if (!options.value.some((o) => o.name === v)) {
options.value = [{ id: 0, name: v }, ...options.value]
const id = Number(props.medicineId)
const currentId = Number.isInteger(id) && id > 0 ? id : 0
if (!options.value.some((o) => o.id === currentId)) {
options.value = [{ id: currentId, name: v }, ...options.value]
}
}
@@ -56,7 +76,7 @@ const remoteMethod = async (query: string) => {
page_size: 100,
status: 1
})
options.value = res.lists || []
options.value = (res.lists || []) as MedicineOption[]
ensureCurrentInOptions()
} catch (e) {
console.error(e)
@@ -72,13 +92,19 @@ const onVisibleChange = (open: boolean) => {
}
}
const onUpdate = (val: string) => {
emit('update:modelValue', val || '')
const onUpdate = (val: number | string) => {
const id = Number(val)
const selected = id > 0 ? options.value.find((item) => item.id === id) : undefined
emit('update:modelValue', selected?.name || '')
emit('update:medicineId', selected?.id)
}
watch(
() => props.modelValue,
() => [props.modelValue, props.medicineId],
() => {
if (!(props.modelValue || '').trim() && Number(props.medicineId) > 0) {
emit('update:medicineId', undefined)
}
ensureCurrentInOptions()
},
{ immediate: true }
@@ -93,4 +119,23 @@ watch(
.medicine-name-select.w-full {
width: 100%;
}
.medicine-name-option {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 60%);
align-items: center;
gap: 16px;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.medicine-name-option__meta {
color: var(--el-text-color-secondary);
font-size: 12px;
text-align: right;
}
</style>
@@ -139,7 +139,12 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<MedicineNameSelect
v-model="formData.herbs[row.index].name"
v-model:medicine-id="formData.herbs[row.index].medicine_id"
: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"
@@ -176,7 +181,12 @@
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
>
<div class="herb-editor-card__title">辅方</div>
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
<MedicineNameSelect
v-model="formData.herbs[row.index].name"
v-model:medicine-id="formData.herbs[row.index].medicine_id"
: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"
@@ -1045,6 +1055,7 @@ import { Search } from '@element-plus/icons-vue'
type FormulaType = '主方' | '辅方'
interface Herb {
medicine_id?: number
name: string
dosage: number
formula_type?: FormulaType
@@ -1071,6 +1082,10 @@ function normalizeHerbRow(raw: any): Herb {
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
if (Number.isInteger(medicineId) && medicineId > 0) {
row.medicine_id = medicineId
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
@@ -1578,8 +1593,8 @@ function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
return herbs
}
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测 */
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
/** 仅有一条完全同名记录时返回稳定药材身份,否则不猜测 */
async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
const q = rawName.trim()
if (!q) return null
try {
@@ -1590,8 +1605,10 @@ async function resolveHerbNameFromLibrary(rawName: string): Promise<string | nul
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
return exact.length === 1
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
: null
} catch {
return null
}
@@ -1612,12 +1629,12 @@ async function handlePasteRecipeImport() {
const resolved: Herb[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolveHerbNameFromLibrary(row.name)
if (!name) {
const medicine = await resolveHerbFromLibrary(row.name)
if (!medicine) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
+16 -2
View File
@@ -8,6 +8,7 @@ interface Options {
params?: Record<any, any>
fixedParams?: Record<any, any>
firstLoading?: boolean
latestOnly?: boolean
}
export function usePaging(options: Options) {
@@ -17,7 +18,8 @@ export function usePaging(options: Options) {
fetchFun,
params = {},
fixedParams = {},
firstLoading = false
firstLoading = false,
latestOnly = false
} = options
// 记录分页初始参数
const paramsInit: Record<any, any> = Object.assign({}, toRaw(params))
@@ -30,8 +32,10 @@ export function usePaging(options: Options) {
lists: [] as any[],
extend: {} as Record<string, any>
})
let latestRequestId = 0
// 请求分页接口;silent: true 时不改 loading(用于定时静默刷新,避免表格闪 loading)
const getLists = (opts?: { silent?: boolean }) => {
const requestId = ++latestRequestId
const silent = opts?.silent === true
if (!silent) {
pager.loading = true
@@ -43,16 +47,26 @@ export function usePaging(options: Options) {
...fixedParams
})
.then((res: any) => {
if (latestOnly && requestId !== latestRequestId) {
return Promise.resolve(res)
}
pager.count = res?.count
pager.lists = res?.lists
pager.extend = res?.extend
return Promise.resolve(res)
})
.catch((err: any) => {
if (latestOnly && requestId !== latestRequestId) {
return Promise.resolve(undefined)
}
return Promise.reject(err)
})
.finally(() => {
if (!silent) {
if (latestOnly) {
if (requestId === latestRequestId) {
pager.loading = false
}
} else if (!silent) {
pager.loading = false
}
})
+40 -5
View File
@@ -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) => {
+4 -2
View File
@@ -89,7 +89,7 @@
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : '已约' }}
{{ slot.available ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
</div>
</div>
</div>
@@ -115,6 +115,7 @@ dayjs.extend(isoWeek)
interface TimeSlot {
time: string
available: boolean
hasAppointment: boolean
quota: number
}
@@ -186,7 +187,7 @@ const filteredTimeSlots = computed(() => {
const slotDateTime = dayjs(`${form.date} ${slot.time}`)
const isPast = slotDateTime.isBefore(now) || slotDateTime.isSame(now, 'minute')
//
//
if (isPast) {
return {
...slot,
@@ -315,6 +316,7 @@ const loadTimeSlots = async (silent = false) => {
timeSlots.value = (response?.slots || []).map((slot: any) => ({
time: slot.time,
available: slot.available,
hasAppointment: Boolean(slot.has_appointment ?? (slot.available === false)),
quota: slot.available ? 1 : 0
}))
} catch (error) {
@@ -0,0 +1,129 @@
<template>
<template v-if="needsReconcile">
<el-button
v-perms="['tcm.prescriptionOrder/confirmGancaoSubmission']"
type="danger"
size="small"
plain
@click="openDialog"
>核对甘草提交</el-button
>
<el-dialog
v-model="visible"
title="人工核对甘草提交"
width="min(92vw, 520px)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<el-alert
title="请先在甘草后台核对。本操作会写入不可变更的审计记录。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="100px" @submit.prevent="submit">
<el-form-item label="核对结果" required>
<el-radio-group v-model="form.resolution">
<el-radio label="CONFIRM_SUCCESS">确认已创建</el-radio>
<el-radio label="CONFIRM_NOT_CREATED">确认未创建</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.resolution === 'CONFIRM_SUCCESS'"
label="甘草单号"
required
>
<el-input v-model="form.remote_order_no" maxlength="64" clearable />
</el-form-item>
<el-form-item label="核对依据" required>
<el-input
v-model="form.note"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="例如:核对时间、甘草后台查询条件及结果"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit"
>确认并记录</el-button
>
</template>
</el-dialog>
</template>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { prescriptionOrderConfirmGancaoSubmission } from '@/api/tcm'
import feedback from '@/utils/feedback'
const props = defineProps<{ order: Record<string, any> | null | undefined }>()
const emit = defineEmits<{ resolved: [] }>()
const needsReconcile = computed(() => {
const target = String(props.order?.pharmacy_claim_target || '')
.trim()
.toLowerCase()
const status = String(props.order?.pharmacy_claim_status || '')
.trim()
.toUpperCase()
const leaseExpiresAt = Number(props.order?.pharmacy_claim_lease_expires_at || 0)
const expiredPending =
status === 'PENDING' &&
leaseExpiresAt > 0 &&
leaseExpiresAt <= Math.floor(Date.now() / 1000)
return (
target === 'gancao' && (expiredPending || ['UNKNOWN', 'PENDING_RECONCILE'].includes(status))
)
})
const visible = ref(false)
const submitting = ref(false)
const form = reactive({
resolution: 'CONFIRM_SUCCESS' as 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED',
remote_order_no: '',
note: ''
})
function openDialog() {
form.resolution = 'CONFIRM_SUCCESS'
form.remote_order_no = ''
form.note = ''
visible.value = true
}
async function submit() {
const id = Number(props.order?.id)
if (!id) return
if (form.resolution === 'CONFIRM_SUCCESS' && !form.remote_order_no.trim()) {
feedback.msgError('请填写甘草药方单号')
return
}
if (!form.note.trim()) {
feedback.msgError('请填写甘草后台核对依据')
return
}
submitting.value = true
try {
await prescriptionOrderConfirmGancaoSubmission({
id,
resolution: form.resolution,
remote_order_no:
form.resolution === 'CONFIRM_SUCCESS' ? form.remote_order_no.trim() : '',
note: form.note.trim()
})
feedback.msgSuccess('甘草提交核对已记录')
visible.value = false
emit('resolved')
} catch {
// Request interceptor presents the server-side reconciliation error.
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,494 @@
/**
* /
*
* order_list.vuePatientOrderList.vue Tab
* PrescriptionOrderDetailDrawer.vue
* /
*/
/** 与诊间医助角色 ID 一致(server 角色表) */
export const TCM_ASSISTANT_ROLE_ID = 2
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
if (!row) return false
if (String(row.gancao_reciperl_order_no || '').trim()) return true
if (String(row.ej_pharmacy_order_no || '').trim()) return true
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
return true
}
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
String(row.pharmacy_claim_status || '').toUpperCase()
)
}
export type SupplyMode = 'gancao' | 'direct' | 'self'
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
if (
String(row?.ship_mode || '')
.trim()
.toLowerCase() === 'direct'
)
return 'direct'
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
return 'self'
}
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
const mode = supplyModeKey(row)
if (mode === 'direct') return '洛阳直发'
return mode === 'gancao' ? '甘草' : '自营'
}
export function supplyModeTagType(
row: Record<string, unknown> | null | undefined
): 'success' | 'warning' | 'info' {
const mode = supplyModeKey(row)
if (mode === 'direct') return 'warning'
return mode === 'gancao' ? 'success' : 'info'
}
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: '甘草下单',
ej_pharmacy_submit: '洛阳药房下单',
ej_pharmacy_callback: '洛阳药房状态',
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 ''
}
/** 服务套餐 dictserver_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
}
+47 -14
View File
@@ -39,7 +39,7 @@
<el-radio-button label="rejected">驳回</el-radio-button>
</el-radio-group>
<span class="pl-toolbar__sep" aria-hidden="true" />
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
<span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
<el-radio-group
v-model="formData.source_filter"
size="small"
@@ -48,7 +48,7 @@
>
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="manual">手工</el-radio-button>
<el-radio-button label="system">系统代开</el-radio-button>
<el-radio-button label="system">空白处方</el-radio-button>
</el-radio-group>
</div>
<div class="pl-toolbar__line pl-toolbar__line--second">
@@ -146,7 +146,7 @@
size="small"
effect="plain"
>
系统代开
空白处方
</el-tag>
<span v-else class="text-gray-400 text-sm">手工</span>
</template>
@@ -290,7 +290,7 @@
type="warning"
size="small"
effect="plain"
>系统代开</el-tag>
>空白处方</el-tag>
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
<el-tag
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
@@ -437,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 }}
@@ -695,7 +696,12 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
<MedicineNameSelect
v-model="editForm.herbs[row.index].name"
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -731,7 +737,12 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
<MedicineNameSelect
v-model="editForm.herbs[row.index].name"
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
:disabled="herbsLocked"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -1293,6 +1304,14 @@
<div v-show="createOrderStep === 1" class="create-order-step-panel">
<el-row :gutter="20">
<el-col v-if="canSelectShipMode" :span="24">
<el-form-item label="发货药房" prop="ship_mode">
<el-radio-group v-model="createOrderForm.ship_mode">
<el-radio-button label="gancao">甘草药房</el-radio-button>
<el-radio-button label="direct">洛阳药房</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item label="复诊">
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
@@ -1696,6 +1715,7 @@ import { roleAll } from '@/api/perms/role'
import { usePaging } from '@/hooks/usePaging'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import type { FormInstance, FormRules } from 'element-plus'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
@@ -1708,7 +1728,7 @@ import jsPDF from 'jspdf'
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
type FormulaType = '主方' | '辅方'
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type HerbRow = { medicine_id?: number; name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
type AuxUsageForm = {
dosage_amount?: number
@@ -1813,6 +1833,10 @@ function normalizeHerbRow(raw: any): HerbRow {
dosage: Number(raw?.dosage) || 0,
formula_type: normalizeFormulaType(raw?.formula_type)
}
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
if (Number.isInteger(medicineId) && medicineId > 0) {
row.medicine_id = medicineId
}
if (raw?.locked === true || raw?.locked === 1) {
row.locked = true
}
@@ -1856,7 +1880,7 @@ const formData = reactive({
creator_ids: [] as number[],
/** all | passed | not_passed | pending | rejectedall 表示不限,接口传参会转为空) */
audit_filter: 'all' as string,
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */
/** all | manual | system — 与 is_system_auto:手工(0) / 空白处方(1) */
source_filter: 'all' as string,
start_time: '',
end_time: ''
@@ -2005,6 +2029,7 @@ const createOrderForm = reactive({
service_package: [] as string[],
express_company: 'auto',
tracking_number: '',
ship_mode: 'gancao' as 'gancao' | 'direct',
fee_type: 3,
amount: 0,
internal_cost: undefined as number | undefined,
@@ -2013,6 +2038,10 @@ const createOrderForm = reactive({
pay_order_ids: [] as number[]
})
//
//
const canSelectShipMode = computed(() => hasPermission(['tcm.prescriptionOrder/setShipMode']))
// API 使
const regionOptions = ref([])
@@ -2251,6 +2280,7 @@ function resetCreateOrderForm() {
createOrderForm.service_package = []
createOrderForm.express_company = 'auto'
createOrderForm.tracking_number = ''
createOrderForm.ship_mode = 'gancao'
createOrderForm.fee_type = 3
createOrderForm.amount = 0
createOrderForm.internal_cost = undefined
@@ -2424,6 +2454,7 @@ async function submitCreateOrderFromPrescription() {
: '',
express_company: createOrderForm.express_company || 'auto',
tracking_number: createOrderForm.tracking_number || '',
ship_mode: createOrderForm.ship_mode,
fee_type: createOrderForm.fee_type,
amount: createOrderForm.amount,
remark_extra: createOrderForm.remark_extra || '',
@@ -3600,7 +3631,7 @@ function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }
return herbs
}
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
const q = rawName.trim()
if (!q) return null
try {
@@ -3611,8 +3642,10 @@ async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string
status: 1
})
const lists = (res.lists || []) as { id: number; name: string }[]
const exact = lists.find((item) => (item.name ?? '').trim() === q)
return exact ? exact.name.trim() : null
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
return exact.length === 1
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
: null
} catch {
return null
}
@@ -3633,12 +3666,12 @@ async function handlePasteRecipeImport() {
const resolved: HerbRow[] = []
const skippedNames: string[] = []
for (const row of parsed) {
const name = await resolvePasteHerbNameFromLibrary(row.name)
if (!name) {
const medicine = await resolvePasteHerbFromLibrary(row.name)
if (!medicine) {
skippedNames.push(row.name.trim())
continue
}
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
}
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
if (resolved.length === 0) {
@@ -157,7 +157,11 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="row.name" :disabled="editMode === 'view'" />
<MedicineNameSelect
v-model="row.name"
v-model:medicine-id="row.medicine_id"
:disabled="editMode === 'view'"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="150">
@@ -257,7 +261,7 @@ const editForm = reactive({
id: 0,
prescription_name: '',
formula_type: '主方',
herbs: [] as Array<{ name: string; dosage: number }>,
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
is_public: 0,
disable_edit: 0
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -231,8 +231,7 @@ import { ref, computed, onMounted } from 'vue'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
import feedback from '@/utils/feedback'
import { adminLists } from '@/api/perms/admin'
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
import { ElMessageBox } from 'element-plus'
dayjs.extend(isoWeek)
@@ -491,10 +490,9 @@ async function removeSegment(row: RosterSegment) {
const loadDoctors = async () => {
try {
const res = await adminLists({
const res = await doctorLists({
page_no: 1,
page_size: 1000,
role_id: 1
page_size: 1000
})
tableData.value = (res?.lists || []).map((doctor: any) => ({
doctorId: doctor.id,
+225 -3
View File
@@ -283,7 +283,15 @@
<td class="col-name">{{ r.name }}</td>
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_count) }}</td>
<td class="col-assign">{{ formatLeaderboardInt(r.assign_count ?? 0) }}</td>
<td class="col-assign" @click.stop="onLeaderboardAssignCellClick(r)">
<span
v-if="Number(r.assign_count ?? 0) > 0"
class="yeji-lead-cell--link"
>{{ formatLeaderboardInt(r.assign_count ?? 0) }}</span>
<template v-else>{{
formatLeaderboardInt(r.assign_count ?? 0)
}}</template>
</td>
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
<span
v-if="Number(r.appointment_count ?? 0) > 0"
@@ -390,7 +398,7 @@
<el-tooltip placement="top" effect="dark" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<b>tcm_diagnosis_assign_log</b> 行数成功指派<b>to_assistant_id &gt; 0</b>** <b>related_po_create_time</b>** 落在所选区间内与日志表快照一致诊单手动指派已写入该字段<b>被指派医助</b>归属展示部门<template v-if="tb.channel_name">
<b>tcm_diagnosis_assign_log</b> 成功指派<b>to_assistant_id &gt; 0</b><b>剔除勾选继承的指派</b><b>指派操作时间</b>落在所选区间内医助 × 诊单去重剔除已删诊单复诊接诊率页面同口径<b>被指派医助</b>归属展示部门<template v-if="tb.channel_name">
选定渠道时收窄与{{ tb.channel_name }}业绩同口径标签诊单或 channels EXISTS<br />
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>
</template>
@@ -561,7 +569,13 @@
>{{ formatInt(r.lead_count) }}</span>
<template v-else>{{ formatInt(r.lead_count) }}</template>
</td>
<td>{{ formatInt(r.assign_count ?? 0) }}</td>
<td @click.stop="onAssignCountCellClick($event, tb, r)">
<span
v-if="yejiAssignCountCellClickable(r)"
class="yeji-lead-cell--link"
>{{ formatInt(r.assign_count ?? 0) }}</span>
<template v-else>{{ formatInt(r.assign_count ?? 0) }}</template>
</td>
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
<span
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
@@ -1356,6 +1370,59 @@
/>
</div>
</el-dialog>
<el-dialog
v-model="assignLinesDialogVisible"
width="min(960px, 96vw)"
destroy-on-close
class="yeji-leadlines-dialog"
>
<template #header>
<div class="yeji-unassigned-dialog__head">
<span class="yeji-unassigned-dialog__title">被指派明细</span>
<p class="yeji-unassigned-dialog__sub">{{ assignLinesSubtitle }}</p>
</div>
</template>
<p v-if="assignLinesApiNote" class="yeji-unassigned-dialog__note">{{ assignLinesApiNote }}</p>
<div v-loading="assignLinesLoading" class="yeji-unassigned-dialog__body">
<el-table
v-if="assignLinesRows.length > 0 || assignLinesLoading"
:data="assignLinesRows"
size="small"
stripe
border
max-height="440"
class="yeji-unassigned-dialog__table"
:empty-text="assignLinesLoading ? '加载中…' : '暂无数据'"
>
<el-table-column type="index" label="#" width="46" :index="assignLinesIndexMethod" />
<el-table-column prop="diagnosis_id" label="诊单ID" width="88" />
<el-table-column prop="patient_name" label="患者" min-width="100" show-overflow-tooltip />
<el-table-column prop="patient_phone" label="联系电话" min-width="120" show-overflow-tooltip />
<el-table-column prop="assistant_name" label="被指派医助" min-width="108" show-overflow-tooltip />
<el-table-column prop="assign_count" label="指派次数" width="88" align="right" />
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="156" show-overflow-tooltip />
</el-table>
<el-empty
v-if="!assignLinesLoading && assignLinesRows.length === 0"
description="暂无被指派明细"
/>
<div v-if="assignLinesCount > 0" class="yeji-leadlines-dialog__pager">
<el-pagination
:current-page="assignLinesPage"
:page-size="assignLinesPageSize"
background
layout="total, sizes, prev, pager, next, jumper"
:total="assignLinesCount"
:page-sizes="[10, 20, 50, 100]"
:disabled="assignLinesLoading"
:hide-on-single-page="false"
@current-change="onAssignLinesPageChange"
@size-change="onAssignLinesPageSizeChange"
/>
</div>
</div>
</el-dialog>
</div>
</template>
@@ -1376,6 +1443,7 @@ import {
yejiStatsLeadLines,
yejiStatsAppointmentLines,
yejiStatsRevisitBreakdown,
yejiStatsAssignLines,
} from '@/api/stats'
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
import { prescriptionOrderLists } from '@/api/tcm'
@@ -1491,6 +1559,17 @@ interface YejiLeadLineRow {
external_contact_name: string
}
interface YejiAssignLineRow {
diagnosis_id: number
patient_name: string
patient_phone: string
assistant_id: number
assistant_name: string
assign_count: number
last_assign_time: number
last_assign_time_text: string
}
interface YejiRow {
dept_id: number
dept_name: string
@@ -1709,6 +1788,31 @@ type AppointmentLinesCtx =
}
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
const assignLinesDialogVisible = ref(false)
const assignLinesLoading = ref(false)
const assignLinesSubtitle = ref('')
const assignLinesApiNote = ref('')
const assignLinesRows = ref<YejiAssignLineRow[]>([])
const assignLinesCount = ref(0)
const assignLinesPage = ref(1)
const assignLinesPageSize = ref(20)
type AssignLinesCtx =
| {
mode: 'assistant'
assistant_id: number
name: string
start_date: string
end_date: string
}
| {
mode: 'dept'
dept_id: number
dept_name: string
start_date: string
end_date: string
}
const assignLinesContext = ref<AssignLinesCtx | null>(null)
const appointmentLinesShowChannelColumn = computed(() =>
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
)
@@ -3193,6 +3297,124 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
void openLeadLinesDialog(tb, row)
}
function yejiAssignCountCellClickable(r: YejiRow): boolean {
return r.dept_id > 0 && Number(r.assign_count ?? 0) > 0
}
function onAssignCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
if (!yejiAssignCountCellClickable(row)) {
return
}
ev.stopPropagation()
void openAssignLinesDialogForDept(tb, row)
}
function assignLinesIndexMethod(index: number) {
return (assignLinesPage.value - 1) * assignLinesPageSize.value + index + 1
}
function buildAssignLinesRequestParams(page: number, pageSize: number): Record<string, string | number> | null {
const ctx = assignLinesContext.value
if (!ctx) {
return null
}
const p: Record<string, string | number> = {
start_date: ctx.start_date,
end_date: ctx.end_date,
page,
page_size: pageSize,
}
if (ctx.mode === 'dept') {
p.dept_id = ctx.dept_id
} else {
p.assistant_id = ctx.assistant_id
}
if (selectedDeptIds.value.length > 0) {
p.dept_ids = selectedDeptIds.value.join(',')
}
if (selectedChannel.value) {
p.channel_code = selectedChannel.value
}
return p
}
async function fetchAssignLinesPage() {
const params = buildAssignLinesRequestParams(assignLinesPage.value, assignLinesPageSize.value)
if (!params) {
return
}
assignLinesLoading.value = true
try {
const res: any = await yejiStatsAssignLines(params as any)
assignLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
assignLinesCount.value = Number(res?.count ?? 0)
assignLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
} catch (e: unknown) {
if (axios.isCancel(e)) return
const any = e as any
ElMessage.error(any?.msg || any?.message || '加载被指派明细失败')
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesApiNote.value = ''
} finally {
assignLinesLoading.value = false
}
}
async function openAssignLinesDialogForDept(tb: YejiTable, row: YejiRow) {
assignLinesContext.value = {
mode: 'dept',
dept_id: row.dept_id,
dept_name: row.dept_name,
start_date: tb.start_date,
end_date: tb.end_date,
}
assignLinesSubtitle.value = `${row.dept_name} · ${tb.start_date} ${tb.end_date}`
assignLinesApiNote.value = ''
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesPage.value = 1
assignLinesPageSize.value = 20
assignLinesDialogVisible.value = true
await fetchAssignLinesPage()
}
function onLeaderboardAssignCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
return
}
if (Number(r.assign_count ?? 0) <= 0) {
return
}
const lb = leaderboardBlock.value
assignLinesContext.value = {
mode: 'assistant',
assistant_id: r.admin_id,
name: r.name,
start_date: lb.start_date,
end_date: lb.end_date,
}
assignLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ${lb.end_date}`
assignLinesApiNote.value = ''
assignLinesRows.value = []
assignLinesCount.value = 0
assignLinesPage.value = 1
assignLinesPageSize.value = 20
assignLinesDialogVisible.value = true
void fetchAssignLinesPage()
}
function onAssignLinesPageChange(p: number) {
assignLinesPage.value = p
void fetchAssignLinesPage()
}
function onAssignLinesPageSizeChange(size: number) {
assignLinesPageSize.value = size
assignLinesPage.value = 1
void fetchAssignLinesPage()
}
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
{ key: 'event_time_text', label: '进线时间' },
{ key: 'reception_admin_name', label: '接待' },
@@ -0,0 +1,582 @@
<template>
<div class="conversion-page" v-loading="loading" element-loading-text="正在汇总权限范围内的数据">
<header class="page-heading">
<div>
<h1>综合数据转化</h1>
<p>{{ scopeDescription }}</p>
</div>
<div class="heading-meta">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
<span v-if="dashboard.meta.generated_at">更新于 {{ dashboard.meta.generated_at }}</span>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
</div>
</header>
<section class="filter-strip">
<div class="filter-item filter-item--time">
<span class="filter-label">时间范围</span>
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
</div>
<div class="filter-item">
<span class="filter-label">搜索员工</span>
<el-select
v-model="query.assistant_id"
clearable
filterable
placeholder="全部员工"
class="employee-select"
@change="loadDashboard"
>
<el-option
v-for="item in dashboard.filters.assistants"
:key="item.id"
:label="item.name"
:value="Number(item.id)"
/>
</el-select>
</div>
<div class="filter-item">
<span class="filter-label">部门</span>
<el-tree-select
v-model="query.dept_id"
:data="dashboard.filters.departments"
:props="deptTreeProps"
node-key="id"
clearable
filterable
check-strictly
default-expand-all
placeholder="全部可见部门"
class="dept-select"
@change="handleDeptChange"
/>
</div>
<div class="filter-item">
<span class="filter-label">渠道</span>
<el-select
v-model="query.media_channel_code"
clearable
filterable
placeholder="全部渠道"
class="channel-select"
@change="loadDashboard"
>
<el-option-group
v-for="group in mediaChannelGroups"
:key="group.group_name || '_'"
:label="`${group.group_name || '(未分组)'} · ${group.customer_count}`"
>
<el-option
v-for="item in group.channels"
:key="item.code"
:label="item.name"
:value="item.code"
>
<span class="channel-option">
<span>{{ item.name }}</span>
<span>{{ item.customer_count }} </span>
</span>
</el-option>
</el-option-group>
</el-select>
</div>
<span class="range-text">{{ dashboard.meta.start_date }} {{ dashboard.meta.end_date }}</span>
</section>
<section class="metric-grid" aria-label="综合转化指标">
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
<small>{{ metric.hint }}</small>
</article>
</section>
<section class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门订单量占比</h2>
<p>按订单创建人归属排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="item in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门金额占比</h2>
<p>仅统计未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="item in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
</article>
</section>
<section class="panel detail-panel">
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/预约接诊率=接诊诊单/加粉</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
<el-table
:data="dashboard.rows"
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
class="detail-table"
>
<el-table-column prop="name" label="部门 / 人员" min-width="250" fixed="left">
<template #default="{ row }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
{{ row.name }}
</strong>
<el-tag v-if="row.type === 'member' && row.role" size="small" type="info" effect="plain" class="role-tag">
{{ row.role }}{{ row.is_leader ? ' · 组长' : '' }}
</el-tag>
<el-tag v-else-if="row.type === 'unbound'" size="small" type="warning" effect="plain" class="role-tag">
未绑定账号
</el-tag>
</template>
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
<el-table-column label="接诊金额" min-width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column>
<el-table-column label="开口率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column>
<el-table-column label="挂号率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
<el-table-column label="ROI" min-width="86" align="right">
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
</el-table-column>
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
</el-table>
</section>
<section class="panel target-panel">
<div class="panel-heading">
<div>
<h2>一诊诊金目标追踪</h2>
<p>{{ dashboard.target.year }} · 按当前角色部门及员工筛选范围计算</p>
</div>
<span>{{ dashboard.target.department_count }} 个目标部门</span>
</div>
<div class="target-layout">
<div class="target-progress-list">
<div class="target-summary">
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
</div>
<div class="progress-block">
<div class="progress-copy">
<span>年度范围目标</span>
<b>{{ nullablePercent(dashboard.target.completion_rate) }}</b>
</div>
<el-progress
:percentage="progressValue(dashboard.target.completion_rate)"
:show-text="false"
:stroke-width="12"
color="#0f9185"
/>
<small>已完成 {{ formatMoney(dashboard.target.actual_amount) }} / 目标 {{ formatMoney(dashboard.target.target_amount) }}</small>
</div>
<div class="progress-block progress-block--month">
<div class="progress-copy">
<span>本月范围目标</span>
<b>{{ nullablePercent(dashboard.target.current_month_rate) }}</b>
</div>
<el-progress
:percentage="progressValue(dashboard.target.current_month_rate)"
:show-text="false"
:stroke-width="12"
color="#2f78df"
/>
<small>已完成 {{ formatMoney(dashboard.target.current_month_actual) }} / 目标 {{ formatMoney(dashboard.target.current_month_target) }}</small>
</div>
<div v-if="Number(dashboard.target.target_amount) <= 0" class="target-empty-note">
当前可见部门尚未维护本年度月度目标实际诊单金额仍会正常统计
</div>
</div>
<div class="trend-wrap">
<div class="trend-title">
<span>年度累计趋势</span>
<div><i class="legend-line is-actual" />实际完成 <i class="legend-line is-target" />目标值</div>
</div>
<v-charts class="target-chart" :option="targetChartOption" autoresize />
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts" name="firstVisitConversionPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Lock, Refresh } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
type MetricType = 'count' | 'money' | 'ratio'
type MediaChannelOption = {
code: string
name: string
tag_id?: string
group_name?: string
customer_count?: number
}
const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
},
filters: {
departments: [] as any[],
assistants: [] as Array<{ id: number; name: string }>,
media_channels: [] as MediaChannelOption[]
},
summary: {} as Record<string, any>,
rankings: { orders: [] as any[], amounts: [] as any[] },
rows: [] as any[],
target: {
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0, completion_rate: null as number | null,
current_month_target: 0, current_month_actual: 0, current_month_rate: null as number | null,
department_count: 0, months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
}
})
const dashboard = reactive(emptyDashboard())
const loading = ref(false)
const query = reactive<FirstVisitConversionParams>({
time_type: 'today',
dept_id: undefined,
assistant_id: undefined,
media_channel_code: ''
})
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '昨天', value: 'yesterday' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '本季度', value: 'quarter' },
{ label: '本年', value: 'year' }
]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
{ key: 'avg_unit_price', label: '平均客单价', type: 'money', hint: '诊单金额 / 接诊诊单' },
{ key: 'account_cost', label: '现金成本', type: 'money', hint: '当前范围内实际投放成本' },
{ key: 'roi', label: 'ROI', type: 'ratio', hint: '诊单金额 / 投放成本' }
]
const scopeDescription = computed(() => {
const parts = [`${dashboard.meta.time_label || '当前区间'}数据`, dashboard.meta.scope_label || '当前权限范围']
if (dashboard.meta.selected_dept_name) parts.push(dashboard.meta.selected_dept_name)
if (dashboard.meta.selected_assistant_name) parts.push(dashboard.meta.selected_assistant_name)
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
return parts.join(' · ')
})
const mediaChannelGroups = computed(() => {
const groups = new Map<string, {
group_name: string
customer_count: number
channels: MediaChannelOption[]
}>()
for (const channel of dashboard.filters.media_channels) {
const groupName = channel.group_name || ''
if (!groups.has(groupName)) {
groups.set(groupName, { group_name: groupName, customer_count: 0, channels: [] })
}
const group = groups.get(groupName)!
group.channels.push(channel)
group.customer_count = Math.max(group.customer_count, Number(channel.customer_count || 0))
}
return Array.from(groups.values())
})
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
const targetChartOption = computed(() => ({
animationDuration: 450,
color: ['#0f9185', '#2f78df'],
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
grid: { left: 62, right: 24, top: 28, bottom: 36 },
xAxis: { type: 'category', boundaryGap: false, data: dashboard.target.months, axisLine: { lineStyle: { color: '#d9e1e9' } }, axisLabel: { color: '#718096' } },
yAxis: { type: 'value', axisLabel: { color: '#718096', formatter: (value: number) => compactNumber(value) }, splitLine: { lineStyle: { color: '#edf1f5' } } },
series: [
{ name: '实际完成', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 3 }, areaStyle: { color: 'rgba(15,145,133,.08)' }, data: dashboard.target.actual_cumulative },
{ name: '目标值', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 2, type: 'dashed' }, data: dashboard.target.target_cumulative }
]
}))
let latestDashboardRequestId = 0
async function loadDashboard() {
const requestId = ++latestDashboardRequestId
loading.value = true
try {
const result: any = await firstVisitConversionOverview({ ...query })
if (requestId !== latestDashboardRequestId) return
Object.assign(dashboard, emptyDashboard(), result || {})
//
query.media_channel_code = dashboard.meta.selected_media_channel_code || ''
} catch (error: any) {
if (requestId !== latestDashboardRequestId) return
ElMessage.error(error?.message || '综合数据加载失败')
} finally {
if (requestId === latestDashboardRequestId) loading.value = false
}
}
function handleDeptChange() {
query.assistant_id = undefined
loadDashboard()
}
function formatMetric(key: string, type: MetricType) {
const value = dashboard.summary[key]
if (type === 'money') return formatMoney(value)
if (type === 'ratio') return formatRatio(value)
return formatNumber(value)
}
function formatNumber(value: any) {
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
}
function formatMoney(value: any) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPercent(value: any) {
return `${Number(value || 0).toFixed(1)}%`
}
function nullablePercent(value: any) {
return value === null || value === undefined ? '未设置' : formatPercent(value)
}
function formatRatio(value: any) {
return Number(value || 0).toFixed(2)
}
function compactNumber(value: number) {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(0)}`
return String(Math.round(value))
}
function barWidth(value: any, maximum: number) {
if (maximum <= 0) return '0%'
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%`
}
function progressValue(value: any) {
return Math.max(0, Math.min(100, Number(value || 0)))
}
onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.conversion-page {
display: grid;
gap: 14px;
min-height: 640px;
padding: 16px;
color: #172033;
background: #f4f6f8;
}
.page-heading,
.filter-strip,
.panel,
.metric-card {
border: 1px solid #dfe5ec;
background: #fff;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 15px 18px;
border-radius: 10px;
h1 { margin: 0; font-size: 20px; font-weight: 750; }
p { margin: 5px 0 0; color: #8590a2; font-size: 12px; }
}
.heading-meta {
display: flex;
align-items: center;
gap: 12px;
color: #8994a5;
font-size: 12px;
}
.scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
color: #0d8077;
}
.filter-strip {
display: flex;
align-items: center;
gap: 22px;
padding: 12px 16px;
border-radius: 9px;
}
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-label, .range-text { color: #748094; font-size: 12px; white-space: nowrap; }
.range-text { margin-left: auto; }
.employee-select { width: 190px; }
.dept-select { width: 220px; }
.channel-select { width: 180px; }
.channel-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
span:last-child {
color: #98a2b3;
font-size: 12px;
}
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.metric-card {
min-height: 100px;
padding: 15px 17px;
border-radius: 10px;
span, small { display: block; color: #748094; font-size: 12px; }
strong { display: block; margin: 9px 0 7px; color: #111b2f; font-size: 25px; line-height: 1; font-variant-numeric: tabular-nums; }
small { color: #a0a9b6; font-size: 11px; }
}
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { padding: 16px; border-radius: 10px; }
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
h2 { margin: 0; font-size: 15px; font-weight: 700; }
p { margin: 4px 0 0; color: #929dac; font-size: 11px; }
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
}
.bar-list { display: grid; gap: 13px; }
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
.bar-track i.is-teal { background: #15998d; }
.bar-track i.is-blue { background: #307bdf; }
.detail-panel { padding-bottom: 10px; }
.detail-table {
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
strong.is-parent { color: #172033; font-weight: 700; }
strong.is-member { color: #314158; font-weight: 600; }
.role-tag { margin-left: 7px; vertical-align: middle; }
}
.target-panel { padding-bottom: 18px; }
.target-layout { display: grid; grid-template-columns: minmax(390px, .82fr) minmax(500px, 1.18fr); gap: 22px; }
.target-progress-list { padding-right: 20px; border-right: 1px solid #e4e9ef; }
.target-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
.target-summary div { display: grid; gap: 5px; }
.target-summary span { color: #8590a2; font-size: 11px; }
.target-summary strong { font-size: 18px; font-variant-numeric: tabular-nums; }
.target-summary strong.is-teal { color: #0f9185; }
.progress-block + .progress-block { margin-top: 18px; }
.progress-copy { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.progress-copy b { color: #263246; font-size: 13px; }
.progress-block small { display: block; margin-top: 7px; color: #8a95a5; font-size: 11px; }
.target-empty-note { margin-top: 16px; padding: 9px 11px; border-radius: 7px; color: #a36b1e; background: #fff7e8; font-size: 11px; }
.trend-title { display: flex; align-items: center; justify-content: space-between; color: #2e3a4d; font-size: 12px; font-weight: 600; }
.trend-title > div { display: flex; align-items: center; gap: 7px; color: #7f8a9b; font-size: 10px; font-weight: 400; }
.legend-line { width: 18px; border-top: 2px solid; }
.legend-line.is-actual { border-color: #0f9185; }
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
.target-chart { width: 100%; height: 260px; }
@media (max-width: 1180px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.range-text { margin-left: 0; }
.target-layout { grid-template-columns: 1fr; }
.target-progress-list { padding-right: 0; padding-bottom: 18px; border-right: 0; border-bottom: 1px solid #e4e9ef; }
}
@media (max-width: 820px) {
.conversion-page { padding: 10px; }
.page-heading, .heading-meta { align-items: flex-start; flex-direction: column; }
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
.target-summary { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,709 @@
<template>
<div class="doctor-dashboard" v-loading="loading" element-loading-text="正在汇总医生经营数据">
<header class="page-heading">
<div class="heading-copy">
<span class="heading-mark"><el-icon><DataLine /></el-icon></span>
<div>
<h1>医生看板</h1>
<p>从挂号预约面诊到接诊成交统一观察医生经营表现</p>
</div>
</div>
<div class="heading-actions">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
<el-button :icon="Download" @click="exportRows">导出</el-button>
</div>
</header>
<section class="filter-strip">
<div class="filter-item filter-item--time">
<span>时间范围</span>
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
</div>
<div v-if="dashboard.filters.can_filter_department" class="filter-item">
<span>所属部门</span>
<el-tree-select
v-model="query.dept_id"
:data="dashboard.filters.departments"
:props="deptTreeProps"
node-key="id"
clearable
filterable
check-strictly
default-expand-all
placeholder="全部可见部门"
class="dept-select"
@change="handleDepartmentChange"
/>
</div>
<div class="filter-item">
<span>医生</span>
<el-select
v-model="query.doctor_id"
clearable
filterable
placeholder="全部医生"
class="doctor-select"
@change="loadDashboard"
>
<el-option
v-for="doctor in dashboard.filters.doctors"
:key="doctor.id"
:value="Number(doctor.id)"
:label="`${doctor.name}${Number(doctor.disable) === 1 ? '(停用)' : ''}`"
/>
</el-select>
</div>
<div class="filter-item filter-item--status">
<span>医生范围</span>
<el-segmented v-model="query.active_only" :options="doctorStatusOptions" @change="handleActiveChange" />
</div>
<div class="view-switch">
<button :class="{ active: viewMode === 'overview' }" @click="viewMode = 'overview'">诊断总览</button>
<button :class="{ active: viewMode === 'detail' }" @click="viewMode = 'detail'">医生明细</button>
</div>
</section>
<section class="metric-grid" aria-label="医生经营核心指标">
<article class="metric-card metric-card--teal">
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
<div>
<span>总挂号</span>
<strong>{{ formatNumber(dashboard.summary.registration_total) }}</strong>
<small>已支付且实收金额大于 0低于 10 元的订单</small>
</div>
</article>
<article class="metric-card metric-card--green">
<div class="metric-icon"><el-icon><User /></el-icon></div>
<div>
<span>总面诊</span>
<strong>{{ formatNumber(dashboard.summary.interview_count) }}</strong>
<small>过号 {{ formatNumber(dashboard.summary.missed_count) }} · 取消 {{ formatNumber(dashboard.summary.cancelled_count) }}</small>
</div>
</article>
<article class="metric-card metric-card--indigo">
<div class="metric-icon"><el-icon><Tickets /></el-icon></div>
<div>
<span>总诊单</span>
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
<small>接诊转化率 {{ formatPercent(dashboard.summary.receive_conversion_rate) }}</small>
</div>
</article>
<article class="metric-card metric-card--blue">
<div class="metric-icon"><el-icon><Money /></el-icon></div>
<div>
<span>总成交金额</span>
<strong>{{ formatMoney(dashboard.summary.deal_amount) }}</strong>
<small>客单价 {{ nullableMoney(dashboard.summary.avg_order_amount) }}</small>
</div>
</article>
<article class="metric-card metric-card--orange">
<div class="metric-icon"><el-icon><DataLine /></el-icon></div>
<div>
<span>总接诊率</span>
<strong>{{ formatPercent(dashboard.summary.receive_conversion_rate) }}</strong>
<small>总接诊 {{ formatNumber(dashboard.summary.order_count) }} ÷ 总面诊 {{ formatNumber(dashboard.summary.interview_count) }}</small>
</div>
</article>
<article class="metric-card metric-card--cyan">
<div class="metric-icon"><el-icon><CircleCheck /></el-icon></div>
<div>
<span>预约完成率</span>
<strong>{{ formatPercent(dashboard.summary.appointment_completion_rate) }}</strong>
<small>总预约 {{ formatNumber(dashboard.summary.appointment_total) }} · {{ dashboard.meta.doctor_count }} 位有数据医生</small>
</div>
</article>
</section>
<template v-if="viewMode === 'overview'">
<section class="two-column-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>成交金额 TOP</h2><p>按有效诊单金额从高到低</p></div>
<span>{{ dashboard.meta.time_label }}</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.doctor_id}`" class="bar-row">
<b>{{ index + 1 }}</b>
<span :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmount) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="52" description="当前范围暂无成交金额" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>接诊转化率 TOP</h2><p>有效诊单数 ÷ 完成面诊数</p></div>
<span>{{ dashboard.meta.time_label }}</span>
</div>
<div v-if="dashboard.rankings.conversion.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.conversion" :key="`rate-${item.doctor_id}`" class="bar-row">
<b>{{ index + 1 }}</b>
<span :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: percentageWidth(item.value) }" /></div>
<strong>{{ formatPercent(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="52" description="当前范围暂无转化数据" />
</article>
</section>
<section class="analytics-grid">
<article class="panel trend-panel">
<div class="panel-heading">
<div><h2> 30 天成交金额趋势</h2><p>{{ dashboard.trend.start_date }} {{ dashboard.trend.end_date }}</p></div>
<span>单位</span>
</div>
<v-charts class="trend-chart" :option="trendChartOption" autoresize />
</article>
</section>
<section class="panel alert-panel" :class="{ 'has-alerts': dashboard.alerts.length }">
<div class="panel-heading alert-heading">
<div>
<h2><el-icon><WarningFilled /></el-icon>需关注医生</h2>
<p>仅对完成过面诊的医生计算接诊转化预警</p>
</div>
<div class="threshold-control">
<span>接诊转化率低于</span>
<el-select v-model="query.alert_threshold" class="threshold-select" @change="loadDashboard">
<el-option v-for="value in thresholdOptions" :key="value" :label="`${value}%`" :value="value" />
</el-select>
</div>
</div>
<div v-if="dashboard.alerts.length" class="alert-list">
<div v-for="item in dashboard.alerts" :key="item.doctor_id" class="alert-row">
<span class="alert-icon">!</span>
<div class="alert-doctor"><strong>{{ item.doctor_name }}</strong><small>{{ item.department_name }}</small></div>
<span class="severity" :class="`is-${item.severity}`">{{ item.severity === 'high' ? '重点关注' : '低于阈值' }}</span>
<span class="alert-data">面诊 {{ item.interview_count }} / 接诊 {{ item.order_count }}</span>
<p>{{ item.suggestion }}</p>
<strong class="alert-rate">{{ formatPercent(item.rate) }}</strong>
</div>
</div>
<div v-else class="alert-empty">
<el-icon><CircleCheck /></el-icon>
当前范围内暂无低于 {{ query.alert_threshold }}% 的医生
</div>
</section>
</template>
<section v-else class="panel detail-panel">
<div class="panel-heading">
<div><h2>医生明细 · 按成交金额排序</h2><p>低于接诊率预警线的医生整行标红过号与取消分别展示</p></div>
<span>{{ businessRows.length }} 位有数据医生</span>
</div>
<el-table
:data="visibleDetailRows"
class="detail-table"
:default-sort="{ prop: 'deal_amount', order: 'descending' }"
:row-class-name="detailRowClassName"
>
<el-table-column prop="doctor_name" label="医生" min-width="130" fixed="left" sortable>
<template #default="{ row }"><strong>{{ row.doctor_name }}</strong></template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="90" sortable>
<template #default="{ row }">
<span class="status-pill" :class="{ disabled: row.status === 'disabled' }">
{{ row.status === 'disabled' ? '停用' : '活跃' }}
</span>
</template>
</el-table-column>
<el-table-column prop="appointment_total" label="预约" min-width="90" sortable />
<el-table-column prop="interview_count" label="面诊" min-width="90" sortable />
<el-table-column prop="order_count" label="接诊" min-width="90" sortable />
<el-table-column prop="receive_conversion_rate" label="接诊率" min-width="110" sortable>
<template #default="{ row }">
<span :class="conversionClass(row.receive_conversion_rate)">
<span v-if="isLowConversion(row)" class="warning-mark"></span>{{ formatPercent(row.receive_conversion_rate) }}
</span>
</template>
</el-table-column>
<el-table-column prop="deal_amount" label="成交金额" min-width="135" sortable>
<template #default="{ row }"><strong class="money-text">{{ formatDetailMoney(row.deal_amount) }}</strong></template>
</el-table-column>
<el-table-column label="过号/取消" min-width="110">
<template #default="{ row }">{{ formatNumber(row.appointment_missed) }}/{{ formatNumber(row.appointment_cancelled) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<el-button class="detail-action" type="primary" link @click="openDoctorDetail(row)">详情</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="当前权限和筛选范围内暂无医生数据" /></template>
</el-table>
<button
v-if="!query.doctor_id && zeroDataRows.length"
type="button"
class="zero-doctor-toggle"
@click="showZeroRows = !showZeroRows"
>
<span>{{ showZeroRows ? '▾' : '▸' }}</span>
{{ showZeroRows ? '收起' : '已隐藏' }} {{ zeroDataRows.length }} 位无业务数据医生{{ showZeroRows ? '' : '(点击展开)' }}
</button>
</section>
<footer class="data-note">
<el-icon><InfoFilled /></el-icon>
<span>{{ dashboard.meta.registration_rule }}{{ dashboard.meta.appointment_rule }}{{ dashboard.meta.performance_rule }}</span>
</footer>
</div>
</template>
<script setup lang="ts" name="firstVisitDoctorDashboardPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
Calendar,
CircleCheck,
DataLine,
Download,
InfoFilled,
Lock,
Money,
Refresh,
Tickets,
User,
WarningFilled
} from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import {
firstVisitDoctorDashboardOverview,
type FirstVisitDoctorDashboardParams
} from '@/api/first_visit'
const emptyDashboard = () => ({
meta: {
time_type: 'month', time_label: '本月', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', scope_kind: '', selected_dept_name: '', selected_doctor_name: '',
doctor_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
},
filters: {
departments: [] as any[], doctors: [] as Array<{ id: number; name: string; disable: number }>,
can_filter_department: true
},
summary: {
registration_total: 0, appointment_total: 0, interview_count: 0, order_count: 0, deal_amount: 0,
avg_order_amount: null as number | null, appointment_completion_rate: null as number | null,
receive_conversion_rate: null as number | null, missed_count: 0, cancelled_count: 0
},
rankings: { amounts: [] as any[], conversion: [] as any[] },
trend: { start_date: '', end_date: '', dates: [] as string[], labels: [] as string[], amounts: [] as number[] },
alerts: [] as any[],
alert_threshold: 15,
rows: [] as any[]
})
const dashboard = reactive(emptyDashboard())
const loading = ref(false)
const viewMode = ref<'overview' | 'detail'>('overview')
const showZeroRows = ref(false)
const query = reactive<FirstVisitDoctorDashboardParams>({
time_type: 'month',
active_only: 1,
alert_threshold: 15
})
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' }
]
const doctorStatusOptions = [
{ label: '仅活跃医生', value: 1 },
{ label: '全部医生', value: 0 }
]
const thresholdOptions = [10, 15, 20, 30]
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
const maxAmount = computed(() => Math.max(0, ...dashboard.rankings.amounts.map((item: any) => Number(item.value) || 0)))
const businessRows = computed(() => dashboard.rows.filter((row: any) => hasBusinessData(row)))
const zeroDataRows = computed(() => dashboard.rows.filter((row: any) => !hasBusinessData(row)))
const visibleDetailRows = computed(() => {
if (query.doctor_id || showZeroRows.value) return dashboard.rows
return businessRows.value
})
const trendChartOption = computed(() => ({
animationDuration: 450,
grid: { left: 20, right: 20, top: 18, bottom: 18, containLabel: true },
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
xAxis: {
type: 'category', boundaryGap: false, data: dashboard.trend.labels,
axisLine: { lineStyle: { color: '#dfe5eb' } }, axisTick: { show: false },
axisLabel: { color: '#7e8a9b', fontSize: 10, interval: 4 }
},
yAxis: {
type: 'value', splitNumber: 4,
axisLabel: { color: '#8793a2', fontSize: 10, formatter: (value: number) => compactMoney(value) },
splitLine: { lineStyle: { color: '#eef2f5' } }
},
series: [{
name: '成交金额', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
data: dashboard.trend.amounts,
lineStyle: { color: '#139a8c', width: 3 },
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
areaStyle: { color: 'rgba(19,154,140,.08)' }
}]
}))
async function loadDashboard() {
loading.value = true
try {
const params: FirstVisitDoctorDashboardParams = {
time_type: query.time_type,
active_only: query.active_only ?? 1,
alert_threshold: Number(query.alert_threshold || 15)
}
if (query.dept_id) params.dept_id = Number(query.dept_id)
if (query.doctor_id) params.doctor_id = Number(query.doctor_id)
const result: any = await firstVisitDoctorDashboardOverview(params)
Object.assign(dashboard, emptyDashboard(), result || {})
} catch (error: any) {
ElMessage.error(error?.message || '医生看板加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function handleDepartmentChange() {
delete query.doctor_id
loadDashboard()
}
function handleActiveChange() {
delete query.doctor_id
loadDashboard()
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
}
function formatMoney(value: unknown) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatDetailMoney(value: unknown) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
}
function nullableMoney(value: unknown) {
return value === null || value === undefined ? '—' : formatMoney(value)
}
function formatPercent(value: unknown) {
return value === null || value === undefined ? '—' : `${Number(value).toFixed(1)}%`
}
function compactMoney(value: number) {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}`
return `${Math.round(value)}`
}
function barWidth(value: unknown, max: number) {
if (max <= 0) return '0%'
return `${Math.max(3, Math.min(100, Number(value || 0) / max * 100))}%`
}
function percentageWidth(value: unknown) {
return `${Math.max(0, Math.min(100, Number(value) || 0))}%`
}
function conversionClass(value: unknown) {
if (value === null || value === undefined) return 'conversion-rate is-neutral'
return Number(value) < Number(query.alert_threshold || 15) ? 'conversion-rate is-low' : 'conversion-rate is-good'
}
function hasBusinessData(row: any) {
return Number(row.appointment_total || 0) > 0
|| Number(row.interview_count || 0) > 0
|| Number(row.order_count || 0) > 0
|| Number(row.deal_amount || 0) > 0
|| Number(row.appointment_missed || 0) > 0
|| Number(row.appointment_cancelled || 0) > 0
}
function isLowConversion(row: any) {
return Number(row.interview_count || 0) > 0
&& Number(row.receive_conversion_rate || 0) < Number(query.alert_threshold || 15)
}
function detailRowClassName({ row }: { row: any }) {
if (isLowConversion(row)) return 'is-conversion-warning'
if (!hasBusinessData(row)) return 'is-zero-data'
return ''
}
async function openDoctorDetail(row: any) {
query.doctor_id = Number(row.doctor_id)
viewMode.value = 'overview'
showZeroRows.value = false
await loadDashboard()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function csvCell(value: unknown) {
return `"${String(value ?? '').replace(/"/g, '""')}"`
}
function exportRows() {
if (!dashboard.rows.length) {
ElMessage.warning('当前范围暂无可导出的医生数据')
return
}
const headers = ['医生', '状态', '预约', '面诊', '接诊', '接诊率', '成交金额', '过号', '取消']
const lines = dashboard.rows.map((row: any) => [
row.doctor_name, row.status === 'disabled' ? '停用' : '活跃', row.appointment_total,
row.interview_count, row.order_count, formatPercent(row.receive_conversion_rate), row.deal_amount,
row.appointment_missed, row.appointment_cancelled
].map(csvCell).join(','))
const blob = new Blob([`\uFEFF${headers.map(csvCell).join(',')}\n${lines.join('\n')}`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `医生看板_${dashboard.meta.start_date}_${dashboard.meta.end_date}.csv`
link.click()
URL.revokeObjectURL(url)
}
onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.doctor-dashboard {
--ink: #17243a;
--muted: #768497;
--line: #e2e8ee;
--canvas: #f4f6f8;
--teal: #139a8c;
--blue: #3d78e7;
min-height: 100%;
padding: 18px;
color: var(--ink);
background: var(--canvas);
}
.page-heading,
.filter-strip,
.metric-card,
.panel { border: 1px solid var(--line); background: #fff; }
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 76px;
padding: 14px 18px;
border-radius: 13px;
}
.heading-copy, .heading-actions, .filter-item, .threshold-control { display: flex; align-items: center; }
.heading-copy { gap: 12px; }
.heading-mark {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 11px;
color: #fff;
background: var(--teal);
box-shadow: 0 8px 20px rgba(19, 154, 140, .16);
font-size: 20px;
}
h1, h2, p { margin: 0; }
h1 { font-size: 20px; line-height: 1.3; }
h2 { font-size: 15px; line-height: 1.4; }
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
.heading-actions { gap: 10px; color: var(--muted); font-size: 12px; }
.scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 9px;
border: 1px solid #cce8e3;
border-radius: 7px;
color: #117f75;
background: #f2faf8;
}
.filter-strip {
display: flex;
align-items: center;
gap: 20px;
margin-top: 14px;
padding: 11px 14px;
border-radius: 11px;
}
.filter-item { gap: 8px; color: #68778a; font-size: 12px; }
.dept-select { width: 190px; }
.doctor-select { width: 160px; }
.view-switch { display: flex; margin-left: auto; padding-left: 12px; border-left: 1px solid #e6ebef; }
.view-switch button {
min-width: 82px;
padding: 9px 12px;
border: 0;
border-bottom: 2px solid transparent;
color: #657488;
background: transparent;
cursor: pointer;
}
.view-switch button.active { border-bottom-color: var(--teal); color: #0e8277; font-weight: 700; }
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.metric-card {
display: flex;
align-items: center;
gap: 12px;
min-height: 105px;
padding: 16px;
border-radius: 11px;
}
.metric-icon {
display: grid;
width: 40px;
height: 40px;
flex: 0 0 40px;
place-items: center;
border-radius: 10px;
font-size: 20px;
}
.metric-card--teal .metric-icon { color: #0b887c; background: #e8f7f4; }
.metric-card--green .metric-icon { color: #37a465; background: #eef8f1; }
.metric-card--indigo .metric-icon { color: #556dde; background: #eef0fd; }
.metric-card--blue .metric-icon { color: #3976e6; background: #edf3ff; }
.metric-card--orange .metric-icon { color: #d77a2c; background: #fff4e9; }
.metric-card--cyan .metric-icon { color: #138da1; background: #eaf7f8; }
.metric-card span { display: block; color: #748296; font-size: 12px; }
.metric-card strong { display: block; margin: 4px 0 3px; font-size: 24px; line-height: 1.1; }
.metric-card small { color: #8995a4; font-size: 10px; }
.two-column-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 16px 11px;
}
.panel-heading > span { color: #8a96a5; font-size: 11px; }
.ranking-panel { min-height: 284px; }
.bar-list { padding: 0 16px 15px; }
.bar-row {
display: grid;
grid-template-columns: 24px minmax(80px, .35fr) minmax(140px, 1fr) 110px;
align-items: center;
gap: 9px;
min-height: 34px;
}
.bar-row > b { color: #8793a2; text-align: center; font-size: 10px; }
.bar-row > span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-row > strong { text-align: right; font-size: 12px; }
.bar-track { height: 9px; overflow: hidden; border-radius: 6px; background: #edf1f4; }
.bar-track i { display: block; height: 100%; border-radius: inherit; }
.bar-track .is-blue { background: var(--blue); }
.bar-track .is-teal { background: var(--teal); }
.analytics-grid { display: grid; grid-template-columns: minmax(0, 1fr); }
.analytics-grid .panel { min-height: 300px; }
.trend-chart { width: 100%; height: 238px; }
.alert-panel { border-color: #dfe8e7; }
.alert-panel.has-alerts { border-color: #efc4c4; }
.alert-heading h2 { display: flex; align-items: center; gap: 6px; }
.alert-heading h2 .el-icon { color: #ee5b5b; }
.threshold-control { gap: 8px; color: #768497; font-size: 11px; }
.threshold-select { width: 88px; }
.alert-list { padding: 0 14px 14px; }
.alert-row {
display: grid;
grid-template-columns: 30px minmax(120px, .45fr) 82px 135px minmax(220px, 1fr) 65px;
align-items: center;
gap: 10px;
min-height: 54px;
margin-top: 8px;
padding: 0 12px;
border: 1px solid #f0dddd;
border-radius: 9px;
background: #fffafa;
}
.alert-icon { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 50%; color: #fff; background: #ef5a5a; font-weight: 700; }
.alert-doctor strong, .alert-doctor small { display: block; }
.alert-doctor strong { font-size: 12px; }
.alert-doctor small { margin-top: 2px; color: #8d98a6; font-size: 10px; }
.severity { padding: 4px 7px; border-radius: 5px; text-align: center; font-size: 10px; }
.severity.is-high { color: #d84444; background: #ffe9e9; }
.severity.is-medium { color: #c77726; background: #fff1df; }
.alert-data { color: #67768a; font-size: 11px; }
.alert-row p { color: #7b8797; font-size: 11px; }
.alert-rate { color: #e34949; text-align: right; font-size: 15px; }
.alert-empty { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 94px; color: #4f8f77; font-size: 12px; }
.alert-empty .el-icon { font-size: 20px; }
.detail-panel { min-height: 360px; }
.detail-table { border-top: 1px solid #edf1f4; --el-table-header-bg-color: #f7f9fb; }
.detail-table :deep(th.el-table__cell) { height: 43px; color: #68778a; font-weight: 500; }
.detail-table :deep(td.el-table__cell) { height: 47px; }
.detail-table :deep(.is-conversion-warning > td.el-table__cell) { background: #fff0f0 !important; }
.detail-table :deep(.is-zero-data > td.el-table__cell) { color: #98a2af; background: #fafbfc !important; }
.money-text { color: #246bd3; }
.conversion-rate.is-low { color: #e04d4d; font-weight: 700; }
.conversion-rate.is-good { color: #168f72; }
.conversion-rate.is-neutral { color: #919cab; }
.warning-mark { margin-right: 3px; color: #ec4e4e; font-size: 10px; }
.status-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 9px;
border-radius: 11px;
color: #16895f;
background: #eaf8ef;
font-size: 11px;
}
.status-pill.disabled { color: #9a6570; background: #f4ecee; }
.detail-action { font-size: 12px; }
.zero-doctor-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 10px 16px 15px;
padding: 4px 9px;
border: 0;
border-radius: 11px;
color: #6e7d90;
background: #eef3f6;
cursor: pointer;
font-size: 11px;
}
.zero-doctor-toggle:hover { color: #0e8277; background: #e7f4f1; }
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
@media (max-width: 1280px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.view-switch { margin-left: 0; }
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.doctor-dashboard { padding: 10px; }
.page-heading { align-items: flex-start; flex-direction: column; }
.heading-actions { flex-wrap: wrap; }
.update-time { display: none; }
.metric-grid, .two-column-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 92px; }
.filter-item { width: 100%; justify-content: space-between; }
.dept-select, .doctor-select { width: calc(100% - 78px); }
.filter-item--time, .filter-item--status { justify-content: flex-start; }
.view-switch { width: 100%; justify-content: flex-end; border-left: 0; }
.alert-row { grid-template-columns: 30px 1fr 80px 65px; padding: 10px; }
.alert-data, .alert-row p { grid-column: 2 / -1; }
}
</style>
@@ -0,0 +1,684 @@
<template>
<prescription-order-detail-drawer
ref="detailDrawerRef"
readonly
append-to-body
:detail-loader="myPatientOrderDetail"
:load-related-data="false"
>
<template #header-actions="{ detail }">
<el-dropdown
v-if="myPatientOrderActions(detail).length"
trigger="click"
@command="(command) => openAction(detail, command as MyPatientOrderAction)"
>
<el-button type="primary" size="small" plain>
订单操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="action in myPatientOrderActions(detail)"
:key="action.key"
:command="action.key"
:class="{ 'danger-menu-item': action.danger }"
>
{{ action.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</prescription-order-detail-drawer>
<el-dialog v-model="auditVisible" :title="auditTitle" width="480px" append-to-body destroy-on-close>
<el-alert
title="通过时审核意见可不填;驳回时必须填写明确原因。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-input
v-model="auditRemark"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入审核意见"
/>
<template #footer>
<el-button @click="auditVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitAudit('approve')">同意通过</el-button>
<el-button type="danger" :loading="submitting" @click="submitAudit('reject')">驳回</el-button>
</template>
</el-dialog>
<el-dialog v-model="trackVisible" title="修改快递单号" width="440px" append-to-body destroy-on-close>
<el-form label-width="92px" @submit.prevent="submitTrack">
<el-form-item label="承运商">
<el-select v-model="trackForm.express_company" class="w-full">
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="快递单号" required>
<el-input v-model="trackForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="trackVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitTrack">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="shipVisible" title="确认发货" width="460px" append-to-body destroy-on-close>
<el-alert
title="确认后订单将进入“已发货”,请先核对发货方式和快递信息。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="92px" @submit.prevent="submitShip">
<el-form-item label="发货方式">
<el-tag :type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'" effect="plain">
{{ shipForm.ship_mode === 'direct' ? '洛阳药房' : '甘草药房' }}
</el-tag>
</el-form-item>
<el-form-item label="承运商">
<el-select v-model="shipForm.express_company" class="w-full">
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="快递单号" required>
<el-input v-model="shipForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="shipVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button>
</template>
</el-dialog>
<el-dialog v-model="editVisible" title="编辑订单" width="680px" append-to-body destroy-on-close>
<el-form v-loading="editLoading" label-width="104px" class="order-edit-form">
<div class="form-grid">
<el-form-item label="收货人" required>
<el-input v-model="editForm.recipient_name" maxlength="50" />
</el-form-item>
<el-form-item label="收货手机" required>
<el-input v-model="editForm.recipient_phone" maxlength="20" />
</el-form-item>
<el-form-item label="费用类别" required>
<el-select v-model="editForm.fee_type" class="w-full">
<el-option v-for="item in feeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="订单金额" required>
<el-input-number v-model="editForm.amount" :min="0" :precision="2" :step="10" class="w-full" />
</el-form-item>
</div>
<el-form-item label="收货地址" required>
<el-input v-model="editForm.shipping_address" maxlength="500" />
</el-form-item>
<div class="form-grid">
<el-form-item label="承运商">
<el-select v-model="editForm.express_company" class="w-full">
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="快递单号">
<el-input v-model="editForm.tracking_number" maxlength="80" clearable />
</el-form-item>
</div>
<el-form-item label="医助备注">
<el-input v-model="editForm.remark_assistant" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item label="药房备注">
<el-input v-model="editForm.remark_extra" type="textarea" :rows="2" maxlength="500" show-word-limit />
</el-form-item>
<el-alert
title="保存后会沿用原订单编辑规则;非已发货订单的支付审核将重新变为待审核。"
type="info"
:closable="false"
show-icon
/>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="editLoading" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="payVisible" title="补齐支付单" width="520px" append-to-body destroy-on-close>
<el-alert
:title="`订单金额 ¥${money(actionRow.amount)},已关联支付 ¥${money(actionRow.linked_pay_paid_total)}`"
type="info"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="108px">
<el-form-item label="支付单类型">
<el-select v-model="payForm.order_type" class="w-full">
<el-option v-for="item in payTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建方式">
<el-radio-group v-model="payForm.pay_create_type">
<el-radio value="fubei">付呗支付</el-radio>
<el-radio value="express_cod">快递代收</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="补齐金额" required>
<el-input-number v-model="payForm.pay_amount" :min="0" :precision="2" :step="10" class="w-full" />
</el-form-item>
<el-form-item label="支付备注">
<el-input v-model="payForm.pay_remark" type="textarea" :rows="3" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="完单申请">
<el-switch v-model="payForm.completion_request" :active-value="1" :inactive-value="0" />
<span class="form-tip">同时提交完单申请</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="payVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitPayOrder">确认新增</el-button>
</template>
</el-dialog>
<el-dialog v-model="completeVisible" title="完成订单" width="480px" append-to-body destroy-on-close>
<el-alert
title="请选择真实履约结果。退款必须使用单独的“退款”操作。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="100px">
<el-form-item label="结案状态" required>
<el-select v-model="completeStatus" class="w-full">
<el-option v-for="item in completeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="completeVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitComplete">确认完成</el-button>
</template>
</el-dialog>
<el-dialog v-model="refundVisible" title="订单退款" width="500px" append-to-body destroy-on-close>
<el-alert
title="退款会同步更新关联支付单和订单金额;退款金额留空时按系统计算的最大可退金额处理。"
type="warning"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-width="100px">
<el-form-item label="退款原因" required>
<el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item label="退款金额">
<el-input-number
v-model="refundForm.refund_amount"
:min="0"
:precision="2"
:step="10"
class="w-full"
placeholder="留空则退最大可退金额"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refundVisible = false">取消</el-button>
<el-button type="danger" :loading="submitting" @click="submitRefund">确认退款</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import feedback from '@/utils/feedback'
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
import {
myPatientOrderAddPayOrder,
myPatientOrderAuditPayment,
myPatientOrderAuditPrescription,
myPatientOrderComplete,
myPatientOrderDdcode,
myPatientOrderDetail,
myPatientOrderEdit,
myPatientOrderRefund,
myPatientOrderRevokePayAudit,
myPatientOrderRevokeRxAudit,
myPatientOrderShip,
myPatientOrderUploadToPharmacy,
myPatientOrderWithdraw
} from '@/api/first_visit'
import { myPatientOrderActions, type MyPatientOrderAction } from './order-actions'
const emit = defineEmits<{ changed: [] }>()
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const actionRow = ref<Record<string, any>>({})
const submitting = ref(false)
const expressOptions = [
{ label: '自动识别', value: 'auto' },
{ label: '顺丰速运', value: 'sf' },
{ label: '京东快递', value: 'jd' },
{ label: '极兔速递', value: 'jt' }
]
const feeTypeOptions = [
{ label: '挂号', value: 1 },
{ label: '问诊', value: 2 },
{ label: '药品', value: 3 },
{ label: '首付', value: 4 },
{ label: '尾款', value: 5 },
{ label: '其他', value: 6 },
{ label: '全部', value: 7 },
{ label: '代收', value: 8 }
]
const payTypeOptions = [
{ label: '药品', value: 3 },
{ label: '尾款', value: 5 },
{ label: '其他', value: 6 }
]
const completeOptions = [
{ label: '已完成', value: 3 },
{ label: '进行中', value: 7 },
{ label: '暂不制药', value: 8 },
{ label: '拒收', value: 9 },
{ label: '保留药方', value: 11 },
{ label: '制药缓发', value: 12 }
]
const auditVisible = ref(false)
const auditKind = ref<'prescription' | 'payment'>('prescription')
const auditRemark = ref('')
const auditTitle = computed(() => auditKind.value === 'prescription' ? '处方业务审核' : '关联支付单审核')
const trackVisible = ref(false)
const trackForm = reactive({ express_company: 'auto', tracking_number: '' })
const shipVisible = ref(false)
const shipForm = reactive<{ ship_mode: 'gancao' | 'direct'; express_company: string; tracking_number: string }>({
ship_mode: 'gancao',
express_company: 'auto',
tracking_number: ''
})
const editVisible = ref(false)
const editLoading = ref(false)
const editSource = ref<Record<string, any>>({})
const editForm = reactive({
recipient_name: '',
recipient_phone: '',
shipping_address: '',
fee_type: 3,
amount: 0,
express_company: 'auto',
tracking_number: '',
remark_assistant: '',
remark_extra: ''
})
const payVisible = ref(false)
const payForm = reactive<{
order_type: number
pay_amount: number
pay_remark: string
completion_request: number
pay_create_type: 'fubei' | 'express_cod'
}>({ order_type: 3, pay_amount: 0, pay_remark: '', completion_request: 0, pay_create_type: 'fubei' })
const completeVisible = ref(false)
const completeStatus = ref(3)
const refundVisible = ref(false)
const refundForm = reactive<{ reason: string; refund_amount: number | undefined }>({
reason: '',
refund_amount: undefined
})
function money(value: unknown) {
return Number(value || 0).toFixed(2)
}
function normalizeShipMode(value: unknown): 'gancao' | 'direct' {
return String(value || '').toLowerCase() === 'direct' ? 'direct' : 'gancao'
}
function openDetail(row: Record<string, any>) {
detailDrawerRef.value?.open(Number(row.id))
}
async function fetchDetail(id: number) {
const response: any = await myPatientOrderDetail({ id })
return response?.data ?? response ?? null
}
async function openEdit(row: Record<string, any>) {
actionRow.value = row
editVisible.value = true
editLoading.value = true
try {
const detail = await fetchDetail(Number(row.id))
if (!detail) throw new Error('订单详情加载失败')
editSource.value = detail
editForm.recipient_name = String(detail.recipient_name || '')
editForm.recipient_phone = String(detail.recipient_phone || '')
editForm.shipping_address = String(detail.shipping_address || '')
editForm.fee_type = Number(detail.fee_type || 3)
editForm.amount = Number(detail.amount || 0)
editForm.express_company = String(detail.express_company || 'auto') || 'auto'
editForm.tracking_number = String(detail.tracking_number || '')
editForm.remark_assistant = String(detail.remark_assistant || '')
editForm.remark_extra = String(detail.remark_extra || '')
} catch (error: any) {
feedback.msgError(error?.message || '订单详情加载失败')
editVisible.value = false
} finally {
editLoading.value = false
}
}
function openAction(row: Record<string, any>, action: MyPatientOrderAction) {
actionRow.value = row
switch (action) {
case 'edit':
void openEdit(row)
break
case 'audit_prescription':
case 'audit_payment':
auditKind.value = action === 'audit_prescription' ? 'prescription' : 'payment'
auditRemark.value = ''
auditVisible.value = true
break
case 'revoke_rx_audit':
void confirmRevokeAudit('prescription')
break
case 'revoke_pay_audit':
void confirmRevokeAudit('payment')
break
case 'ddcode':
trackForm.express_company = String(row.express_company || 'auto') || 'auto'
trackForm.tracking_number = String(row.tracking_number || '')
trackVisible.value = true
break
case 'ship':
shipForm.ship_mode = normalizeShipMode(row.ship_mode)
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
shipForm.tracking_number = String(row.tracking_number || '')
shipVisible.value = true
break
case 'add_pay_order': {
const diff = Math.max(0, Number(row.amount || 0) - Number(row.linked_pay_paid_total || 0))
payForm.order_type = 3
payForm.pay_amount = Number(diff.toFixed(2))
payForm.pay_remark = ''
payForm.completion_request = 0
payForm.pay_create_type = 'fubei'
payVisible.value = true
break
}
case 'complete':
completeStatus.value = 3
completeVisible.value = true
break
case 'refund':
refundForm.reason = ''
refundForm.refund_amount = undefined
refundVisible.value = true
break
case 'withdraw':
void confirmWithdraw()
break
case 'upload_pharmacy':
void confirmUploadPharmacy()
break
}
}
async function changed(message: string) {
feedback.msgSuccess(message)
emit('changed')
await detailDrawerRef.value?.refreshIfCurrent(actionRow.value.id)
}
async function submitAudit(action: 'approve' | 'reject') {
if (action === 'reject' && !auditRemark.value.trim()) {
feedback.msgError('驳回时必须填写审核意见')
return
}
submitting.value = true
try {
const params = { id: Number(actionRow.value.id), action, remark: auditRemark.value.trim() }
if (auditKind.value === 'prescription') await myPatientOrderAuditPrescription(params)
else await myPatientOrderAuditPayment(params)
auditVisible.value = false
await changed(action === 'approve' ? '审核通过成功' : '订单已驳回')
} catch {
//
} finally {
submitting.value = false
}
}
async function confirmRevokeAudit(kind: 'prescription' | 'payment') {
try {
await ElMessageBox.confirm(
`确认撤回${kind === 'prescription' ? '处方' : '支付单'}审核并恢复为待审核吗?`,
'撤回审核',
{ type: 'warning', confirmButtonText: '确认撤回', cancelButtonText: '取消' }
)
if (kind === 'prescription') await myPatientOrderRevokeRxAudit({ id: Number(actionRow.value.id) })
else await myPatientOrderRevokePayAudit({ id: Number(actionRow.value.id) })
await changed('审核已撤回')
} catch {
//
}
}
async function submitTrack() {
if (!trackForm.tracking_number.trim()) {
feedback.msgError('请填写快递单号')
return
}
submitting.value = true
try {
await myPatientOrderDdcode({
id: Number(actionRow.value.id),
express_company: trackForm.express_company || 'auto',
tracking_number: trackForm.tracking_number.trim()
})
trackVisible.value = false
await changed('快递单号已保存')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitShip() {
if (!shipForm.tracking_number.trim()) {
feedback.msgError('请填写快递单号')
return
}
submitting.value = true
try {
await myPatientOrderShip({
id: Number(actionRow.value.id),
ship_mode: shipForm.ship_mode,
express_company: shipForm.express_company || 'auto',
tracking_number: shipForm.tracking_number.trim()
})
shipVisible.value = false
await changed('确认发货成功')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitEdit() {
if (!editForm.recipient_name.trim() || !editForm.recipient_phone.trim() || !editForm.shipping_address.trim()) {
feedback.msgError('请完整填写收货人、收货手机和收货地址')
return
}
if (Number(editForm.amount) < 0) {
feedback.msgError('订单金额不能为负数')
return
}
const source = editSource.value
submitting.value = true
try {
await myPatientOrderEdit({
id: Number(actionRow.value.id),
recipient_name: editForm.recipient_name.trim(),
recipient_phone: editForm.recipient_phone.trim(),
shipping_province: source.shipping_province || '',
shipping_city: source.shipping_city || '',
shipping_district: source.shipping_district || '',
shipping_address: editForm.shipping_address.trim(),
is_follow_up: Number(source.is_follow_up || 0),
medication_days: source.medication_days,
dose_unit: source.dose_unit || '剂',
dose_count: Number(source.dose_count || 1),
prev_staff: source.prev_staff || '',
service_channel: source.service_channel || '',
service_package: source.service_package || '',
tracking_number: editForm.tracking_number.trim(),
express_company: editForm.express_company || 'auto',
fee_type: Number(editForm.fee_type),
amount: Number(editForm.amount),
remark_extra: editForm.remark_extra.trim(),
remark_assistant: editForm.remark_assistant.trim(),
pay_order_ids: Array.isArray(source.pay_order_ids) ? source.pay_order_ids : []
})
editVisible.value = false
await changed('订单保存成功')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitPayOrder() {
if (!(Number(payForm.pay_amount) > 0)) {
feedback.msgError('补齐金额必须大于 0')
return
}
submitting.value = true
try {
await myPatientOrderAddPayOrder({
id: Number(actionRow.value.id),
order_type: Number(payForm.order_type),
pay_amount: Number(payForm.pay_amount),
pay_remark: payForm.pay_remark.trim(),
completion_request: Number(payForm.completion_request),
pay_create_type: payForm.pay_create_type
})
payVisible.value = false
await changed('支付单已新增,等待支付审核')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitComplete() {
submitting.value = true
try {
await myPatientOrderComplete({ id: Number(actionRow.value.id), fulfillment_status: Number(completeStatus.value) })
completeVisible.value = false
await changed('订单状态已更新')
} catch {
//
} finally {
submitting.value = false
}
}
async function submitRefund() {
if (!refundForm.reason.trim()) {
feedback.msgError('请填写退款原因')
return
}
submitting.value = true
try {
await myPatientOrderRefund({
id: Number(actionRow.value.id),
reason: refundForm.reason.trim(),
refund_amount: refundForm.refund_amount === undefined ? undefined : Number(refundForm.refund_amount)
})
refundVisible.value = false
await changed('退款成功')
} catch {
//
} finally {
submitting.value = false
}
}
async function confirmWithdraw() {
try {
await ElMessageBox.confirm('确认撤回该订单吗?撤回后当前订单将结束。', '撤回订单', {
type: 'warning',
confirmButtonText: '确认撤回',
cancelButtonText: '取消'
})
await myPatientOrderWithdraw({ id: Number(actionRow.value.id) })
await changed('订单已撤回')
} catch {
//
}
}
async function confirmUploadPharmacy() {
try {
await ElMessageBox.confirm('确认将该订单上传到订单设定的药房吗?', '上传药房', {
type: 'warning',
confirmButtonText: '确认上传',
cancelButtonText: '取消'
})
await myPatientOrderUploadToPharmacy({ id: Number(actionRow.value.id) })
await changed('药方上传成功')
} catch {
//
}
}
defineExpose({ openDetail, openAction })
</script>
<style scoped lang="scss">
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.form-tip {
margin-left: 10px;
color: #8a95a6;
font-size: 12px;
}
:global(.danger-menu-item) {
color: var(--el-color-danger) !important;
}
@media (max-width: 720px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,720 @@
<template>
<section class="embedded-panel">
<div class="panel-toolbar">
<div>
<h2>订单管理</h2>
<p>展示当前患者范围内的处方业务订单订单创建人不参与数据归属判断</p>
</div>
<div class="toolbar-actions">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
<el-button :icon="Refresh" :loading="pager.loading" @click="refreshPanel">刷新</el-button>
</div>
</div>
<div class="filter-panel">
<div class="filter-heading">
<div>
<h3>订单检索</h3>
<p>审核与履约状态直接展示点击后立即筛选</p>
</div>
<el-button link :disabled="!hasActiveFilters" @click="resetFilters">清空全部条件</el-button>
</div>
<div class="status-filter-list">
<div class="status-filter-row">
<span class="filter-label">处方审核</span>
<el-radio-group v-model="formData.prescription_audit_status" @change="handleSearch">
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="status-filter-row">
<span class="filter-label">支付单审核</span>
<el-radio-group v-model="formData.payment_slip_audit_status" @change="handleSearch">
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="status-filter-row status-filter-row--fulfillment">
<span class="filter-label">履约状态</span>
<el-radio-group v-model="formData.fulfillment_status" @change="handleSearch">
<el-radio-button v-for="item in fulfillmentFilterOptions" :key="String(item.value)" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="search-filter-row">
<label class="filter-field filter-field--keyword">
<span>关键词</span>
<el-input
v-model="formData.keyword"
clearable
:prefix-icon="Search"
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</label>
<label class="filter-field filter-field--date">
<span>创建时间</span>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
@change="handleDateChange"
/>
</label>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</div>
</div>
<div class="metric-grid">
<div class="metric-card">
<span>订单数量</span>
<strong>{{ summary.orders }}</strong>
<small></small>
</div>
<div class="metric-card">
<span>有效订单金额</span>
<strong>¥{{ money(summary.amount) }}</strong>
<small>排除取消拒收及退款</small>
</div>
<div class="metric-card metric-warning">
<span>待审核</span>
<strong>{{ summary.pending }}</strong>
<small>任一审核待处理</small>
</div>
<div class="metric-card metric-success">
<span>已完成 / 签收</span>
<strong>{{ summary.completed }}</strong>
<small></small>
</div>
<button
class="metric-card metric-card--interactive metric-rejected"
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
type="button"
@click="toggleRejectedFilter"
>
<span>拒收订单</span>
<strong>{{ summary.rejected }}</strong>
<small> · 点击查看明细</small>
</button>
<button
class="metric-card metric-card--interactive metric-rejected-rate"
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
type="button"
@click="toggleRejectedFilter"
>
<span>拒收率</span>
<strong>{{ percent(summary.rejectionRate) }}</strong>
<small>拒收订单 ÷ 同检索范围订单</small>
</button>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
class="embedded-table"
:row-class-name="orderRowClassName"
>
<el-table-column label="订单" min-width="174" fixed="left">
<template #default="{ row }">
<div class="primary-cell">
<strong>{{ row.order_no || '—' }}</strong>
<span>#{{ row.id }} · {{ row.fee_type_text }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="患者" min-width="150">
<template #default="{ row }">
<div class="primary-cell">
<strong>{{ row.patient_name || row.recipient_name || '—' }}</strong>
<span>{{ row.patient_phone_masked || row.recipient_phone_masked || '无手机号' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="处方 / 诊单" min-width="118">
<template #default="{ row }">
<div class="id-stack">
<span>处方 #{{ row.prescription_id || '—' }}</span>
<span>诊单 #{{ row.diagnosis_id || '—' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="有效金额" width="118" align="right">
<template #default="{ row }">
<strong v-if="row.amount_included" class="amount">¥{{ money(row.effective_amount) }}</strong>
<span v-else class="amount-excluded">{{ row.amount_exclusion_text || '不计入' }}</span>
</template>
</el-table-column>
<el-table-column label="处方审核" width="104" align="center">
<template #default="{ row }">
<el-tag size="small" :type="auditTagType(row.prescription_audit_status)" effect="light">
{{ row.prescription_audit_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="支付单审核" width="112" align="center">
<template #default="{ row }">
<el-tag size="small" :type="auditTagType(row.payment_slip_audit_status)" effect="light">
{{ row.payment_slip_audit_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="履约状态" width="112" align="center">
<template #default="{ row }">
<el-tag size="small" :type="fulfillmentTagType(row.fulfillment_status)" effect="light">
{{ row.fulfillment_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="关联支付单" width="100" align="center">
<template #default="{ row }">{{ Number(row.linked_pay_order_count) > 0 ? `${row.linked_pay_order_count}` : '—' }}</template>
</el-table-column>
<el-table-column prop="assistant_name" label="归属助理" min-width="100" show-overflow-tooltip />
<el-table-column prop="doctor_name" label="开方人" min-width="90" show-overflow-tooltip />
<el-table-column prop="creator_name" label="创建人" min-width="90" show-overflow-tooltip />
<el-table-column prop="create_time_text" label="创建时间" min-width="145" />
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<div class="order-actions">
<el-button type="primary" link @click="emit('openDiagnosis', row)">诊单</el-button>
<el-button
v-if="canViewMyPatientOrderDetail()"
type="primary"
link
@click="orderActionHostRef?.openDetail(row)"
>详情</el-button>
<el-dropdown
v-if="myPatientOrderActions(row).length"
trigger="click"
@command="(command) => openOrderAction(row, command as MyPatientOrderAction)"
>
<el-button type="primary" link>
操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="action in myPatientOrderActions(row)"
:key="action.key"
:command="action.key"
:class="{ 'danger-menu-item': action.danger }"
>
{{ action.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="当前范围内暂无订单" /></template>
</el-table>
<div class="pagination-wrap">
<pagination v-model="pager" @change="getLists" />
</div>
<order-action-host ref="orderActionHostRef" @changed="getLists" />
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ArrowDown, Lock, Refresh, Search } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { myPatientOrderLists } from '@/api/first_visit'
import OrderActionHost from './OrderActionHost.vue'
import {
canViewMyPatientOrderDetail,
myPatientOrderActions,
type MyPatientOrderAction
} from './order-actions'
type SelectValue = '' | number
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
const orderActionHostRef = ref<InstanceType<typeof OrderActionHost>>()
const dateRange = ref<string[]>([])
const formData = reactive({
keyword: '',
prescription_audit_status: '' as SelectValue,
payment_slip_audit_status: '' as SelectValue,
fulfillment_status: '' as SelectValue,
start_date: '',
end_date: ''
})
const auditFilterOptions: Array<{ label: string; value: SelectValue }> = [
{ label: '全部', value: '' },
{ label: '待审核', value: 0 },
{ label: '已通过', value: 1 },
{ label: '已驳回', value: 2 }
]
const fulfillmentFilterOptions: Array<{ label: string; value: SelectValue }> = [
{ label: '全部', value: '' },
{ label: '待双审通过', value: 1 },
{ label: '待发货', value: 2 },
{ label: '已完成', value: 3 },
{ label: '已取消', value: 4 },
{ label: '已发货', value: 5 },
{ label: '已签收', value: 6 },
{ label: '进行中', value: 7 },
{ label: '暂不制药', value: 8 },
{ label: '拒收', value: 9 },
{ label: '退款', value: 10 },
{ label: '保留药方', value: 11 },
{ label: '制药缓发', value: 12 }
]
const { pager, getLists, resetPage } = usePaging({
fetchFun: myPatientOrderLists as any,
params: formData,
size: 15,
firstLoading: true
})
const summary = computed(() => ({
orders: Number(pager.extend?.summary?.orders || 0),
amount: Number(pager.extend?.summary?.amount || 0),
pending: Number(pager.extend?.summary?.pending || 0),
completed: Number(pager.extend?.summary?.completed || 0),
rejected: Number(pager.extend?.summary?.rejected || 0),
rejectionRate: Number(pager.extend?.summary?.rejection_rate || 0)
}))
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
const hasActiveFilters = computed(() => Boolean(
formData.keyword.trim()
|| formData.prescription_audit_status !== ''
|| formData.payment_slip_audit_status !== ''
|| formData.fulfillment_status !== ''
|| formData.start_date
|| formData.end_date
))
function handleSearch() {
resetPage()
}
function refreshPanel() {
return getLists()
}
function handleDateChange(value: string[] | null) {
formData.start_date = value?.[0] || ''
formData.end_date = value?.[1] || value?.[0] || ''
resetPage()
}
function resetFilters() {
formData.keyword = ''
formData.prescription_audit_status = ''
formData.payment_slip_audit_status = ''
formData.fulfillment_status = ''
formData.start_date = ''
formData.end_date = ''
dateRange.value = []
resetPage()
}
function toggleRejectedFilter() {
formData.fulfillment_status = Number(formData.fulfillment_status) === 9 ? '' : 9
resetPage()
}
function money(value: number | string) {
return Number(value || 0).toFixed(2)
}
function percent(value: number | string) {
const number = Number(value || 0)
return `${Number.isInteger(number) ? number.toFixed(0) : number.toFixed(2)}%`
}
function auditTagType(status: number): 'success' | 'warning' | 'danger' | 'info' {
if (Number(status) === 1) return 'success'
if (Number(status) === 2) return 'danger'
return 'warning'
}
function fulfillmentTagType(status: number): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
const value = Number(status)
if ([3, 6].includes(value)) return 'success'
if ([4, 9, 10].includes(value)) return 'danger'
if ([2, 5, 7].includes(value)) return 'primary'
return 'warning'
}
function orderRowClassName({ row }: { row: any }) {
if ([2].includes(Number(row.prescription_audit_status)) || [2].includes(Number(row.payment_slip_audit_status))) {
return 'order-row-risk'
}
if ([3, 6].includes(Number(row.fulfillment_status))) return 'order-row-done'
return ''
}
function openOrderAction(row: Record<string, any>, action: MyPatientOrderAction) {
orderActionHostRef.value?.openAction(row, action)
}
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
onMounted(getLists)
</script>
<style scoped lang="scss">
.embedded-panel {
min-height: 420px;
}
.panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 17px;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 12px;
}
}
.toolbar-actions,
.scope-chip {
display: flex;
align-items: center;
gap: 8px;
}
.scope-chip {
color: #0f766e;
font-size: 12px;
}
.filter-panel {
padding: 16px;
border: 1px solid #e3e8ef;
border-radius: 10px;
background: #fbfcfd;
}
.filter-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e8edf2;
h3 {
margin: 0;
color: #273244;
font-size: 14px;
font-weight: 600;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 12px;
}
}
.status-filter-list {
display: grid;
gap: 10px;
padding: 13px 0;
border-bottom: 1px solid #e8edf2;
}
.status-filter-row {
display: flex;
align-items: flex-start;
gap: 12px;
:deep(.el-radio-group) {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 6px;
}
:deep(.el-radio-button__inner) {
min-width: 72px;
padding: 7px 12px;
color: #5f6b7d;
border: 1px solid #dfe5ec;
border-radius: 6px;
box-shadow: none;
background: #fff;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
:deep(.el-radio-button:first-child .el-radio-button__inner),
:deep(.el-radio-button:last-child .el-radio-button__inner) {
border-radius: 6px;
}
:deep(.el-radio-button.is-active .el-radio-button__inner) {
color: #fff;
border-color: #0f9185;
background: #0f9185;
box-shadow: none;
}
}
.filter-label {
flex: 0 0 76px;
padding-top: 7px;
color: #475467;
font-size: 12px;
font-weight: 600;
}
.search-filter-row {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(300px, 360px) auto;
align-items: end;
gap: 12px;
padding-top: 13px;
}
.filter-field {
display: grid;
gap: 6px;
min-width: 0;
> span {
color: #667085;
font-size: 12px;
font-weight: 500;
}
:deep(.el-date-editor) {
width: 100%;
}
}
.filter-actions {
display: flex;
align-items: center;
:deep(.el-button + .el-button) {
margin-left: 8px;
}
}
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
margin: 14px 0;
}
.metric-card {
min-height: 82px;
padding: 14px 16px;
text-align: left;
font: inherit;
border: 1px solid #e3e8ef;
border-radius: 10px;
background: #fff;
span,
small {
color: #8a95a6;
font-size: 12px;
}
strong {
display: block;
margin: 7px 0 3px;
color: #172033;
font-size: 22px;
line-height: 1;
}
}
.metric-card--interactive {
color: inherit;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
&:hover,
&:focus-visible {
border-color: #e5a9a3;
box-shadow: 0 7px 18px rgba(132, 45, 40, 0.08);
transform: translateY(-1px);
outline: none;
}
&:active {
transform: translateY(0);
}
&.is-active {
border-color: #d9685f;
box-shadow: 0 0 0 2px rgba(217, 104, 95, 0.11);
}
}
.metric-rejected,
.metric-rejected-rate {
border-color: #f0cbc7;
background: #fff9f8;
strong {
color: #c64f47;
}
}
.metric-warning {
border-color: #f3d8aa;
background: #fffcf5;
}
.metric-success {
border-color: #b9e2dc;
background: #f7fcfb;
}
.embedded-table {
width: 100%;
border: 1px solid #e7ebf0;
border-radius: 9px;
overflow: hidden;
:deep(th.el-table__cell) {
height: 44px;
color: #5f6b7d;
background: #f7f9fb;
font-weight: 600;
}
:deep(.order-row-risk > td.el-table__cell) {
background: #fff8f7;
}
:deep(.order-row-done > td.el-table__cell) {
background: #f8fcfb;
}
}
.primary-cell,
.id-stack {
display: flex;
flex-direction: column;
gap: 3px;
strong {
color: #202939;
font-size: 13px;
}
span {
color: #8b96a8;
font-size: 12px;
}
}
.amount {
color: #d04f3f;
font-variant-numeric: tabular-nums;
}
.amount-excluded {
color: #9aa4b2;
font-size: 11px;
}
.order-actions {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
:global(.danger-menu-item) {
color: var(--el-color-danger) !important;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
@media (max-width: 1400px) {
.metric-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.search-filter-row {
grid-template-columns: 1fr 1fr;
}
.filter-actions {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.panel-toolbar {
align-items: flex-start;
flex-direction: column;
}
.metric-grid {
grid-template-columns: 1fr;
}
.status-filter-row,
.search-filter-row {
display: flex;
align-items: stretch;
flex-direction: column;
}
.filter-label {
padding-top: 0;
}
.filter-actions {
display: grid;
grid-template-columns: 1fr 1fr;
:deep(.el-button) {
width: 100%;
}
}
}
</style>
@@ -0,0 +1,843 @@
<template>
<div class="progress-board">
<section class="board-section overview-section">
<div class="section-heading">
<div class="heading-copy">
<h2>今日面诊概览</h2>
<span class="heading-badge">{{ isOwnershipMode ? '按本人归属' : '与排班合并' }}</span>
</div>
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
</div>
<div class="overview-grid">
<article class="overview-card">
<span>{{ isOwnershipMode ? '今日本人面诊' : '今日面诊总号源' }}</span>
<strong>{{ todayOverview.totalVisits }}</strong>
<small>{{ todayOverview.doctorCount }} {{ isOwnershipMode ? '接诊' : '排班' }}医生</small>
</article>
<article class="overview-card">
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
<strong>{{ todayOverview.booked }}</strong>
<small>{{ isOwnershipMode ? '本人归属患者的有效挂号' : '按有效挂号占用号源' }}</small>
</article>
<article :class="['overview-card', isOwnershipMode ? 'overview-card-completed' : 'overview-card-empty']">
<span>{{ isOwnershipMode ? '已完成' : '空号' }}</span>
<strong>{{ isOwnershipMode ? todayOverview.completed : todayOverview.emptySlots }}</strong>
<small>{{ isOwnershipMode ? `已过号 ${todayOverview.missed}` : '未被有效挂号占用' }}</small>
</article>
</div>
</section>
<section class="board-section schedule-section">
<div class="section-heading">
<div class="heading-copy">
<h2>{{ isOwnershipMode ? '近一周面诊安排' : '近一周排班' }}</h2>
<span class="heading-badge">{{ isOwnershipMode ? '本人患者' : '今日起 7 天' }}</span>
</div>
<span class="schedule-range">{{ scheduleRange }}</span>
</div>
<div class="schedule-grid">
<button
v-for="day in weekSchedule"
:key="day.date"
type="button"
class="schedule-card"
:class="{
'is-today': day.date === today,
'is-selected': day.date === selectedScheduleDate
}"
:aria-pressed="day.date === selectedScheduleDate"
@click="selectScheduleDay(day.date)"
>
<span class="schedule-date">{{ day.date_text }} {{ day.weekday }}</span>
<strong>{{ isOwnershipMode ? day.total_appointments : day.total_slots }}</strong>
<span class="schedule-card-stats">
<span>{{ isOwnershipMode ? '待诊' : '已约' }} {{ isOwnershipMode ? day.waiting_appointments : day.booked_slots }}</span>
<i>/</i>
<span :class="{ 'is-completed': isOwnershipMode }">{{ isOwnershipMode ? '完成' : '空' }} {{ isOwnershipMode ? day.completed_appointments : day.empty_slots }}</span>
</span>
<span class="schedule-card-action">
<span>{{ day.doctor_count }} 位医生</span>
<span>{{ day.date === selectedScheduleDate ? '正在查看' : '查看明细' }} </span>
</span>
</button>
</div>
<div class="schedule-drilldown">
<div class="drilldown-heading">
<div>
<h3>{{ selectedScheduleLabel }}{{ isOwnershipMode ? '面诊安排' : '排班明细' }}</h3>
<p> {{ selectedScheduleDay.doctor_count }} {{ isOwnershipMode ? '接诊' : '排班' }}医生点击上方日期可切换</p>
</div>
<div :class="['drilldown-summary', { 'is-ownership': isOwnershipMode }]">
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.total_appointments : selectedScheduleDay.total_slots }}</strong></span>
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.waiting_appointments : selectedScheduleDay.booked_slots }}</strong></span>
<span>{{ isOwnershipMode ? '已完成' : '空号' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.completed_appointments : selectedScheduleDay.empty_slots }}</strong></span>
<span v-if="isOwnershipMode">已过号 <strong>{{ selectedScheduleDay.missed_appointments }}</strong></span>
</div>
</div>
<div v-if="selectedScheduleDoctors.length" class="doctor-schedule-list">
<article
v-for="(doctor, index) in selectedScheduleDoctors"
:key="doctor.doctor_id"
class="doctor-schedule-row"
>
<div class="doctor-identity">
<span class="doctor-index">{{ index + 1 }}</span>
<div>
<strong>{{ doctor.doctor_name }}</strong>
<small>{{ isOwnershipMode ? '本人患者接诊医生' : '医生排班' }}</small>
</div>
</div>
<div class="doctor-windows">
<span>{{ isOwnershipMode ? '预约时刻' : '排班时段' }}</span>
<strong>{{ doctorWindows(doctor) }}</strong>
</div>
<div class="doctor-metric">
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }}</span>
<strong>{{ isOwnershipMode ? doctor.total_appointments : doctor.total_slots }}</strong>
</div>
<div class="doctor-metric doctor-metric-booked">
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
<strong>{{ isOwnershipMode ? doctor.waiting_appointments : doctor.booked_slots }}</strong>
</div>
<div :class="['doctor-metric', isOwnershipMode ? 'doctor-metric-completed' : 'doctor-metric-empty']">
<span>{{ isOwnershipMode ? '完成 / 过号' : '空号' }}</span>
<strong>{{ isOwnershipMode ? `${doctor.completed_appointments} / ${doctor.missed_appointments}` : doctor.empty_slots }}</strong>
</div>
</article>
</div>
<el-empty
v-else
:image-size="56"
:description="isOwnershipMode ? '该日期暂无本人归属患者的有效挂号' : '该日期暂无当前权限范围内的医生排班'"
/>
</div>
</section>
<section class="board-section queue-section">
<div class="section-heading queue-heading">
<div class="heading-copy">
<h2>{{ queueDateLabel }}候诊列表</h2>
<span class="heading-badge">按医生排队</span>
<span class="queue-count"> {{ pager.count }} </span>
</div>
<span class="refresh-time"> 15 秒自动刷新</span>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
class="queue-table"
:row-class-name="tableRowClassName"
@row-dblclick="openDiagnosis"
>
<el-table-column label="排队" width="92">
<template #default="{ row }">
<span class="queue-number">{{ row.queue_no || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="患者" min-width="170">
<template #default="{ row }">
<div class="patient-cell">
<strong>{{ row.patient_name || '未命名患者' }}</strong>
<span>{{ row.phone_masked || '无手机号' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="doctor_name" label="医生" min-width="130" show-overflow-tooltip />
<el-table-column label="预约时间" min-width="135">
<template #default="{ row }">
<span class="appointment-clock">{{ appointmentClock(row) }}</span>
</template>
</el-table-column>
<el-table-column label="前方等待" min-width="180">
<template #default="{ row }">
<span :class="['waiting-text', `is-${row.queue_status || 'waiting'}`]">
{{ waitingText(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<el-tag size="small" round effect="light" :type="queueTagType(row.queue_status)">
{{ row.queue_status_text || '等待中' }}
</el-tag>
</template>
</el-table-column>
<template #empty>
<el-empty :description="`当前权限范围内${queueDateLabel}暂无候诊患者`" />
</template>
</el-table>
<div v-if="pager.count > pager.size" class="pagination-wrap">
<pagination v-model="pager" @change="getLists" />
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import { Lock } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { myPatientProgressLists } from '@/api/first_visit'
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
const today = dayjs().format('YYYY-MM-DD')
const selectedScheduleDate = ref(today)
const formData = reactive({
keyword: '',
status: 1 as const,
start_date: today,
end_date: today
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: myPatientProgressLists as any,
params: formData,
size: 15,
firstLoading: true,
latestOnly: true
})
const todayOverview = computed(() => ({
totalVisits: Number(pager.extend?.today_overview?.total_visits || 0),
booked: Number(pager.extend?.today_overview?.booked || 0),
completed: Number(pager.extend?.today_overview?.completed || 0),
missed: Number(pager.extend?.today_overview?.missed || 0),
emptySlots: Number(pager.extend?.today_overview?.empty_slots || 0),
doctorCount: Number(pager.extend?.today_overview?.doctor_count || 0)
}))
const isOwnershipMode = computed(() => pager.extend?.schedule_mode === 'ownership')
const weekSchedule = computed(() => {
const rows = Array.isArray(pager.extend?.week_schedule) ? pager.extend.week_schedule : []
if (rows.length) return rows
return Array.from({ length: 7 }, (_, index) => {
const date = dayjs().add(index, 'day')
return {
date: date.format('YYYY-MM-DD'),
date_text: date.format('MM-DD'),
weekday: `${'日一二三四五六'[date.day()]}`,
total_slots: 0,
booked_slots: 0,
empty_slots: 0,
doctor_count: 0,
doctors: [],
total_appointments: 0,
waiting_appointments: 0,
completed_appointments: 0,
missed_appointments: 0
}
})
})
const selectedScheduleDay = computed(() => {
return weekSchedule.value.find((day: any) => day.date === selectedScheduleDate.value) || weekSchedule.value[0] || {
date: today,
date_text: dayjs().format('MM-DD'),
weekday: `${'日一二三四五六'[dayjs().day()]}`,
total_slots: 0,
booked_slots: 0,
empty_slots: 0,
doctor_count: 0,
doctors: [],
total_appointments: 0,
waiting_appointments: 0,
completed_appointments: 0,
missed_appointments: 0
}
})
const selectedScheduleDoctors = computed(() => {
return Array.isArray(selectedScheduleDay.value?.doctors) ? selectedScheduleDay.value.doctors : []
})
const selectedScheduleLabel = computed(() => {
const day = selectedScheduleDay.value
return `${day.date_text || ''} ${day.weekday || ''} `
})
const queueDateLabel = computed(() => {
if (selectedScheduleDate.value === today) return '今日'
return selectedScheduleLabel.value.trim() || selectedScheduleDate.value
})
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
const scheduleRange = computed(() => {
const rows = weekSchedule.value
if (!rows.length) return ''
return `${rows[0].date_text}${rows[rows.length - 1].date_text}`
})
let refreshTimer: ReturnType<typeof setInterval> | null = null
function refreshPanel(options?: { silent?: boolean }) {
return getLists(options)
}
function selectScheduleDay(date: string) {
if (!date) return
selectedScheduleDate.value = date
formData.start_date = date
formData.end_date = date
resetPage()
}
function doctorWindows(doctor: Record<string, any>) {
const source = isOwnershipMode.value ? doctor.appointment_times : doctor.schedule_windows
const windows = Array.isArray(source) ? source.filter(Boolean) : []
if (windows.length) return windows.join('、')
return isOwnershipMode.value ? '暂无预约时刻' : '未设置具体时段'
}
function appointmentClock(row: Record<string, any>) {
const time = String(row.appointment_time || '').slice(0, 5)
return time || '—'
}
function waitingText(row: Record<string, any>) {
if (row.queue_status === 'consulting') return '0(进行中)'
if (row.queue_status === 'next') return '0(待接诊)'
const ahead = Math.max(0, Number(row.ahead_count || 0))
if (ahead === 0) return '0 位'
return `${ahead} 位 · 约 ${Number(row.estimated_wait_minutes || ahead * 15)} 分钟`
}
function queueTagType(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (status === 'consulting') return 'success'
if (status === 'next') return 'warning'
if (status === 'completed') return 'success'
if (status === 'missed') return 'danger'
return 'info'
}
function tableRowClassName({ row }: { row: Record<string, any> }) {
return Number(row.is_self_patient) === 1 ? 'queue-row-self' : ''
}
function openDiagnosis(row: Record<string, any>) {
emit('openDiagnosis', row)
}
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
onMounted(() => {
getLists()
refreshTimer = setInterval(() => getLists({ silent: true }), 15_000)
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<style scoped lang="scss">
.progress-board {
display: grid;
gap: 14px;
color: #172033;
}
.board-section {
padding: 16px;
border: 1px solid #e1e7ee;
border-radius: 12px;
background: #fff;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.heading-copy,
.scope-chip {
display: flex;
align-items: center;
}
.heading-copy {
gap: 8px;
min-width: 0;
h2 {
margin: 0;
color: #172033;
font-size: 15px;
font-weight: 700;
}
}
.heading-badge {
padding: 3px 7px;
border-radius: 5px;
color: #758195;
background: #eef2f6;
font-size: 11px;
white-space: nowrap;
}
.scope-chip {
gap: 5px;
color: #0f766e;
font-size: 12px;
white-space: nowrap;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.overview-card {
min-height: 78px;
padding: 14px 16px;
border: 1px solid #dfe5ed;
border-radius: 10px;
background: #fff;
> span,
> small {
display: block;
color: #778398;
font-size: 12px;
}
> strong {
display: block;
margin: 7px 0 5px;
color: #111a2c;
font-size: 24px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
> small {
color: #a0a9b7;
font-size: 11px;
}
}
.overview-card-empty > strong {
color: #ee4d55;
}
.overview-card-completed > strong {
color: #07886d;
}
.schedule-range,
.refresh-time,
.queue-count {
color: #929dac;
font-size: 12px;
}
.schedule-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
}
.schedule-card {
min-width: 0;
padding: 11px 8px;
text-align: center;
border: 1px solid #dfe5ed;
border-radius: 9px;
background: #fbfcfe;
color: inherit;
font: inherit;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
&:hover {
border-color: #7ac9c2;
background: #f7fcfb;
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 3px solid rgba(15, 145, 133, 0.18);
outline-offset: 2px;
}
&.is-today {
border-color: #8fd3cd;
background: #f4fbfa;
}
&.is-selected {
border-color: #0f9185;
background: #effaf8;
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1), inset 0 -3px 0 #0f9185;
}
> strong {
display: block;
margin: 7px 0 5px;
color: #131c2d;
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
}
.schedule-date {
display: block;
overflow: hidden;
color: #70809a;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-card-stats {
display: flex;
justify-content: center;
gap: 4px;
color: #0c9967;
font-size: 11px;
white-space: nowrap;
i {
color: #a5afbd;
font-style: normal;
}
span:last-child {
color: #d8862b;
}
span.is-completed {
color: #07886d;
}
}
.schedule-card-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin: 10px 2px 0;
padding-top: 8px;
border-top: 1px solid #e7ecef;
color: #7b8799;
font-size: 10px;
span:last-child {
color: #0f8077;
font-weight: 600;
}
}
.schedule-drilldown {
margin-top: 14px;
padding: 15px;
border-radius: 10px;
background: #f7f9fb;
}
.drilldown-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 11px;
h3 {
margin: 0;
color: #1e293b;
font-size: 14px;
font-weight: 700;
}
p {
margin: 4px 0 0;
color: #8a95a6;
font-size: 11px;
}
}
.drilldown-summary {
display: flex;
align-items: center;
gap: 18px;
color: #7a8698;
font-size: 11px;
white-space: nowrap;
strong {
margin-left: 3px;
color: #243044;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
span:last-child strong {
color: #d17820;
}
}
.doctor-schedule-list {
display: grid;
gap: 7px;
}
.doctor-schedule-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(3, minmax(64px, 0.55fr));
align-items: center;
gap: 14px;
min-height: 58px;
padding: 8px 13px;
border: 1px solid #e4e9ef;
border-radius: 8px;
background: #fff;
}
.doctor-identity {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
> div {
min-width: 0;
}
strong,
small {
display: block;
}
strong {
overflow: hidden;
color: #202b3d;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
margin-top: 2px;
color: #9aa4b2;
font-size: 10px;
}
}
.doctor-index {
display: grid;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 7px;
color: #0f766e;
background: #e7f6f4;
font-size: 12px;
font-weight: 700;
}
.doctor-windows,
.doctor-metric {
min-width: 0;
span,
strong {
display: block;
}
span {
color: #98a2b3;
font-size: 10px;
}
strong {
margin-top: 3px;
color: #344054;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
.doctor-windows strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doctor-metric {
text-align: right;
strong {
font-size: 15px;
}
}
.doctor-metric-booked strong {
color: #07886d;
}
.doctor-metric-empty strong {
color: #d17820;
}
.doctor-metric-completed strong {
color: #07886d;
}
.queue-heading {
margin-bottom: 10px;
}
.queue-table {
width: 100%;
border-radius: 8px;
overflow: hidden;
:deep(th.el-table__cell) {
height: 40px;
color: #667085;
background: #f7f9fb;
font-size: 12px;
font-weight: 600;
}
:deep(td.el-table__cell) {
height: 46px;
padding: 6px 0;
color: #253044;
font-size: 13px;
}
:deep(.queue-row-self > td.el-table__cell) {
background: #fff8e8 !important;
}
:deep(.queue-row-self > td.el-table__cell:first-child) {
border-left: 3px solid #f08332;
}
}
.queue-number {
display: inline-grid;
min-width: 26px;
height: 26px;
padding: 0 7px;
place-items: center;
border-radius: 8px;
color: #0f766e;
background: #e7f6f4;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.patient-cell {
display: flex;
flex-direction: column;
gap: 2px;
strong {
color: #1f2937;
font-size: 13px;
}
span {
color: #98a2b3;
font-size: 11px;
}
}
.appointment-clock {
color: #263246;
font-variant-numeric: tabular-nums;
}
.waiting-text {
color: #667085;
font-size: 12px;
&.is-consulting {
color: #07886d;
font-weight: 600;
}
&.is-next {
color: #d17820;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
padding-top: 14px;
}
@media (max-width: 1180px) {
.schedule-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.doctor-schedule-row {
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) repeat(3, minmax(56px, 0.5fr));
gap: 10px;
}
}
@media (max-width: 760px) {
.board-section {
padding: 12px;
}
.overview-grid,
.schedule-grid {
grid-template-columns: 1fr;
}
.section-heading {
align-items: flex-start;
flex-direction: column;
gap: 7px;
}
.heading-copy {
flex-wrap: wrap;
}
.drilldown-heading,
.drilldown-summary {
align-items: flex-start;
flex-direction: column;
}
.drilldown-summary {
gap: 5px;
}
.doctor-schedule-row {
grid-template-columns: repeat(3, 1fr);
}
.doctor-identity,
.doctor-windows {
grid-column: 1 / -1;
}
.doctor-metric {
text-align: left;
}
}
</style>
@@ -0,0 +1,125 @@
import { hasPermission } from '@/utils/perm'
import { isRemoteSnapshotLocked } from '@/views/consumer/prescription/components/prescription-order-utils'
export type MyPatientOrderAction =
| 'edit'
| 'audit_prescription'
| 'revoke_rx_audit'
| 'audit_payment'
| 'revoke_pay_audit'
| 'ddcode'
| 'ship'
| 'add_pay_order'
| 'complete'
| 'refund'
| 'withdraw'
| 'upload_pharmacy'
export interface MyPatientOrderActionItem {
key: MyPatientOrderAction
label: string
danger?: boolean
}
function permitted(permission: string) {
return hasPermission([permission])
}
function remoteLocked(row: Record<string, any>) {
return isRemoteSnapshotLocked(row as Record<string, unknown>)
}
export function canViewMyPatientOrderDetail() {
return permitted('tcm.prescriptionOrder/detail')
}
export function myPatientOrderActions(row: Record<string, any>): MyPatientOrderActionItem[] {
const actions: MyPatientOrderActionItem[] = []
const fulfillment = Number(row.fulfillment_status)
const prescriptionAudit = Number(row.prescription_audit_status)
const paymentAudit = Number(row.payment_slip_audit_status)
const locked = remoteLocked(row)
if (
canViewMyPatientOrderDetail()
&& permitted('tcm.prescriptionOrder/edit')
&& fulfillment === 1
&& !locked
) {
actions.push({ key: 'edit', label: '编辑订单' })
}
if (
permitted('tcm.prescriptionOrder/auditPrescription')
&& prescriptionAudit === 0
&& ![3, 4, 6].includes(fulfillment)
) {
actions.push({ key: 'audit_prescription', label: '处方审核' })
}
if (
permitted('tcm.prescriptionOrder/auditPrescription')
&& [1, 2].includes(prescriptionAudit)
&& paymentAudit === 0
&& ![3, 4, 6].includes(fulfillment)
&& !locked
) {
actions.push({ key: 'revoke_rx_audit', label: '撤回处方审核' })
}
if (
permitted('tcm.prescriptionOrder/auditPayment')
&& prescriptionAudit === 1
&& paymentAudit === 0
&& ![3, 4].includes(fulfillment)
) {
actions.push({ key: 'audit_payment', label: '支付单审核' })
}
if (
permitted('tcm.prescriptionOrder/auditPayment')
&& prescriptionAudit === 1
&& [1, 2].includes(paymentAudit)
&& ![3, 4, 6].includes(fulfillment)
) {
actions.push({ key: 'revoke_pay_audit', label: '撤回支付审核' })
}
if (permitted('tcm.prescriptionOrder/ddcode')) {
actions.push({ key: 'ddcode', label: '修改快递单号' })
}
if (permitted('tcm.prescriptionOrder/ship') && fulfillment === 2) {
actions.push({ key: 'ship', label: '确认发货' })
}
const amount = Math.round((Number(row.amount) || 0) * 100) / 100
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
if (
permitted('tcm.prescriptionOrder/addPayOrder')
&& [5, 6].includes(fulfillment)
&& paidTotal < amount
) {
actions.push({ key: 'add_pay_order', label: '补齐支付单' })
}
if (
permitted('tcm.prescriptionOrder/complete')
&& [5, 6].includes(fulfillment)
&& paymentAudit === 1
) {
actions.push({ key: 'complete', label: '完成订单' })
}
if (
permitted('tcm.prescriptionOrder/refund')
&& [3, 5, 6, 9].includes(fulfillment)
&& paymentAudit === 1
) {
actions.push({ key: 'refund', label: '退款', danger: true })
}
if (permitted('tcm.prescriptionOrder/withdraw') && fulfillment === 1 && !locked) {
actions.push({ key: 'withdraw', label: '撤回订单', danger: true })
}
if (
permitted('tcm.prescriptionOrder/uploadToPharmacy')
&& row.can_upload_pharmacy !== false
&& prescriptionAudit === 1
&& ![3, 4, 8, 10, 11, 12].includes(fulfillment)
) {
actions.push({ key: 'upload_pharmacy', label: '上传药房' })
}
return actions
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,646 @@
<template>
<div
class="registration-stats"
v-loading="loading"
element-loading-text="正在汇总权限范围内的挂号与预约数据"
>
<header class="page-heading">
<div class="heading-copy">
<span class="heading-mark"><el-icon><Histogram /></el-icon></span>
<div>
<h1>挂号统计</h1>
<p>挂号预约诊单与目标数据按当前角色和部门权限实时汇总</p>
</div>
</div>
<div class="heading-actions">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
</div>
</header>
<section class="filter-strip">
<div class="filter-item">
<span>部门</span>
<el-tree-select
v-model="query.dept_id"
:data="dashboard.filters.departments"
:props="deptTreeProps"
node-key="id"
clearable
filterable
check-strictly
default-expand-all
placeholder="全部可见部门"
class="dept-select"
@change="handleDepartmentChange"
/>
</div>
<div class="filter-item filter-item--time">
<span>时间</span>
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
</div>
<div class="filter-item">
<span>员工</span>
<el-select
v-model="query.assistant_id"
clearable
filterable
placeholder="全部可见员工"
class="employee-select"
@change="loadDashboard"
>
<el-option
v-for="item in dashboard.filters.assistants"
:key="item.id"
:label="item.name"
:value="Number(item.id)"
/>
</el-select>
</div>
<div class="filter-summary">
<strong>{{ dashboard.meta.start_date || '—' }}</strong>
<span> {{ dashboard.meta.end_date || '—' }} · {{ dashboard.meta.member_count || 0 }} 位员工</span>
</div>
</section>
<section class="metric-grid" aria-label="挂号统计核心指标">
<article class="metric-card metric-card--teal">
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总挂号</span>
<strong>{{ formatNumber(dashboard.summary.registration_count) }}</strong>
<small :class="compareClass(dashboard.summary.registration_compare_rate)">
{{ compareText(dashboard.summary.registration_compare_rate) }}
</small>
</div>
</article>
<article class="metric-card metric-card--blue">
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总预约</span>
<strong>{{ formatNumber(dashboard.summary.appointment_count) }}</strong>
<small :class="compareClass(dashboard.summary.appointment_compare_rate)">
{{ compareText(dashboard.summary.appointment_compare_rate) }}
</small>
</div>
</article>
<article class="metric-card metric-card--violet">
<div class="metric-icon"><el-icon><DocumentChecked /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总诊单</span>
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
<small>排除取消拒收和退款订单</small>
</div>
</article>
<article class="metric-card metric-card--amber">
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
<div>
<span>{{ dashboard.meta.time_label || '今日' }}总业绩</span>
<strong>{{ formatMoney(dashboard.summary.order_amount) }}</strong>
<small>按业务订单创建人归属</small>
</div>
</article>
</section>
<section class="panel employee-panel">
<div class="panel-heading">
<div>
<h2>本组员工挂号与预约统计</h2>
<p>挂号按已支付且实收低于 10 元的支付订单统计预约按预约记录统计</p>
</div>
<span class="panel-badge">{{ dashboard.meta.time_label || '当前范围' }}</span>
</div>
<el-table
:data="dashboard.employee_rows"
row-key="id"
:tree-props="{ children: 'children' }"
default-expand-all
class="stats-table"
>
<el-table-column prop="name" label="部门 / 员工" min-width="250" fixed="left">
<template #default="{ row }">
<div class="name-cell" :class="`is-${row.row_type}`">
<span v-if="row.row_type === 'department'" class="dept-dot" />
<el-avatar v-else :size="26">{{ avatarText(row.name) }}</el-avatar>
<strong>{{ row.name }}</strong>
<em v-if="row.row_type === 'department'">{{ row.member_count }} </em>
</div>
</template>
</el-table-column>
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
<el-table-column prop="day_after_count" label="后日预约" min-width="105" align="right" sortable />
<el-table-column prop="order_count" label="诊单" min-width="86" align="right" sortable />
<el-table-column label="业绩" min-width="128" align="right" sortable :sort-method="sortAmount">
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
</el-table-column>
<el-table-column label="挂号较上期" min-width="112" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
{{ compactCompare(row.registration_compare_rate) }}
</span>
</template>
</el-table-column>
<el-table-column label="预约较上期" min-width="112" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
{{ compactCompare(row.appointment_compare_rate) }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="92" align="center">
<template #default><span class="status-pill"><i />正常</span></template>
</el-table-column>
<template #empty><el-empty description="当前权限与筛选范围内暂无医助数据" /></template>
</el-table>
</section>
<section class="panel target-panel">
<div class="panel-heading">
<div>
<h2>一诊诊金目标追踪</h2>
<p>{{ dashboard.target.year }} · {{ dashboard.target.scope_note }}</p>
</div>
<span class="panel-badge">{{ dashboard.target.department_count || 0 }} 个目标部门</span>
</div>
<div class="target-layout">
<div class="target-summary">
<div class="target-numbers">
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
</div>
<el-progress
:percentage="progressValue(dashboard.target.completion_rate)"
:show-text="false"
:stroke-width="13"
color="#139a8c"
/>
<p v-if="Number(dashboard.target.target_amount) > 0">
尚差 {{ formatMoney(Math.max(0, Number(dashboard.target.target_amount) - Number(dashboard.target.actual_amount))) }} 达成年度目标
</p>
<p v-else class="target-empty">当前范围未维护可用目标实际业绩仍按统一口径正常统计</p>
</div>
<div class="target-chart-wrap">
<div class="chart-legend"><i class="actual" />累计完成 <i class="target" />累计目标</div>
<v-charts class="target-chart" :option="targetChartOption" autoresize />
</div>
</div>
</section>
<section class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>{{ dashboard.meta.time_label || '今日' }}业绩 TOP</h2><p>按业务订单创建人排序</p></div>
<span class="panel-badge">金额</span>
</div>
<div v-if="dashboard.rankings.performance.length" class="ranking-list">
<div v-for="(item, index) in dashboard.rankings.performance" :key="`performance-${item.admin_id}`" class="ranking-row">
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
<span>{{ item.name }}<small>{{ item.count }} </small></span>
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxPerformance) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong>
</div>
</div>
<el-empty v-else :image-size="52" description="暂无业绩数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>{{ dashboard.meta.time_label || '今日' }}挂号 TOP</h2><p>按低于 10 元的已支付订单笔数排序</p></div>
<span class="panel-badge">挂号</span>
</div>
<div v-if="dashboard.rankings.registrations.length" class="ranking-list ranking-list--blue">
<div v-for="(item, index) in dashboard.rankings.registrations" :key="`registration-${item.admin_id}`" class="ranking-row">
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
<span>{{ item.name }}<small>已支付小额订单</small></span>
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxRegistrations) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="52" description="暂无挂号数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div><h2>{{ dashboard.meta.time_label || '今日' }}预约 TOP</h2><p>按有效预约记录数量排序</p></div>
<span class="panel-badge">预约</span>
</div>
<div v-if="dashboard.rankings.appointments.length" class="ranking-list ranking-list--blue">
<div v-for="(item, index) in dashboard.rankings.appointments" :key="`appointment-${item.admin_id}`" class="ranking-row">
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
<span>{{ item.name }}<small>有效预约</small></span>
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxAppointments) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="52" description="暂无预约数据" />
</article>
</section>
<section class="panel department-panel">
<div class="panel-heading">
<div><h2>按部门统计</h2><p>员工按最深层有效归属部门唯一计入避免多部门重复累计</p></div>
<span class="panel-badge">{{ dashboard.departments.length }} 个部门</span>
</div>
<el-table :data="dashboard.departments" class="stats-table department-table">
<el-table-column prop="name" label="部门" min-width="220">
<template #default="{ row }"><strong>{{ row.name }}</strong></template>
</el-table-column>
<el-table-column prop="member_count" label="人数" min-width="90" align="right" sortable />
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
<el-table-column prop="order_count" label="诊单" min-width="90" align="right" sortable />
<el-table-column label="业绩" min-width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
</el-table-column>
<el-table-column label="挂号环比" min-width="110" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
{{ compactCompare(row.registration_compare_rate) }}
</span>
</template>
</el-table-column>
<el-table-column label="预约环比" min-width="110" align="right">
<template #default="{ row }">
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
{{ compactCompare(row.appointment_compare_rate) }}
</span>
</template>
</el-table-column>
<template #empty><el-empty description="当前范围暂无部门汇总" /></template>
</el-table>
</section>
<footer class="data-note">
<el-icon><InfoFilled /></el-icon>
<span>{{ dashboard.meta.registration_rule }}{{ dashboard.meta.appointment_rule }}{{ dashboard.meta.performance_rule }}</span>
</footer>
</div>
</template>
<script setup lang="ts" name="firstVisitRegistrationStatsPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
Calendar,
DocumentChecked,
Histogram,
InfoFilled,
Lock,
Refresh,
Wallet
} from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import {
firstVisitRegistrationStatsOverview,
type FirstVisitRegistrationStatsParams
} from '@/api/first_visit'
const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
member_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
},
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
summary: {
registration_count: 0, registration_compare_count: 0, registration_compare_rate: null as number | null,
appointment_count: 0, appointment_compare_count: 0, appointment_compare_rate: null as number | null,
order_count: 0, order_amount: 0, range_label: '今日'
},
employee_rows: [] as any[],
rankings: { performance: [] as any[], registrations: [] as any[], appointments: [] as any[] },
departments: [] as any[],
target: {
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0,
completion_rate: null as number | null, department_count: 0, scope_note: '',
months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
}
})
const loading = ref(false)
const dashboard = reactive(emptyDashboard())
const query = reactive<FirstVisitRegistrationStatsParams>({ time_type: 'today' })
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' }
]
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
const maxPerformance = computed(() => Math.max(0, ...dashboard.rankings.performance.map((item: any) => Number(item.value) || 0)))
const maxRegistrations = computed(() => Math.max(0, ...dashboard.rankings.registrations.map((item: any) => Number(item.value) || 0)))
const maxAppointments = computed(() => Math.max(0, ...dashboard.rankings.appointments.map((item: any) => Number(item.value) || 0)))
const targetChartOption = computed(() => ({
animationDuration: 450,
grid: { left: 18, right: 18, top: 18, bottom: 12, containLabel: true },
tooltip: {
trigger: 'axis',
valueFormatter: (value: number) => formatMoney(value)
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dashboard.target.months,
axisLine: { lineStyle: { color: '#d9e1e8' } },
axisTick: { show: false },
axisLabel: { color: '#7d8b9c', fontSize: 11 }
},
yAxis: {
type: 'value',
splitNumber: 3,
axisLabel: { color: '#8b98a8', formatter: (value: number) => compactMoney(value) },
splitLine: { lineStyle: { color: '#eef2f5' } }
},
series: [
{
name: '累计完成', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
data: dashboard.target.actual_cumulative,
lineStyle: { color: '#139a8c', width: 3 },
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
areaStyle: { color: 'rgba(19,154,140,.08)' }
},
{
name: '累计目标', type: 'line', smooth: true, showSymbol: false,
data: dashboard.target.target_cumulative,
lineStyle: { color: '#5f86e8', width: 2, type: 'dashed' }
}
]
}))
async function loadDashboard() {
loading.value = true
try {
const params: FirstVisitRegistrationStatsParams = { time_type: query.time_type }
if (query.dept_id) params.dept_id = Number(query.dept_id)
if (query.assistant_id) params.assistant_id = Number(query.assistant_id)
const result: any = await firstVisitRegistrationStatsOverview(params)
Object.assign(dashboard, emptyDashboard(), result || {})
} catch (error: any) {
ElMessage.error(error?.message || '挂号统计加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function handleDepartmentChange() {
delete query.assistant_id
loadDashboard()
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
}
function formatMoney(value: unknown) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function compactMoney(value: number) {
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}`
return `${Math.round(value)}`
}
function nullablePercent(value: unknown) {
return value === null || value === undefined ? '未设置' : `${Number(value).toFixed(1)}%`
}
function progressValue(value: unknown) {
return Math.min(100, Math.max(0, Number(value) || 0))
}
function compareText(value: unknown) {
if (value === null || value === undefined) return '上期无数据,暂不计算环比'
const number = Number(value)
if (number === 0) return '与上期持平'
return `${number > 0 ? '较上期增长' : '较上期下降'} ${Math.abs(number).toFixed(1)}%`
}
function compactCompare(value: unknown) {
if (value === null || value === undefined) return '—'
const number = Number(value)
return `${number > 0 ? '+' : ''}${number.toFixed(1)}%`
}
function compareClass(value: unknown) {
if (value === null || value === undefined || Number(value) === 0) return 'is-neutral'
return Number(value) > 0 ? 'is-up' : 'is-down'
}
function rankWidth(value: unknown, max: number) {
if (max <= 0) return '0%'
return `${Math.max(4, Math.min(100, Number(value || 0) / max * 100))}%`
}
function avatarText(name: unknown) {
const text = String(name || '员').trim()
return text.slice(-1)
}
function sortAmount(left: any, right: any) {
return Number(left?.order_amount || 0) - Number(right?.order_amount || 0)
}
onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.registration-stats {
--ink: #17243a;
--muted: #778598;
--line: #e3e9ef;
--canvas: #f4f6f8;
--teal: #139a8c;
--blue: #4f78e5;
min-height: 100%;
padding: 18px;
color: var(--ink);
background: var(--canvas);
}
.page-heading,
.filter-strip,
.panel,
.metric-card {
border: 1px solid var(--line);
background: #fff;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 76px;
padding: 14px 18px;
border-radius: 13px;
}
.heading-copy,
.heading-actions,
.filter-item,
.name-cell,
.chart-legend {
display: flex;
align-items: center;
}
.heading-copy { gap: 12px; }
.heading-mark {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 11px;
color: #fff;
background: var(--teal);
box-shadow: 0 8px 20px rgba(19, 154, 140, .18);
font-size: 20px;
}
h1, h2, p { margin: 0; }
h1 { font-size: 20px; line-height: 1.3; letter-spacing: .01em; }
h2 { font-size: 15px; line-height: 1.4; }
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
.heading-actions { gap: 12px; color: var(--muted); font-size: 12px; }
.scope-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 9px;
border: 1px solid #cbe8e3;
border-radius: 7px;
color: #127f75;
background: #f1faf8;
}
.filter-strip {
display: flex;
align-items: center;
gap: 22px;
margin-top: 14px;
padding: 11px 14px;
border-radius: 11px;
}
.filter-item { gap: 9px; color: #68778b; font-size: 13px; }
.dept-select { width: 220px; }
.employee-select { width: 180px; }
.filter-summary { margin-left: auto; text-align: right; }
.filter-summary strong { display: block; font-size: 13px; }
.filter-summary span { color: var(--muted); font-size: 11px; }
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.metric-card {
display: flex;
align-items: center;
gap: 13px;
min-height: 114px;
padding: 18px;
border-radius: 12px;
}
.metric-icon {
display: grid;
width: 42px;
height: 42px;
flex: 0 0 42px;
place-items: center;
border-radius: 11px;
font-size: 20px;
}
.metric-card--teal .metric-icon { color: #107f75; background: #e9f7f5; }
.metric-card--blue .metric-icon { color: #416bd7; background: #edf1ff; }
.metric-card--violet .metric-icon { color: #7257c7; background: #f2efff; }
.metric-card--amber .metric-icon { color: #bc7428; background: #fff4e7; }
.metric-card span { display: block; color: #748296; font-size: 13px; }
.metric-card strong { display: block; margin: 4px 0 2px; font-size: 27px; line-height: 1.15; }
.metric-card small { color: #8b98a8; font-size: 11px; }
.metric-card small.is-up, .rate-text.is-up { color: #17956f; }
.metric-card small.is-down, .rate-text.is-down { color: #e25858; }
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 15px 16px 13px;
}
.panel-badge {
padding: 4px 8px;
border-radius: 6px;
color: #758497;
background: #f2f5f7;
font-size: 11px;
}
.stats-table { --el-table-header-bg-color: #f7f9fb; --el-table-border-color: #e7ecf1; }
.stats-table :deep(th.el-table__cell) { height: 42px; color: #68778b; font-weight: 500; }
.stats-table :deep(td.el-table__cell) { height: 47px; }
.stats-table :deep(.el-table__row--level-0) { background: #fafcfd; }
.name-cell { gap: 9px; }
.name-cell em { color: #8d99a7; font-size: 11px; font-style: normal; font-weight: 400; }
.name-cell.is-department strong { font-weight: 700; }
.name-cell :deep(.el-avatar) { color: #167e75; background: #e8f5f3; font-size: 11px; }
.dept-dot { width: 8px; height: 8px; border-radius: 3px; background: var(--teal); }
.status-pill { display: inline-flex; align-items: center; gap: 5px; color: #218d71; font-size: 12px; }
.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: #34b38d; }
.rate-text { font-size: 12px; }
.rate-text.is-neutral { color: #8b98a8; }
.target-layout { display: grid; grid-template-columns: minmax(340px, .8fr) minmax(480px, 1.2fr); border-top: 1px solid #edf1f4; }
.target-summary { display: flex; flex-direction: column; justify-content: center; padding: 24px 22px; border-right: 1px solid #edf1f4; }
.target-numbers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-bottom: 22px; }
.target-numbers span { display: block; color: var(--muted); font-size: 12px; }
.target-numbers strong { display: block; margin-top: 5px; font-size: 20px; }
.target-numbers .is-teal { color: var(--teal); }
.target-summary p { margin-top: 9px; color: var(--muted); font-size: 11px; }
.target-summary .target-empty { color: #b17832; }
.target-chart-wrap { position: relative; min-height: 238px; padding: 10px 14px 4px; }
.target-chart { width: 100%; height: 225px; }
.chart-legend { position: absolute; z-index: 2; top: 11px; right: 18px; gap: 6px; color: #778598; font-size: 11px; }
.chart-legend i { width: 18px; height: 3px; margin-left: 8px; border-radius: 2px; }
.chart-legend .actual { background: var(--teal); }
.chart-legend .target { background: repeating-linear-gradient(90deg, var(--blue) 0 5px, transparent 5px 8px); }
.ranking-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.ranking-panel { min-height: 286px; }
.ranking-list { padding: 2px 16px 16px; }
.ranking-row { display: grid; grid-template-columns: 30px minmax(86px, .8fr) minmax(64px, 1fr) auto; align-items: center; gap: 10px; min-height: 43px; border-top: 1px solid #eff2f5; }
.ranking-row > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 7px; color: #8491a1; background: #f1f4f6; font-size: 11px; }
.ranking-row > b.is-top { color: #fff; background: var(--teal); }
.ranking-list--blue .ranking-row > b.is-top { background: var(--blue); }
.ranking-row > span { overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.ranking-row small { margin-left: 6px; color: #939eab; font-size: 10px; font-weight: 400; }
.ranking-row > strong { text-align: right; font-size: 12px; }
.rank-track { height: 7px; overflow: hidden; border-radius: 5px; background: #edf1f4; }
.rank-track i { display: block; height: 100%; border-radius: inherit; background: var(--teal); }
.ranking-list--blue .rank-track i { background: var(--blue); }
.department-table { border-top: 1px solid #edf1f4; }
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
@media (max-width: 1180px) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.filter-summary { margin-left: 0; }
.target-layout { grid-template-columns: 1fr; }
.target-summary { border-right: 0; border-bottom: 1px solid #edf1f4; }
}
@media (max-width: 820px) {
.registration-stats { padding: 10px; }
.page-heading { align-items: flex-start; flex-direction: column; }
.heading-actions { flex-wrap: wrap; }
.update-time { display: none; }
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item { width: 100%; justify-content: space-between; }
.dept-select, .employee-select { width: calc(100% - 52px); }
.filter-item--time { justify-content: flex-start; }
.target-numbers { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,147 @@
export const WECOM_WIDGET_TEMPLATE_IDS = [
'bubble',
'pill',
'card',
'message',
'edge',
'bar'
] as const
export type WecomWidgetTemplateId = (typeof WECOM_WIDGET_TEMPLATE_IDS)[number]
export type WecomWidgetPosition = 'bottom-right' | 'bottom-left'
export interface WecomWidgetConfig {
v: 1
enabled: boolean
template: WecomWidgetTemplateId
position: WecomWidgetPosition
title: string
subtitle: string
button_text: string
primary_color: string
bottom_offset: number
show_mobile: boolean
}
export interface WecomWidgetTemplateOption {
id: WecomWidgetTemplateId
name: string
description: string
scene: string
}
export const DEFAULT_WECOM_WIDGET_CONFIG: Readonly<WecomWidgetConfig> = Object.freeze({
v: 1,
enabled: false,
template: 'bubble',
position: 'bottom-right',
title: '专属顾问在线',
subtitle: '点击添加企业微信,获取一对一服务',
button_text: '立即咨询',
primary_color: '#139A8C',
bottom_offset: 28,
show_mobile: true
})
export const WECOM_WIDGET_TEMPLATES: ReadonlyArray<WecomWidgetTemplateOption> = Object.freeze([
{
id: 'bubble',
name: '轻巧气泡',
description: '圆形入口,占用空间最少',
scene: '内容型页面'
},
{
id: 'pill',
name: '行动胶囊',
description: '图标与按钮文案同时露出',
scene: '营销落地页'
},
{
id: 'card',
name: '顾问名片',
description: '完整呈现标题、说明与行动按钮',
scene: '高意向咨询'
},
{
id: 'message',
name: '消息提醒',
description: '模拟新消息,视觉提醒更明确',
scene: '活动推广页'
},
{
id: 'edge',
name: '贴边咨询',
description: '沿浏览器边缘停靠,干扰更低',
scene: '工具与内容页'
},
{
id: 'bar',
name: '底部咨询条',
description: '宽幅行动区,移动端更醒目',
scene: '移动端页面'
}
])
const TEMPLATE_SET = new Set<string>(WECOM_WIDGET_TEMPLATE_IDS)
const POSITION_SET = new Set<string>(['bottom-right', 'bottom-left'])
const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/i
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value !== 'string' || !value.trim()) return {}
try {
const parsed: unknown = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {}
} catch {
return {}
}
}
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (value === true || value === 1 || value === '1' || value === 'true') return true
if (value === false || value === 0 || value === '0' || value === 'false') return false
return fallback
}
function normalizeText(value: unknown, fallback: string, maxLength: number): string {
if (value === undefined || value === null) return fallback
return Array.from(String(value).trim()).slice(0, maxLength).join('')
}
export function normalizeWecomWidgetConfig(value: unknown): WecomWidgetConfig {
const source = asRecord(value)
const template = String(source.template || '')
const position = String(source.position || '')
const color = String(source.primary_color || '').trim().toUpperCase()
const rawOffset = Number(source.bottom_offset)
const bottomOffset = Number.isFinite(rawOffset)
? Math.min(160, Math.max(16, Math.round(rawOffset)))
: DEFAULT_WECOM_WIDGET_CONFIG.bottom_offset
return {
v: 1,
enabled: normalizeBoolean(source.enabled, DEFAULT_WECOM_WIDGET_CONFIG.enabled),
template: TEMPLATE_SET.has(template)
? template as WecomWidgetTemplateId
: DEFAULT_WECOM_WIDGET_CONFIG.template,
position: POSITION_SET.has(position)
? position as WecomWidgetPosition
: DEFAULT_WECOM_WIDGET_CONFIG.position,
title: normalizeText(source.title, DEFAULT_WECOM_WIDGET_CONFIG.title, 24),
subtitle: normalizeText(source.subtitle, DEFAULT_WECOM_WIDGET_CONFIG.subtitle, 48),
button_text: normalizeText(source.button_text, DEFAULT_WECOM_WIDGET_CONFIG.button_text, 12),
primary_color: HEX_COLOR_PATTERN.test(color)
? color
: DEFAULT_WECOM_WIDGET_CONFIG.primary_color,
bottom_offset: bottomOffset,
show_mobile: normalizeBoolean(source.show_mobile, DEFAULT_WECOM_WIDGET_CONFIG.show_mobile)
}
}
export function cloneDefaultWecomWidgetConfig(): WecomWidgetConfig {
return { ...DEFAULT_WECOM_WIDGET_CONFIG }
}
@@ -0,0 +1,988 @@
<template>
<div class="promotion-page" v-loading="loading" element-loading-text="正在加载推广配置">
<header class="page-header">
<div class="heading-copy">
<span class="heading-icon"><el-icon><Promotion /></el-icon></span>
<div>
<h1>企业微信获客助手</h1>
<p>对接企业微信获客助手官方 API按角色与部门管理获客成员并将链接安全分流到推广页面</p>
</div>
</div>
<div class="heading-actions">
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ overview.meta.scope_label || '当前数据范围' }}</span>
<span v-if="overview.meta.generated_at" class="update-time">更新于 {{ overview.meta.generated_at }}</span>
<el-button :icon="Refresh" :loading="loading || customerStatsLoading" @click="refreshCurrentTab">刷新</el-button>
</div>
</header>
<section class="metric-grid">
<article class="metric-card">
<span class="metric-icon is-teal"><el-icon><OfficeBuilding /></el-icon></span>
<div><small>内部应用</small><strong>{{ overview.summary.configured_apps }}</strong><p>直接读取服务器环境配置</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-blue"><el-icon><SetUp /></el-icon></span>
<div><small>分流方案</small><strong>{{ overview.summary.pool_count }}</strong><p>每个方案生成独立 JS</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-green"><el-icon><Link /></el-icon></span>
<div><small>在线链接</small><strong>{{ overview.summary.online_links }}</strong><p>停用或超限自动排除</p></div>
</article>
<article class="metric-card">
<span class="metric-icon is-orange"><el-icon><Mouse /></el-icon></span>
<div><small>今日分流点击</small><strong>{{ overview.summary.today_clicks }}</strong><p>只记录脱敏访问数据</p></div>
</article>
</section>
<section class="workspace-card">
<nav class="tab-nav" aria-label="企业微信获客助手功能">
<button :class="{ active: activeTab === 'links' }" @click="activeTab = 'links'">
<el-icon><Connection /></el-icon>获客助手链接
</button>
<button :class="{ active: activeTab === 'customer-stats' }" @click="activeTab = 'customer-stats'">
<el-icon><DataAnalysis /></el-icon>获客客户统计
</button>
<button :class="{ active: activeTab === 'configuration' }" @click="activeTab = 'configuration'">
<el-icon><Key /></el-icon>应用配置
</button>
<button :class="{ active: activeTab === 'install' }" @click="activeTab = 'install'">
<el-icon><DocumentCopy /></el-icon>JS 安装
</button>
</nav>
<div v-if="activeTab === 'links'" class="tab-content links-tab">
<div class="section-heading">
<div>
<h2>获客助手链接分流</h2>
<p>访客点击时由服务端从当前可用的获客助手链接中按权重随机选择页面端不会暴露完整链接池</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
</div>
<div v-if="overview.pools.length" class="pool-layout">
<aside class="pool-sidebar">
<button
v-for="pool in overview.pools"
:key="pool.id"
class="pool-item"
:class="{ active: Number(pool.id) === selectedPoolId }"
@click="selectedPoolId = Number(pool.id)"
>
<span class="pool-status" :class="Number(pool.status) === 1 ? 'online' : 'offline'" />
<span><strong>{{ pool.name }}</strong><small>{{ linkCount(pool.id) }} 条链接 · {{ formatNumber(pool.click_count) }} 次点击</small></span>
<el-icon><ArrowRight /></el-icon>
</button>
</aside>
<div class="pool-main">
<div v-if="selectedPool" class="pool-toolbar">
<div>
<div class="pool-title-row">
<h3>{{ selectedPool.name }}</h3>
<span class="status-tag" :class="Number(selectedPool.status) === 1 ? 'is-online' : 'is-offline'">
{{ Number(selectedPool.status) === 1 ? '运行中' : '已停用' }}
</span>
<span class="owner-tag">{{ selectedPool.dept_name || '未分部门' }} · {{ selectedPool.owner_name || '系统' }}</span>
</div>
<p>公开键{{ selectedPool.public_key }}</p>
</div>
<div class="toolbar-actions">
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
<el-button :icon="Refresh" :loading="syncingRemote" @click="syncRemoteLinks">同步企业微信</el-button>
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button type="danger" plain :icon="Delete" @click="removePool(selectedPool)">删除</el-button>
<el-button type="primary" :icon="Plus" @click="openLinkDialog()">创建官方获客链接</el-button>
</div>
</div>
<el-alert
v-if="selectedLegacyCount > 0 && selectedOfficialCount === 0"
class="legacy-sync-alert"
type="warning"
show-icon
:closable="false"
:title="`当前方案的 ${selectedLegacyCount} 条链接都是历史手工链接,企业微信同步不会返回它们`"
description="官方接口只返回当前应用通过 API 创建的获客链接。现有链接仍可参与本地分流;如需同步 link_id、客户和官方统计,请点击“创建官方获客链接”。"
/>
<el-table :data="selectedLinks" class="link-table" stripe>
<el-table-column label="推广成员 / 分组" min-width="180" fixed="left">
<template #default="{ row }">
<div class="member-cell"><strong>{{ row.name }}</strong><small>{{ row.group_name || '默认分组' }}</small></div>
</template>
</el-table-column>
<el-table-column label="企业微信链接 ID" min-width="180">
<template #default="{ row }">
<span v-if="row.remote_link_id" class="remote-id" :title="row.remote_link_id">{{ row.remote_link_id }}</span>
<span v-else class="legacy-tag">历史手工链接</span>
</template>
</el-table-column>
<el-table-column label="可用成员" min-width="150">
<template #default="{ row }">
<span>{{ remoteMemberLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="权重" prop="weight" width="84" align="center" />
<el-table-column label="今日 / 上限" min-width="120" align="center">
<template #default="{ row }">{{ todayCount(row) }} / {{ Number(row.daily_limit) ? row.daily_limit : '不限' }}</template>
</el-table-column>
<el-table-column label="累计点击" prop="click_count" min-width="105" align="center" />
<el-table-column label="当前可用性" min-width="130">
<template #default="{ row }">
<span class="availability" :class="eligibility(row).className">{{ eligibility(row).label }}</span>
</template>
</el-table-column>
<el-table-column label="上线" width="90" align="center">
<template #default="{ row }">
<el-switch :model-value="Number(row.status) === 1" :disabled="Number(row.remote_status) === 2" @change="(value) => handleToggle(row, value)" />
</template>
</el-table-column>
<el-table-column label="操作" min-width="245" fixed="right">
<template #default="{ row }">
<el-button v-if="row.is_official && Number(row.remote_status) !== 2" type="primary" link :loading="refreshingLinkId === Number(row.id)" @click="refreshRemoteDetail(row)">刷新详情</el-button>
<el-button v-if="!row.is_official || Number(row.remote_status) !== 2" type="primary" link @click="openLinkDialog(row)">编辑</el-button>
<el-dropdown v-if="row.is_official" trigger="click" @command="(command) => handleLinkCommand(command, row)">
<el-button type="primary" link>更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="local">从本地移除</el-dropdown-item>
<el-dropdown-item v-if="Number(row.remote_status) !== 2" command="remote" divided class="danger-command">永久删除企业微信链接</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-else type="danger" link @click="removeLink(row)">从本地移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="当前方案还没有获客助手链接" /></template>
</el-table>
</div>
</div>
<el-empty v-else description="先创建一个分流方案,再添加企业微信获客助手链接">
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
</el-empty>
</div>
<div v-else-if="activeTab === 'customer-stats'" class="tab-content customer-stats-tab">
<div class="section-heading customer-stats-heading">
<div>
<h2>获客客户消息统计</h2>
<p>统计当前权限范围内由获客助手链接添加的客户以及客户向承接成员发送消息的情况</p>
</div>
<div class="customer-heading-actions">
<span v-if="customerStats.meta.last_synced_at" class="update-time">最近同步 {{ customerStats.meta.last_synced_at }}</span>
<el-button type="primary" :icon="Refresh" :loading="syncingCustomers" @click="syncCustomers">同步客户</el-button>
</div>
</div>
<section class="customer-metric-grid" aria-label="获客客户消息汇总">
<article class="customer-metric-card">
<span class="metric-icon is-teal"><el-icon><User /></el-icon></span>
<div><small>获客客户数</small><strong>{{ formatNumber(customerStats.summary.customer_count) }}</strong><p>当前筛选范围内的去重客户</p></div>
</article>
<article class="customer-metric-card">
<span class="metric-icon is-blue"><el-icon><ChatDotRound /></el-icon></span>
<div><small>已发消息客户数</small><strong>{{ formatNumber(customerStats.summary.messaged_customer_count) }}</strong><p>至少发送过一条消息</p></div>
</article>
<article class="customer-metric-card">
<span class="metric-icon is-green"><el-icon><DataAnalysis /></el-icon></span>
<div><small>消息客户率</small><strong>{{ formatCustomerRate(customerStats.summary.message_customer_rate) }}</strong><p>已发消息客户占获客客户比例</p></div>
</article>
<article class="customer-metric-card">
<span class="metric-icon is-orange"><el-icon><Connection /></el-icon></span>
<div><small>累计接收消息数</small><strong>{{ formatNumber(customerStats.summary.received_message_count) }}</strong><p>已精确统计 {{ formatNumber(customerStats.summary.message_count_known_count) }} 位客户</p></div>
</article>
</section>
<el-alert
v-if="!overview.config.callback_ready"
class="chatkey-alert"
type="warning"
show-icon
:closable="false"
title="消息次数回调尚未配置完成"
description="请在企业微信获客助手的 API 接入场景中配置本页“应用配置”展示的消息回调地址,并在服务器配置 contact_callback_token 与 contact_callback_aes_key。未完成前只能同步客户及聊天状态,无法得到精确接收消息次数。"
/>
<el-alert
class="chatkey-alert"
type="info"
show-icon
:closable="false"
title="消息统计依赖企业微信 ChatKey"
description="ChatKey 仅在事件产生后的 30 分钟内有效。服务端收到企业微信回调后会立即拉取会话详情;手动同步用于刷新客户清单,无法补取已过期的会话消息。"
/>
<div class="customer-filter-bar">
<el-form :model="customerFilters" inline label-position="top">
<el-form-item label="来源链接">
<el-select v-model="customerFilters.link_id" clearable filterable placeholder="全部链接">
<el-option v-for="option in customerLinkOptions" :key="String(option.id)" :label="option.name" :value="option.id" />
</el-select>
</el-form-item>
<el-form-item label="承接成员">
<el-select v-model="customerFilters.userid" clearable filterable placeholder="全部成员">
<el-option v-for="option in customerMemberOptions" :key="option.userid || String(option.id)" :label="memberFilterLabel(option)" :value="option.userid || String(option.id)" />
</el-select>
</el-form-item>
<el-form-item label="聊天状态">
<el-select v-model="customerFilters.chat_status" clearable placeholder="全部状态">
<el-option label="已发过消息" value="messaged" />
<el-option label="未发过消息" value="silent" />
<el-option label="状态未知" value="unknown" />
</el-select>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" :icon="Search" :loading="customerStatsLoading" @click="applyCustomerFilters">查询</el-button>
<el-button @click="resetCustomerFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-loading="customerStatsLoading" :data="customerStats.rows" class="customer-table" stripe>
<el-table-column label="客户标识(已脱敏)" min-width="190" fixed="left">
<template #default="{ row }">
<span class="customer-identifier" :title="customerIdentifier(row)">{{ customerIdentifier(row) }}</span>
</template>
</el-table-column>
<el-table-column label="来源链接" min-width="190">
<template #default="{ row }">
<div class="member-cell"><strong>{{ customerLinkName(row) }}</strong><small>{{ customerLinkId(row) }}</small></div>
</template>
</el-table-column>
<el-table-column label="承接成员 / 部门" min-width="175">
<template #default="{ row }">
<div class="member-cell"><strong>{{ row.member_name || row.owner_name || '未匹配成员' }}</strong><small>{{ row.department_name || row.dept_name || '未分部门' }}</small></div>
</template>
</el-table-column>
<el-table-column label="是否发过消息" width="125" align="center">
<template #default="{ row }">
<span class="message-state" :class="`is-${customerChatState(row)}`">{{ customerChatStateLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="接收消息数" width="120" align="right">
<template #default="{ row }"><strong class="message-count" :class="{ muted: !customerMessageCountKnown(row) }">{{ customerMessageCountLabel(row) }}</strong></template>
</el-table-column>
<el-table-column label="最近同步时间" min-width="165">
<template #default="{ row }">{{ formatCustomerTime(row.last_synced_at || row.last_sync_time || row.synced_at || row.sync_time) }}</template>
</el-table-column>
<el-table-column label="最近消息时间" min-width="165">
<template #default="{ row }"><span :class="{ muted: !customerLastMessageTime(row) }">{{ formatCustomerTime(customerLastMessageTime(row)) }}</span></template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="当前筛选范围暂无获客客户数据" /></template>
</el-table>
<div class="customer-pagination">
<span> {{ formatNumber(customerPager.total) }} 位客户</span>
<el-pagination
v-model:current-page="customerPager.page_no"
v-model:page-size="customerPager.page_size"
background
layout="sizes, prev, pager, next"
:page-sizes="[10, 20, 50, 100]"
:total="customerPager.total"
@current-change="loadCustomerStats"
@size-change="handleCustomerPageSizeChange"
/>
</div>
</div>
<div v-else-if="activeTab === 'configuration'" class="tab-content authorization-tab">
<div class="section-heading">
<div>
<h2>企业微信内部应用配置</h2>
<p>直接读取服务器现有的 work_wechat 配置密钥不会返回浏览器也不需要第三方应用授权</p>
</div>
</div>
<div class="configuration-panel" :class="overview.config.ready ? 'is-ready' : 'is-pending'">
<div class="configuration-state">
<span><el-icon><component :is="overview.config.ready ? CircleCheck : Warning" /></el-icon></span>
<div>
<strong>{{ overview.config.ready ? '内部应用已就绪' : '内部应用配置不完整' }}</strong>
<p v-if="overview.config.ready">已识别 CorpID 与应用 Secret点击验证 API可同时检查可信 IP获客助手开通状态和应用权限</p>
<p v-else>请补齐服务器 .env [work_wechat] 配置页面不会展示 Secret 内容</p>
</div>
</div>
<div v-if="overview.config.missing?.length" class="missing-fields">
缺少<code v-for="item in overview.config.missing" :key="item">{{ item }}</code>
</div>
<dl class="callback-list">
<div><dt>企业 CorpID</dt><dd>{{ overview.config.corp_id_masked || '未配置' }}</dd><span>{{ overview.config.corp_id_masked ? '已识别' : '缺失' }}</span></div>
<div><dt>应用 AgentID</dt><dd>{{ overview.config.agent_id || '未配置' }}</dd><span>{{ overview.config.agent_id ? '已识别' : '缺失' }}</span></div>
<div><dt>应用 Secret</dt><dd>仅保存在服务器环境变量中</dd><span>{{ overview.config.secret_configured ? '已配置' : '缺失' }}</span></div>
<div><dt>消息回调地址</dt><dd>{{ overview.config.callback_url || '未生成' }}</dd><span>{{ overview.config.callback_ready ? '已配置密钥' : '待配置 Token/AESKey' }}</span></div>
</dl>
<div class="configuration-actions">
<el-button type="primary" :loading="checkingApi" @click="checkApiPermission">验证获客助手 API</el-button>
<el-link v-if="overview.config.official_doc" :href="overview.config.official_doc" target="_blank" type="primary">查看企业微信官方文档</el-link>
</div>
</div>
</div>
<div v-else class="tab-content install-tab">
<div class="section-heading">
<div>
<h2>安装 JS 与浮窗客服</h2>
<p>为每个分流方案配置独立浮窗基础脚本仍只负责展示入口捕获点击并请求服务端分流</p>
</div>
<el-select v-model="selectedInstallPoolId" placeholder="选择分流方案" class="install-pool-select">
<el-option v-for="pool in overview.pools" :key="pool.id" :label="pool.name" :value="Number(pool.id)" />
</el-select>
</div>
<WecomFloatingWidgetBuilder
v-if="selectedInstallPool"
:key="Number(selectedInstallPool.id)"
:pool="selectedInstallPool"
@saved="handleWidgetSaved"
/>
<el-empty v-else description="请先创建分流方案,系统会自动生成 JS 安装代码" />
</div>
</section>
<el-dialog v-model="poolDialogVisible" :title="poolForm.id ? '编辑分流方案' : '新建分流方案'" width="560px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="方案名称" required><el-input v-model="poolForm.name" maxlength="60" show-word-limit placeholder="例如:官网咨询分流" /></el-form-item>
<el-form-item label="兜底获客助手链接">
<el-input v-model="poolForm.fallback_url" placeholder="可选;没有可用成员时跳转" />
<span class="form-tip">仅接受企业微信获客助手生成的 https://work.weixin.qq.com/ca/... </span>
</el-form-item>
<el-form-item label="运行状态"><el-switch v-model="poolForm.status" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">保存方案</el-button></template>
</el-dialog>
<el-dialog v-model="linkDialogVisible" :title="linkForm.id ? '编辑获客助手链接' : '创建企业微信官方获客链接'" width="720px" destroy-on-close>
<el-form label-position="top" class="link-form">
<div class="form-grid">
<el-form-item label="获客链接名称" required><el-input v-model="linkForm.name" maxlength="80" placeholder="用于企业微信和后台识别" /></el-form-item>
<el-form-item label="分组"><el-input v-model="linkForm.group_name" maxlength="60" placeholder="默认分组" /></el-form-item>
</div>
<el-form-item v-if="!linkForm.legacy" label="获客成员" required>
<el-select v-model="linkForm.member_admin_ids" multiple filterable collapse-tags collapse-tags-tooltip placeholder="仅显示当前角色和部门可管理、且已绑定企业微信的成员" style="width: 100%">
<el-option v-for="member in overview.member_options" :key="member.id" :label="memberOptionLabel(member)" :value="Number(member.id)" />
</el-select>
<span class="form-tip">官方链接按这里的企业微信 userid 建立可见范围越权成员不会出现在列表中</span>
</el-form-item>
<el-form-item v-else label="企业微信获客助手链接" required>
<el-input v-model="linkForm.wecom_url" type="textarea" :rows="2" :placeholder="overview.customer_acquisition_link_example" />
<span class="form-tip">这是历史手工链接只更新本地分流规则不会调用企业微信创建或修改接口</span>
</el-form-item>
<div class="form-grid">
<el-form-item label="分流权重" required><el-input-number v-model="linkForm.weight" :min="1" :max="100" controls-position="right" /></el-form-item>
<el-form-item label="每日点击上限"><el-input-number v-model="linkForm.daily_limit" :min="0" :max="1000000" controls-position="right" /><span class="form-tip">0 表示不限</span></el-form-item>
</div>
<el-form-item v-if="!linkForm.legacy" label="添加客户时跳过验证">
<el-switch v-model="linkForm.skip_verify" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
</el-form-item>
<el-form-item label="上线状态"><el-switch v-model="linkForm.status" :active-value="1" :inactive-value="0" active-text="上线" inactive-text="下线" /></el-form-item>
<el-form-item label="生效时间段">
<el-date-picker v-model="linkForm.active_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" />
<span class="form-tip">不选择表示长期有效</span>
</el-form-item>
<el-form-item label="备注"><el-input v-model="linkForm.remark" maxlength="255" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="linkDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingLink" @click="saveLink">保存链接</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="firstVisitWecomPromotionPage">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowDown, ArrowRight, ChatDotRound, CircleCheck, Connection, DataAnalysis, Delete,
DocumentCopy, Edit, Key, Link, Lock, Mouse, OfficeBuilding, Plus,
Promotion, Refresh, Search, SetUp, User, Warning
} from '@element-plus/icons-vue'
import {
wecomPromotionCheckApiPermission,
wecomPromotionCustomerStats,
wecomPromotionDeleteLink,
wecomPromotionDeleteRemoteLink,
wecomPromotionDeletePool,
wecomPromotionOverview,
wecomPromotionRemoteLinkDetail,
wecomPromotionSaveLink,
wecomPromotionSavePool,
wecomPromotionSyncCustomers,
wecomPromotionSyncRemoteLinks,
wecomPromotionToggleLink
} from '@/api/first_visit'
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
const emptyOverview = () => ({
meta: { scope_label: '', generated_at: '' },
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, callback_ready: false, callback_url: '', official_doc: '' },
summary: { configured_apps: 0, pool_count: 0, online_links: 0, today_clicks: 0 },
pools: [] as any[], links: [] as any[], member_options: [] as any[], customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
})
const overview = reactive(emptyOverview())
const loading = ref(false)
const activeTab = ref<TabName>('links')
const selectedPoolId = ref<number>()
const selectedInstallPoolId = ref<number>()
const poolDialogVisible = ref(false)
const linkDialogVisible = ref(false)
const savingPool = ref(false)
const savingLink = ref(false)
const checkingApi = ref(false)
const syncingRemote = ref(false)
const refreshingLinkId = ref(0)
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1 })
const linkForm = reactive({ id: 0, pool_id: 0, name: '', group_name: '默认分组', wecom_url: '', member_admin_ids: [] as number[], skip_verify: 0, legacy: false, weight: 1, status: 1, daily_limit: 0, active_range: [] as string[], remark: '' })
const customerStatsLoading = ref(false)
const customerStatsLoaded = ref(false)
const syncingCustomers = ref(false)
const customerFilters = reactive<{
link_id: number | string
userid: string
chat_status: WecomPromotionCustomerChatStatus
}>({ link_id: '', userid: '', chat_status: '' })
const customerPager = reactive({ page_no: 1, page_size: 20, total: 0 })
const customerStats = reactive({
summary: { customer_count: 0, messaged_customer_count: 0, message_customer_rate: 0, received_message_count: 0, message_count_known_count: 0 },
rows: [] as any[],
link_options: [] as Array<{ id: number | string; name: string }>,
member_options: [] as Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }>,
meta: { last_synced_at: '' }
})
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
const selectedLinks = computed(() => overview.links.filter((item: any) => Number(item.pool_id) === selectedPoolId.value))
const selectedOfficialCount = computed(() => selectedLinks.value.filter((item: any) => Boolean(item.remote_link_id)).length)
const selectedLegacyCount = computed(() => selectedLinks.value.length - selectedOfficialCount.value)
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
const customerLinkOptions = computed(() => customerStats.link_options.length
? customerStats.link_options
: overview.links
.filter((item: any) => item.id)
.map((item: any) => ({ id: Number(item.id), name: item.name || item.remote_link_id || `链接 ${item.id}` })))
const customerMemberOptions = computed(() => customerStats.member_options.length
? customerStats.member_options
: overview.member_options.map((item: any) => ({
id: Number(item.id),
userid: String(item.userid || ''),
name: item.name,
department_name: Array.isArray(item.dept_names) ? item.dept_names.join(' / ') : (item.department_name || item.dept_name || '')
})))
watch(() => overview.pools.map((item: any) => Number(item.id)).join(','), () => {
const ids = overview.pools.map((item: any) => Number(item.id))
if (!selectedPoolId.value || !ids.includes(selectedPoolId.value)) selectedPoolId.value = ids[0]
if (!selectedInstallPoolId.value || !ids.includes(selectedInstallPoolId.value)) selectedInstallPoolId.value = ids[0]
}, { immediate: true })
watch(activeTab, (tab) => {
if (tab === 'customer-stats' && !customerStatsLoaded.value) loadCustomerStats()
})
async function loadOverview() {
loading.value = true
try {
const result: any = await wecomPromotionOverview()
Object.assign(overview, emptyOverview(), result || {})
} catch (error: any) {
ElMessage.error(error?.message || '企业微信推广配置加载失败')
} finally {
loading.value = false
}
}
function linkCount(poolId: number) {
return overview.links.filter((item: any) => Number(item.pool_id) === Number(poolId)).length
}
function openPoolDialog(pool?: any) {
Object.assign(poolForm, pool ? { id: Number(pool.id), name: pool.name, fallback_url: pool.fallback_url || '', status: Number(pool.status) } : { id: 0, name: '', fallback_url: '', status: 1 })
poolDialogVisible.value = true
}
async function savePool() {
if (!poolForm.name.trim()) return ElMessage.warning('请输入分流方案名称')
savingPool.value = true
try {
const result: any = await wecomPromotionSavePool({ ...poolForm })
poolDialogVisible.value = false
await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id)
ElMessage.success('分流方案已保存')
} catch (error: any) {
ElMessage.error(error?.message || '分流方案保存失败')
} finally {
savingPool.value = false
}
}
async function removePool(pool: any) {
await ElMessageBox.confirm(`删除“${pool.name}”后,该方案下的获客助手链接也会停用。确认继续?`, '删除分流方案', { type: 'warning' })
try {
await wecomPromotionDeletePool({ id: Number(pool.id) })
ElMessage.success('分流方案已删除')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
function openLinkDialog(row?: any) {
if (!selectedPool.value) return
const range = row && Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)] : []
const selectedUserIds = new Set<string>((row?.range_userids || []).map((item: unknown) => String(item)))
const selectedMemberIds = overview.member_options
.filter((member: any) => selectedUserIds.has(String(member.userid)))
.map((member: any) => Number(member.id))
Object.assign(linkForm, row ? {
id: Number(row.id), pool_id: Number(row.pool_id),
name: row.name, group_name: row.group_name || '默认分组', wecom_url: row.wecom_url,
member_admin_ids: selectedMemberIds, skip_verify: Number(row.skip_verify) === 1 ? 1 : 0, legacy: !row.is_official,
weight: Number(row.weight) || 1, status: Number(row.status), daily_limit: Number(row.daily_limit) || 0,
active_range: range, remark: row.remark || ''
} : {
id: 0, pool_id: Number(selectedPool.value.id), name: '', group_name: '默认分组',
wecom_url: '', member_admin_ids: [], skip_verify: 0, legacy: false,
weight: 1, status: 1, daily_limit: 0, active_range: [], remark: ''
})
linkDialogVisible.value = true
}
async function saveLink() {
if (!linkForm.name.trim()) return ElMessage.warning('请输入获客链接名称')
if (linkForm.legacy && !linkForm.wecom_url.trim()) return ElMessage.warning('请填写企业微信获客助手链接')
if (!linkForm.legacy && !linkForm.member_admin_ids.length) return ElMessage.warning('请至少选择一名获客成员')
savingLink.value = true
try {
await wecomPromotionSaveLink({
...linkForm,
active_start: linkForm.active_range?.[0] || '',
active_end: linkForm.active_range?.[1] || ''
})
linkDialogVisible.value = false
await loadOverview()
ElMessage.success(linkForm.legacy ? '本地分流规则已保存' : '企业微信官方获客链接已保存')
} catch (error: any) {
ElMessage.error(error?.message || '获客助手链接保存失败')
} finally {
savingLink.value = false
}
}
async function checkApiPermission() {
checkingApi.value = true
try {
const result: any = await wecomPromotionCheckApiPermission()
if (result?.has_link) {
ElMessage.success(result?.message || 'API 验证通过:应用凭证、可信 IP 与获客助手权限均可用')
} else {
ElMessage.warning(result?.message || 'API 权限可用,但当前应用还没有创建官方获客链接')
}
} catch (error: any) {
ElMessage.error(error?.message || '获客助手 API 验证失败')
} finally {
checkingApi.value = false
}
}
async function refreshCurrentTab() {
await loadOverview()
if (activeTab.value === 'customer-stats') await loadCustomerStats()
}
function numberFrom(source: Record<string, any>, keys: string[]) {
for (const key of keys) {
if (source?.[key] !== undefined && source?.[key] !== null && source?.[key] !== '') {
const value = Number(source[key])
return Number.isFinite(value) ? value : 0
}
}
return 0
}
async function loadCustomerStats() {
customerStatsLoading.value = true
try {
const result: any = await wecomPromotionCustomerStats({
page_no: customerPager.page_no,
page_size: customerPager.page_size,
promotion_link_id: customerFilters.link_id ? Number(customerFilters.link_id) : undefined,
userid: customerFilters.userid || undefined,
chat_status: customerFilters.chat_status === ''
? undefined
: (customerFilters.chat_status === 'messaged' ? 1 : (customerFilters.chat_status === 'unknown' ? 2 : 0))
})
const summary = result?.summary || result?.stats || {}
const customerCount = numberFrom(summary, ['customer_count', 'total_customers', 'customer_total', 'total'])
const messagedCount = numberFrom(summary, ['messaged_customer_count', 'started_chat_count', 'chat_customer_count', 'message_customer_count', 'chatting_customers'])
const rateKeys = ['message_customer_rate', 'chat_customer_rate', 'messaged_rate', 'chat_rate']
const hasRate = rateKeys.some((key) => summary?.[key] !== undefined && summary?.[key] !== null && summary?.[key] !== '')
Object.assign(customerStats.summary, {
customer_count: customerCount,
messaged_customer_count: messagedCount,
message_customer_rate: hasRate ? numberFrom(summary, rateKeys) : (customerCount ? messagedCount / customerCount * 100 : 0),
received_message_count: numberFrom(summary, ['received_message_count', 'recv_msg_cnt', 'recv_msg_count', 'total_recv_msg_cnt', 'message_count']),
message_count_known_count: numberFrom(summary, ['message_count_known_count', 'exact_message_customer_count'])
})
customerStats.rows = Array.isArray(result?.lists)
? result.lists
: (Array.isArray(result?.list) ? result.list : (Array.isArray(result?.rows) ? result.rows : []))
customerPager.total = Number(result?.total ?? result?.count ?? result?.page?.total ?? customerStats.rows.length) || 0
const filters = result?.filters || {}
const linkOptions = result?.link_options || filters?.link_options || filters?.links
const memberOptions = result?.member_options || filters?.member_options || filters?.members
if (Array.isArray(linkOptions)) customerStats.link_options = linkOptions
if (Array.isArray(memberOptions)) customerStats.member_options = memberOptions
const latestSyncTime = customerStats.rows.reduce((latest: unknown, row: any) => {
const current = row.last_synced_at || row.last_sync_time || row.synced_at || row.sync_time || ''
return Number(current || 0) > Number(latest || 0) ? current : latest
}, '')
const syncedAt = result?.meta?.last_synced_at || result?.last_synced_at || latestSyncTime || customerStats.meta.last_synced_at || ''
Object.assign(customerStats.meta, result?.meta || {}, {
last_synced_at: syncedAt ? formatCustomerTime(syncedAt) : ''
})
customerStatsLoaded.value = true
} catch (error: any) {
ElMessage.error(error?.message || '获客客户统计加载失败')
} finally {
customerStatsLoading.value = false
}
}
function applyCustomerFilters() {
customerPager.page_no = 1
loadCustomerStats()
}
function resetCustomerFilters() {
Object.assign(customerFilters, { link_id: '', userid: '', chat_status: '' })
customerPager.page_no = 1
loadCustomerStats()
}
function handleCustomerPageSizeChange() {
customerPager.page_no = 1
loadCustomerStats()
}
async function syncCustomers() {
syncingCustomers.value = true
try {
const result: any = await wecomPromotionSyncCustomers({
promotion_link_id: customerFilters.link_id ? Number(customerFilters.link_id) : undefined
})
const scanned = Number(result?.scanned ?? result?.customer_scanned ?? result?.customers?.scanned ?? 0)
const created = Number(result?.created ?? result?.customer_created ?? result?.customers?.created ?? 0)
const updated = Number(result?.updated ?? result?.customer_updated ?? result?.customers?.updated ?? 0)
ElMessage.success(result?.message || `客户同步完成:扫描 ${formatNumber(scanned)} 位,新增 ${formatNumber(created)} 位,更新 ${formatNumber(updated)}`)
customerPager.page_no = 1
await Promise.all([loadOverview(), loadCustomerStats()])
} catch (error: any) {
ElMessage.error(error?.message || '获客客户同步失败')
} finally {
syncingCustomers.value = false
}
}
async function syncRemoteLinks() {
if (!selectedPool.value) return ElMessage.warning('请先选择分流方案')
syncingRemote.value = true
try {
const result: any = await wecomPromotionSyncRemoteLinks({ pool_id: Number(selectedPool.value.id) })
const message = `扫描 ${Number(result?.scanned || 0)} 条,新建 ${Number(result?.created || 0)} 条,更新 ${Number(result?.updated || 0)} 条,按权限跳过 ${Number(result?.skipped || 0)} 条,失败 ${Number(result?.failed || 0)}`
if (Number(result?.scanned || 0) === 0) {
await ElMessageBox.alert(
`${result?.empty_reason || '当前应用没有可同步的官方获客链接'}\n\n${result?.suggestion || ''}`,
'没有可同步的官方链接',
{ type: 'warning', confirmButtonText: '知道了' }
)
} else {
result?.failed ? ElMessage.warning(message) : ElMessage.success(message)
}
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '同步企业微信获客链接失败')
} finally {
syncingRemote.value = false
}
}
async function refreshRemoteDetail(row: any) {
refreshingLinkId.value = Number(row.id)
try {
await wecomPromotionRemoteLinkDetail({ id: Number(row.id) })
ElMessage.success('企业微信官方详情已刷新')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '刷新官方详情失败')
} finally {
refreshingLinkId.value = 0
}
}
async function handleLinkCommand(command: string, row: any) {
if (command === 'local') {
await removeLink(row)
return
}
if (command !== 'remote') return
await ElMessageBox.confirm(
`将永久删除企业微信后台中的“${row.name}”,现有推广页面会立即失效,且无法恢复。确认继续?`,
'永久删除企业微信获客链接',
{ type: 'error', confirmButtonText: '永久删除', cancelButtonText: '取消' }
)
try {
await wecomPromotionDeleteRemoteLink({ id: Number(row.id) })
ElMessage.success('企业微信获客链接已永久删除,本地记录已保留并停止分流')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '永久删除失败')
}
}
async function handleToggle(row: any, value: unknown) {
try {
await wecomPromotionToggleLink({ id: Number(row.id), status: value ? 1 : 0 })
row.status = value ? 1 : 0
ElMessage.success(value ? '链接已上线' : '链接已下线')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '状态更新失败')
await loadOverview()
}
}
async function removeLink(row: any) {
await ElMessageBox.confirm(`仅从本地分流池移除“${row.name}”,不会删除企业微信后台的官方链接。确认继续?`, '从本地移除', { type: 'warning' })
try {
await wecomPromotionDeleteLink({ id: Number(row.id) })
ElMessage.success('已从本地分流池移除')
await loadOverview()
} catch (error: any) {
ElMessage.error(error?.message || '删除失败')
}
}
function eligibility(row: any) {
const now = Math.floor(Date.now() / 1000)
if (Number(row.remote_status) === 2) return { label: '企业微信已删除', className: 'is-error' }
if (row.valid_customer_acquisition_link === false) return { label: '非获客助手链接', className: 'is-error' }
if (Number(row.status) !== 1) return { label: '已下线', className: 'is-muted' }
if (Number(row.active_start) > 0 && Number(row.active_start) > now) return { label: '尚未生效', className: 'is-waiting' }
if (Number(row.active_end) > 0 && Number(row.active_end) < now) return { label: '已过期', className: 'is-error' }
if (Number(row.daily_limit) > 0 && todayCount(row) >= Number(row.daily_limit)) return { label: '今日已达上限', className: 'is-waiting' }
return { label: '可参与分流', className: 'is-ok' }
}
function todayCount(row: any) {
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
return row.today_date === today ? Number(row.today_count || 0) : 0
}
function memberOptionLabel(member: any) {
const departments = Array.isArray(member.dept_names) && member.dept_names.length
? member.dept_names.join(' / ')
: '未分部门'
return `${member.name} · ${departments} · ${member.userid}`
}
function remoteMemberLabel(row: any) {
const userIds = Array.isArray(row.range_userids) ? row.range_userids : []
const names = userIds.map((userid: unknown) => {
const member = overview.member_options.find((item: any) => String(item.userid) === String(userid))
return member?.name || String(userid)
})
if (names.length) return names.length > 2 ? `${names.slice(0, 2).join('、')}${names.length}` : names.join('、')
const departments = Array.isArray(row.range_department_ids) ? row.range_department_ids : []
if (departments.length) return `企微部门 ${departments.join('、')}`
return row.is_official ? '未返回成员范围' : '手工配置'
}
function memberFilterLabel(member: any) {
const department = member.department_name || member.dept_name || ''
return department ? `${member.name} · ${department}` : member.name
}
function customerIdentifier(row: any) {
const masked = row.external_userid_masked || row.customer_id_masked || row.customer_masked || row.customer_name_masked
if (masked) return String(masked)
return maskIdentifier(row.external_userid || row.customer_id || row.id)
}
function maskIdentifier(value: unknown) {
const text = String(value || '').trim()
if (!text) return '-'
if (text.includes('*')) return text
if (text.length <= 4) return `${text.slice(0, 1)}***`
if (text.length <= 8) return `${text.slice(0, 2)}***${text.slice(-1)}`
return `${text.slice(0, 4)}****${text.slice(-4)}`
}
function customerLinkName(row: any) {
return row.link_name || row.source_link_name || row.promotion_link_name || '未匹配来源链接'
}
function customerLinkId(row: any) {
const linkId = row.link_id || row.remote_link_id || row.source_link_id
return linkId ? `链接 ID${linkId}` : '暂无链接 ID'
}
function customerMessageCount(row: any) {
return numberFrom(row, ['received_message_count', 'recv_msg_cnt', 'message_count', 'received_count'])
}
function customerMessageCountKnown(row: any) {
const explicit = row.message_count_known ?? row.message_count_exact ?? row.count_known
if (explicit !== undefined && explicit !== null) {
return explicit === true || explicit === 1 || explicit === '1' || explicit === 'true'
}
return customerMessageCount(row) > 0
}
function customerChatState(row: any): 'messaged' | 'silent' | 'unknown' {
const status = Number(row.chat_status)
if (status === 2) return 'unknown'
const explicit = row.has_messaged ?? row.has_message ?? row.messaged
if (explicit !== undefined && explicit !== null) {
return explicit === true || explicit === 1 || explicit === '1' || explicit === 'true' || explicit === 'messaged'
? 'messaged'
: 'silent'
}
return status === 1 || customerMessageCount(row) > 0 ? 'messaged' : 'silent'
}
function customerChatStateLabel(row: any) {
const state = customerChatState(row)
return state === 'messaged' ? '已发消息' : (state === 'unknown' ? '状态未知' : '未发消息')
}
function customerMessageCountLabel(row: any) {
if (customerMessageCountKnown(row)) return formatNumber(customerMessageCount(row))
const state = customerChatState(row)
if (state === 'silent') return '0'
return state === 'unknown' ? '未知' : '待回调'
}
function customerLastMessageTime(row: any) {
return row.last_message_at || row.last_chat_time || row.last_message_time || row.last_msg_time || row.event_time || ''
}
function formatCustomerTime(value: unknown) {
if (value === undefined || value === null || value === '' || value === 0 || value === '0') return '-'
if (typeof value === 'number' || /^\d+$/.test(String(value))) {
const timestamp = Number(value)
const date = new Date(timestamp > 100000000000 ? timestamp : timestamp * 1000)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', { hour12: false }).replaceAll('/', '-')
}
return String(value).replace('T', ' ').replace(/Z$/, '').slice(0, 19)
}
function formatCustomerRate(value: unknown) {
return `${Number(value || 0).toFixed(1)}%`
}
function formatNumber(value: unknown) {
return Number(value || 0).toLocaleString('zh-CN')
}
function formatPickerTime(value: unknown) {
const date = new Date(Number(value || 0) * 1000)
const pad = (number: number) => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
async function copyText(value: string, label: string) {
if (!value) return ElMessage.warning(`${label}暂无内容`)
try {
await navigator.clipboard.writeText(value)
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
}
ElMessage.success(`${label}已复制`)
}
async function handleWidgetSaved() {
await loadOverview()
}
onMounted(async () => {
await loadOverview()
})
</script>
<style scoped lang="scss">
.promotion-page {
--ink: #17243a;
--muted: #778598;
--line: #e1e7ed;
--canvas: #f4f6f8;
--teal: #139a8c;
min-height: 100%;
padding: 18px;
color: var(--ink);
background: var(--canvas);
}
h1, h2, h3, p { margin: 0; }
.page-header, .metric-card, .customer-metric-card, .workspace-card { border: 1px solid var(--line); background: #fff; }
.page-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 78px; padding: 14px 18px; border-radius: 13px; }
.heading-copy, .heading-actions, .pool-title-row, .toolbar-actions { display: flex; align-items: center; }
.heading-copy { gap: 12px; }
.heading-icon { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 11px; color: #fff; background: var(--teal); font-size: 21px; box-shadow: 0 8px 20px rgba(19,154,140,.18); }
.heading-copy h1 { font-size: 20px; }
.heading-copy p { margin-top: 4px; color: var(--muted); font-size: 12px; }
.heading-actions { gap: 10px; color: var(--muted); font-size: 12px; }
.scope-chip { display: inline-flex; align-items: center; gap: 5px; padding: 6px 9px; border: 1px solid #cce8e3; border-radius: 7px; color: #117f75; background: #f2faf8; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 14px; }
.metric-card { display: flex; align-items: center; gap: 13px; min-height: 104px; padding: 16px; border-radius: 11px; }
.metric-icon { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border-radius: 11px; font-size: 20px; }
.metric-icon.is-teal { color: #0e8b7e; background: #e8f7f4; }.metric-icon.is-blue { color: #3976e6; background: #edf3ff; }.metric-icon.is-green { color: #3b9c67; background: #edf8f1; }.metric-icon.is-orange { color: #d87a2d; background: #fff2e7; }
.metric-card small { color: #78869a; font-size: 12px; }.metric-card strong { display: block; margin: 3px 0; font-size: 25px; line-height: 1.15; }.metric-card p { color: #98a2af; font-size: 10px; }
.workspace-card { margin-top: 14px; border-radius: 13px; overflow: hidden; }
.tab-nav { display: flex; gap: 4px; padding: 0 18px; border-bottom: 1px solid var(--line); background: #fbfcfd; }
.tab-nav button { display: flex; align-items: center; gap: 7px; min-width: 132px; height: 54px; justify-content: center; border: 0; border-bottom: 3px solid transparent; color: #5e6d80; background: transparent; cursor: pointer; font-size: 14px; }
.tab-nav button.active { border-bottom-color: var(--teal); color: #0d8277; font-weight: 700; }
.tab-content { min-height: 460px; padding: 20px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
.section-heading h2 { font-size: 17px; }.section-heading p { margin-top: 5px; color: var(--muted); font-size: 12px; }
.pool-layout { display: grid; grid-template-columns: 252px minmax(0, 1fr); min-height: 430px; border: 1px solid var(--line); border-radius: 11px; overflow: hidden; }
.pool-sidebar { padding: 8px; border-right: 1px solid var(--line); background: #f7f9fa; }
.pool-item { display: grid; grid-template-columns: 9px 1fr 16px; align-items: center; gap: 9px; width: 100%; min-height: 62px; margin-bottom: 5px; padding: 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; cursor: pointer; }
.pool-item:hover { background: #fff; }.pool-item.active { border-color: #bfe1dc; background: #fff; box-shadow: 0 5px 16px rgba(25,69,70,.05); }
.pool-item strong, .pool-item small { display: block; }.pool-item strong { overflow: hidden; color: #253348; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.pool-item small { margin-top: 4px; color: #8c98a7; font-size: 10px; }
.pool-status { width: 8px; height: 8px; border-radius: 50%; }.pool-status.online { background: #18a277; }.pool-status.offline { background: #aab3bf; }.pool-item > .el-icon { color: #9ca7b4; }
.pool-main { min-width: 0; }.pool-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 78px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
.pool-title-row { gap: 8px; }.pool-title-row h3 { font-size: 15px; }.pool-toolbar p { margin-top: 6px; color: #8b97a6; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }.toolbar-actions { flex-wrap: wrap; justify-content: flex-end; gap: 7px; }.toolbar-actions .el-button + .el-button { margin-left: 0; }
.status-tag, .owner-tag, .availability { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 11px; font-size: 10px; }.status-tag.is-online, .availability.is-ok { color: #16895f; background: #eaf8ef; }.status-tag.is-offline, .availability.is-muted { color: #788696; background: #eef2f5; }.owner-tag { color: #50728c; background: #edf4f8; }.availability.is-error { color: #d94b4b; background: #ffeded; }.availability.is-waiting { color: #b86c1f; background: #fff1dd; }
.link-table, .account-table { --el-table-header-bg-color: #f7f9fb; }.link-table :deep(th.el-table__cell), .account-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.member-cell strong, .member-cell small { display: block; }.member-cell small { margin-top: 3px; color: #8d98a7; font-size: 10px; }.muted { color: #9aa4b0; }
.remote-id { display: block; overflow: hidden; color: #68778b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.legacy-tag { display: inline-flex; padding: 3px 7px; border-radius: 5px; color: #9a6b22; background: #fff5df; font-size: 10px; }.danger-command { color: #d94b4b; }
.customer-heading-actions { display: flex; align-items: center; gap: 12px; }
.customer-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.customer-metric-card { display: flex; align-items: center; gap: 12px; min-width: 0; min-height: 94px; padding: 14px; border-radius: 10px; }
.customer-metric-card > div { min-width: 0; }.customer-metric-card small { color: #78869a; font-size: 11px; }.customer-metric-card strong { display: block; margin: 3px 0; font-size: 23px; font-variant-numeric: tabular-nums; line-height: 1.15; }.customer-metric-card p { overflow: hidden; color: #98a2af; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.chatkey-alert { margin-top: 14px; }.chatkey-alert :deep(.el-alert__title) { font-size: 12px; }.chatkey-alert :deep(.el-alert__description) { line-height: 1.6; }
.customer-filter-bar { margin-top: 14px; padding: 12px 14px 0; border: 1px solid var(--line); border-radius: 9px; background: #f8fafb; }
.customer-filter-bar :deep(.el-form-item) { margin-right: 12px; margin-bottom: 12px; }.customer-filter-bar :deep(.el-form-item__label) { height: 22px; padding: 0; color: #67768a; font-size: 11px; line-height: 22px; }.customer-filter-bar .el-select { width: 220px; }.customer-filter-bar .filter-actions { align-self: flex-end; margin-right: 0; }
.customer-table { margin-top: 14px; --el-table-header-bg-color: #f7f9fb; }.customer-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.customer-identifier { color: #53657a; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }.message-state { display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px; border-radius: 12px; font-size: 10px; }.message-state.is-messaged { color: #16895f; background: #eaf8ef; }.message-state.is-silent { color: #788696; background: #eef2f5; }.message-state.is-unknown { color: #a66a1f; background: #fff3df; }.message-count { color: #253348; font-variant-numeric: tabular-nums; }
.customer-pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; color: #8491a2; font-size: 11px; }
.configuration-panel { margin-bottom: 16px; padding: 16px; border: 1px solid; border-radius: 10px; }.configuration-panel.is-ready { border-color: #cce8e3; background: #f5fbfa; }.configuration-panel.is-pending { border-color: #f0d8b6; background: #fffaf2; }
.configuration-state { display: flex; align-items: center; gap: 12px; }.configuration-state > span { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 9px; color: #fff; background: var(--teal); font-size: 18px; }.is-pending .configuration-state > span { background: #e69a43; }.configuration-state strong { font-size: 14px; }.configuration-state p { margin-top: 4px; color: #718094; font-size: 11px; }
.missing-fields { margin-top: 12px; color: #9c651f; font-size: 11px; }.missing-fields code { margin-left: 6px; padding: 3px 6px; border-radius: 4px; background: rgba(224,153,63,.11); }
.callback-list { margin: 14px 0 0; border-top: 1px solid rgba(124,150,157,.16); }.callback-list > div { display: grid; grid-template-columns: 120px 1fr 50px; align-items: center; min-height: 40px; border-bottom: 1px solid rgba(124,150,157,.12); font-size: 11px; }.callback-list dt { color: #657488; }.callback-list dd { overflow: hidden; margin: 0; color: #27374c; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }.callback-list button { border: 0; color: #148f83; background: transparent; cursor: pointer; }
.configuration-actions { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
.install-pool-select { width: 220px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; } }
</style>
+56 -14
View File
@@ -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,686 @@
<template>
<div class="mapping-page">
<header class="page-header">
<h1>洛阳药房药材映射</h1>
<el-button
v-if="status.sync_enabled"
v-perms="['pharmacy.medicineMapping/sync']"
type="primary"
:icon="Refresh"
:loading="syncing"
@click="handleSync"
>
增量同步
</el-button>
</header>
<section class="status-strip" v-loading="statusLoading">
<div class="status-item">
<span>目录</span>
<strong>{{ status.catalog_active }} / {{ status.catalog_total }}</strong>
<small>启用 / 总数</small>
</div>
<div class="status-item warning">
<span>未映射本地药材</span>
<strong>{{ status.unmapped_local }}</strong>
<small>仅统计启用药材</small>
</div>
<div class="status-item">
<span>同步游标</span>
<strong>{{ status.cursor }}</strong>
<small>{{ formatTime(status.last_success_time) || '尚未成功同步' }}</small>
</div>
<div class="status-item" :class="{ danger: !!status.last_error_summary }">
<span>最近同步</span>
<strong>{{
status.is_syncing ? '进行中' : status.last_error_summary ? '失败' : '正常'
}}</strong>
<small :title="status.last_error_summary">
{{
status.last_error_summary ||
formatTime(status.last_failure_time) ||
'无失败记录'
}}
</small>
</div>
</section>
<el-form class="filter-bar" inline @submit.prevent>
<el-form-item label="本地药材">
<el-input
v-model="query.local_name"
clearable
placeholder="名称"
:prefix-icon="Search"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="远端目录">
<el-input
v-model="query.remote_keyword"
clearable
placeholder="名称或编码"
:prefix-icon="Search"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="映射状态">
<el-select
v-model="query.mapping_status"
clearable
placeholder="全部"
style="width: 150px"
>
<el-option label="已映射" value="mapped" />
<el-option label="未映射" value="unmapped" />
<el-option label="远端失效" value="invalid" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="table-wrap">
<el-table v-loading="loading" :data="rows" border stripe table-layout="fixed">
<el-table-column label="本地药材" min-width="190" fixed="left">
<template #default="{ row }">
<div class="medicine-name">{{ row.local_name }}</div>
<div class="subline">
ID {{ row.local_medicine_id }} · {{ row.local_unit || '-' }}
</div>
</template>
</el-table-column>
<el-table-column label="本地状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.local_status === 1 ? 'success' : 'info'" size="small">
{{ row.local_status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="映射状态" width="105" align="center">
<template #default="{ row }">
<el-tag :type="mappingTag(row.mapping_status)" size="small">
{{ mappingText(row.mapping_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="洛阳药房目录" min-width="250">
<template #default="{ row }">
<template v-if="row.medicine_code">
<div class="medicine-name">{{ row.remote_name || '目录项不可用' }}</div>
<div class="subline code">{{ row.medicine_code }}</div>
</template>
<template v-else>
<div class="medicine-name">-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
{{ row.mapping_status === 0 ? '-' : row.remote_brand || '-' }}
</template>
</el-table-column>
<el-table-column label="单位" width="80" align="center">
<template #default="{ row }">{{ row.remote_unit || '-' }}</template>
</el-table-column>
<el-table-column label="价格" width="155" align="right">
<template #default="{ row }">
<template v-if="row.mapping_status !== 0">
<div>结算 ¥{{ formatPrice(row.settlement_price) }}</div>
<div class="subline">零售 ¥{{ formatPrice(row.retail_price) }}</div>
</template>
<template v-else>
<div>-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="版本 / 状态" width="130" align="center">
<template #default="{ row }">
<template v-if="row.medicine_code">
<div>v{{ row.catalog_version || 0 }}</div>
<div class="subline">
{{
row.remote_status === 1 && row.remote_deleted !== 1
? '远端启用'
: '远端停用'
}}
</div>
</template>
<template v-else>
<div>-</div>
<div class="subline">未映射</div>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-perms="['pharmacy.medicineMapping/save']"
type="primary"
link
:icon="Link"
:disabled="row.local_status !== 1"
@click="openMapping(row)"
>
{{ row.mapping_status === 1 ? '更换' : '映射' }}
</el-button>
<el-button
v-if="row.mapping_status === 1 || row.mapping_status === 2"
v-perms="['pharmacy.medicineMapping/unlink']"
type="danger"
link
:icon="CloseBold"
@click="handleUnlink(row)"
>
解除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="query.page_no"
v-model:page-size="query.page_size"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadRows"
@current-change="loadRows"
/>
</div>
<el-dialog
v-model="dialogVisible"
:title="`${editingRow?.mapping_status === 1 ? '更换' : '建立'}药材映射`"
width="min(560px, calc(100vw - 32px))"
destroy-on-close
>
<div class="local-summary">
<span>本地药材</span>
<strong>{{ editingRow?.local_name }}</strong>
<small
>ID {{ editingRow?.local_medicine_id }} ·
{{ editingRow?.local_unit || '-' }}</small
>
</div>
<el-form label-position="top" class="mapping-form">
<el-form-item label="洛阳药房药材">
<el-select
v-model="selectedCode"
filterable
remote
reserve-keyword
clearable
:remote-method="searchCatalog"
:loading="catalogLoading"
placeholder="输入药材名称或编码检索"
style="width: 100%"
>
<el-option
v-for="option in catalogOptions"
:key="option.medicine_code"
:label="`${option.name} · ${option.medicine_code}`"
:value="option.medicine_code"
>
<div class="option-row">
<span>{{ option.name }}</span>
<small
>{{ option.medicine_code }} · {{ option.brand || '无品牌' }} ·
{{ option.unit }}</small
>
</div>
</el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="!selectedCode"
@click="saveMapping"
>
保存映射
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="pharmacyMedicineMapping">
import { onMounted, reactive, ref } from 'vue'
import { CloseBold, Link, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessageBox } from 'element-plus'
import feedback from '@/utils/feedback'
import { createLatestRequestGuard } from './latest-request.mjs'
import {
medicineCatalogOptions,
medicineCatalogSync,
medicineMappingLists,
medicineMappingSave,
medicineMappingStatus,
medicineMappingUnlink,
type CatalogOption,
type MedicineMappingQuery,
type MedicineMappingRow,
type PharmacySyncResult,
type PharmacySyncStatus
} from '@/api/pharmacy'
const emptyStatus = (): PharmacySyncStatus => ({
sync_enabled: false,
cursor: 0,
last_success_time: 0,
last_failure_time: 0,
last_error_summary: '',
is_syncing: false,
catalog_total: 0,
catalog_active: 0,
unmapped_local: 0
})
const loading = ref(false)
const statusLoading = ref(false)
const syncing = ref(false)
const saving = ref(false)
const catalogLoading = ref(false)
const rows = ref<MedicineMappingRow[]>([])
const total = ref(0)
const status = ref<PharmacySyncStatus>(emptyStatus())
const query = reactive<MedicineMappingQuery>({
page_no: 1,
page_size: 20,
local_name: '',
remote_keyword: '',
mapping_status: ''
})
const dialogVisible = ref(false)
const editingRow = ref<MedicineMappingRow | null>(null)
const selectedCode = ref('')
const catalogOptions = ref<CatalogOption[]>([])
let catalogTimer: number | undefined
const listRequests = createLatestRequestGuard<MedicineMappingQuery>()
const catalogRequests = createLatestRequestGuard<{ keyword: string; localMedicineId: number }>()
const statusRequests = createLatestRequestGuard()
const formatPrice = (value: string | number | null | undefined) => {
const number = Number(value || 0)
return Number.isFinite(number) ? number.toFixed(2) : '0.00'
}
const formatTime = (timestamp: number) => {
if (!timestamp) return ''
return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false })
}
const mappingText = (value: number) => {
if (value === 1) return '已映射'
if (value === 2) return '远端失效'
return '未映射'
}
const mappingTag = (value: number): 'success' | 'warning' | 'info' => {
if (value === 1) return 'success'
if (value === 2) return 'warning'
return 'info'
}
const loadRows = async () => {
const ticket = listRequests.next({ ...query })
loading.value = true
try {
const response = await medicineMappingLists(ticket.snapshot)
if (listRequests.isLatest(ticket)) {
rows.value = response.lists || []
total.value = response.count || 0
}
} finally {
if (listRequests.isLatest(ticket)) {
loading.value = false
}
}
}
const loadStatus = async () => {
const ticket = statusRequests.next(undefined)
statusLoading.value = true
try {
const nextStatus = await medicineMappingStatus()
if (statusRequests.isLatest(ticket)) {
status.value = nextStatus
}
} finally {
if (statusRequests.isLatest(ticket)) {
statusLoading.value = false
}
}
}
const search = () => {
query.page_no = 1
void loadRows()
}
const resetFilters = () => {
query.local_name = ''
query.remote_keyword = ''
query.mapping_status = ''
search()
}
const handleSync = async () => {
syncing.value = true
try {
const result = (await medicineCatalogSync()) as PharmacySyncResult
feedback.msgSuccess(
`同步完成:拉取 ${result.pulled},新增 ${result.created},更新 ${result.updated},停用 ${result.deactivated}`
)
await Promise.all([loadRows(), loadStatus()])
} finally {
syncing.value = false
}
}
const searchCatalogNow = async (
keyword: string,
localMedicineId = Number(editingRow.value?.local_medicine_id || 0)
) => {
const ticket = catalogRequests.next({ keyword: keyword.trim(), localMedicineId })
catalogLoading.value = true
try {
const options = await medicineCatalogOptions({ keyword: ticket.snapshot.keyword, limit: 30 })
if (
catalogRequests.isLatest(ticket) &&
Number(editingRow.value?.local_medicine_id || 0) === ticket.snapshot.localMedicineId
) {
catalogOptions.value = options
}
} finally {
if (catalogRequests.isLatest(ticket)) {
catalogLoading.value = false
}
}
return ticket
}
const searchCatalog = (keyword: string) => {
if (catalogTimer) window.clearTimeout(catalogTimer)
catalogRequests.invalidate()
catalogTimer = window.setTimeout(() => void searchCatalogNow(keyword), 250)
}
const openMapping = async (row: MedicineMappingRow) => {
if (catalogTimer) window.clearTimeout(catalogTimer)
catalogRequests.invalidate()
const rowSnapshot = { ...row }
const localMedicineId = Number(rowSnapshot.local_medicine_id)
editingRow.value = rowSnapshot
selectedCode.value = rowSnapshot.mapping_status === 1 ? rowSnapshot.medicine_code || '' : ''
catalogOptions.value = []
dialogVisible.value = true
const initialTicket = await searchCatalogNow(rowSnapshot.local_name, localMedicineId)
if (
!catalogRequests.isLatest(initialTicket) ||
Number(editingRow.value?.local_medicine_id || 0) !== localMedicineId
)
return
if (
selectedCode.value &&
!catalogOptions.value.some((item) => item.medicine_code === selectedCode.value)
) {
await searchCatalogNow(selectedCode.value, localMedicineId)
}
}
const saveMapping = async () => {
if (!editingRow.value || !selectedCode.value) return
saving.value = true
try {
await medicineMappingSave({
local_medicine_id: editingRow.value.local_medicine_id,
medicine_code: selectedCode.value
})
dialogVisible.value = false
await Promise.all([loadRows(), loadStatus()])
} finally {
saving.value = false
}
}
const handleUnlink = async (row: MedicineMappingRow) => {
await ElMessageBox.confirm(
`确认解除“${row.local_name}”与 ${row.medicine_code} 的映射?`,
'解除映射',
{
type: 'warning',
confirmButtonText: '解除',
cancelButtonText: '取消'
}
)
await medicineMappingUnlink({ local_medicine_id: row.local_medicine_id })
await Promise.all([loadRows(), loadStatus()])
}
onMounted(() => {
void Promise.all([loadRows(), loadStatus()])
})
</script>
<style scoped>
.mapping-page {
min-width: 0;
padding: 16px;
color: var(--el-text-color-primary);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.page-header h1 {
margin: 0;
font-size: 20px;
line-height: 28px;
letter-spacing: 0;
}
.status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
min-height: 88px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-light);
background: var(--el-bg-color);
}
.status-item {
min-width: 0;
padding: 14px 16px;
border-right: 1px solid var(--el-border-color-lighter);
}
.status-item:last-child {
border-right: 0;
}
.status-item span,
.status-item small {
display: block;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-item strong {
display: block;
margin: 5px 0 2px;
font-size: 18px;
line-height: 24px;
}
.status-item.warning strong {
color: var(--el-color-warning-dark-2);
}
.status-item.danger strong,
.status-item.danger small {
color: var(--el-color-danger);
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0 8px;
padding: 14px 16px 0;
border: 1px solid var(--el-border-color-light);
border-bottom: 0;
background: var(--el-fill-color-extra-light);
}
.filter-bar :deep(.el-input) {
width: 190px;
}
.table-wrap {
min-width: 0;
overflow-x: auto;
}
.table-wrap :deep(.el-table) {
min-width: 1220px;
}
.medicine-name {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.subline,
.muted {
margin-top: 3px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
overflow-x: auto;
padding-top: 16px;
}
.local-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
padding: 12px 14px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
}
.local-summary span,
.local-summary small {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.local-summary small {
grid-column: 2;
}
.mapping-form {
margin-top: 18px;
}
.option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.option-row small {
overflow: hidden;
color: var(--el-text-color-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.status-item:nth-child(2) {
border-right: 0;
}
.status-item:nth-child(-n + 2) {
border-bottom: 1px solid var(--el-border-color-lighter);
}
}
@media (max-width: 600px) {
.mapping-page {
padding: 12px;
}
.page-header {
align-items: stretch;
flex-direction: column;
}
.page-header .el-button {
width: 100%;
}
.status-strip {
grid-template-columns: 1fr;
}
.status-item,
.status-item:nth-child(2) {
border-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.status-item:last-child {
border-bottom: 0;
}
.filter-bar {
display: block;
}
.filter-bar :deep(.el-form-item),
.filter-bar :deep(.el-input),
.filter-bar :deep(.el-select) {
width: 100% !important;
}
.pagination-wrap {
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,12 @@
export interface LatestRequestTicket<T> {
readonly generation: number
readonly snapshot: T
}
export interface LatestRequestGuard<T> {
next(snapshot: T): LatestRequestTicket<T>
invalidate(): number
isLatest(ticket: LatestRequestTicket<T>): boolean
}
export function createLatestRequestGuard<T>(): LatestRequestGuard<T>
@@ -0,0 +1,22 @@
export function createLatestRequestGuard() {
let generation = 0
return {
next(snapshot) {
generation += 1
const stableSnapshot = Array.isArray(snapshot)
? [...snapshot]
: snapshot && typeof snapshot === 'object'
? { ...snapshot }
: snapshot
return Object.freeze({ generation, snapshot: stableSnapshot })
},
invalidate() {
generation += 1
return generation
},
isLatest(ticket) {
return ticket?.generation === generation
}
}
}
@@ -0,0 +1,319 @@
<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="timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
@change="doSearch"
/>
</el-form-item>
<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-select
v-model="queryParams.is_rollback"
clearable
placeholder="全部"
class="w-[120px]"
@change="doSearch"
>
<el-option label="未回退" value="0" />
<el-option label="已回退" value="1" />
</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">
由每日定时任务待分配诊单自动指派写入按上月二诊复诊接诊率分档轮询分配&gt;70% 每人3条/60%~70%
每人2条/50%~60% 每人1条/&lt;50% 不分回退可将已分配医助撤回到自动分配前的原医助通常为一中心并写入指派日志
</div>
</el-card>
<el-card class="!border-none mt-3" shadow="never">
<div class="toolbar mb-3">
<el-button
v-perms="['stats.autoAssignLog/rollback']"
type="warning"
:disabled="selectedRows.length === 0"
:loading="rollbackLoading"
@click="handleBatchRollback"
>
回退选中{{ selectedRows.length }}
</el-button>
<span v-if="selectedRows.length > 0" class="toolbar-tip">
已选 {{ selectedRows.length }} 条可回退记录
</span>
</div>
<el-table
ref="tableRef"
:data="pager.lists"
v-loading="pager.loading"
size="default"
stripe
row-key="id"
@selection-change="onSelectionChange"
>
<el-table-column
type="selection"
width="48"
:selectable="rowSelectable"
reserve-selection
/>
<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="回退状态" width="140">
<template #default="{ row }">
<template v-if="Number(row.is_rollback) === 1">
<el-tag type="warning" size="small">已回退</el-tag>
<div class="cell-sub">{{ row.rollback_time_text }}</div>
<div v-if="row.rollback_admin_name" class="cell-sub">
{{ row.rollback_admin_name }}
</div>
</template>
<el-tag v-else-if="Number(row.can_rollback) === 1" type="success" size="small">
可回退
</el-tag>
<span v-else></span>
</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="280" 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="160">
<template #default="{ row }">
<span class="cell-sub">{{ row.batch_no }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="Number(row.can_rollback) === 1"
v-perms="['stats.autoAssignLog/rollback']"
link
type="warning"
:loading="rollbackLoading"
@click="handleSingleRollback(row)"
>
回退
</el-button>
<span v-else class="cell-sub"></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, autoAssignLogRollback } from '@/api/stats'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import { onMounted, reactive, ref, watch } from 'vue'
const dateRange = ref<[string, string] | null>(null)
const timeRange = ref<[string, string] | null>(null)
const tableRef = ref()
const selectedRows = ref<any[]>([])
const rollbackLoading = ref(false)
const queryParams = reactive({
start_date: '',
end_date: '',
start_time: '',
end_time: '',
action: '' as '' | '0' | '1',
is_rollback: '' as '' | '0' | '1',
keyword: ''
})
watch(dateRange, (val) => {
queryParams.start_date = val?.[0] ?? ''
queryParams.end_date = val?.[1] ?? ''
})
watch(timeRange, (val) => {
queryParams.start_time = val?.[0] ?? ''
queryParams.end_time = val?.[1] ?? ''
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: autoAssignLogLists,
params: queryParams
})
const doSearch = () => {
selectedRows.value = []
tableRef.value?.clearSelection?.()
resetPage()
}
const handleReset = () => {
dateRange.value = null
timeRange.value = null
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.start_time = ''
queryParams.end_time = ''
queryParams.action = ''
queryParams.is_rollback = ''
queryParams.keyword = ''
doSearch()
}
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows.filter((r) => Number(r.can_rollback) === 1)
}
const runRollback = async (ids: number[]) => {
if (ids.length === 0) return
await feedback.confirm(
`确认回退选中的 ${ids.length} 条自动分配?将把诊单医助撤回到自动分配前的原医助,并写入指派日志。`
)
rollbackLoading.value = true
try {
const res: any = await autoAssignLogRollback({ ids })
const success = Number(res?.success ?? 0)
const failed = Number(res?.failed ?? 0)
if (failed > 0 && Array.isArray(res?.messages) && res.messages.length) {
feedback.msgWarning(
`回退成功 ${success} 条,跳过/失败 ${failed} 条:${res.messages.slice(0, 3).join('')}`
)
} else {
feedback.msgSuccess(`回退成功 ${success}`)
}
selectedRows.value = []
tableRef.value?.clearSelection?.()
await getLists()
} finally {
rollbackLoading.value = false
}
}
const handleBatchRollback = () => {
const ids = selectedRows.value.map((r) => Number(r.id)).filter((id) => id > 0)
return runRollback(ids)
}
const handleSingleRollback = (row: any) => {
const id = Number(row?.id ?? 0)
if (id <= 0) return
return runRollback([id])
}
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;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>
+152 -59
View File
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -131,7 +131,7 @@
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="220">
<template #default="{ row }">
<MedicineNameSelect v-model="row.name" />
<MedicineNameSelect v-model="row.name" v-model:medicine-id="row.medicine_id" />
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
@@ -321,7 +321,7 @@ const formData = reactive({
pulse: '',
pulse_condition: '',
clinical_diagnosis: '',
herbs: [] as Array<{ name: string; dosage: number }>,
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
dose_count: 7,
dose_unit: '剂',
usage_days: 7,
+45 -1
View File
@@ -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;
}
}
}
+6 -1
View File
@@ -556,7 +556,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
}
const validateIdCard = (rule: any, value: any, callback: any) => {
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
if (!value) {
callback()
return
}
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
callback(new Error('身份证号格式不正确'))
return
}
@@ -572,6 +576,7 @@ const formRules = {
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
}
+15 -1
View File
@@ -206,10 +206,20 @@ import isoWeek from 'dayjs/plugin/isoWeek'
import { getDoctors } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { getAvailableSlots, createAppointment, rosterLists, appointmentLists } from '@/api/doctor'
import { myPatientCreateAppointment } from '@/api/first_visit'
import feedback from '@/utils/feedback'
dayjs.extend(isoWeek)
const props = withDefaults(
defineProps<{
apiScene?: 'default' | 'my_patient'
}>(),
{
apiScene: 'default'
}
)
interface TimeSlot {
time: string
available: boolean
@@ -703,7 +713,11 @@ const handleConfirm = async () => {
console.log('提交预约参数:', params)
await createAppointment(params)
if (props.apiScene === 'my_patient') {
await myPatientCreateAppointment(params)
} else {
await createAppointment(params)
}
feedback.msgSuccess('预约成功')
visible.value = false
@@ -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('起始偏移须在 020 之间')
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>
+155 -27
View File
@@ -67,26 +67,66 @@
>
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-alert
v-if="mode === 'edit' && patientBasicLocked && !canEditPatientBasicFields"
type="warning"
:closable="false"
show-icon
class="mb-3"
>
<template v-if="latestPrescriptionOrder">
最近业务订单 #{{ latestPrescriptionOrder.id }} 状态为{{ latestPrescriptionOrder.fulfillment_status_text }}患者基本信息不可修改
</template>
<template v-else>
存在未完成的业务订单患者基本信息不可修改
</template>
</el-alert>
<el-form-item label="诊单ID">
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
</el-form-item>
<fieldset
:disabled="!canEditPatientBasicFields"
class="diagnosis-patient-basic-fieldset border-0 min-w-0 p-0 m-0"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓名" prop="patient_name">
<el-input
v-model="formData.patient_name"
placeholder="请输入姓名"
:disabled="!canEditPatientBasicFields"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="身份证号" prop="id_card">
<template v-if="showIdCardMaskedEdit">
<el-input
:model-value="
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
"
readonly
placeholder="点击可查看完整身份证号"
maxlength="18"
class="cursor-pointer phone-mask-toggle"
@click="toggleIdCardReveal"
/>
<p
v-if="originalIdCard && !idCardRevealUnlocked"
class="text-xs text-gray-400 mt-1"
>
当前为脱敏显示点击输入框可查看完整号码
</p>
</template>
<el-input
v-else
v-model="formData.id_card"
placeholder="请输入身份证号"
maxlength="18"
:disabled="!canEditIdCard"
@focus="handleIdCardFocus"
@blur="handleIdCardBlur"
/>
@@ -120,6 +160,7 @@
v-model="formData.phone"
placeholder="请输入手机号"
maxlength="11"
:disabled="!canEditPatientBasicFields"
@focus="handlePhoneFocus"
@blur="handlePhoneBlur"
/>
@@ -127,7 +168,7 @@
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender">
<el-radio-group v-model="formData.gender" :disabled="!canEditPatientBasicFields">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
@@ -144,11 +185,14 @@
:max="150"
placeholder="请输入年龄"
class="w-full"
:disabled="!canEditPatientBasicFields"
:controls="canEditPatientBasicFields"
/>
</el-form-item>
</el-col>
</el-row>
</fieldset>
<!-- 生命体征 -->
@@ -778,9 +822,40 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
const latestPrescriptionOrder = ref<{
id: number
fulfillment_status: number
fulfillment_status_text: string
} | null>(null)
const canEditPatientBasicFields = computed(() => {
if (viewOnly.value) return false
if (mode.value === 'add') return true
return canEditPatientBasicFromApi.value
})
/** 编辑且无明文权限或基本信息锁定:只读脱敏,点击切换查看完整号 */
const showPhoneMaskedEdit = computed(() => {
if (mode.value !== 'edit') return false
if (!canEditPatientBasicFields.value) return true
return !hasPhonePlainPermission.value
})
const phoneRevealUnlocked = ref(false)
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
const showIdCardMaskedEdit = computed(() => {
if (mode.value !== 'edit') return false
if (!originalIdCard.value) return false
if (!canEditPatientBasicFields.value) return true
return !hasPhonePlainPermission.value
})
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
const canEditIdCard = computed(() => {
if (!canEditPatientBasicFields.value) return false
if (mode.value === 'add') return true
if (!originalIdCard.value) return true
return hasPhonePlainPermission.value
})
const idCardRevealUnlocked = ref(false)
const activeTab = ref('basic')
const drawerTitle = computed(() => {
if (viewOnly.value) return '诊单详情'
@@ -920,6 +995,11 @@ const togglePhoneReveal = () => {
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
}
const toggleIdCardReveal = () => {
if (!originalIdCard.value) return
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
}
const maskIdCard = (idCard: string) => {
if (!idCard) return idCard
if (idCard.length === 15) {
@@ -990,43 +1070,40 @@ const handlePhoneBlur = async () => {
}
}
// -
// -
const handleIdCardFocus = () => {
isIdCardFocused.value = true
if (originalIdCard.value) {
if (!originalIdCard.value) return
if (hasPhonePlainPermission.value) {
formData.value.id_card = originalIdCard.value
return
}
if (mode.value === 'add') {
formData.value.id_card = originalIdCard.value
}
}
// -
//
const handleIdCardBlur = async () => {
isIdCardFocused.value = false
//
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
//
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
//
const id = formData.value.id_card
const birthYear = parseInt(id.substring(6, 10))
const birthMonth = parseInt(id.substring(10, 12))
const birthDay = parseInt(id.substring(12, 14))
const processIdCard = async (idCard: string) => {
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
const today = new Date()
let age = today.getFullYear() - birthYear
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
age--
}
formData.value.age = age
//
try {
const result = await checkIdCard({
id_card: formData.value.id_card,
id_card: idCard,
id: formData.value.id || ''
})
if (result.exists) {
ElMessage.warning(result.message)
}
@@ -1034,8 +1111,24 @@ const handleIdCardBlur = async () => {
console.error('检查身份证号失败:', error)
}
}
//
}
if (hasPhonePlainPermission.value) {
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
await processIdCard(formData.value.id_card)
}
return
}
//
if (mode.value === 'edit' && originalIdCard.value) {
return
}
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
originalIdCard.value = formData.value.id_card
await processIdCard(formData.value.id_card)
formData.value.id_card = maskIdCard(formData.value.id_card)
}
}
@@ -1058,7 +1151,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
const validateIdCard = (rule: any, value: any, callback: any) => {
// 使
const idCardToValidate = originalIdCard.value || value
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
if (!idCardToValidate) {
callback()
return
}
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
callback(new Error('身份证号格式不正确'))
return
}
@@ -1073,6 +1170,7 @@ const formRules = {
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
}
@@ -1167,9 +1265,12 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
originalPhone.value = data.phone || ''
originalIdCard.value = data.id_card || ''
patientBasicLocked.value = !!data.patient_basic_locked
canEditPatientBasicFromApi.value = data.can_edit_patient_basic !== false
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
data.id_card = maskIdCard(data.id_card || '')
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
formData.value = data
formData.value.create_source = data.create_source ?? ''
@@ -1178,12 +1279,20 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
}
const resetPatientBasicLockState = () => {
patientBasicLocked.value = false
canEditPatientBasicFromApi.value = true
latestPrescriptionOrder.value = null
}
const open = async (type: string, id?: number) => {
viewOnly.value = false
mode.value = type
visible.value = true
activeTab.value = 'basic' //
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
//
await getDictOptions()
@@ -1206,6 +1315,8 @@ const openViewOnly = async (id: number) => {
visible.value = true
activeTab.value = 'basic'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
resetPatientBasicLockState()
await getDictOptions()
await loadDiagnosisDetailIntoForm(id)
}
@@ -1230,6 +1341,7 @@ const handleSubmit = async () => {
if (result && result.id) {
mode.value = 'edit'
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
formData.value.id = result.id
// patient_id
@@ -1244,7 +1356,9 @@ const handleSubmit = async () => {
formData.value.phone = hasPhonePlainPermission.value
? detail.phone || ''
: maskPhone(detail.phone || '')
formData.value.id_card = maskIdCard(detail.id_card || '')
formData.value.id_card = hasPhonePlainPermission.value
? detail.id_card || ''
: maskIdCard(detail.id_card || '')
} catch (error) {
console.error('获取详情失败:', error)
}
@@ -1346,6 +1460,7 @@ const handleClose = () => {
isPhoneFocused.value = false
isIdCardFocused.value = false
phoneRevealUnlocked.value = false
idCardRevealUnlocked.value = false
viewOnly.value = false
visible.value = false
@@ -1370,6 +1485,19 @@ defineExpose({
}
}
.diagnosis-patient-basic-fieldset:disabled {
opacity: 1;
:deep(.el-input__wrapper),
:deep(.el-input-number),
:deep(.el-input-number__decrease),
:deep(.el-input-number__increase),
:deep(.el-radio),
:deep(.el-radio__input) {
cursor: not-allowed;
pointer-events: none;
}
}
:deep(.el-form) {
padding-bottom: 20px;
font-size: 12px;
+234 -18
View File
@@ -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 {
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
# 此文件是源码开发/企业部署模板,不会被复制进发布 ZIP/.app。
# 生产配置应由受控启动器、设备管理或进程环境注入;禁止把密码、token、
# UserSig、TRTC SecretKey 或其他长期凭据写进本文件或发布包。
# 后端根地址。程序会自动追加 /adminapi;也可直接填写以 /adminapi 结尾的地址。
DOCTOR_API_BASE_URL=https://api.example.com
# 首次验收可设为 true,使用内置演示数据;生产必须设为 false。
DOCTOR_DEMO_MODE=true
# 当前只支持 embedded。browser 在后端提供一次性 handoff 前会被明确拒绝,且不会自动打开系统浏览器。
DOCTOR_VIDEO_MODE=embedded
# 可选:本地 dist 缺失时由 QtWebEngine 内嵌加载的可信 HTTPS 页面;不是 browser handoff URL,禁止携带 UserSig。
DOCTOR_VIDEO_WEB_URL=
# 生产环境必须保持 true。仅内网自签证书调试时临时关闭。
DOCTOR_VERIFY_SSL=true
DOCTOR_REQUEST_TIMEOUT=30
# 日志级别:DEBUG / INFO / WARNING / ERROR。日志会自动脱敏 token 与 UserSig。
DOCTOR_LOG_LEVEL=INFO
# 可选:仅供企业部署/自动化验收隔离用户数据目录。
# DOCTOR_CONFIG_DIR=
# DOCTOR_LOG_DIR=
+23
View File
@@ -0,0 +1,23 @@
.env
.venv/
.venv-build/
.uv-cache/
.uv-python/
__pycache__/
*.py[cod]
.pytest_cache/
.pytest-tmp-*/
artifacts/pytest_*/
.ruff_cache/
.coverage
htmlcov/
build/
dist/
!video_companion/dist/
!video_companion/dist/**
*.spec.bak
node_modules/
video_companion/node_modules/
*.log
.DS_Store
Thumbs.db
+22
View File
@@ -0,0 +1,22 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "PROJECT_ROOT=%~dp0"
set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\package_windows.ps1" %*
set "RESULT=%ERRORLEVEL%"
if "%RESULT%"=="0" (
echo.
echo Package ready in: %PROJECT_ROOT%dist
if /I not "%~1"=="-ValidateOnly" (
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
)
) else (
echo.
echo DoctorWorkstation packaging failed. Exit code: %RESULT%
echo Press any key to close this window.
pause >nul
)
endlocal & exit /b %RESULT%
+132
View File
@@ -0,0 +1,132 @@
# 臻阳堂医生工作站
一个以 Python + PySide6 编写的跨平台医生桌面端,面向 Windows 10/11 与 macOS 13+。项目按现有 `admin` 源码的真实接口契约实现,覆盖登录、接诊台、我的处方库、已开处方、患者列表、问诊列表和腾讯云视频面诊。
> 当前版本提供完整的演示数据模式,便于在没有后端账号或腾讯云配置时验收界面与流程。切换到生产模式后,数据与权限均由现有后端返回。
## 一键运行与一键打包
Windows 直接在项目根目录双击:
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Windows-x64-<版本>.zip` 和 SHA-256 文件。
英文稳定别名分别是 `Run_DoctorWorkstation.bat``Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`
macOS 在 Finder 中双击:
- `一键运行.command`:优先打开现有 `DoctorWorkstation.app`,否则自动准备源码环境并运行。
- `一键打包.command`:构建、QtWebEngine/QtMultimedia 文件门禁、签名检查和两项冻结冒烟验证后,生成 `.app`、可分发 ZIP 及 SHA-256 文件。
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境和锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
## 已实现范围
- 账号密码登录、token 会话、记住账号(不保存密码)和退出登录。
- 登录后读取 `/adminapi/auth.admin/mySelf`,按 `permissions` 动态控制页面和操作按钮;`*` 超级权限兼容现有后台。
- 接诊台:今日待接诊/已过号、患者详情、医生备注、通知医助、完成接诊、发起视频面诊。
- 我的处方库:主方/辅方与公开范围筛选,药材动态编辑,模板所有权和增删改权限。
- 已开处方:处方号/患者/审核状态筛选、状态展示和只读详情。
- 患者列表:复用 `/firstvisit.myPatient/lists` 的服务端数据范围,不在客户端伪造医生或部门过滤。
- 问诊列表:按日期、状态和患者筛选,支持从有效预约发起视频。
- 腾讯视频:沿用现有项目的 `@trtc/calls-uikit-vue` 主链;UserSig 只从后端短时获取,客户端不包含 SDKSecretKey。当前仅支持隔离的 QtWebEngine 内嵌模式。
- PyInstaller Windows/macOS 构建脚本、macOS 摄像头/麦克风权限配置与自动化测试。
## 手动运行
先安装 [uv](https://docs.astral.sh/uv/),然后在项目根目录执行:
```powershell
uv sync --extra dev --extra build
Copy-Item .env.example .env
uv run doctor-workstation
```
macOS/Linux
```bash
uv sync --extra dev --extra build
cp .env.example .env
uv run doctor-workstation
```
`.env.example` 默认启用演示模式。演示账号:`doctor`,密码:`doctor123`
`.env.example` 仅作为源码开发/企业部署模板,不会复制进发布 ZIP 或 `.app`。生产环境请通过受控启动器、设备管理或进程环境注入配置;不要把密码、token、UserSig、TRTC SecretKey 等凭据放进 `.env` 或发布包。
## 连接现有后端
`.env` 调整为:
```dotenv
DOCTOR_API_BASE_URL=https://your-api.example.com
DOCTOR_DEMO_MODE=false
DOCTOR_VERIFY_SSL=true
```
程序会自动在地址末尾追加 `/adminapi`。它与现有管理端保持相同约定:
- 登录:`POST /login/account`,请求包含 `account``password``terminal=1`
- 鉴权请求头:`token: <登录 token>``version: 1.9.4`
- 响应 envelope`code=1` 成功、`0` 业务失败、`-1` 登录失效、`10` 需绑定企业微信。
- 权限与数据范围:完全以后端 `/auth.admin/mySelf` 返回为准。
环境配置不会保存密码、TRTC SecretKey 或腾讯云长期凭据。登录 token 优先存入系统凭据库;无法使用时仅回退到用户配置目录中的受限文件。
## 视频伴随页
腾讯云没有官方 Python/PySide6 客户端 SDK。本项目因此采用 Python 业务主程序 + 腾讯官方 Web TUICallKit 伴随页:
```powershell
Set-Location video_companion
npm ci
npm run build
```
构建输出位于 `video_companion/dist`,由桌面端内嵌加载。生产 UserSig 必须由现有 `/tcm.diagnosis/getCallSignature` 接口签发;不要把 SDKSecretKey 写入 `.env` 或 JavaScript。
当前仅支持 `embedded`。在业务后端提供服务端签发、一次性消费的 browser handoff 之前,`browser` 模式会被明确拒绝,QtWebEngine 不可用时也不会自动打开系统浏览器。这样可以避免后端已记录 `startCall`、浏览器页面却没有通话票据的“幽灵通话”。
```dotenv
DOCTOR_VIDEO_MODE=embedded
DOCTOR_VIDEO_WEB_URL=https://rtc.example.com/doctor-call
```
`DOCTOR_VIDEO_WEB_URL` 仅用于本地 `dist` 缺失时,在 QtWebEngine 中内嵌加载受信任的 HTTPS 主文档;它不是 browser handoff URL,也不得在 URL 中携带 UserSig 或其他 RTC 凭据。
## 测试与打包
```powershell
uv run pytest
uv run ruff check src tests
.\scripts\build_windows.ps1
```
`build_windows.ps1` 只生成并验证 onedir;正式一键发布请运行 `Build_DoctorWorkstation.bat`,它在所有冻结文件/媒体门禁通过后再生成版本 ZIP 与 SHA-256。
macOS 必须在 macOS 机器上构建、签名和公证:
```bash
./scripts/build_macos.sh
```
`build_macos.sh` 只生成并验证 `.app`;正式一键发布请双击 `一键打包.command`(或 `package_macos.command`),通过相同门禁后再归档并生成 SHA-256。
Windows 与 macOS 的 Qt/媒体权限和签名产物不能交叉编译。首次生产发布前,应按 [research/tencent_rtc.md](research/tencent_rtc.md) 的准入清单完成摄像头、麦克风、设备插拔、休眠恢复和弱网实测。
## 工程结构
```text
src/doctor_workstation/
core/ 业务模型、权限、会话与异常
services/ HTTP 客户端、远程仓库、演示仓库与安全 token 存储
ui/ PySide6 登录、主框架、页面和对话框
video/ 视频请求规范化、异步生命周期与隔离的内嵌窗口
video_companion/ 腾讯 TUICallKit 页面
packaging/ PyInstaller 与 macOS 权限配置
scripts/ Windows/macOS 构建脚本
tests/ 不依赖真实后端和腾讯云的自动化测试
research/ admin 源码审计、架构和腾讯 RTC 官方资料研究
```
详细的接口/字段与权限审计见 [research/admin_audit.md](research/admin_audit.md)。
+16
View File
@@ -0,0 +1,16 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "PROJECT_ROOT=%~dp0"
set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\run_windows.ps1" %*
set "RESULT=%ERRORLEVEL%"
if not "%RESULT%"=="0" (
echo.
echo DoctorWorkstation failed to start. Exit code: %RESULT%
echo Press any key to close this window.
pause >nul
)
endlocal & exit /b %RESULT%
+14
View File
@@ -0,0 +1,14 @@
# 安全与隐私约束
医生工作台会处理患者身份、病历、处方和音视频等敏感信息。生产部署必须遵守以下基线:
- 客户端不包含腾讯云 `SDKSecretKey`、COS Secret 或后端数据库凭据。TRTC `UserSig` 由服务端按当前医生和单次通话短时签发。
- 不在 URL 查询参数、命令行或日志中传递 token、UserSig、患者身份证号和完整病历。项目日志过滤器会遮蔽常见凭据,但调用代码仍应避免记录完整请求/响应。
- 密码从不落盘;“记住账号”只保存账号。登录 token 优先写入 Windows Credential Manager / macOS Keychain。
- 生产 API 与浏览器视频页必须使用 HTTPS 并验证证书。`DOCTOR_VERIFY_SSL=false` 只允许在受控开发环境临时使用。
- 页面可见性与按钮权限来自 `/auth.admin/mySelf`,但客户端权限仅用于界面体验;服务端仍必须对每个接口执行身份、租户、数据范围和动作权限校验。
- 本地不缓存患者列表、病历、处方和通话票据。演示数据是完全虚构的静态数据。
- 通话录制、截图和报告上传属于单独的合规能力;启用前必须确认患者告知/同意、留存周期、访问审计和删除流程。
- 软件发布必须签名。Windows 建议 AuthenticodemacOS 需要 Developer ID、Hardened Runtime、摄像头/麦克风用途说明与公证。
发现凭据泄露、越权、患者数据写入日志或视频房间被未授权加入时,应立即停用相关凭据、保留审计证据并按组织的安全响应流程处置。
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

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