Compare commits
109
Commits
master-6-2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fefbae8f | ||
|
|
541fe59ad1 | ||
|
|
b6c5b422c2 | ||
|
|
500038601c | ||
|
|
f6688a740d | ||
|
|
a347e0642c | ||
|
|
13adbac3fd | ||
|
|
333a18c37e | ||
|
|
e3a91a91f7 | ||
|
|
5ca5f06255 | ||
|
|
e01f3e7072 | ||
|
|
ed78803015 | ||
|
|
546138d60e | ||
|
|
fdddfdb3dd | ||
|
|
f5a89b4310 | ||
|
|
a650747fd3 | ||
|
|
46b62229a6 | ||
|
|
0f33f2ad28 | ||
|
|
a677b69e97 | ||
|
|
6432e9990c | ||
|
|
4d6125268a | ||
|
|
7f93cf5480 | ||
|
|
f8f1953a2a | ||
|
|
99eca8b2e5 | ||
|
|
bde396189a | ||
|
|
55e3b5d9e0 | ||
|
|
eb0efba739 | ||
|
|
c7e7022181 | ||
|
|
26e6cc433b | ||
|
|
c22b84cff9 | ||
|
|
4f9486fb3d | ||
|
|
d24b04f9e9 | ||
|
|
465cba3788 | ||
|
|
dc590d37e9 | ||
|
|
d1f587fdd7 | ||
|
|
e8f68101c4 | ||
|
|
cea9392239 | ||
|
|
3ea3c97c42 | ||
|
|
efd058bce5 | ||
|
|
e538dd03a1 | ||
|
|
4a17fe8a0c | ||
|
|
e2a1e62867 | ||
|
|
b6cdd20c56 | ||
|
|
c1be0aa3e5 | ||
|
|
6c87b72ce4 | ||
|
|
e28480af32 | ||
|
|
37b2945c44 | ||
|
|
c9c2b4608f | ||
|
|
e61e7f97fa | ||
|
|
14e5bba9a2 | ||
|
|
6b713a2d6c | ||
|
|
8ae5b4eead | ||
|
|
e5959fd89a | ||
|
|
08249451a7 | ||
|
|
79ae055bb6 | ||
|
|
c0cc23393e | ||
|
|
470ba419e3 | ||
|
|
1c0cd84355 | ||
|
|
f2e2f84e18 | ||
|
|
f7e52bcafc | ||
|
|
2e59e883b8 | ||
|
|
9f1801771e | ||
|
|
1adf8ccf4b | ||
|
|
abc78f4291 | ||
|
|
072c0c3663 | ||
|
|
e9cf65a41c | ||
|
|
af9bd05bb4 | ||
|
|
18f7fb3772 | ||
|
|
29ab45033d | ||
|
|
8a1a9ea0ca | ||
|
|
f8ccd8b0af | ||
|
|
9e019708f2 | ||
|
|
2712c262bd | ||
|
|
bd7002e176 | ||
|
|
0be6d06544 | ||
|
|
7a4973470c | ||
|
|
e79f3190b8 | ||
|
|
1811abc794 | ||
|
|
07abf2abca | ||
|
|
c76bd5416a | ||
|
|
7bb8db4126 | ||
|
|
30b6037752 | ||
|
|
5a235280a1 | ||
|
|
b9d36d8d04 | ||
|
|
f37310ec50 | ||
|
|
a440702c07 | ||
|
|
b9486f32fe | ||
|
|
07018598ad | ||
|
|
c554e08f2e | ||
|
|
e6209c8912 | ||
|
|
e49f806c3a | ||
|
|
b566e60624 | ||
|
|
123e9075dd | ||
|
|
84eef45553 | ||
|
|
9873ac5e87 | ||
|
|
cdf8b1f76d | ||
|
|
b0b78c82ab | ||
|
|
ff61e815d8 | ||
|
|
dd93ffe683 | ||
|
|
ef014e0d9d | ||
|
|
32f8e19bf1 | ||
|
|
26b2cde699 | ||
|
|
5ab3be1fdd | ||
|
|
6b528c34c8 | ||
|
|
2138626519 | ||
|
|
37df208706 | ||
|
|
ec4e0b9f29 | ||
|
|
a3bae1555a | ||
|
|
eadc82535d |
+1
-4
@@ -26,11 +26,8 @@ bin-release/
|
|||||||
/.cursor
|
/.cursor
|
||||||
/.codex
|
/.codex
|
||||||
/.agents
|
/.agents
|
||||||
/.claude
|
|
||||||
/.claude
|
|
||||||
/.claude
|
|
||||||
/.kbctx
|
|
||||||
/server/.spool
|
/server/.spool
|
||||||
/server/.claude
|
/server/.claude
|
||||||
/.spool
|
/.spool
|
||||||
TUICallKit-Vue3/.env
|
TUICallKit-Vue3/.env
|
||||||
|
/.codegraph
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
alias ll='ls -alhG'
|
|
||||||
|
|
||||||
# Starship prompt
|
|
||||||
eval "$(starship init zsh)"
|
|
||||||
|
|
||||||
# zsh-autosuggestions 自动补全
|
|
||||||
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
|
|
||||||
|
|
||||||
# NVM 懒加载 - 只在第一次使用相关命令时再初始化
|
|
||||||
export NVM_DIR="$HOME/.nvm"
|
|
||||||
_nvm_default_bin() {
|
|
||||||
local default_alias version_dir
|
|
||||||
[ -r "$NVM_DIR/alias/default" ] || return 1
|
|
||||||
|
|
||||||
default_alias="$(<"$NVM_DIR/alias/default")"
|
|
||||||
default_alias="${default_alias#"${default_alias%%[![:space:]]*}"}"
|
|
||||||
default_alias="${default_alias%"${default_alias##*[![:space:]]}"}"
|
|
||||||
|
|
||||||
case "$default_alias" in
|
|
||||||
v[0-9]*)
|
|
||||||
version_dir="$NVM_DIR/versions/node/$default_alias"
|
|
||||||
;;
|
|
||||||
[0-9]*)
|
|
||||||
version_dir="$(ls -d "$NVM_DIR"/versions/node/v"$default_alias".* 2>/dev/null | tail -n 1)"
|
|
||||||
;;
|
|
||||||
node|stable|lts/*)
|
|
||||||
version_dir="$(ls -d "$NVM_DIR"/versions/node/v* 2>/dev/null | sort -V | tail -n 1)"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[ -n "$version_dir" ] && [ -x "$version_dir/bin/node" ] || return 1
|
|
||||||
printf '%s\n' "$version_dir/bin"
|
|
||||||
}
|
|
||||||
if _nvm_default_node_bin="$(_nvm_default_bin)"; then
|
|
||||||
export PATH="$_nvm_default_node_bin:$PATH"
|
|
||||||
fi
|
|
||||||
unset _nvm_default_node_bin
|
|
||||||
_lazy_load_nvm() {
|
|
||||||
unset -f nvm node npm npx pnpm yarn 2>/dev/null
|
|
||||||
[ -s "$NVM_DIR/nvm.sh" ] || return 1
|
|
||||||
\. "$NVM_DIR/nvm.sh"
|
|
||||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
|
||||||
hash -r 2>/dev/null
|
|
||||||
}
|
|
||||||
nvm() { _lazy_load_nvm && nvm "$@"; }
|
|
||||||
node() { _lazy_load_nvm && node "$@"; }
|
|
||||||
npm() { _lazy_load_nvm && npm "$@"; }
|
|
||||||
npx() { _lazy_load_nvm && npx "$@"; }
|
|
||||||
pnpm() { _lazy_load_nvm && pnpm "$@"; }
|
|
||||||
yarn() { _lazy_load_nvm && yarn "$@"; }
|
|
||||||
export PATH="/Applications/EServer/bin:$PATH"
|
|
||||||
function EC_start(){
|
|
||||||
/Applications/EasyConnect.app/Contents/Resources/bin/EasyMonitor > /dev/null 2>&1 &
|
|
||||||
/Applications/EasyConnect.app/Contents/MacOS/EasyConnect > /dev/null 2>&1 &
|
|
||||||
open /Applications/EasyConnect.app
|
|
||||||
}
|
|
||||||
|
|
||||||
function EC_kill(){
|
|
||||||
pkill EasyMonitor
|
|
||||||
pkill ECAgent
|
|
||||||
pkill ECAgentProxy
|
|
||||||
pkill EasyConnect
|
|
||||||
}
|
|
||||||
# The following lines have been added by Docker Desktop to enable Docker CLI completions.
|
|
||||||
fpath=(/Users/long/.docker/completions $fpath)
|
|
||||||
autoload -Uz compinit
|
|
||||||
compinit
|
|
||||||
# End of Docker CLI completions
|
|
||||||
|
|
||||||
|
|
||||||
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/
|
|
||||||
|
|
||||||
# pyenv 懒加载 - 只在第一次使用 Python 相关命令时再初始化
|
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
|
||||||
_lazy_load_pyenv() {
|
|
||||||
unset -f pyenv python python3 pip pip3 2>/dev/null
|
|
||||||
|
|
||||||
local pyenv_bin
|
|
||||||
pyenv_bin="$(command -v pyenv 2>/dev/null)"
|
|
||||||
if [ -z "$pyenv_bin" ] && [ -x /opt/homebrew/bin/pyenv ]; then
|
|
||||||
pyenv_bin=/opt/homebrew/bin/pyenv
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ -n "$pyenv_bin" ] || return 1
|
|
||||||
|
|
||||||
eval "$("$pyenv_bin" init --path)"
|
|
||||||
eval "$("$pyenv_bin" init - zsh)"
|
|
||||||
hash -r 2>/dev/null
|
|
||||||
}
|
|
||||||
pyenv() { _lazy_load_pyenv && pyenv "$@"; }
|
|
||||||
python() { _lazy_load_pyenv && python "$@"; }
|
|
||||||
python3() { _lazy_load_pyenv && python3 "$@"; }
|
|
||||||
pip() { _lazy_load_pyenv && pip "$@"; }
|
|
||||||
pip3() { _lazy_load_pyenv && pip3 "$@"; }
|
|
||||||
|
|
||||||
# 引入 bash 配置
|
|
||||||
if [ -f ~/.bash_profile ]; then
|
|
||||||
source ~/.bash_profile
|
|
||||||
fi
|
|
||||||
if [ -f ~/.bashrc ]; then
|
|
||||||
source ~/.bashrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Added by Windsurf
|
|
||||||
export PATH="/Users/long/.codeium/windsurf/bin:$PATH"
|
|
||||||
|
|
||||||
# Added by Antigravity
|
|
||||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
|
|
||||||
# MindOS Desktop — CLI (mindos)
|
|
||||||
export PATH="$HOME/.mindos/bin:$PATH"
|
|
||||||
|
|
||||||
source "$HOME/.cargo/env"
|
|
||||||
|
|
||||||
# Added by Antigravity
|
|
||||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
alias ll='ls -alhG'
|
|
||||||
|
|
||||||
# Starship prompt
|
|
||||||
eval "$(starship init zsh)"
|
|
||||||
|
|
||||||
# zsh-autosuggestions 自动补全
|
|
||||||
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
|
|
||||||
|
|
||||||
# NVM
|
|
||||||
export NVM_DIR="$HOME/.nvm"
|
|
||||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
|
||||||
\. "$NVM_DIR/nvm.sh"
|
|
||||||
fi
|
|
||||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
|
||||||
export PATH="/Applications/EServer/bin:$PATH"
|
|
||||||
function EC_start(){
|
|
||||||
/Applications/EasyConnect.app/Contents/Resources/bin/EasyMonitor > /dev/null 2>&1 &
|
|
||||||
/Applications/EasyConnect.app/Contents/MacOS/EasyConnect > /dev/null 2>&1 &
|
|
||||||
open /Applications/EasyConnect.app
|
|
||||||
}
|
|
||||||
|
|
||||||
function EC_kill(){
|
|
||||||
pkill EasyMonitor
|
|
||||||
pkill ECAgent
|
|
||||||
pkill ECAgentProxy
|
|
||||||
pkill EasyConnect
|
|
||||||
}
|
|
||||||
# The following lines have been added by Docker Desktop to enable Docker CLI completions.
|
|
||||||
fpath=(/Users/long/.docker/completions $fpath)
|
|
||||||
autoload -Uz compinit
|
|
||||||
compinit
|
|
||||||
# End of Docker CLI completions
|
|
||||||
|
|
||||||
|
|
||||||
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
export PYENV_ROOT="$HOME/.pyenv"
|
|
||||||
export PATH="/opt/homebrew/bin:$PYENV_ROOT/bin:$PATH"
|
|
||||||
if command -v pyenv >/dev/null 2>&1; then
|
|
||||||
eval "$(pyenv init --path)"
|
|
||||||
eval "$(pyenv init - zsh)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 引入 bash 配置
|
|
||||||
if [ -f ~/.bash_profile ]; then
|
|
||||||
source ~/.bash_profile
|
|
||||||
fi
|
|
||||||
if [ -f ~/.bashrc ]; then
|
|
||||||
source ~/.bashrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Added by Windsurf
|
|
||||||
export PATH="/Users/long/.codeium/windsurf/bin:$PATH"
|
|
||||||
|
|
||||||
# Added by Antigravity
|
|
||||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
|
||||||
|
|
||||||
if command -v nvm >/dev/null 2>&1; then
|
|
||||||
_nvm_default_version="$(nvm version default 2>/dev/null)"
|
|
||||||
if [ -n "$_nvm_default_version" ] && [ -d "$NVM_DIR/versions/node/$_nvm_default_version/bin" ]; then
|
|
||||||
export PATH="$NVM_DIR/versions/node/$_nvm_default_version/bin:$PATH"
|
|
||||||
fi
|
|
||||||
unset _nvm_default_version
|
|
||||||
fi
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
|
|
||||||
# MindOS Desktop — CLI (mindos)
|
|
||||||
export PATH="$HOME/.mindos/bin:$PATH"
|
|
||||||
|
|
||||||
source "$HOME/.cargo/env"
|
|
||||||
|
|
||||||
# Added by Antigravity
|
|
||||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
|
||||||
@@ -1,42 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 页面内嵌模块(如 weekly 统计页主内容区) -->
|
<!-- 页面内嵌模块(如 weekly 统计页主内容区) -->
|
||||||
<view v-if="visible && props.inline" class="dev-entry-inline">
|
<view
|
||||||
<view class="inline-head">
|
v-if="visible && props.inline"
|
||||||
<view class="inline-head-text">
|
class="dev-entry-inline"
|
||||||
<text class="inline-title">居家耗糖训练</text>
|
:class="{ 'dev-entry-inline--weekly': props.tone === 'weekly' }"
|
||||||
<text class="inline-sub">低强度动起来,配合血糖管理</text>
|
>
|
||||||
|
<view class="inline-hero">
|
||||||
|
<view class="inline-hero-icon" aria-hidden="true">
|
||||||
|
<view class="inline-hero-wave" />
|
||||||
</view>
|
</view>
|
||||||
<view class="inline-wave" aria-hidden="true" />
|
<view class="inline-hero-text">
|
||||||
</view>
|
<view class="inline-hero-title-row">
|
||||||
<view class="inline-grid">
|
<text class="inline-title">居家耗糖训练</text>
|
||||||
<view
|
<text v-if="props.tone === 'weekly'" class="inline-pill">体验</text>
|
||||||
v-for="item in trainingItems"
|
|
||||||
:key="item.url"
|
|
||||||
class="inline-item"
|
|
||||||
@click="goto(item.url)"
|
|
||||||
>
|
|
||||||
<view class="menu-icon" :class="item.iconClass">
|
|
||||||
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
|
|
||||||
<view class="dumbbell-plate dumbbell-plate-left" />
|
|
||||||
<view class="dumbbell-bar" />
|
|
||||||
<view class="dumbbell-plate dumbbell-plate-right" />
|
|
||||||
</view>
|
|
||||||
<view v-else-if="item.icon === 'pedal'" class="foot-pedal-shape">
|
|
||||||
<view class="pedal-bar pedal-bar-left" />
|
|
||||||
<view class="pedal-bar pedal-bar-right" />
|
|
||||||
</view>
|
|
||||||
<view v-else-if="item.icon === 'pilates'" class="pilates-ring-shape" />
|
|
||||||
<view v-else-if="item.icon === 'grip'" class="grip-ring-shape" />
|
|
||||||
<view v-else-if="item.icon === 'metro'" class="menu-icon--metro-inner">
|
|
||||||
<view class="metro-bar metro-bar-1" />
|
|
||||||
<view class="metro-bar metro-bar-2" />
|
|
||||||
<view class="metro-bar metro-bar-3" />
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<text class="inline-item-title">{{ item.title }}</text>
|
<text class="inline-sub">低强度动起来,帮助身体消耗糖分</text>
|
||||||
<text class="inline-item-sub">{{ item.sub }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
scroll-x
|
||||||
|
class="inline-scroll"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
:enable-flex="true"
|
||||||
|
>
|
||||||
|
<view class="inline-scroll-track">
|
||||||
|
<view
|
||||||
|
v-for="item in inlineItems"
|
||||||
|
:key="item.url"
|
||||||
|
class="inline-card"
|
||||||
|
@click="goto(item.url)"
|
||||||
|
>
|
||||||
|
<view v-if="item.featured" class="inline-card-badge">
|
||||||
|
<text>推荐</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-icon" :class="item.iconClass">
|
||||||
|
<view v-if="item.icon === 'dumbbell'" class="dumbbell-shape">
|
||||||
|
<view class="dumbbell-plate dumbbell-plate-left" />
|
||||||
|
<view class="dumbbell-bar" />
|
||||||
|
<view class="dumbbell-plate dumbbell-plate-right" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="item.icon === 'pedal'" class="foot-pedal-shape">
|
||||||
|
<view class="pedal-bar pedal-bar-left" />
|
||||||
|
<view class="pedal-bar pedal-bar-right" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="item.icon === 'pilates'" class="pilates-ring-shape" />
|
||||||
|
<view v-else-if="item.icon === 'grip'" class="grip-ring-shape" />
|
||||||
|
<view v-else-if="item.icon === 'metro'" class="menu-icon--metro-inner">
|
||||||
|
<view class="metro-bar metro-bar-1" />
|
||||||
|
<view class="metro-bar metro-bar-2" />
|
||||||
|
<view class="metro-bar metro-bar-3" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="inline-card-title">{{ item.title }}</text>
|
||||||
|
<text class="inline-card-sub">{{ item.sub }}</text>
|
||||||
|
<text class="inline-card-go" aria-hidden="true">进入</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<text v-if="props.tone === 'weekly'" class="inline-scroll-hint">左右滑动,选择适合您的训练</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
<view v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
||||||
@@ -124,15 +147,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const trainingItems = [
|
type TrainingItem = {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
sub: string
|
||||||
|
icon: 'dumbbell' | 'pedal' | 'pilates' | 'grip' | 'metro'
|
||||||
|
iconClass: string
|
||||||
|
featured?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const trainingItems: TrainingItem[] = [
|
||||||
{ url: '/training/pages/dumbbell', title: '哑铃', sub: '力量塑形', icon: 'dumbbell', iconClass: 'menu-icon--dumbbell' },
|
{ url: '/training/pages/dumbbell', title: '哑铃', sub: '力量塑形', icon: 'dumbbell', iconClass: 'menu-icon--dumbbell' },
|
||||||
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
|
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
|
||||||
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
|
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
|
||||||
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
|
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
|
||||||
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro' },
|
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
|
||||||
] as const
|
]
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -140,10 +172,20 @@ const props = withDefaults(
|
|||||||
bottom?: number
|
bottom?: number
|
||||||
/** 内嵌到页面主内容区,非 fixed 悬浮 */
|
/** 内嵌到页面主内容区,非 fixed 悬浮 */
|
||||||
inline?: boolean
|
inline?: boolean
|
||||||
|
/** weekly 页:与统计页 Vital Mint 视觉一致 */
|
||||||
|
tone?: 'default' | 'weekly'
|
||||||
}>(),
|
}>(),
|
||||||
{ bottom: 200, inline: false },
|
{ bottom: 200, inline: false, tone: 'default' },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** weekly 将健走节拍器置前并标推荐 */
|
||||||
|
const inlineItems = computed(() => {
|
||||||
|
if (props.tone !== 'weekly') return trainingItems
|
||||||
|
const metro = trainingItems.find((i) => i.icon === 'metro')
|
||||||
|
const rest = trainingItems.filter((i) => i.icon !== 'metro')
|
||||||
|
return metro ? [metro, ...rest] : trainingItems
|
||||||
|
})
|
||||||
|
|
||||||
const isDevMode = (): boolean => {
|
const isDevMode = (): boolean => {
|
||||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||||
try {
|
try {
|
||||||
@@ -515,28 +557,59 @@ $brand-light: #34d399;
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
.dev-entry-inline {
|
.dev-entry-inline {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 24rpx 24rpx 20rpx;
|
|
||||||
border-radius: 48rpx;
|
|
||||||
background: #fff;
|
|
||||||
border: 1rpx solid rgba(0, 108, 73, 0.12);
|
|
||||||
box-shadow: 0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08), 0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-head {
|
.dev-entry-inline--weekly {
|
||||||
display: flex;
|
padding: 32rpx 28rpx 24rpx;
|
||||||
align-items: center;
|
border-radius: 48rpx;
|
||||||
justify-content: space-between;
|
background: var(--surface-container-lowest, #fff);
|
||||||
gap: 16rpx;
|
border: 1rpx solid rgba(0, 108, 73, 0.12);
|
||||||
margin-bottom: 20rpx;
|
box-shadow:
|
||||||
|
0 24rpx 60rpx -20rpx rgba(0, 108, 73, 0.08),
|
||||||
|
0 8rpx 20rpx -8rpx rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-head-text {
|
.inline-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero-icon {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero-wave {
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23006c49' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4rpx;
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-hero-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-title {
|
.inline-title {
|
||||||
@@ -546,75 +619,119 @@ $brand-light: #34d399;
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-pill {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #006c49;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-sub {
|
.inline-sub {
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #64748b;
|
color: #475569;
|
||||||
line-height: 1.35;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-wave {
|
.inline-scroll {
|
||||||
width: 120rpx;
|
width: 100%;
|
||||||
height: 48rpx;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
|
||||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 40' fill='none'><path d='M0 20 H18 L26 8 L34 32 L42 14 L50 26 L58 20 H120' stroke='%23006c49' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' opacity='0.85'/></svg>");
|
|
||||||
background-size: contain;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 五行并列,一屏展示完整(不横向滑动) */
|
.inline-scroll-track {
|
||||||
.inline-grid {
|
display: inline-flex;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: space-between;
|
gap: 16rpx;
|
||||||
gap: 8rpx;
|
padding: 4rpx 2rpx 8rpx;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-item {
|
.inline-card {
|
||||||
flex: 1;
|
position: relative;
|
||||||
min-width: 0;
|
display: inline-flex;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
padding: 14rpx 4rpx 12rpx;
|
width: 212rpx;
|
||||||
border-radius: 20rpx;
|
min-height: 220rpx;
|
||||||
background: #f0fdf4;
|
padding: 20rpx 18rpx 18rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: #f4f7f5;
|
||||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.98);
|
||||||
opacity: 0.9;
|
background: #e8f3ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
width: 56rpx;
|
width: 72rpx;
|
||||||
height: 56rpx;
|
height: 72rpx;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: 14rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-item-title {
|
.inline-card-badge {
|
||||||
font-size: 22rpx;
|
position: absolute;
|
||||||
font-weight: 700;
|
top: 12rpx;
|
||||||
color: #0f172a;
|
right: 12rpx;
|
||||||
text-align: center;
|
padding: 4rpx 10rpx;
|
||||||
line-height: 1.2;
|
border-radius: 8rpx;
|
||||||
width: 100%;
|
background: #006c49;
|
||||||
word-break: break-all;
|
|
||||||
|
text {
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-item-sub {
|
.inline-card-title {
|
||||||
margin-top: 2rpx;
|
font-size: 28rpx;
|
||||||
font-size: 18rpx;
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.25;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card-sub {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.35;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-card-go {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #006c49;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-scroll-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.2;
|
line-height: 1.35;
|
||||||
width: 100%;
|
}
|
||||||
word-break: break-all;
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.inline-card:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -52,14 +52,14 @@
|
|||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false
|
"urlCheck" : false,
|
||||||
|
"minified" : true
|
||||||
},
|
},
|
||||||
"optimization" : {
|
"optimization" : {
|
||||||
"subPackages" : true
|
"subPackages" : true
|
||||||
},
|
},
|
||||||
"usingComponents" : true,
|
"usingComponents" : true,
|
||||||
"requiredBackgroundModes" : ["audio"],
|
"requiredBackgroundModes" : [ "audio" ],
|
||||||
|
|
||||||
"plugins" : {
|
"plugins" : {
|
||||||
"WechatSI" : {
|
"WechatSI" : {
|
||||||
"version" : "0.3.5",
|
"version" : "0.3.5",
|
||||||
|
|||||||
@@ -204,7 +204,15 @@
|
|||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "日常护理",
|
"navigationBarTitleText": "日常护理",
|
||||||
"backgroundColor": "#f4fbf4",
|
"backgroundColor": "#f4fbf4",
|
||||||
"enablePullDownRefresh": true
|
"enablePullDownRefresh": true,
|
||||||
|
"mp-weixin": {
|
||||||
|
"usingPlugins": {
|
||||||
|
"WechatSI": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"provider": "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 45 KiB |
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
|
<view
|
||||||
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
class="food-tile-icon"
|
||||||
|
:class="[`food-tile-icon--${size}`, { 'is-custom-img': hasCustomImg }]"
|
||||||
|
>
|
||||||
|
<text v-if="showEmojiFallback" class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||||
<image
|
<image
|
||||||
v-if="src && !imageFailed"
|
v-if="src && !imageFailed"
|
||||||
class="food-tile-icon__img"
|
class="food-tile-icon__img"
|
||||||
:src="src"
|
:src="src"
|
||||||
:mode="size === 'tile' ? 'aspectFill' : 'aspectFit'"
|
:mode="imageMode"
|
||||||
@error="onImageError"
|
@error="onImageError"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
@@ -27,12 +30,20 @@ const fallbackIcon = computed(() => {
|
|||||||
return '🍽'
|
return '🍽'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
|
||||||
|
|
||||||
const src = computed(() => {
|
const src = computed(() => {
|
||||||
if (typeof props.food === 'object' && props.food?.img) return props.food.img
|
if (typeof props.food === 'object' && props.food?.img) return props.food.img
|
||||||
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
|
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
|
||||||
|
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
|
||||||
|
|
||||||
|
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
|
||||||
|
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => src.value,
|
() => src.value,
|
||||||
() => {
|
() => {
|
||||||
@@ -66,11 +77,21 @@ function onImageError() {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
width: 58%;
|
width: 75%;
|
||||||
height: 58%;
|
height: 75%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.food-tile-icon--tile.is-custom-img .food-tile-icon__img {
|
||||||
|
width: 82%;
|
||||||
|
height: 82%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
|
||||||
|
.food-tile-icon--tile .food-tile-icon__img {
|
||||||
|
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
|
||||||
|
}
|
||||||
|
|
||||||
.food-tile-icon--goal {
|
.food-tile-icon--goal {
|
||||||
width: 40rpx;
|
width: 40rpx;
|
||||||
height: 40rpx;
|
height: 40rpx;
|
||||||
@@ -104,6 +125,11 @@ function onImageError() {
|
|||||||
font-size: 34rpx;
|
font-size: 34rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.food-tile-icon--goal.is-custom-img .food-tile-icon__img,
|
||||||
|
.food-tile-icon--tooltip.is-custom-img .food-tile-icon__img {
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.food-tile-icon__img {
|
.food-tile-icon__img {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
|
||||||
|
<view class="vsm-body">
|
||||||
|
<view class="vsm-leaf" aria-hidden="true" />
|
||||||
|
<view class="vsm-face">
|
||||||
|
<view class="vsm-eye vsm-eye--l" />
|
||||||
|
<view class="vsm-eye vsm-eye--r" />
|
||||||
|
<view class="vsm-blush vsm-blush--l" />
|
||||||
|
<view class="vsm-blush vsm-blush--r" />
|
||||||
|
<view class="vsm-mouth" />
|
||||||
|
</view>
|
||||||
|
<view v-if="active" class="vsm-waves" aria-hidden="true">
|
||||||
|
<view class="vsm-wave vsm-wave--1" />
|
||||||
|
<view class="vsm-wave vsm-wave--2" />
|
||||||
|
<view class="vsm-wave vsm-wave--3" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
/** 正在按住说话 / 录音中 */
|
||||||
|
active: { type: Boolean, default: false },
|
||||||
|
/** 深色条背景上使用浅色卡通 */
|
||||||
|
onDark: { type: Boolean, default: false },
|
||||||
|
size: { type: String, default: 'md' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.vsm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.vsm--md { width: 72rpx; height: 72rpx; }
|
||||||
|
.vsm--sm { width: 64rpx; height: 64rpx; }
|
||||||
|
|
||||||
|
.vsm-body {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-leaf {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rpx;
|
||||||
|
left: 50%;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
margin-left: -8rpx;
|
||||||
|
border-radius: 0 100% 0 100%;
|
||||||
|
background: #34d399;
|
||||||
|
transform: rotate(-18deg);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-leaf {
|
||||||
|
background: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-face {
|
||||||
|
position: relative;
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fef9c3;
|
||||||
|
border: 3rpx solid #047857;
|
||||||
|
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-face {
|
||||||
|
width: 46rpx;
|
||||||
|
height: 46rpx;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-face {
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vsm-eye {
|
||||||
|
position: absolute;
|
||||||
|
top: 16rpx;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #064e3b;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
|
||||||
|
.vsm--on-dark .vsm-eye { background: #134e4a; }
|
||||||
|
.vsm-eye--l { left: 12rpx; }
|
||||||
|
.vsm-eye--r { right: 12rpx; }
|
||||||
|
.vsm--sm .vsm-eye--l { left: 10rpx; }
|
||||||
|
.vsm--sm .vsm-eye--r { right: 10rpx; }
|
||||||
|
|
||||||
|
.vsm-blush {
|
||||||
|
position: absolute;
|
||||||
|
top: 24rpx;
|
||||||
|
width: 10rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(251, 113, 133, 0.45);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
|
||||||
|
.vsm-blush--l { left: 6rpx; }
|
||||||
|
.vsm-blush--r { right: 6rpx; }
|
||||||
|
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
|
||||||
|
|
||||||
|
.vsm-mouth {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 10rpx;
|
||||||
|
width: 18rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
margin-left: -9rpx;
|
||||||
|
border-radius: 0 0 18rpx 18rpx;
|
||||||
|
background: #047857;
|
||||||
|
transform-origin: center top;
|
||||||
|
}
|
||||||
|
.vsm--sm .vsm-mouth {
|
||||||
|
bottom: 9rpx;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 7rpx;
|
||||||
|
margin-left: -8rpx;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
|
||||||
|
|
||||||
|
.vsm-waves {
|
||||||
|
position: absolute;
|
||||||
|
right: -2rpx;
|
||||||
|
top: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
margin-top: -14rpx;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.vsm-wave {
|
||||||
|
width: 5rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
background: #047857;
|
||||||
|
animation: vsm-wave-jump 0.72s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
|
||||||
|
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
|
||||||
|
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
|
||||||
|
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
|
||||||
|
|
||||||
|
.vsm--active .vsm-body {
|
||||||
|
animation: vsm-bob 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vsm--active .vsm-mouth {
|
||||||
|
animation: vsm-talk 0.32s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.vsm--active .vsm-eye {
|
||||||
|
animation: vsm-blink 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vsm-bob {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-4rpx); }
|
||||||
|
}
|
||||||
|
@keyframes vsm-talk {
|
||||||
|
0% {
|
||||||
|
transform: scaleY(0.45);
|
||||||
|
border-radius: 0 0 18rpx 18rpx;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1.15);
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes vsm-blink {
|
||||||
|
0%, 42%, 46%, 100% { transform: scaleY(1); }
|
||||||
|
44% { transform: scaleY(0.15); }
|
||||||
|
}
|
||||||
|
@keyframes vsm-wave-jump {
|
||||||
|
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
|
||||||
|
50% { transform: scaleY(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
|
||||||
|
|
||||||
|
const emptyBloodCarePlan = () => ({
|
||||||
|
summary: '',
|
||||||
|
blood_advice: '',
|
||||||
|
tcm_plan: '',
|
||||||
|
watch_points: [],
|
||||||
|
next_steps: '',
|
||||||
|
control_level: '',
|
||||||
|
control_label: '',
|
||||||
|
disclaimer: '',
|
||||||
|
analysis: [],
|
||||||
|
source: '',
|
||||||
|
date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** AI 血糖照护建议(累计 7 天有记录后) */
|
||||||
|
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
|
||||||
|
const bloodCarePlan = ref(emptyBloodCarePlan())
|
||||||
|
const bloodCareLoading = ref(false)
|
||||||
|
const bloodCareStreaming = ref(false)
|
||||||
|
const bloodCareStreamField = ref('')
|
||||||
|
let careStreamBuffer = ''
|
||||||
|
/** 用户点击后才展开照护建议卡片 */
|
||||||
|
const bloodCarePanelOpen = ref(false)
|
||||||
|
|
||||||
|
const bloodCareEligible = computed(() => {
|
||||||
|
return !!diagnosisId.value && !chartUseMockData.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasBloodCareContent = computed(() => {
|
||||||
|
const p = bloodCarePlan.value
|
||||||
|
return !!(p.summary || p.blood_advice || p.tcm_plan)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bloodCareAnalysis = computed(() => {
|
||||||
|
const list = bloodCarePlan.value?.analysis
|
||||||
|
return Array.isArray(list) ? list : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const bloodCareControlLabel = computed(() => {
|
||||||
|
const label = String(bloodCarePlan.value?.control_label || '').trim()
|
||||||
|
return label
|
||||||
|
})
|
||||||
|
|
||||||
|
const bloodCareDisclaimer = computed(() => {
|
||||||
|
return String(bloodCarePlan.value?.disclaimer || '').trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
function detectCareStreamField(text) {
|
||||||
|
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[::]\s*([^\n]*)$/)
|
||||||
|
if (!tail) return 'summary'
|
||||||
|
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
|
||||||
|
return map[tail[1]] || 'summary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePartialCareFromStream() {
|
||||||
|
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
|
||||||
|
const next = { ...bloodCarePlan.value }
|
||||||
|
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
|
||||||
|
if (partial[key] != null) next[key] = partial[key]
|
||||||
|
}
|
||||||
|
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
|
||||||
|
bloodCarePlan.value = next
|
||||||
|
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBloodCarePayload(payload) {
|
||||||
|
if (!payload) return
|
||||||
|
const prevAnalysis = bloodCarePlan.value.analysis
|
||||||
|
bloodCarePlan.value = {
|
||||||
|
...emptyBloodCarePlan(),
|
||||||
|
...payload,
|
||||||
|
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
|
||||||
|
analysis: Array.isArray(payload.analysis) && payload.analysis.length
|
||||||
|
? payload.analysis
|
||||||
|
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBloodCareStreamEvent(event, payload) {
|
||||||
|
if (event === 'analysis') {
|
||||||
|
if (Array.isArray(payload?.analysis)) {
|
||||||
|
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
|
||||||
|
}
|
||||||
|
} else if (event === 'delta') {
|
||||||
|
bloodCareStreaming.value = true
|
||||||
|
if (payload.buffer != null) {
|
||||||
|
careStreamBuffer = payload.buffer
|
||||||
|
} else {
|
||||||
|
careStreamBuffer += payload.text || ''
|
||||||
|
}
|
||||||
|
mergePartialCareFromStream()
|
||||||
|
} else if (event === 'done') {
|
||||||
|
applyBloodCarePayload(payload)
|
||||||
|
careStreamBuffer = ''
|
||||||
|
bloodCareStreamField.value = ''
|
||||||
|
} else if (event === 'error') {
|
||||||
|
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBloodCareAiFallback(refresh) {
|
||||||
|
const res = await proxy.apiUrl({
|
||||||
|
url: '/api/tcm/dailyBloodCareAiRecommend',
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 35000,
|
||||||
|
data: {
|
||||||
|
diagnosis_id: diagnosisId.value,
|
||||||
|
refresh: refresh ? 1 : 0,
|
||||||
|
...(refresh ? { _t: Date.now() } : {})
|
||||||
|
}
|
||||||
|
}, false)
|
||||||
|
if (res?.code === 1 && res.data) {
|
||||||
|
applyBloodCarePayload(res.data)
|
||||||
|
} else if (res?.msg) {
|
||||||
|
showUserToast(res.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBloodCarePanel() {
|
||||||
|
bloodCarePanelOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
|
||||||
|
async function toggleBloodCareFromChart() {
|
||||||
|
if (!bloodCareEligible.value || bloodCareLoading.value) return
|
||||||
|
if (bloodCarePanelOpen.value) {
|
||||||
|
closeBloodCarePanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bloodCarePanelOpen.value = true
|
||||||
|
if (!hasBloodCareContent.value) {
|
||||||
|
await fetchBloodCareAi(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBloodCareAi(refresh = false) {
|
||||||
|
if (!bloodCareEligible.value || bloodCareLoading.value) return
|
||||||
|
bloodCareLoading.value = true
|
||||||
|
bloodCareStreaming.value = false
|
||||||
|
careStreamBuffer = ''
|
||||||
|
bloodCareStreamField.value = ''
|
||||||
|
if (refresh) {
|
||||||
|
applyBloodCarePayload(emptyBloodCarePlan())
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestAiStream({
|
||||||
|
baseUrl: proxy.$url,
|
||||||
|
url: '/api/tcm/dailyBloodCareAiRecommendStream',
|
||||||
|
method: 'GET',
|
||||||
|
data: {
|
||||||
|
diagnosis_id: diagnosisId.value,
|
||||||
|
refresh: refresh ? 1 : 0,
|
||||||
|
...(refresh ? { _t: Date.now() } : {})
|
||||||
|
},
|
||||||
|
onEvent: onBloodCareStreamEvent,
|
||||||
|
fallback: () => fetchBloodCareAiFallback(refresh)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await fetchBloodCareAiFallback(refresh)
|
||||||
|
} catch (e2) {
|
||||||
|
showUserToast('照护建议加载失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bloodCareLoading.value = false
|
||||||
|
bloodCareStreaming.value = false
|
||||||
|
bloodCareStreamField.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshBloodCareAi() {
|
||||||
|
if (bloodCareLoading.value) return
|
||||||
|
fetchBloodCareAi(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBloodCarePlan() {
|
||||||
|
applyBloodCarePayload(emptyBloodCarePlan())
|
||||||
|
careStreamBuffer = ''
|
||||||
|
bloodCareStreamField.value = ''
|
||||||
|
bloodCarePanelOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bloodCarePlan,
|
||||||
|
bloodCareLoading,
|
||||||
|
bloodCareStreaming,
|
||||||
|
bloodCareStreamField,
|
||||||
|
bloodCareEligible,
|
||||||
|
bloodCarePanelOpen,
|
||||||
|
hasBloodCareContent,
|
||||||
|
bloodCareAnalysis,
|
||||||
|
bloodCareControlLabel,
|
||||||
|
bloodCareDisclaimer,
|
||||||
|
fetchBloodCareAi,
|
||||||
|
toggleBloodCareFromChart,
|
||||||
|
closeBloodCarePanel,
|
||||||
|
refreshBloodCareAi,
|
||||||
|
resetBloodCarePlan
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
|
|||||||
|
|
||||||
const emptyDietAiRecommend = () => ({
|
const emptyDietAiRecommend = () => ({
|
||||||
breakfast: '',
|
breakfast: '',
|
||||||
|
drinks: '',
|
||||||
lunch: '',
|
lunch: '',
|
||||||
dinner: '',
|
dinner: '',
|
||||||
tips: '',
|
tips: '',
|
||||||
@@ -31,20 +32,20 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
|||||||
|
|
||||||
const hasDietAiMealContent = computed(() => {
|
const hasDietAiMealContent = computed(() => {
|
||||||
const r = dietAiRecommend.value
|
const r = dietAiRecommend.value
|
||||||
return !!(r.breakfast || r.lunch || r.dinner || r.tips)
|
return !!(r.breakfast || r.drinks || r.lunch || r.dinner || r.tips)
|
||||||
})
|
})
|
||||||
|
|
||||||
function detectDietStreamField(text) {
|
function detectDietStreamField(text) {
|
||||||
const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||||
if (!tail) return 'breakfast'
|
if (!tail) return 'breakfast'
|
||||||
const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
||||||
return map[tail[1]] || 'breakfast'
|
return map[tail[1]] || 'breakfast'
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergePartialDietFromStream() {
|
function mergePartialDietFromStream() {
|
||||||
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
||||||
const next = { ...dietAiRecommend.value }
|
const next = { ...dietAiRecommend.value }
|
||||||
for (const key of ['breakfast', 'lunch', 'dinner', 'tips']) {
|
for (const key of ['breakfast', 'drinks', 'lunch', 'dinner', 'tips']) {
|
||||||
if (partial[key] != null) next[key] = partial[key]
|
if (partial[key] != null) next[key] = partial[key]
|
||||||
}
|
}
|
||||||
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
if (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
||||||
@@ -80,6 +81,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
|||||||
...emptyDietAiRecommend(),
|
...emptyDietAiRecommend(),
|
||||||
...payload,
|
...payload,
|
||||||
breakfast: streamed.breakfast,
|
breakfast: streamed.breakfast,
|
||||||
|
drinks: streamed.drinks || payload.drinks || '',
|
||||||
lunch: streamed.lunch || payload.lunch || '',
|
lunch: streamed.lunch || payload.lunch || '',
|
||||||
dinner: streamed.dinner || payload.dinner || '',
|
dinner: streamed.dinner || payload.dinner || '',
|
||||||
tips: streamed.tips || payload.tips || '',
|
tips: streamed.tips || payload.tips || '',
|
||||||
@@ -205,6 +207,8 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!diagnosisId.value) return
|
if (!diagnosisId.value) return
|
||||||
|
// 发送后立即清空输入框
|
||||||
|
dietAiAskText.value = ''
|
||||||
dietAiAsking.value = true
|
dietAiAsking.value = true
|
||||||
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
||||||
try {
|
try {
|
||||||
@@ -259,6 +263,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
|||||||
try {
|
try {
|
||||||
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
||||||
breakfast: r.breakfast,
|
breakfast: r.breakfast,
|
||||||
|
drinks: r.drinks || '',
|
||||||
lunch: r.lunch,
|
lunch: r.lunch,
|
||||||
dinner: r.dinner,
|
dinner: r.dinner,
|
||||||
tips: r.tips || ''
|
tips: r.tips || ''
|
||||||
|
|||||||
@@ -2,22 +2,106 @@ import { ref, onUnmounted } from 'vue'
|
|||||||
|
|
||||||
const MIN_HOLD_MS = 280
|
const MIN_HOLD_MS = 280
|
||||||
|
|
||||||
|
/** 短按/过早松手时插件常见错误,不应打扰用户 */
|
||||||
|
function isBenignSttError(msg, retcode) {
|
||||||
|
const m = String(msg || '').toLowerCase()
|
||||||
|
if (/internal voice data failed/i.test(m)) return true
|
||||||
|
if (/voice data failed/i.test(m)) return true
|
||||||
|
if (/recordfailed|record failed|record manager/i.test(m)) return true
|
||||||
|
if (/please stop after start/i.test(m)) return true
|
||||||
|
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
|
||||||
|
const code = Number(retcode)
|
||||||
|
if (code === -30002 || code === -30003 || code === -30012) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlySttError(msg) {
|
||||||
|
const m = String(msg || '').trim()
|
||||||
|
if (!m || isBenignSttError(m)) return ''
|
||||||
|
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
|
||||||
|
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
|
||||||
|
return '语音识别失败,请重试'
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
||||||
|
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
||||||
|
// 回调仍指向已销毁页面的闭包,新页面收不到 onStart/onStop,
|
||||||
|
// 其本地状态卡在 recording,表现为一直录制/无法再次语音。
|
||||||
|
let sharedManager = null
|
||||||
|
let sharedManagerInited = false
|
||||||
|
let sharedRecording = false
|
||||||
|
let activeCtl = null
|
||||||
|
|
||||||
|
function getSharedRecordManager() {
|
||||||
|
if (sharedManagerInited) return sharedManager
|
||||||
|
sharedManagerInited = true
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const plugin = requirePlugin('WechatSI')
|
||||||
|
sharedManager = plugin.getRecordRecognitionManager()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('WechatSI record recognition not available', e)
|
||||||
|
sharedManager = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!sharedManager) return null
|
||||||
|
|
||||||
|
sharedManager.onStart = () => {
|
||||||
|
sharedRecording = true
|
||||||
|
if (activeCtl) activeCtl.handleStart()
|
||||||
|
}
|
||||||
|
sharedManager.onRecognize = (res) => {
|
||||||
|
if (activeCtl) activeCtl.handleRecognize(res)
|
||||||
|
}
|
||||||
|
sharedManager.onStop = (res) => {
|
||||||
|
sharedRecording = false
|
||||||
|
if (activeCtl) activeCtl.handleStop(res)
|
||||||
|
}
|
||||||
|
sharedManager.onError = (res) => {
|
||||||
|
sharedRecording = false
|
||||||
|
if (activeCtl) activeCtl.handleError(res)
|
||||||
|
}
|
||||||
|
return sharedManager
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
||||||
*
|
*
|
||||||
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
|
* @param {{
|
||||||
|
* onResult?: (text: string) => void,
|
||||||
|
* onSettle?: (text: string, info: { heldMs: number }) => boolean | void,
|
||||||
|
* onPartial?: (text: string) => void,
|
||||||
|
* onError?: (msg: string) => boolean | void,
|
||||||
|
* showToast?: (msg: string) => void
|
||||||
|
* }} options
|
||||||
|
*
|
||||||
|
* onSettle 在每次录音结束时都会触发(含空结果),用于语音一问一答等需要
|
||||||
|
* 完全接管识别结果的场景。若 onSettle 返回 true,则跳过默认的 onResult 回调
|
||||||
|
* 与「没听清」提示,避免与上层流程重复处理。
|
||||||
|
*
|
||||||
|
* onPartial 在录音过程中实时返回中间识别结果(微信 onRecognize / H5 interim),
|
||||||
|
* 用于「抢答打断」等场景:检测到用户已开口作答即可提前结束播报/录音。
|
||||||
|
*
|
||||||
|
* onError 在录音/识别出错时触发(如 "record manager recordfailed")。
|
||||||
|
* 若返回 true,则跳过默认的错误 Toast,由上层自行处理(如自动重试)。
|
||||||
*/
|
*/
|
||||||
export function useSpeechToText({ onResult, showToast } = {}) {
|
export function useSpeechToText({ onResult, onSettle, onPartial, onError, showToast } = {}) {
|
||||||
const sttListening = ref(false)
|
const sttListening = ref(false)
|
||||||
const sttHolding = ref(false)
|
const sttHolding = ref(false)
|
||||||
const sttSupported = ref(false)
|
const sttSupported = ref(false)
|
||||||
|
|
||||||
let wxRecordManager = null
|
let wxRecordManager = null
|
||||||
let h5Recognition = null
|
let h5Recognition = null
|
||||||
|
let h5GotResult = false
|
||||||
let holdStartTs = 0
|
let holdStartTs = 0
|
||||||
let holdSessionId = 0
|
let holdSessionId = 0
|
||||||
let startRequested = false
|
let startRequested = false
|
||||||
let recordAuthorized = null
|
let recordAuthorized = null
|
||||||
|
// 快速点按可能残留会话:用排队标记把 start/stop 串行化,
|
||||||
|
// 避免 "please stop after start"
|
||||||
|
let pendingStartSession = 0
|
||||||
|
|
||||||
function notify(msg) {
|
function notify(msg) {
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
@@ -31,6 +115,11 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
function emitResult(text) {
|
function emitResult(text) {
|
||||||
const t = String(text || '').trim()
|
const t = String(text || '').trim()
|
||||||
const heldMs = Date.now() - holdStartTs
|
const heldMs = Date.now() - holdStartTs
|
||||||
|
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
|
||||||
|
if (typeof onSettle === 'function') {
|
||||||
|
const handled = onSettle(t, { heldMs })
|
||||||
|
if (handled === true) return
|
||||||
|
}
|
||||||
if (!t) {
|
if (!t) {
|
||||||
if (heldMs < MIN_HOLD_MS) return
|
if (heldMs < MIN_HOLD_MS) return
|
||||||
notify('没听清,请再试一次')
|
notify('没听清,请再试一次')
|
||||||
@@ -43,7 +132,9 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
|
|
||||||
function stopListening() {
|
function stopListening() {
|
||||||
// #ifdef MP-WEIXIN
|
// #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 {
|
try {
|
||||||
wxRecordManager.stop()
|
wxRecordManager.stop()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -59,6 +150,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
startRequested = false
|
startRequested = false
|
||||||
|
sttHolding.value = false
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +187,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
function startWechatRecord(session) {
|
// #ifdef MP-WEIXIN
|
||||||
// #ifdef MP-WEIXIN
|
function actuallyStartWechat(session) {
|
||||||
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,9 +200,28 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
startRequested = false
|
startRequested = false
|
||||||
|
sharedRecording = false
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
notify('无法开始录音')
|
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
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +234,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
startRequested = true
|
startRequested = true
|
||||||
|
h5GotResult = false
|
||||||
h5Recognition.start()
|
h5Recognition.start()
|
||||||
sttListening.value = true
|
sttListening.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -146,6 +258,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
sttHolding.value = true
|
sttHolding.value = true
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
|
// 把全局录音回调切到当前实例
|
||||||
|
activeCtl = controller
|
||||||
ensureRecordAuth(() => startWechatRecord(session))
|
ensureRecordAuth(() => startWechatRecord(session))
|
||||||
// #endif
|
// #endif
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -159,50 +273,95 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 松手结束:手指抬起 */
|
/** 松手结束:手指抬起;force=true 时强制取消尚未真正开始的按住会话 */
|
||||||
function endHoldSpeech() {
|
function endHoldSpeech(force = false) {
|
||||||
if (!sttHolding.value && !sttListening.value && !startRequested) {
|
let recording = false
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
recording = sharedRecording && activeCtl === controller
|
||||||
|
// #endif
|
||||||
|
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
if (sttListening.value || startRequested) {
|
// 松手后不再补开排队的录音
|
||||||
|
pendingStartSession = 0
|
||||||
|
if (sttListening.value || recording) {
|
||||||
stopListening()
|
stopListening()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (startRequested) {
|
||||||
|
// start 已发出但 onStart 未到:取消会话,勿调 stop()
|
||||||
|
startRequested = false
|
||||||
|
holdSessionId += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
holdSessionId += 1
|
holdSessionId += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
try {
|
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
|
||||||
// eslint-disable-next-line no-undef
|
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
|
||||||
const plugin = requirePlugin('WechatSI')
|
const controller = {
|
||||||
wxRecordManager = plugin.getRecordRecognitionManager()
|
handleStart() {
|
||||||
sttSupported.value = !!wxRecordManager
|
|
||||||
|
|
||||||
wxRecordManager.onStart = () => {
|
|
||||||
if (!sttHolding.value) {
|
if (!sttHolding.value) {
|
||||||
try {
|
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
|
||||||
wxRecordManager.stop()
|
startRequested = false
|
||||||
} catch (e) {}
|
sttListening.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
startRequested = false
|
startRequested = false
|
||||||
sttListening.value = true
|
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
|
sttListening.value = false
|
||||||
startRequested = false
|
startRequested = false
|
||||||
|
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
|
||||||
|
if (pendingStartSession && pendingStartSession === holdSessionId && sttHolding.value) {
|
||||||
|
const s = pendingStartSession
|
||||||
|
pendingStartSession = 0
|
||||||
|
actuallyStartWechat(s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingStartSession = 0
|
||||||
emitResult(res?.result)
|
emitResult(res?.result)
|
||||||
}
|
},
|
||||||
wxRecordManager.onError = (res) => {
|
handleError(res) {
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
startRequested = 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
|
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
|
// #endif
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
@@ -212,27 +371,49 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
if (SR) {
|
if (SR) {
|
||||||
h5Recognition = new SR()
|
h5Recognition = new SR()
|
||||||
h5Recognition.lang = 'zh-CN'
|
h5Recognition.lang = 'zh-CN'
|
||||||
h5Recognition.interimResults = false
|
h5Recognition.interimResults = true
|
||||||
h5Recognition.continuous = false
|
h5Recognition.continuous = false
|
||||||
h5Recognition.maxAlternatives = 1
|
h5Recognition.maxAlternatives = 1
|
||||||
sttSupported.value = true
|
sttSupported.value = true
|
||||||
|
|
||||||
h5Recognition.onresult = (event) => {
|
h5Recognition.onresult = (event) => {
|
||||||
const item = event.results?.[0]?.[0]
|
let interim = ''
|
||||||
emitResult(item?.transcript || '')
|
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 = () => {
|
h5Recognition.onend = () => {
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
startRequested = false
|
startRequested = false
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
|
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
|
||||||
|
if (!h5GotResult) {
|
||||||
|
emitResult('')
|
||||||
|
}
|
||||||
|
h5GotResult = false
|
||||||
}
|
}
|
||||||
h5Recognition.onerror = (event) => {
|
h5Recognition.onerror = (event) => {
|
||||||
sttListening.value = false
|
sttListening.value = false
|
||||||
startRequested = false
|
startRequested = false
|
||||||
sttHolding.value = 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('请允许浏览器使用麦克风')
|
notify('请允许浏览器使用麦克风')
|
||||||
} else if (event?.error !== 'aborted') {
|
} else {
|
||||||
notify('语音识别失败')
|
notify('语音识别失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +427,14 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
sttHolding.value = false
|
sttHolding.value = false
|
||||||
holdSessionId += 1
|
holdSessionId += 1
|
||||||
|
pendingStartSession = 0
|
||||||
stopListening()
|
stopListening()
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 释放全局回调指向,避免事件继续派发给已销毁的实例
|
||||||
|
if (activeCtl === controller) {
|
||||||
|
activeCtl = null
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -258,3 +446,5 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
|||||||
stopSpeechToText: endHoldSpeech
|
stopSpeechToText: endHoldSpeech
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { isBenignSttError, friendlySttError }
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export function warmUpFoodImgs(foods) {
|
|||||||
const list = Array.isArray(foods) ? foods : []
|
const list = Array.isArray(foods) ? foods : []
|
||||||
list.forEach((f) => {
|
list.forEach((f) => {
|
||||||
const icon = typeof f === 'string' ? f : f?.icon
|
const icon = typeof f === 'string' ? f : f?.icon
|
||||||
const url = getFoodImgUrl(icon)
|
// 优先预热自定义图片素材,缺省回退 emoji PNG
|
||||||
|
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
|
||||||
if (!url) return
|
if (!url) return
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -58,7 +58,20 @@ const FOOD_TIP_OVERRIDE = {
|
|||||||
apple: '带皮整果,升糖较慢',
|
apple: '带皮整果,升糖较慢',
|
||||||
milk: '低糖,含蛋白,适量喝好',
|
milk: '低糖,含蛋白,适量喝好',
|
||||||
fish: '优质蛋白,升糖低',
|
fish: '优质蛋白,升糖低',
|
||||||
beef: '蛋白为主,升糖低'
|
beef: '蛋白为主,升糖低',
|
||||||
|
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
|
||||||
|
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
|
||||||
|
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
|
||||||
|
friedNoodles: '油多又是精面,升糖快',
|
||||||
|
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
|
||||||
|
sandwich: '夹心面包精制碳多,升糖快',
|
||||||
|
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
|
||||||
|
milletCongee: '小米粥熬软升糖快,糖友控量',
|
||||||
|
shandongPancake: '煎饼加油面,升糖中等偏快',
|
||||||
|
cake: '蛋糕又甜又油,升糖快',
|
||||||
|
biscuit: '饼干又甜又碎,升糖快',
|
||||||
|
wonton: '馄饨面皮精白,升糖中等,别多吃',
|
||||||
|
blackCoffee: '不加糖的黑咖啡,升糖很低'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 含糖等级通用科普文案 */
|
/** 含糖等级通用科普文案 */
|
||||||
@@ -76,6 +89,9 @@ export function getFoodTip(food) {
|
|||||||
return GI_TIP_DEFAULT[food.gi] || ''
|
return GI_TIP_DEFAULT[food.gi] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自定义图片素材目录(腾讯云 COS,优先使用真实图片而非 emoji) */
|
||||||
|
const GAMES_IMG_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games'
|
||||||
|
|
||||||
const RAW_FOODS = [
|
const RAW_FOODS = [
|
||||||
// 低 GI
|
// 低 GI
|
||||||
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
|
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
|
||||||
@@ -101,7 +117,7 @@ const RAW_FOODS = [
|
|||||||
|
|
||||||
// 中 GI
|
// 中 GI
|
||||||
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
||||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
|
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39', img: `${GAMES_IMG_BASE}/%E7%95%AA%E8%96%AF.png` },
|
||||||
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
|
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
|
||||||
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
|
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
|
||||||
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
|
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
|
||||||
@@ -118,14 +134,24 @@ const RAW_FOODS = [
|
|||||||
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
|
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
|
||||||
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
|
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
|
||||||
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
|
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
|
||||||
|
{ key: 'blackCoffee', icon: '☕', name: '黑咖啡', gi: 'low', val: -1, color: '#4E342E', img: `${GAMES_IMG_BASE}/%E9%BB%91%E5%92%96%E5%95%A1.png` },
|
||||||
|
{ key: 'wonton', icon: '🥟', name: '馄饨', gi: 'mid', val: 5, color: '#E8DCC8', img: `${GAMES_IMG_BASE}/%E9%A6%84%E9%A5%A8.png` },
|
||||||
|
{ key: 'milletCongee', icon: '🥣', name: '小米粥', gi: 'mid', val: 5, color: '#F5E6B8', img: `${GAMES_IMG_BASE}/%E5%B0%8F%E7%B1%B3%E7%B2%A5.png` },
|
||||||
|
{ key: 'shandongPancake', icon: '🥞', name: '山东煎饼', gi: 'high', val: 16, color: '#D4A574', img: `${GAMES_IMG_BASE}/%E5%B1%B1%E4%B8%9C%E7%85%8E%E9%A5%BC.png` },
|
||||||
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
|
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
|
||||||
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
|
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
|
||||||
|
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
|
||||||
|
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
|
||||||
|
|
||||||
// 高 GI
|
// 高 GI
|
||||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
|
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0', img: `${GAMES_IMG_BASE}/%E7%B1%B3%E9%A5%AD.png` },
|
||||||
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
|
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
|
||||||
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
|
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
|
||||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
|
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F', img: `${GAMES_IMG_BASE}/%E6%B2%B9%E6%9D%A1.png` },
|
||||||
|
{ key: 'sandwich', icon: '🥪', name: '三明治', gi: 'high', val: 16, color: '#D9B88F', img: `${GAMES_IMG_BASE}/%E4%B8%89%E6%98%8E%E6%B2%BB.png` },
|
||||||
|
{ key: 'biscuit', icon: '🍪', name: '饼干', gi: 'high', val: 18, color: '#C9A063', img: `${GAMES_IMG_BASE}/%E9%A5%BC%E5%B9%B2.png` },
|
||||||
|
{ key: 'cake', icon: '🎂', name: '蛋糕', gi: 'high', val: 22, color: '#F48FB1', img: `${GAMES_IMG_BASE}/%E8%9B%8B%E7%B3%95.png` },
|
||||||
|
{ key: 'eightTreasureCongee', icon: '🥣', name: '八宝粥', gi: 'high', val: 17, color: '#E8C4A0', img: `${GAMES_IMG_BASE}/%E5%85%AB%E5%AE%9D%E7%B2%A5.png` },
|
||||||
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
|
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
|
||||||
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
|
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
|
||||||
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
|
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
|
||||||
@@ -136,7 +162,10 @@ const RAW_FOODS = [
|
|||||||
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
|
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
|
||||||
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
|
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
|
||||||
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
|
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
|
||||||
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' }
|
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
|
||||||
|
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
|
||||||
|
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
|
||||||
|
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||||
@@ -149,7 +178,8 @@ export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
|||||||
clearVal,
|
clearVal,
|
||||||
giLabel: GI_LABEL[f.gi],
|
giLabel: GI_LABEL[f.gi],
|
||||||
get img() {
|
get img() {
|
||||||
return getFoodImgUrl(f.icon)
|
// 优先使用自定义图片素材,缺省回退到 emoji PNG
|
||||||
|
return f.img || getFoodImgUrl(f.icon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -170,6 +200,6 @@ export function cloneFoodType(food) {
|
|||||||
giLabel: food.giLabel,
|
giLabel: food.giLabel,
|
||||||
val: food.val,
|
val: food.val,
|
||||||
color: food.color,
|
color: food.color,
|
||||||
img: getFoodImgUrl(food.icon)
|
img: food.img || getFoodImgUrl(food.icon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
|
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
|
||||||
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
|
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
|
||||||
|
* 第一关固定为这 16 种带真实图片素材的食物。
|
||||||
|
*/
|
||||||
|
const FIXED_LEVEL_FOODS = {
|
||||||
|
1: [
|
||||||
|
'sandwich',
|
||||||
|
'eightTreasureCongee',
|
||||||
|
'milkOats',
|
||||||
|
'milletCongee',
|
||||||
|
'shandongPancake',
|
||||||
|
'grainMantou',
|
||||||
|
'riceNoodleSoup',
|
||||||
|
'youtiao',
|
||||||
|
'friedNoodles',
|
||||||
|
'sweetPotato',
|
||||||
|
'centuryEggCongee',
|
||||||
|
'whiteRice',
|
||||||
|
'cake',
|
||||||
|
'biscuit',
|
||||||
|
'wonton',
|
||||||
|
'blackCoffee'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export function getLevelConfig(levelId) {
|
export function getLevelConfig(levelId) {
|
||||||
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
|
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
|
||||||
const tier = Math.floor((lv - 1) / 5)
|
const tier = Math.floor((lv - 1) / 5)
|
||||||
@@ -14,6 +39,8 @@ export function getLevelConfig(levelId) {
|
|||||||
goalTargets: [6 + tier, 8 + tier],
|
goalTargets: [6 + tier, 8 + tier],
|
||||||
scoreTarget: 2000 + lv * 300,
|
scoreTarget: 2000 + lv * 300,
|
||||||
startGlucose: 50,
|
startGlucose: 50,
|
||||||
|
// 固定食材(可选):存在时本关只用这些食物
|
||||||
|
foods: FIXED_LEVEL_FOODS[lv] || null,
|
||||||
boosters: {
|
boosters: {
|
||||||
insulin: 3,
|
insulin: 3,
|
||||||
fiber: 1,
|
fiber: 1,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -419,7 +419,7 @@
|
|||||||
|
|
||||||
<view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }">
|
<view v-else class="ai-diet-body" :class="{ 'is-streaming': dietAiStreaming }">
|
||||||
<view class="ai-diet-section-head">
|
<view class="ai-diet-section-head">
|
||||||
<text class="ai-diet-section-title">今日三餐</text>
|
<text class="ai-diet-section-title">今日饮食</text>
|
||||||
<text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中…</text>
|
<text v-if="dietAiStreaming" class="ai-diet-section-tag">生成中…</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="ai-diet-meal-list">
|
<view class="ai-diet-meal-list">
|
||||||
@@ -434,6 +434,20 @@
|
|||||||
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
|
||||||
|
class="ai-diet-meal-card meal-drinks"
|
||||||
|
>
|
||||||
|
<view class="ai-diet-meal-head">
|
||||||
|
<view class="ai-diet-meal-icon" aria-hidden="true">
|
||||||
|
<TongjiIcon name="droplet" size="sm" color="#ffffff" />
|
||||||
|
</view>
|
||||||
|
<text class="ai-diet-meal-label">喝的</text>
|
||||||
|
</view>
|
||||||
|
<text class="ai-diet-meal-text">
|
||||||
|
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor">▍</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
<view class="ai-diet-meal-card meal-lunch">
|
<view class="ai-diet-meal-card meal-lunch">
|
||||||
<view class="ai-diet-meal-head">
|
<view class="ai-diet-meal-head">
|
||||||
<view class="ai-diet-meal-icon" aria-hidden="true">
|
<view class="ai-diet-meal-icon" aria-hidden="true">
|
||||||
@@ -2657,16 +2671,13 @@ async function bootstrap(options) {
|
|||||||
if (!target) target = list[0]
|
if (!target) target = list[0]
|
||||||
await applyCard(target)
|
await applyCard(target)
|
||||||
} else {
|
} else {
|
||||||
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
|
diagnosisId.value = 0
|
||||||
if (passedPatientId) patientId.value = passedPatientId
|
patientId.value = 0
|
||||||
if (passedPatientName) patientName.value = passedPatientName
|
patientName.value = ''
|
||||||
if (passedAge) age.value = passedAge
|
age.value = 0
|
||||||
if (passedGender) gender.value = passedGender
|
gender.value = 0
|
||||||
if (!diagnosisId.value || !patientId.value) {
|
|
||||||
readUserContext()
|
|
||||||
}
|
|
||||||
readUserMobile()
|
readUserMobile()
|
||||||
if (!diagnosisId.value && userMobile.value) {
|
if (userMobile.value) {
|
||||||
await ensureDailyContextByPhone()
|
await ensureDailyContextByPhone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6558,6 +6569,12 @@ async function onFamilyLike() {
|
|||||||
.ai-diet-meal-card.meal-breakfast::before {
|
.ai-diet-meal-card.meal-breakfast::before {
|
||||||
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
|
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
}
|
}
|
||||||
|
.ai-diet-meal-card.meal-drinks {
|
||||||
|
background: linear-gradient(120deg, rgba(14, 165, 233, 0.14) 0%, rgba(255, 255, 255, 0) 45%);
|
||||||
|
}
|
||||||
|
.ai-diet-meal-card.meal-drinks::before {
|
||||||
|
background: linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
|
||||||
|
}
|
||||||
.ai-diet-meal-card.meal-lunch {
|
.ai-diet-meal-card.meal-lunch {
|
||||||
background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%);
|
background: linear-gradient(120deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0) 45%);
|
||||||
}
|
}
|
||||||
@@ -6588,6 +6605,9 @@ async function onFamilyLike() {
|
|||||||
.meal-breakfast .ai-diet-meal-icon {
|
.meal-breakfast .ai-diet-meal-icon {
|
||||||
background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%);
|
background: linear-gradient(150deg, #fde68a 0%, #fbbf24 100%);
|
||||||
}
|
}
|
||||||
|
.meal-drinks .ai-diet-meal-icon {
|
||||||
|
background: linear-gradient(150deg, #7dd3fc 0%, #0ea5e9 100%);
|
||||||
|
}
|
||||||
.meal-lunch .ai-diet-meal-icon {
|
.meal-lunch .ai-diet-meal-icon {
|
||||||
background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%);
|
background: linear-gradient(150deg, #6ee7b7 0%, #10b981 100%);
|
||||||
}
|
}
|
||||||
|
|||||||
+1652
-113
File diff suppressed because it is too large
Load Diff
@@ -48,13 +48,15 @@
|
|||||||
|
|
||||||
|
|
||||||
<view class="st-main">
|
<view class="st-main">
|
||||||
<!-- 1. 录入今日血糖 -->
|
<!-- 快捷建档失败提示(toast 字数有限,完整文案展示在页面) -->
|
||||||
|
<view v-if="dailyContextError && !diagnosisId" class="st-daily-context-error" role="alert">
|
||||||
|
<text class="st-daily-context-error-text">{{ dailyContextError }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 2. 血糖趋势(无就诊卡或录入不足 7 天显示示例曲线) -->
|
<!-- 2. 血糖趋势(无就诊卡或累计录入不足 7 天显示示例曲线) -->
|
||||||
<view class="st-chart-card">
|
<view class="st-chart-card">
|
||||||
<view class="st-chart-head">
|
<view class="st-chart-head">
|
||||||
<view>
|
<view style="padding: 0 20rpx;">
|
||||||
<text class="st-chart-title">血糖趋势</text>
|
<text class="st-chart-title">血糖趋势</text>
|
||||||
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
|
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
|
||||||
|
|
||||||
@@ -94,9 +96,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="chartUseMockData" class="st-chart-foot">
|
<view v-if="chartUseMockData" class="st-chart-foot">
|
||||||
<text class="st-chart-mock-hint">示例数据,连续记录满 7 天后显示您的真实趋势</text>
|
<text class="st-chart-mock-hint">示例数据,累计记录满 7 天后显示您的真实趋势</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage">
|
<view class="st-game-entry st-game-entry--in-card" @click="goGamePage" style="margin: 0 20rpx 0 20rpx;">
|
||||||
<view class="st-game-entry-icon">
|
<view class="st-game-entry-icon">
|
||||||
<TongjiIcon name="activity" size="sm" color="#006c49" />
|
<TongjiIcon name="activity" size="sm" color="#006c49" />
|
||||||
</view>
|
</view>
|
||||||
@@ -107,9 +109,9 @@
|
|||||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="st-training-module">
|
<view class="st-training-section">
|
||||||
<dev-training-entry inline />
|
<dev-training-entry inline tone="weekly" />
|
||||||
</view>
|
</view>
|
||||||
<!-- 3. AI 饮食建议 -->
|
<!-- 3. AI 饮食建议 -->
|
||||||
<view v-if="diagnosisId" class="st-ai-section">
|
<view v-if="diagnosisId" class="st-ai-section">
|
||||||
<view class="st-ai-deco" aria-hidden="true" />
|
<view class="st-ai-deco" aria-hidden="true" />
|
||||||
@@ -129,7 +131,7 @@
|
|||||||
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
|
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="st-ai-sub">读您近 7 天 · 30 天血糖,定制今日三餐</text>
|
<text class="st-ai-sub">读您近 7 天 · 30 天血糖,定制早餐(含驼奶粉)、喝的与三餐</text>
|
||||||
|
|
||||||
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="st-skeleton">
|
<view v-if="dietAiLoading && !hasDietAiMealContent && !dietAiStreaming && !dietAiReplacing" class="st-skeleton">
|
||||||
<view v-for="n in 2" :key="'sk-' + n" class="st-skeleton-line" />
|
<view v-for="n in 2" :key="'sk-' + n" class="st-skeleton-line" />
|
||||||
@@ -145,6 +147,18 @@
|
|||||||
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="dietAiRecommend.drinks || (dietAiLoading && dietAiStreamField === 'drinks')"
|
||||||
|
class="st-meal-glass st-meal-glass--drinks"
|
||||||
|
>
|
||||||
|
<view class="st-meal-head">
|
||||||
|
<TongjiIcon name="droplet" size="sm" color="#0ea5e9" />
|
||||||
|
<text class="st-meal-label">喝的</text>
|
||||||
|
</view>
|
||||||
|
<text class="st-meal-text">
|
||||||
|
{{ dietAiRecommend.drinks || '…' }}<text v-if="dietAiLoading && dietAiStreamField === 'drinks'" class="ai-stream-cursor">▍</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
<view class="st-meal-glass">
|
<view class="st-meal-glass">
|
||||||
<view class="st-meal-head">
|
<view class="st-meal-head">
|
||||||
<TongjiIcon name="leaf" size="sm" color="#006c49" />
|
<TongjiIcon name="leaf" size="sm" color="#006c49" />
|
||||||
@@ -179,19 +193,26 @@
|
|||||||
<view class="st-float-close" @click="floatAskClosed = true">
|
<view class="st-float-close" @click="floatAskClosed = true">
|
||||||
<text class="st-float-close-icon">×</text>
|
<text class="st-float-close-icon">×</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="st-float-inner">
|
<view class="st-float-inner" :class="{ 'is-expanded': dietAiAskExpanded }">
|
||||||
<view class="st-float-icon">
|
<view class="st-float-icon">
|
||||||
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||||||
</view>
|
</view>
|
||||||
<input
|
<textarea
|
||||||
class="st-float-input"
|
class="st-float-input"
|
||||||
type="text"
|
|
||||||
:value="dietAiAskText"
|
:value="dietAiAskText"
|
||||||
placeholder="长按麦克风说话,或输入文字咨询 AI..."
|
placeholder="输入或语音咨询 AI…"
|
||||||
placeholder-class="st-float-placeholder"
|
placeholder-class="st-float-placeholder"
|
||||||
|
:maxlength="-1"
|
||||||
|
auto-height
|
||||||
|
:show-confirm-bar="false"
|
||||||
|
:disable-default-padding="true"
|
||||||
|
:cursor-spacing="24"
|
||||||
confirm-type="send"
|
confirm-type="send"
|
||||||
|
:confirm-hold="false"
|
||||||
@input="onDietAiAskInput"
|
@input="onDietAiAskInput"
|
||||||
@confirm="askDietAiFood"
|
@confirm="askDietAiFood"
|
||||||
|
@focus="onDietAiAskFocus"
|
||||||
|
@blur="onDietAiAskBlur"
|
||||||
/>
|
/>
|
||||||
<view
|
<view
|
||||||
class="st-float-mic"
|
class="st-float-mic"
|
||||||
@@ -248,6 +269,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="phone-gate-body">
|
<view class="phone-gate-body">
|
||||||
|
<view v-if="dailyContextError" class="phone-gate-error" role="alert">
|
||||||
|
<text>{{ dailyContextError }}</text>
|
||||||
|
</view>
|
||||||
<text class="phone-gate-tip">微信仅提供手机号,请选择性别以便写入就诊档案。</text>
|
<text class="phone-gate-tip">微信仅提供手机号,请选择性别以便写入就诊档案。</text>
|
||||||
<view class="phone-gate-gender">
|
<view class="phone-gate-gender">
|
||||||
<text class="phone-gate-gender-label">性别</text>
|
<text class="phone-gate-gender-label">性别</text>
|
||||||
@@ -432,11 +456,42 @@ function showUserToast(title, options = {}) {
|
|||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: text,
|
title: text,
|
||||||
icon: options.icon || 'none',
|
icon: options.icon || 'none',
|
||||||
duration: options.duration
|
duration: options.duration || 2800
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析接口错误文案(兼容 msg / message / data.error) */
|
||||||
|
function extractApiError(res, fallback = '') {
|
||||||
|
if (!res) return fallback
|
||||||
|
const raw = res.msg ?? res.message ?? res.data?.error ?? res.data?.msg ?? res.errMsg
|
||||||
|
return formatUserMessage(raw, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
const diagnosisId = ref(0)
|
const diagnosisId = ref(0)
|
||||||
|
/** 手机号快捷建档失败:页面常驻提示(避免 toast 被 7 字截断) */
|
||||||
|
const dailyContextError = ref('')
|
||||||
|
|
||||||
|
function clearDailyContextError() {
|
||||||
|
dailyContextError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportDailyContextError(res, fallback = '无法开始录入', options = {}) {
|
||||||
|
const text = extractApiError(res, fallback)
|
||||||
|
if (!text) return
|
||||||
|
dailyContextError.value = text
|
||||||
|
if (options.alert) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '无法录入血糖',
|
||||||
|
content: text,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const short = text.length > 14 ? `${text.slice(0, 14)}…` : text
|
||||||
|
showUserToast(short, { duration: 3200 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const patientId = ref(0)
|
const patientId = ref(0)
|
||||||
const patientName = ref('')
|
const patientName = ref('')
|
||||||
const displayPatientName = computed(() => {
|
const displayPatientName = computed(() => {
|
||||||
@@ -471,6 +526,19 @@ const {
|
|||||||
/** 底部 AI 浮动卡片是否被用户收起 */
|
/** 底部 AI 浮动卡片是否被用户收起 */
|
||||||
const floatAskClosed = ref(false)
|
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):录入弹窗据此整体上移,避免输入框被键盘遮挡 */
|
/** 键盘高度(px):录入弹窗据此整体上移,避免输入框被键盘遮挡 */
|
||||||
const keyboardHeight = ref(0)
|
const keyboardHeight = ref(0)
|
||||||
function onKeyboardHeightChange(res) {
|
function onKeyboardHeightChange(res) {
|
||||||
@@ -632,6 +700,19 @@ const currentRange = computed(() => {
|
|||||||
return { start: formatDate(start), end: formatDate(end) }
|
return { start: formatDate(start), end: formatDate(end) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 拉取血糖时向前多看几天,用于判断「累计满 7 天」是否达标(图表仍只画 currentRange) */
|
||||||
|
const BLOOD_LOOKBACK_DAYS = 30
|
||||||
|
|
||||||
|
const fetchRange = computed(() => {
|
||||||
|
const displayDays = rangeMode.value === '30' ? 30 : 7
|
||||||
|
const lookback = Math.max(BLOOD_LOOKBACK_DAYS, displayDays)
|
||||||
|
const end = new Date()
|
||||||
|
end.setHours(0, 0, 0, 0)
|
||||||
|
const start = new Date(end)
|
||||||
|
start.setDate(start.getDate() - (lookback - 1))
|
||||||
|
return { start: formatDate(start), end: formatDate(end) }
|
||||||
|
})
|
||||||
|
|
||||||
const visibleDates = computed(() => {
|
const visibleDates = computed(() => {
|
||||||
const { start, end } = currentRange.value
|
const { start, end } = currentRange.value
|
||||||
if (!start || !end) return []
|
if (!start || !end) return []
|
||||||
@@ -794,27 +875,37 @@ const recordDays = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function bloodDayHasSugar(b) {
|
||||||
|
if (!b) return false
|
||||||
|
return toNumber(b.fasting_blood_sugar) !== null
|
||||||
|
|| toNumber(b.postprandial_blood_sugar) !== null
|
||||||
|
|| toNumber(b.other_blood_sugar) !== null
|
||||||
|
}
|
||||||
|
|
||||||
const bloodHasData = computed(() => {
|
const bloodHasData = computed(() => {
|
||||||
return chartDates.value.some((date) => {
|
return chartDates.value.some((date) => bloodDayHasSugar(bloodByDate.value[date]))
|
||||||
const b = bloodByDate.value[date]
|
|
||||||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 当前时间范围内有真实录入的天数 */
|
/** 当前图表时间范围内有真实录入的天数(用于小结统计) */
|
||||||
const bloodRecordDayCount = computed(() => {
|
const bloodRecordDayCount = computed(() => {
|
||||||
return chartDates.value.filter((date) => {
|
return chartDates.value.filter((date) => bloodDayHasSugar(bloodByDate.value[date])).length
|
||||||
const b = bloodByDate.value[date]
|
})
|
||||||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
|
||||||
}).length
|
/** 拉取窗口内累计有血糖录入的天数(不要求连续) */
|
||||||
|
const bloodCumulativeDayCount = computed(() => {
|
||||||
|
let n = 0
|
||||||
|
for (const date of Object.keys(bloodByDate.value)) {
|
||||||
|
if (bloodDayHasSugar(bloodByDate.value[date])) n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
})
|
})
|
||||||
|
|
||||||
const REAL_BLOOD_DAYS_MIN = 7
|
const REAL_BLOOD_DAYS_MIN = 7
|
||||||
|
|
||||||
/** 无就诊卡或真实录入不足 7 天时,图表用示例数据 */
|
/** 无就诊卡或累计录入不足 7 天时,图表用示例数据 */
|
||||||
const chartUseMockData = computed(() => {
|
const chartUseMockData = computed(() => {
|
||||||
if (!diagnosisId.value) return true
|
if (!diagnosisId.value) return true
|
||||||
return bloodRecordDayCount.value < REAL_BLOOD_DAYS_MIN
|
return bloodCumulativeDayCount.value < REAL_BLOOD_DAYS_MIN
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 示例曲线:7 天内有波动的空腹/餐后,便于老人理解趋势图形态 */
|
/** 示例曲线:7 天内有波动的空腹/餐后,便于老人理解趋势图形态 */
|
||||||
@@ -1154,8 +1245,14 @@ function syncPhoneGateSexFromUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPhoneGate() {
|
async function openPhoneGate() {
|
||||||
syncPhoneGateSexFromUser()
|
syncPhoneGateSexFromUser()
|
||||||
|
readUserMobile()
|
||||||
|
if (userMobile.value) {
|
||||||
|
phoneGateVisible.value = false
|
||||||
|
await resolveDailyPhoneContext(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
phoneGateVisible.value = true
|
phoneGateVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,8 +1269,11 @@ async function refreshUserMobile() {
|
|||||||
return userMobile.value
|
return userMobile.value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDailyContextByPhone(sex) {
|
async function ensureDailyContextByPhone(sex, options = {}) {
|
||||||
if (diagnosisId.value) return true
|
if (diagnosisId.value) {
|
||||||
|
clearDailyContextError()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {}
|
const payload = {}
|
||||||
const sexVal = sex ?? phoneGateSex.value
|
const sexVal = sex ?? phoneGateSex.value
|
||||||
@@ -1188,6 +1288,7 @@ async function ensureDailyContextByPhone(sex) {
|
|||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
if (res && res.code === 1 && res.data && res.data.diagnosis_id) {
|
if (res && res.code === 1 && res.data && res.data.diagnosis_id) {
|
||||||
|
clearDailyContextError()
|
||||||
await applyCard({
|
await applyCard({
|
||||||
id: res.data.diagnosis_id,
|
id: res.data.diagnosis_id,
|
||||||
patient_id: res.data.patient_id,
|
patient_id: res.data.patient_id,
|
||||||
@@ -1195,17 +1296,32 @@ async function ensureDailyContextByPhone(sex) {
|
|||||||
age: res.data.age || 0,
|
age: res.data.age || 0,
|
||||||
gender: res.data.gender || 0
|
gender: res.data.gender || 0
|
||||||
})
|
})
|
||||||
if (res.data.created) {
|
if (res.data.created || res.data.linked_existing) {
|
||||||
await fetchCardList()
|
await fetchCardList()
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res && res.data && res.data.need_mobile) {
|
if (res && res.data && res.data.need_mobile) {
|
||||||
|
clearDailyContextError()
|
||||||
return 'need_mobile'
|
return 'need_mobile'
|
||||||
}
|
}
|
||||||
|
|
||||||
showUserToast(formatUserMessage(res?.msg, '无法开始录入'))
|
reportDailyContextError(res, '无法开始录入', options)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已绑定手机号时直接关联档案,不再弹出授权建档 */
|
||||||
|
async function resolveDailyPhoneContext(openFormAfter = false) {
|
||||||
|
const ok = await ensureDailyContextByPhone(phoneGateSex.value, { alert: true })
|
||||||
|
if (ok === true) {
|
||||||
|
if (openFormAfter) await openInputForm(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (ok === 'need_mobile') {
|
||||||
|
syncPhoneGateSexFromUser()
|
||||||
|
phoneGateVisible.value = true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1218,7 +1334,7 @@ async function ensureCanRecordGlucose() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await ensureDailyContextByPhone()
|
const ok = await ensureDailyContextByPhone(undefined, { alert: true })
|
||||||
if (ok === 'need_mobile') {
|
if (ok === 'need_mobile') {
|
||||||
openPhoneGate()
|
openPhoneGate()
|
||||||
return false
|
return false
|
||||||
@@ -1251,6 +1367,13 @@ async function onPhoneGateAuthorize(e) {
|
|||||||
|
|
||||||
phoneBinding.value = true
|
phoneBinding.value = true
|
||||||
try {
|
try {
|
||||||
|
readUserMobile()
|
||||||
|
if (userMobile.value) {
|
||||||
|
phoneGateVisible.value = false
|
||||||
|
await resolveDailyPhoneContext(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const res = await proxy.apiUrl({
|
const res = await proxy.apiUrl({
|
||||||
url: '/api/user/getMobileByMnp',
|
url: '/api/user/getMobileByMnp',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1262,12 +1385,7 @@ async function onPhoneGateAuthorize(e) {
|
|||||||
}
|
}
|
||||||
await refreshUserMobile()
|
await refreshUserMobile()
|
||||||
phoneGateVisible.value = false
|
phoneGateVisible.value = false
|
||||||
const ok = await ensureDailyContextByPhone(phoneGateSex.value)
|
await resolveDailyPhoneContext(true)
|
||||||
if (ok === true) {
|
|
||||||
await openInputForm(true)
|
|
||||||
} else if (ok === 'need_mobile') {
|
|
||||||
openPhoneGate()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showUserToast('网络异常,请稍后再试')
|
showUserToast('网络异常,请稍后再试')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1290,7 +1408,7 @@ async function onSelectCard(card) {
|
|||||||
async function fetchAll() {
|
async function fetchAll() {
|
||||||
if (!hasAuthToken() || !diagnosisId.value) return
|
if (!hasAuthToken() || !diagnosisId.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const { start, end } = currentRange.value
|
const { start, end } = fetchRange.value
|
||||||
try {
|
try {
|
||||||
const [winRes, noteRes] = await Promise.all([
|
const [winRes, noteRes] = await Promise.all([
|
||||||
proxy.apiUrl({
|
proxy.apiUrl({
|
||||||
@@ -2490,15 +2608,15 @@ async function bootstrap(options) {
|
|||||||
if (!target) target = list[0]
|
if (!target) target = list[0]
|
||||||
await applyCard(target)
|
await applyCard(target)
|
||||||
} else {
|
} else {
|
||||||
diagnosisId.value = passedDiagnosisId || 0
|
// 无可见就诊卡:勿用 URL/缓存里的隐藏诊单 ID
|
||||||
patientId.value = passedPatientId || 0
|
diagnosisId.value = 0
|
||||||
if (passedPatientName) patientName.value = passedPatientName
|
patientId.value = 0
|
||||||
if (passedAge) age.value = passedAge
|
patientName.value = ''
|
||||||
if (passedGender) gender.value = passedGender
|
age.value = 0
|
||||||
if (!patientName.value) readUserContext()
|
gender.value = 0
|
||||||
readUserMobile()
|
readUserMobile()
|
||||||
if (!diagnosisId.value && userMobile.value) {
|
if (userMobile.value) {
|
||||||
await ensureDailyContextByPhone()
|
await ensureDailyContextByPhone(undefined, { alert: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -639,7 +639,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.more-page .mc-tasks-section {
|
.more-page .mc-tasks-section {
|
||||||
margin-top: 40rpx;
|
margin-top: 0;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-page .mc-tasks-grid {
|
.more-page .mc-tasks-grid {
|
||||||
@@ -1167,3 +1168,259 @@
|
|||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: rgba(108, 122, 113, 0.6);
|
color: rgba(108, 122, 113, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.more-page .mc-tree-species-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
margin-left: 8rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(0, 108, 73, 0.08);
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel,
|
||||||
|
.more-page .mc-species-panel {
|
||||||
|
width: calc(100% - 48rpx);
|
||||||
|
max-width: 680rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
max-height: 78vh;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
|
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-summary {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 18rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row.is-water {
|
||||||
|
border-color: rgba(0, 108, 73, 0.22);
|
||||||
|
background: rgba(0, 108, 73, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row.is-claimed {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-growth {
|
||||||
|
padding: 16rpx 18rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(0, 108, 73, 0.06);
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--on-surface);
|
||||||
|
color: var(--surface);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-btn.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-water-panel-btn.ghost {
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-panel-tip {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--outline);
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-scroll {
|
||||||
|
max-height: 56vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card {
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
border: 2rpx solid rgba(0, 108, 73, 0.1);
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(0, 108, 73, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card-emoji {
|
||||||
|
font-size: 56rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-card-tagline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stages {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage {
|
||||||
|
width: calc(20% - 8rpx);
|
||||||
|
min-width: 96rpx;
|
||||||
|
padding: 10rpx 6rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage.reached {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage.current {
|
||||||
|
box-shadow: 0 0 0 2rpx var(--primary);
|
||||||
|
background: rgba(0, 108, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage-emoji {
|
||||||
|
display: block;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage-lv {
|
||||||
|
display: block;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-page .mc-species-stage-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: var(--outline);
|
||||||
|
margin-top: 2rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,22 @@
|
|||||||
padding: 32rpx;
|
padding: 32rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.weekly-page .phone-gate-error {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
padding: 20rpx 22rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1rpx solid rgba(220, 38, 38, 0.2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #991b1b;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.weekly-page .phone-gate-tip {
|
.weekly-page .phone-gate-tip {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
|
|||||||
@@ -108,10 +108,25 @@
|
|||||||
color: var(--on-primary);
|
color: var(--on-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 居家训练(DEV 内嵌,与 user 页同源入口) ===== */
|
/* ===== 居家训练(DEV 内嵌) ===== */
|
||||||
.weekly-page .st-training-module {
|
.weekly-page .st-training-section {
|
||||||
padding: 10rpx var(--st-container-margin);
|
width: 100%;
|
||||||
margin: -10rpx -30rpx 10rpx -30rpx;
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-training-section .dev-entry-inline--weekly {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-training-section .inline-scroll {
|
||||||
|
margin-left: -4rpx;
|
||||||
|
margin-right: -4rpx;
|
||||||
|
width: calc(100% + 8rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-training-section .inline-card:first-child {
|
||||||
|
border-color: rgba(0, 108, 73, 0.22);
|
||||||
|
background: linear-gradient(160deg, rgba(0, 108, 73, 0.08) 0%, #f4f7f5 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Main ===== */
|
/* ===== Main ===== */
|
||||||
@@ -123,6 +138,21 @@
|
|||||||
margin-top: 16rpx;
|
margin-top: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-daily-context-error {
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1rpx solid rgba(220, 38, 38, 0.22);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-daily-context-error-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #991b1b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.weekly-page .st-main-spacer {
|
.weekly-page .st-main-spacer {
|
||||||
height: 32rpx;
|
height: 32rpx;
|
||||||
}
|
}
|
||||||
@@ -149,7 +179,7 @@
|
|||||||
|
|
||||||
/* 糖分突袭游戏入口 */
|
/* 糖分突袭游戏入口 */
|
||||||
.weekly-page .st-game-entry {
|
.weekly-page .st-game-entry {
|
||||||
width: 100%;
|
|
||||||
margin-top: 24rpx;
|
margin-top: 24rpx;
|
||||||
padding: 28rpx 32rpx;
|
padding: 28rpx 32rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -626,6 +656,11 @@
|
|||||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
|
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.weekly-page .st-meal-glass--drinks {
|
||||||
|
background: rgba(224, 242, 254, 0.65);
|
||||||
|
border-color: rgba(14, 165, 233, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
.weekly-page .st-meal-head {
|
.weekly-page .st-meal-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -705,13 +740,22 @@
|
|||||||
.weekly-page .st-float-inner {
|
.weekly-page .st-float-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24rpx;
|
gap: 16rpx;
|
||||||
padding: 16rpx 16rpx 16rpx 40rpx;
|
padding: 16rpx 16rpx 16rpx 28rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
backdrop-filter: blur(20rpx);
|
backdrop-filter: blur(20rpx);
|
||||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||||
box-shadow: var(--st-shadow-float);
|
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 {
|
.weekly-page .st-float-icon {
|
||||||
@@ -728,15 +772,30 @@
|
|||||||
.weekly-page .st-float-input {
|
.weekly-page .st-float-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 64rpx;
|
width: 100%;
|
||||||
|
min-height: 64rpx;
|
||||||
|
max-height: 240rpx;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
color: var(--on-surface);
|
color: var(--on-surface);
|
||||||
background: transparent;
|
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 {
|
.weekly-page .st-float-placeholder {
|
||||||
color: rgba(60, 74, 66, 0.6);
|
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 {
|
.weekly-page .st-float-mic {
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ export function parsePartialDietPlainText(text) {
|
|||||||
const out = {}
|
const out = {}
|
||||||
if (!text) return out
|
if (!text) return out
|
||||||
|
|
||||||
const lineRe = /^(早餐|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm
|
const lineRe = /^(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm
|
||||||
let match
|
let match
|
||||||
while ((match = lineRe.exec(text)) !== null) {
|
while ((match = lineRe.exec(text)) !== null) {
|
||||||
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
|
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]]
|
||||||
const val = (match[2] || '').trim()
|
const val = (match[2] || '').trim()
|
||||||
if (key === 'avoid') {
|
if (key === 'avoid') {
|
||||||
out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||||
@@ -76,9 +76,9 @@ export function parsePartialDietPlainText(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tail = text.match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
const tail = text.match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||||
if (tail) {
|
if (tail) {
|
||||||
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
|
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
|
||||||
const val = (tail[2] || '').trim()
|
const val = (tail[2] || '').trim()
|
||||||
if (key === 'avoid') {
|
if (key === 'avoid') {
|
||||||
if (val) out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
if (val) out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { TREE_LEVELS, TREE_MAX_LEVEL, TREE_XP_PER_LEVEL } from './treeLevels.js'
|
||||||
|
|
||||||
|
/** 可选树种:每级 emoji 与 treeLevels.js Lv.0~9 一一对应 */
|
||||||
|
export const TREE_SPECIES_LIST = [
|
||||||
|
{
|
||||||
|
id: 'classic',
|
||||||
|
name: '稳糖灵树',
|
||||||
|
tagline: '经典路线,从种子到圆满',
|
||||||
|
stages: TREE_LEVELS.map((l) => l.emoji)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bonsai',
|
||||||
|
name: '雅韵盆景',
|
||||||
|
tagline: '小巧精致,书桌旁的温柔陪伴',
|
||||||
|
stages: ['🫘', '🌱', '🪴', '🎋', '🪴', '🌳', '🎍', '🌸', '🏵️', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pine',
|
||||||
|
name: '苍劲青松',
|
||||||
|
tagline: '挺拔向上,越记越稳',
|
||||||
|
stages: ['🫘', '🌱', '🌿', '🌲', '🌲', '🌲', '⛰️', '🌲', '🏔️', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sakura',
|
||||||
|
name: '樱花小树',
|
||||||
|
tagline: '坚持打卡,枝头渐开',
|
||||||
|
stages: ['🫘', '🌱', '🌿', '🌳', '🌸', '🌸', '🌸', '🌸', '🌺', '🏆']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ginkgo',
|
||||||
|
name: '金秋银杏',
|
||||||
|
tagline: '叶色渐变,见证每一天',
|
||||||
|
stages: ['🫘', '🌱', '🍃', '🌳', '🍂', '🍂', '🌳', '🍁', '🌟', '🏆']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TREE_SPECIES_STORAGE_KEY = 'tongji_tree_species_v1'
|
||||||
|
|
||||||
|
/** 四项任务全勤时每日最多可领积分(与后端 taskDefs 一致) */
|
||||||
|
export const DAILY_MAX_TASK_POINTS = 40
|
||||||
|
|
||||||
|
export function getTreeSpecies(id) {
|
||||||
|
return TREE_SPECIES_LIST.find((s) => s.id === id) || TREE_SPECIES_LIST[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speciesEmojiAtLevel(speciesId, level) {
|
||||||
|
const sp = getTreeSpecies(speciesId)
|
||||||
|
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
|
||||||
|
return sp.stages[lv] || sp.stages[0] || '🌱'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前树种成长路线图(供选择面板展示) */
|
||||||
|
export function speciesGrowthRoadmap(speciesId) {
|
||||||
|
const sp = getTreeSpecies(speciesId)
|
||||||
|
return TREE_LEVELS.map((meta, i) => {
|
||||||
|
const xpTotal = i * TREE_XP_PER_LEVEL
|
||||||
|
const daysHint = i === 0
|
||||||
|
? 0
|
||||||
|
: Math.max(1, Math.ceil(xpTotal / DAILY_MAX_TASK_POINTS))
|
||||||
|
return {
|
||||||
|
level: i,
|
||||||
|
name: meta.name,
|
||||||
|
emoji: sp.stages[i] || meta.emoji,
|
||||||
|
desc: meta.desc,
|
||||||
|
xpTotal,
|
||||||
|
daysHint
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全勤打卡大约多少天满级 */
|
||||||
|
export function estimateDaysToMaxLevel() {
|
||||||
|
const totalXp = TREE_MAX_LEVEL * TREE_XP_PER_LEVEL
|
||||||
|
return Math.max(1, Math.ceil(totalXp / DAILY_MAX_TASK_POINTS))
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* 健康数据语音文本解析。
|
||||||
|
*
|
||||||
|
* 把语音识别得到的中文口语文本解析为结构化字段:
|
||||||
|
* - 血糖(空腹 / 餐后 / 其他)
|
||||||
|
* - 血压(高压 / 低压 / 西药 / 胰岛素)
|
||||||
|
* - 饮食(早 / 午 / 晚餐 + 备注)
|
||||||
|
* - 运动(类型 / 时长 / 强度)
|
||||||
|
*
|
||||||
|
* 兼容中文数字(六点五 / 一百二十 / 一百二)与阿拉伯数字(6.5 / 120)。
|
||||||
|
* 纯前端解析,无网络依赖,识别失败时返回空对象。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CN_DIGIT = {
|
||||||
|
零: 0, 〇: 0, 一: 1, 壹: 1, 幺: 1, 二: 2, 贰: 2, 两: 2,
|
||||||
|
三: 3, 叁: 3, 四: 4, 肆: 4, 五: 5, 伍: 5, 六: 6, 陆: 6,
|
||||||
|
七: 7, 柒: 7, 八: 8, 捌: 8, 九: 9, 玖: 9
|
||||||
|
}
|
||||||
|
const CN_UNIT = { 十: 10, 拾: 10, 百: 100, 佰: 100, 千: 1000, 仟: 1000, 万: 10000, 亿: 100000000 }
|
||||||
|
|
||||||
|
/** 中文整数串 → 数字,例如 "一百二十" → 120、"八十" → 80、"十" → 10、"一百二" → 120 */
|
||||||
|
function cnIntToNumber(s) {
|
||||||
|
let total = 0
|
||||||
|
let section = 0
|
||||||
|
let number = 0
|
||||||
|
let hadUnit = false
|
||||||
|
let lastUnit = 0
|
||||||
|
let sawZeroAfterUnit = false
|
||||||
|
for (const ch of s) {
|
||||||
|
if (CN_DIGIT[ch] !== undefined) {
|
||||||
|
number = CN_DIGIT[ch]
|
||||||
|
if (number === 0) sawZeroAfterUnit = true
|
||||||
|
} else if (CN_UNIT[ch] !== undefined) {
|
||||||
|
hadUnit = true
|
||||||
|
const unit = CN_UNIT[ch]
|
||||||
|
lastUnit = unit
|
||||||
|
sawZeroAfterUnit = false
|
||||||
|
if (unit >= 10000) {
|
||||||
|
section = (section + number) * unit
|
||||||
|
total += section
|
||||||
|
section = 0
|
||||||
|
} else {
|
||||||
|
if (number === 0) number = 1 // 十 = 10
|
||||||
|
section += number * unit
|
||||||
|
}
|
||||||
|
number = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有任何单位且为纯数字串(如 "一二零")时按位拼接更符合口语
|
||||||
|
if (!hadUnit && s.length > 1) {
|
||||||
|
let joined = ''
|
||||||
|
for (const ch of s) {
|
||||||
|
if (CN_DIGIT[ch] !== undefined) joined += CN_DIGIT[ch]
|
||||||
|
}
|
||||||
|
if (joined) return Number(joined)
|
||||||
|
}
|
||||||
|
// 口语省略尾部单位:"一百二" → 120、"一千五" → 1500(避开 "一百零五" 这类带零的)
|
||||||
|
if (number > 0 && lastUnit >= 100 && !sawZeroAfterUnit) {
|
||||||
|
number = number * (lastUnit / 10)
|
||||||
|
}
|
||||||
|
return total + section + number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中文数字串(含小数点)→ 数字,例如 "六点五" → 6.5 */
|
||||||
|
function cnSeqToNumber(seq) {
|
||||||
|
if (seq.includes('点')) {
|
||||||
|
const parts = seq.split('点')
|
||||||
|
const intPart = parts[0]
|
||||||
|
const decPart = parts.slice(1).join('')
|
||||||
|
const intVal = intPart ? cnIntToNumber(intPart) : 0
|
||||||
|
let decStr = ''
|
||||||
|
for (const ch of decPart) {
|
||||||
|
if (CN_DIGIT[ch] !== undefined) decStr += CN_DIGIT[ch]
|
||||||
|
}
|
||||||
|
if (!decStr) return intVal
|
||||||
|
return Number(`${intVal}.${decStr}`)
|
||||||
|
}
|
||||||
|
return cnIntToNumber(seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否包含中文数字字符(用于过滤“点”“重点”等误匹配) */
|
||||||
|
function hasCnDigit(seq) {
|
||||||
|
for (const ch of seq) {
|
||||||
|
if (CN_DIGIT[ch] !== undefined || CN_UNIT[ch] !== undefined) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把文本中的中文数字段落替换为阿拉伯数字 */
|
||||||
|
export function normalizeNumbers(text) {
|
||||||
|
const raw = String(text || '')
|
||||||
|
return raw.replace(/[零〇一壹幺二贰两三叁四肆五伍六陆七柒八捌九玖十拾百佰千仟万亿点]+/g, (m) => {
|
||||||
|
if (!hasCnDigit(m)) return m
|
||||||
|
const n = cnSeqToNumber(m)
|
||||||
|
return Number.isFinite(n) ? String(n) : m
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在文本中找到关键词后紧随的第一个数字 */
|
||||||
|
function numAfter(text, keys) {
|
||||||
|
for (const k of keys) {
|
||||||
|
const i = text.indexOf(k)
|
||||||
|
if (i >= 0) {
|
||||||
|
const rest = text.slice(i + k.length)
|
||||||
|
const m = rest.match(/-?\d+(?:\.\d+)?/)
|
||||||
|
if (m) return m[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关键词后紧随的一段文本(截止到标点或下一个分隔关键词) */
|
||||||
|
function textAfter(text, keys, stopKeys = []) {
|
||||||
|
for (const k of keys) {
|
||||||
|
const i = text.indexOf(k)
|
||||||
|
if (i >= 0) {
|
||||||
|
let rest = text.slice(i + k.length)
|
||||||
|
// 去掉口语连接词
|
||||||
|
rest = rest.replace(/^[是为吃了喝了吃的喝的有打了用了::,,、。\s]+/, '')
|
||||||
|
let end = rest.length
|
||||||
|
const mStop = rest.match(/[,,。.;;!!??\n]/)
|
||||||
|
if (mStop && mStop.index < end) end = mStop.index
|
||||||
|
for (const sk of stopKeys) {
|
||||||
|
const si = rest.indexOf(sk)
|
||||||
|
if (si >= 0 && si < end) end = si
|
||||||
|
}
|
||||||
|
const seg = rest.slice(0, end).trim()
|
||||||
|
if (seg) return seg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析血糖:返回 { fasting_blood_sugar, postprandial_blood_sugar, other_blood_sugar }
|
||||||
|
* 例:"空腹六点五餐后八点二" → { fasting:6.5, postprandial:8.2 }
|
||||||
|
*/
|
||||||
|
export function parseGlucose(raw) {
|
||||||
|
const t = normalizeNumbers(raw)
|
||||||
|
const result = {}
|
||||||
|
const fasting = numAfter(t, ['空腹'])
|
||||||
|
const post = numAfter(t, ['餐后', '饭后'])
|
||||||
|
const other = numAfter(t, ['其他', '随机', '睡前', '凌晨', '夜间', '晚上'])
|
||||||
|
if (fasting) result.fasting_blood_sugar = fasting
|
||||||
|
if (post) result.postprandial_blood_sugar = post
|
||||||
|
if (other) result.other_blood_sugar = other
|
||||||
|
// 没有关键词但只有一个数字 → 视为“其他血糖”
|
||||||
|
if (!fasting && !post && !other) {
|
||||||
|
const nums = t.match(/\d+(?:\.\d+)?/g)
|
||||||
|
if (nums && nums.length === 1) result.other_blood_sugar = nums[0]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析血压:返回 { systolic_pressure, diastolic_pressure, western_medicine, insulin }
|
||||||
|
* 例:"高压一百二低压八十" → { systolic:120, diastolic:80 }
|
||||||
|
*/
|
||||||
|
export function parseBloodPressure(raw) {
|
||||||
|
const t = normalizeNumbers(raw)
|
||||||
|
const result = {}
|
||||||
|
let sys = numAfter(t, ['高压', '收缩压', '收缩'])
|
||||||
|
let dia = numAfter(t, ['低压', '舒张压', '舒张'])
|
||||||
|
if (!sys || !dia) {
|
||||||
|
const nums = (t.match(/\d{2,3}/g) || [])
|
||||||
|
.map(Number)
|
||||||
|
.filter((n) => n >= 30 && n <= 300)
|
||||||
|
if (!sys && !dia && nums.length >= 2) {
|
||||||
|
sys = String(nums[0])
|
||||||
|
dia = String(nums[1])
|
||||||
|
} else if (!sys && nums.length === 1 && nums[0] >= 90) {
|
||||||
|
sys = String(nums[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 高压应不低于低压,顺序异常时交换
|
||||||
|
if (sys && dia && Number(sys) < Number(dia)) {
|
||||||
|
const tmp = sys
|
||||||
|
sys = dia
|
||||||
|
dia = tmp
|
||||||
|
}
|
||||||
|
if (sys) result.systolic_pressure = sys
|
||||||
|
if (dia) result.diastolic_pressure = dia
|
||||||
|
|
||||||
|
const insulin = textAfter(raw, ['胰岛素'], ['高压', '低压', '血压', '西药', '备注'])
|
||||||
|
if (insulin) result.insulin = insulin
|
||||||
|
const western = textAfter(raw, ['西药', '降糖药', '口服药'], ['胰岛素', '高压', '低压', '血压', '备注'])
|
||||||
|
if (western) result.western_medicine = western
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIET_MARKERS = [
|
||||||
|
{ keys: ['早餐', '早饭', '早上', '早点', '早晨'], field: 'breakfast_foods' },
|
||||||
|
{ keys: ['午餐', '午饭', '中午'], field: 'lunch_foods' },
|
||||||
|
{ keys: ['晚餐', '晚饭', '晚上', '夜宵', '夜里'], field: 'dinner_foods' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function cleanFoodSeg(seg) {
|
||||||
|
return String(seg || '')
|
||||||
|
.replace(/^[是为吃了吃的喝了喝的有::,,、。\s]+/, '')
|
||||||
|
.replace(/[。.\s]+$/, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析饮食:返回 { breakfast_foods, lunch_foods, dinner_foods, note }
|
||||||
|
* 例:"早餐鸡蛋粥中午米饭炒青菜晚上面条" → 分别归位
|
||||||
|
* 无餐别关键词时整句写入 note
|
||||||
|
*/
|
||||||
|
export function parseDiet(raw) {
|
||||||
|
const t = String(raw || '').trim()
|
||||||
|
const result = {}
|
||||||
|
const hits = []
|
||||||
|
DIET_MARKERS.forEach((m) => {
|
||||||
|
for (const k of m.keys) {
|
||||||
|
const idx = t.indexOf(k)
|
||||||
|
if (idx >= 0) {
|
||||||
|
hits.push({ idx, field: m.field, klen: k.length })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!hits.length) {
|
||||||
|
if (t) result.note = t
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
hits.sort((a, b) => a.idx - b.idx)
|
||||||
|
hits.forEach((h, i) => {
|
||||||
|
const start = h.idx + h.klen
|
||||||
|
const end = i + 1 < hits.length ? hits[i + 1].idx : t.length
|
||||||
|
const seg = cleanFoodSeg(t.slice(start, end))
|
||||||
|
if (seg && !result[h.field]) result[h.field] = seg
|
||||||
|
})
|
||||||
|
// 第一个餐别关键词之前的内容作为备注
|
||||||
|
const head = cleanFoodSeg(t.slice(0, hits[0].idx))
|
||||||
|
if (head) result.note = head
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXERCISE_FILLERS = [
|
||||||
|
'今天', '我', '做了', '做', '进行了', '进行', '运动了', '运动', '锻炼了', '锻炼',
|
||||||
|
'了', '大概', '左右', '持续', '差不多', '一共', '总共', '的', '走了', '打了', '跑了'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析运动:返回 { exercise_type, duration, intensity }
|
||||||
|
* 例:"散步三十分钟中强度" → { type:'散步', duration:30, intensity:2 }
|
||||||
|
*/
|
||||||
|
export function parseExercise(raw) {
|
||||||
|
const t = normalizeNumbers(raw)
|
||||||
|
const result = {}
|
||||||
|
|
||||||
|
const durMatch = t.match(/(\d+(?:\.\d+)?)\s*(个小时|小时|钟头|时|分钟|分)/)
|
||||||
|
if (durMatch) {
|
||||||
|
const val = parseFloat(durMatch[1])
|
||||||
|
const isHour = /个小时|小时|钟头|时/.test(durMatch[2])
|
||||||
|
result.duration = String(Math.round(isHour ? val * 60 : val))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/高强度|剧烈|很累|大汗|气喘/.test(t)) result.intensity = 3
|
||||||
|
else if (/中强度|中等强度|中等|有点累|微微出汗|微汗/.test(t)) result.intensity = 2
|
||||||
|
else if (/低强度|轻松|轻微|溜达|缓慢/.test(t)) result.intensity = 1
|
||||||
|
|
||||||
|
// 运动类型:移除时长、强度、填充词后剩余文本
|
||||||
|
let type = t
|
||||||
|
if (durMatch) type = type.replace(durMatch[0], '')
|
||||||
|
type = type
|
||||||
|
.replace(/高强度|中强度|中等强度|低强度|剧烈|很累|大汗|气喘|有点累|微微出汗|微汗|轻松|轻微|缓慢/g, '')
|
||||||
|
.replace(/\d+(?:\.\d+)?/g, '')
|
||||||
|
.replace(/[,,。.;;、\s]+/g, '')
|
||||||
|
EXERCISE_FILLERS.forEach((f) => {
|
||||||
|
type = type.split(f).join('')
|
||||||
|
})
|
||||||
|
type = type.trim()
|
||||||
|
if (type && type.length <= 20) result.exercise_type = type
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABELS = {
|
||||||
|
fasting_blood_sugar: '空腹',
|
||||||
|
postprandial_blood_sugar: '餐后',
|
||||||
|
other_blood_sugar: '其他血糖',
|
||||||
|
systolic_pressure: '高压',
|
||||||
|
diastolic_pressure: '低压',
|
||||||
|
western_medicine: '西药',
|
||||||
|
insulin: '胰岛素',
|
||||||
|
breakfast_foods: '早餐',
|
||||||
|
lunch_foods: '午餐',
|
||||||
|
dinner_foods: '晚餐',
|
||||||
|
note: '备注',
|
||||||
|
exercise_type: '运动',
|
||||||
|
duration: '时长',
|
||||||
|
intensity: '强度'
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTENSITY_LABELS = { 1: '低强度', 2: '中强度', 3: '高强度' }
|
||||||
|
|
||||||
|
/** 把解析结果转成可读的反馈文案,例如 "空腹6.5 · 餐后8.2" */
|
||||||
|
export function summarizeParsed(parsed) {
|
||||||
|
const parts = []
|
||||||
|
Object.keys(parsed).forEach((k) => {
|
||||||
|
const label = FIELD_LABELS[k] || k
|
||||||
|
let val = parsed[k]
|
||||||
|
if (k === 'intensity') val = INTENSITY_LABELS[val] || val
|
||||||
|
parts.push(`${label}${val}`)
|
||||||
|
})
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -96,6 +96,21 @@ export function yejiStatsRevisitBreakdown(params: {
|
|||||||
return request.get({ url: '/stats.yejiStats/revisitBreakdown', 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: {
|
export function doctorDailyStatsOverview(params: {
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
@@ -181,6 +196,42 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
|||||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
||||||
|
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
||||||
|
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复诊接诊率:部门下拉(前端组树) */
|
||||||
|
export function revisitRateDeptOptions() {
|
||||||
|
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
||||||
|
export function revisitRateAssignLines(params: {
|
||||||
|
month?: string
|
||||||
|
dept_ids?: string
|
||||||
|
assistant_id?: number
|
||||||
|
dept_id?: number
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
||||||
|
export function revisitRateVisitOrderLines(params: {
|
||||||
|
month?: string
|
||||||
|
slot: number
|
||||||
|
dept_ids?: string
|
||||||
|
assistant_id?: number
|
||||||
|
dept_id?: number
|
||||||
|
}) {
|
||||||
|
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
|
||||||
|
export function autoAssignLogLists(params: Record<string, any>) {
|
||||||
|
return request.get({ url: '/stats.autoAssignLog/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 医助个人业绩概览 */
|
/** 医助个人业绩概览 */
|
||||||
export function assistantPerformanceOverview(params: {
|
export function assistantPerformanceOverview(params: {
|
||||||
time_type?: string
|
time_type?: string
|
||||||
|
|||||||
+46
-3
@@ -61,6 +61,14 @@ export function tcmDiagnosisDetail(params: any) {
|
|||||||
return request.get({ url: '/tcm.diagnosis/detail', params })
|
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 }) {
|
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
|
||||||
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
|
||||||
@@ -403,6 +411,11 @@ export function prescriptionOrderLogisticsTrace(params: {
|
|||||||
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
|
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 直接调用京东官方物流接口刷新轨迹并落库(绕过快递100缓存) */
|
||||||
|
export function prescriptionOrderLogisticsJdUpdate(params: { id: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/logisticsJdUpdate', params })
|
||||||
|
}
|
||||||
|
|
||||||
export function prescriptionOrderDetail(params: { id: number }) {
|
export function prescriptionOrderDetail(params: { id: number }) {
|
||||||
return request.get({ url: '/tcm.prescriptionOrder/detail', params })
|
return request.get({ url: '/tcm.prescriptionOrder/detail', params })
|
||||||
}
|
}
|
||||||
@@ -420,6 +433,17 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', 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: {
|
export function prescriptionOrderAuditPrescription(params: {
|
||||||
id: number
|
id: number
|
||||||
action: 'approve' | 'reject'
|
action: 'approve' | 'reject'
|
||||||
@@ -441,6 +465,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
|
||||||
|
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 撤回支付单审核 */
|
/** 撤回支付单审核 */
|
||||||
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
||||||
@@ -463,6 +492,8 @@ export function prescriptionOrderAddPayOrder(params: {
|
|||||||
pay_amount: number
|
pay_amount: number
|
||||||
pay_remark?: string
|
pay_remark?: string
|
||||||
completion_request?: number
|
completion_request?: number
|
||||||
|
/** 创建方式:fubei 付呗(默认) / express_cod 快递代收 */
|
||||||
|
pay_create_type?: 'fubei' | 'express_cod'
|
||||||
}) {
|
}) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||||
}
|
}
|
||||||
@@ -482,13 +513,13 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态,不含退款) */
|
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」) */
|
||||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 业务订单退款(须填写原因;关联收款单标记已退款、已付总额清零) */
|
/** 业务订单退款(须填写原因;refund_amount 可选,不传则退满可退上限) */
|
||||||
export function prescriptionOrderRefund(params: { id: number; reason: string }) {
|
export function prescriptionOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
|
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +542,18 @@ export function prescriptionOrderLogs(params: { id: number }) {
|
|||||||
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
|
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 }) {
|
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
:end-placeholder="endPlaceholder"
|
:end-placeholder="endPlaceholder"
|
||||||
:value-format="valueFormat"
|
:value-format="valueFormat"
|
||||||
clearable
|
clearable
|
||||||
|
@change="emit('change', $event)"
|
||||||
></el-date-picker>
|
></el-date-picker>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ const props = withDefaults(
|
|||||||
endPlaceholder: '结束时间'
|
endPlaceholder: '结束时间'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const emit = defineEmits(['update:startTime', 'update:endTime'])
|
const emit = defineEmits(['update:startTime', 'update:endTime', 'change'])
|
||||||
|
|
||||||
const content = computed<any>({
|
const content = computed<any>({
|
||||||
get: () => {
|
get: () => {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="variant === 'split'">
|
||||||
|
<el-form-item :label="label" :class="{ 'is-compact': compact }">
|
||||||
|
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
|
||||||
|
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
|
||||||
|
<el-radio-button label="today">今天</el-radio-button>
|
||||||
|
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||||
|
<el-radio-button label="week">最近7天</el-radio-button>
|
||||||
|
<el-radio-button label="month">本月</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="timeType === 'custom'">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRangeModel"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
:size="compact ? 'small' : 'default'"
|
||||||
|
:style="{ width: pickerWidth }"
|
||||||
|
@change="emit('custom-change')"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
<el-form-item v-else :label="label" :class="{ 'is-compact': compact }">
|
||||||
|
<div class="list-time-filter-inline">
|
||||||
|
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
|
||||||
|
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
|
||||||
|
<el-radio-button label="today">今天</el-radio-button>
|
||||||
|
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||||
|
<el-radio-button label="week">最近7天</el-radio-button>
|
||||||
|
<el-radio-button label="month">本月</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-date-picker
|
||||||
|
v-if="timeType === 'custom'"
|
||||||
|
v-model="dateRangeModel"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
:size="compact ? 'small' : 'default'"
|
||||||
|
:style="{ width: pickerWidth }"
|
||||||
|
@change="emit('custom-change')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ListTimeType } from '@/hooks/useListTimeFilter'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string
|
||||||
|
/** split:与自录统计一致,两个 form-item;inline:处方订单筛选用单行 */
|
||||||
|
variant?: 'split' | 'inline'
|
||||||
|
pickerWidth?: string
|
||||||
|
/** 紧凑模式:small 尺寸 radio / 日期 */
|
||||||
|
compact?: boolean
|
||||||
|
/** 是否显示「全部」(不限时间)选项 */
|
||||||
|
showAll?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: '时间范围',
|
||||||
|
variant: 'split',
|
||||||
|
pickerWidth: '260px',
|
||||||
|
compact: false,
|
||||||
|
showAll: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeType = defineModel<ListTimeType>('timeType', { required: true })
|
||||||
|
const dateRangeModel = defineModel<string[]>('dateRange', { default: () => [] })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'time-type-change': []
|
||||||
|
'custom-change': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list-time-filter-inline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.is-compact.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -88,13 +88,17 @@
|
|||||||
共 <strong>{{ formData.herbs.length }}</strong> 味(主方 {{ mainHerbCount }} / 辅方
|
共 <strong>{{ formData.herbs.length }}</strong> 味(主方 {{ mainHerbCount }} / 辅方
|
||||||
{{ auxHerbCount }})· 可手动添加、从处方库或粘贴药方批量录入
|
{{ auxHerbCount }})· 可手动添加、从处方库或粘贴药方批量录入
|
||||||
</p>
|
</p>
|
||||||
|
<p v-if="herbsLocked" class="rp-toolbar__lock-tip">
|
||||||
|
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
|
||||||
|
含「禁用修改」处方库模板,药材已锁定,不可增删改,如需调整请重新「从处方库导入」覆盖
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
|
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
|
||||||
<el-button type="primary" size="small" :icon="CirclePlus" @click="handleAddHerb('主方')">
|
<el-button type="primary" size="small" :icon="CirclePlus" :disabled="herbsLocked" @click="handleAddHerb('主方')">
|
||||||
添加主方
|
添加主方
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" plain size="small" @click="handleAddHerb('辅方')">
|
<el-button type="primary" plain size="small" :disabled="herbsLocked" @click="handleAddHerb('辅方')">
|
||||||
添加辅方
|
添加辅方
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -135,7 +139,7 @@
|
|||||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||||
>
|
>
|
||||||
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
|
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
|
||||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
|
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||||
<div
|
<div
|
||||||
v-if="isDuplicateHerbName(row.index)"
|
v-if="isDuplicateHerbName(row.index)"
|
||||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||||
@@ -148,11 +152,12 @@
|
|||||||
:min="0.1"
|
:min="0.1"
|
||||||
:step="1"
|
:step="1"
|
||||||
:precision="1"
|
:precision="1"
|
||||||
|
:disabled="herbsLocked"
|
||||||
placeholder="剂量(克)"
|
placeholder="剂量(克)"
|
||||||
class="herb-editor-card__dose"
|
class="herb-editor-card__dose"
|
||||||
/>
|
/>
|
||||||
<span class="text-gray-500 shrink-0">克</span>
|
<span class="text-gray-500 shrink-0">克</span>
|
||||||
<el-button type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +176,7 @@
|
|||||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||||
>
|
>
|
||||||
<div class="herb-editor-card__title">辅方</div>
|
<div class="herb-editor-card__title">辅方</div>
|
||||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
|
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||||
<div
|
<div
|
||||||
v-if="isDuplicateHerbName(row.index)"
|
v-if="isDuplicateHerbName(row.index)"
|
||||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||||
@@ -184,11 +189,12 @@
|
|||||||
:min="0.1"
|
:min="0.1"
|
||||||
:step="1"
|
:step="1"
|
||||||
:precision="1"
|
:precision="1"
|
||||||
|
:disabled="herbsLocked"
|
||||||
placeholder="剂量(克)"
|
placeholder="剂量(克)"
|
||||||
class="herb-editor-card__dose"
|
class="herb-editor-card__dose"
|
||||||
/>
|
/>
|
||||||
<span class="text-gray-500 shrink-0">克</span>
|
<span class="text-gray-500 shrink-0">克</span>
|
||||||
<el-button type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,7 +559,11 @@
|
|||||||
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
|
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
|
||||||
<div class="rx-notice">
|
<div class="rx-notice">
|
||||||
<span class="rx-notice-text">
|
<span class="rx-notice-text">
|
||||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
{{
|
||||||
|
prescriptionTabType === 'user'
|
||||||
|
? '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
|
||||||
|
: '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="rx-notice-meta">
|
<span class="rx-notice-meta">
|
||||||
<span>日期:{{ rxDateText }}</span>
|
<span>日期:{{ rxDateText }}</span>
|
||||||
@@ -672,22 +682,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rx-text">
|
<div class="rx-text">
|
||||||
<p v-if="prescriptionTabType === 'internal' || savedAuxUsageText">主方服法:{{ rxUsageText }}</p>
|
<template v-if="prescriptionTabType === 'internal'">
|
||||||
<p v-else>服法:{{ rxUsageText }}</p>
|
<p v-if="savedAuxUsageText">主方服法:{{ rxUsageText }}</p>
|
||||||
<p v-if="savedAuxUsageText">{{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}:{{ savedAuxUsageText }}</p>
|
<p v-else>服法:{{ rxUsageText }}</p>
|
||||||
|
<p v-if="savedAuxUsageText">辅方服法:{{ savedAuxUsageText }}</p>
|
||||||
|
</template>
|
||||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||||
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
|
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
|
||||||
忌口:{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('、') : savedPrescription.dietary_taboo }}
|
忌口:{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('、') : savedPrescription.dietary_taboo }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">药房备注:{{ rxPharmacyRemarkText }}</p>
|
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">药房备注:{{ rxPharmacyRemarkText }}</p>
|
||||||
<p v-if="rxOutPelletText && savedPrescription.prescription_type !== '饮片'" class="rx-text-warn">
|
<p
|
||||||
|
v-if="prescriptionTabType === 'internal' && rxOutPelletText && savedPrescription.prescription_type !== '饮片'"
|
||||||
|
class="rx-text-warn"
|
||||||
|
>
|
||||||
出丸:{{ rxOutPelletText }}
|
出丸:{{ rxOutPelletText }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rx-bottom">
|
<div class="rx-bottom">
|
||||||
<div class="rx-bot-row rx-bot-row-1">
|
<div
|
||||||
|
class="rx-bot-row rx-bot-row-1"
|
||||||
|
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
|
||||||
|
>
|
||||||
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
|
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
|
||||||
<div class="rx-bot-label">医师</div>
|
<div class="rx-bot-label">医师</div>
|
||||||
<div class="rx-bot-doctor-body">
|
<div class="rx-bot-doctor-body">
|
||||||
@@ -697,10 +715,12 @@
|
|||||||
alt="医师签名"
|
alt="医师签名"
|
||||||
class="rx-bot-sign-img"
|
class="rx-bot-sign-img"
|
||||||
/>
|
/>
|
||||||
<div class="rx-bot-doctor-name">{{ savedPrescription.doctor_name || '—' }}</div>
|
<div v-if="!savedPrescription.doctor_signature" class="rx-bot-doctor-name">
|
||||||
|
{{ savedPrescription.doctor_name || '—' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||||
<span class="rx-bot-meta-key">类型:</span>
|
<span class="rx-bot-meta-key">类型:</span>
|
||||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -708,7 +728,7 @@
|
|||||||
<span class="rx-bot-meta-key">天数:</span>
|
<span class="rx-bot-meta-key">天数:</span>
|
||||||
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
|
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||||
<span class="rx-bot-meta-key">剂量:</span>
|
<span class="rx-bot-meta-key">剂量:</span>
|
||||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1028,6 +1048,8 @@ interface Herb {
|
|||||||
name: string
|
name: string
|
||||||
dosage: number
|
dosage: number
|
||||||
formula_type?: FormulaType
|
formula_type?: FormulaType
|
||||||
|
/** 来自「禁用修改」处方库模板:锁定后不可增删改,只能再次导入覆盖 */
|
||||||
|
locked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuxUsageForm = {
|
type AuxUsageForm = {
|
||||||
@@ -1044,11 +1066,15 @@ function normalizeFormulaType(v: unknown): FormulaType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHerbRow(raw: any): Herb {
|
function normalizeHerbRow(raw: any): Herb {
|
||||||
return {
|
const row: Herb = {
|
||||||
name: String(raw?.name ?? '').trim(),
|
name: String(raw?.name ?? '').trim(),
|
||||||
dosage: Number(raw?.dosage) || 0,
|
dosage: Number(raw?.dosage) || 0,
|
||||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||||
}
|
}
|
||||||
|
if (raw?.locked === true || raw?.locked === 1) {
|
||||||
|
row.locked = true
|
||||||
|
}
|
||||||
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
|
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
|
||||||
@@ -1430,6 +1456,12 @@ const auxHerbRows = computed(() =>
|
|||||||
const mainHerbCount = computed(() => mainHerbRows.value.length)
|
const mainHerbCount = computed(() => mainHerbRows.value.length)
|
||||||
const auxHerbCount = computed(() => auxHerbRows.value.length)
|
const auxHerbCount = computed(() => auxHerbRows.value.length)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整张处方是否被锁定:只要任一药材来自「禁用修改」模板即锁定。
|
||||||
|
* 锁定后:不可增删改任意药材,仅可再次「从处方库导入」覆盖。
|
||||||
|
*/
|
||||||
|
const herbsLocked = computed(() => formData.herbs.some((h) => (h as Herb).locked === true))
|
||||||
|
|
||||||
const libraryDoctorId = computed(() => {
|
const libraryDoctorId = computed(() => {
|
||||||
const uid = Number(userStore.userInfo?.id)
|
const uid = Number(userStore.userInfo?.id)
|
||||||
return uid > 0 ? uid : 0
|
return uid > 0 ? uid : 0
|
||||||
@@ -2029,10 +2061,18 @@ function findDuplicateHerbNames(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddHerb = (formulaType: FormulaType = '主方') => {
|
const handleAddHerb = (formulaType: FormulaType = '主方') => {
|
||||||
|
if (herbsLocked.value) {
|
||||||
|
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
|
||||||
|
return
|
||||||
|
}
|
||||||
formData.herbs.push({ name: '', dosage: 6, formula_type: formulaType })
|
formData.herbs.push({ name: '', dosage: 6, formula_type: formulaType })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveHerb = (index: number) => {
|
const handleRemoveHerb = (index: number) => {
|
||||||
|
if (herbsLocked.value) {
|
||||||
|
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
formData.herbs.splice(index, 1)
|
formData.herbs.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2089,10 +2129,18 @@ const handleImportLibrary = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formulaType = normalizeFormulaType(row.formula_type)
|
const formulaType = normalizeFormulaType(row.formula_type)
|
||||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
|
// 「禁用修改」模板:导入的药材打上锁定标记 → 整张处方锁定(不可增删改,只能再次导入覆盖)
|
||||||
...normalizeHerbRow(h),
|
const isLockedTemplate = Number(row?.disable_edit) === 1
|
||||||
formula_type: formulaType
|
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
|
||||||
}))
|
const herb = normalizeHerbRow(h)
|
||||||
|
herb.formula_type = formulaType
|
||||||
|
if (isLockedTemplate) {
|
||||||
|
herb.locked = true
|
||||||
|
} else {
|
||||||
|
delete herb.locked
|
||||||
|
}
|
||||||
|
return herb
|
||||||
|
})
|
||||||
if (libraryImportMode.value === 'replace') {
|
if (libraryImportMode.value === 'replace') {
|
||||||
const kept = formData.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
const kept = formData.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||||
formData.herbs = [...kept, ...imported]
|
formData.herbs = [...kept, ...imported]
|
||||||
@@ -2532,6 +2580,17 @@ defineExpose({
|
|||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rp-toolbar__lock-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.rp-toolbar__actions {
|
.rp-toolbar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2925,6 +2984,10 @@ defineExpose({
|
|||||||
min-height: 70px;
|
min-height: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rx-bot-row-1--user {
|
||||||
|
grid-template-columns: 1.4fr 0.6fr;
|
||||||
|
}
|
||||||
|
|
||||||
.rx-hospital-row {
|
.rx-hospital-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
export type ListTimeType = 'all' | 'today' | 'yesterday' | 'week' | 'month' | 'custom'
|
||||||
|
|
||||||
|
export function formatYmd(d: Date): string {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表时间筛选:month 为本自然月 1 日~月末 */
|
||||||
|
export function resolveListTimeRange(timeType: ListTimeType, dateRange: string[]): [string, string] {
|
||||||
|
const today = formatYmd(new Date())
|
||||||
|
|
||||||
|
switch (timeType) {
|
||||||
|
case 'all':
|
||||||
|
return ['', '']
|
||||||
|
case 'yesterday': {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - 1)
|
||||||
|
const y = formatYmd(d)
|
||||||
|
return [y, y]
|
||||||
|
}
|
||||||
|
case 'week': {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
start.setDate(start.getDate() - 6)
|
||||||
|
return [formatYmd(start), formatYmd(end)]
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||||
|
return [formatYmd(start), formatYmd(end)]
|
||||||
|
}
|
||||||
|
case 'custom': {
|
||||||
|
let start = dateRange[0] || today
|
||||||
|
let end = dateRange[1] || today
|
||||||
|
if (start > end) {
|
||||||
|
;[start, end] = [end, start]
|
||||||
|
}
|
||||||
|
return [start, end]
|
||||||
|
}
|
||||||
|
case 'today':
|
||||||
|
default:
|
||||||
|
return [today, today]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListTimeFilter(defaultType: ListTimeType = 'today') {
|
||||||
|
const time_type = ref<ListTimeType>(defaultType)
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
|
||||||
|
const resolve = () => resolveListTimeRange(time_type.value, dateRange.value)
|
||||||
|
|
||||||
|
const handleTimeTypeChange = (onQuery: () => void) => {
|
||||||
|
if (time_type.value !== 'custom') {
|
||||||
|
dateRange.value = []
|
||||||
|
onQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomDateChange = (onQuery: () => void) => {
|
||||||
|
if (dateRange.value.length === 2) {
|
||||||
|
onQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTimeFilter = () => {
|
||||||
|
time_type.value = defaultType
|
||||||
|
dateRange.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyCustomRange = (start: string, end: string) => {
|
||||||
|
if (!start || !end) return
|
||||||
|
time_type.value = 'custom'
|
||||||
|
dateRange.value = [start, end]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time_type,
|
||||||
|
dateRange,
|
||||||
|
resolve,
|
||||||
|
handleTimeTypeChange,
|
||||||
|
handleCustomDateChange,
|
||||||
|
resetTimeFilter,
|
||||||
|
applyCustomRange,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,8 +32,12 @@
|
|||||||
|
|
||||||
<!-- 列表区域 -->
|
<!-- 列表区域 -->
|
||||||
<el-card class="!border-none mt-4" shadow="never">
|
<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="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>
|
</div>
|
||||||
|
|
||||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||||
@@ -42,7 +46,8 @@
|
|||||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
|
||||||
<el-table :data="tableData" v-loading="loading">
|
<el-table :data="tableData" 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="id" label="ID" width="80" /> -->
|
||||||
<el-table-column prop="title" label="标题" min-width="80" />
|
<el-table-column prop="title" label="标题" min-width="80" />
|
||||||
<el-table-column label="预览" width="100">
|
<el-table-column label="预览" width="100">
|
||||||
@@ -92,7 +97,10 @@
|
|||||||
<el-input v-model="formData.title" placeholder="请输入资源标题" />
|
<el-input v-model="formData.title" placeholder="请输入资源标题" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
|
<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">
|
<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>
|
<el-button type="primary">上传视频 (OSS直传)</el-button>
|
||||||
</upload>
|
</upload>
|
||||||
@@ -126,8 +134,9 @@ import MaterialPicker from '@/components/material/picker.vue'
|
|||||||
import Upload from '@/components/upload/index.vue'
|
import Upload from '@/components/upload/index.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tableData = ref([])
|
const tableData = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const selectedIds = ref<number[]>([])
|
||||||
|
|
||||||
const searchData = reactive<any>({
|
const searchData = reactive<any>({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -156,6 +165,15 @@ const formData = reactive<any>({
|
|||||||
user_ids: []
|
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(() => {
|
const computedRules = computed(() => {
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
return {
|
return {
|
||||||
@@ -164,7 +182,7 @@ const computedRules = computed(() => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
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())
|
const res = await apiAssetResourceList(buildQueryParams())
|
||||||
tableData.value = res.lists
|
tableData.value = res.lists
|
||||||
total.value = res.count
|
total.value = res.count
|
||||||
|
selectedIds.value = []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -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 () => {
|
const submitForm = async () => {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
|||||||
@@ -27,6 +27,20 @@
|
|||||||
<el-table :data="tableData" v-loading="loading">
|
<el-table :data="tableData" v-loading="loading">
|
||||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||||
<el-table-column prop="phone" label="手机号" />
|
<el-table-column prop="phone" label="手机号" />
|
||||||
|
<el-table-column label="备注" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="flex items-center gap-1 min-w-0">
|
||||||
|
<span class="truncate text-gray-600" :title="row.remark || ''">
|
||||||
|
{{ row.remark || '—' }}
|
||||||
|
</span>
|
||||||
|
<el-tooltip content="编辑备注" placement="top">
|
||||||
|
<el-icon class="shrink-0 cursor-pointer text-gray-400 hover:text-primary" @click="openQuickRemark(row)">
|
||||||
|
<Edit />
|
||||||
|
</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -70,6 +84,16 @@
|
|||||||
<el-form-item label="密码" prop="password">
|
<el-form-item label="密码" prop="password">
|
||||||
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
|
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="可填写用户相关信息,仅管理员可见"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
|
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -79,12 +103,30 @@
|
|||||||
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
|
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 快捷备注 -->
|
||||||
|
<el-dialog v-model="remarkDialogVisible" title="快捷备注" width="420px" destroy-on-close>
|
||||||
|
<div class="text-sm text-gray-500 mb-3">账号:{{ remarkTargetPhone }}</div>
|
||||||
|
<el-input
|
||||||
|
v-model="remarkDraft"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="可填写用户相关信息,仅管理员可见"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="remarkDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="remarkSaving" @click="saveQuickRemark">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Edit } from '@element-plus/icons-vue'
|
||||||
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
|
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -105,10 +147,17 @@ const dialogTitle = ref('')
|
|||||||
const submitLoading = ref(false)
|
const submitLoading = ref(false)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
|
const remarkDialogVisible = ref(false)
|
||||||
|
const remarkSaving = ref(false)
|
||||||
|
const remarkDraft = ref('')
|
||||||
|
const remarkTargetId = ref<number | string>('')
|
||||||
|
const remarkTargetPhone = ref('')
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
id: '',
|
id: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
remark: '',
|
||||||
status: 1
|
status: 1
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -154,16 +203,41 @@ const handleStatusChange = async (row: any) => {
|
|||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
dialogTitle.value = '新增账号'
|
dialogTitle.value = '新增账号'
|
||||||
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
|
Object.assign(formData, { id: '', phone: '', password: '', remark: '', status: 1 })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (row: any) => {
|
const handleEdit = (row: any) => {
|
||||||
dialogTitle.value = '编辑账号'
|
dialogTitle.value = '编辑账号'
|
||||||
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
|
Object.assign(formData, { id: row.id, phone: row.phone, password: '', remark: row.remark || '', status: row.status })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openQuickRemark = (row: any) => {
|
||||||
|
remarkTargetId.value = row.id
|
||||||
|
remarkTargetPhone.value = row.phone || ''
|
||||||
|
remarkDraft.value = row.remark || ''
|
||||||
|
remarkDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveQuickRemark = async () => {
|
||||||
|
if (!remarkTargetId.value) return
|
||||||
|
remarkSaving.value = true
|
||||||
|
try {
|
||||||
|
await apiAssetUserEdit({ id: remarkTargetId.value, remark: remarkDraft.value })
|
||||||
|
const target = tableData.value.find((item: any) => item.id === remarkTargetId.value)
|
||||||
|
if (target) {
|
||||||
|
target.remark = remarkDraft.value
|
||||||
|
}
|
||||||
|
ElMessage.success('备注已保存')
|
||||||
|
remarkDialogVisible.value = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
remarkSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async (row: any) => {
|
const handleDelete = async (row: any) => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
|
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* 处方业务订单——纯展示/格式化工具
|
||||||
|
*
|
||||||
|
* 唯一数据源:order_list.vue(处方业务订单)、PatientOrderList.vue(诊单业务订单 Tab)
|
||||||
|
* 与 PrescriptionOrderDetailDrawer.vue(共享详情抽屉)共用,禁止在页面内重复定义同名函数,
|
||||||
|
* 否则两个详情页面会再次出现文案/口径漂移。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 与诊间医助角色 ID 一致(server 角色表) */
|
||||||
|
export const TCM_ASSISTANT_ROLE_ID = 2
|
||||||
|
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||||
|
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||||
|
|
||||||
|
export function formatTime(v: unknown) {
|
||||||
|
if (v === null || v === undefined || v === '') return '—'
|
||||||
|
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||||
|
const d = new Date(v * 1000)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(v: unknown) {
|
||||||
|
const n = Number(v)
|
||||||
|
if (Number.isNaN(n)) return '—'
|
||||||
|
return n.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOrderTime(v: unknown) {
|
||||||
|
if (v === null || v === undefined || v === '') return '—'
|
||||||
|
if (typeof v === 'string' && String(v).includes('-')) return String(v)
|
||||||
|
return formatTime(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feeTypeText(t: number | undefined) {
|
||||||
|
const m: Record<number, string> = {
|
||||||
|
1: '挂号费',
|
||||||
|
2: '问诊费',
|
||||||
|
3: '药品费用',
|
||||||
|
4: '首付费用',
|
||||||
|
5: '尾款费用',
|
||||||
|
6: '其他费用',
|
||||||
|
7: '全部费用',
|
||||||
|
8: '驼奶费用'
|
||||||
|
}
|
||||||
|
return m[Number(t)] ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fulfillmentText(s: number | undefined) {
|
||||||
|
const m: Record<number, string> = {
|
||||||
|
1: '待双审通过',
|
||||||
|
2: '待发货',
|
||||||
|
3: '已完成',
|
||||||
|
4: '已取消',
|
||||||
|
5: '已发货',
|
||||||
|
6: '已签收',
|
||||||
|
7: '进行中',
|
||||||
|
8: '暂不制药',
|
||||||
|
9: '拒收',
|
||||||
|
10: '退款',
|
||||||
|
11: '保留药方',
|
||||||
|
12: '制药缓发'
|
||||||
|
}
|
||||||
|
return m[Number(s)] ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||||
|
const n = Number(s)
|
||||||
|
if (n === 3) return 'success'
|
||||||
|
if (n === 6) return 'success'
|
||||||
|
if (n === 5) return 'primary'
|
||||||
|
if (n === 4) return 'danger'
|
||||||
|
if (n === 2) return 'warning'
|
||||||
|
if (n === 7) return 'warning'
|
||||||
|
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||||
|
if (n === 9 || n === 10) return 'danger'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderStatusText(s: number | undefined) {
|
||||||
|
const m: Record<number, string> = {
|
||||||
|
1: '待支付',
|
||||||
|
2: '已支付',
|
||||||
|
3: '已取消',
|
||||||
|
4: '已退款',
|
||||||
|
5: '待审核'
|
||||||
|
}
|
||||||
|
return m[Number(s)] ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
|
const n = Number(s)
|
||||||
|
if (n === 2) return 'success'
|
||||||
|
if (n === 1) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumerRxAuditText(s: number | undefined) {
|
||||||
|
const n = Number(s)
|
||||||
|
if (n === 1) return '已通过'
|
||||||
|
if (n === 2) return '已驳回'
|
||||||
|
return '待审核'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
|
const n = Number(s)
|
||||||
|
if (n === 1) return 'success'
|
||||||
|
if (n === 2) return 'danger'
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expressCompanyLabel(v: unknown) {
|
||||||
|
const s = String(v || '').toLowerCase()
|
||||||
|
if (s === 'sf') return '顺丰速运'
|
||||||
|
if (s === 'jd') return '京东快递'
|
||||||
|
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||||
|
return '自动识别'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logActionText(act: string) {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
create: '创建',
|
||||||
|
edit: '编辑',
|
||||||
|
audit_rx_approve: '处方审核',
|
||||||
|
audit_rx_reject: '处方审核',
|
||||||
|
audit_pay_approve: '支付审核',
|
||||||
|
audit_pay_reject: '支付审核',
|
||||||
|
fill_tracking: '填快递单',
|
||||||
|
ship: '确认发货',
|
||||||
|
withdraw: '撤销',
|
||||||
|
link_pay_order: '关联支付单',
|
||||||
|
completion_request: '完单申请',
|
||||||
|
auto_complete: '自动完成',
|
||||||
|
revoke_rx_audit: '撤回处方审核',
|
||||||
|
revoke_pay_audit: '撤回支付审核',
|
||||||
|
gancao_submit: '甘草下单',
|
||||||
|
patch_rx_patient: '处方患者信息',
|
||||||
|
patch_rx_usage: '服用参数',
|
||||||
|
update_amount: '修改订单金额',
|
||||||
|
complete: '完成订单',
|
||||||
|
refund: '退款',
|
||||||
|
manual_log: '手工备注',
|
||||||
|
assign_assistant: '改派医助',
|
||||||
|
add_pay_order: '补齐支付单',
|
||||||
|
set_ship_mode: '发货类型'
|
||||||
|
}
|
||||||
|
return m[act] || act
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
|
||||||
|
export function auditStatusText(s: number | undefined) {
|
||||||
|
if (s === 1) return '已通过'
|
||||||
|
if (s === 2) return '已驳回'
|
||||||
|
return '待审核'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
|
||||||
|
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||||
|
const createType = String(row?.create_type || '')
|
||||||
|
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||||
|
if (createType === 'fubei') return '付呗'
|
||||||
|
if (createType === 'express_cod') return '快递代收'
|
||||||
|
const paymentMethod = String(row?.payment_method || '')
|
||||||
|
const methodMap: Record<string, string> = {
|
||||||
|
alipay: '支付宝',
|
||||||
|
wechat: '微信',
|
||||||
|
wechat_work: '企业微信',
|
||||||
|
fubei: '付呗',
|
||||||
|
express_cod: '快递代收',
|
||||||
|
manual: '手动确认到账'
|
||||||
|
}
|
||||||
|
if (paymentMethod && methodMap[paymentMethod]) {
|
||||||
|
return methodMap[paymentMethod]
|
||||||
|
}
|
||||||
|
return '普通订单'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBizPhone(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
return String(v).replace(/\s/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
|
||||||
|
const a = normalizeBizPhone(recipient)
|
||||||
|
const b = normalizeBizPhone(rxPhone)
|
||||||
|
if (!a || !b) return false
|
||||||
|
return a !== b
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
||||||
|
if (!raw) return []
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((x: any) => ({
|
||||||
|
name: String(x?.name ?? '').trim(),
|
||||||
|
dosage: Number(x?.dosage) || 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlipFormulaType = '主方' | '辅方'
|
||||||
|
|
||||||
|
export interface SlipAuxUsageForm {
|
||||||
|
dosage_amount?: number
|
||||||
|
dosage_bag_count: number
|
||||||
|
need_decoction: boolean
|
||||||
|
bags_per_dose: number
|
||||||
|
times_per_day: number
|
||||||
|
usage_days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
|
||||||
|
return v === '辅方' ? '辅方' : '主方'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
|
||||||
|
if (prescriptionType === '饮片') {
|
||||||
|
return {
|
||||||
|
dosage_amount: 50,
|
||||||
|
dosage_bag_count: 1,
|
||||||
|
need_decoction: false,
|
||||||
|
bags_per_dose: 1,
|
||||||
|
times_per_day: 3,
|
||||||
|
usage_days: 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prescriptionType === '浓缩水丸') {
|
||||||
|
return {
|
||||||
|
dosage_amount: 5,
|
||||||
|
dosage_bag_count: 1,
|
||||||
|
need_decoction: false,
|
||||||
|
bags_per_dose: 1,
|
||||||
|
times_per_day: 3,
|
||||||
|
usage_days: 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dosage_amount: 1,
|
||||||
|
dosage_bag_count: 1,
|
||||||
|
need_decoction: false,
|
||||||
|
bags_per_dose: 1,
|
||||||
|
times_per_day: 3,
|
||||||
|
usage_days: 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
|
||||||
|
const base = defaultSlipAuxUsage(prescriptionType)
|
||||||
|
if (!raw || typeof raw !== 'object') return { ...base }
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
dosage_amount:
|
||||||
|
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
|
||||||
|
? Number(o.dosage_amount)
|
||||||
|
: base.dosage_amount,
|
||||||
|
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
|
||||||
|
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
|
||||||
|
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
|
||||||
|
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
|
||||||
|
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula) */
|
||||||
|
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
|
||||||
|
if (!rx) return false
|
||||||
|
const herbs = rx.herbs
|
||||||
|
if (Array.isArray(herbs)) {
|
||||||
|
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
|
||||||
|
}
|
||||||
|
return Number(rx.has_aux_formula) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
|
||||||
|
export const LOGISTICS_URGENT_KEYWORDS = [
|
||||||
|
'拒收',
|
||||||
|
'拒签',
|
||||||
|
'退件',
|
||||||
|
'客户拒收',
|
||||||
|
'拦截退回',
|
||||||
|
'退回发件',
|
||||||
|
'派件退回',
|
||||||
|
'无人签收',
|
||||||
|
'退回快件'
|
||||||
|
]
|
||||||
|
|
||||||
|
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
|
||||||
|
const t = String(s ?? '')
|
||||||
|
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logisticsTraceLineUrgent(context: unknown): boolean {
|
||||||
|
return logisticsStringHasUrgentKeyword(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
|
||||||
|
export function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
|
||||||
|
show: boolean
|
||||||
|
keywords: string[]
|
||||||
|
lines: string[]
|
||||||
|
} {
|
||||||
|
if (!p) {
|
||||||
|
return { show: false, keywords: [], lines: [] }
|
||||||
|
}
|
||||||
|
const traces = Array.isArray(p.traces)
|
||||||
|
? (p.traces as Array<{ time?: string; context?: string }>)
|
||||||
|
: []
|
||||||
|
const chunks: string[] = []
|
||||||
|
if (p.state_text) chunks.push(String(p.state_text))
|
||||||
|
if (p.hint) chunks.push(String(p.hint))
|
||||||
|
for (const row of traces) {
|
||||||
|
if (row.context) chunks.push(String(row.context))
|
||||||
|
}
|
||||||
|
const joined = chunks.join('\n')
|
||||||
|
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
|
||||||
|
if (!keywords.length) {
|
||||||
|
return { show: false, keywords: [], lines: [] }
|
||||||
|
}
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const row of traces) {
|
||||||
|
const ctx = String(row.context || '')
|
||||||
|
if (logisticsStringHasUrgentKeyword(ctx)) {
|
||||||
|
lines.push(ctx)
|
||||||
|
if (lines.length >= 5) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
|
||||||
|
lines.push(`最新状态:${p.state_text}`)
|
||||||
|
}
|
||||||
|
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
|
||||||
|
lines.push(String(p.hint))
|
||||||
|
}
|
||||||
|
return { show: true, keywords, lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
|
||||||
|
export function parseLogisticsTracePayload(
|
||||||
|
res: unknown,
|
||||||
|
expectedTrackingNumber: string,
|
||||||
|
expectedOrderId: number
|
||||||
|
): Record<string, any> | null {
|
||||||
|
const raw = (res as { data?: unknown })?.data ?? res
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const payload = raw as Record<string, any>
|
||||||
|
const respNum = String(payload.tracking_number || '').trim()
|
||||||
|
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||||
|
const respOrderId = Number(payload.order_id)
|
||||||
|
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
|
||||||
|
if (!row?.id) return false
|
||||||
|
const fs = Number(row.fulfillment_status)
|
||||||
|
return fs !== 3 && fs !== 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
|
||||||
|
export function formatDietaryTaboo(raw: unknown): string {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('、')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务套餐 dict:server_order */
|
||||||
|
export type ServicePackageOption = { name: string; value: string; status?: number }
|
||||||
|
|
||||||
|
export function normalizeServicePackageValue(v: unknown): string {
|
||||||
|
return String(v ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
|
||||||
|
export function parseServicePackageValues(raw: unknown): string[] {
|
||||||
|
if (raw == null || raw === '') return []
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return raw.map(normalizeServicePackageValue).filter(Boolean)
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
return raw.split(',').map((v) => v.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
const one = normalizeServicePackageValue(raw)
|
||||||
|
return one ? [one] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
|
||||||
|
const sa = normalizeServicePackageValue(a)
|
||||||
|
const sb = normalizeServicePackageValue(b)
|
||||||
|
if (!sa || !sb) return false
|
||||||
|
if (sa === sb) return true
|
||||||
|
const na = Number(sa)
|
||||||
|
const nb = Number(sb)
|
||||||
|
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw
|
||||||
|
.map((item: any) => ({
|
||||||
|
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
|
||||||
|
value: normalizeServicePackageValue(item?.value),
|
||||||
|
status: Number(item?.status ?? 1)
|
||||||
|
}))
|
||||||
|
.filter((item) => item.value !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findServicePackageOption(
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
value: unknown
|
||||||
|
): ServicePackageOption | undefined {
|
||||||
|
const key = normalizeServicePackageValue(value)
|
||||||
|
if (!key) return undefined
|
||||||
|
return options.find((opt) => servicePackageValueEquals(opt.value, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示用:value → 字典 name,多选用「、」连接 */
|
||||||
|
export function formatServicePackageLabels(
|
||||||
|
value: unknown,
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
emptyText = '—'
|
||||||
|
): string {
|
||||||
|
const packages = parseServicePackageValues(value)
|
||||||
|
if (packages.length === 0) return emptyText
|
||||||
|
const names = packages.map((val) => {
|
||||||
|
const option = findServicePackageOption(options, val)
|
||||||
|
return option?.name || val
|
||||||
|
})
|
||||||
|
return names.join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
|
||||||
|
export function mergeServicePackageSelectOptions(
|
||||||
|
options: ServicePackageOption[],
|
||||||
|
selected: unknown[]
|
||||||
|
): ServicePackageOption[] {
|
||||||
|
const known = new Set(options.map((o) => o.value))
|
||||||
|
const extras: ServicePackageOption[] = []
|
||||||
|
for (const raw of selected) {
|
||||||
|
const val = normalizeServicePackageValue(raw)
|
||||||
|
if (!val || known.has(val)) continue
|
||||||
|
const matched = findServicePackageOption(options, val)
|
||||||
|
extras.push(matched ?? { name: val, value: val, status: 0 })
|
||||||
|
known.add(val)
|
||||||
|
}
|
||||||
|
return extras.length ? [...options, ...extras] : options
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<el-radio-button label="rejected">驳回</el-radio-button>
|
<el-radio-button label="rejected">驳回</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<span class="pl-toolbar__sep" aria-hidden="true" />
|
<span class="pl-toolbar__sep" aria-hidden="true" />
|
||||||
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
|
<span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
|
||||||
<el-radio-group
|
<el-radio-group
|
||||||
v-model="formData.source_filter"
|
v-model="formData.source_filter"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
>
|
>
|
||||||
<el-radio-button label="all">全部</el-radio-button>
|
<el-radio-button label="all">全部</el-radio-button>
|
||||||
<el-radio-button label="manual">手工</el-radio-button>
|
<el-radio-button label="manual">手工</el-radio-button>
|
||||||
<el-radio-button label="system">系统代开</el-radio-button>
|
<el-radio-button label="system">空白处方</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-toolbar__line pl-toolbar__line--second">
|
<div class="pl-toolbar__line pl-toolbar__line--second">
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
size="small"
|
size="small"
|
||||||
effect="plain"
|
effect="plain"
|
||||||
>
|
>
|
||||||
系统代开
|
空白处方
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<span v-else class="text-gray-400 text-sm">手工</span>
|
<span v-else class="text-gray-400 text-sm">手工</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
type="warning"
|
type="warning"
|
||||||
size="small"
|
size="small"
|
||||||
effect="plain"
|
effect="plain"
|
||||||
>系统代开</el-tag>
|
>空白处方</el-tag>
|
||||||
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
|
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
|
||||||
<el-tag
|
<el-tag
|
||||||
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
|
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
|
||||||
@@ -437,6 +437,7 @@
|
|||||||
<p>主方服法:{{ rxUsageText }}</p>
|
<p>主方服法:{{ rxUsageText }}</p>
|
||||||
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
||||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||||
|
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||||
药房备注:{{ rxPharmacyRemarkText }}
|
药房备注:{{ rxPharmacyRemarkText }}
|
||||||
@@ -458,7 +459,9 @@
|
|||||||
alt="医师签名"
|
alt="医师签名"
|
||||||
class="rx-bot-sign-img"
|
class="rx-bot-sign-img"
|
||||||
/>
|
/>
|
||||||
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
|
<div v-if="!slipView.doctor_signature" class="rx-bot-doctor-name">
|
||||||
|
{{ slipView.doctor_name || '—' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rx-bot-cell rx-bot-meta">
|
<div class="rx-bot-cell rx-bot-meta">
|
||||||
@@ -679,17 +682,21 @@
|
|||||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
|
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="herbsLocked" class="flex items-center gap-2 flex-wrap mt-2 text-xs text-[var(--el-color-danger)]">
|
||||||
|
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
|
||||||
|
含「禁用修改」处方库模板,药材已锁定,不可增删改,如需调整请重新「从处方库导入」覆盖
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="herb-formula-block mt-3">
|
<div class="herb-formula-block mt-3">
|
||||||
<div class="herb-formula-block__head">
|
<div class="herb-formula-block__head">
|
||||||
<el-tag type="primary" size="small">主方</el-tag>
|
<el-tag type="primary" size="small">主方</el-tag>
|
||||||
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
|
<el-button type="primary" size="small" :disabled="herbsLocked" @click="addHerb('主方')">添加主方药材</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
|
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
|
||||||
<el-table-column label="序号" type="index" width="60" />
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
<el-table-column label="药材名称" min-width="220">
|
<el-table-column label="药材名称" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剂量(克)" min-width="120">
|
<el-table-column label="剂量(克)" min-width="120">
|
||||||
@@ -699,6 +706,7 @@
|
|||||||
:min="0"
|
:min="0"
|
||||||
:precision="1"
|
:precision="1"
|
||||||
:step="0.5"
|
:step="0.5"
|
||||||
|
:disabled="herbsLocked"
|
||||||
placeholder="剂量"
|
placeholder="剂量"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -706,7 +714,8 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="80">
|
<el-table-column label="操作" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||||
|
<span v-else class="text-gray-400 text-xs">已锁定</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -715,7 +724,7 @@
|
|||||||
<div class="herb-formula-block mt-4">
|
<div class="herb-formula-block mt-4">
|
||||||
<div class="herb-formula-block__head">
|
<div class="herb-formula-block__head">
|
||||||
<el-tag type="warning" size="small">辅方</el-tag>
|
<el-tag type="warning" size="small">辅方</el-tag>
|
||||||
<el-button type="primary" size="small" plain @click="addHerb('辅方')">
|
<el-button type="primary" size="small" plain :disabled="herbsLocked" @click="addHerb('辅方')">
|
||||||
添加辅方药材
|
添加辅方药材
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -723,7 +732,7 @@
|
|||||||
<el-table-column label="序号" type="index" width="60" />
|
<el-table-column label="序号" type="index" width="60" />
|
||||||
<el-table-column label="药材名称" min-width="220">
|
<el-table-column label="药材名称" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="剂量(克)" min-width="120">
|
<el-table-column label="剂量(克)" min-width="120">
|
||||||
@@ -733,6 +742,7 @@
|
|||||||
:min="0"
|
:min="0"
|
||||||
:precision="1"
|
:precision="1"
|
||||||
:step="0.5"
|
:step="0.5"
|
||||||
|
:disabled="herbsLocked"
|
||||||
placeholder="剂量"
|
placeholder="剂量"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -740,7 +750,8 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="80">
|
<el-table-column label="操作" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||||
|
<span v-else class="text-gray-400 text-xs">已锁定</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -1698,7 +1709,7 @@ import jsPDF from 'jspdf'
|
|||||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
type FormulaType = '主方' | '辅方'
|
type FormulaType = '主方' | '辅方'
|
||||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType }
|
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||||
|
|
||||||
type AuxUsageForm = {
|
type AuxUsageForm = {
|
||||||
dosage_amount?: number
|
dosage_amount?: number
|
||||||
@@ -1798,11 +1809,15 @@ function normalizeFormulaType(v: unknown): FormulaType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHerbRow(raw: any): HerbRow {
|
function normalizeHerbRow(raw: any): HerbRow {
|
||||||
return {
|
const row: HerbRow = {
|
||||||
name: String(raw?.name ?? '').trim(),
|
name: String(raw?.name ?? '').trim(),
|
||||||
dosage: Number(raw?.dosage) || 0,
|
dosage: Number(raw?.dosage) || 0,
|
||||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||||
}
|
}
|
||||||
|
if (raw?.locked === true || raw?.locked === 1) {
|
||||||
|
row.locked = true
|
||||||
|
}
|
||||||
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
|
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
|
||||||
@@ -1842,7 +1857,7 @@ const formData = reactive({
|
|||||||
creator_ids: [] as number[],
|
creator_ids: [] as number[],
|
||||||
/** all | passed | not_passed | pending | rejected(all 表示不限,接口传参会转为空) */
|
/** all | passed | not_passed | pending | rejected(all 表示不限,接口传参会转为空) */
|
||||||
audit_filter: 'all' as string,
|
audit_filter: 'all' as string,
|
||||||
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */
|
/** all | manual | system — 与 is_system_auto:手工(0) / 空白处方(1) */
|
||||||
source_filter: 'all' as string,
|
source_filter: 'all' as string,
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: ''
|
end_time: ''
|
||||||
@@ -2481,6 +2496,12 @@ const auxHerbRows = computed(() =>
|
|||||||
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
|
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整张处方是否被锁定:只要任一药材来自「禁用修改」处方库模板即锁定。
|
||||||
|
* 锁定后不可增删改任意药材,只能再次「从处方库导入」覆盖。
|
||||||
|
*/
|
||||||
|
const herbsLocked = computed(() => editForm.herbs.some((h) => (h as HerbRow).locked === true))
|
||||||
|
|
||||||
const slipDietaryText = computed(() => {
|
const slipDietaryText = computed(() => {
|
||||||
const d = slipView.value?.dietary_taboo
|
const d = slipView.value?.dietary_taboo
|
||||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||||
@@ -3441,10 +3462,18 @@ const handleImportLibrary = (row: any) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const formulaType = normalizeFormulaType(row.formula_type)
|
const formulaType = normalizeFormulaType(row.formula_type)
|
||||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
|
// 「禁用修改」模板:导入药材打上锁定标记 → 整张处方锁定(不可增删改,只能再次导入覆盖)
|
||||||
...normalizeHerbRow(h),
|
const isLockedTemplate = Number(row?.disable_edit) === 1
|
||||||
formula_type: formulaType
|
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
|
||||||
}))
|
const herb = normalizeHerbRow(h)
|
||||||
|
herb.formula_type = formulaType
|
||||||
|
if (isLockedTemplate) {
|
||||||
|
herb.locked = true
|
||||||
|
} else {
|
||||||
|
delete herb.locked
|
||||||
|
}
|
||||||
|
return herb
|
||||||
|
})
|
||||||
if (libraryImportMode.value === 'replace') {
|
if (libraryImportMode.value === 'replace') {
|
||||||
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||||
editForm.herbs = [...kept, ...imported]
|
editForm.herbs = [...kept, ...imported]
|
||||||
@@ -3650,6 +3679,10 @@ async function handlePasteRecipeImport() {
|
|||||||
|
|
||||||
// 添加药材(主方 / 辅方)
|
// 添加药材(主方 / 辅方)
|
||||||
const addHerb = (formulaType: FormulaType = '主方') => {
|
const addHerb = (formulaType: FormulaType = '主方') => {
|
||||||
|
if (herbsLocked.value) {
|
||||||
|
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
|
||||||
|
return
|
||||||
|
}
|
||||||
editForm.herbs.push({
|
editForm.herbs.push({
|
||||||
name: '',
|
name: '',
|
||||||
dosage: 0,
|
dosage: 0,
|
||||||
@@ -3659,6 +3692,10 @@ const addHerb = (formulaType: FormulaType = '主方') => {
|
|||||||
|
|
||||||
// 删除药材
|
// 删除药材
|
||||||
const removeHerb = (index: number) => {
|
const removeHerb = (index: number) => {
|
||||||
|
if (herbsLocked.value) {
|
||||||
|
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
editForm.herbs.splice(index, 1)
|
editForm.herbs.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,13 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="禁用修改" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.disable_edit ? 'danger' : 'info'">
|
||||||
|
{{ row.disable_edit ? '已禁用' : '可修改' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="创建人" prop="creator_name" min-width="100" />
|
<el-table-column label="创建人" prop="creator_name" min-width="100" />
|
||||||
<el-table-column label="创建时间" prop="create_time" min-width="160" />
|
<el-table-column label="创建时间" prop="create_time" min-width="160" />
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
@@ -193,6 +200,21 @@
|
|||||||
勾选后,所有医生都可以查看和使用此处方模板
|
勾选后,所有医生都可以查看和使用此处方模板
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="禁用修改" prop="disable_edit">
|
||||||
|
<div class="flex flex-col gap-1 leading-none">
|
||||||
|
<el-switch
|
||||||
|
v-model="editForm.disable_edit"
|
||||||
|
:active-value="1"
|
||||||
|
:inactive-value="0"
|
||||||
|
active-text="开启"
|
||||||
|
inactive-text="关闭"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-400 leading-relaxed">
|
||||||
|
开启后,导入此模板到处方时将锁定整张处方的药材,不可增删改,只能再次导入覆盖
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template #footer v-if="editMode !== 'view'">
|
<template #footer v-if="editMode !== 'view'">
|
||||||
@@ -236,7 +258,8 @@ const editForm = reactive({
|
|||||||
prescription_name: '',
|
prescription_name: '',
|
||||||
formula_type: '主方',
|
formula_type: '主方',
|
||||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||||
is_public: 0
|
is_public: 0,
|
||||||
|
disable_edit: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
@@ -293,6 +316,7 @@ const resetForm = () => {
|
|||||||
editForm.formula_type = '主方'
|
editForm.formula_type = '主方'
|
||||||
editForm.herbs = []
|
editForm.herbs = []
|
||||||
editForm.is_public = 0
|
editForm.is_public = 0
|
||||||
|
editForm.disable_edit = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增处方
|
// 新增处方
|
||||||
@@ -310,6 +334,7 @@ const handleView = (row: any) => {
|
|||||||
editForm.formula_type = row.formula_type || '主方'
|
editForm.formula_type = row.formula_type || '主方'
|
||||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||||
editForm.is_public = row.is_public ?? 0
|
editForm.is_public = row.is_public ?? 0
|
||||||
|
editForm.disable_edit = row.disable_edit ?? 0
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +346,7 @@ const handleEdit = (row: any) => {
|
|||||||
editForm.formula_type = row.formula_type || '主方'
|
editForm.formula_type = row.formula_type || '主方'
|
||||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||||
editForm.is_public = row.is_public ?? 0
|
editForm.is_public = row.is_public ?? 0
|
||||||
|
editForm.disable_edit = row.disable_edit ?? 0
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -231,8 +231,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import { adminLists } from '@/api/perms/admin'
|
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||||
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
dayjs.extend(isoWeek)
|
dayjs.extend(isoWeek)
|
||||||
@@ -491,10 +490,9 @@ async function removeSegment(row: RosterSegment) {
|
|||||||
|
|
||||||
const loadDoctors = async () => {
|
const loadDoctors = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await adminLists({
|
const res = await doctorLists({
|
||||||
page_no: 1,
|
page_no: 1,
|
||||||
page_size: 1000,
|
page_size: 1000
|
||||||
role_id: 1
|
|
||||||
})
|
})
|
||||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||||
doctorId: doctor.id,
|
doctorId: doctor.id,
|
||||||
|
|||||||
@@ -283,7 +283,15 @@
|
|||||||
<td class="col-name">{{ r.name }}</td>
|
<td class="col-name">{{ r.name }}</td>
|
||||||
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
|
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
|
||||||
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_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)">
|
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
|
||||||
<span
|
<span
|
||||||
v-if="Number(r.appointment_count ?? 0) > 0"
|
v-if="Number(r.appointment_count ?? 0) > 0"
|
||||||
@@ -390,7 +398,7 @@
|
|||||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||||
<b>tcm_diagnosis_assign_log</b> 行数(成功指派:<b>to_assistant_id > 0</b>),**仅按 <b>related_po_create_time</b>** 落在所选区间内(与日志表快照一致;诊单手动指派已写入该字段)。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
<b>tcm_diagnosis_assign_log</b> 中成功指派(<b>to_assistant_id > 0</b>,<b>剔除勾选「继承」的指派</b>)按<b>指派操作时间</b>落在所选区间内,「医助 × 诊单」去重、剔除已删诊单,与「复诊接诊率」页面同口径。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||||||
选定渠道时收窄与「{{ tb.channel_name }}」业绩同口径(标签诊单或 channels EXISTS)。<br />
|
选定渠道时收窄与「{{ tb.channel_name }}」业绩同口径(标签诊单或 channels EXISTS)。<br />
|
||||||
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>。
|
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>。
|
||||||
</template>
|
</template>
|
||||||
@@ -561,7 +569,13 @@
|
|||||||
>{{ formatInt(r.lead_count) }}</span>
|
>{{ formatInt(r.lead_count) }}</span>
|
||||||
<template v-else>{{ formatInt(r.lead_count) }}</template>
|
<template v-else>{{ formatInt(r.lead_count) }}</template>
|
||||||
</td>
|
</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)">
|
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
|
||||||
<span
|
<span
|
||||||
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
|
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
|
||||||
@@ -1356,6 +1370,59 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1376,6 +1443,7 @@ import {
|
|||||||
yejiStatsLeadLines,
|
yejiStatsLeadLines,
|
||||||
yejiStatsAppointmentLines,
|
yejiStatsAppointmentLines,
|
||||||
yejiStatsRevisitBreakdown,
|
yejiStatsRevisitBreakdown,
|
||||||
|
yejiStatsAssignLines,
|
||||||
} from '@/api/stats'
|
} from '@/api/stats'
|
||||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||||
import { prescriptionOrderLists } from '@/api/tcm'
|
import { prescriptionOrderLists } from '@/api/tcm'
|
||||||
@@ -1491,6 +1559,17 @@ interface YejiLeadLineRow {
|
|||||||
external_contact_name: string
|
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 {
|
interface YejiRow {
|
||||||
dept_id: number
|
dept_id: number
|
||||||
dept_name: string
|
dept_name: string
|
||||||
@@ -1709,6 +1788,31 @@ type AppointmentLinesCtx =
|
|||||||
}
|
}
|
||||||
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
|
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(() =>
|
const appointmentLinesShowChannelColumn = computed(() =>
|
||||||
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
|
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
|
||||||
)
|
)
|
||||||
@@ -3193,6 +3297,124 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
|||||||
void openLeadLinesDialog(tb, row)
|
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 }[] = [
|
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
|
||||||
{ key: 'event_time_text', label: '进线时间' },
|
{ key: 'event_time_text', label: '进线时间' },
|
||||||
{ key: 'reception_admin_name', label: '接待' },
|
{ key: 'reception_admin_name', label: '接待' },
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||||
<el-form-item label="日期范围">
|
<list-time-filter
|
||||||
<daterange-picker
|
v-model:time-type="time_type"
|
||||||
v-model:startTime="queryParams.start_date"
|
v-model:date-range="dateRange"
|
||||||
v-model:endTime="queryParams.end_date"
|
@time-type-change="onTimeTypeChange"
|
||||||
/>
|
@custom-change="onCustomDateChange"
|
||||||
</el-form-item>
|
/>
|
||||||
<el-form-item label="自媒体渠道">
|
<el-form-item label="自媒体渠道">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="queryParams.media_channel_code"
|
v-model="queryParams.media_channel_code"
|
||||||
@@ -67,11 +67,11 @@
|
|||||||
v-model="queryParams.remark"
|
v-model="queryParams.remark"
|
||||||
placeholder="备注 / 创建人 / 修改人"
|
placeholder="备注 / 创建人 / 修改人"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="resetPage"
|
@keyup.enter="handleQuery"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -148,7 +148,9 @@
|
|||||||
<script lang="ts" setup name="accountCostList">
|
<script lang="ts" setup name="accountCostList">
|
||||||
import { accountCostDelete, accountCostLists } from '@/api/finance'
|
import { accountCostDelete, accountCostLists } from '@/api/finance'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
|
|
||||||
import EditPopup from './edit.vue'
|
import EditPopup from './edit.vue'
|
||||||
|
|
||||||
@@ -172,18 +174,40 @@ const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
|||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
|
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
media_channel_code: '',
|
media_channel_code: '',
|
||||||
dept_id: '',
|
dept_id: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { pager, getLists, resetPage } = usePaging({
|
const {
|
||||||
fetchFun: accountCostLists,
|
time_type,
|
||||||
|
dateRange,
|
||||||
|
resolve,
|
||||||
|
handleTimeTypeChange,
|
||||||
|
handleCustomDateChange,
|
||||||
|
resetTimeFilter,
|
||||||
|
} = useListTimeFilter('today')
|
||||||
|
|
||||||
|
const { pager, getLists } = usePaging({
|
||||||
|
fetchFun: async (params: Record<string, any>) => {
|
||||||
|
const [start_date, end_date] = resolve()
|
||||||
|
return accountCostLists({
|
||||||
|
...params,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
})
|
||||||
|
},
|
||||||
params: queryParams,
|
params: queryParams,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
pager.page = 1
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
|
||||||
|
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
|
||||||
|
|
||||||
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
|
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
|
||||||
const options = pager.extend.media_channel_options
|
const options = pager.extend.media_channel_options
|
||||||
if (!Array.isArray(options)) return []
|
if (!Array.isArray(options)) return []
|
||||||
@@ -258,18 +282,27 @@ const handleDelete = async (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
queryParams.start_date = ''
|
resetTimeFilter()
|
||||||
queryParams.end_date = ''
|
|
||||||
queryParams.media_channel_code = ''
|
queryParams.media_channel_code = ''
|
||||||
queryParams.dept_id = ''
|
queryParams.dept_id = ''
|
||||||
queryParams.remark = ''
|
queryParams.remark = ''
|
||||||
resetPage()
|
pager.page = 1
|
||||||
|
pager.size = 15
|
||||||
|
getLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
getLists()
|
onMounted(() => {
|
||||||
|
getLists()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.account-cost-channel-select {
|
.account-cost-channel-select {
|
||||||
:deep(.channel-opt-row) {
|
:deep(.channel-opt-row) {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -174,9 +174,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="支付方式" width="100">
|
<el-table-column label="支付方式" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ getPaymentMethodText(row.payment_method) }}
|
{{ getCreateTypeText(row) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
编辑
|
编辑
|
||||||
</el-button>
|
</el-button>
|
||||||
<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']"
|
v-perms="['order.order/split']"
|
||||||
type="primary"
|
type="primary"
|
||||||
link
|
link
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
小程序码
|
小程序码
|
||||||
</el-button>
|
</el-button>
|
||||||
<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']"
|
v-perms="['order.order/pay']"
|
||||||
type="success"
|
type="success"
|
||||||
link
|
link
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
退款
|
退款
|
||||||
</el-button>
|
</el-button>
|
||||||
<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']"
|
v-perms="['order.order/cancel']"
|
||||||
type="danger"
|
type="danger"
|
||||||
link
|
link
|
||||||
@@ -327,7 +327,7 @@
|
|||||||
<span class="text-red-500 font-semibold">¥{{ detailData.amount }}</span>
|
<span class="text-red-500 font-semibold">¥{{ detailData.amount }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="支付方式">
|
<el-descriptions-item label="支付方式">
|
||||||
{{ getPaymentMethodText(detailData.payment_method) }}
|
{{ getCreateTypeText(detailData) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="支付时间">
|
<el-descriptions-item label="支付时间">
|
||||||
{{ detailData.payment_time || '-' }}
|
{{ detailData.payment_time || '-' }}
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
title="该单为付呗·待审核,请选择实际到账方式后确认「已支付」"
|
title="该单为待审核(付呗/快递代收),请选择实际到账方式后确认「已支付」"
|
||||||
/>
|
/>
|
||||||
<el-form-item v-if="!payForm.fubeiPendingAudit" label="支付类型" required>
|
<el-form-item v-if="!payForm.fubeiPendingAudit" label="支付类型" required>
|
||||||
<el-radio-group v-model="payForm.payType">
|
<el-radio-group v-model="payForm.payType">
|
||||||
@@ -502,6 +502,7 @@
|
|||||||
<el-radio label="normal">普通订单</el-radio>
|
<el-radio label="normal">普通订单</el-radio>
|
||||||
<el-radio label="wechat_work">企业微信对外收款</el-radio>
|
<el-radio label="wechat_work">企业微信对外收款</el-radio>
|
||||||
<el-radio label="fubei">付呗</el-radio>
|
<el-radio label="fubei">付呗</el-radio>
|
||||||
|
<el-radio label="express_cod">快递代收</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-alert
|
<el-alert
|
||||||
@@ -524,7 +525,20 @@
|
|||||||
<template #title>付呗</template>
|
<template #title>付呗</template>
|
||||||
通过付呗收款的支付单,创建后请按实际对账/审核流程在列表中处理。
|
通过付呗收款的支付单,创建后请按实际对账/审核流程在列表中处理。
|
||||||
</el-alert>
|
</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
|
<el-switch
|
||||||
v-model="createForm.requirePaymentSlipAudit"
|
v-model="createForm.requirePaymentSlipAudit"
|
||||||
active-text="申请审核"
|
active-text="申请审核"
|
||||||
@@ -980,8 +994,8 @@ const patientLoading = ref(false)
|
|||||||
const patientList = ref<any[]>([])
|
const patientList = ref<any[]>([])
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei',
|
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei' | 'express_cod',
|
||||||
/** 仅「付呗」:开启后订单为待审核(5) */
|
/** 「付呗」「快递代收」:开启后订单为待审核(5) */
|
||||||
requirePaymentSlipAudit: false,
|
requirePaymentSlipAudit: false,
|
||||||
patient_id: '',
|
patient_id: '',
|
||||||
order_type: '',
|
order_type: '',
|
||||||
@@ -1148,7 +1162,8 @@ const submitCreateOrder = async () => {
|
|||||||
patient_id: createForm.patient_id,
|
patient_id: createForm.patient_id,
|
||||||
order_type: createForm.order_type,
|
order_type: createForm.order_type,
|
||||||
amount: createForm.amount,
|
amount: createForm.amount,
|
||||||
remark: createForm.remark
|
remark: createForm.remark,
|
||||||
|
create_type: 'wechat_work'
|
||||||
}
|
}
|
||||||
const res: any = await orderCreateForWechatWork(params)
|
const res: any = await orderCreateForWechatWork(params)
|
||||||
const orderNo = res?.order_no ?? res?.data?.order_no
|
const orderNo = res?.order_no ?? res?.data?.order_no
|
||||||
@@ -1160,18 +1175,23 @@ const submitCreateOrder = async () => {
|
|||||||
patient_id: createForm.patient_id,
|
patient_id: createForm.patient_id,
|
||||||
order_type: createForm.order_type,
|
order_type: createForm.order_type,
|
||||||
amount: createForm.amount,
|
amount: createForm.amount,
|
||||||
remark: createForm.remark
|
remark: createForm.remark,
|
||||||
|
create_type: createForm.createType
|
||||||
}
|
}
|
||||||
if (createForm.createType === 'fubei') {
|
if (createForm.createType === 'fubei') {
|
||||||
params.payment_channel = 'fubei'
|
params.payment_channel = 'fubei'
|
||||||
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
|
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 {
|
} else {
|
||||||
params.payment_channel = 'normal'
|
params.payment_channel = 'normal'
|
||||||
params.require_payment_slip_audit = 0
|
params.require_payment_slip_audit = 0
|
||||||
}
|
}
|
||||||
await orderCreate(params)
|
await orderCreate(params)
|
||||||
const tip =
|
const tip =
|
||||||
createForm.createType === 'fubei' && createForm.requirePaymentSlipAudit
|
(createForm.createType === 'fubei' || createForm.createType === 'express_cod') &&
|
||||||
|
createForm.requirePaymentSlipAudit
|
||||||
? '订单已创建,支付状态为「待审核」'
|
? '订单已创建,支付状态为「待审核」'
|
||||||
: '订单创建成功'
|
: '订单创建成功'
|
||||||
feedback.msgSuccess(tip)
|
feedback.msgSuccess(tip)
|
||||||
@@ -1247,6 +1267,27 @@ const getPaymentMethodText = (method: string | null) => {
|
|||||||
return methodMap[method] || '未知'
|
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) => {
|
const handleEditOrder = (row: any) => {
|
||||||
editOrderForm.value = {
|
editOrderForm.value = {
|
||||||
@@ -1373,7 +1414,8 @@ const handleDetail = async (row: any) => {
|
|||||||
|
|
||||||
// 支付订单
|
// 支付订单
|
||||||
const handlePay = (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 = {
|
payForm.value = {
|
||||||
order_id: row.id,
|
order_id: row.id,
|
||||||
order_no: row.order_no,
|
order_no: row.order_no,
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="note-timeline-wrap">
|
<div class="note-timeline-wrap">
|
||||||
<div v-if="!readonly && diagnosisId" class="timeline-actions">
|
<div v-if="!readonly && diagnosisId" class="timeline-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="hasAddDoctorNotePermission"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
@click="showNoteDialog = true"
|
||||||
|
>
|
||||||
|
添加备注
|
||||||
|
</el-button>
|
||||||
<material-picker
|
<material-picker
|
||||||
v-model="tongueModel"
|
v-model="tongueModel"
|
||||||
:limit="99"
|
:limit="99"
|
||||||
@@ -95,16 +104,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!notes.length && readonly" description="暂无备注" :image-size="48" />
|
<el-empty v-if="!notes.length && readonly" description="暂无备注" :image-size="48" />
|
||||||
|
|
||||||
|
<el-dialog v-model="showNoteDialog" title="添加备注" width="480px" append-to-body>
|
||||||
|
<el-input
|
||||||
|
v-model="noteContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="输入今日备注..."
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showNoteDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="noteSaving" @click="handleSaveNote">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { CircleClose, Document } from '@element-plus/icons-vue'
|
import { CircleClose, Document } from '@element-plus/icons-vue'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import useAppStore from '@/stores/modules/app'
|
import useAppStore from '@/stores/modules/app'
|
||||||
import { addDoctorNote, deleteDoctorNoteImage } from '@/api/patient'
|
import { addDoctorNote, deleteDoctorNoteImage } from '@/api/patient'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
export interface DoctorNoteItem {
|
export interface DoctorNoteItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -122,6 +147,13 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{ refresh: [] }>()
|
const emit = defineEmits<{ refresh: [] }>()
|
||||||
|
|
||||||
|
/** 与接诊台悬浮「备注」按钮一致 */
|
||||||
|
const hasAddDoctorNotePermission = computed(() => hasPermission(['doctor.appointment/addDoctorNote']))
|
||||||
|
|
||||||
|
const showNoteDialog = ref(false)
|
||||||
|
const noteContent = ref('')
|
||||||
|
const noteSaving = ref(false)
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
||||||
|
|
||||||
@@ -201,6 +233,30 @@ watch(() => props.notes, () => {
|
|||||||
prevReportCount.value = 0
|
prevReportCount.value = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleSaveNote = async () => {
|
||||||
|
if (!props.diagnosisId) {
|
||||||
|
feedback.msgWarning('当前无诊单,无法添加备注')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = noteContent.value.trim()
|
||||||
|
if (!text) {
|
||||||
|
feedback.msgWarning('请输入备注内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteSaving.value = true
|
||||||
|
try {
|
||||||
|
await addDoctorNote({ diagnosis_id: props.diagnosisId, content: text })
|
||||||
|
feedback.msgSuccess('备注保存成功')
|
||||||
|
noteContent.value = ''
|
||||||
|
showNoteDialog.value = false
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e: any) {
|
||||||
|
feedback.msgError(e?.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
noteSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'report_files', imagePath: string) => {
|
const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'report_files', imagePath: string) => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' })
|
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' })
|
||||||
@@ -217,6 +273,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
|
|||||||
.timeline-actions {
|
.timeline-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auto-assign-log-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="log-filter-form">
|
||||||
|
<el-form-item label="执行日期">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
clearable
|
||||||
|
@change="doSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结果">
|
||||||
|
<el-select v-model="queryParams.action" clearable placeholder="全部" class="w-[120px]" @change="doSearch">
|
||||||
|
<el-option label="已分配" value="1" />
|
||||||
|
<el-option label="未分配" value="0" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="患者/手机号/医助/诊单ID"
|
||||||
|
clearable
|
||||||
|
class="w-[220px]"
|
||||||
|
@keyup.enter="doSearch"
|
||||||
|
@clear="doSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="doSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="log-hint">
|
||||||
|
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70% 每人2条/日,50%~60% 每人1条/日,<50% 不分)。每条待指派诊单一行,含分配 / 不分配原因。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-3" shadow="never">
|
||||||
|
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
|
||||||
|
<el-table-column label="记录时间" width="160">
|
||||||
|
<template #default="{ row }">{{ row.create_time_text || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="诊单" width="90" prop="diagnosis_id" />
|
||||||
|
<el-table-column label="患者" min-width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.patient_name || '—' }}</div>
|
||||||
|
<div class="cell-sub">{{ row.patient_phone || '' }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结果" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="Number(row.action) === 1 ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.action_text }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="医助" min-width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.assistant_id) > 0">
|
||||||
|
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
||||||
|
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="档位" width="100" align="center">
|
||||||
|
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="轮次" width="70" align="center">
|
||||||
|
<template #default="{ row }">{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="原因" min-width="360" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.reason }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="统计月" width="90" prop="stat_month" align="center" />
|
||||||
|
<el-table-column label="批次" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="cell-sub">{{ row.batch_no }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="statsAutoAssignLog">
|
||||||
|
import { autoAssignLogLists } from '@/api/stats'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
action: '' as '' | '0' | '1',
|
||||||
|
keyword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(dateRange, (val) => {
|
||||||
|
queryParams.start_date = val?.[0] ?? ''
|
||||||
|
queryParams.end_date = val?.[1] ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage } = usePaging({
|
||||||
|
fetchFun: autoAssignLogLists,
|
||||||
|
params: queryParams
|
||||||
|
})
|
||||||
|
|
||||||
|
const doSearch = () => resetPage()
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
dateRange.value = null
|
||||||
|
queryParams.start_date = ''
|
||||||
|
queryParams.end_date = ''
|
||||||
|
queryParams.action = ''
|
||||||
|
queryParams.keyword = ''
|
||||||
|
resetPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
onMounted(() => getLists())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.auto-assign-log-page {
|
||||||
|
.log-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>诊单金额占比</span>
|
<span>{{ amountPieTitle }}</span>
|
||||||
<span class="card-hint">TOP 10</span>
|
<span class="card-hint">TOP 10</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -146,17 +146,17 @@
|
|||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>加粉占比</span>
|
<span>{{ secondaryPieTitle }}</span>
|
||||||
<span class="card-hint">TOP 10</span>
|
<span class="card-hint">TOP 10</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<v-charts
|
<v-charts
|
||||||
v-if="fanPieHasData"
|
v-if="secondaryPieHasData"
|
||||||
class="stats-chart"
|
class="stats-chart"
|
||||||
:option="fanPieOption"
|
:option="secondaryPieOption"
|
||||||
autoresize
|
autoresize
|
||||||
/>
|
/>
|
||||||
<el-empty v-else description="暂无加粉数据" />
|
<el-empty v-else :description="secondaryPieEmptyText" />
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -311,10 +311,13 @@ const overview = reactive<Record<string, any>>({
|
|||||||
amounts: [],
|
amounts: [],
|
||||||
order_counts: [],
|
order_counts: [],
|
||||||
fan_counts: [],
|
fan_counts: [],
|
||||||
rois: []
|
rois: [],
|
||||||
|
appointment_counts: [],
|
||||||
|
interview_counts: []
|
||||||
},
|
},
|
||||||
amount_share: [],
|
amount_share: [],
|
||||||
fan_share: []
|
fan_share: [],
|
||||||
|
order_share: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -392,7 +395,7 @@ const currentEntityId = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const summaryCards: MetricCard[] = [
|
const defaultSummaryCards: MetricCard[] = [
|
||||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
@@ -410,7 +413,15 @@ const summaryCards: MetricCard[] = [
|
|||||||
{ key: 'roi', label: 'ROI', type: 'ratio' }
|
{ key: 'roi', label: 'ROI', type: 'ratio' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const tableColumns: MetricColumn[] = [
|
const doctorSummaryCards: MetricCard[] = [
|
||||||
|
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||||
|
{ key: 'completed_order_amount', label: '开药金额', type: 'money' },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultTableColumns: MetricColumn[] = [
|
||||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
|
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
|
||||||
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
|
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
|
||||||
@@ -431,6 +442,18 @@ const tableColumns: MetricColumn[] = [
|
|||||||
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
|
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const doctorTableColumns: MetricColumn[] = [
|
||||||
|
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||||
|
{ key: 'completed_order_amount', label: '开药金额', type: 'money', minWidth: 120 },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const isDoctorDimension = computed(() => queryParams.dimension === 'doctor')
|
||||||
|
const summaryCards = computed(() => isDoctorDimension.value ? doctorSummaryCards : defaultSummaryCards)
|
||||||
|
const tableColumns = computed(() => isDoctorDimension.value ? doctorTableColumns : defaultTableColumns)
|
||||||
|
|
||||||
const dateRangeText = computed(() => {
|
const dateRangeText = computed(() => {
|
||||||
if (!overview.date_range?.length) return '未选择'
|
if (!overview.date_range?.length) return '未选择'
|
||||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
@@ -438,7 +461,14 @@ const dateRangeText = computed(() => {
|
|||||||
|
|
||||||
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
|
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
|
||||||
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
|
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
|
||||||
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
|
const secondaryPieHasData = computed(() =>
|
||||||
|
isDoctorDimension.value
|
||||||
|
? (overview.charts.order_share?.length || 0) > 0
|
||||||
|
: (overview.charts.fan_share?.length || 0) > 0
|
||||||
|
)
|
||||||
|
const amountPieTitle = computed(() => isDoctorDimension.value ? '开药金额占比' : '诊单金额占比')
|
||||||
|
const secondaryPieTitle = computed(() => isDoctorDimension.value ? '接诊单数占比' : '加粉占比')
|
||||||
|
const secondaryPieEmptyText = computed(() => isDoctorDimension.value ? '暂无接诊数据' : '暂无加粉数据')
|
||||||
|
|
||||||
const rankingChartData = computed(() => {
|
const rankingChartData = computed(() => {
|
||||||
const ranking = overview.charts?.ranking || {}
|
const ranking = overview.charts?.ranking || {}
|
||||||
@@ -448,56 +478,110 @@ const rankingChartData = computed(() => {
|
|||||||
return {
|
return {
|
||||||
names,
|
names,
|
||||||
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
|
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
|
||||||
|
appointmentCounts: normalize(Array.isArray(ranking.appointment_counts) ? ranking.appointment_counts : []),
|
||||||
|
interviewCounts: normalize(Array.isArray(ranking.interview_counts) ? ranking.interview_counts : []),
|
||||||
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
|
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
|
||||||
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
|
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const rankingChartOption = computed(() => ({
|
const rankingChartOption = computed(() => {
|
||||||
tooltip: { trigger: 'axis' },
|
const common = {
|
||||||
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
tooltip: { trigger: 'axis' },
|
||||||
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: rankingChartData.value.names,
|
data: rankingChartData.value.names,
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
interval: 0,
|
interval: 0,
|
||||||
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||||
}
|
}
|
||||||
},
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: '金额 / 数量' }
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '诊单金额',
|
|
||||||
type: 'bar',
|
|
||||||
barMaxWidth: 36,
|
|
||||||
data: rankingChartData.value.amounts,
|
|
||||||
itemStyle: { color: '#4a78ff' }
|
|
||||||
},
|
},
|
||||||
{
|
yAxis: [
|
||||||
name: '接诊诊单',
|
{ type: 'value', name: '金额 / 数量' }
|
||||||
type: 'line',
|
]
|
||||||
smooth: true,
|
}
|
||||||
showSymbol: true,
|
|
||||||
symbolSize: 8,
|
if (!isDoctorDimension.value) {
|
||||||
lineStyle: { width: 3 },
|
return {
|
||||||
data: rankingChartData.value.orderCounts,
|
...common,
|
||||||
itemStyle: { color: '#15b8a6' }
|
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||||
},
|
series: [
|
||||||
{
|
{
|
||||||
name: '加粉数',
|
name: '诊单金额',
|
||||||
type: 'line',
|
type: 'bar',
|
||||||
smooth: true,
|
barMaxWidth: 36,
|
||||||
showSymbol: true,
|
data: rankingChartData.value.amounts,
|
||||||
symbolSize: 8,
|
itemStyle: { color: '#4a78ff' }
|
||||||
lineStyle: { width: 3 },
|
},
|
||||||
data: rankingChartData.value.fanCounts,
|
{
|
||||||
itemStyle: { color: '#ff9f43' }
|
name: '接诊诊单',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.orderCounts,
|
||||||
|
itemStyle: { color: '#15b8a6' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '加粉数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.fanCounts,
|
||||||
|
itemStyle: { color: '#ff9f43' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}))
|
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
legend: { data: ['开药金额', '挂号数', '面诊数', '接诊单数'] },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '开药金额',
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 36,
|
||||||
|
data: rankingChartData.value.amounts,
|
||||||
|
itemStyle: { color: '#4a78ff' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '挂号数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.appointmentCounts,
|
||||||
|
itemStyle: { color: '#15b8a6' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '面诊数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.interviewCounts,
|
||||||
|
itemStyle: { color: '#ff9f43' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '接诊单数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.orderCounts,
|
||||||
|
itemStyle: { color: '#7c3aed' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const amountPieOption = computed(() => ({
|
const amountPieOption = computed(() => ({
|
||||||
tooltip: { trigger: 'item' },
|
tooltip: { trigger: 'item' },
|
||||||
@@ -511,7 +595,7 @@ const amountPieOption = computed(() => ({
|
|||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '诊单金额',
|
name: amountPieTitle.value,
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['42%', '70%'],
|
radius: ['42%', '70%'],
|
||||||
center: ['50%', '42%'],
|
center: ['50%', '42%'],
|
||||||
@@ -531,7 +615,7 @@ const amountPieOption = computed(() => ({
|
|||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const fanPieOption = computed(() => ({
|
const secondaryPieOption = computed(() => ({
|
||||||
tooltip: { trigger: 'item' },
|
tooltip: { trigger: 'item' },
|
||||||
legend: {
|
legend: {
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -543,7 +627,7 @@ const fanPieOption = computed(() => ({
|
|||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '加粉数',
|
name: secondaryPieTitle.value,
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['42%', '70%'],
|
radius: ['42%', '70%'],
|
||||||
center: ['50%', '42%'],
|
center: ['50%', '42%'],
|
||||||
@@ -558,7 +642,7 @@ const fanPieOption = computed(() => ({
|
|||||||
labelLayout: {
|
labelLayout: {
|
||||||
hideOverlap: true,
|
hideOverlap: true,
|
||||||
},
|
},
|
||||||
data: overview.charts.fan_share
|
data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
@@ -658,9 +742,18 @@ const handleReset = () => {
|
|||||||
fetchOverview()
|
fetchOverview()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const calcDoctorReceiveRate = (source: Record<string, any>) => {
|
||||||
|
const completedOrderCount = Number(source?.completed_order_count || 0)
|
||||||
|
const interviewCount = Number(source?.interview_count || 0)
|
||||||
|
if (interviewCount <= 0) return 0
|
||||||
|
return (completedOrderCount / interviewCount) * 100
|
||||||
|
}
|
||||||
|
|
||||||
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
|
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
|
||||||
if (placeholder) return '—'
|
if (placeholder) return '—'
|
||||||
const value = source?.[key] ?? 0
|
const value = isDoctorDimension.value && key === 'receive_rate'
|
||||||
|
? calcDoctorReceiveRate(source)
|
||||||
|
: source?.[key] ?? 0
|
||||||
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||||
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||||
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||||
|
|||||||
@@ -0,0 +1,644 @@
|
|||||||
|
<template>
|
||||||
|
<div class="revisit-rate-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="rate-filter-form">
|
||||||
|
<el-form-item label="统计月份">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="month"
|
||||||
|
type="month"
|
||||||
|
value-format="YYYY-MM"
|
||||||
|
placeholder="选择月份"
|
||||||
|
:clearable="false"
|
||||||
|
:disabled-date="disableFutureMonth"
|
||||||
|
style="width: 160px"
|
||||||
|
@change="loadData"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="deptId"
|
||||||
|
:data="deptTreeOptions"
|
||||||
|
placeholder="二中心(全部)"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
check-strictly
|
||||||
|
node-key="id"
|
||||||
|
:default-expand-all="true"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
style="width: 220px"
|
||||||
|
@change="loadData"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||||
|
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
||||||
|
<template #reference>
|
||||||
|
<span class="rate-caliber-trigger">
|
||||||
|
<el-icon><InfoFilled /></el-icon>
|
||||||
|
统计口径
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div class="rate-caliber">
|
||||||
|
<p>
|
||||||
|
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="rate-kpi-grid">
|
||||||
|
<div
|
||||||
|
v-for="card in summaryCards"
|
||||||
|
:key="card.key"
|
||||||
|
class="rate-kpi-card"
|
||||||
|
:class="{ 'is-clickable': card.clickable }"
|
||||||
|
@click="card.clickable && card.onClick && card.onClick()"
|
||||||
|
>
|
||||||
|
<div class="rate-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="rate-kpi-value">{{ card.value }}</div>
|
||||||
|
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-4" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="rate-card-header">
|
||||||
|
<span>部门 · 医助明细({{ monthText }})</span>
|
||||||
|
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="rows"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
row-key="row_key"
|
||||||
|
:tree-props="{ children: 'children' }"
|
||||||
|
:default-expand-all="true"
|
||||||
|
show-summary
|
||||||
|
:summary-method="getSummaries"
|
||||||
|
>
|
||||||
|
<el-table-column label="部门 / 医助" min-width="200" fixed>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.is_dept" class="rate-dept-cell">
|
||||||
|
{{ row.dept_name }}
|
||||||
|
<span class="rate-dept-count">{{ row.assistant_count }} 人</span>
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ row.assistant_name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row)"
|
||||||
|
>
|
||||||
|
{{ row.assigned_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.assigned_count }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template v-for="slot in visitSlots" :key="slot">
|
||||||
|
<el-table-column
|
||||||
|
:prop="`visit${slot}_count`"
|
||||||
|
:label="`${slotLabel(slot)}单数`"
|
||||||
|
min-width="96"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ slotLabel(slot) }}单数</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
:content="`当月内下单、诊次为第 ${slot} 次的订单数(诊次跨月累计;归属下单时点持有患者的医助)`"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderDetail(slot, row)"
|
||||||
|
>
|
||||||
|
{{ row[`visit${slot}_count`] }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row[`visit${slot}_count`] }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
:prop="`visit${slot}_rate`"
|
||||||
|
:label="`${slotLabel(slot)}接诊率`"
|
||||||
|
min-width="96"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 下钻明细 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailVisible"
|
||||||
|
:title="detailTitle"
|
||||||
|
width="980px"
|
||||||
|
top="6vh"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-table
|
||||||
|
v-loading="detailLoading"
|
||||||
|
:data="detailRows"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
max-height="60vh"
|
||||||
|
>
|
||||||
|
<template v-if="detailType === 'assign'">
|
||||||
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openPatientDetail(row.diagnosis_id)"
|
||||||
|
>
|
||||||
|
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="patient_phone" label="联系电话" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||||
|
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderDetail && row.order_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderInfo(row.order_id)"
|
||||||
|
>
|
||||||
|
{{ row.order_no || `#${row.order_id}` }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openPatientDetail(row.diagnosis_id)"
|
||||||
|
>
|
||||||
|
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="amount" label="金额" width="100" align="right">
|
||||||
|
<template #default="{ row }">¥ {{ row.amount }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
|
||||||
|
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
|
||||||
|
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
<div class="rate-detail-footer">共 {{ detailRows.length }} 条</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 订单详情抽屉(共享组件,与处方订单列表详情一致;只读) -->
|
||||||
|
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
|
||||||
|
|
||||||
|
<!-- 诊单(患者)详情:编辑页只读模式 -->
|
||||||
|
<diagnosis-edit-popup ref="diagnosisEditRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="revisitRateStatsPage">
|
||||||
|
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
revisitRateAssignLines,
|
||||||
|
revisitRateDeptOptions,
|
||||||
|
revisitRateOverview,
|
||||||
|
revisitRateVisitOrderLines
|
||||||
|
} from '@/api/stats'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
|
/** 业务订单详情抽屉(共享组件,readonly 模式,与 order_list.vue 详情同源) */
|
||||||
|
const OrderDetailDrawer = defineAsyncComponent(
|
||||||
|
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
|
||||||
|
)
|
||||||
|
/** 诊单编辑弹窗(只读打开) */
|
||||||
|
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
|
interface MetricPack extends Record<string, any> {
|
||||||
|
assigned_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsRow extends MetricPack {
|
||||||
|
row_key: string
|
||||||
|
is_dept: 0 | 1
|
||||||
|
dept_id: number
|
||||||
|
dept_name: string
|
||||||
|
assistant_id?: number
|
||||||
|
assistant_name?: string
|
||||||
|
assistant_count?: number
|
||||||
|
children?: StatsRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeptTreeNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
pid: number
|
||||||
|
children?: DeptTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const canViewAssignLines = hasPermission(['stats.revisitRate/assignLines'])
|
||||||
|
const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
||||||
|
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||||
|
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||||
|
|
||||||
|
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
||||||
|
const visitSlots = ref<number[]>([2, 3, 4])
|
||||||
|
|
||||||
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot >= 2 && slot <= 10) {
|
||||||
|
return `${cnDigits[slot]}诊`
|
||||||
|
}
|
||||||
|
if (slot > 10 && slot < 20) {
|
||||||
|
return `十${cnDigits[slot - 10]}诊`
|
||||||
|
}
|
||||||
|
return `${slot}诊`
|
||||||
|
}
|
||||||
|
|
||||||
|
const initNow = new Date()
|
||||||
|
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const month = ref<string>(currentMonth)
|
||||||
|
const deptId = ref<number | undefined>(undefined)
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<StatsRow[]>([])
|
||||||
|
const total = ref<MetricPack | null>(null)
|
||||||
|
|
||||||
|
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||||
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const monthText = computed(() => {
|
||||||
|
const [y, m] = month.value.split('-')
|
||||||
|
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
const baseQuery = () => ({
|
||||||
|
month: month.value,
|
||||||
|
dept_ids: deptId.value ? String(deptId.value) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
interface SummaryCard {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sub: string
|
||||||
|
clickable: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryCards = computed<SummaryCard[]>(() => {
|
||||||
|
const t = total.value
|
||||||
|
const cards: SummaryCard[] = [
|
||||||
|
{
|
||||||
|
key: 'assigned',
|
||||||
|
label: '当月被指派总数',
|
||||||
|
value: t ? String(t.assigned_count) : '—',
|
||||||
|
sub: '',
|
||||||
|
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||||
|
onClick: () => openAssignDetail(null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
for (const slot of visitSlots.value) {
|
||||||
|
const cnt = t ? t[`visit${slot}_count`] : null
|
||||||
|
const rate = t ? t[`visit${slot}_rate`] : null
|
||||||
|
cards.push({
|
||||||
|
key: `visit${slot}`,
|
||||||
|
label: `当月${slotLabel(slot)}接诊率`,
|
||||||
|
value: t ? formatRate(rate) : '—',
|
||||||
|
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
||||||
|
clickable: !!(canViewOrderLines && cnt > 0),
|
||||||
|
onClick: () => openOrderDetail(slot, null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 底栏合计:直接展示后端按诊单去重后的合计,不做各行求和 */
|
||||||
|
const getSummaries = ({ columns }: { columns: any[] }) => {
|
||||||
|
const t = total.value
|
||||||
|
return columns.map((col, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return '合计'
|
||||||
|
}
|
||||||
|
if (!t) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const prop = String(col.property || '')
|
||||||
|
if (prop === 'assigned_count') {
|
||||||
|
return String(t.assigned_count)
|
||||||
|
}
|
||||||
|
if (/^visit\d+_count$/.test(prop)) {
|
||||||
|
return String(t[prop] ?? '')
|
||||||
|
}
|
||||||
|
if (/^visit\d+_rate$/.test(prop)) {
|
||||||
|
return formatRate(t[prop])
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableFutureMonth = (date: Date) => {
|
||||||
|
const now = new Date()
|
||||||
|
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await revisitRateOverview(baseQuery())
|
||||||
|
rows.value = res?.rows ?? []
|
||||||
|
total.value = res?.total ?? null
|
||||||
|
if (Array.isArray(res?.slots) && res.slots.length) {
|
||||||
|
visitSlots.value = res.slots.map((s: any) => Number(s)).filter((s: number) => s >= 2)
|
||||||
|
}
|
||||||
|
if (res?.month) {
|
||||||
|
month.value = res.month
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||||
|
const map = new Map<number, DeptTreeNode>()
|
||||||
|
for (const d of flat) {
|
||||||
|
map.set(d.id, { ...d, children: [] })
|
||||||
|
}
|
||||||
|
const roots: DeptTreeNode[] = []
|
||||||
|
for (const d of flat) {
|
||||||
|
const node = map.get(d.id)!
|
||||||
|
if (d.pid > 0 && map.has(d.pid)) {
|
||||||
|
map.get(d.pid)!.children!.push(node)
|
||||||
|
} else {
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strip = (n: DeptTreeNode) => {
|
||||||
|
if (n.children?.length) {
|
||||||
|
n.children.forEach(strip)
|
||||||
|
} else {
|
||||||
|
delete n.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.forEach(strip)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDeptOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await revisitRateDeptOptions()
|
||||||
|
deptTreeOptions.value = buildDeptTree(res?.rows ?? [])
|
||||||
|
} catch {
|
||||||
|
deptTreeOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 下钻明细 ──────────────────────────────────────────
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailType = ref<'assign' | 'orders'>('assign')
|
||||||
|
const detailTitle = ref('')
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailRows = ref<any[]>([])
|
||||||
|
|
||||||
|
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||||
|
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||||
|
if (!row) {
|
||||||
|
return { label: '合计' }
|
||||||
|
}
|
||||||
|
if (row.is_dept) {
|
||||||
|
return { dept_id: row.dept_id, label: row.dept_name }
|
||||||
|
}
|
||||||
|
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAssignDetail = async (row: StatsRow | null) => {
|
||||||
|
if (!canViewAssignLines) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scope = rowScope(row)
|
||||||
|
detailType.value = 'assign'
|
||||||
|
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
detailRows.value = []
|
||||||
|
try {
|
||||||
|
const res = await revisitRateAssignLines({
|
||||||
|
...baseQuery(),
|
||||||
|
assistant_id: scope.assistant_id,
|
||||||
|
dept_id: scope.dept_id
|
||||||
|
})
|
||||||
|
detailRows.value = res?.rows ?? []
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||||
|
if (!canViewOrderLines) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scope = rowScope(row)
|
||||||
|
detailType.value = 'orders'
|
||||||
|
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
detailRows.value = []
|
||||||
|
try {
|
||||||
|
const res = await revisitRateVisitOrderLines({
|
||||||
|
...baseQuery(),
|
||||||
|
slot,
|
||||||
|
assistant_id: scope.assistant_id,
|
||||||
|
dept_id: scope.dept_id
|
||||||
|
})
|
||||||
|
detailRows.value = res?.rows ?? []
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 订单详情(复用处方订单列表的详情抽屉) ──────────────
|
||||||
|
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
|
||||||
|
|
||||||
|
const openOrderInfo = (orderId: number) => {
|
||||||
|
if (!canViewOrderDetail || !orderId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderDetailRef.value?.open(orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 患者(诊单)详情:诊单编辑页只读打开 ────────────────
|
||||||
|
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
|
||||||
|
|
||||||
|
const openPatientDetail = (diagnosisId: number) => {
|
||||||
|
if (!canViewPatientDetail || !diagnosisId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
diagnosisEditRef.value?.openViewOnly(diagnosisId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
month.value = currentMonth
|
||||||
|
deptId.value = undefined
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDeptOptions()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.revisit-rate-page {
|
||||||
|
.rate-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-caliber-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-card {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
|
||||||
|
&.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-label {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-kpi-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.rate-card-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-dept-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.rate-dept-count {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-th-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-detail-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-caliber {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
|
||||||
|
p + p {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,13 +14,14 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="!border-none" shadow="never">
|
<el-card class="!border-none" shadow="never">
|
||||||
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||||
<el-form-item label="日期范围">
|
<list-time-filter
|
||||||
<daterange-picker
|
v-model:time-type="time_type"
|
||||||
v-model:startTime="queryParams.start_date"
|
v-model:date-range="dateRange"
|
||||||
v-model:endTime="queryParams.end_date"
|
@time-type-change="onTimeTypeChange"
|
||||||
/>
|
@custom-change="onCustomDateChange"
|
||||||
</el-form-item>
|
/>
|
||||||
|
|
||||||
<el-form-item label="部门">
|
<el-form-item label="部门">
|
||||||
<el-tree-select
|
<el-tree-select
|
||||||
v-model="queryParams.dept_id"
|
v-model="queryParams.dept_id"
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
clearable
|
clearable
|
||||||
filterable
|
filterable
|
||||||
class="w-[220px]"
|
class="w-[220px]"
|
||||||
@change="resetPage"
|
@change="handleQuery"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="自媒体来源">
|
<el-form-item label="自媒体来源">
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
placeholder="全部来源"
|
placeholder="全部来源"
|
||||||
:allow-create="false"
|
:allow-create="false"
|
||||||
select-class="w-[220px]"
|
select-class="w-[220px]"
|
||||||
@change="resetPage"
|
@change="handleQuery"
|
||||||
@visible-change="onMediaSourceDropdownVisible"
|
@visible-change="onMediaSourceDropdownVisible"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -53,11 +54,11 @@
|
|||||||
v-model="queryParams.remark"
|
v-model="queryParams.remark"
|
||||||
placeholder="备注 / 创建人"
|
placeholder="备注 / 创建人"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="resetPage"
|
@keyup.enter="handleQuery"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -146,6 +147,8 @@ import feedback from '@/utils/feedback'
|
|||||||
import CostEditPopup from './cost-edit.vue'
|
import CostEditPopup from './cost-edit.vue'
|
||||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||||
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||||
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
|
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
|
||||||
@@ -173,25 +176,48 @@ const goSelfInput = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
media_source: '',
|
media_source: '',
|
||||||
dept_id: undefined as number | undefined,
|
dept_id: undefined as number | undefined,
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
time_type,
|
||||||
|
dateRange,
|
||||||
|
resolve,
|
||||||
|
handleTimeTypeChange,
|
||||||
|
handleCustomDateChange,
|
||||||
|
resetTimeFilter,
|
||||||
|
} = useListTimeFilter('today')
|
||||||
|
|
||||||
const { pager, getLists, resetPage } = usePaging({
|
const { pager, getLists, resetPage } = usePaging({
|
||||||
fetchFun: personalAccountCostLists,
|
fetchFun: async (params: Record<string, any>) => {
|
||||||
|
const [start_date, end_date] = resolve()
|
||||||
|
return personalAccountCostLists({
|
||||||
|
...params,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
})
|
||||||
|
},
|
||||||
params: queryParams,
|
params: queryParams,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
pager.page = 1
|
||||||
|
getLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
|
||||||
|
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
queryParams.start_date = ''
|
resetTimeFilter()
|
||||||
queryParams.end_date = ''
|
|
||||||
queryParams.media_source = ''
|
queryParams.media_source = ''
|
||||||
queryParams.dept_id = undefined
|
queryParams.dept_id = undefined
|
||||||
queryParams.remark = ''
|
queryParams.remark = ''
|
||||||
resetPage()
|
pager.page = 1
|
||||||
|
pager.size = 15
|
||||||
|
getLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||||
@@ -236,9 +262,10 @@ const loadDeptOptions = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadDeptOptions()
|
loadDeptOptions()
|
||||||
loadMediaSourceOptions()
|
loadMediaSourceOptions()
|
||||||
|
await getLists()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -247,6 +274,12 @@ onMounted(() => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dept-cell {
|
.dept-cell {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
border-bottom: 1px dashed #c0c4cc;
|
border-bottom: 1px dashed #c0c4cc;
|
||||||
|
|||||||
@@ -83,6 +83,23 @@
|
|||||||
<el-radio-button value="0">未确认</el-radio-button>
|
<el-radio-button value="0">未确认</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -542,6 +559,7 @@
|
|||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||||
|
import { deptAll } from '@/api/org/department'
|
||||||
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
import { addDoctorNote } from '@/api/patient'
|
import { addDoctorNote } from '@/api/patient'
|
||||||
@@ -603,10 +621,19 @@ const formData = reactive({
|
|||||||
end_date: '',
|
end_date: '',
|
||||||
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
|
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
|
||||||
diagnosis_confirmed: '' as '' | '0' | '1', // ''=全部 1=已确认 0=未确认
|
diagnosis_confirmed: '' as '' | '0' | '1', // ''=全部 1=已确认 0=未确认
|
||||||
|
/** 接诊医生 / 诊单医助 / 挂号医助所属部门(选父级含子级) */
|
||||||
|
assistant_dept_id: '' as number | '',
|
||||||
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
|
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
|
||||||
include_status_counts: 0 as 0 | 1
|
include_status_counts: 0 as 0 | 1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const departmentTreeRaw = ref<unknown[]>([])
|
||||||
|
const assistantDeptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children'
|
||||||
|
}
|
||||||
|
|
||||||
const activeTab = ref('1')
|
const activeTab = ref('1')
|
||||||
const dateCustomVisible = ref(false)
|
const dateCustomVisible = ref(false)
|
||||||
const statusCount = ref<Record<number, number>>({
|
const statusCount = ref<Record<number, number>>({
|
||||||
@@ -721,6 +748,12 @@ const handleDiagnosisConfirmedChange = () => {
|
|||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 部门筛选变更
|
||||||
|
const handleAssistantDeptChange = () => {
|
||||||
|
pager.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
// 快捷日期变更
|
// 快捷日期变更
|
||||||
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
|
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
|
||||||
const v = String(val || '')
|
const v = String(val || '')
|
||||||
@@ -770,6 +803,7 @@ const handleReset = () => {
|
|||||||
formData.doctor_name = ''
|
formData.doctor_name = ''
|
||||||
formData.date_preset = 'today'
|
formData.date_preset = 'today'
|
||||||
formData.diagnosis_confirmed = ''
|
formData.diagnosis_confirmed = ''
|
||||||
|
formData.assistant_dept_id = ''
|
||||||
const t = new Date()
|
const t = new Date()
|
||||||
const p = (n: number) => String(n).padStart(2, '0')
|
const p = (n: number) => String(n).padStart(2, '0')
|
||||||
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
|
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.end_date = formData.start_date
|
||||||
formData.status = 1
|
formData.status = 1
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const deptTree = await deptAll()
|
||||||
|
departmentTreeRaw.value = Array.isArray(deptTree) ? deptTree : []
|
||||||
|
} catch {
|
||||||
|
departmentTreeRaw.value = []
|
||||||
|
}
|
||||||
loadData()
|
loadData()
|
||||||
listPollTimer = setInterval(() => {
|
listPollTimer = setInterval(() => {
|
||||||
loadData({ silent: true })
|
loadData({ silent: true })
|
||||||
@@ -1228,6 +1268,10 @@ onUnmounted(() => {
|
|||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-dept-select {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -556,7 +556,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||||
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
if (!value) {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||||
callback(new Error('身份证号格式不正确'))
|
callback(new Error('身份证号格式不正确'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -572,6 +576,7 @@ const formRules = {
|
|||||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
||||||
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
|
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
|
||||||
|
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
|
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,43 @@
|
|||||||
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
|
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<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
|
<el-table
|
||||||
v-loading="pager.loading"
|
v-loading="pager.loading"
|
||||||
:data="pager.lists"
|
:data="pager.lists"
|
||||||
@@ -12,6 +49,23 @@
|
|||||||
empty-text="暂无业务订单"
|
empty-text="暂无业务订单"
|
||||||
>
|
>
|
||||||
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
|
<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">
|
<el-table-column label="金额" width="120" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
|
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
|
||||||
@@ -56,298 +110,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 业务订单详情抽屉(只读,与 order_list.vue 详情保持一致) -->
|
<!-- 业务订单详情抽屉:共享组件的 readonly 受限版(展示范围由组件内 v-if="!readonly" 门控) -->
|
||||||
<el-drawer
|
<prescription-order-detail-drawer ref="detailDrawerRef" readonly append-to-body />
|
||||||
v-model="detailVisible"
|
|
||||||
size="80%"
|
|
||||||
destroy-on-close
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
append-to-body
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="font-semibold text-lg">业务订单详情</span>
|
|
||||||
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
|
|
||||||
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
|
|
||||||
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
|
|
||||||
|
|
||||||
<!-- 顶部数据概览 -->
|
|
||||||
<el-row :gutter="16" class="mb-5">
|
|
||||||
<el-col :xs="12" :sm="6">
|
|
||||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
|
||||||
<div class="text-gray-500 text-xs mb-1">总金额</div>
|
|
||||||
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="12" :sm="6">
|
|
||||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
|
||||||
<div class="text-gray-500 text-xs mb-1">已付总额</div>
|
|
||||||
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="12" :sm="6">
|
|
||||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
|
||||||
<div class="text-gray-500 text-xs mb-1">需代收</div>
|
|
||||||
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :xs="12" :sm="6">
|
|
||||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
|
||||||
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
|
|
||||||
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} 笔</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
||||||
<!-- 左侧:处方单(不含测试价格、改患者信息、药材明细) -->
|
|
||||||
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
|
|
||||||
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
|
|
||||||
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="font-medium text-[15px]">处方详情</span>
|
|
||||||
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
|
|
||||||
开方人:<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-if="detailPrescription">
|
|
||||||
<el-descriptions :column="2" border size="small" class="bg-white">
|
|
||||||
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}岁</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="手机">
|
|
||||||
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
|
|
||||||
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
|
|
||||||
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
|
|
||||||
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="处方金额">
|
|
||||||
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="消费者审核">
|
|
||||||
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="用量">
|
|
||||||
<template v-if="detailPrescription.dosage_amount">
|
|
||||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
|
||||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
|
||||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
|
||||||
</template>
|
|
||||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
|
||||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>—</template>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="服用方式">
|
|
||||||
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">每天次数:</span>
|
|
||||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">处方开立:</span>
|
|
||||||
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">订单设置:</span>
|
|
||||||
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="状态" :span="2">
|
|
||||||
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</template>
|
|
||||||
<el-empty v-else description="无处方数据" :image-size="72" />
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 右侧:未关联 + 已关联支付单 -->
|
|
||||||
<div class="flex flex-col gap-4 h-full">
|
|
||||||
<el-card shadow="never" class="border-gray-100">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-medium text-[15px]">未关联的收款记录</span>
|
|
||||||
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
|
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
|
||||||
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
|
|
||||||
<el-table-column label="类型" width="90">
|
|
||||||
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="金额" width="86" align="right">
|
|
||||||
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" width="82" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
|
||||||
<el-table-column label="创建时间" min-width="136">
|
|
||||||
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<el-empty v-else description="无未关联的收款单" :image-size="60" />
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
|
|
||||||
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
|
|
||||||
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="font-medium text-[15px]">关联收款记录</span>
|
|
||||||
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
|
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
|
||||||
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
|
|
||||||
<el-table-column label="类型" width="90">
|
|
||||||
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="金额" width="86" align="right">
|
|
||||||
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" width="82" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
|
||||||
<el-table-column label="创建时间" min-width="136">
|
|
||||||
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<el-empty v-else description="未关联收款单" :image-size="60" />
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 履约与收货信息 -->
|
|
||||||
<el-card shadow="never" class="border-gray-100 mb-4">
|
|
||||||
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
|
|
||||||
<el-descriptions :column="3" border size="small">
|
|
||||||
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
|
|
||||||
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="业务订单创建人" :span="2">
|
|
||||||
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
|
|
||||||
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
|
|
||||||
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
|
|
||||||
</div>
|
|
||||||
<span v-else class="text-gray-400">—</span>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="创建人所属部门" :span="3">
|
|
||||||
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
|
|
||||||
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
|
|
||||||
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
|
|
||||||
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
|
|
||||||
</el-breadcrumb>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span v-else class="text-gray-400">—</span>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="收货手机">
|
|
||||||
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
|
|
||||||
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 物流轨迹(只读) -->
|
|
||||||
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
|
|
||||||
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
|
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
|
||||||
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" class="w-[180px]" @keyup.enter="fetchLogisticsTrace" />
|
|
||||||
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
|
|
||||||
<el-option label="自动识别" value="auto" />
|
|
||||||
<el-option label="顺丰速运" value="sf" />
|
|
||||||
<el-option label="京东快递" value="jd" />
|
|
||||||
<el-option label="极兔速递" value="jt" />
|
|
||||||
</el-select>
|
|
||||||
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
|
|
||||||
</div>
|
|
||||||
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
|
|
||||||
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
|
|
||||||
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
|
|
||||||
<span class="text-gray-800">状态:{{ logisticsTracePayload.state_text }}</span>
|
|
||||||
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
|
|
||||||
</div>
|
|
||||||
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
|
|
||||||
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
|
|
||||||
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<!-- 操作日志 -->
|
|
||||||
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
|
|
||||||
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
|
|
||||||
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
|
|
||||||
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
|
|
||||||
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
|
|
||||||
</div>
|
|
||||||
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
<el-empty v-else description="暂无操作日志" :image-size="64" />
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</el-drawer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { usePaging } from '@/hooks/usePaging'
|
||||||
import {
|
import { prescriptionOrderLists, tcmDiagnosisDetail, tcmDiagnosisSetRevisitSlotStartOffset } from '@/api/tcm'
|
||||||
prescriptionOrderLists,
|
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||||
prescriptionOrderDetail,
|
|
||||||
prescriptionOrderLogs,
|
|
||||||
prescriptionOrderLogisticsTrace,
|
|
||||||
prescriptionOrderPaidPayOrders
|
|
||||||
} from '@/api/tcm'
|
|
||||||
import { getDictData } from '@/api/app'
|
|
||||||
import { hasPermission } from '@/utils/perm'
|
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import useUserStore from '@/stores/modules/user'
|
import {
|
||||||
|
formatTime,
|
||||||
const userStore = useUserStore()
|
fulfillmentText,
|
||||||
|
fulfillmentTagType
|
||||||
|
} from '@/views/consumer/prescription/components/prescription-order-utils'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
diagnosisId: number
|
diagnosisId: number
|
||||||
@@ -368,6 +147,26 @@ const { pager, getLists, resetPage } = usePaging({
|
|||||||
size: 10
|
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 = () => {
|
const buildParams = () => {
|
||||||
Object.keys(queryParams).forEach((k) => delete queryParams[k])
|
Object.keys(queryParams).forEach((k) => delete queryParams[k])
|
||||||
if (props.diagnosisId > 0) {
|
if (props.diagnosisId > 0) {
|
||||||
@@ -379,175 +178,52 @@ const buildParams = () => {
|
|||||||
queryParams.scene = 'diagnosis_edit'
|
queryParams.scene = 'diagnosis_edit'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
async function loadRevisitSlotStartOffset() {
|
||||||
const TCM_ASSISTANT_ROLE_ID = 2
|
if (props.diagnosisId <= 0) return
|
||||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
offsetLoading.value = true
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
const res: any = await tcmDiagnosisDetail({ id: props.diagnosisId })
|
||||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
const d = res?.data ?? res ?? {}
|
||||||
id,
|
const offset = Number(d.revisit_slot_start_offset)
|
||||||
express_company: detailLogisticsExpress.value
|
const val = Number.isFinite(offset) && offset >= 0 && offset <= 20 ? offset : 0
|
||||||
}
|
revisitSlotStartOffset.value = val
|
||||||
if (digits.length >= 4) params.phone_tail = digits
|
savedRevisitSlotStartOffset.value = val
|
||||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
|
||||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
|
||||||
} catch {
|
} catch {
|
||||||
logisticsTracePayload.value = null
|
revisitSlotStartOffset.value = 0
|
||||||
|
savedRevisitSlotStartOffset.value = 0
|
||||||
} finally {
|
} finally {
|
||||||
logisticsTraceLoading.value = false
|
offsetLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 打开详情 ───
|
async function saveRevisitSlotStartOffset() {
|
||||||
async function openDetail(id: number) {
|
if (props.diagnosisId <= 0) return
|
||||||
detailData.value = null
|
const offset = Number(revisitSlotStartOffset.value)
|
||||||
detailUnlinkedPayOrders.value = []
|
if (!Number.isFinite(offset) || offset < 0 || offset > 20) {
|
||||||
logisticsTracePayload.value = null
|
feedback.msgWarning('起始偏移须在 0~20 之间')
|
||||||
detailLogisticsExpress.value = 'auto'
|
return
|
||||||
logisticsTracePhoneTail.value = ''
|
}
|
||||||
detailLogs.value = []
|
offsetSaving.value = true
|
||||||
|
|
||||||
detailVisible.value = true
|
|
||||||
detailLoading.value = true
|
|
||||||
try {
|
try {
|
||||||
const res: any = await prescriptionOrderDetail({ id })
|
await tcmDiagnosisSetRevisitSlotStartOffset({
|
||||||
const d = res?.data ?? res ?? null
|
id: props.diagnosisId,
|
||||||
detailData.value = d
|
revisit_slot_start_offset: offset
|
||||||
if (d) {
|
})
|
||||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
savedRevisitSlotStartOffset.value = offset
|
||||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
feedback.msgSuccess('保存成功')
|
||||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
getLists()
|
||||||
if (String(d.tracking_number || '').trim()) fetchLogisticsTrace()
|
} catch {
|
||||||
fetchLogs(id)
|
/* 拦截器已提示 */
|
||||||
if (d.diagnosis_id) {
|
|
||||||
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
feedback.msgError(e?.message || '加载详情失败')
|
|
||||||
detailVisible.value = false
|
|
||||||
} finally {
|
} finally {
|
||||||
detailLoading.value = false
|
offsetSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
|
// ─── 详情抽屉(共享组件,数据拉取/展示全部在组件内) ───
|
||||||
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
|
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||||
try {
|
|
||||||
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
|
|
||||||
const lists = res?.lists ?? res?.data?.lists ?? []
|
|
||||||
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
|
|
||||||
} catch { detailUnlinkedPayOrders.value = [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
function canViewPrescriptionOrderLogs() {
|
function openDetail(id: number) {
|
||||||
const p = userStore.perms || []
|
detailDrawerRef.value?.open(id)
|
||||||
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLogs(id: number) {
|
|
||||||
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
|
|
||||||
try {
|
|
||||||
const res: any = await prescriptionOrderLogs({ id })
|
|
||||||
detailLogs.value = res?.data ?? res ?? []
|
|
||||||
} catch { detailLogs.value = [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 工具函数 ───
|
|
||||||
function normalizeBizPhone(v: unknown): string {
|
|
||||||
if (v === null || v === undefined) return ''
|
|
||||||
return String(v).replace(/\s/g, '').trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAmount = (value: unknown) => {
|
const formatAmount = (value: unknown) => {
|
||||||
@@ -555,131 +231,16 @@ const formatAmount = (value: unknown) => {
|
|||||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
|
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(
|
watch(
|
||||||
() => [props.diagnosisId, patientIdNum.value] as const,
|
() => [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()
|
buildParams()
|
||||||
|
void loadRevisitSlotStartOffset()
|
||||||
resetPage()
|
resetPage()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -695,22 +256,21 @@ defineExpose({ refresh: () => getLists() })
|
|||||||
.po-empty-tip {
|
.po-empty-tip {
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
.audit-stamp {
|
.po-revisit-offset-bar {
|
||||||
position: absolute;
|
display: flex;
|
||||||
top: 12px;
|
align-items: center;
|
||||||
right: 16px;
|
justify-content: space-between;
|
||||||
transform: rotate(15deg);
|
gap: 12px;
|
||||||
opacity: 0.15;
|
flex-wrap: wrap;
|
||||||
pointer-events: none;
|
padding: 12px 14px;
|
||||||
z-index: 1;
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
}
|
|
||||||
.stamp-inner {
|
|
||||||
border: 3px solid currentColor;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 4px 12px;
|
background: var(--el-fill-color-lighter);
|
||||||
font-size: 18px;
|
}
|
||||||
font-weight: bold;
|
.po-revisit-offset-bar__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.stamp-pass { color: #22c55e; }
|
|
||||||
.stamp-reject { color: #ef4444; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,26 +67,66 @@
|
|||||||
>
|
>
|
||||||
<!-- 基本信息 -->
|
<!-- 基本信息 -->
|
||||||
<el-divider content-position="left">基本信息</el-divider>
|
<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-form-item label="诊单ID">
|
||||||
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
|
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
|
||||||
</el-form-item>
|
</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-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="姓名" prop="patient_name">
|
<el-form-item label="姓名" prop="patient_name">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="formData.patient_name"
|
v-model="formData.patient_name"
|
||||||
placeholder="请输入姓名"
|
placeholder="请输入姓名"
|
||||||
|
:disabled="!canEditPatientBasicFields"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="身份证号" prop="id_card">
|
<el-form-item label="身份证号" prop="id_card">
|
||||||
|
<template v-if="showIdCardMaskedEdit">
|
||||||
|
<el-input
|
||||||
|
:model-value="
|
||||||
|
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
|
||||||
|
"
|
||||||
|
readonly
|
||||||
|
placeholder="点击可查看完整身份证号"
|
||||||
|
maxlength="18"
|
||||||
|
class="cursor-pointer phone-mask-toggle"
|
||||||
|
@click="toggleIdCardReveal"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="originalIdCard && !idCardRevealUnlocked"
|
||||||
|
class="text-xs text-gray-400 mt-1"
|
||||||
|
>
|
||||||
|
当前为脱敏显示,点击输入框可查看完整号码
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
<el-input
|
<el-input
|
||||||
|
v-else
|
||||||
v-model="formData.id_card"
|
v-model="formData.id_card"
|
||||||
placeholder="请输入身份证号"
|
placeholder="请输入身份证号"
|
||||||
maxlength="18"
|
maxlength="18"
|
||||||
|
:disabled="!canEditIdCard"
|
||||||
@focus="handleIdCardFocus"
|
@focus="handleIdCardFocus"
|
||||||
@blur="handleIdCardBlur"
|
@blur="handleIdCardBlur"
|
||||||
/>
|
/>
|
||||||
@@ -120,6 +160,7 @@
|
|||||||
v-model="formData.phone"
|
v-model="formData.phone"
|
||||||
placeholder="请输入手机号"
|
placeholder="请输入手机号"
|
||||||
maxlength="11"
|
maxlength="11"
|
||||||
|
:disabled="!canEditPatientBasicFields"
|
||||||
@focus="handlePhoneFocus"
|
@focus="handlePhoneFocus"
|
||||||
@blur="handlePhoneBlur"
|
@blur="handlePhoneBlur"
|
||||||
/>
|
/>
|
||||||
@@ -127,7 +168,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="性别" prop="gender">
|
<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="1">男</el-radio>
|
||||||
<el-radio :label="0">女</el-radio>
|
<el-radio :label="0">女</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
@@ -144,11 +185,14 @@
|
|||||||
:max="150"
|
:max="150"
|
||||||
placeholder="请输入年龄"
|
placeholder="请输入年龄"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
:disabled="!canEditPatientBasicFields"
|
||||||
|
:controls="canEditPatientBasicFields"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
</el-row>
|
</el-row>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
|
||||||
<!-- 生命体征 -->
|
<!-- 生命体征 -->
|
||||||
@@ -778,9 +822,40 @@ const submitting = ref(false)
|
|||||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||||
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
|
const patientBasicLocked = ref(false)
|
||||||
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
|
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 phoneRevealUnlocked = ref(false)
|
||||||
|
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
|
||||||
|
const showIdCardMaskedEdit = computed(() => {
|
||||||
|
if (mode.value !== 'edit') return false
|
||||||
|
if (!originalIdCard.value) return false
|
||||||
|
if (!canEditPatientBasicFields.value) return true
|
||||||
|
return !hasPhonePlainPermission.value
|
||||||
|
})
|
||||||
|
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
|
||||||
|
const canEditIdCard = computed(() => {
|
||||||
|
if (!canEditPatientBasicFields.value) return false
|
||||||
|
if (mode.value === 'add') return true
|
||||||
|
if (!originalIdCard.value) return true
|
||||||
|
return hasPhonePlainPermission.value
|
||||||
|
})
|
||||||
|
const idCardRevealUnlocked = ref(false)
|
||||||
const activeTab = ref('basic')
|
const activeTab = ref('basic')
|
||||||
const drawerTitle = computed(() => {
|
const drawerTitle = computed(() => {
|
||||||
if (viewOnly.value) return '诊单详情'
|
if (viewOnly.value) return '诊单详情'
|
||||||
@@ -920,6 +995,11 @@ const togglePhoneReveal = () => {
|
|||||||
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
|
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleIdCardReveal = () => {
|
||||||
|
if (!originalIdCard.value) return
|
||||||
|
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
|
||||||
|
}
|
||||||
|
|
||||||
const maskIdCard = (idCard: string) => {
|
const maskIdCard = (idCard: string) => {
|
||||||
if (!idCard) return idCard
|
if (!idCard) return idCard
|
||||||
if (idCard.length === 15) {
|
if (idCard.length === 15) {
|
||||||
@@ -990,43 +1070,40 @@ const handlePhoneBlur = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 身份证聚焦 - 显示完整数据
|
// 身份证聚焦 - 显示完整数据(无「明文」权限时仅新增模式在失焦后可再次展开编辑)
|
||||||
const handleIdCardFocus = () => {
|
const handleIdCardFocus = () => {
|
||||||
isIdCardFocused.value = true
|
isIdCardFocused.value = true
|
||||||
if (originalIdCard.value) {
|
if (!originalIdCard.value) return
|
||||||
|
if (hasPhonePlainPermission.value) {
|
||||||
|
formData.value.id_card = originalIdCard.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode.value === 'add') {
|
||||||
formData.value.id_card = originalIdCard.value
|
formData.value.id_card = originalIdCard.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 身份证失焦 - 恢复脱敏并验证
|
// 身份证失焦:有明文权限则保持明文并校验;新增且无明文权限时仍脱敏展示
|
||||||
const handleIdCardBlur = async () => {
|
const handleIdCardBlur = async () => {
|
||||||
isIdCardFocused.value = false
|
isIdCardFocused.value = false
|
||||||
|
|
||||||
// 保存原始数据并脱敏显示
|
const processIdCard = async (idCard: string) => {
|
||||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
|
||||||
originalIdCard.value = formData.value.id_card
|
const birthYear = parseInt(idCard.substring(6, 10))
|
||||||
|
const birthMonth = parseInt(idCard.substring(10, 12))
|
||||||
// 验证身份证格式并计算年龄
|
const birthDay = parseInt(idCard.substring(12, 14))
|
||||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
|
||||||
// 从身份证提取出生日期计算年龄
|
|
||||||
const id = formData.value.id_card
|
|
||||||
const birthYear = parseInt(id.substring(6, 10))
|
|
||||||
const birthMonth = parseInt(id.substring(10, 12))
|
|
||||||
const birthDay = parseInt(id.substring(12, 14))
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
let age = today.getFullYear() - birthYear
|
let age = today.getFullYear() - birthYear
|
||||||
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
||||||
age--
|
age--
|
||||||
}
|
}
|
||||||
formData.value.age = age
|
formData.value.age = age
|
||||||
|
|
||||||
// 验证身份证唯一性
|
|
||||||
try {
|
try {
|
||||||
const result = await checkIdCard({
|
const result = await checkIdCard({
|
||||||
id_card: formData.value.id_card,
|
id_card: idCard,
|
||||||
id: formData.value.id || ''
|
id: formData.value.id || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.exists) {
|
if (result.exists) {
|
||||||
ElMessage.warning(result.message)
|
ElMessage.warning(result.message)
|
||||||
}
|
}
|
||||||
@@ -1034,8 +1111,24 @@ const handleIdCardBlur = async () => {
|
|||||||
console.error('检查身份证号失败:', error)
|
console.error('检查身份证号失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 脱敏显示
|
|
||||||
|
if (hasPhonePlainPermission.value) {
|
||||||
|
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||||
|
originalIdCard.value = formData.value.id_card
|
||||||
|
await processIdCard(formData.value.id_card)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑模式下已有身份证号且无明文权限时不处理
|
||||||
|
if (mode.value === 'edit' && originalIdCard.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||||
|
originalIdCard.value = formData.value.id_card
|
||||||
|
await processIdCard(formData.value.id_card)
|
||||||
formData.value.id_card = maskIdCard(formData.value.id_card)
|
formData.value.id_card = maskIdCard(formData.value.id_card)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1058,7 +1151,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
|||||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||||
// 使用原始数据进行验证
|
// 使用原始数据进行验证
|
||||||
const idCardToValidate = originalIdCard.value || value
|
const idCardToValidate = originalIdCard.value || value
|
||||||
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
if (!idCardToValidate) {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||||
callback(new Error('身份证号格式不正确'))
|
callback(new Error('身份证号格式不正确'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1073,6 +1170,7 @@ const formRules = {
|
|||||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||||
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
||||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||||
|
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
|
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,9 +1265,12 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
|||||||
|
|
||||||
originalPhone.value = data.phone || ''
|
originalPhone.value = data.phone || ''
|
||||||
originalIdCard.value = data.id_card || ''
|
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.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
|
||||||
data.id_card = maskIdCard(data.id_card || '')
|
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
|
||||||
|
|
||||||
formData.value = data
|
formData.value = data
|
||||||
formData.value.create_source = data.create_source ?? ''
|
formData.value.create_source = data.create_source ?? ''
|
||||||
@@ -1178,12 +1279,20 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
|||||||
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
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) => {
|
const open = async (type: string, id?: number) => {
|
||||||
viewOnly.value = false
|
viewOnly.value = false
|
||||||
mode.value = type
|
mode.value = type
|
||||||
visible.value = true
|
visible.value = true
|
||||||
activeTab.value = 'basic' // 重置到基本信息标签页
|
activeTab.value = 'basic' // 重置到基本信息标签页
|
||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
|
idCardRevealUnlocked.value = false
|
||||||
|
resetPatientBasicLockState()
|
||||||
|
|
||||||
// 加载字典数据
|
// 加载字典数据
|
||||||
await getDictOptions()
|
await getDictOptions()
|
||||||
@@ -1206,6 +1315,8 @@ const openViewOnly = async (id: number) => {
|
|||||||
visible.value = true
|
visible.value = true
|
||||||
activeTab.value = 'basic'
|
activeTab.value = 'basic'
|
||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
|
idCardRevealUnlocked.value = false
|
||||||
|
resetPatientBasicLockState()
|
||||||
await getDictOptions()
|
await getDictOptions()
|
||||||
await loadDiagnosisDetailIntoForm(id)
|
await loadDiagnosisDetailIntoForm(id)
|
||||||
}
|
}
|
||||||
@@ -1230,6 +1341,7 @@ const handleSubmit = async () => {
|
|||||||
if (result && result.id) {
|
if (result && result.id) {
|
||||||
mode.value = 'edit'
|
mode.value = 'edit'
|
||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
|
idCardRevealUnlocked.value = false
|
||||||
formData.value.id = result.id
|
formData.value.id = result.id
|
||||||
|
|
||||||
// 重新获取详情,确保patient_id等字段正确
|
// 重新获取详情,确保patient_id等字段正确
|
||||||
@@ -1244,7 +1356,9 @@ const handleSubmit = async () => {
|
|||||||
formData.value.phone = hasPhonePlainPermission.value
|
formData.value.phone = hasPhonePlainPermission.value
|
||||||
? detail.phone || ''
|
? detail.phone || ''
|
||||||
: maskPhone(detail.phone || '')
|
: maskPhone(detail.phone || '')
|
||||||
formData.value.id_card = maskIdCard(detail.id_card || '')
|
formData.value.id_card = hasPhonePlainPermission.value
|
||||||
|
? detail.id_card || ''
|
||||||
|
: maskIdCard(detail.id_card || '')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取详情失败:', error)
|
console.error('获取详情失败:', error)
|
||||||
}
|
}
|
||||||
@@ -1262,7 +1376,7 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleViewCase = (row: any) => {
|
const handleViewCase = (row: any) => {
|
||||||
prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true })
|
prescriptionRef.value?.openById(row.id, { slipType: 'user' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开方:传入当前诊单数据,包含详细病历(深拷贝避免响应式引用)
|
// 开方:传入当前诊单数据,包含详细病历(深拷贝避免响应式引用)
|
||||||
@@ -1346,6 +1460,7 @@ const handleClose = () => {
|
|||||||
isPhoneFocused.value = false
|
isPhoneFocused.value = false
|
||||||
isIdCardFocused.value = false
|
isIdCardFocused.value = false
|
||||||
phoneRevealUnlocked.value = false
|
phoneRevealUnlocked.value = false
|
||||||
|
idCardRevealUnlocked.value = false
|
||||||
viewOnly.value = false
|
viewOnly.value = false
|
||||||
|
|
||||||
visible.value = false
|
visible.value = false
|
||||||
@@ -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) {
|
:deep(.el-form) {
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
formData.pending_assign !== '1' &&
|
formData.pending_assign !== '1' &&
|
||||||
formData.pending_booking !== '1' &&
|
formData.pending_booking !== '1' &&
|
||||||
formData.completed_appointment !== '1' &&
|
formData.completed_appointment !== '1' &&
|
||||||
|
!hasLatestAppointmentFilter() &&
|
||||||
formData.appointment_date === tab.value
|
formData.appointment_date === tab.value
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
@@ -110,6 +111,42 @@
|
|||||||
<el-select v-model="formData.assistant_id" placeholder="医助" clearable filterable size="small" @change="doSearch">
|
<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-option v-for="item in assistantOptions" :key="item.id" :label="item.name" :value="Number(item.id)" />
|
||||||
</el-select>
|
</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>
|
<el-button size="small" @click="handleReset">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -146,6 +183,7 @@
|
|||||||
v-loading="pager.loading"
|
v-loading="pager.loading"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
@row-dblclick="goReadonly"
|
@row-dblclick="goReadonly"
|
||||||
|
@sort-change="handleTableSortChange"
|
||||||
:row-class-name="getRowClassName"
|
:row-class-name="getRowClassName"
|
||||||
class="diagnosis-table"
|
class="diagnosis-table"
|
||||||
stripe
|
stripe
|
||||||
@@ -213,6 +251,9 @@
|
|||||||
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
|
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
|
||||||
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
|
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div v-if="latestAppointmentChannelText(row)" class="apt-latest-channel">
|
||||||
|
最近渠道:{{ latestAppointmentChannelText(row) }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="apt-none">未挂号</span>
|
<span v-else class="apt-none">未挂号</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +294,14 @@
|
|||||||
<span v-else class="status-unprescribed">未开方</span>
|
<span v-else class="status-unprescribed">未开方</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
v-if="row.last_blood_record_at"
|
v-if="row.last_blood_record_at"
|
||||||
@@ -689,6 +737,7 @@ import useUserStore from '@/stores/modules/user'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||||
|
|
||||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||||
@@ -728,6 +777,13 @@ const formData = reactive({
|
|||||||
assistant_id: '',
|
assistant_id: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_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',
|
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||||
appointment_date: '' as string,
|
appointment_date: '' as string,
|
||||||
has_appointment: '' as '' | '0' | '1',
|
has_appointment: '' as '' | '0' | '1',
|
||||||
@@ -760,6 +816,9 @@ const setHasAppointment = (v: string) => {
|
|||||||
formData.has_appointment = v as '' | '0' | '1'
|
formData.has_appointment = v as '' | '0' | '1'
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
if (v === '0') {
|
||||||
|
clearLatestAppointmentFilters()
|
||||||
|
}
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
getLists()
|
getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -812,19 +871,50 @@ function resolvePendingAssignOrderMonthForRequest(): string {
|
|||||||
return dayjs().format('YYYY-MM')
|
return dayjs().format('YYYY-MM')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
|
/** 除顶部 Tab 专属条件外,与主列表共用的「更多筛选」参数(角标 count 需同步) */
|
||||||
function buildPendingAssignCountPayload(): Record<string, unknown> {
|
function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
|
||||||
return buildTcmDiagnosisListRequestPayload({
|
return {
|
||||||
...formData,
|
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_no: 1,
|
||||||
page_size: 1,
|
page_size: 1,
|
||||||
pending_assign: 1,
|
...buildSharedDiagnosisFilterPayload(),
|
||||||
appointment_date: '',
|
appointment_date: '',
|
||||||
has_appointment: '',
|
|
||||||
pending_booking: '',
|
pending_booking: '',
|
||||||
completed_appointment: '',
|
completed_appointment: '',
|
||||||
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
|
pending_assign: '',
|
||||||
} as Record<string, unknown>) as Record<string, unknown>
|
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>) =>
|
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
|
||||||
@@ -850,6 +940,12 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
|||||||
formData.assistant_id = ''
|
formData.assistant_id = ''
|
||||||
formData.start_time = ''
|
formData.start_time = ''
|
||||||
formData.end_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 = ''
|
formData.pending_assign_order_month = ''
|
||||||
activeTab.value = 'all'
|
activeTab.value = 'all'
|
||||||
if (kw1 !== '') {
|
if (kw1 !== '') {
|
||||||
@@ -886,6 +982,7 @@ const handleDateTabClick = async (value: string) => {
|
|||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
|
clearLatestAppointmentFilters()
|
||||||
formData.appointment_date = value
|
formData.appointment_date = value
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
@@ -901,6 +998,7 @@ const handlePendingBookingTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = '0'
|
formData.has_appointment = '0'
|
||||||
|
clearLatestAppointmentFilters()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -915,6 +1013,7 @@ const handleCompletedVisitTabClick = async () => {
|
|||||||
formData.completed_appointment = '1'
|
formData.completed_appointment = '1'
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
|
clearLatestAppointmentFilters()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -926,6 +1025,7 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
|
clearLatestAppointmentFilters()
|
||||||
if (!formData.pending_assign_order_month) {
|
if (!formData.pending_assign_order_month) {
|
||||||
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
||||||
}
|
}
|
||||||
@@ -947,14 +1047,26 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
|||||||
const fetchDateCounts = async () => {
|
const fetchDateCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
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(
|
||||||
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
),
|
||||||
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists(
|
||||||
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
|
buildDateCountRequestPayload({ appointment_date: dayBeforeStr.value, has_appointment: '' }) as any
|
||||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
),
|
||||||
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists(
|
||||||
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
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)
|
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
||||||
])
|
])
|
||||||
dateCounts.value = {
|
dateCounts.value = {
|
||||||
@@ -1047,16 +1159,29 @@ const handleMoreCommand = (cmd: string) => {
|
|||||||
const diagnosisTypeOptions = ref<any[]>([])
|
const diagnosisTypeOptions = ref<any[]>([])
|
||||||
const syndromeTypeOptions = ref<any[]>([])
|
const syndromeTypeOptions = ref<any[]>([])
|
||||||
const assistantOptions = ref<any[]>([])
|
const assistantOptions = ref<any[]>([])
|
||||||
|
const channelOptions = ref<Array<{ name: string; value: string }>>([])
|
||||||
|
|
||||||
const getDictOptions = async () => {
|
const getDictOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const [diagnosisType, syndromeType, assistants] = await Promise.all([
|
const [diagnosisType, syndromeType, channels, assistants] = await Promise.all([
|
||||||
getDictData({ type: 'diagnosis_type' }),
|
getDictData({ type: 'diagnosis_type' }),
|
||||||
getDictData({ type: 'syndrome_type' }),
|
getDictData({ type: 'syndrome_type' }),
|
||||||
|
getDictData({ type: 'channels' }),
|
||||||
getAssistants()
|
getAssistants()
|
||||||
])
|
])
|
||||||
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
|
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
|
||||||
syndromeTypeOptions.value = syndromeType?.syndrome_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 || []
|
assistantOptions.value = assistants || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取字典数据失败:', error)
|
console.error('获取字典数据失败:', error)
|
||||||
@@ -1069,6 +1194,69 @@ const getDictLabel = (options: any[], value: string) => {
|
|||||||
return item ? item.name : value || '-'
|
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) => {
|
const getAssistantName = (assistantId: number | string) => {
|
||||||
// 如果没有assistant_id,返回"-"
|
// 如果没有assistant_id,返回"-"
|
||||||
@@ -1103,6 +1291,12 @@ const handleReset = () => {
|
|||||||
formData.diagnosis_type = ''
|
formData.diagnosis_type = ''
|
||||||
formData.syndrome_type = ''
|
formData.syndrome_type = ''
|
||||||
formData.assistant_id = ''
|
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.diagnosis_confirmed = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
@@ -2190,10 +2384,25 @@ onUnmounted(() => {
|
|||||||
.filter-more {
|
.filter-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px dashed var(--el-border-color-lighter);
|
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 {
|
.list-card {
|
||||||
@@ -2363,6 +2572,13 @@ onUnmounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--el-text-color-placeholder);
|
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 {
|
.status-confirmed {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -55,34 +55,59 @@ class AssetResourceController extends BaseAdminController
|
|||||||
public function add()
|
public function add()
|
||||||
{
|
{
|
||||||
$params = $this->request->post();
|
$params = $this->request->post();
|
||||||
if (empty($params['type']) || empty($params['title']) || empty($params['file_url'])) {
|
$type = $params['type'] ?? 0;
|
||||||
|
$title = trim((string)($params['title'] ?? ''));
|
||||||
|
|
||||||
|
// 兼容单图(字符串)与多图批量上传(数组)
|
||||||
|
$fileUrl = $params['file_url'] ?? '';
|
||||||
|
if (is_array($fileUrl)) {
|
||||||
|
$fileUrls = array_values(array_filter($fileUrl, fn($v) => trim((string)$v) !== ''));
|
||||||
|
} else {
|
||||||
|
$fileUrls = trim((string)$fileUrl) !== '' ? [$fileUrl] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($type) || $title === '' || empty($fileUrls)) {
|
||||||
return $this->fail('请填写完整的资源信息');
|
return $this->fail('请填写完整的资源信息');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅图片支持批量;视频/语音只取首个
|
||||||
|
if ($type != 1) {
|
||||||
|
$fileUrls = [$fileUrls[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$multi = count($fileUrls) > 1;
|
||||||
|
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
$resource = AssetResource::create([
|
$createdIds = [];
|
||||||
'type' => $params['type'],
|
$index = 0;
|
||||||
'title' => $params['title'],
|
foreach ($fileUrls as $url) {
|
||||||
'file_url' => $params['file_url'],
|
$index++;
|
||||||
'cover_url' => $params['cover_url'] ?? '',
|
$resource = AssetResource::create([
|
||||||
]);
|
'type' => $type,
|
||||||
|
'title' => $multi ? $title . '_' . $index : $title,
|
||||||
// 绑定用户
|
'file_url' => $url,
|
||||||
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
|
'cover_url' => $params['cover_url'] ?? '',
|
||||||
$userResources = [];
|
]);
|
||||||
foreach ($params['user_ids'] as $userId) {
|
|
||||||
$userResources[] = [
|
// 绑定用户
|
||||||
'user_id' => $userId,
|
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
|
||||||
'resource_id' => $resource->id,
|
$userResources = [];
|
||||||
'create_time' => time(),
|
foreach ($params['user_ids'] as $userId) {
|
||||||
];
|
$userResources[] = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'resource_id' => $resource->id,
|
||||||
|
'create_time' => time(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
(new AssetUserResource())->saveAll($userResources);
|
||||||
}
|
}
|
||||||
(new AssetUserResource())->saveAll($userResources);
|
|
||||||
|
$createdIds[] = $resource->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
return $this->success('添加并分配成功', ['id' => $resource->id]);
|
return $this->success('添加并分配成功', ['ids' => $createdIds]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
return $this->fail('操作失败: ' . $e->getMessage());
|
return $this->fail('操作失败: ' . $e->getMessage());
|
||||||
@@ -144,14 +169,19 @@ class AssetResourceController extends BaseAdminController
|
|||||||
public function delete()
|
public function delete()
|
||||||
{
|
{
|
||||||
$id = $this->request->post('id');
|
$id = $this->request->post('id');
|
||||||
if (empty($id)) {
|
$ids = is_array($id) ? $id : [$id];
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $item): bool {
|
||||||
|
return $item > 0;
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
return $this->fail('缺少参数');
|
return $this->fail('缺少参数');
|
||||||
}
|
}
|
||||||
|
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
AssetResource::destroy($id);
|
AssetResource::destroy($ids);
|
||||||
AssetUserResource::where('resource_id', $id)->delete();
|
AssetUserResource::whereIn('resource_id', $ids)->delete();
|
||||||
Db::commit();
|
Db::commit();
|
||||||
return $this->success('删除成功');
|
return $this->success('删除成功');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class AssetUserController extends BaseAdminController
|
|||||||
'phone' => $params['phone'],
|
'phone' => $params['phone'],
|
||||||
'password' => $passwordHash,
|
'password' => $passwordHash,
|
||||||
'status' => $params['status'] ?? 1,
|
'status' => $params['status'] ?? 1,
|
||||||
|
'remark' => trim((string)($params['remark'] ?? '')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->success('添加成功', ['id' => $user->id]);
|
return $this->success('添加成功', ['id' => $user->id]);
|
||||||
@@ -92,6 +93,10 @@ class AssetUserController extends BaseAdminController
|
|||||||
if (isset($params['status'])) {
|
if (isset($params['status'])) {
|
||||||
$user->status = $params['status'];
|
$user->status = $params['status'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('remark', $params)) {
|
||||||
|
$user->remark = trim((string)$params['remark']);
|
||||||
|
}
|
||||||
|
|
||||||
$user->save();
|
$user->save();
|
||||||
return $this->success('修改成功');
|
return $this->success('修改成功');
|
||||||
|
|||||||
@@ -207,6 +207,17 @@ class OrderController extends BaseAdminController
|
|||||||
$params['creator_id'] = $this->adminId;
|
$params['creator_id'] = $this->adminId;
|
||||||
$params['payment_channel'] = (string)$this->request->post('payment_channel', 'normal');
|
$params['payment_channel'] = (string)$this->request->post('payment_channel', 'normal');
|
||||||
$params['require_payment_slip_audit'] = (int)$this->request->post('require_payment_slip_audit', 0);
|
$params['require_payment_slip_audit'] = (int)$this->request->post('require_payment_slip_audit', 0);
|
||||||
|
// 创建方式:优先取前端透传的 create_type,否则按 payment_channel 派生(fubei→fubei,express_cod→express_cod,其余 normal)
|
||||||
|
$createTypeReq = (string)$this->request->post('create_type', '');
|
||||||
|
if (in_array($createTypeReq, ['normal', 'wechat_work', 'fubei', 'express_cod'], true)) {
|
||||||
|
$params['create_type'] = $createTypeReq;
|
||||||
|
} elseif ($params['payment_channel'] === 'fubei') {
|
||||||
|
$params['create_type'] = 'fubei';
|
||||||
|
} elseif ($params['payment_channel'] === 'express_cod') {
|
||||||
|
$params['create_type'] = 'express_cod';
|
||||||
|
} else {
|
||||||
|
$params['create_type'] = 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
$result = OrderLogic::create($params);
|
$result = OrderLogic::create($params);
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
@@ -227,6 +238,7 @@ class OrderController extends BaseAdminController
|
|||||||
{
|
{
|
||||||
$params = (new OrderValidate())->post()->goCheck('create');
|
$params = (new OrderValidate())->post()->goCheck('create');
|
||||||
$params['creator_id'] = $this->adminId;
|
$params['creator_id'] = $this->adminId;
|
||||||
|
$params['create_type'] = 'wechat_work';
|
||||||
|
|
||||||
$result = OrderLogic::createForWechatWork($params);
|
$result = OrderLogic::createForWechatWork($params);
|
||||||
if (!$result) {
|
if (!$result) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\stats;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\lists\stats\AutoAssignLogLists;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待分配诊单自动指派日志
|
||||||
|
*
|
||||||
|
* - GET stats.autoAssignLog/lists 日志列表(每条待指派诊单一行:分配结果 + 原因)
|
||||||
|
*/
|
||||||
|
class AutoAssignLogController extends BaseAdminController
|
||||||
|
{
|
||||||
|
public function lists()
|
||||||
|
{
|
||||||
|
return $this->dataLists(new AutoAssignLogLists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\stats;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\logic\stats\RevisitRateLogic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊接诊率统计(按月)
|
||||||
|
*
|
||||||
|
* - GET stats.revisitRate/overview 当月 N 诊接诊率(部门 → 医助分组 + 合计)
|
||||||
|
* - GET stats.revisitRate/deptOptions 部门下拉(前端组树)
|
||||||
|
* - GET stats.revisitRate/assignLines 「被指派数」明细(按诊单聚合)
|
||||||
|
* - GET stats.revisitRate/visitOrderLines 「N 诊单数」订单明细
|
||||||
|
*/
|
||||||
|
class RevisitRateController extends BaseAdminController
|
||||||
|
{
|
||||||
|
public function overview()
|
||||||
|
{
|
||||||
|
// 涉及指派日志 + 全量订单序列扫描,放宽执行时间兜底
|
||||||
|
@set_time_limit(120);
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(RevisitRateLogic::overview($params));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deptOptions()
|
||||||
|
{
|
||||||
|
return $this->data(RevisitRateLogic::deptOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「被指派数」点击下钻:诊单维度明细 */
|
||||||
|
public function assignLines()
|
||||||
|
{
|
||||||
|
@set_time_limit(120);
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(RevisitRateLogic::assignLines($params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「N 诊单数」点击下钻:具体订单明细 */
|
||||||
|
public function visitOrderLines()
|
||||||
|
{
|
||||||
|
@set_time_limit(120);
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(RevisitRateLogic::visitOrderLines($params));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use app\adminapi\logic\stats\YejiStatsLogic;
|
|||||||
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
|
* - GET stats.yejiStats/leadLines 进线数据明细(add_external_contact 逐条)
|
||||||
* - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
|
* - GET stats.yejiStats/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
|
||||||
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
|
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
|
||||||
|
* - GET stats.yejiStats/assignLines 被指派数明细(医助×诊单去重,与看板同口径)
|
||||||
*/
|
*/
|
||||||
class YejiStatsController extends BaseAdminController
|
class YejiStatsController extends BaseAdminController
|
||||||
{
|
{
|
||||||
@@ -141,4 +142,13 @@ class YejiStatsController extends BaseAdminController
|
|||||||
|
|
||||||
return $this->data(YejiStatsLogic::revisitDeptAssistantBreakdown($params, $this->adminId, $this->adminInfo));
|
return $this->data(YejiStatsLogic::revisitDeptAssistantBreakdown($params, $this->adminId, $this->adminInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 被指派数明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重) */
|
||||||
|
public function assignLines()
|
||||||
|
{
|
||||||
|
@set_time_limit(120);
|
||||||
|
$params = $this->request->get();
|
||||||
|
|
||||||
|
return $this->data(YejiStatsLogic::assignLines($params, $this->adminId, $this->adminInfo));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,13 +87,31 @@ class DiagnosisController extends BaseAdminController
|
|||||||
public function edit()
|
public function edit()
|
||||||
{
|
{
|
||||||
$params = (new DiagnosisValidate())->post()->goCheck('edit');
|
$params = (new DiagnosisValidate())->post()->goCheck('edit');
|
||||||
$result = DiagnosisLogic::edit($params);
|
$result = DiagnosisLogic::edit($params, $this->adminInfo);
|
||||||
if ($result) {
|
if ($result) {
|
||||||
return $this->success('编辑成功', [], 1, 1);
|
return $this->success('编辑成功', [], 1, 1);
|
||||||
}
|
}
|
||||||
return $this->fail(DiagnosisLogic::getError());
|
return $this->fail(DiagnosisLogic::getError());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 设置复诊接诊率统计起始偏移(业务订单 tab)
|
||||||
|
*/
|
||||||
|
public function setRevisitSlotStartOffset()
|
||||||
|
{
|
||||||
|
$params = (new DiagnosisValidate())->post()->goCheck('setRevisitSlotStartOffset');
|
||||||
|
$ok = DiagnosisLogic::setRevisitSlotStartOffset(
|
||||||
|
(int) $params['id'],
|
||||||
|
(int) $params['revisit_slot_start_offset'],
|
||||||
|
$this->adminInfo
|
||||||
|
);
|
||||||
|
if (!$ok) {
|
||||||
|
return $this->fail(DiagnosisLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('保存成功');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 删除诊单
|
* @notes 删除诊单
|
||||||
* @return \think\response\Json
|
* @return \think\response\Json
|
||||||
@@ -116,7 +134,7 @@ class DiagnosisController extends BaseAdminController
|
|||||||
{
|
{
|
||||||
|
|
||||||
$params = (new DiagnosisValidate())->goCheck('id');
|
$params = (new DiagnosisValidate())->goCheck('id');
|
||||||
$result = DiagnosisLogic::detail($params);
|
$result = DiagnosisLogic::detail($params, $this->adminInfo);
|
||||||
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
|
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
|
||||||
return $this->data($result);
|
return $this->data($result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,24 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('', $data);
|
return $this->success('', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接调用京东官方物流接口刷新轨迹并落库(绕过快递100缓存)
|
||||||
|
*/
|
||||||
|
public function logisticsJdUpdate()
|
||||||
|
{
|
||||||
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('logisticsJdUpdate');
|
||||||
|
$data = PrescriptionOrderLogic::logisticsJdUpdate(
|
||||||
|
(int) $params['id'],
|
||||||
|
$this->adminId,
|
||||||
|
$this->adminInfo
|
||||||
|
);
|
||||||
|
if ($data === null) {
|
||||||
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success((string) ($data['message'] ?? '京东物流轨迹已更新'), $data);
|
||||||
|
}
|
||||||
|
|
||||||
public function edit()
|
public function edit()
|
||||||
{
|
{
|
||||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('edit');
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('edit');
|
||||||
@@ -163,6 +181,20 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('保存成功');
|
return $this->success('保存成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
|
||||||
|
*/
|
||||||
|
public function patchPrescriptionUsage()
|
||||||
|
{
|
||||||
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('patchPrescriptionUsage');
|
||||||
|
$ok = PrescriptionOrderLogic::patchPrescriptionUsage($params, $this->adminId, $this->adminInfo);
|
||||||
|
if (!$ok) {
|
||||||
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('保存成功');
|
||||||
|
}
|
||||||
|
|
||||||
public function auditPrescription()
|
public function auditPrescription()
|
||||||
{
|
{
|
||||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
|
||||||
@@ -268,6 +300,32 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('已撤回', $result);
|
return $this->success('已撤回', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 批量将处方业务订单改派给其他医助(写入 creator_id 并逐单记操作日志)
|
||||||
|
*/
|
||||||
|
public function batchAssignAssistant()
|
||||||
|
{
|
||||||
|
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
||||||
|
return $this->fail('无权限批量改派订单');
|
||||||
|
}
|
||||||
|
$params = $this->request->post();
|
||||||
|
$rawIds = $params['order_ids'] ?? [];
|
||||||
|
if (!\is_array($rawIds) || $rawIds === []) {
|
||||||
|
return $this->fail('请选择订单');
|
||||||
|
}
|
||||||
|
$assistantId = (int) ($params['assistant_id'] ?? 0);
|
||||||
|
$result = PrescriptionOrderLogic::batchAssignAssistant($rawIds, $assistantId, $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
$msg = '已改派 ' . (int) $result['success'] . ' 单';
|
||||||
|
if (!empty($result['errors'])) {
|
||||||
|
$msg .= ';' . implode(';', $result['errors']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success($msg, $result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取业务订单操作日志
|
* 获取业务订单操作日志
|
||||||
*/
|
*/
|
||||||
@@ -284,6 +342,20 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
return $this->success('', $result);
|
return $this->success('', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手工新增操作日志(可选同步调整处方/支付单审核状态)
|
||||||
|
*/
|
||||||
|
public function addLog()
|
||||||
|
{
|
||||||
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('addLog');
|
||||||
|
$result = PrescriptionOrderLogic::addLog($params, $this->adminId, $this->adminInfo);
|
||||||
|
if ($result === false) {
|
||||||
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('日志已添加', $result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
|
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
|
||||||
*/
|
*/
|
||||||
@@ -351,11 +423,17 @@ class PrescriptionOrderController extends BaseAdminController
|
|||||||
public function refund()
|
public function refund()
|
||||||
{
|
{
|
||||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('refund');
|
$params = (new PrescriptionOrderValidate())->post()->goCheck('refund');
|
||||||
|
$rawRefundAmount = $params['refund_amount'] ?? null;
|
||||||
|
$refundAmount = ($rawRefundAmount === null || $rawRefundAmount === '')
|
||||||
|
? null
|
||||||
|
: round((float) $rawRefundAmount, 2);
|
||||||
|
|
||||||
$result = PrescriptionOrderLogic::refund(
|
$result = PrescriptionOrderLogic::refund(
|
||||||
(int) $params['id'],
|
(int) $params['id'],
|
||||||
(string) ($params['reason'] ?? ''),
|
(string) ($params['reason'] ?? ''),
|
||||||
$this->adminId,
|
$this->adminId,
|
||||||
$this->adminInfo
|
$this->adminInfo,
|
||||||
|
$refundAmount
|
||||||
);
|
);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return $this->fail(PrescriptionOrderLogic::getError());
|
return $this->fail(PrescriptionOrderLogic::getError());
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ class AuthMiddleware
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 京东物流接口刷新:与「物流轨迹」同一数据域,复用 logisticsTrace 权限
|
||||||
|
if ($accessUri === 'tcm.prescriptionorder/logisticsjdupdate'
|
||||||
|
&& in_array('tcm.prescriptionorder/logisticstrace', $adminUris, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 挂号列表批量改渠道:与单条编辑同一数据域,复用 doctor.appointment/edit
|
// 挂号列表批量改渠道:与单条编辑同一数据域,复用 doctor.appointment/edit
|
||||||
if ($accessUri === 'doctor.appointment/batcheditchannel'
|
if ($accessUri === 'doctor.appointment/batcheditchannel'
|
||||||
&& in_array('doctor.appointment/edit', $adminUris, true)) {
|
&& in_array('doctor.appointment/edit', $adminUris, true)) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace app\adminapi\lists\doctor;
|
namespace app\adminapi\lists\doctor;
|
||||||
|
|
||||||
use app\adminapi\lists\BaseAdminDataLists;
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\common\model\auth\AdminDept;
|
||||||
use app\common\model\DiagnosisViewRecord;
|
use app\common\model\DiagnosisViewRecord;
|
||||||
use app\common\model\doctor\Appointment;
|
use app\common\model\doctor\Appointment;
|
||||||
use app\common\model\tcm\Prescription;
|
use app\common\model\tcm\Prescription;
|
||||||
@@ -75,6 +77,35 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按部门筛选:接诊医生、诊单医助或挂号医助所属部门命中子树即可(选父级含子级)
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyAssistantDeptIdFilter($query): void
|
||||||
|
{
|
||||||
|
if (!isset($this->params['assistant_dept_id']) || $this->params['assistant_dept_id'] === '' || (int) $this->params['assistant_dept_id'] <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$rootDeptId = (int) $this->params['assistant_dept_id'];
|
||||||
|
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
|
||||||
|
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
|
||||||
|
return $id > 0;
|
||||||
|
}));
|
||||||
|
if ($deptIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$inList = implode(',', $deptIds);
|
||||||
|
$adTbl = (new AdminDept())->getTable();
|
||||||
|
$query->whereRaw(
|
||||||
|
"(EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`doctor_id` AND ad.`dept_id` IN ({$inList}))"
|
||||||
|
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = u.`assistant_id` AND ad.`dept_id` IN ({$inList}))"
|
||||||
|
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`assistant_id` AND ad.`dept_id` IN ({$inList})))"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
|
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
|
||||||
*
|
*
|
||||||
@@ -191,6 +222,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
|
|
||||||
$this->applyAssistantIdFilter($query);
|
$this->applyAssistantIdFilter($query);
|
||||||
|
|
||||||
|
$this->applyAssistantDeptIdFilter($query);
|
||||||
|
|
||||||
$this->applyChannelSourceFilter($query, $chFilter);
|
$this->applyChannelSourceFilter($query, $chFilter);
|
||||||
|
|
||||||
// 是否确认诊单:1=已确认 0=未确认
|
// 是否确认诊单:1=已确认 0=未确认
|
||||||
@@ -373,6 +406,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
|
|||||||
|
|
||||||
$this->applyAssistantIdFilter($query);
|
$this->applyAssistantIdFilter($query);
|
||||||
|
|
||||||
|
$this->applyAssistantDeptIdFilter($query);
|
||||||
|
|
||||||
$this->applyChannelSourceFilter($query, $chFilter);
|
$this->applyChannelSourceFilter($query, $chFilter);
|
||||||
|
|
||||||
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
|
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\lists\stats;
|
||||||
|
|
||||||
|
use app\adminapi\lists\BaseAdminDataLists;
|
||||||
|
use app\common\lists\ListsSearchInterface;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待分配诊单自动指派日志列表
|
||||||
|
*
|
||||||
|
* 数据来源:tcm_diagnosis_auto_assign_log(定时命令 tcm:auto-assign-pending 写入)
|
||||||
|
* 筛选:run_date(执行日期)/ action(1=已分配 0=未分配)/ assistant_id / keyword(患者姓名、手机号、医助姓名,纯数字兼容诊单ID)
|
||||||
|
*/
|
||||||
|
class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||||
|
{
|
||||||
|
private const TIER_LABELS = [
|
||||||
|
'gt70' => '>70%',
|
||||||
|
'60_70' => '60%~70%',
|
||||||
|
'50_60' => '50%~60%',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 设置搜索条件
|
||||||
|
*/
|
||||||
|
public function setSearch(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'=' => ['run_date', 'action', 'assistant_id', 'batch_no', 'stat_month'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 获取列表
|
||||||
|
*/
|
||||||
|
public function lists(): array
|
||||||
|
{
|
||||||
|
$rows = $this->buildQuery()
|
||||||
|
->order(['id' => 'desc'])
|
||||||
|
->limit($this->limitOffset, $this->limitLength)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$ct = (int) ($row['create_time'] ?? 0);
|
||||||
|
$row['create_time_text'] = $ct > 0 ? date('Y-m-d H:i:s', $ct) : '';
|
||||||
|
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
|
||||||
|
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
|
||||||
|
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 获取数量
|
||||||
|
*/
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return $this->buildQuery()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \think\db\Query
|
||||||
|
*/
|
||||||
|
private function buildQuery()
|
||||||
|
{
|
||||||
|
$query = Db::name('tcm_diagnosis_auto_assign_log')->where($this->searchWhere);
|
||||||
|
|
||||||
|
$keyword = trim((string) ($this->params['keyword'] ?? ''));
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$query->where(function ($q) use ($keyword) {
|
||||||
|
$q->whereLike('patient_name', '%' . $keyword . '%')
|
||||||
|
->whereOr('patient_phone', 'like', '%' . $keyword . '%')
|
||||||
|
->whereOr('assistant_name', 'like', '%' . $keyword . '%');
|
||||||
|
if (preg_match('/^\d+$/', $keyword) === 1 && (int) $keyword > 0) {
|
||||||
|
$q->whereOr('diagnosis_id', '=', (int) $keyword);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = trim((string) ($this->params['start_date'] ?? ''));
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) === 1) {
|
||||||
|
$query->where('run_date', '>=', $startDate);
|
||||||
|
}
|
||||||
|
$endDate = trim((string) ($this->params['end_date'] ?? ''));
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) === 1) {
|
||||||
|
$query->where('run_date', '<=', $endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,10 @@ use app\common\model\tcm\CallRecord;
|
|||||||
use app\common\model\auth\Admin;
|
use app\common\model\auth\Admin;
|
||||||
use app\common\model\auth\AdminDept;
|
use app\common\model\auth\AdminDept;
|
||||||
use app\common\model\auth\AdminRole;
|
use app\common\model\auth\AdminRole;
|
||||||
|
use app\common\model\dict\DictData;
|
||||||
use app\common\lists\ListsSearchInterface;
|
use app\common\lists\ListsSearchInterface;
|
||||||
use app\common\lists\Traits\HasDataScopeFilter;
|
use app\common\lists\Traits\HasDataScopeFilter;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 中医辨房病因诊单列表
|
* 中医辨房病因诊单列表
|
||||||
@@ -39,6 +41,10 @@ use app\common\lists\Traits\HasDataScopeFilter;
|
|||||||
class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||||
{
|
{
|
||||||
use HasDataScopeFilter;
|
use HasDataScopeFilter;
|
||||||
|
|
||||||
|
/** 最近挂号筛选只统计这些有效状态:已预约、已完成、已过号 */
|
||||||
|
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 设置搜索条件
|
* @notes 设置搜索条件
|
||||||
* @return array
|
* @return array
|
||||||
@@ -133,6 +139,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||||
|
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||||
|
|
||||||
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||||
|
|
||||||
// 仅已开方(待分配+关键词检索时不限制)
|
// 仅已开方(待分配+关键词检索时不限制)
|
||||||
@@ -159,30 +168,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按挂号状态优先级排序:已过号(4) > 已预约(1) > 已完成(3),然后按挂号日期+时间升序
|
$orderRaw = $this->resolveListOrderRaw($pendingWideSearch);
|
||||||
// 若传了 appointment_date(当天/明天等筛选),只按「该日」的挂号排序与展示
|
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
|
||||||
$aptTbl = (new Appointment())->getTable();
|
|
||||||
$minAptDateCond = '';
|
|
||||||
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
|
||||||
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
|
||||||
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取最早的挂号状态(用于排序优先级)
|
|
||||||
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ' ORDER BY CASE apt.status WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END, apt.appointment_date ASC, apt.appointment_time ASC LIMIT 1)';
|
|
||||||
|
|
||||||
// 获取最早的挂号时间(用于同状态内排序)
|
|
||||||
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
|
|
||||||
|
|
||||||
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序…
|
|
||||||
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
|
|
||||||
if ($isCompletedTab) {
|
|
||||||
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
|
|
||||||
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
|
|
||||||
} else {
|
|
||||||
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
|
|
||||||
}
|
|
||||||
|
|
||||||
$lists = $query
|
$lists = $query
|
||||||
->with(['DiagnosisViewRecord'])
|
->with(['DiagnosisViewRecord'])
|
||||||
@@ -210,6 +196,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
->order('appointment_time', 'asc')
|
->order('appointment_time', 'asc')
|
||||||
->order('id', 'asc');
|
->order('id', 'asc');
|
||||||
$appointments = $subQuery->select()->toArray();
|
$appointments = $subQuery->select()->toArray();
|
||||||
|
$latestAppointmentMap = $this->buildLatestAppointmentMap($diagnosisIds);
|
||||||
|
|
||||||
foreach ($appointments as $apt) {
|
foreach ($appointments as $apt) {
|
||||||
$did = (int) ($apt['patient_id'] ?? 0);
|
$did = (int) ($apt['patient_id'] ?? 0);
|
||||||
@@ -240,6 +227,8 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
// 合并到诊单列表
|
// 合并到诊单列表
|
||||||
foreach ($lists as &$item) {
|
foreach ($lists as &$item) {
|
||||||
|
$latestAppointment = $latestAppointmentMap[(int) $item['id']] ?? null;
|
||||||
|
$this->appendLatestAppointmentSummary($item, $latestAppointment);
|
||||||
$aptList = $appointmentMap[(int) $item['id']] ?? [];
|
$aptList = $appointmentMap[(int) $item['id']] ?? [];
|
||||||
if (!empty($aptList)) {
|
if (!empty($aptList)) {
|
||||||
$apt = $aptList[0];
|
$apt = $aptList[0];
|
||||||
@@ -280,6 +269,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
foreach ($lists as &$item) {
|
foreach ($lists as &$item) {
|
||||||
|
$this->appendLatestAppointmentSummary($item, null);
|
||||||
$item['has_appointment'] = 0;
|
$item['has_appointment'] = 0;
|
||||||
$item['appointment_id'] = null;
|
$item['appointment_id'] = null;
|
||||||
$item['appointment_status'] = 0;
|
$item['appointment_status'] = 0;
|
||||||
@@ -558,6 +548,9 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||||
|
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||||
|
|
||||||
// 仅已开方(待分配+关键词检索时不限制)
|
// 仅已开方(待分配+关键词检索时不限制)
|
||||||
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||||
$rxTbl = (new Prescription())->getTable();
|
$rxTbl = (new Prescription())->getTable();
|
||||||
@@ -587,6 +580,297 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
return $query->count();
|
return $query->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次有效挂号过滤:按 appointment_date DESC, appointment_time DESC, id DESC 取一条。
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyLatestAppointmentFilters($query, bool $pendingWideSearch): void
|
||||||
|
{
|
||||||
|
if ($pendingWideSearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = $this->normalizeYmd($this->params['latest_appointment_start_date'] ?? '');
|
||||||
|
$endDate = $this->normalizeYmd($this->params['latest_appointment_end_date'] ?? '');
|
||||||
|
$channelSource = trim((string) ($this->params['latest_appointment_channel_source'] ?? ''));
|
||||||
|
|
||||||
|
if ($startDate === '' && $endDate === '' && $channelSource === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aptTbl = (new Appointment())->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$latestIdSql = $this->latestAppointmentIdSubSql($aptTbl, $diagTbl);
|
||||||
|
$conditions = ["latest_apt.id = ({$latestIdSql})"];
|
||||||
|
|
||||||
|
if ($startDate !== '') {
|
||||||
|
$conditions[] = "latest_apt.appointment_date >= '" . addslashes($startDate) . "'";
|
||||||
|
}
|
||||||
|
if ($endDate !== '') {
|
||||||
|
$conditions[] = "latest_apt.appointment_date <= '" . addslashes($endDate) . "'";
|
||||||
|
}
|
||||||
|
if ($channelSource !== '') {
|
||||||
|
$channelCond = $this->appointmentChannelConditionSql('latest_apt', $channelSource);
|
||||||
|
if ($channelCond === '') {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$conditions[] = $channelCond;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereExists("SELECT 1 FROM {$aptTbl} latest_apt WHERE " . implode(' AND ', $conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次成功指派过滤:按 create_time DESC, id DESC 取 to_assistant_id>0 的一条。
|
||||||
|
*
|
||||||
|
* @param mixed $query
|
||||||
|
*/
|
||||||
|
private function applyLatestAssignFilters($query, bool $pendingWideSearch): void
|
||||||
|
{
|
||||||
|
if ($pendingWideSearch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = $this->normalizeYmd($this->params['latest_assign_start_date'] ?? '');
|
||||||
|
$endDate = $this->normalizeYmd($this->params['latest_assign_end_date'] ?? '');
|
||||||
|
if ($startDate === '' && $endDate === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logTbl = Db::name('tcm_diagnosis_assign_log')->getTable();
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$latestIdSql = $this->latestAssignLogIdSubSql($logTbl, $diagTbl);
|
||||||
|
$conditions = ["latest_lg.id = ({$latestIdSql})"];
|
||||||
|
|
||||||
|
if ($startDate !== '') {
|
||||||
|
$startTs = (int) strtotime($startDate . ' 00:00:00');
|
||||||
|
$conditions[] = "latest_lg.create_time >= {$startTs}";
|
||||||
|
}
|
||||||
|
if ($endDate !== '') {
|
||||||
|
$endTs = (int) strtotime($endDate . ' 23:59:59');
|
||||||
|
$conditions[] = "latest_lg.create_time <= {$endTs}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereExists("SELECT 1 FROM {$logTbl} latest_lg WHERE " . implode(' AND ', $conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestAssignLogIdSubSql(string $logTbl, string $diagTbl): string
|
||||||
|
{
|
||||||
|
return "SELECT lg_latest.id FROM {$logTbl} lg_latest "
|
||||||
|
. "WHERE lg_latest.diagnosis_id = {$diagTbl}.id "
|
||||||
|
. 'AND lg_latest.to_assistant_id > 0 '
|
||||||
|
. 'ORDER BY lg_latest.create_time DESC, lg_latest.id DESC LIMIT 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表默认排序;支持 sort_unserved_days=asc|desc 按未服务天数排序。
|
||||||
|
*/
|
||||||
|
private function resolveListOrderRaw(bool $pendingWideSearch): string
|
||||||
|
{
|
||||||
|
$diagTbl = (new Diagnosis())->getTable();
|
||||||
|
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
|
||||||
|
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
|
||||||
|
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
|
||||||
|
$nullLast = "CASE WHEN IFNULL({$anchorExpr}, 0) = 0 THEN 1 ELSE 0 END ASC";
|
||||||
|
if ($sortUnserved === 'desc') {
|
||||||
|
return "{$nullLast}, IFNULL({$anchorExpr}, 0) ASC, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$nullLast}, IFNULL({$anchorExpr}, 0) DESC, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
$aptTbl = (new Appointment())->getTable();
|
||||||
|
$minAptDateCond = '';
|
||||||
|
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
|
||||||
|
$sortAptDate = addslashes((string) $this->params['appointment_date']);
|
||||||
|
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ' ORDER BY CASE apt.status WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END, apt.appointment_date ASC, apt.appointment_time ASC LIMIT 1)';
|
||||||
|
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
|
||||||
|
|
||||||
|
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
|
||||||
|
if ($isCompletedTab) {
|
||||||
|
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
|
||||||
|
|
||||||
|
return $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $diagTbl . '.assign_read_at IS NULL DESC, CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未服务天数锚点:血糖/饮食/运动记录最近 record_date 的最大值 */
|
||||||
|
private function unservedAnchorExpr(string $diagTbl): string
|
||||||
|
{
|
||||||
|
$bloodTbl = (new BloodRecord())->getTable();
|
||||||
|
$dietTbl = (new DietRecord())->getTable();
|
||||||
|
$exerciseTbl = (new ExerciseRecord())->getTable();
|
||||||
|
|
||||||
|
return 'GREATEST('
|
||||||
|
. "COALESCE((SELECT MAX(br.record_date) FROM {$bloodTbl} br WHERE br.diagnosis_id = {$diagTbl}.id AND br.delete_time IS NULL), 0), "
|
||||||
|
. "COALESCE((SELECT MAX(dr.record_date) FROM {$dietTbl} dr WHERE dr.diagnosis_id = {$diagTbl}.id AND dr.delete_time IS NULL), 0), "
|
||||||
|
. "COALESCE((SELECT MAX(er.record_date) FROM {$exerciseTbl} er WHERE er.diagnosis_id = {$diagTbl}.id AND er.delete_time IS NULL), 0)"
|
||||||
|
. ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestAppointmentIdSubSql(string $aptTbl, string $diagTbl): string
|
||||||
|
{
|
||||||
|
$statuses = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
|
||||||
|
|
||||||
|
return "SELECT apt_latest.id FROM {$aptTbl} apt_latest "
|
||||||
|
. "WHERE apt_latest.patient_id = {$diagTbl}.id "
|
||||||
|
. "AND apt_latest.status IN ({$statuses}) "
|
||||||
|
. "ORDER BY apt_latest.appointment_date DESC, "
|
||||||
|
. "IFNULL(NULLIF(TRIM(apt_latest.appointment_time), ''), '00:00:00') DESC, "
|
||||||
|
. "apt_latest.id DESC LIMIT 1";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function appointmentChannelConditionSql(string $alias, string $channelSource): string
|
||||||
|
{
|
||||||
|
$cols = $this->appointmentTableFields();
|
||||||
|
$hasChannelSource = in_array('channel_source', $cols, true);
|
||||||
|
$hasChannels = in_array('channels', $cols, true);
|
||||||
|
if (!$hasChannelSource && !$hasChannels) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$quoted = "'" . addslashes($channelSource) . "'";
|
||||||
|
$parts = [];
|
||||||
|
if ($hasChannelSource) {
|
||||||
|
$parts[] = "{$alias}.channel_source = {$quoted}";
|
||||||
|
}
|
||||||
|
if ($hasChannels) {
|
||||||
|
$parts[] = "{$alias}.channels = {$quoted}";
|
||||||
|
if (is_numeric($channelSource)) {
|
||||||
|
$parts[] = "{$alias}.channels = " . (int) $channelSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = array_values(array_unique($parts));
|
||||||
|
|
||||||
|
return $parts === [] ? '' : '(' . implode(' OR ', $parts) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string, mixed> $diagnosisIds
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function buildLatestAppointmentMap(array $diagnosisIds): array
|
||||||
|
{
|
||||||
|
$diagnosisIds = array_values(array_filter(array_unique(array_map('intval', $diagnosisIds)), static function (int $id): bool {
|
||||||
|
return $id > 0;
|
||||||
|
}));
|
||||||
|
if ($diagnosisIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cols = $this->appointmentTableFields();
|
||||||
|
$fields = ['id', 'patient_id', 'appointment_date', 'appointment_time', 'status'];
|
||||||
|
foreach (['channel_source', 'channel_source_detail', 'channels'] as $col) {
|
||||||
|
if (in_array($col, $cols, true)) {
|
||||||
|
$fields[] = $col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointments = Appointment::whereIn('patient_id', $diagnosisIds)
|
||||||
|
->whereIn('status', self::EFFECTIVE_APPOINTMENT_STATUSES)
|
||||||
|
->field($fields)
|
||||||
|
->order('patient_id', 'asc')
|
||||||
|
->order('appointment_date', 'desc')
|
||||||
|
->orderRaw("IFNULL(NULLIF(TRIM(appointment_time), ''), '00:00:00') DESC")
|
||||||
|
->order('id', 'desc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
$channelNameByValue = DictData::where('type_value', 'channels')->column('name', 'value');
|
||||||
|
foreach ($appointments as $appointment) {
|
||||||
|
$diagnosisId = (int) ($appointment['patient_id'] ?? 0);
|
||||||
|
if ($diagnosisId <= 0 || isset($map[$diagnosisId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$appointment['channel_source_desc'] = $this->appointmentChannelDesc($appointment, $channelNameByValue ?: []);
|
||||||
|
$map[$diagnosisId] = $appointment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
* @param array<string, mixed>|null $appointment
|
||||||
|
*/
|
||||||
|
private function appendLatestAppointmentSummary(array &$item, ?array $appointment): void
|
||||||
|
{
|
||||||
|
$item['latest_appointment_id'] = null;
|
||||||
|
$item['latest_appointment_time_text'] = '';
|
||||||
|
$item['latest_appointment_channel_source'] = '';
|
||||||
|
$item['latest_appointment_channel_source_desc'] = '';
|
||||||
|
$item['latest_appointment_channel_source_detail'] = '';
|
||||||
|
|
||||||
|
if ($appointment === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$timePart = (string) ($appointment['appointment_time'] ?? '');
|
||||||
|
if (strlen($timePart) > 5) {
|
||||||
|
$timePart = substr($timePart, 0, 5);
|
||||||
|
}
|
||||||
|
$rawChannel = trim((string) ($appointment['channel_source'] ?? ''));
|
||||||
|
if ($rawChannel === '' && isset($appointment['channels']) && $appointment['channels'] !== '' && $appointment['channels'] !== null) {
|
||||||
|
$rawChannel = trim((string) $appointment['channels']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item['latest_appointment_id'] = (int) ($appointment['id'] ?? 0);
|
||||||
|
$item['latest_appointment_time_text'] = trim((string) ($appointment['appointment_date'] ?? '') . ' ' . $timePart);
|
||||||
|
$item['latest_appointment_channel_source'] = $rawChannel;
|
||||||
|
$item['latest_appointment_channel_source_desc'] = (string) ($appointment['channel_source_desc'] ?? '');
|
||||||
|
$item['latest_appointment_channel_source_detail'] = trim((string) ($appointment['channel_source_detail'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $appointment
|
||||||
|
* @param array<string, string> $channelNameByValue
|
||||||
|
*/
|
||||||
|
private function appointmentChannelDesc(array $appointment, array $channelNameByValue): string
|
||||||
|
{
|
||||||
|
$srcKey = trim((string) ($appointment['channel_source'] ?? ''));
|
||||||
|
if ($srcKey === '' && isset($appointment['channels']) && $appointment['channels'] !== '' && $appointment['channels'] !== null) {
|
||||||
|
$srcKey = trim((string) $appointment['channels']);
|
||||||
|
}
|
||||||
|
if ($srcKey === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($channelNameByValue[$srcKey] ?? $channelNameByValue[(string) (int) $srcKey] ?? $srcKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private function appointmentTableFields(): array
|
||||||
|
{
|
||||||
|
static $fields = null;
|
||||||
|
if ($fields !== null) {
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tblFields = Db::name('doctor_appointment')->getTableFields();
|
||||||
|
$fields = is_array($tblFields) ? array_values(array_map('strval', $tblFields)) : [];
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeYmd($value): string
|
||||||
|
{
|
||||||
|
$date = trim((string) $value);
|
||||||
|
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ? $date : '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 待分配 Tab 且有关键词时:放宽列表条件(与 `applyDiagnosisListKeywordFilter` 判定一致)。
|
* 待分配 Tab 且有关键词时:放宽列表条件(与 `applyDiagnosisListKeywordFilter` 判定一致)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
|
|||||||
public function lists(): array
|
public function lists(): array
|
||||||
{
|
{
|
||||||
$field = [
|
$field = [
|
||||||
'id', 'prescription_name', 'formula_type', 'herbs', 'is_public',
|
'id', 'prescription_name', 'formula_type', 'herbs', 'is_public', 'disable_edit',
|
||||||
'creator_id', 'creator_name', 'create_time', 'update_time'
|
'creator_id', 'creator_name', 'create_time', 'update_time'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use app\adminapi\logic\dept\DeptLogic;
|
|||||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||||
use app\common\enum\ExportEnum;
|
use app\common\enum\ExportEnum;
|
||||||
|
use app\common\cache\ExportCache;
|
||||||
use app\common\lists\ListsExcelInterface;
|
use app\common\lists\ListsExcelInterface;
|
||||||
use app\common\lists\ListsExtendInterface;
|
use app\common\lists\ListsExtendInterface;
|
||||||
use app\common\lists\ListsSearchInterface;
|
use app\common\lists\ListsSearchInterface;
|
||||||
@@ -26,6 +27,12 @@ use app\common\service\gancao\GancaoScmRecipelService;
|
|||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\db\Query;
|
use think\db\Query;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
|
|
||||||
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface, ListsExcelInterface
|
class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface, ListsExcelInterface
|
||||||
{
|
{
|
||||||
@@ -108,6 +115,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
$this->applyExpressKeywordFilter($query);
|
$this->applyExpressKeywordFilter($query);
|
||||||
$this->applyServiceChannelFilter($query);
|
$this->applyServiceChannelFilter($query);
|
||||||
$this->applySupplyModeFilter($query);
|
$this->applySupplyModeFilter($query);
|
||||||
|
$this->applyHasAuxFormulaFilter($query);
|
||||||
$this->applyAuditAdminFilter($query);
|
$this->applyAuditAdminFilter($query);
|
||||||
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||||
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
|
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
|
||||||
@@ -347,6 +355,39 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否含辅方:关联处方 herbs 中是否存在 formula_type=辅方(与 PrescriptionOrderLogic::detail 口径一致)
|
||||||
|
*
|
||||||
|
* 入参 has_aux_formula:1 含辅方 | 0 不含辅方,空表示不限
|
||||||
|
*/
|
||||||
|
private function applyHasAuxFormulaFilter($query): void
|
||||||
|
{
|
||||||
|
$raw = $this->params['has_aux_formula'] ?? '';
|
||||||
|
if ($raw === '' || $raw === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$wantAux = (int) $raw === 1;
|
||||||
|
$poTbl = (new PrescriptionOrder())->getTable();
|
||||||
|
$rxTbl = (new Prescription())->getTable();
|
||||||
|
// herbs 为 JSON 字段,ThinkPHP 写入时中文按 Unicode 转义存储(辅方 => \u8f85\u65b9)。
|
||||||
|
// 用 ESCAPE '~' 让反斜杠按字面匹配;同时兼容极少数原文「辅方」写法。
|
||||||
|
$auxEsc = '%"formula_type":"\\\\u8f85\\\\u65b9"%';
|
||||||
|
$auxRaw = '%"formula_type":"辅方"%';
|
||||||
|
$herbsHasAux = "(IFNULL(rx.`herbs`,'') LIKE '{$auxEsc}' ESCAPE '~'"
|
||||||
|
. " OR IFNULL(rx.`herbs`,'') LIKE '{$auxRaw}')";
|
||||||
|
$existsSql = "SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
|
||||||
|
. " AND rx.`delete_time` IS NULL AND {$herbsHasAux}";
|
||||||
|
if ($wantAux) {
|
||||||
|
$query->whereExists($existsSql);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->whereExists(
|
||||||
|
"SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
|
||||||
|
. " AND rx.`delete_time` IS NULL AND NOT ({$herbsHasAux})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则额外包含关联处方开方人为本人的订单。
|
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则额外包含关联处方开方人为本人的订单。
|
||||||
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
|
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
|
||||||
@@ -700,6 +741,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
unset($item);
|
unset($item);
|
||||||
|
|
||||||
|
if ($this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||||
|
$this->appendDiagnosisEditVisitSeqFields($lists);
|
||||||
|
}
|
||||||
|
|
||||||
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
|
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
|
||||||
|
|
||||||
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
|
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
|
||||||
@@ -778,10 +823,18 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'export_channel' => '渠道',
|
'export_channel' => '渠道',
|
||||||
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
|
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
|
||||||
'export_medication_form' => '药品形态',
|
'export_medication_form' => '药品形态',
|
||||||
|
'export_prescription_name' => '药方名称',
|
||||||
|
'export_prescription_herbs' => '处方',
|
||||||
|
'export_main_usage' => '主方服用方式',
|
||||||
|
'export_main_usage_days' => '主方天数',
|
||||||
|
'export_aux_usage' => '辅方服用方式',
|
||||||
|
'export_aux_usage_days' => '辅方天数',
|
||||||
'export_service_package' => '服务套餐',
|
'export_service_package' => '服务套餐',
|
||||||
'export_medication_days' => '天数',
|
'export_medication_days' => '天数',
|
||||||
'export_amount' => '总金额',
|
'export_amount' => '总金额',
|
||||||
'export_paid_amount' => '已付金额',
|
'export_paid_amount' => '已付金额',
|
||||||
|
'export_linked_pay_records' => '关联收款记录',
|
||||||
|
'export_refund_amount' => '退款金额',
|
||||||
'export_agency_collect' => '代收金额',
|
'export_agency_collect' => '代收金额',
|
||||||
'export_tracking_number' => '快递单号',
|
'export_tracking_number' => '快递单号',
|
||||||
'export_sign_time' => '签收日期',
|
'export_sign_time' => '签收日期',
|
||||||
@@ -793,6 +846,141 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处方业务订单导出:深色表头、斑马纹、长文本自动换行(覆盖默认亮蓝表头)
|
||||||
|
*
|
||||||
|
* @param array<string, string> $excelFields
|
||||||
|
* @param array<int, array<string, mixed>> $lists
|
||||||
|
*/
|
||||||
|
public function createExcel($excelFields, $lists): string
|
||||||
|
{
|
||||||
|
$title = array_values($excelFields);
|
||||||
|
$fieldKeys = array_keys($excelFields);
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
foreach ($lists as $row) {
|
||||||
|
$temp = [];
|
||||||
|
foreach ($excelFields as $key => $excelField) {
|
||||||
|
$fieldData = $row[$key] ?? '';
|
||||||
|
if (is_numeric($fieldData) && strlen((string) $fieldData) >= 12) {
|
||||||
|
$fieldData .= "\t";
|
||||||
|
}
|
||||||
|
$temp[$key] = $fieldData;
|
||||||
|
}
|
||||||
|
$data[] = $temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
$spreadsheet = new Spreadsheet();
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$sheet->setTitle('处方业务订单');
|
||||||
|
|
||||||
|
foreach ($title as $key => $value) {
|
||||||
|
$sheet->setCellValueByColumnAndRow($key + 1, 1, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowNum = 2;
|
||||||
|
foreach ($data as $item) {
|
||||||
|
$column = 1;
|
||||||
|
foreach ($item as $value) {
|
||||||
|
$sheet->setCellValueByColumnAndRow($column, $rowNum, $value);
|
||||||
|
++$column;
|
||||||
|
}
|
||||||
|
++$rowNum;
|
||||||
|
}
|
||||||
|
|
||||||
|
$highest = $sheet->getHighestRowAndColumn();
|
||||||
|
$highestRow = (int) $highest['row'];
|
||||||
|
$lastColumn = (string) $highest['column'];
|
||||||
|
$titleScope = 'A1:' . $lastColumn . '1';
|
||||||
|
$allScope = 'A1:' . $lastColumn . $highestRow;
|
||||||
|
$bodyScope = 'A2:' . $lastColumn . $highestRow;
|
||||||
|
|
||||||
|
$sheet->freezePane('A2');
|
||||||
|
$sheet->getRowDimension(1)->setRowHeight(26);
|
||||||
|
|
||||||
|
$sheet->getStyle($titleScope)->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()->setARGB('FF334155');
|
||||||
|
$sheet->getStyle($titleScope)->getFont()->getColor()->setARGB('FFFFFFFF');
|
||||||
|
$sheet->getStyle($titleScope)->getFont()->setBold(true);
|
||||||
|
$sheet->getStyle($titleScope)->getAlignment()
|
||||||
|
->setHorizontal(Alignment::HORIZONTAL_CENTER)
|
||||||
|
->setVertical(Alignment::VERTICAL_CENTER);
|
||||||
|
|
||||||
|
$sheet->getStyle($allScope)->getBorders()->getAllBorders()
|
||||||
|
->setBorderStyle(Border::BORDER_THIN)
|
||||||
|
->getColor()->setARGB('FFE2E8F0');
|
||||||
|
|
||||||
|
$sheet->getStyle($bodyScope)->getAlignment()
|
||||||
|
->setVertical(Alignment::VERTICAL_TOP)
|
||||||
|
->setWrapText(true);
|
||||||
|
|
||||||
|
$wrapWideKeys = [
|
||||||
|
'export_linked_pay_records',
|
||||||
|
'export_prescription_name',
|
||||||
|
'export_prescription_herbs',
|
||||||
|
'export_main_usage',
|
||||||
|
'export_aux_usage',
|
||||||
|
'export_guahao_channel_source',
|
||||||
|
'export_assistant_dept',
|
||||||
|
'export_service_package',
|
||||||
|
];
|
||||||
|
$fixedWidths = [
|
||||||
|
'export_fulfillment_status_text' => 12,
|
||||||
|
'export_order_time' => 18,
|
||||||
|
'export_patient_gender' => 6,
|
||||||
|
'export_patient_age' => 6,
|
||||||
|
'export_medication_days' => 6,
|
||||||
|
'export_main_usage_days' => 8,
|
||||||
|
'export_aux_usage_days' => 8,
|
||||||
|
'export_amount' => 10,
|
||||||
|
'export_paid_amount' => 10,
|
||||||
|
'export_refund_amount' => 10,
|
||||||
|
'export_agency_collect' => 10,
|
||||||
|
'export_sign_time' => 12,
|
||||||
|
'export_supply_mode' => 10,
|
||||||
|
'export_linked_pay_records' => 52,
|
||||||
|
'export_prescription_name' => 34,
|
||||||
|
'export_prescription_herbs' => 36,
|
||||||
|
'export_main_usage' => 28,
|
||||||
|
'export_aux_usage' => 28,
|
||||||
|
'export_guahao_channel_source' => 22,
|
||||||
|
'export_assistant_dept' => 24,
|
||||||
|
'export_service_package' => 18,
|
||||||
|
'export_tracking_number' => 18,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($fieldKeys as $idx => $fieldKey) {
|
||||||
|
$colLetter = Coordinate::stringFromColumnIndex($idx + 1);
|
||||||
|
if (isset($fixedWidths[$fieldKey])) {
|
||||||
|
$sheet->getColumnDimension($colLetter)->setWidth($fixedWidths[$fieldKey]);
|
||||||
|
} elseif (!in_array($fieldKey, $wrapWideKeys, true)) {
|
||||||
|
$sheet->getColumnDimension($colLetter)->setAutoSize(true);
|
||||||
|
} else {
|
||||||
|
$sheet->getColumnDimension($colLetter)->setWidth(28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($r = 2; $r <= $highestRow; ++$r) {
|
||||||
|
if ($r % 2 === 0) {
|
||||||
|
$sheet->getStyle('A' . $r . ':' . $lastColumn . $r)->getFill()
|
||||||
|
->setFillType(Fill::FILL_SOLID)
|
||||||
|
->getStartColor()->setARGB('FFF8FAFC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
|
||||||
|
$exportCache = new ExportCache();
|
||||||
|
$src = $exportCache->getSrc();
|
||||||
|
if (!file_exists($src)) {
|
||||||
|
mkdir($src, 0775, true);
|
||||||
|
}
|
||||||
|
$writer->save($src . $this->fileName);
|
||||||
|
$vars = ['file' => $exportCache->setFile($this->fileName)];
|
||||||
|
|
||||||
|
return (string) url('adminapi/download/export', $vars, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 业绩看板侧栏专用:约诊/接诊条数,与 YejiStatsLogic::applyConsultFiltersAlignedWithAppointmentLists 同口径
|
* 业绩看板侧栏专用:约诊/接诊条数,与 YejiStatsLogic::applyConsultFiltersAlignedWithAppointmentLists 同口径
|
||||||
* (doctor_appointment.status=3,appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
|
* (doctor_appointment.status=3,appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
|
||||||
@@ -1233,6 +1421,69 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $lists
|
||||||
|
*/
|
||||||
|
private function appendDiagnosisEditVisitSeqFields(array &$lists): void
|
||||||
|
{
|
||||||
|
if ($lists === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diagIds = [];
|
||||||
|
foreach ($lists as $row) {
|
||||||
|
$d = (int) ($row['diagnosis_id'] ?? 0);
|
||||||
|
if ($d > 0) {
|
||||||
|
$diagIds[$d] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$contextDid = (int) ($this->params['context_diagnosis_id'] ?? 0);
|
||||||
|
if ($contextDid > 0) {
|
||||||
|
$diagIds[$contextDid] = true;
|
||||||
|
}
|
||||||
|
$diagIdList = array_keys($diagIds);
|
||||||
|
if ($diagIdList === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->column('revisit_slot_start_offset', 'id');
|
||||||
|
$offsetMap = [];
|
||||||
|
foreach ($offsetRows as $id => $offset) {
|
||||||
|
$offsetMap[(int) $id] = max(0, min(20, (int) $offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
|
||||||
|
$seqByOrderId = [];
|
||||||
|
foreach ($diagIdList as $did) {
|
||||||
|
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
|
||||||
|
$orderIds = $q
|
||||||
|
->order(['create_time' => 'asc', 'id' => 'asc'])
|
||||||
|
->column('id');
|
||||||
|
$seq = 0;
|
||||||
|
foreach ($orderIds as $oid) {
|
||||||
|
$seq++;
|
||||||
|
$seqByOrderId[(int) $oid] = $seq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lists as &$row) {
|
||||||
|
$oid = (int) ($row['id'] ?? 0);
|
||||||
|
$did = (int) ($row['diagnosis_id'] ?? 0);
|
||||||
|
$seq = (int) ($seqByOrderId[$oid] ?? 0);
|
||||||
|
$offset = (int) ($offsetMap[$did] ?? 0);
|
||||||
|
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
|
||||||
|
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
|
||||||
|
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
|
||||||
|
$row['counts_for_revisit_rate'] = $effectiveSlot >= 2 ? 1 : 0;
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
|
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
|
||||||
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
|
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
|
||||||
|
|||||||
@@ -151,6 +151,24 @@ class DeptLogic extends BaseLogic
|
|||||||
* @return list<int>
|
* @return list<int>
|
||||||
*/
|
*/
|
||||||
private static function findErCenterRootDeptIds(): array
|
private static function findErCenterRootDeptIds(): array
|
||||||
|
{
|
||||||
|
return self::findCenterRootDeptIdsByNameKeyword('二中心');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名称含「一中心」的部门 id。
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private static function findYiCenterRootDeptIds(): array
|
||||||
|
{
|
||||||
|
return self::findCenterRootDeptIdsByNameKeyword('一中心');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private static function findCenterRootDeptIdsByNameKeyword(string $keyword): array
|
||||||
{
|
{
|
||||||
$rows = Dept::whereNull('delete_time')
|
$rows = Dept::whereNull('delete_time')
|
||||||
->field(['id', 'name'])
|
->field(['id', 'name'])
|
||||||
@@ -159,7 +177,7 @@ class DeptLogic extends BaseLogic
|
|||||||
$out = [];
|
$out = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$name = (string) ($r['name'] ?? '');
|
$name = (string) ($r['name'] ?? '');
|
||||||
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
|
if ($name !== '' && mb_strpos($name, $keyword) !== false) {
|
||||||
$out[] = (int) $r['id'];
|
$out[] = (int) $r['id'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +219,19 @@ class DeptLogic extends BaseLogic
|
|||||||
return array_fill_keys($subtreeIds, true);
|
return array_fill_keys($subtreeIds, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名称含「一中心」的部门及其全部下级 id(map)。
|
||||||
|
*
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
public static function getYiCenterSubtreeDeptIdSet(): array
|
||||||
|
{
|
||||||
|
$yiRoots = self::findYiCenterRootDeptIds();
|
||||||
|
$subtreeIds = self::unionErCenterSubtreeDeptIds($yiRoots);
|
||||||
|
|
||||||
|
return array_fill_keys($subtreeIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
|
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ use think\facade\Log;
|
|||||||
*/
|
*/
|
||||||
class OrderLogic
|
class OrderLogic
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 创建方式:normal 普通订单 / wechat_work 企业微信对外收款 / fubei 付呗 / express_cod 快递代收
|
||||||
|
*/
|
||||||
|
private const CREATE_TYPES = ['normal', 'wechat_work', 'fubei', 'express_cod'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 生成订单号
|
* @notes 生成订单号
|
||||||
* @return string
|
* @return string
|
||||||
@@ -31,6 +36,43 @@ class OrderLogic
|
|||||||
return 'ORD' . date('YmdHis') . mt_rand(100000, 999999);
|
return 'ORD' . date('YmdHis') . mt_rand(100000, 999999);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function normalizeCreateType(
|
||||||
|
?string $createType,
|
||||||
|
?string $paymentMethod = null,
|
||||||
|
?string $payeeUserid = null,
|
||||||
|
?string $payerExternalUserid = null,
|
||||||
|
?string $remark = null
|
||||||
|
): string {
|
||||||
|
$raw = trim((string) $createType);
|
||||||
|
if (in_array($raw, self::CREATE_TYPES, true)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentMethod = trim((string) $paymentMethod);
|
||||||
|
if ($paymentMethod === 'fubei') {
|
||||||
|
return 'fubei';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trim((string) $payeeUserid) !== ''
|
||||||
|
|| trim((string) $payerExternalUserid) !== ''
|
||||||
|
|| str_contains((string) $remark, '企业微信')
|
||||||
|
) {
|
||||||
|
return 'wechat_work';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为「待审核(5)且需人工确认到账」的单:付呗(payment_method=fubei) 或 快递代收(create_type=express_cod)
|
||||||
|
*/
|
||||||
|
private static function isPendingManualAudit(Order $order): bool
|
||||||
|
{
|
||||||
|
return (string)($order->payment_method ?? '') === 'fubei'
|
||||||
|
|| (string)($order->create_type ?? '') === 'express_cod';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 创建订单
|
* @notes 创建订单
|
||||||
* @param array $params
|
* @param array $params
|
||||||
@@ -40,9 +82,16 @@ class OrderLogic
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$channel = (string)($params['payment_channel'] ?? 'normal');
|
$channel = (string)($params['payment_channel'] ?? 'normal');
|
||||||
if (!in_array($channel, ['normal', 'fubei'], true)) {
|
if (!in_array($channel, ['normal', 'fubei', 'express_cod'], true)) {
|
||||||
$channel = 'normal';
|
$channel = 'normal';
|
||||||
}
|
}
|
||||||
|
$paymentMethod = $channel === 'fubei' ? 'fubei' : null;
|
||||||
|
// 快递代收:以 express_cod 标记创建方式(与 fubei 流转一致,但 payment_method 待到账时再写)
|
||||||
|
$createTypeHint = $channel === 'express_cod' ? 'express_cod' : '';
|
||||||
|
$createType = self::normalizeCreateType(
|
||||||
|
(string)($params['create_type'] ?? $createTypeHint),
|
||||||
|
$paymentMethod
|
||||||
|
);
|
||||||
$requirePaymentSlipAudit = (int)($params['require_payment_slip_audit'] ?? 0) === 1;
|
$requirePaymentSlipAudit = (int)($params['require_payment_slip_audit'] ?? 0) === 1;
|
||||||
|
|
||||||
$order = new Order();
|
$order = new Order();
|
||||||
@@ -51,10 +100,14 @@ class OrderLogic
|
|||||||
$order->creator_id = $params['creator_id'];
|
$order->creator_id = $params['creator_id'];
|
||||||
$order->order_type = $params['order_type'];
|
$order->order_type = $params['order_type'];
|
||||||
$order->amount = $params['amount'];
|
$order->amount = $params['amount'];
|
||||||
// 付呗且申请审核支付单:待审核(5);否则待支付(1)
|
$order->create_type = $createType;
|
||||||
|
// 付呗/快递代收且申请审核支付单:待审核(5);否则待支付(1)
|
||||||
if ($channel === 'fubei') {
|
if ($channel === 'fubei') {
|
||||||
$order->payment_method = 'fubei';
|
$order->payment_method = 'fubei';
|
||||||
$order->status = $requirePaymentSlipAudit ? 5 : 1;
|
$order->status = $requirePaymentSlipAudit ? 5 : 1;
|
||||||
|
} elseif ($channel === 'express_cod') {
|
||||||
|
// payment_method 留空,待人工确认到账时再写真实支付方式
|
||||||
|
$order->status = $requirePaymentSlipAudit ? 5 : 1;
|
||||||
} else {
|
} else {
|
||||||
$order->status = 1; // 待支付
|
$order->status = 1; // 待支付
|
||||||
}
|
}
|
||||||
@@ -183,6 +236,10 @@ class OrderLogic
|
|||||||
$order->payment_method = 'wechat';
|
$order->payment_method = 'wechat';
|
||||||
$needSave = true;
|
$needSave = true;
|
||||||
}
|
}
|
||||||
|
if ((string) ($order->create_type ?? '') !== 'wechat_work') {
|
||||||
|
$order->create_type = 'wechat_work';
|
||||||
|
$needSave = true;
|
||||||
|
}
|
||||||
if ($paymentTimeStr !== '' && (string)$order->payment_time !== $paymentTimeStr) {
|
if ($paymentTimeStr !== '' && (string)$order->payment_time !== $paymentTimeStr) {
|
||||||
$order->payment_time = $paymentTimeStr;
|
$order->payment_time = $paymentTimeStr;
|
||||||
$needSave = true;
|
$needSave = true;
|
||||||
@@ -235,6 +292,7 @@ class OrderLogic
|
|||||||
$order->amount = $amount;
|
$order->amount = $amount;
|
||||||
$order->status = $targetStatus;
|
$order->status = $targetStatus;
|
||||||
$order->payment_method = 'wechat';
|
$order->payment_method = 'wechat';
|
||||||
|
$order->create_type = 'wechat_work';
|
||||||
$order->payment_time = $paymentTimeStr;
|
$order->payment_time = $paymentTimeStr;
|
||||||
$order->trade_no = $tradeNo;
|
$order->trade_no = $tradeNo;
|
||||||
$remark = '企业微信直接收款同步,待关联患者';
|
$remark = '企业微信直接收款同步,待关联患者';
|
||||||
@@ -283,6 +341,7 @@ class OrderLogic
|
|||||||
$order->amount = $amount;
|
$order->amount = $amount;
|
||||||
$order->status = 2; // 已支付
|
$order->status = 2; // 已支付
|
||||||
$order->payment_method = 'wechat';
|
$order->payment_method = 'wechat';
|
||||||
|
$order->create_type = 'wechat_work';
|
||||||
$order->payment_time = date('Y-m-d H:i:s');
|
$order->payment_time = date('Y-m-d H:i:s');
|
||||||
$order->trade_no = $tradeNo;
|
$order->trade_no = $tradeNo;
|
||||||
$order->remark = '企业微信直接收款,待关联患者';
|
$order->remark = '企业微信直接收款,待关联患者';
|
||||||
@@ -345,11 +404,11 @@ class OrderLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$st = (int)$order->status;
|
$st = (int)$order->status;
|
||||||
// 1=待支付;5=待审核(付呗+申请支付单审核) 时允许录入手动到账
|
// 1=待支付;5=待审核(付呗/快递代收+申请支付单审核) 时允许录入手动到账
|
||||||
if ($st === 1) {
|
if ($st === 1) {
|
||||||
// 正常待支付
|
// 正常待支付
|
||||||
} elseif ($st === 5 && (string)($order->payment_method ?? '') === 'fubei') {
|
} elseif ($st === 5 && self::isPendingManualAudit($order)) {
|
||||||
// 待审核的付呗单,通过人工确认后标记已支付
|
// 待审核的付呗/快递代收单,通过人工确认后标记已支付
|
||||||
} else {
|
} else {
|
||||||
self::setError('订单状态不允许支付');
|
self::setError('订单状态不允许支付');
|
||||||
return false;
|
return false;
|
||||||
@@ -382,8 +441,8 @@ class OrderLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$st = (int)$order->status;
|
$st = (int)$order->status;
|
||||||
if ($st !== 1 && !($st === 5 && (string)($order->payment_method ?? '') === 'fubei')) {
|
if ($st !== 1 && !($st === 5 && self::isPendingManualAudit($order))) {
|
||||||
self::setError('只有待支付或待审核(付呗)的订单才能取消');
|
self::setError('只有待支付或待审核(付呗/快递代收)的订单才能取消');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,13 +526,13 @@ class OrderLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$st = (int) $order->status;
|
$st = (int) $order->status;
|
||||||
if ($st === 5 && (string) ($order->payment_method ?? '') !== 'fubei') {
|
if ($st === 5 && !self::isPendingManualAudit($order)) {
|
||||||
self::setError('待审核订单仅支持付呗渠道拆分');
|
self::setError('待审核订单仅支持付呗/快递代收渠道拆分');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (! in_array($st, [1, 5, 2], true)) {
|
if (! in_array($st, [1, 5, 2], true)) {
|
||||||
self::setError('仅「待支付」「待审核(付呗)」或「已支付」的订单可拆分');
|
self::setError('仅「待支付」「待审核(付呗/快递代收)」或「已支付」的订单可拆分');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -536,8 +595,18 @@ class OrderLogic
|
|||||||
$n->creator_id = $order->creator_id;
|
$n->creator_id = $order->creator_id;
|
||||||
$n->order_type = (int) $typeList[$i];
|
$n->order_type = (int) $typeList[$i];
|
||||||
$n->amount = $amt;
|
$n->amount = $amt;
|
||||||
|
$n->create_type = self::normalizeCreateType(
|
||||||
|
(string) ($order->create_type ?? ''),
|
||||||
|
(string) ($order->payment_method ?? ''),
|
||||||
|
(string) ($order->payee_userid ?? ''),
|
||||||
|
(string) ($order->payer_external_userid ?? ''),
|
||||||
|
(string) ($order->remark ?? '')
|
||||||
|
);
|
||||||
if ($st === 5) {
|
if ($st === 5) {
|
||||||
$n->payment_method = 'fubei';
|
// 付呗子单沿用 payment_method=fubei;快递代收子单 payment_method 留空,靠 create_type 标记
|
||||||
|
if ((string) ($order->payment_method ?? '') === 'fubei') {
|
||||||
|
$n->payment_method = 'fubei';
|
||||||
|
}
|
||||||
$n->status = 5;
|
$n->status = 5;
|
||||||
} elseif ($st === 2) {
|
} elseif ($st === 2) {
|
||||||
$n->status = 2;
|
$n->status = 2;
|
||||||
@@ -1213,7 +1282,7 @@ class OrderLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$rows = $q
|
$rows = $q
|
||||||
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt'])
|
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt', 'payment_method', 'create_type'])
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ class ConversionLogic
|
|||||||
$emptyResult = [
|
$emptyResult = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
'lists' => [],
|
'lists' => [],
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -57,8 +57,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -75,8 +75,8 @@ class ConversionLogic
|
|||||||
$result = [
|
$result = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
'lists' => [],
|
'lists' => [],
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -84,8 +84,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -124,8 +124,8 @@ class ConversionLogic
|
|||||||
$result = [
|
$result = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
'lists' => [],
|
'lists' => [],
|
||||||
'count' => 0,
|
'count' => 0,
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -133,8 +133,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary([]),
|
'summary' => self::buildSummary([], $dimension),
|
||||||
'charts' => self::buildCharts([]),
|
'charts' => self::buildCharts([], $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -191,8 +191,8 @@ class ConversionLogic
|
|||||||
$result = [
|
$result = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary($allRows),
|
'summary' => self::buildSummary($allRows, $dimension),
|
||||||
'charts' => self::buildCharts($chartRows),
|
'charts' => self::buildCharts($chartRows, $dimension),
|
||||||
'lists' => $pagedRows,
|
'lists' => $pagedRows,
|
||||||
'count' => count($allRows),
|
'count' => count($allRows),
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -200,8 +200,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary($allRows),
|
'summary' => self::buildSummary($allRows, $dimension),
|
||||||
'charts' => self::buildCharts($chartRows),
|
'charts' => self::buildCharts($chartRows, $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -226,8 +226,8 @@ class ConversionLogic
|
|||||||
$result = [
|
$result = [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary($rows),
|
'summary' => self::buildSummary($rows, $dimension),
|
||||||
'charts' => self::buildCharts($rows),
|
'charts' => self::buildCharts($rows, $dimension),
|
||||||
'lists' => array_slice($rows, $offset, $pageSize),
|
'lists' => array_slice($rows, $offset, $pageSize),
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
'page_no' => $pageNo,
|
'page_no' => $pageNo,
|
||||||
@@ -235,8 +235,8 @@ class ConversionLogic
|
|||||||
'extend' => [
|
'extend' => [
|
||||||
'dimension' => $dimension,
|
'dimension' => $dimension,
|
||||||
'date_range' => [$startDate, $endDate],
|
'date_range' => [$startDate, $endDate],
|
||||||
'summary' => self::buildSummary($rows),
|
'summary' => self::buildSummary($rows, $dimension),
|
||||||
'charts' => self::buildCharts($rows),
|
'charts' => self::buildCharts($rows, $dimension),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
if ($includeFilters) {
|
if ($includeFilters) {
|
||||||
@@ -1226,7 +1226,7 @@ class ConversionLogic
|
|||||||
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||||
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||||
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
|
||||||
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||||
@@ -1459,7 +1459,7 @@ class ConversionLogic
|
|||||||
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||||
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||||
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
$node['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
|
||||||
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||||
@@ -2010,7 +2010,12 @@ class ConversionLogic
|
|||||||
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
|
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
|
||||||
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
|
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
|
||||||
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
|
'interview_rate' => self::percent($interviewCount, $appointmentTotalCount),
|
||||||
'receive_rate' => self::percent($completedOrderCount, $addFansCount),
|
'receive_rate' => self::receiveRate(
|
||||||
|
$completedOrderCount,
|
||||||
|
$addFansCount,
|
||||||
|
$interviewCount,
|
||||||
|
'member'
|
||||||
|
),
|
||||||
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
||||||
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
||||||
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
||||||
@@ -2149,7 +2154,7 @@ class ConversionLogic
|
|||||||
* @param array<int, array<string, mixed>> $rows
|
* @param array<int, array<string, mixed>> $rows
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static function buildSummary(array $rows): array
|
private static function buildSummary(array $rows, string $dimension = 'dept'): array
|
||||||
{
|
{
|
||||||
$summary = [
|
$summary = [
|
||||||
'add_fans_count' => 0,
|
'add_fans_count' => 0,
|
||||||
@@ -2181,7 +2186,12 @@ class ConversionLogic
|
|||||||
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
|
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
|
||||||
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
|
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
|
||||||
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
|
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
|
||||||
$summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_count']);
|
$summary['receive_rate'] = self::receiveRate(
|
||||||
|
$summary['completed_order_count'],
|
||||||
|
$summary['add_fans_count'],
|
||||||
|
$summary['interview_count'],
|
||||||
|
$dimension
|
||||||
|
);
|
||||||
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
||||||
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
|
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
|
||||||
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
|
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
|
||||||
@@ -2310,7 +2320,7 @@ class ConversionLogic
|
|||||||
* @param array<int, array<string, mixed>> $rows
|
* @param array<int, array<string, mixed>> $rows
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private static function buildCharts(array $rows): array
|
private static function buildCharts(array $rows, string $dimension = 'dept'): array
|
||||||
{
|
{
|
||||||
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
|
$chartableRows = array_values(array_filter($rows, static fn (array $row): bool => !((bool)($row['_virtual_bucket'] ?? false))));
|
||||||
$topRows = array_slice($chartableRows, 0, 10);
|
$topRows = array_slice($chartableRows, 0, 10);
|
||||||
@@ -2319,29 +2329,56 @@ class ConversionLogic
|
|||||||
'names' => [],
|
'names' => [],
|
||||||
'amounts' => [],
|
'amounts' => [],
|
||||||
'order_counts' => [],
|
'order_counts' => [],
|
||||||
'fan_counts' => [],
|
|
||||||
'rois' => [],
|
|
||||||
];
|
];
|
||||||
|
if ($dimension === 'doctor') {
|
||||||
|
$ranking['appointment_counts'] = [];
|
||||||
|
$ranking['interview_counts'] = [];
|
||||||
|
} else {
|
||||||
|
$ranking['fan_counts'] = [];
|
||||||
|
$ranking['rois'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($topRows as $row) {
|
foreach ($topRows as $row) {
|
||||||
$ranking['names'][] = $row['name'];
|
$ranking['names'][] = $row['name'];
|
||||||
$ranking['amounts'][] = $row['completed_order_amount'];
|
$ranking['amounts'][] = $row['completed_order_amount'];
|
||||||
$ranking['order_counts'][] = $row['completed_order_count'];
|
$ranking['order_counts'][] = $row['completed_order_count'];
|
||||||
$ranking['fan_counts'][] = $row['add_fans_count'];
|
if ($dimension === 'doctor') {
|
||||||
$ranking['rois'][] = $row['roi'];
|
$ranking['appointment_counts'][] = $row['appointment_total_count'];
|
||||||
|
$ranking['interview_counts'][] = $row['interview_count'];
|
||||||
|
} else {
|
||||||
|
$ranking['fan_counts'][] = $row['add_fans_count'];
|
||||||
|
$ranking['rois'][] = $row['roi'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
$charts = [
|
||||||
'ranking' => $ranking,
|
'ranking' => $ranking,
|
||||||
'amount_share' => array_map(
|
'amount_share' => array_map(
|
||||||
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
|
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
|
||||||
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
|
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
|
||||||
),
|
),
|
||||||
'fan_share' => array_map(
|
];
|
||||||
|
|
||||||
|
if ($dimension === 'doctor') {
|
||||||
|
$charts['order_share'] = array_map(
|
||||||
|
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_count']],
|
||||||
|
array_filter($topRows, static fn (array $row): bool => (int)$row['completed_order_count'] > 0)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$charts['fan_share'] = array_map(
|
||||||
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
|
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
|
||||||
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
|
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
|
||||||
),
|
);
|
||||||
];
|
}
|
||||||
|
|
||||||
|
return $charts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
|
||||||
|
{
|
||||||
|
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
|
||||||
|
|
||||||
|
return self::percent($completedOrderCount, $denominator);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function percent(int $numerator, int $denominator): float
|
private static function percent(int $numerator, int $denominator): float
|
||||||
|
|||||||
@@ -0,0 +1,829 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊接诊率统计(按月)
|
||||||
|
*
|
||||||
|
* 口径说明:
|
||||||
|
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||||
|
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
||||||
|
* 同一诊单当月被多次指派给同一医助只计 1 次;
|
||||||
|
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
|
||||||
|
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
||||||
|
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
|
||||||
|
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
|
||||||
|
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
|
||||||
|
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
|
||||||
|
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
|
||||||
|
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%;
|
||||||
|
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
||||||
|
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
||||||
|
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
||||||
|
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
||||||
|
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
||||||
|
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
||||||
|
*/
|
||||||
|
class RevisitRateLogic
|
||||||
|
{
|
||||||
|
/** 至少展示到的复诊序号(二诊/三诊/四诊) */
|
||||||
|
private const MIN_VISIT_SLOT_CEILING = 4;
|
||||||
|
|
||||||
|
/** 复诊序号统计上限(防御脏数据导致列爆炸;诊次跨月累计,上限放宽) */
|
||||||
|
private const MAX_VISIT_SLOT = 50;
|
||||||
|
|
||||||
|
/** 未分配部门的占位分组 */
|
||||||
|
private const UNASSIGNED_DEPT_NAME = '未分配部门';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{month?:string,dept_ids?:int[]|string} $params
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* month:string,start_date:string,end_date:string,
|
||||||
|
* slots:list<int>,
|
||||||
|
* total:array<string,int|float|null>,
|
||||||
|
* rows:list<array<string,mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function overview(array $params): array
|
||||||
|
{
|
||||||
|
$ctx = self::buildStatsCore($params);
|
||||||
|
$month = $ctx['month'];
|
||||||
|
|
||||||
|
$minSlots = range(2, self::MIN_VISIT_SLOT_CEILING);
|
||||||
|
$universe = self::assistantUniverse($ctx);
|
||||||
|
if ($universe === []) {
|
||||||
|
return [
|
||||||
|
'month' => $month,
|
||||||
|
'start_date' => $ctx['startDate'],
|
||||||
|
'end_date' => $ctx['endDate'],
|
||||||
|
'slots' => $minSlots,
|
||||||
|
'total' => self::buildMetricPack(0, [], $minSlots),
|
||||||
|
'rows' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分档:2 ~ max(4, 当月命中的最大诊次)
|
||||||
|
$maxHitSlot = 0;
|
||||||
|
foreach ($ctx['slotOrdersByAssistant'] as $slotMap) {
|
||||||
|
foreach ($slotMap as $slot => $_) {
|
||||||
|
if ((int) $slot > $maxHitSlot) {
|
||||||
|
$maxHitSlot = (int) $slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot));
|
||||||
|
|
||||||
|
$nameMap = Db::name('admin')
|
||||||
|
->whereIn('id', array_keys($universe))
|
||||||
|
->column('name', 'id');
|
||||||
|
|
||||||
|
// 医助行(按部门分组收集)
|
||||||
|
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
|
||||||
|
$assistantRowsByDept = [];
|
||||||
|
/** @var array<int, array<int, true>> $deptDiagSet 部门 => 去重被指派诊单集合 */
|
||||||
|
$deptDiagSet = [];
|
||||||
|
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
|
||||||
|
$deptSlotCounts = [];
|
||||||
|
/** @var array<int, true> $totalDiagSet */
|
||||||
|
$totalDiagSet = [];
|
||||||
|
/** @var array<int, int> $totalSlotCounts */
|
||||||
|
$totalSlotCounts = [];
|
||||||
|
|
||||||
|
foreach ($universe as $aid => $_) {
|
||||||
|
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
|
||||||
|
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
|
||||||
|
foreach ($diagSet as $did => $_d) {
|
||||||
|
$deptDiagSet[$deptId][$did] = true;
|
||||||
|
$totalDiagSet[$did] = true;
|
||||||
|
}
|
||||||
|
$slotCounts = [];
|
||||||
|
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
|
||||||
|
$cnt = \count($orders);
|
||||||
|
$slotCounts[$slot] = $cnt;
|
||||||
|
$deptSlotCounts[$deptId][$slot] = ($deptSlotCounts[$deptId][$slot] ?? 0) + $cnt;
|
||||||
|
$totalSlotCounts[$slot] = ($totalSlotCounts[$slot] ?? 0) + $cnt;
|
||||||
|
}
|
||||||
|
$deptName = $deptId > 0
|
||||||
|
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
||||||
|
: self::UNASSIGNED_DEPT_NAME;
|
||||||
|
$assistantRowsByDept[$deptId][] = [
|
||||||
|
'row_key' => 'a' . $aid,
|
||||||
|
'is_dept' => 0,
|
||||||
|
'assistant_id' => (int) $aid,
|
||||||
|
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
|
||||||
|
'dept_id' => $deptId,
|
||||||
|
'dept_name' => $deptName,
|
||||||
|
] + self::buildMetricPack(\count($diagSet), $slotCounts, $slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部门行 + 子行
|
||||||
|
$rows = [];
|
||||||
|
foreach ($assistantRowsByDept as $deptId => $children) {
|
||||||
|
usort($children, static function (array $a, array $b): int {
|
||||||
|
if ($a['assigned_count'] !== $b['assigned_count']) {
|
||||||
|
return $b['assigned_count'] <=> $a['assigned_count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp((string) $a['assistant_name'], (string) $b['assistant_name']);
|
||||||
|
});
|
||||||
|
$deptName = $deptId > 0
|
||||||
|
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
||||||
|
: self::UNASSIGNED_DEPT_NAME;
|
||||||
|
$rows[] = [
|
||||||
|
'row_key' => 'd' . $deptId,
|
||||||
|
'is_dept' => 1,
|
||||||
|
'dept_id' => (int) $deptId,
|
||||||
|
'dept_name' => $deptName,
|
||||||
|
'assistant_count' => \count($children),
|
||||||
|
'children' => $children,
|
||||||
|
] + self::buildMetricPack(\count($deptDiagSet[$deptId] ?? []), $deptSlotCounts[$deptId] ?? [], $slots);
|
||||||
|
}
|
||||||
|
usort($rows, static function (array $a, array $b): int {
|
||||||
|
if ($a['assigned_count'] !== $b['assigned_count']) {
|
||||||
|
return $b['assigned_count'] <=> $a['assigned_count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'month' => $month,
|
||||||
|
'start_date' => $ctx['startDate'],
|
||||||
|
'end_date' => $ctx['endDate'],
|
||||||
|
'slots' => $slots,
|
||||||
|
'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots),
|
||||||
|
'rows' => $rows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。
|
||||||
|
* scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
|
||||||
|
*
|
||||||
|
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
|
||||||
|
*
|
||||||
|
* @return array{month:string,count:int,rows:list<array<string,mixed>>}
|
||||||
|
*/
|
||||||
|
public static function assignLines(array $params): array
|
||||||
|
{
|
||||||
|
$ctx = self::buildStatsCore($params);
|
||||||
|
$assistantSet = self::applyRowScope($ctx, $params);
|
||||||
|
|
||||||
|
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $byDiag */
|
||||||
|
$byDiag = [];
|
||||||
|
foreach ($ctx['pairsRaw'] as $p) {
|
||||||
|
$aid = (int) $p['to_assistant_id'];
|
||||||
|
$did = (int) $p['diagnosis_id'];
|
||||||
|
if (!isset($assistantSet[$aid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($byDiag[$did])) {
|
||||||
|
$byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
|
||||||
|
}
|
||||||
|
$byDiag[$did]['assistants'][$aid] = true;
|
||||||
|
$byDiag[$did]['assign_count']++;
|
||||||
|
$byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($byDiag === []) {
|
||||||
|
return ['month' => $ctx['month'], 'count' => 0, 'rows' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
|
||||||
|
$assistantIds = [];
|
||||||
|
foreach ($byDiag as $d) {
|
||||||
|
foreach ($d['assistants'] as $aid => $_) {
|
||||||
|
$assistantIds[$aid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$nameMap = Db::name('admin')
|
||||||
|
->whereIn('id', array_keys($assistantIds))
|
||||||
|
->column('name', 'id');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($byDiag as $did => $d) {
|
||||||
|
$names = [];
|
||||||
|
foreach ($d['assistants'] as $aid => $_) {
|
||||||
|
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
'diagnosis_id' => (int) $did,
|
||||||
|
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
|
||||||
|
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
|
||||||
|
'assistant_names' => implode('、', $names),
|
||||||
|
'assign_count' => (int) $d['assign_count'],
|
||||||
|
'last_assign_time' => (int) $d['last_time'],
|
||||||
|
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
|
||||||
|
|
||||||
|
return ['month' => $ctx['month'], 'count' => \count($rows), 'rows' => $rows];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
|
||||||
|
*
|
||||||
|
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
|
||||||
|
*
|
||||||
|
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
|
||||||
|
*/
|
||||||
|
public static function visitOrderLines(array $params): array
|
||||||
|
{
|
||||||
|
$slot = (int) ($params['slot'] ?? 0);
|
||||||
|
if ($slot < 2 || $slot > self::MAX_VISIT_SLOT) {
|
||||||
|
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctx = self::buildStatsCore($params);
|
||||||
|
$assistantSet = self::applyRowScope($ctx, $params);
|
||||||
|
|
||||||
|
$orderRows = [];
|
||||||
|
foreach ($assistantSet as $aid => $_) {
|
||||||
|
foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $r) {
|
||||||
|
$r['holder_assistant_id'] = (int) $aid;
|
||||||
|
$orderRows[] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($orderRows === []) {
|
||||||
|
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$diagIds = array_values(array_unique(array_map(
|
||||||
|
static fn (array $r): int => (int) $r['diagnosis_id'],
|
||||||
|
$orderRows
|
||||||
|
)));
|
||||||
|
$diagInfo = self::fetchDiagnosisInfo($diagIds);
|
||||||
|
$adminIds = [];
|
||||||
|
foreach ($orderRows as $r) {
|
||||||
|
if ((int) $r['creator_id'] > 0) {
|
||||||
|
$adminIds[(int) $r['creator_id']] = true;
|
||||||
|
}
|
||||||
|
$adminIds[(int) $r['holder_assistant_id']] = true;
|
||||||
|
}
|
||||||
|
$adminNames = $adminIds !== []
|
||||||
|
? Db::name('admin')->whereIn('id', array_keys($adminIds))->column('name', 'id')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($orderRows as $r) {
|
||||||
|
$did = (int) $r['diagnosis_id'];
|
||||||
|
$cid = (int) $r['creator_id'];
|
||||||
|
$hid = (int) $r['holder_assistant_id'];
|
||||||
|
$ct = (int) $r['create_time'];
|
||||||
|
$rows[] = [
|
||||||
|
'order_id' => (int) $r['id'],
|
||||||
|
'order_no' => (string) ($r['order_no'] ?? ''),
|
||||||
|
'diagnosis_id' => $did,
|
||||||
|
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
|
||||||
|
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
|
||||||
|
'amount' => round((float) ($r['amount'] ?? 0), 2),
|
||||||
|
'create_time' => $ct,
|
||||||
|
'create_time_text' => $ct > 0 ? date('Y-m-d H:i:s', $ct) : '',
|
||||||
|
'creator_id' => $cid,
|
||||||
|
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
|
||||||
|
'assistant_id' => $hid,
|
||||||
|
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
|
||||||
|
|
||||||
|
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => \count($rows), 'rows' => $rows];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门下拉:仅「二中心」及其组织下级(与业绩看板 ErCenter 子树一致)。
|
||||||
|
*
|
||||||
|
* @return array{rows: list<array{id:int,pid:int,name:string}>}
|
||||||
|
*/
|
||||||
|
public static function deptOptions(): array
|
||||||
|
{
|
||||||
|
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
if ($erSet === []) {
|
||||||
|
return ['rows' => []];
|
||||||
|
}
|
||||||
|
$rows = Db::name('dept')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->whereIn('id', array_keys($erSet))
|
||||||
|
->field(['id', 'pid', 'name'])
|
||||||
|
->order('sort', 'desc')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => array_map(static fn (array $r): array => [
|
||||||
|
'id' => (int) $r['id'],
|
||||||
|
'pid' => (int) $r['pid'],
|
||||||
|
'name' => (string) $r['name'],
|
||||||
|
], $rows),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── 内部实现 ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心统计上下文:
|
||||||
|
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
||||||
|
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
||||||
|
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
|
||||||
|
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
|
||||||
|
*
|
||||||
|
* @param array{month?:string,dept_ids?:int[]|string} $params
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,
|
||||||
|
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,create_time:int}>,
|
||||||
|
* diagsByAssistant:array<int,array<int,true>>,
|
||||||
|
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
|
||||||
|
* assistantDept:array<int,int>,
|
||||||
|
* deptNames:array<int,string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function buildStatsCore(array $params): array
|
||||||
|
{
|
||||||
|
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
|
||||||
|
$startTs = (int) strtotime($month . '-01 00:00:00');
|
||||||
|
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
|
||||||
|
|
||||||
|
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
|
||||||
|
$logRows = Db::name('tcm_diagnosis_assign_log')
|
||||||
|
->alias('lg')
|
||||||
|
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||||
|
->where('lg.create_time', '<=', $endTs)
|
||||||
|
->where('lg.diagnosis_id', '>', 0)
|
||||||
|
->field(['lg.id', 'lg.diagnosis_id', 'lg.to_assistant_id', 'lg.create_time', 'lg.is_inherit'])
|
||||||
|
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序) */
|
||||||
|
$timeline = [];
|
||||||
|
$pairsRaw = [];
|
||||||
|
/** @var array<int, array<int, true>> $diagsByAssistant 分母:医助 => 诊单集合 */
|
||||||
|
$diagsByAssistant = [];
|
||||||
|
/** @var array<int, true> $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */
|
||||||
|
$candidateDiagSet = [];
|
||||||
|
foreach ($logRows as $r) {
|
||||||
|
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
$aid = (int) ($r['to_assistant_id'] ?? 0);
|
||||||
|
$t = (int) ($r['create_time'] ?? 0);
|
||||||
|
$timeline[$did][] = ['t' => $t, 'to' => $aid];
|
||||||
|
if ($aid > 0) {
|
||||||
|
$candidateDiagSet[$did] = true;
|
||||||
|
if ((int) ($r['is_inherit'] ?? 0) === 0 && $t >= $startTs && $t <= $endTs) {
|
||||||
|
$pairsRaw[] = ['diagnosis_id' => $did, 'to_assistant_id' => $aid, 'create_time' => $t];
|
||||||
|
$diagsByAssistant[$aid][$did] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分母:剔除名下存在拒收(9)/退款(10) 业务订单的诊单(与明细 assignLines 同口径)
|
||||||
|
$assignedDiagIds = [];
|
||||||
|
foreach ($diagsByAssistant as $diagSet) {
|
||||||
|
foreach ($diagSet as $did => $_) {
|
||||||
|
$assignedDiagIds[(int) $did] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(array_keys($assignedDiagIds));
|
||||||
|
if ($refundRejectDiagSet !== []) {
|
||||||
|
foreach ($diagsByAssistant as $aid => $diagSet) {
|
||||||
|
foreach ($diagSet as $did => $_) {
|
||||||
|
if (isset($refundRejectDiagSet[$did])) {
|
||||||
|
unset($diagsByAssistant[$aid][$did]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($diagsByAssistant[$aid] === []) {
|
||||||
|
unset($diagsByAssistant[$aid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pairsRaw = array_values(array_filter(
|
||||||
|
$pairsRaw,
|
||||||
|
static fn (array $p): bool => !isset($refundRejectDiagSet[(int) $p['diagnosis_id']])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 0 → 第 1 笔实单为一诊)
|
||||||
|
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
|
||||||
|
$slotOrdersByAssistant = [];
|
||||||
|
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
|
||||||
|
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
|
||||||
|
$orderRows = self::fetchOrderSeqRows(
|
||||||
|
$chunk,
|
||||||
|
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
|
||||||
|
);
|
||||||
|
$curDid = 0;
|
||||||
|
$seq = 0;
|
||||||
|
$ptr = 0;
|
||||||
|
$holder = 0;
|
||||||
|
$offset = 0;
|
||||||
|
foreach ($orderRows as $r) {
|
||||||
|
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||||
|
if ($did <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($did !== $curDid) {
|
||||||
|
$curDid = $did;
|
||||||
|
$seq = 0;
|
||||||
|
$ptr = 0;
|
||||||
|
$holder = 0;
|
||||||
|
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||||
|
}
|
||||||
|
$seq++;
|
||||||
|
$effectiveSlot = $seq + $offset;
|
||||||
|
$ct = (int) ($r['create_time'] ?? 0);
|
||||||
|
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
|
||||||
|
$tl = $timeline[$did] ?? [];
|
||||||
|
$tlCount = \count($tl);
|
||||||
|
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
|
||||||
|
$holder = (int) $tl[$ptr]['to'];
|
||||||
|
$ptr++;
|
||||||
|
}
|
||||||
|
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ct < $startTs || $ct > $endTs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($holder > 0) {
|
||||||
|
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
||||||
|
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
||||||
|
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
||||||
|
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
|
||||||
|
if ($subtreeSet === []) {
|
||||||
|
// 无二中心部门时整表为空,避免误展示其它中心数据
|
||||||
|
$diagsByAssistant = [];
|
||||||
|
$slotOrdersByAssistant = [];
|
||||||
|
} else {
|
||||||
|
foreach ($universeIds as $aid) {
|
||||||
|
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
||||||
|
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
|
||||||
|
unset($diagsByAssistant[$aid], $slotOrdersByAssistant[$aid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'month' => $month,
|
||||||
|
'startDate' => date('Y-m-d', $startTs),
|
||||||
|
'endDate' => date('Y-m-d', $endTs),
|
||||||
|
'startTs' => $startTs,
|
||||||
|
'endTs' => $endTs,
|
||||||
|
'pairsRaw' => $pairsRaw,
|
||||||
|
'diagsByAssistant' => $diagsByAssistant,
|
||||||
|
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
||||||
|
'assistantDept' => $assistantDept,
|
||||||
|
'deptNames' => $deptNames,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计涉及的医助全集:分母(被指派)∪ 分子(持有成交)。
|
||||||
|
*
|
||||||
|
* @param array{diagsByAssistant:array<int,array<int,true>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
|
||||||
|
*
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
private static function assistantUniverse(array $ctx): array
|
||||||
|
{
|
||||||
|
$set = [];
|
||||||
|
foreach (array_keys($ctx['diagsByAssistant']) as $aid) {
|
||||||
|
$set[(int) $aid] = true;
|
||||||
|
}
|
||||||
|
foreach (array_keys($ctx['slotOrdersByAssistant']) as $aid) {
|
||||||
|
$set[(int) $aid] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行级 scope:assistant_id(医助行)优先;其次 dept_id(部门归组行,0=未分配部门);都不传 = 全部(已含部门筛选)。
|
||||||
|
*
|
||||||
|
* @return array<int, true> scope 内医助集合
|
||||||
|
*/
|
||||||
|
private static function applyRowScope(array $ctx, array $params): array
|
||||||
|
{
|
||||||
|
$assistantId = (int) ($params['assistant_id'] ?? 0);
|
||||||
|
$hasDeptScope = isset($params['dept_id']) && $params['dept_id'] !== '' && $params['dept_id'] !== null;
|
||||||
|
$deptScopeId = $hasDeptScope ? (int) $params['dept_id'] : -1;
|
||||||
|
|
||||||
|
$assistantSet = [];
|
||||||
|
foreach (self::assistantUniverse($ctx) as $aid => $_) {
|
||||||
|
if ($assistantId > 0) {
|
||||||
|
if ((int) $aid === $assistantId) {
|
||||||
|
$assistantSet[$aid] = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($hasDeptScope) {
|
||||||
|
if ((int) ($ctx['assistantDept'][$aid] ?? 0) === $deptScopeId) {
|
||||||
|
$assistantSet[$aid] = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$assistantSet[$aid] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $assistantSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医助 → 归属部门 + 部门名称表。
|
||||||
|
*
|
||||||
|
* @param list<int> $adminIds
|
||||||
|
*
|
||||||
|
* @return array{0: array<int,int>, 1: array<int,string>}
|
||||||
|
*/
|
||||||
|
private static function buildAssistantDeptIndex(array $adminIds): array
|
||||||
|
{
|
||||||
|
if ($adminIds === []) {
|
||||||
|
return [[], []];
|
||||||
|
}
|
||||||
|
// admin_dept 为 (admin_id, dept_id) 联合主键、无自增 id;取最小 dept_id 作为归属部门保证确定性
|
||||||
|
$relRows = Db::name('admin_dept')
|
||||||
|
->whereIn('admin_id', $adminIds)
|
||||||
|
->order(['admin_id' => 'asc', 'dept_id' => 'asc'])
|
||||||
|
->field(['admin_id', 'dept_id'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$deptNames = Db::name('dept')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->column('name', 'id');
|
||||||
|
|
||||||
|
$canonical = [];
|
||||||
|
foreach ($relRows as $r) {
|
||||||
|
$aid = (int) ($r['admin_id'] ?? 0);
|
||||||
|
$deptId = (int) ($r['dept_id'] ?? 0);
|
||||||
|
if ($aid <= 0 || $deptId <= 0 || isset($canonical[$aid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($deptNames[$deptId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$canonical[$aid] = $deptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
foreach ($deptNames as $id => $name) {
|
||||||
|
$names[(int) $id] = (string) $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$canonical, $names];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门筛选集合:始终落在「二中心」子树内。
|
||||||
|
* - 未传 dept_ids:整棵二中心子树
|
||||||
|
* - 已传:所选部门及其下级 ∩ 二中心子树(非法/非二中心 id 被忽略)
|
||||||
|
*
|
||||||
|
* @param mixed $raw
|
||||||
|
*
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
private static function resolveDeptFilterSet(mixed $raw): array
|
||||||
|
{
|
||||||
|
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
if ($erSet === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$deptFilterIds = self::parseDeptIds($raw);
|
||||||
|
if ($deptFilterIds === []) {
|
||||||
|
return $erSet;
|
||||||
|
}
|
||||||
|
$allowedRoots = [];
|
||||||
|
foreach ($deptFilterIds as $id) {
|
||||||
|
if (isset($erSet[$id])) {
|
||||||
|
$allowedRoots[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($allowedRoots === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$expanded = self::expandDeptSubtreeSet($allowedRoots);
|
||||||
|
$out = [];
|
||||||
|
foreach ($expanded as $id => $_) {
|
||||||
|
if (isset($erSet[$id])) {
|
||||||
|
$out[$id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $raw int[] | 逗号分隔字符串
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private static function parseDeptIds(mixed $raw): array
|
||||||
|
{
|
||||||
|
if ($raw === null || $raw === '' || $raw === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$list = \is_array($raw) ? $raw : explode(',', (string) $raw);
|
||||||
|
|
||||||
|
return array_values(array_unique(array_filter(
|
||||||
|
array_map('intval', $list),
|
||||||
|
static fn (int $v): bool => $v > 0
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选中部门 + 全部组织下级的 id 集合。
|
||||||
|
*
|
||||||
|
* @param list<int> $deptIds
|
||||||
|
*
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
private static function expandDeptSubtreeSet(array $deptIds): array
|
||||||
|
{
|
||||||
|
$rows = Db::name('dept')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->field(['id', 'pid'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$childrenByPid = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$childrenByPid[(int) $r['pid']][] = (int) $r['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$set = [];
|
||||||
|
$queue = $deptIds;
|
||||||
|
while ($queue !== []) {
|
||||||
|
$id = (int) array_shift($queue);
|
||||||
|
if ($id <= 0 || isset($set[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$set[$id] = true;
|
||||||
|
foreach ($childrenByPid[$id] ?? [] as $childId) {
|
||||||
|
$queue[] = $childId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单集合。
|
||||||
|
* 用于「当月被指派总数」分母过滤;不限订单创建月份。
|
||||||
|
*
|
||||||
|
* @param list<int> $diagIds
|
||||||
|
*
|
||||||
|
* @return array<int, true>
|
||||||
|
*/
|
||||||
|
private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array
|
||||||
|
{
|
||||||
|
if ($diagIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach (array_chunk($diagIds, 2000) as $chunk) {
|
||||||
|
$ids = Db::name('tcm_prescription_order')
|
||||||
|
->whereIn('diagnosis_id', $chunk)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->whereIn('fulfillment_status', [9, 10])
|
||||||
|
->group('diagnosis_id')
|
||||||
|
->column('diagnosis_id');
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$out[(int) $id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
|
||||||
|
*
|
||||||
|
* @param list<int> $diagIds
|
||||||
|
* @param list<string> $fields
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
|
||||||
|
{
|
||||||
|
$q = Db::name('tcm_prescription_order')
|
||||||
|
->alias('o')
|
||||||
|
->whereIn('o.diagnosis_id', $diagIds)
|
||||||
|
->whereNull('o.delete_time');
|
||||||
|
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||||
|
|
||||||
|
return $q
|
||||||
|
->field($fields)
|
||||||
|
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $diagIds
|
||||||
|
*
|
||||||
|
* @return array<int, int> diagnosis_id => revisit_slot_start_offset
|
||||||
|
*/
|
||||||
|
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
|
||||||
|
{
|
||||||
|
if ($diagIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach (array_chunk($diagIds, 2000) as $chunk) {
|
||||||
|
$rows = Db::name('tcm_diagnosis')
|
||||||
|
->whereIn('id', $chunk)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->column('revisit_slot_start_offset', 'id');
|
||||||
|
foreach ($rows as $id => $offset) {
|
||||||
|
$out[(int) $id] = (int) $offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移)
|
||||||
|
*
|
||||||
|
* @param array<int, int> $offsetMap
|
||||||
|
*/
|
||||||
|
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
|
||||||
|
{
|
||||||
|
$offset = (int) ($offsetMap[$diagId] ?? 0);
|
||||||
|
if ($offset < 0) {
|
||||||
|
$offset = 0;
|
||||||
|
}
|
||||||
|
if ($offset > 20) {
|
||||||
|
$offset = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $diagIds
|
||||||
|
*
|
||||||
|
* @return array<int, array{patient_name:string,phone:string}>
|
||||||
|
*/
|
||||||
|
private static function fetchDiagnosisInfo(array $diagIds): array
|
||||||
|
{
|
||||||
|
if ($diagIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$rows = Db::name('tcm_diagnosis')
|
||||||
|
->whereIn('id', $diagIds)
|
||||||
|
->field(['id', 'patient_name', 'phone'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$out[(int) $r['id']] = [
|
||||||
|
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
|
||||||
|
'phone' => trim((string) ($r['phone'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $slotCounts [slot => n]
|
||||||
|
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
||||||
|
*
|
||||||
|
* @return array<string, int|float|null>
|
||||||
|
*/
|
||||||
|
private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array
|
||||||
|
{
|
||||||
|
$pack = ['assigned_count' => $assigned];
|
||||||
|
foreach ($slots as $slot) {
|
||||||
|
$cnt = (int) ($slotCounts[$slot] ?? 0);
|
||||||
|
$pack['visit' . $slot . '_count'] = $cnt;
|
||||||
|
$pack['visit' . $slot . '_rate'] = $assigned > 0
|
||||||
|
? round($cnt / $assigned * 100, 2)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
|
||||||
|
private static function normalizeMonth(string $month): string
|
||||||
|
{
|
||||||
|
$month = trim($month);
|
||||||
|
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) {
|
||||||
|
return $month;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date('Y-m');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ use think\facade\Db;
|
|||||||
*
|
*
|
||||||
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
||||||
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
|
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
|
||||||
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,且 **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign)
|
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
|
||||||
|
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面「当月被指派总数」完全同口径(见 RevisitRateLogic)
|
||||||
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
|
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
|
||||||
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
||||||
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
||||||
@@ -857,7 +858,7 @@ class YejiStatsLogic
|
|||||||
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
||||||
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
|
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
|
||||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
||||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
||||||
|
|
||||||
$leaderboards = [];
|
$leaderboards = [];
|
||||||
@@ -1777,8 +1778,9 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0),**仅**按 **related_po_create_time** 落入区间(须 >0)。
|
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
|
||||||
* 选定字典渠道时 INNER JOIN 诊单以满足挂号 channels EXISTS;tag 退化口径只过滤日志字段,不强制挂诊单。
|
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
|
||||||
|
* 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
||||||
*
|
*
|
||||||
* @param array<int, int> $adminToPrimary
|
* @param array<int, int> $adminToPrimary
|
||||||
* @param int[]|null $tagDiagIds
|
* @param int[]|null $tagDiagIds
|
||||||
@@ -1801,11 +1803,13 @@ class YejiStatsLogic
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||||
$query = Db::name('tcm_diagnosis_assign_log')
|
$query = Db::name('tcm_diagnosis_assign_log')
|
||||||
->alias('lg')
|
->alias('lg')
|
||||||
->where('lg.related_po_create_time', '>', 0)
|
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||||
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
|
->where('lg.create_time', 'between', [$startTs, $endTs])
|
||||||
->where('lg.to_assistant_id', '>', 0);
|
->where('lg.to_assistant_id', '>', 0)
|
||||||
|
->where('lg.is_inherit', 0);
|
||||||
if ($channelFilterActive) {
|
if ($channelFilterActive) {
|
||||||
if ($appointmentChannelValues !== []) {
|
if ($appointmentChannelValues !== []) {
|
||||||
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
|
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
|
||||||
@@ -1816,8 +1820,6 @@ class YejiStatsLogic
|
|||||||
if ($pack === null) {
|
if ($pack === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
|
||||||
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
|
|
||||||
$query->whereRaw($pack[0], $pack[1]);
|
$query->whereRaw($pack[0], $pack[1]);
|
||||||
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
||||||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||||
@@ -1837,7 +1839,7 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
|
$query->field(['lg.to_assistant_id', Db::raw('COUNT(DISTINCT lg.diagnosis_id) AS cnt')])
|
||||||
->group('lg.to_assistant_id');
|
->group('lg.to_assistant_id');
|
||||||
|
|
||||||
$rows = $query->select()->toArray();
|
$rows = $query->select()->toArray();
|
||||||
@@ -1869,7 +1871,7 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间,按 to_assistant_id 汇总(供排行榜)。
|
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1),按 to_assistant_id 汇总(供排行榜)。
|
||||||
*
|
*
|
||||||
* @return array<int, int>
|
* @return array<int, int>
|
||||||
*/
|
*/
|
||||||
@@ -1883,11 +1885,13 @@ class YejiStatsLogic
|
|||||||
?array $dataScopeAdminIds = null,
|
?array $dataScopeAdminIds = null,
|
||||||
?array $channelInfo = null
|
?array $channelInfo = null
|
||||||
): array {
|
): array {
|
||||||
|
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||||
$query = Db::name('tcm_diagnosis_assign_log')
|
$query = Db::name('tcm_diagnosis_assign_log')
|
||||||
->alias('lg')
|
->alias('lg')
|
||||||
->where('lg.related_po_create_time', '>', 0)
|
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||||
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
|
->where('lg.create_time', 'between', [$startTs, $endTs])
|
||||||
->where('lg.to_assistant_id', '>', 0);
|
->where('lg.to_assistant_id', '>', 0)
|
||||||
|
->where('lg.is_inherit', 0);
|
||||||
|
|
||||||
if ($channelFilterActive) {
|
if ($channelFilterActive) {
|
||||||
if ($appointmentChannelValues !== []) {
|
if ($appointmentChannelValues !== []) {
|
||||||
@@ -1899,8 +1903,6 @@ class YejiStatsLogic
|
|||||||
if ($pack === null) {
|
if ($pack === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
|
||||||
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
|
|
||||||
$query->whereRaw($pack[0], $pack[1]);
|
$query->whereRaw($pack[0], $pack[1]);
|
||||||
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
||||||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||||
@@ -1920,7 +1922,7 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
|
$query->field(['lg.to_assistant_id', Db::raw('COUNT(DISTINCT lg.diagnosis_id) AS cnt')])
|
||||||
->group('lg.to_assistant_id');
|
->group('lg.to_assistant_id');
|
||||||
|
|
||||||
$rows = $query->select()->toArray();
|
$rows = $query->select()->toArray();
|
||||||
@@ -2614,6 +2616,420 @@ class YejiStatsLogic
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。
|
||||||
|
*
|
||||||
|
* @param array{
|
||||||
|
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
|
||||||
|
* assistant_id?:int|string,admin_id?:int|string,dept_id?:int|string,
|
||||||
|
* page?:int|string,page_size?:int|string
|
||||||
|
* } $params
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function assignLines(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
|
||||||
|
{
|
||||||
|
$c = self::resolveYejiContext($params);
|
||||||
|
if ($viewerAdminId > 0) {
|
||||||
|
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assistantId = (int) ($params['assistant_id'] ?? $params['admin_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($assistantId <= 0 && array_key_exists('dept_id', $params)) {
|
||||||
|
return self::yejiDeptAssignLinesInner($params, $c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::yejiAssistantAssignLinesInner($params, $c, $assistantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @param array<string, mixed> $c
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function yejiDeptAssignLinesInner(array $params, array $c): array
|
||||||
|
{
|
||||||
|
$deptIdParam = (int) ($params['dept_id'] ?? 0);
|
||||||
|
$channelCode = (string) $c['channelCode'];
|
||||||
|
$deptName = $deptIdParam === 0
|
||||||
|
? '未归属中心'
|
||||||
|
: self::formatYejiDeptRowDisplayName($deptIdParam, $c['deptById']);
|
||||||
|
|
||||||
|
$empty = static function (string $note) use ($c, $channelCode, $deptIdParam, $deptName): array {
|
||||||
|
return self::yejiAssignLinesEmptyPayload($c, $note, 0, '', $deptIdParam, $deptName, $channelCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($deptIdParam <= 0) {
|
||||||
|
return $empty('未归属中心无被指派明细(看板该行为 0)。');
|
||||||
|
}
|
||||||
|
if (!in_array($deptIdParam, $c['tableRowDeptIds'], true)) {
|
||||||
|
return $empty('该部门不在当前展示部门筛选或数据权限范围内。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
if ($channelCode !== '' && isset($erCenterDeptSet[$deptIdParam])) {
|
||||||
|
return $empty('与看板一致:二中心及其组织下级在选定渠道下被指派数计 0,无明细。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetAssistantIds = [];
|
||||||
|
foreach ($c['adminToPrimary'] as $aid => $primary) {
|
||||||
|
if ((int) $primary === $deptIdParam) {
|
||||||
|
$targetAssistantIds[] = (int) $aid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($targetAssistantIds === []) {
|
||||||
|
return $empty('当前部门下没有可映射的被指派医助,无明细。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::yejiAssignLinesFetch($c, $params, $targetAssistantIds, 0, '', $deptIdParam, $deptName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @param array<string, mixed> $c
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function yejiAssistantAssignLinesInner(array $params, array $c, int $assistantId): array
|
||||||
|
{
|
||||||
|
$channelCode = (string) $c['channelCode'];
|
||||||
|
|
||||||
|
$assistantName = '';
|
||||||
|
if ($assistantId > 0) {
|
||||||
|
$assistantName = (string) (Db::name('admin')->where('id', $assistantId)->whereNull('delete_time')->value('name') ?: '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$empty = static function (string $note) use ($c, $assistantId, $assistantName, $channelCode): array {
|
||||||
|
return self::yejiAssignLinesEmptyPayload($c, $note, $assistantId, $assistantName, 0, '', $channelCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($assistantId <= 0) {
|
||||||
|
return $empty('请指定有效医助,或传入 dept_id 查看部门汇总明细。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
|
||||||
|
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
|
||||||
|
? $c['dataScopeVisibleAdminIds']
|
||||||
|
: null;
|
||||||
|
if ($dataScopeRestricted && $dataScopeAdminIds !== null) {
|
||||||
|
$flip = array_flip($dataScopeAdminIds);
|
||||||
|
if (!isset($flip[$assistantId])) {
|
||||||
|
return $empty('当前账号数据权限下不可查看该医助被指派明细。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($c['adminToPrimary'][$assistantId])) {
|
||||||
|
return $empty('该医助不在当前业绩看板展示范围内。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagDiagIds = $c['tagDiagIds'];
|
||||||
|
$tagAssistantIds = $c['tagAssistantIds'];
|
||||||
|
$tagFallback = $c['tagFallback'];
|
||||||
|
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
|
||||||
|
|
||||||
|
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||||
|
return $empty('当前渠道标签下无关联诊单,无被指派明细。');
|
||||||
|
}
|
||||||
|
if ($scopedAssistants !== null && $scopedAssistants === []) {
|
||||||
|
return $empty('当前渠道标签下无关联医助,无被指派明细。');
|
||||||
|
}
|
||||||
|
if ($scopedAssistants !== null && !isset($scopedAssistants[$assistantId])) {
|
||||||
|
return $empty('当前渠道标签口径下该医助无被指派记录(与排行榜「被指派数」一致)。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
$primaryDept = (int) ($c['adminToPrimary'][$assistantId] ?? 0);
|
||||||
|
if ($channelCode !== '' && $primaryDept > 0 && isset($erCenterDeptSet[$primaryDept])) {
|
||||||
|
return $empty('与看板一致:二中心及其组织下级医助在选定渠道下被指派数计 0,无明细。');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::yejiAssignLinesFetch(
|
||||||
|
$c,
|
||||||
|
$params,
|
||||||
|
[$assistantId],
|
||||||
|
$assistantId,
|
||||||
|
$assistantName,
|
||||||
|
0,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $c
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @param list<int> $targetAssistantIds
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function yejiAssignLinesFetch(
|
||||||
|
array $c,
|
||||||
|
array $params,
|
||||||
|
array $targetAssistantIds,
|
||||||
|
int $assistantIdOut,
|
||||||
|
string $assistantNameOut,
|
||||||
|
int $deptIdOut,
|
||||||
|
string $deptNameOut
|
||||||
|
): array {
|
||||||
|
$channelCode = (string) $c['channelCode'];
|
||||||
|
$startTs = (int) $c['startTs'];
|
||||||
|
$endTs = (int) $c['endTs'];
|
||||||
|
$page = max(1, (int) ($params['page'] ?? 1));
|
||||||
|
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
|
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
|
||||||
|
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
|
||||||
|
? $c['dataScopeVisibleAdminIds']
|
||||||
|
: null;
|
||||||
|
if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) {
|
||||||
|
$visFlip = array_flip($dataScopeAdminIds);
|
||||||
|
$targetAssistantIds = array_values(array_filter(
|
||||||
|
$targetAssistantIds,
|
||||||
|
static fn (int $aid): bool => isset($visFlip[$aid])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetAssistantIds === []) {
|
||||||
|
return self::yejiAssignLinesEmptyPayload(
|
||||||
|
$c,
|
||||||
|
'当前数据权限下无可查看的被指派医助。',
|
||||||
|
$assistantIdOut,
|
||||||
|
$assistantNameOut,
|
||||||
|
$deptIdOut,
|
||||||
|
$deptNameOut,
|
||||||
|
$channelCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagDiagIds = $c['tagDiagIds'];
|
||||||
|
$tagAssistantIds = $c['tagAssistantIds'];
|
||||||
|
$tagFallback = $c['tagFallback'];
|
||||||
|
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
|
||||||
|
|
||||||
|
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||||
|
return self::yejiAssignLinesEmptyPayload(
|
||||||
|
$c,
|
||||||
|
'当前渠道标签下无关联诊单,无被指派明细。',
|
||||||
|
$assistantIdOut,
|
||||||
|
$assistantNameOut,
|
||||||
|
$deptIdOut,
|
||||||
|
$deptNameOut,
|
||||||
|
$channelCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($scopedAssistants !== null && $scopedAssistants === []) {
|
||||||
|
return self::yejiAssignLinesEmptyPayload(
|
||||||
|
$c,
|
||||||
|
'当前渠道标签下无关联医助,无被指派明细。',
|
||||||
|
$assistantIdOut,
|
||||||
|
$assistantNameOut,
|
||||||
|
$deptIdOut,
|
||||||
|
$deptNameOut,
|
||||||
|
$channelCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$buildBaseQuery = static function () use (
|
||||||
|
$c,
|
||||||
|
$targetAssistantIds,
|
||||||
|
$startTs,
|
||||||
|
$endTs,
|
||||||
|
$tagDiagIds,
|
||||||
|
$scopedAssistants
|
||||||
|
) {
|
||||||
|
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||||
|
$q = Db::name('tcm_diagnosis_assign_log')
|
||||||
|
->alias('lg')
|
||||||
|
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||||
|
->where('lg.create_time', 'between', [$startTs, $endTs])
|
||||||
|
->where('lg.to_assistant_id', '>', 0)
|
||||||
|
->where('lg.is_inherit', 0)
|
||||||
|
->whereIn('lg.to_assistant_id', $targetAssistantIds);
|
||||||
|
|
||||||
|
self::applyYejiAssignLogChannelFilters(
|
||||||
|
$q,
|
||||||
|
$tagDiagIds,
|
||||||
|
$scopedAssistants,
|
||||||
|
$c['appointmentChannelValues'],
|
||||||
|
$c['channelFilterActive'],
|
||||||
|
$c['channelInfo']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $q;
|
||||||
|
};
|
||||||
|
|
||||||
|
$countRows = $buildBaseQuery()
|
||||||
|
->field(['lg.to_assistant_id', 'lg.diagnosis_id'])
|
||||||
|
->group('lg.to_assistant_id,lg.diagnosis_id')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$count = \count($countRows);
|
||||||
|
|
||||||
|
$rawList = $buildBaseQuery()
|
||||||
|
->field([
|
||||||
|
'lg.to_assistant_id',
|
||||||
|
'lg.diagnosis_id',
|
||||||
|
Db::raw('COUNT(*) AS assign_count'),
|
||||||
|
Db::raw('MAX(lg.create_time) AS last_assign_time'),
|
||||||
|
])
|
||||||
|
->group('lg.to_assistant_id,lg.diagnosis_id')
|
||||||
|
->order('last_assign_time', 'desc')
|
||||||
|
->order('lg.diagnosis_id', 'desc')
|
||||||
|
->limit($offset, $limit)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$diagIds = [];
|
||||||
|
$assistantIds = [];
|
||||||
|
foreach ($rawList as $rw) {
|
||||||
|
$diagIds[(int) ($rw['diagnosis_id'] ?? 0)] = true;
|
||||||
|
$assistantIds[(int) ($rw['to_assistant_id'] ?? 0)] = true;
|
||||||
|
}
|
||||||
|
$diagInfo = self::fetchYejiAssignDiagnosisInfo(array_keys($diagIds));
|
||||||
|
$nameMap = $assistantIds !== []
|
||||||
|
? Db::name('admin')->whereIn('id', array_keys($assistantIds))->column('name', 'id')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$lists = [];
|
||||||
|
foreach ($rawList as $rw) {
|
||||||
|
$did = (int) ($rw['diagnosis_id'] ?? 0);
|
||||||
|
$aid = (int) ($rw['to_assistant_id'] ?? 0);
|
||||||
|
$lastTs = (int) ($rw['last_assign_time'] ?? 0);
|
||||||
|
$lists[] = [
|
||||||
|
'diagnosis_id' => $did,
|
||||||
|
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
|
||||||
|
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
|
||||||
|
'assistant_id' => $aid,
|
||||||
|
'assistant_name' => $aid > 0 ? (string) ($nameMap[$aid] ?? ('#' . $aid)) : '—',
|
||||||
|
'assign_count' => (int) ($rw['assign_count'] ?? 0),
|
||||||
|
'last_assign_time' => $lastTs,
|
||||||
|
'last_assign_time_text' => $lastTs > 0 ? date('Y-m-d H:i:s', $lastTs) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
|
||||||
|
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'start_date' => (string) $c['startDate'],
|
||||||
|
'end_date' => (string) $c['endDate'],
|
||||||
|
'assistant_id' => $assistantIdOut,
|
||||||
|
'assistant_name' => $assistantNameOut,
|
||||||
|
'dept_id' => $deptIdOut,
|
||||||
|
'dept_name' => $deptNameOut,
|
||||||
|
'channel_code' => $channelCode,
|
||||||
|
'count' => $count,
|
||||||
|
'lists' => $lists,
|
||||||
|
'note' => $note,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $c
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function yejiAssignLinesEmptyPayload(
|
||||||
|
array $c,
|
||||||
|
string $note,
|
||||||
|
int $assistantId,
|
||||||
|
string $assistantName,
|
||||||
|
int $deptId,
|
||||||
|
string $deptName,
|
||||||
|
string $channelCode
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'start_date' => (string) $c['startDate'],
|
||||||
|
'end_date' => (string) $c['endDate'],
|
||||||
|
'assistant_id' => $assistantId,
|
||||||
|
'assistant_name' => $assistantName,
|
||||||
|
'dept_id' => $deptId,
|
||||||
|
'dept_name' => $deptName,
|
||||||
|
'channel_code' => $channelCode,
|
||||||
|
'count' => 0,
|
||||||
|
'lists' => [],
|
||||||
|
'note' => $note,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \think\db\Query $query
|
||||||
|
*/
|
||||||
|
private static function applyYejiAssignLogChannelFilters(
|
||||||
|
$query,
|
||||||
|
?array $tagDiagIds,
|
||||||
|
?array $tagAssistantIds,
|
||||||
|
array $appointmentChannelValues,
|
||||||
|
bool $channelFilterActive,
|
||||||
|
?array $channelInfo
|
||||||
|
): void {
|
||||||
|
if (!$channelFilterActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($appointmentChannelValues !== []) {
|
||||||
|
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
|
||||||
|
if ($norm === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo);
|
||||||
|
if ($pack === null) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$query->whereRaw($pack[0], $pack[1]);
|
||||||
|
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
||||||
|
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($tagDiagIds !== null) {
|
||||||
|
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
|
||||||
|
}
|
||||||
|
if ($tagAssistantIds !== null) {
|
||||||
|
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $diagIds
|
||||||
|
*
|
||||||
|
* @return array<int, array{patient_name:string,phone:string}>
|
||||||
|
*/
|
||||||
|
private static function fetchYejiAssignDiagnosisInfo(array $diagIds): array
|
||||||
|
{
|
||||||
|
if ($diagIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$rows = Db::name('tcm_diagnosis')
|
||||||
|
->whereIn('id', $diagIds)
|
||||||
|
->field(['id', 'patient_name', 'phone'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$out[(int) $r['id']] = [
|
||||||
|
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
|
||||||
|
'phone' => trim((string) ($r['phone'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* admin → 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。
|
* admin → 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use app\common\model\DiagnosisViewRecord;
|
|||||||
use app\common\model\doctor\Appointment;
|
use app\common\model\doctor\Appointment;
|
||||||
use app\common\model\auth\Admin;
|
use app\common\model\auth\Admin;
|
||||||
use app\common\model\auth\AdminRole;
|
use app\common\model\auth\AdminRole;
|
||||||
|
use app\adminapi\logic\auth\AuthLogic;
|
||||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||||
@@ -73,8 +74,8 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成患者ID
|
// 新患者先占位 0,写入后 patient_id 与自增 id 对齐
|
||||||
$params['patient_id'] = self::generatePatientId();
|
$params['patient_id'] = 0;
|
||||||
|
|
||||||
// 处理既往史数组
|
// 处理既往史数组
|
||||||
if (isset($params['past_history']) && is_array($params['past_history'])) {
|
if (isset($params['past_history']) && is_array($params['past_history'])) {
|
||||||
@@ -119,6 +120,7 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$model = Diagnosis::create($params);
|
$model = Diagnosis::create($params);
|
||||||
|
$model = self::syncPatientIdWithDiagnosisId($model);
|
||||||
|
|
||||||
// 图片写入 doctor_note 表
|
// 图片写入 doctor_note 表
|
||||||
if (!empty($newTongueImages) || !empty($newReportFiles)) {
|
if (!empty($newTongueImages) || !empty($newReportFiles)) {
|
||||||
@@ -130,7 +132,7 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自动为患者创建 TRTC 账号
|
// 自动为患者创建 TRTC 账号
|
||||||
self::createPatientTrtcAccount($model->id);
|
self::createPatientTrtcAccount($model->patient_id);
|
||||||
|
|
||||||
return $model->id;
|
return $model->id;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -140,16 +142,20 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 生成患者ID
|
* @notes 新患者首张诊单:patient_id 与自增 id 对齐
|
||||||
* @return int
|
* @param Diagnosis $model
|
||||||
|
* @return Diagnosis
|
||||||
*/
|
*/
|
||||||
private static function generatePatientId(): int
|
private static function syncPatientIdWithDiagnosisId(Diagnosis $model): Diagnosis
|
||||||
{
|
{
|
||||||
// 获取当前最大的患者ID
|
if ((int) $model->patient_id === (int) $model->id) {
|
||||||
$maxPatientId = Diagnosis::max('id') ?? 10000000;
|
return $model;
|
||||||
|
}
|
||||||
// 返回下一个患者ID
|
|
||||||
return $maxPatientId + 1;
|
Diagnosis::where('id', $model->id)->update(['patient_id' => $model->id]);
|
||||||
|
$model->patient_id = (int) $model->id;
|
||||||
|
|
||||||
|
return $model;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,9 +163,23 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
* @param array $params
|
* @param array $params
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function edit(array $params): bool
|
/** 诊单编辑:患者基本信息字段(姓名/身份证/手机/性别/年龄) */
|
||||||
|
private const PATIENT_BASIC_FIELDS = ['patient_name', 'id_card', 'phone', 'gender', 'age'];
|
||||||
|
|
||||||
|
public static function edit(array $params, array $adminInfo = []): bool
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
if (!empty($params['id']) && $adminInfo !== []) {
|
||||||
|
$existing = Diagnosis::find((int) $params['id']);
|
||||||
|
if ($existing && !self::canEditPatientBasicInfo((int) $params['id'], $adminInfo)) {
|
||||||
|
if (self::patientBasicFieldsChanged($existing, $params)) {
|
||||||
|
self::setError('最近业务订单未完成,无法修改患者基本信息');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查手机号是否重复(排除当前记录)
|
// 检查手机号是否重复(排除当前记录)
|
||||||
if (!empty($params['phone'])) {
|
if (!empty($params['phone'])) {
|
||||||
$exists = Diagnosis::where('phone', $params['phone'])
|
$exists = Diagnosis::where('phone', $params['phone'])
|
||||||
@@ -210,6 +230,10 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$params['diagnosis_date'] = strtotime($params['diagnosis_date']);
|
$params['diagnosis_date'] = strtotime($params['diagnosis_date']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('show_card', $params)) {
|
||||||
|
$params['show_card'] = (int) $params['show_card'] === 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
Diagnosis::update($params);
|
Diagnosis::update($params);
|
||||||
|
|
||||||
// 如果是编辑,也确保患者有 TRTC 账号
|
// 如果是编辑,也确保患者有 TRTC 账号
|
||||||
@@ -246,7 +270,7 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
* @param $params
|
* @param $params
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public static function detail($params): array
|
public static function detail($params, array $adminInfo = []): array
|
||||||
{
|
{
|
||||||
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
|
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
|
||||||
|
|
||||||
@@ -313,7 +337,17 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$diagnosis['local_hospital_visit_date'] = $diagnosis['diagnosis_date'];
|
$diagnosis['local_hospital_visit_date'] = $diagnosis['diagnosis_date'];
|
||||||
}
|
}
|
||||||
$diagnosis['is_view'] =DiagnosisViewRecord::where(['diagnosis_id'=> $diagnosis['id'], 'user_id' =>$params['user_id']??0])->find() ? 1 : 0;
|
$diagnosis['is_view'] =DiagnosisViewRecord::where(['diagnosis_id'=> $diagnosis['id'], 'user_id' =>$params['user_id']??0])->find() ? 1 : 0;
|
||||||
|
|
||||||
|
$latestPo = self::getLatestPrescriptionOrderRowForDiagnosis((int) ($diagnosis['id'] ?? 0));
|
||||||
|
$patientBasicLocked = $latestPo !== null && (int) ($latestPo['fulfillment_status'] ?? 0) !== 3;
|
||||||
|
$diagnosis['patient_basic_locked'] = $patientBasicLocked;
|
||||||
|
$diagnosis['can_edit_patient_basic'] = self::canEditPatientBasicInfo((int) ($diagnosis['id'] ?? 0), $adminInfo);
|
||||||
|
$diagnosis['latest_prescription_order'] = $latestPo !== null ? [
|
||||||
|
'id' => (int) ($latestPo['id'] ?? 0),
|
||||||
|
'fulfillment_status' => (int) ($latestPo['fulfillment_status'] ?? 0),
|
||||||
|
'fulfillment_status_text'=> PrescriptionOrderLogic::fulfillmentStatusLabel((int) ($latestPo['fulfillment_status'] ?? 0)),
|
||||||
|
] : null;
|
||||||
|
|
||||||
return $diagnosis;
|
return $diagnosis;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,11 +516,11 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
|
* 诊单下「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
|
||||||
*
|
*
|
||||||
* @return array{creator_id: int, create_time: int}|null
|
* @return array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
|
private static function getLatestPrescriptionOrderRowForDiagnosis(int $diagnosisId): ?array
|
||||||
{
|
{
|
||||||
if ($diagnosisId <= 0) {
|
if ($diagnosisId <= 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -496,19 +530,88 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
->whereNull('delete_time')
|
->whereNull('delete_time')
|
||||||
->order('create_time', 'desc')
|
->order('create_time', 'desc')
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->field(['creator_id', 'create_time'])
|
->field(['id', 'creator_id', 'create_time', 'fulfillment_status'])
|
||||||
->find();
|
->find();
|
||||||
|
|
||||||
if ($row === null || $row === []) {
|
if ($row === null || $row === []) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
|
||||||
|
*
|
||||||
|
* @return array{creator_id: int, create_time: int}|null
|
||||||
|
*/
|
||||||
|
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
|
||||||
|
{
|
||||||
|
$row = self::getLatestPrescriptionOrderRowForDiagnosis($diagnosisId);
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'creator_id' => (int) ($row['creator_id'] ?? 0),
|
'creator_id' => (int) ($row['creator_id'] ?? 0),
|
||||||
'create_time' => (int) ($row['create_time'] ?? 0),
|
'create_time' => (int) ($row['create_time'] ?? 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单权限 tcm.diagnosis/editPatientBasic:最近业务订单未完成时仍可改患者基本信息
|
||||||
|
*/
|
||||||
|
public static function hasEditPatientBasicPermission(array $adminInfo): bool
|
||||||
|
{
|
||||||
|
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
|
||||||
|
|
||||||
|
return in_array('tcm.diagnosis/editPatientBasic', $perms, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许编辑诊单患者基本信息(无业务单 / 最近一单已完成 / 具备 editPatientBasic 权限)
|
||||||
|
*/
|
||||||
|
public static function canEditPatientBasicInfo(int $diagnosisId, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
$latest = self::getLatestPrescriptionOrderRowForDiagnosis($diagnosisId);
|
||||||
|
if ($latest === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ((int) ($latest['fulfillment_status'] ?? 0) === 3) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::hasEditPatientBasicPermission($adminInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Diagnosis $existing
|
||||||
|
*/
|
||||||
|
private static function patientBasicFieldsChanged($existing, array $params): bool
|
||||||
|
{
|
||||||
|
foreach (self::PATIENT_BASIC_FIELDS as $field) {
|
||||||
|
if (!array_key_exists($field, $params)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$old = $existing->{$field};
|
||||||
|
$new = $params[$field];
|
||||||
|
if ($field === 'age' || $field === 'gender') {
|
||||||
|
if ((int) $old !== (int) $new) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((string) $old !== (string) $new) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 诊单 current assistant_id 为 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。
|
* 诊单 current assistant_id 为 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。
|
||||||
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
|
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
|
||||||
@@ -645,6 +748,49 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @notes 诊单指派医助操作记录列表
|
||||||
|
*/
|
||||||
|
public static function setRevisitSlotStartOffset(int $diagnosisId, int $offset, array $adminInfo): bool
|
||||||
|
{
|
||||||
|
self::$error = '';
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
self::setError('诊单不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($offset < 0 || $offset > 20) {
|
||||||
|
self::setError('起始偏移须在 0~20 之间');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$diagnosis = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find();
|
||||||
|
if (!$diagnosis) {
|
||||||
|
self::setError('诊单不存在');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$old = (int) ($diagnosis->revisit_slot_start_offset ?? 0);
|
||||||
|
if ($old < 0) {
|
||||||
|
$old = 0;
|
||||||
|
}
|
||||||
|
if ($old > 20) {
|
||||||
|
$old = 20;
|
||||||
|
}
|
||||||
|
if ($old === $offset) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
self::setError($e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 诊单指派医助操作记录列表
|
* @notes 诊单指派医助操作记录列表
|
||||||
*/
|
*/
|
||||||
@@ -3088,8 +3234,9 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record
|
unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record
|
||||||
|
|
||||||
$patientId = $params['patient_id'] ?? 0;
|
$patientId = $params['patient_id'] ?? 0;
|
||||||
if (!$patientId) {
|
$isNewPatient = !$patientId;
|
||||||
$params['patient_id'] = self::generatePatientId();
|
if ($isNewPatient) {
|
||||||
|
$params['patient_id'] = 0;
|
||||||
}
|
}
|
||||||
$params['status'] = 1;
|
$params['status'] = 1;
|
||||||
|
|
||||||
@@ -3119,6 +3266,9 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
$model = Diagnosis::create($params);
|
$model = Diagnosis::create($params);
|
||||||
|
if ($isNewPatient) {
|
||||||
|
$model = self::syncPatientIdWithDiagnosisId($model);
|
||||||
|
}
|
||||||
self::createPatientTrtcAccount($model->patient_id);
|
self::createPatientTrtcAccount($model->patient_id);
|
||||||
|
|
||||||
// 图片写入 doctor_note
|
// 图片写入 doctor_note
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'gender' => 'require|in:0,1',
|
'gender' => 'require|in:0,1',
|
||||||
'age' => 'require|number|between:0,150',
|
'age' => 'require|number|between:0,150',
|
||||||
'diagnosis_type' => 'require',
|
'diagnosis_type' => 'require',
|
||||||
|
'local_hospital_name' => 'require|max:255',
|
||||||
'status' => 'in:0,1',
|
'status' => 'in:0,1',
|
||||||
|
'show_card' => 'in:0,1',
|
||||||
'create_source' => 'max:32',
|
'create_source' => 'max:32',
|
||||||
'current_medications' => 'max:2000',
|
'current_medications' => 'max:2000',
|
||||||
'tongue_images' => 'array',
|
'tongue_images' => 'array',
|
||||||
@@ -42,6 +44,7 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'end_date' => 'date|checkDateRange',
|
'end_date' => 'date|checkDateRange',
|
||||||
'diagnosis_id' => 'require|integer|checkDiagnosisId',
|
'diagnosis_id' => 'require|integer|checkDiagnosisId',
|
||||||
'tracking_content' => 'require|length:1,1000',
|
'tracking_content' => 'require|length:1,1000',
|
||||||
|
'revisit_slot_start_offset' => 'integer|between:0,20',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $message = [
|
protected $message = [
|
||||||
@@ -49,6 +52,7 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'patient_name.require' => '请输入患者姓名',
|
'patient_name.require' => '请输入患者姓名',
|
||||||
'patient_name.length' => '患者姓名长度须在1-50位字符',
|
'patient_name.length' => '患者姓名长度须在1-50位字符',
|
||||||
'id_card.length' => '身份证号长度不正确',
|
'id_card.length' => '身份证号长度不正确',
|
||||||
|
'id_card.require' => '请输入身份证号',
|
||||||
'phone.require' => '请输入手机号',
|
'phone.require' => '请输入手机号',
|
||||||
'phone.mobile' => '手机号格式不正确',
|
'phone.mobile' => '手机号格式不正确',
|
||||||
'gender.require' => '请选择性别',
|
'gender.require' => '请选择性别',
|
||||||
@@ -57,6 +61,8 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
'age.number' => '年龄必须为数字',
|
'age.number' => '年龄必须为数字',
|
||||||
'age.between' => '年龄范围0-150',
|
'age.between' => '年龄范围0-150',
|
||||||
'diagnosis_type.require' => '请选择诊断类型',
|
'diagnosis_type.require' => '请选择诊断类型',
|
||||||
|
'local_hospital_name.require' => '请输入当地就诊医院名称',
|
||||||
|
'local_hospital_name.max' => '当地就诊医院名称最多255个字符',
|
||||||
'status.in' => '状态参数错误',
|
'status.in' => '状态参数错误',
|
||||||
'create_source.max' => '渠道来源长度不能超过32个字符',
|
'create_source.max' => '渠道来源长度不能超过32个字符',
|
||||||
'current_medications.max' => '在用药物最多2000个字符',
|
'current_medications.max' => '在用药物最多2000个字符',
|
||||||
@@ -78,7 +84,7 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
|
|
||||||
public function sceneEdit()
|
public function sceneEdit()
|
||||||
{
|
{
|
||||||
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'create_source']);
|
return $this->only(['id', 'patient_name', 'id_card', 'phone', 'gender', 'age', 'diagnosis_date', 'diagnosis_type', 'syndrome_type', 'marital_status', 'height', 'weight', 'region', 'systolic_pressure', 'diastolic_pressure', 'fasting_blood_sugar', 'diabetes_discovery_year', 'local_hospital_diagnosis', 'local_hospital_name', 'past_history', 'symptoms', 'tongue_coating', 'pulse', 'treatment_principle', 'prescription', 'doctor_advice', 'remark', 'current_medications', 'status', 'show_card', 'create_source']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sceneId()
|
public function sceneId()
|
||||||
@@ -134,6 +140,13 @@ class DiagnosisValidate extends BaseValidate
|
|||||||
return $this->only(['id']);
|
return $this->only(['id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 业务订单 tab:设置复诊接诊率统计起始偏移 */
|
||||||
|
public function sceneSetRevisitSlotStartOffset()
|
||||||
|
{
|
||||||
|
return $this->only(['id', 'revisit_slot_start_offset'])
|
||||||
|
->append('revisit_slot_start_offset', 'require|integer|between:0,20');
|
||||||
|
}
|
||||||
|
|
||||||
protected function checkDiagnosis($value)
|
protected function checkDiagnosis($value)
|
||||||
{
|
{
|
||||||
$diagnosis = Diagnosis::findOrEmpty($value);
|
$diagnosis = Diagnosis::findOrEmpty($value);
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ class PrescriptionLibraryValidate extends BaseValidate
|
|||||||
'prescription_name' => 'require|max:100',
|
'prescription_name' => 'require|max:100',
|
||||||
'formula_type' => 'in:主方,辅方',
|
'formula_type' => 'in:主方,辅方',
|
||||||
'herbs' => 'require|array',
|
'herbs' => 'require|array',
|
||||||
'is_public' => 'in:0,1'
|
'is_public' => 'in:0,1',
|
||||||
|
'disable_edit' => 'in:0,1'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $message = [
|
protected $message = [
|
||||||
@@ -27,7 +28,8 @@ class PrescriptionLibraryValidate extends BaseValidate
|
|||||||
'formula_type.in' => '处方类型只能是主方或辅方',
|
'formula_type.in' => '处方类型只能是主方或辅方',
|
||||||
'herbs.require' => '药材列表不能为空',
|
'herbs.require' => '药材列表不能为空',
|
||||||
'herbs.array' => '药材列表格式错误',
|
'herbs.array' => '药材列表格式错误',
|
||||||
'is_public.in' => '是否公开参数错误'
|
'is_public.in' => '是否公开参数错误',
|
||||||
|
'disable_edit.in' => '禁用修改参数错误'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +37,7 @@ class PrescriptionLibraryValidate extends BaseValidate
|
|||||||
*/
|
*/
|
||||||
public function sceneAdd()
|
public function sceneAdd()
|
||||||
{
|
{
|
||||||
return $this->only(['prescription_name', 'formula_type', 'herbs', 'is_public']);
|
return $this->only(['prescription_name', 'formula_type', 'herbs', 'is_public', 'disable_edit']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +45,7 @@ class PrescriptionLibraryValidate extends BaseValidate
|
|||||||
*/
|
*/
|
||||||
public function sceneEdit()
|
public function sceneEdit()
|
||||||
{
|
{
|
||||||
return $this->only(['id', 'prescription_name', 'formula_type', 'herbs', 'is_public']);
|
return $this->only(['id', 'prescription_name', 'formula_type', 'herbs', 'is_public', 'disable_edit']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,8 +32,14 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'remark_assistant' => 'max:500',
|
'remark_assistant' => 'max:500',
|
||||||
'action' => 'require|in:approve,reject',
|
'action' => 'require|in:approve,reject',
|
||||||
'remark' => 'max:500',
|
'remark' => 'max:500',
|
||||||
|
'summary' => 'require|max:500',
|
||||||
|
'prescription_audit_status' => 'in:0,1,2',
|
||||||
|
'payment_slip_audit_status' => 'in:0,1,2',
|
||||||
|
'prescription_audit_remark' => 'max:500',
|
||||||
|
'payment_slip_audit_remark' => 'max:500',
|
||||||
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
|
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
|
||||||
'reason' => 'require|max:500',
|
'reason' => 'require|max:500',
|
||||||
|
'refund_amount' => 'float|egt:0',
|
||||||
'patient_name' => 'require|max:50',
|
'patient_name' => 'require|max:50',
|
||||||
'phone' => 'require|max:20',
|
'phone' => 'require|max:20',
|
||||||
'phone_tail' => 'max:20|regex:/^\\d*$/',
|
'phone_tail' => 'max:20|regex:/^\\d*$/',
|
||||||
@@ -67,20 +73,23 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
||||||
],
|
],
|
||||||
'logisticsTrace' => ['id', 'phone_tail'],
|
'logisticsTrace' => ['id', 'phone_tail'],
|
||||||
|
'logisticsJdUpdate' => ['id'],
|
||||||
'auditPrescription' => ['id', 'action', 'remark'],
|
'auditPrescription' => ['id', 'action', 'remark'],
|
||||||
'auditPayment' => ['id', 'action', 'remark'],
|
'auditPayment' => ['id', 'action', 'remark'],
|
||||||
'withdraw' => ['id'],
|
'withdraw' => ['id'],
|
||||||
'ship' => ['id', 'tracking_number', 'express_company', 'ship_mode'],
|
'ship' => ['id', 'tracking_number', 'express_company', 'ship_mode'],
|
||||||
'logs' => ['id'],
|
'logs' => ['id'],
|
||||||
|
'addLog' => ['id', 'summary'],
|
||||||
'paidPayOrders' => ['diagnosis_id'],
|
'paidPayOrders' => ['diagnosis_id'],
|
||||||
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
||||||
'linkPayOrder' => ['id', 'pay_order_id'],
|
'linkPayOrder' => ['id', 'pay_order_id'],
|
||||||
'requestCompletion' => ['id'],
|
'requestCompletion' => ['id'],
|
||||||
'complete' => ['id', 'fulfillment_status'],
|
'complete' => ['id', 'fulfillment_status'],
|
||||||
'refund' => ['id', 'reason'],
|
'refund' => ['id', 'reason', 'refund_amount'],
|
||||||
'submitGancaoRecipel' => ['id'],
|
'submitGancaoRecipel' => ['id'],
|
||||||
'previewGancaoRecipel' => ['id'],
|
'previewGancaoRecipel' => ['id'],
|
||||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||||
|
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
||||||
'updateAmount' => ['id', 'amount'],
|
'updateAmount' => ['id', 'amount'],
|
||||||
'setShipMode' => ['id', 'ship_mode'],
|
'setShipMode' => ['id', 'ship_mode'],
|
||||||
];
|
];
|
||||||
@@ -89,6 +98,17 @@ class PrescriptionOrderValidate extends BaseValidate
|
|||||||
{
|
{
|
||||||
return $this->only(['id', 'amount'])
|
return $this->only(['id', 'amount'])
|
||||||
->append('id', 'require|integer|gt:0')
|
->append('id', 'require|integer|gt:0')
|
||||||
->append('amount', 'require|float|gt:0');
|
->append('amount', 'require|float|egt:0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function patchPrescriptionUsage(): PrescriptionOrderValidate
|
||||||
|
{
|
||||||
|
return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'])
|
||||||
|
->append('id', 'require|integer|gt:0')
|
||||||
|
->append('times_per_day', 'require|integer|between:1,6')
|
||||||
|
->append('usage_days', 'require|integer|between:1,999')
|
||||||
|
->append('medication_days', 'require|integer|between:1,999')
|
||||||
|
->append('aux_times_per_day', 'integer|between:1,6')
|
||||||
|
->append('aux_usage_days', 'integer|between:1,999');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,10 @@ class TcmController extends BaseApiController
|
|||||||
return ['ok' => false, 'error' => '诊单不存在'];
|
return ['ok' => false, 'error' => '诊单不存在'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||||
|
return ['ok' => false, 'error' => '该就诊卡已在统计端隐藏'];
|
||||||
|
}
|
||||||
|
|
||||||
// 通过 diagnosis_view_records 校验当前小程序用户与该诊单的归属关系
|
// 通过 diagnosis_view_records 校验当前小程序用户与该诊单的归属关系
|
||||||
// 该表是「就诊卡列表」的来源(参考 DiagnosisLogic::getCardList)
|
// 该表是「就诊卡列表」的来源(参考 DiagnosisLogic::getCardList)
|
||||||
$owned = \think\facade\Db::name('diagnosis_view_records')
|
$owned = \think\facade\Db::name('diagnosis_view_records')
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\api\logic\tcm;
|
||||||
|
|
||||||
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
use app\common\service\AiChatService;
|
||||||
|
use think\facade\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日常护理 - 血糖照护 AI(累计 7 天有记录后:血糖建议 + 中医调理方向)
|
||||||
|
*/
|
||||||
|
class DailyBloodCareAiLogic
|
||||||
|
{
|
||||||
|
private const CACHE_TTL = 86400;
|
||||||
|
|
||||||
|
private const MIN_RECORD_DAYS = 7;
|
||||||
|
|
||||||
|
private const AI_TIMEOUT_SEC = 14;
|
||||||
|
|
||||||
|
private const AI_STREAM_TIMEOUT_SEC = 22;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,error?:string,data?:array}
|
||||||
|
*/
|
||||||
|
public static function getDailyCarePlan(int $diagnosisId, bool $refresh = false): array
|
||||||
|
{
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
return ['ok' => false, 'error' => '缺少就诊卡'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = self::buildCareContext($diagnosisId);
|
||||||
|
if (!$context['ok']) {
|
||||||
|
return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$gate = self::ensureEnoughBloodDays($context['data']);
|
||||||
|
if (!$gate['ok']) {
|
||||||
|
return ['ok' => false, 'error' => $gate['error']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$analysis = self::buildAnalysisPayload($context['data']);
|
||||||
|
$cacheKey = self::careCacheKey($diagnosisId);
|
||||||
|
|
||||||
|
if (!$refresh) {
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if (is_array($cached) && !empty($cached['summary'])) {
|
||||||
|
$cached['cached'] = true;
|
||||||
|
$cached['analysis'] = $analysis;
|
||||||
|
return ['ok' => true, 'data' => $cached];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = null;
|
||||||
|
if (AiChatService::isEnabled()) {
|
||||||
|
$raw = self::aiDailyCarePlan($context['data'], $refresh);
|
||||||
|
if (is_array($raw) && !empty($raw['summary'])) {
|
||||||
|
$data = self::finalizeCarePlan($raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$data) {
|
||||||
|
$data = GiKnowledge::fallbackBloodCarePlan(
|
||||||
|
$context['data']['stats_7d'] ?? [],
|
||||||
|
$context['data']['stats_30d'] ?? [],
|
||||||
|
$context['data']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['date'] = date('Y-m-d');
|
||||||
|
$data['cached'] = false;
|
||||||
|
$data['analysis'] = $analysis;
|
||||||
|
Cache::set($cacheKey, $data, self::CACHE_TTL);
|
||||||
|
|
||||||
|
return ['ok' => true, 'data' => $data];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(string, array<string, mixed>):void $emit
|
||||||
|
*/
|
||||||
|
public static function streamDailyCarePlan(int $diagnosisId, bool $refresh, callable $emit): void
|
||||||
|
{
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
$emit('error', ['message' => '缺少就诊卡']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = self::buildCareContext($diagnosisId);
|
||||||
|
if (!$context['ok']) {
|
||||||
|
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gate = self::ensureEnoughBloodDays($context['data']);
|
||||||
|
if (!$gate['ok']) {
|
||||||
|
$emit('error', ['message' => $gate['error']]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$analysis = self::buildAnalysisPayload($context['data']);
|
||||||
|
$emit('analysis', ['analysis' => $analysis]);
|
||||||
|
|
||||||
|
$cacheKey = self::careCacheKey($diagnosisId);
|
||||||
|
if (!$refresh) {
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if (is_array($cached) && !empty($cached['summary'])) {
|
||||||
|
$cached['cached'] = true;
|
||||||
|
$cached['analysis'] = $analysis;
|
||||||
|
$emit('done', $cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = null;
|
||||||
|
$fullText = '';
|
||||||
|
|
||||||
|
if (AiChatService::isEnabled()) {
|
||||||
|
[$system, $user] = self::buildCareStreamPrompts($context['data'], $refresh);
|
||||||
|
$streamRes = AiChatService::streamChat([
|
||||||
|
['role' => 'system', 'content' => $system],
|
||||||
|
['role' => 'user', 'content' => $user],
|
||||||
|
], static function (string $delta) use (&$fullText, $emit): void {
|
||||||
|
$fullText .= $delta;
|
||||||
|
$emit('delta', ['text' => $delta, 'buffer' => $fullText]);
|
||||||
|
}, [
|
||||||
|
'temperature' => $refresh ? 0.75 : 0.4,
|
||||||
|
'max_tokens' => 720,
|
||||||
|
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($streamRes['ok'] && trim($fullText) !== '') {
|
||||||
|
$parsed = self::parseCarePlainText(trim($fullText));
|
||||||
|
if ($parsed && !empty($parsed['summary'])) {
|
||||||
|
$data = self::finalizeCarePlan($parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$data) {
|
||||||
|
$data = GiKnowledge::fallbackBloodCarePlan(
|
||||||
|
$context['data']['stats_7d'] ?? [],
|
||||||
|
$context['data']['stats_30d'] ?? [],
|
||||||
|
$context['data']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['date'] = date('Y-m-d');
|
||||||
|
$data['cached'] = false;
|
||||||
|
$data['analysis'] = $analysis;
|
||||||
|
Cache::set($cacheKey, $data, self::CACHE_TTL);
|
||||||
|
$emit('done', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function careCacheKey(int $diagnosisId): string
|
||||||
|
{
|
||||||
|
return 'daily_blood_care_ai_v1:' . $diagnosisId . ':' . date('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok:bool,error?:string,data?:array}
|
||||||
|
*/
|
||||||
|
private static function buildCareContext(int $diagnosisId): array
|
||||||
|
{
|
||||||
|
$base = DailyDietAiLogic::getPatientContext($diagnosisId);
|
||||||
|
if (!$base['ok']) {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||||
|
if ($diagnosis) {
|
||||||
|
$row = $diagnosis->toArray();
|
||||||
|
$base['data']['syndrome_type'] = trim((string) ($row['syndrome_type'] ?? ''));
|
||||||
|
$base['data']['symptoms'] = trim((string) ($row['symptoms'] ?? ''));
|
||||||
|
$base['data']['remark'] = trim((string) ($row['remark'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $context
|
||||||
|
* @return array{ok:bool,error?:string}
|
||||||
|
*/
|
||||||
|
private static function ensureEnoughBloodDays(array $context): array
|
||||||
|
{
|
||||||
|
$days30 = (int) (($context['stats_30d']['record_days'] ?? 0));
|
||||||
|
$days7 = (int) (($context['stats_7d']['record_days'] ?? 0));
|
||||||
|
$total = max($days30, $days7);
|
||||||
|
if ($total < self::MIN_RECORD_DAYS) {
|
||||||
|
return ['ok' => false, 'error' => '累计记录满7天后可生成照护建议'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $context
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function buildAnalysisPayload(array $context): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach (['stats_7d', 'stats_30d'] as $key) {
|
||||||
|
$s = $context[$key] ?? null;
|
||||||
|
if (is_array($s)) {
|
||||||
|
$out[] = $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $context
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private static function aiDailyCarePlan(array $context, bool $refresh): ?array
|
||||||
|
{
|
||||||
|
[$system, $user] = self::buildCareJsonPrompts($context, $refresh);
|
||||||
|
$res = AiChatService::chat([
|
||||||
|
['role' => 'system', 'content' => $system],
|
||||||
|
['role' => 'user', 'content' => $user],
|
||||||
|
], [
|
||||||
|
'temperature' => $refresh ? 0.7 : 0.35,
|
||||||
|
'max_tokens' => 650,
|
||||||
|
'timeout' => self::AI_TIMEOUT_SEC,
|
||||||
|
'response_format' => ['type' => 'json_object'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (empty($res['ok']) || empty($res['content'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = self::parseJsonObject((string) $res['content']);
|
||||||
|
return is_array($json) ? $json : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $context
|
||||||
|
* @return array{0:string,1:string}
|
||||||
|
*/
|
||||||
|
private static function buildCareJsonPrompts(array $context, bool $refresh): array
|
||||||
|
{
|
||||||
|
$bloodLine = self::bloodLine($context);
|
||||||
|
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
|
||||||
|
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
|
||||||
|
$syndrome = trim((string) ($context['syndrome_type'] ?? ''));
|
||||||
|
$symptoms = trim((string) ($context['symptoms'] ?? ''));
|
||||||
|
|
||||||
|
$system = <<<SYS
|
||||||
|
你是基层中医糖尿病照护助手,面向农村中老年患者,用通俗中文给「血糖管理 + 中医调理方向」建议。
|
||||||
|
|
||||||
|
硬性要求:
|
||||||
|
1. 不得开具具体中药处方名、剂量、加减;不得指导自行增减西药/胰岛素。
|
||||||
|
2. 中医部分写调理思路、食疗方向、穴位艾灸注意事项,强调需面诊脉诊后由医师定方。
|
||||||
|
3. 结合提供的血糖统计与逐日记录,给出可执行的监测与生活方式建议。
|
||||||
|
4. 只输出 JSON:
|
||||||
|
{"summary":"一句话总评","blood_advice":"血糖监测与饮食运动","tcm_plan":"中医辨证与调理方案","watch_points":["留意1","留意2"],"next_steps":"下一步复诊或就医提醒"}
|
||||||
|
SYS;
|
||||||
|
|
||||||
|
$user = sprintf(
|
||||||
|
"患者:%s,%s,%s岁。证型登记:%s。症状摘要:%s。\n日期:%s。\n近7日血糖:%s。\n统计:%s;%s。\n请根据控制好坏给出个性化 JSON。",
|
||||||
|
$context['patient_name'] ?: '患者',
|
||||||
|
$context['gender_text'] ?? '',
|
||||||
|
$context['age'] ?? 0,
|
||||||
|
$syndrome !== '' ? $syndrome : '未登记',
|
||||||
|
$symptoms !== '' ? mb_substr($symptoms, 0, 120) : '无',
|
||||||
|
$context['today'] ?? date('Y-m-d'),
|
||||||
|
$bloodLine,
|
||||||
|
$stat7Line,
|
||||||
|
$stat30Line
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($refresh) {
|
||||||
|
$user .= "\n\n用户点了「换一换」,请换一套不同表述但同样严谨的建议,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$system, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $context
|
||||||
|
* @return array{0:string,1:string}
|
||||||
|
*/
|
||||||
|
private static function buildCareStreamPrompts(array $context, bool $refresh): array
|
||||||
|
{
|
||||||
|
$bloodLine = self::bloodLine($context);
|
||||||
|
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
|
||||||
|
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
|
||||||
|
|
||||||
|
$system = <<<SYS
|
||||||
|
你是基层中医糖尿病照护助手,给农村中老年患者写血糖与中医调理建议。
|
||||||
|
|
||||||
|
硬性要求:
|
||||||
|
1. 不得写具体中药方名剂量、不得指导自行调药。
|
||||||
|
2. 中医写辨证思路与调理方向,强调面诊后由医师定方。
|
||||||
|
3. 严格按下面 5 行输出,不要 JSON、不要 markdown:
|
||||||
|
总评:(一句话)
|
||||||
|
血糖:(监测、饮食、运动,2-4句)
|
||||||
|
中医:(辨证思路、食疗艾灸方向,2-4句)
|
||||||
|
留意:(用顿号隔开 2-4 条)
|
||||||
|
复诊:(下一步)
|
||||||
|
SYS;
|
||||||
|
|
||||||
|
$user = sprintf(
|
||||||
|
"患者:%s,%s,%s岁。证型:%s。症状:%s。\n近7日血糖:%s。\n%s;%s。",
|
||||||
|
$context['patient_name'] ?: '患者',
|
||||||
|
$context['gender_text'] ?? '',
|
||||||
|
$context['age'] ?? 0,
|
||||||
|
($context['syndrome_type'] ?? '') ?: '未登记',
|
||||||
|
mb_substr((string) ($context['symptoms'] ?? ''), 0, 80) ?: '无',
|
||||||
|
$bloodLine,
|
||||||
|
$stat7Line,
|
||||||
|
$stat30Line
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($refresh) {
|
||||||
|
$user .= "\n\n换一换,重新写一套,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$system, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $context
|
||||||
|
*/
|
||||||
|
private static function bloodLine(array $context): string
|
||||||
|
{
|
||||||
|
$recent = $context['blood_recent'] ?? [];
|
||||||
|
if (!is_array($recent) || empty($recent)) {
|
||||||
|
return '近7日无逐日明细';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(';', $recent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $stats
|
||||||
|
*/
|
||||||
|
private static function statsLine(array $stats): string
|
||||||
|
{
|
||||||
|
$label = (string) ($stats['label'] ?? '统计');
|
||||||
|
if (($stats['record_days'] ?? 0) <= 0) {
|
||||||
|
return $label . '无记录';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [
|
||||||
|
$label . "有记录{$stats['record_days']}天",
|
||||||
|
"偏高{$stats['high_days']}天",
|
||||||
|
"达标率{$stats['compliance']}%",
|
||||||
|
];
|
||||||
|
if ($stats['fasting_avg'] !== null) {
|
||||||
|
$parts[] = "空腹均值{$stats['fasting_avg']}";
|
||||||
|
}
|
||||||
|
if ($stats['postprandial_avg'] !== null) {
|
||||||
|
$parts[] = "餐后均值{$stats['postprandial_avg']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(',', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private static function parseCarePlainText(string $text): ?array
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($text[0] === '{') {
|
||||||
|
$json = self::parseJsonObject($text);
|
||||||
|
return is_array($json) ? $json : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
if (preg_match('/总评[::]\s*(.+)$/mu', $text, $m)) {
|
||||||
|
$map['summary'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||||
|
}
|
||||||
|
if (preg_match('/血糖[::]\s*(.+)$/mu', $text, $m)) {
|
||||||
|
$map['blood_advice'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||||
|
}
|
||||||
|
if (preg_match('/中医[::]\s*(.+)$/mu', $text, $m)) {
|
||||||
|
$map['tcm_plan'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||||
|
}
|
||||||
|
if (preg_match('/留意[::]\s*(.+)$/mu', $text, $m)) {
|
||||||
|
$line = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||||
|
$parts = preg_split('/[、,,;;\s]+/u', $line) ?: [];
|
||||||
|
$map['watch_points'] = array_values(array_filter(array_map('trim', $parts)));
|
||||||
|
}
|
||||||
|
if (preg_match('/复诊[::]\s*(.+)$/mu', $text, $m)) {
|
||||||
|
$map['next_steps'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($map['summary'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $raw
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function finalizeCarePlan(array $raw): array
|
||||||
|
{
|
||||||
|
$watch = $raw['watch_points'] ?? [];
|
||||||
|
if (!is_array($watch)) {
|
||||||
|
$watch = preg_split('/[、,,;;\n]+/u', (string) $watch) ?: [];
|
||||||
|
}
|
||||||
|
$watch = array_values(array_filter(array_map('trim', $watch)));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => trim((string) ($raw['summary'] ?? '')),
|
||||||
|
'blood_advice' => trim((string) ($raw['blood_advice'] ?? '')),
|
||||||
|
'tcm_plan' => trim((string) ($raw['tcm_plan'] ?? '')),
|
||||||
|
'watch_points' => $watch,
|
||||||
|
'next_steps' => trim((string) ($raw['next_steps'] ?? '')),
|
||||||
|
'control_level' => (string) ($raw['control_level'] ?? ''),
|
||||||
|
'control_label' => (string) ($raw['control_label'] ?? ''),
|
||||||
|
'disclaimer' => trim((string) ($raw['disclaimer'] ?? 'AI 建议供参考,不能替代医师面诊、开方与调药。')),
|
||||||
|
'source' => (string) ($raw['source'] ?? 'ai'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private static function parseJsonObject(string $text): ?array
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (preg_match('/\{[\s\S]*\}/u', $text, $m)) {
|
||||||
|
$text = $m[0];
|
||||||
|
}
|
||||||
|
$data = json_decode($text, true);
|
||||||
|
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -499,8 +499,10 @@ class DailyDietAiLogic
|
|||||||
3. 严禁:白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料、白稀饭(杂面窝头、玉米碴粥除外)。
|
3. 严禁:白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料、白稀饭(杂面窝头、玉米碴粥除外)。
|
||||||
4. 低 GI 优先;农村家常;禁止轻食/沙拉/藜麦等词。
|
4. 低 GI 优先;农村家常;禁止轻食/沙拉/藜麦等词。
|
||||||
5. lunch 字段写法:蛋白菜放前面,淀粉(如有)放最后且注明「小半碗/一个」。
|
5. lunch 字段写法:蛋白菜放前面,淀粉(如有)放最后且注明「小半碗/一个」。
|
||||||
6. 只输出 JSON:
|
6. 早餐必须包含「驼奶粉」(温水冲服,适量),可与粥、蛋、菜并列写。
|
||||||
{"breakfast":"...","lunch":"...","dinner":"...","tips":"...","avoid":["...","..."]}
|
7. drinks 单独写全天喝什么:温开水为主;可写驼奶粉冲饮时间;严禁含糖饮料、果汁、酒。
|
||||||
|
8. 只输出 JSON:
|
||||||
|
{"breakfast":"...","drinks":"...","lunch":"...","dinner":"...","tips":"...","avoid":["...","..."]}
|
||||||
SYS;
|
SYS;
|
||||||
|
|
||||||
$user = sprintf(
|
$user = sprintf(
|
||||||
@@ -544,9 +546,11 @@ SYS;
|
|||||||
2. 淀粉(粥/饭/面/饼/窝头/薯/南瓜)每餐最多 1 种,早中晚互不相同。
|
2. 淀粉(粥/饭/面/饼/窝头/薯/南瓜)每餐最多 1 种,早中晚互不相同。
|
||||||
3. 严禁白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料。
|
3. 严禁白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料。
|
||||||
4. 农村家常饭,禁止轻食/沙拉/藜麦。
|
4. 农村家常饭,禁止轻食/沙拉/藜麦。
|
||||||
|
5. 早餐必须含驼奶粉(温水冲服);喝的单独一行。
|
||||||
|
|
||||||
严格按下面 5 行格式输出,不要 JSON、不要 markdown、不要多余解释:
|
严格按下面 6 行格式输出,不要 JSON、不要 markdown、不要多余解释:
|
||||||
早餐:(具体食物)
|
早餐:(具体食物,须含驼奶粉)
|
||||||
|
喝的:(温开水、驼奶粉冲饮安排等,忌甜饮)
|
||||||
午餐:(具体食物,蛋白放前)
|
午餐:(具体食物,蛋白放前)
|
||||||
晚餐:(具体食物)
|
晚餐:(具体食物)
|
||||||
提示:(一句土话提醒)
|
提示:(一句土话提醒)
|
||||||
@@ -593,6 +597,7 @@ SYS;
|
|||||||
|
|
||||||
$map = [
|
$map = [
|
||||||
'breakfast' => null,
|
'breakfast' => null,
|
||||||
|
'drinks' => null,
|
||||||
'lunch' => null,
|
'lunch' => null,
|
||||||
'dinner' => null,
|
'dinner' => null,
|
||||||
'tips' => null,
|
'tips' => null,
|
||||||
@@ -602,6 +607,9 @@ SYS;
|
|||||||
if (preg_match('/早餐[::]\s*(.+)$/mu', $text, $m)) {
|
if (preg_match('/早餐[::]\s*(.+)$/mu', $text, $m)) {
|
||||||
$map['breakfast'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
$map['breakfast'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||||
}
|
}
|
||||||
|
if (preg_match('/喝的[::]\s*(.+)$/mu', $text, $m)) {
|
||||||
|
$map['drinks'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||||
|
}
|
||||||
if (preg_match('/午餐[::]\s*(.+)$/mu', $text, $m)) {
|
if (preg_match('/午餐[::]\s*(.+)$/mu', $text, $m)) {
|
||||||
$map['lunch'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
$map['lunch'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||||
}
|
}
|
||||||
@@ -623,6 +631,7 @@ SYS;
|
|||||||
|
|
||||||
$out = [
|
$out = [
|
||||||
'breakfast' => (string) $map['breakfast'],
|
'breakfast' => (string) $map['breakfast'],
|
||||||
|
'drinks' => (string) ($map['drinks'] ?? ''),
|
||||||
'lunch' => (string) ($map['lunch'] ?? ''),
|
'lunch' => (string) ($map['lunch'] ?? ''),
|
||||||
'dinner' => (string) ($map['dinner'] ?? ''),
|
'dinner' => (string) ($map['dinner'] ?? ''),
|
||||||
'tips' => (string) ($map['tips'] ?? ''),
|
'tips' => (string) ($map['tips'] ?? ''),
|
||||||
@@ -632,6 +641,29 @@ SYS;
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品要求:早餐含驼奶粉,并单独给出「喝的」
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $plan
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function applyDietProductDefaults(array $plan): array
|
||||||
|
{
|
||||||
|
$breakfast = trim((string) ($plan['breakfast'] ?? ''));
|
||||||
|
if ($breakfast !== '' && mb_strpos($breakfast, '驼奶粉') === false && mb_strpos($breakfast, '驼奶') === false) {
|
||||||
|
$plan['breakfast'] = '驼奶粉(温水冲服)、' . $breakfast;
|
||||||
|
}
|
||||||
|
|
||||||
|
$drinks = trim((string) ($plan['drinks'] ?? ''));
|
||||||
|
if ($drinks === '') {
|
||||||
|
$plan['drinks'] = '白天多喝温开水;早餐按量冲驼奶粉,别喝甜饮料、果汁。';
|
||||||
|
} elseif (mb_strpos($drinks, '驼奶') === false) {
|
||||||
|
$plan['drinks'] = $drinks . ';早餐已安排驼奶粉';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plan;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 规范化 AI 三餐方案;校验未过也保留 AI 文案(避免流式展示后被规则模板覆盖)
|
* 规范化 AI 三餐方案;校验未过也保留 AI 文案(避免流式展示后被规则模板覆盖)
|
||||||
*
|
*
|
||||||
@@ -640,6 +672,7 @@ SYS;
|
|||||||
*/
|
*/
|
||||||
private static function finalizeAiRecommendPlan(array $parsed): array
|
private static function finalizeAiRecommendPlan(array $parsed): array
|
||||||
{
|
{
|
||||||
|
$parsed = self::applyDietProductDefaults($parsed);
|
||||||
$checked = DietMealValidator::validateAndNormalize($parsed);
|
$checked = DietMealValidator::validateAndNormalize($parsed);
|
||||||
$data = $checked['plan'];
|
$data = $checked['plan'];
|
||||||
$data['source'] = 'ai';
|
$data['source'] = 'ai';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace app\api\logic\tcm;
|
|||||||
|
|
||||||
use app\common\model\tcm\DailyFamilyLike;
|
use app\common\model\tcm\DailyFamilyLike;
|
||||||
use app\common\model\tcm\DailyShareInvite;
|
use app\common\model\tcm\DailyShareInvite;
|
||||||
|
use app\common\model\tcm\Diagnosis;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 家人点赞(邀请观看页 → 患者日常页展示)
|
* 家人点赞(邀请观看页 → 患者日常页展示)
|
||||||
@@ -204,6 +205,11 @@ class DailyFamilyLikeLogic
|
|||||||
return self::setError('无权查看该诊单') ? false : false;
|
return self::setError('无权查看该诊单') ? false : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->field('show_card')->find();
|
||||||
|
if (!$diagnosis || (int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||||
|
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
|
||||||
|
}
|
||||||
|
|
||||||
$likeDate = date('Y-m-d');
|
$likeDate = date('Y-m-d');
|
||||||
$count = self::countToday($diagnosisId, $likeDate);
|
$count = self::countToday($diagnosisId, $likeDate);
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ class DailyGamifyLogic
|
|||||||
if (!$owned) {
|
if (!$owned) {
|
||||||
return self::setError('无权操作该诊单') ? false : false;
|
return self::setError('无权操作该诊单') ? false : false;
|
||||||
}
|
}
|
||||||
|
$diagnosis = \app\common\model\tcm\Diagnosis::where('id', $diagnosisId)
|
||||||
|
->where('delete_time', null)
|
||||||
|
->field('show_card')
|
||||||
|
->find();
|
||||||
|
if (!$diagnosis || (int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||||
|
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace app\api\logic\tcm;
|
namespace app\api\logic\tcm;
|
||||||
|
|
||||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||||
|
use app\common\model\DiagnosisViewRecord;
|
||||||
|
use app\common\model\tcm\Diagnosis;
|
||||||
use app\common\model\user\User;
|
use app\common\model\user\User;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,19 +76,38 @@ class DailyPhoneLogic
|
|||||||
|
|
||||||
if (!empty($cardList)) {
|
if (!empty($cardList)) {
|
||||||
$card = $cardList[0];
|
$card = $cardList[0];
|
||||||
|
return self::buildContextOk($card, $mobile, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 该手机号已在诊单库建档:关联查看记录,禁止重复创建轻量档案
|
||||||
|
$existing = Diagnosis::where('phone', $mobile)
|
||||||
|
->where('delete_time', null)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
if ($existing && (int) ($existing['show_card'] ?? 1) !== 1) {
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => false,
|
||||||
'need_mobile' => false,
|
'need_mobile' => false,
|
||||||
'created' => false,
|
'error' => '该手机号对应就诊卡因隐私设置已隐藏,请联系医生',
|
||||||
'diagnosis_id' => (int) $card['id'],
|
|
||||||
'patient_id' => (int) $card['patient_id'],
|
|
||||||
'patient_name' => (string) ($card['patient_name'] ?? ''),
|
|
||||||
'gender' => (int) ($card['gender'] ?? 0),
|
|
||||||
'age' => (int) ($card['age'] ?? 0),
|
|
||||||
'mobile' => $mobile,
|
|
||||||
'create_source' => (string) ($card['create_source'] ?? ''),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if ($existing) {
|
||||||
|
if (!self::ensureUserViewRecord($userId, (int) $existing['id'], (int) $existing['patient_id'])) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'need_mobile' => false,
|
||||||
|
'error' => '该手机号已建档,暂无法关联到当前账号',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return self::buildContextOk([
|
||||||
|
'id' => (int) $existing['id'],
|
||||||
|
'patient_id' => (int) $existing['patient_id'],
|
||||||
|
'patient_name' => (string) ($existing['patient_name'] ?? ''),
|
||||||
|
'gender' => (int) ($existing['gender'] ?? 0),
|
||||||
|
'age' => (int) ($existing['age'] ?? 0),
|
||||||
|
'create_source' => (string) ($existing['create_source'] ?? ''),
|
||||||
|
], $mobile, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
$displayName = trim((string) (($user['real_name'] ?? '') ?: ($user['nickname'] ?? '')));
|
$displayName = trim((string) (($user['real_name'] ?? '') ?: ($user['nickname'] ?? '')));
|
||||||
if ($displayName === '') {
|
if ($displayName === '') {
|
||||||
@@ -114,17 +135,66 @@ class DailyPhoneLogic
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return self::buildContextOk([
|
||||||
'ok' => true,
|
'id' => (int) $result['id'],
|
||||||
'need_mobile' => false,
|
|
||||||
'created' => true,
|
|
||||||
'diagnosis_id' => (int) $result['id'],
|
|
||||||
'patient_id' => (int) $result['patient_id'],
|
'patient_id' => (int) $result['patient_id'],
|
||||||
'patient_name' => $displayName,
|
'patient_name' => $displayName,
|
||||||
'gender' => $gender,
|
'gender' => $gender,
|
||||||
'age' => max(0, (int) ($user['age'] ?? 0)),
|
'age' => max(0, (int) ($user['age'] ?? 0)),
|
||||||
'mobile' => $mobile,
|
|
||||||
'create_source' => self::CREATE_SOURCE_MNP_DAILY,
|
'create_source' => self::CREATE_SOURCE_MNP_DAILY,
|
||||||
|
], $mobile, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{id:int,patient_id:int,patient_name?:string,gender?:int,age?:int,create_source?:string} $card
|
||||||
|
*/
|
||||||
|
private static function buildContextOk(array $card, string $mobile, bool $created, bool $linkedExisting): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'need_mobile' => false,
|
||||||
|
'created' => $created,
|
||||||
|
'linked_existing' => $linkedExisting,
|
||||||
|
'diagnosis_id' => (int) $card['id'],
|
||||||
|
'patient_id' => (int) $card['patient_id'],
|
||||||
|
'patient_name' => (string) ($card['patient_name'] ?? ''),
|
||||||
|
'gender' => (int) ($card['gender'] ?? 0),
|
||||||
|
'age' => (int) ($card['age'] ?? 0),
|
||||||
|
'mobile' => $mobile,
|
||||||
|
'create_source' => (string) ($card['create_source'] ?? ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将已有诊单挂到当前小程序用户(diagnosis_view_records) */
|
||||||
|
private static function ensureUserViewRecord(int $userId, int $diagnosisId, int $patientId): bool
|
||||||
|
{
|
||||||
|
if ($userId <= 0 || $diagnosisId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$exists = DiagnosisViewRecord::where('user_id', $userId)
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
|
->where('delete_time', null)
|
||||||
|
->find();
|
||||||
|
if ($exists) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$now = time();
|
||||||
|
DiagnosisViewRecord::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'patient_id' => $patientId,
|
||||||
|
'share_user_id' => 0,
|
||||||
|
'view_count' => 1,
|
||||||
|
'first_view_time' => $now,
|
||||||
|
'last_view_time' => $now,
|
||||||
|
'is_confirmed' => 0,
|
||||||
|
'create_time' => $now,
|
||||||
|
'update_time' => $now,
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ class DailyShareLogic
|
|||||||
if (!$diagnosis) {
|
if (!$diagnosis) {
|
||||||
return self::setError('诊单不存在') ? false : false;
|
return self::setError('诊单不存在') ? false : false;
|
||||||
}
|
}
|
||||||
|
if ((int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||||
|
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
|
||||||
|
}
|
||||||
|
|
||||||
$inviteDate = date('Y-m-d');
|
$inviteDate = date('Y-m-d');
|
||||||
$code = self::generateUniqueCode();
|
$code = self::generateUniqueCode();
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class DietMealValidator
|
|||||||
|
|
||||||
$normalized = [
|
$normalized = [
|
||||||
'breakfast' => $breakfast,
|
'breakfast' => $breakfast,
|
||||||
|
'drinks' => trim((string) ($plan['drinks'] ?? '')),
|
||||||
'lunch' => $lunch,
|
'lunch' => $lunch,
|
||||||
'dinner' => $dinner,
|
'dinner' => $dinner,
|
||||||
'tips' => $tips,
|
'tips' => $tips,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class GiKnowledge
|
|||||||
'蔬菜类' => ['魔芋', '粟米', '大白菜', '黄瓜', '芹菜', '茄子', '青椒', '海带', '金针菇', '香菇', '菠菜', '番茄', '豆芽', '芦笋', '花椰菜', '洋葱', '生菜'],
|
'蔬菜类' => ['魔芋', '粟米', '大白菜', '黄瓜', '芹菜', '茄子', '青椒', '海带', '金针菇', '香菇', '菠菜', '番茄', '豆芽', '芦笋', '花椰菜', '洋葱', '生菜'],
|
||||||
'豆类' => ['黄豆', '眉豆', '鸡心豆', '豆腐', '豆角', '绿豆', '扁豆', '四季豆'],
|
'豆类' => ['黄豆', '眉豆', '鸡心豆', '豆腐', '豆角', '绿豆', '扁豆', '四季豆'],
|
||||||
'水果类' => ['苹果', '水梨', '橙子', '桃', '提子', '沙田柚', '雪梨', '车厘子', '柚子', '草莓', '樱桃', '金桔', '葡萄'],
|
'水果类' => ['苹果', '水梨', '橙子', '桃', '提子', '沙田柚', '雪梨', '车厘子', '柚子', '草莓', '樱桃', '金桔', '葡萄'],
|
||||||
'奶类' => ['牛奶', '低脂奶', '脱脂奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'],
|
'奶类' => ['牛奶', '低脂奶', '脱脂奶', '驼奶粉', '驼奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'],
|
||||||
],
|
],
|
||||||
self::LEVEL_MEDIUM => [
|
self::LEVEL_MEDIUM => [
|
||||||
'主食类' => ['红米饭', '糙米饭', '西米', '乌冬面', '面包', '麦片', '番薯', '芋头'],
|
'主食类' => ['红米饭', '糙米饭', '西米', '乌冬面', '面包', '麦片', '番薯', '芋头'],
|
||||||
@@ -36,40 +36,46 @@ class GiKnowledge
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @var list<array{breakfast:string,lunch:string,dinner:string,tips:string}> */
|
/** @var list<array{breakfast:string,drinks:string,lunch:string,dinner:string,tips:string}> */
|
||||||
private static array $mealTemplates = [
|
private static array $mealTemplates = [
|
||||||
[
|
[
|
||||||
'breakfast' => '玉米碴子粥、煮鸡蛋、拌黄瓜',
|
'breakfast' => '驼奶粉(温水冲服)、玉米碴子粥、煮鸡蛋、拌黄瓜',
|
||||||
|
'drinks' => '白天多喝温开水;早餐冲驼奶粉,别喝甜饮料、果汁。',
|
||||||
'lunch' => '清炖鲤鱼、豆腐炖白菜、清炒豆角、米饭小半碗',
|
'lunch' => '清炖鲤鱼、豆腐炖白菜、清炒豆角、米饭小半碗',
|
||||||
'dinner' => '玉米面饼一个、瘦肉丝、凉拌茄子',
|
'dinner' => '玉米面饼一个、瘦肉丝、凉拌茄子',
|
||||||
'tips' => '中午鱼豆肉吃足;早中晚淀粉别重复,一顿一种主食就够。',
|
'tips' => '中午鱼豆肉吃足;早中晚淀粉别重复,一顿一种主食就够。',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'breakfast' => '小米粥、煮鸡蛋、小碟咸菜(少盐)',
|
'breakfast' => '驼奶粉(温水冲服)、小米粥、煮鸡蛋、小碟咸菜(少盐)',
|
||||||
|
'drinks' => '温开水为主;驼奶粉跟早餐一起吃,下午也记得喝水。',
|
||||||
'lunch' => '去皮炖鸡肉、芹菜炒豆干、木耳炒鸡蛋',
|
'lunch' => '去皮炖鸡肉、芹菜炒豆干、木耳炒鸡蛋',
|
||||||
'dinner' => '蒸红薯(小半块)、清炒菠菜、豆腐汤',
|
'dinner' => '蒸红薯(小半块)、清炒菠菜、豆腐汤',
|
||||||
'tips' => '午餐最讲究蛋白;晚上才吃薯,别跟中午再配大米饭。',
|
'tips' => '午餐最讲究蛋白;晚上才吃薯,别跟中午再配大米饭。',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'breakfast' => '豆腐脑(少卤)、鸡蛋、一个苹果',
|
'breakfast' => '驼奶粉(温水冲服)、豆腐脑(少卤)、煮鸡蛋',
|
||||||
|
'drinks' => '早餐饮驼奶粉;白天喝白开水,别碰含糖饮料。',
|
||||||
'lunch' => '清炖鸡肉、炖芸豆、番茄炒蛋、贴饼子一个',
|
'lunch' => '清炖鸡肉、炖芸豆、番茄炒蛋、贴饼子一个',
|
||||||
'dinner' => '挂面一小碗、清蒸小鱼、拌海带丝',
|
'dinner' => '挂面一小碗、清蒸小鱼、拌海带丝',
|
||||||
'tips' => '中午饼配肉豆,蛋白管够;面条放晚上,别三顿都是粥饭。',
|
'tips' => '中午饼配肉豆,蛋白管够;面条放晚上,别三顿都是粥饭。',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'breakfast' => '高粱米粥、茶叶蛋、拌萝卜丝',
|
'breakfast' => '驼奶粉(温水冲服)、高粱米粥、茶叶蛋、拌萝卜丝',
|
||||||
|
'drinks' => '温开水;早餐驼奶粉按说明冲,别加糖。',
|
||||||
'lunch' => '炖牛肉(瘦)、豆腐炒韭菜、清炒油菜、杂豆饭小半碗',
|
'lunch' => '炖牛肉(瘦)、豆腐炒韭菜、清炒油菜、杂豆饭小半碗',
|
||||||
'dinner' => '蒸南瓜(小半碗)、鸡蛋羹、拍黄瓜',
|
'dinner' => '蒸南瓜(小半碗)、鸡蛋羹、拍黄瓜',
|
||||||
'tips' => '中午肉蛋豆要多吃;南瓜算晚饭主食,别再加馒头稀饭。',
|
'tips' => '中午肉蛋豆要多吃;南瓜算晚饭主食,别再加馒头稀饭。',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'breakfast' => '棒子面粥、水煮蛋、大拌菜(少油)',
|
'breakfast' => '驼奶粉(温水冲服)、棒子面粥、水煮蛋、大拌菜(少油)',
|
||||||
|
'drinks' => '多喝温开水;驼奶粉放早餐,忌可乐汽水果汁。',
|
||||||
'lunch' => '清炖鲫鱼、番茄炒蛋、炖扁豆、糙米饭小半碗',
|
'lunch' => '清炖鲫鱼、番茄炒蛋、炖扁豆、糙米饭小半碗',
|
||||||
'dinner' => '玉米面饼一个、清炒豆芽、豆腐脑',
|
'dinner' => '玉米面饼一个、清炒豆芽、豆腐脑',
|
||||||
'tips' => '午饭鱼蛋豆齐上;玉米面放晚上,跟中午米饭分开。',
|
'tips' => '午饭鱼蛋豆齐上;玉米面放晚上,跟中午米饭分开。',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'breakfast' => '小米粥、煮鸡蛋、拌生菜',
|
'breakfast' => '驼奶粉(温水冲服)、小米粥、煮鸡蛋、拌生菜',
|
||||||
|
'drinks' => '白天小口多次喝温水;早餐冲好驼奶粉再吃饭。',
|
||||||
'lunch' => '白切鸡(去皮)、炖豆腐、清炒豆角、二米饭小半碗',
|
'lunch' => '白切鸡(去皮)、炖豆腐、清炒豆角、二米饭小半碗',
|
||||||
'dinner' => '杂面馒头一个、瘦肉炒芹菜、凉拌黄瓜',
|
'dinner' => '杂面馒头一个、瘦肉炒芹菜、凉拌黄瓜',
|
||||||
'tips' => '中午蛋白打主力;三顿别都喝粥吃面,一顿一种淀粉。',
|
'tips' => '中午蛋白打主力;三顿别都喝粥吃面,一顿一种淀粉。',
|
||||||
@@ -92,7 +98,8 @@ class GiKnowledge
|
|||||||
private static array $aliases = [
|
private static array $aliases = [
|
||||||
'棒子面' => '荞麦面', '玉米碴' => '粟米', '地瓜' => '番薯', '红薯' => '番薯',
|
'棒子面' => '荞麦面', '玉米碴' => '粟米', '地瓜' => '番薯', '红薯' => '番薯',
|
||||||
'洋芋' => '芋头', '土豆' => '芋头', '窝头' => '通心粉', '贴饼子' => '通心粉',
|
'洋芋' => '芋头', '土豆' => '芋头', '窝头' => '通心粉', '贴饼子' => '通心粉',
|
||||||
'二米饭' => '红米饭', '稀饭' => '白饭', '白稀饭' => '白饭', '糖糕' => '麦芽糖',
|
'二米饭' => '红米饭', '稀饭' => '白饭', '白稀饭' => '白饭', '糖糕' => '麦芽糖',
|
||||||
|
'驼奶' => '驼奶粉',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,6 +139,8 @@ class GiKnowledge
|
|||||||
public static function ruralMealHints(): string
|
public static function ruralMealHints(): string
|
||||||
{
|
{
|
||||||
return implode("\n", [
|
return implode("\n", [
|
||||||
|
'【早餐】须安排驼奶粉温水冲服(低 GI 奶类,稳血糖),可与粥、蛋、菜同餐。',
|
||||||
|
'【喝的】单独说明:温开水为主;早餐驼奶粉;严禁含糖饮料、果汁、酒。',
|
||||||
'【午餐】蛋白质要最高:至少 2 样高蛋白(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),肉蛋豆是午饭主角。',
|
'【午餐】蛋白质要最高:至少 2 样高蛋白(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),肉蛋豆是午饭主角。',
|
||||||
'【淀粉不重复】粥/饭/面/饼/窝头/红薯/土豆/玉米/南瓜等算淀粉;早中晚各最多 1 种,且三顿不能同一种、不要顿顿大主食。',
|
'【淀粉不重复】粥/饭/面/饼/窝头/红薯/土豆/玉米/南瓜等算淀粉;早中晚各最多 1 种,且三顿不能同一种、不要顿顿大主食。',
|
||||||
'推荐蛋白:鸡蛋、豆腐、豆干、自家鸡鸭蛋、瘦肉、鲫鱼鲤鱼;',
|
'推荐蛋白:鸡蛋、豆腐、豆干、自家鸡鸭蛋、瘦肉、鲫鱼鲤鱼;',
|
||||||
@@ -177,6 +186,7 @@ class GiKnowledge
|
|||||||
|
|
||||||
$plan = [
|
$plan = [
|
||||||
'breakfast' => $meal['breakfast'],
|
'breakfast' => $meal['breakfast'],
|
||||||
|
'drinks' => $meal['drinks'] ?? '白天多喝温开水;早餐冲驼奶粉,别喝甜饮。',
|
||||||
'lunch' => $meal['lunch'],
|
'lunch' => $meal['lunch'],
|
||||||
'dinner' => $meal['dinner'],
|
'dinner' => $meal['dinner'],
|
||||||
'tips' => $meal['tips'],
|
'tips' => $meal['tips'],
|
||||||
|
|||||||
@@ -0,0 +1,571 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\command;
|
||||||
|
|
||||||
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\adminapi\logic\stats\RevisitRateLogic;
|
||||||
|
use think\console\Command;
|
||||||
|
use think\console\Input;
|
||||||
|
use think\console\input\Option;
|
||||||
|
use think\console\Output;
|
||||||
|
use think\facade\Db;
|
||||||
|
use think\facade\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待分配诊单自动指派(每天定时执行)
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* php think tcm:auto-assign-pending # 正式执行
|
||||||
|
* php think tcm:auto-assign-pending --dry-run # 只演练输出分配计划,不写库
|
||||||
|
*
|
||||||
|
* 推荐 cron(每天 09:00 执行一次;或经 zyt_dev_crontab 表调度):
|
||||||
|
* 0 9 * * * cd /path/to/server && php think tcm:auto-assign-pending >> /var/log/zyt-auto-assign.log 2>&1
|
||||||
|
*
|
||||||
|
* 分配规则(依据上个自然月「二诊复诊接诊率」,与 RevisitRateLogic::overview 口径完全一致):
|
||||||
|
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
|
||||||
|
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
|
||||||
|
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
|
||||||
|
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
|
||||||
|
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
|
||||||
|
*
|
||||||
|
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
|
||||||
|
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
|
||||||
|
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
|
||||||
|
* 第一轮 3+7+5=15 条;剩余 5 条进第二轮:>70% 再各 1 条(3 条),余 2 条给 60%~70% 档前 2 人。
|
||||||
|
*
|
||||||
|
* 日上限跨执行累计:同一天重复执行命令时,会先从 tcm_diagnosis_auto_assign_log 扣减当日已自动分配数,不会超额。
|
||||||
|
*
|
||||||
|
* 日志:无论分配与否,每条待指派诊单都会写入 tcm_diagnosis_auto_assign_log,记录原因(为什么分配 / 为什么不分配)。
|
||||||
|
* 成功分配同时写 tcm_diagnosis_assign_log(is_inherit=0,计入次月接诊率分母),与手动指派同口径。
|
||||||
|
*/
|
||||||
|
class AutoAssignPendingDiagnosis extends Command
|
||||||
|
{
|
||||||
|
/** 档位标识 */
|
||||||
|
private const TIER_GT70 = 'gt70';
|
||||||
|
private const TIER_60_70 = '60_70';
|
||||||
|
private const TIER_50_60 = '50_60';
|
||||||
|
|
||||||
|
/** 各档位当日每人分配上限(按优先级排列,先高档后低档) */
|
||||||
|
private const TIER_DAILY_CAPS = [
|
||||||
|
self::TIER_GT70 => 3,
|
||||||
|
self::TIER_60_70 => 2,
|
||||||
|
self::TIER_50_60 => 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
private const TIER_LABELS = [
|
||||||
|
self::TIER_GT70 => '>70%',
|
||||||
|
self::TIER_60_70 => '60%~70%',
|
||||||
|
self::TIER_50_60 => '50%~60%',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function configure()
|
||||||
|
{
|
||||||
|
$this->setName('tcm:auto-assign-pending')
|
||||||
|
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
|
||||||
|
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
$runDate = date('Y-m-d', $now);
|
||||||
|
$statMonth = date('Y-m', strtotime(date('Y-m-01', $now) . ' -1 month'));
|
||||||
|
$batchNo = date('YmdHis', $now) . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
|
||||||
|
date('Y-m-d H:i:s', $now),
|
||||||
|
$batchNo,
|
||||||
|
$statMonth,
|
||||||
|
$dryRun ? '(演练模式,不写库)'
|
||||||
|
: ''
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 上月二诊复诊接诊率 → 医助分档
|
||||||
|
$tiers = $this->buildAssistantTiers($statMonth);
|
||||||
|
$tierTotal = array_sum(array_map('count', $tiers));
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
|
||||||
|
\count($tiers[self::TIER_GT70]),
|
||||||
|
\count($tiers[self::TIER_60_70]),
|
||||||
|
\count($tiers[self::TIER_50_60])
|
||||||
|
));
|
||||||
|
|
||||||
|
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
|
||||||
|
$remaining = $this->buildRemainingQuota($tiers, $runDate);
|
||||||
|
|
||||||
|
// 3. 待指派池:与「待分配医助」Tab 同口径(assistant_id 空/0 + 当月有业务订单),先到先分
|
||||||
|
[$pool, $ineligible] = $this->fetchPendingPool($now);
|
||||||
|
$output->writeln(sprintf('待指派池:符合条件 %d 条,不符合条件 %d 条', \count($pool), \count($ineligible)));
|
||||||
|
|
||||||
|
$logRows = [];
|
||||||
|
|
||||||
|
// 不符合条件的待指派诊单:不分配,逐条记录原因
|
||||||
|
foreach ($ineligible as $item) {
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diag'], [
|
||||||
|
'action' => 0,
|
||||||
|
'reason' => $item['reason'],
|
||||||
|
], $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pool === []) {
|
||||||
|
$this->flushLogs($logRows, $dryRun, $output);
|
||||||
|
$output->writeln('待指派池为空,本次无需分配。');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tierTotal === 0) {
|
||||||
|
$reason = sprintf('未分配:上月(%s)无二诊复诊接诊率≥50%%的医助,本批次不执行分配', $statMonth);
|
||||||
|
foreach ($pool as $diag) {
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
|
||||||
|
'action' => 0,
|
||||||
|
'reason' => $reason,
|
||||||
|
], $now);
|
||||||
|
}
|
||||||
|
$this->flushLogs($logRows, $dryRun, $output);
|
||||||
|
$output->writeln($reason);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 轮询排分配计划
|
||||||
|
[$plan, $leftover] = $this->buildAssignPlan($pool, $tiers, $remaining);
|
||||||
|
|
||||||
|
// 5. 执行计划(逐条事务 + 行锁复核,落库诊单/指派日志/自动指派日志)
|
||||||
|
$assigned = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($plan as $item) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$assigned++;
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'[演练] 诊单#%d(%s) → %s(%s,第%d轮,当日第%d/%d条)',
|
||||||
|
$item['diagnosis']['id'],
|
||||||
|
(string) $item['diagnosis']['patient_name'],
|
||||||
|
$item['assistant']['name'],
|
||||||
|
self::TIER_LABELS[$item['tier']],
|
||||||
|
$item['round'],
|
||||||
|
$item['day_seq'],
|
||||||
|
self::TIER_DAILY_CAPS[$item['tier']]
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ok = $this->applyAssignment($item, $now);
|
||||||
|
if ($ok) {
|
||||||
|
$assigned++;
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
|
||||||
|
'action' => 1,
|
||||||
|
'assistant_id' => $item['assistant']['id'],
|
||||||
|
'assistant_name' => $item['assistant']['name'],
|
||||||
|
'tier' => $item['tier'],
|
||||||
|
'visit2_rate' => $item['assistant']['rate'],
|
||||||
|
'round_no' => $item['round'],
|
||||||
|
'reason' => sprintf(
|
||||||
|
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
|
||||||
|
$item['assistant']['name'],
|
||||||
|
$item['assistant']['id'],
|
||||||
|
$statMonth,
|
||||||
|
$item['assistant']['rate'],
|
||||||
|
self::TIER_LABELS[$item['tier']],
|
||||||
|
self::TIER_DAILY_CAPS[$item['tier']],
|
||||||
|
$item['round'],
|
||||||
|
$item['day_seq']
|
||||||
|
),
|
||||||
|
], $now);
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
|
||||||
|
'action' => 0,
|
||||||
|
'reason' => '未分配:执行时诊单已被指派给其他医助(并发/人工抢先),本次跳过',
|
||||||
|
], $now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 额度用尽后剩余的待指派诊单:不分配,记录原因
|
||||||
|
foreach ($leftover as $diag) {
|
||||||
|
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
|
||||||
|
'action' => 0,
|
||||||
|
'reason' => '未分配:各档位医助当日剩余额度已用尽(>70%每人3条、60%~70%每人2条、50%~60%每人1条),顺延至下次执行',
|
||||||
|
], $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->flushLogs($logRows, $dryRun, $output);
|
||||||
|
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'执行完成。计划分配: %d, 实际分配: %d, 并发跳过: %d, 额度不足未分: %d, 不符合条件: %d',
|
||||||
|
\count($plan),
|
||||||
|
$assigned,
|
||||||
|
$skipped,
|
||||||
|
\count($leftover),
|
||||||
|
\count($ineligible)
|
||||||
|
));
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('待分配诊单自动指派异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
$output->error('执行异常: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按上月二诊复诊接诊率把医助分档(复用 RevisitRateLogic 口径),并剔除已禁用/已删除账号。
|
||||||
|
*
|
||||||
|
* @return array{gt70: list<array{id:int,name:string,rate:float}>, 60_70: list<array{id:int,name:string,rate:float}>, 50_60: list<array{id:int,name:string,rate:float}>}
|
||||||
|
*/
|
||||||
|
private function buildAssistantTiers(string $statMonth): array
|
||||||
|
{
|
||||||
|
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
|
||||||
|
|
||||||
|
/** @var list<array{id:int,name:string,rate:float}> $candidates */
|
||||||
|
$candidates = [];
|
||||||
|
foreach ($overview['rows'] ?? [] as $deptRow) {
|
||||||
|
foreach ($deptRow['children'] ?? [] as $row) {
|
||||||
|
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||||
|
$rate = $row['visit2_rate'] ?? null;
|
||||||
|
// 上月无被指派数据(rate=null)或接诊率低于 50% 的医助不参与分配
|
||||||
|
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$candidates[] = [
|
||||||
|
'id' => $aid,
|
||||||
|
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
|
||||||
|
'rate' => (float) $rate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($candidates === []) {
|
||||||
|
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只分配给当前在职可用的医助账号(role_id=2 且未禁用未删除),
|
||||||
|
// 且当前部门须在「二中心」子树内(统计月在二中心、后来调离的不再参与)
|
||||||
|
$erDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||||
|
if ($erDeptSet === []) {
|
||||||
|
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||||
|
}
|
||||||
|
$activeIds = Db::name('admin')
|
||||||
|
->alias('a')
|
||||||
|
->join('admin_role ar', 'a.id = ar.admin_id')
|
||||||
|
->join('admin_dept ad', 'a.id = ad.admin_id')
|
||||||
|
->where('ar.role_id', 2)
|
||||||
|
->where('a.disable', 0)
|
||||||
|
->whereNull('a.delete_time')
|
||||||
|
->whereIn('ad.dept_id', array_keys($erDeptSet))
|
||||||
|
->whereIn('a.id', array_column($candidates, 'id'))
|
||||||
|
->group('a.id')
|
||||||
|
->column('a.id');
|
||||||
|
$activeSet = array_fill_keys(array_map('intval', $activeIds), true);
|
||||||
|
|
||||||
|
$tiers = [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
|
||||||
|
foreach ($candidates as $c) {
|
||||||
|
if (!isset($activeSet[$c['id']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($c['rate'] > 70.0) {
|
||||||
|
$tiers[self::TIER_GT70][] = $c;
|
||||||
|
} elseif ($c['rate'] > 60.0) {
|
||||||
|
$tiers[self::TIER_60_70][] = $c;
|
||||||
|
} else { // 50 <= rate <= 60
|
||||||
|
$tiers[self::TIER_50_60][] = $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 档内按接诊率降序、id 升序,保证分配顺序确定可复现
|
||||||
|
foreach ($tiers as &$list) {
|
||||||
|
usort($list, static function (array $a, array $b): int {
|
||||||
|
if ($a['rate'] !== $b['rate']) {
|
||||||
|
return $b['rate'] <=> $a['rate'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $a['id'] <=> $b['id'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
unset($list);
|
||||||
|
|
||||||
|
return $tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各医助当日剩余额度 = 档位日上限 - 当日已自动分配条数(同日重复执行不超额)。
|
||||||
|
*
|
||||||
|
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
|
||||||
|
*
|
||||||
|
* @return array<int, int> assistant_id => 剩余额度
|
||||||
|
*/
|
||||||
|
private function buildRemainingQuota(array $tiers, string $runDate): array
|
||||||
|
{
|
||||||
|
$usedToday = Db::name('tcm_diagnosis_auto_assign_log')
|
||||||
|
->where('run_date', $runDate)
|
||||||
|
->where('action', 1)
|
||||||
|
->where('assistant_id', '>', 0)
|
||||||
|
->group('assistant_id')
|
||||||
|
->column('COUNT(*)', 'assistant_id');
|
||||||
|
|
||||||
|
$remaining = [];
|
||||||
|
foreach (self::TIER_DAILY_CAPS as $tier => $cap) {
|
||||||
|
foreach ($tiers[$tier] as $assistant) {
|
||||||
|
$used = (int) ($usedToday[$assistant['id']] ?? 0);
|
||||||
|
$remaining[$assistant['id']] = max(0, $cap - $used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待指派池:与后台「待分配医助」Tab 同口径 —— assistant_id 为空/0、未删除,
|
||||||
|
* 且当月内存在业务订单(order.patient_id = 诊单 id,Tab 默认按当月过滤)。先到先分。
|
||||||
|
* 返回 [符合条件, 不符合条件(附原因)] 两组;不符合条件的诊单不分配,只记日志。
|
||||||
|
*
|
||||||
|
* @return array{0: list<array<string,mixed>>, 1: list<array{diag:array<string,mixed>,reason:string}>}
|
||||||
|
*/
|
||||||
|
private function fetchPendingPool(int $now): array
|
||||||
|
{
|
||||||
|
$rows = Db::name('tcm_diagnosis')
|
||||||
|
->whereRaw('(assistant_id IS NULL OR assistant_id = 0)')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->field(['id', 'patient_name', 'phone', 'status', 'create_time'])
|
||||||
|
->order(['create_time' => 'asc', 'id' => 'asc'])
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
if ($rows === []) {
|
||||||
|
return [[], []];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当月存在业务订单的诊单集合(order.create_time 兼容整型时间戳与 datetime 字符串,与 DiagnosisLists 一致)
|
||||||
|
$tStart = (int) strtotime(date('Y-m-01 00:00:00', $now));
|
||||||
|
$tEnd = (int) strtotime(date('Y-m-t 23:59:59', $now));
|
||||||
|
$dsStart = date('Y-m-d H:i:s', $tStart);
|
||||||
|
$dsEnd = date('Y-m-d H:i:s', $tEnd);
|
||||||
|
$hasOrderSet = [];
|
||||||
|
foreach (array_chunk(array_column($rows, 'id'), 2000) as $chunk) {
|
||||||
|
$ids = Db::name('order')
|
||||||
|
->whereIn('patient_id', $chunk)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->where(static function ($q) use ($tStart, $tEnd, $dsStart, $dsEnd) {
|
||||||
|
$q->whereBetween('create_time', [$tStart, $tEnd])
|
||||||
|
->whereOr(static function ($q2) use ($dsStart, $dsEnd) {
|
||||||
|
$q2->where('create_time', '>=', $dsStart)->where('create_time', '<=', $dsEnd);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->group('patient_id')
|
||||||
|
->column('patient_id');
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$hasOrderSet[(int) $id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$curMonth = date('Y-m', $now);
|
||||||
|
$eligible = [];
|
||||||
|
$ineligible = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$did = (int) ($r['id'] ?? 0);
|
||||||
|
if (!isset($hasOrderSet[$did])) {
|
||||||
|
$ineligible[] = [
|
||||||
|
'diag' => $r,
|
||||||
|
'reason' => sprintf('未分配:诊单当月(%s)无业务订单,不在「待分配医助」列表范围内,不满足自动分配条件', $curMonth),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((int) ($r['status'] ?? 0) !== 1) {
|
||||||
|
$ineligible[] = [
|
||||||
|
'diag' => $r,
|
||||||
|
'reason' => '未分配:诊单未启用(status≠1),不满足自动分配条件',
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$eligible[] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$eligible, $ineligible];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询排分配计划:每轮内先 >70% 档每人 1 条,再 60%~70%,再 50%~60%;受各自当日剩余额度约束。
|
||||||
|
*
|
||||||
|
* @param list<array<string,mixed>> $pool 待指派诊单(先到先分)
|
||||||
|
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
|
||||||
|
* @param array<int, int> $remaining assistant_id => 剩余额度(会被消耗)
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* 0: list<array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float},tier:string,round:int,day_seq:int}>,
|
||||||
|
* 1: list<array<string,mixed>>
|
||||||
|
* } [分配计划, 额度用尽后剩余诊单]
|
||||||
|
*/
|
||||||
|
private function buildAssignPlan(array $pool, array $tiers, array $remaining): array
|
||||||
|
{
|
||||||
|
$plan = [];
|
||||||
|
$poolIdx = 0;
|
||||||
|
$poolCount = \count($pool);
|
||||||
|
/** @var array<int, int> $daySeq 医助当日已排序号(含历史已用额度) */
|
||||||
|
$daySeq = [];
|
||||||
|
foreach ($remaining as $aid => $left) {
|
||||||
|
// 起始序号 = 日上限 - 剩余额度(同日多次执行时序号衔接)
|
||||||
|
$cap = 0;
|
||||||
|
foreach (self::TIER_DAILY_CAPS as $tier => $tierCap) {
|
||||||
|
foreach ($tiers[$tier] as $assistant) {
|
||||||
|
if ($assistant['id'] === $aid) {
|
||||||
|
$cap = $tierCap;
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$daySeq[$aid] = $cap - $left;
|
||||||
|
}
|
||||||
|
|
||||||
|
$round = 0;
|
||||||
|
while ($poolIdx < $poolCount) {
|
||||||
|
$round++;
|
||||||
|
$assignedThisRound = 0;
|
||||||
|
foreach (self::TIER_DAILY_CAPS as $tier => $_cap) {
|
||||||
|
foreach ($tiers[$tier] as $assistant) {
|
||||||
|
if ($poolIdx >= $poolCount) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
$aid = $assistant['id'];
|
||||||
|
if (($remaining[$aid] ?? 0) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$remaining[$aid]--;
|
||||||
|
$daySeq[$aid]++;
|
||||||
|
$plan[] = [
|
||||||
|
'diagnosis' => $pool[$poolIdx],
|
||||||
|
'assistant' => $assistant,
|
||||||
|
'tier' => $tier,
|
||||||
|
'round' => $round,
|
||||||
|
'day_seq' => $daySeq[$aid],
|
||||||
|
];
|
||||||
|
$poolIdx++;
|
||||||
|
$assignedThisRound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($assignedThisRound === 0) {
|
||||||
|
// 所有医助额度用尽,剩余诊单不再分配
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$plan, \array_slice($pool, $poolIdx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 落库单条分配:事务 + 行锁复核诊单仍未指派,更新诊单并写指派日志(与手动指派 DiagnosisLogic::assign 同口径)。
|
||||||
|
*
|
||||||
|
* @param array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float}} $item
|
||||||
|
*/
|
||||||
|
private function applyAssignment(array $item, int $now): bool
|
||||||
|
{
|
||||||
|
$diagnosisId = (int) $item['diagnosis']['id'];
|
||||||
|
$toAssistantId = (int) $item['assistant']['id'];
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
$diagLock = Db::name('tcm_diagnosis')
|
||||||
|
->where('id', $diagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->lock(true)
|
||||||
|
->field(['id', 'assistant_id'])
|
||||||
|
->find();
|
||||||
|
if ($diagLock === null || $diagLock === [] || (int) ($diagLock['assistant_id'] ?? 0) > 0) {
|
||||||
|
Db::rollback();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('tcm_diagnosis')
|
||||||
|
->where('id', $diagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->update([
|
||||||
|
'assistant_id' => $toAssistantId,
|
||||||
|
'assign_read_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$poSnap = Db::name('tcm_prescription_order')
|
||||||
|
->where('diagnosis_id', $diagnosisId)
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->order(['create_time' => 'desc', 'id' => 'desc'])
|
||||||
|
->field(['creator_id', 'create_time'])
|
||||||
|
->find();
|
||||||
|
$relatedPoCreatorId = (int) ($poSnap['creator_id'] ?? 0);
|
||||||
|
$relatedPoCreateTime = (int) ($poSnap['create_time'] ?? 0);
|
||||||
|
if ($relatedPoCreateTime <= 0) {
|
||||||
|
$relatedPoCreateTime = $now;
|
||||||
|
$relatedPoCreatorId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('tcm_diagnosis_assign_log')->insert([
|
||||||
|
'diagnosis_id' => $diagnosisId,
|
||||||
|
'from_assistant_id' => 0,
|
||||||
|
'to_assistant_id' => $toAssistantId,
|
||||||
|
'operator_admin_id' => 0,
|
||||||
|
'operator_name' => '系统自动分配',
|
||||||
|
'operator_account' => 'system',
|
||||||
|
'ip' => '',
|
||||||
|
'related_po_creator_id' => $relatedPoCreatorId,
|
||||||
|
'related_po_create_time' => $relatedPoCreateTime,
|
||||||
|
'is_inherit' => 0,
|
||||||
|
'create_time' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Db::commit();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
Log::error(sprintf('自动指派落库失败 diagnosis_id=%d assistant_id=%d msg=%s', $diagnosisId, $toAssistantId, $e->getMessage()));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $diag 诊单行(含 id/patient_name/phone)
|
||||||
|
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/round_no/reason
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function buildLogRow(string $batchNo, string $runDate, string $statMonth, array $diag, array $extra, int $now): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'batch_no' => $batchNo,
|
||||||
|
'run_date' => $runDate,
|
||||||
|
'stat_month' => $statMonth,
|
||||||
|
'diagnosis_id' => (int) ($diag['id'] ?? 0),
|
||||||
|
'patient_name' => mb_substr(trim((string) ($diag['patient_name'] ?? '')), 0, 64),
|
||||||
|
'patient_phone' => mb_substr(trim((string) ($diag['phone'] ?? '')), 0, 32),
|
||||||
|
'action' => (int) ($extra['action'] ?? 0),
|
||||||
|
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
|
||||||
|
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
|
||||||
|
'tier' => (string) ($extra['tier'] ?? ''),
|
||||||
|
'visit2_rate' => $extra['visit2_rate'] ?? null,
|
||||||
|
'round_no' => (int) ($extra['round_no'] ?? 0),
|
||||||
|
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
|
||||||
|
'create_time' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $logRows
|
||||||
|
*/
|
||||||
|
private function flushLogs(array $logRows, bool $dryRun, Output $output): void
|
||||||
|
{
|
||||||
|
if ($logRows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln(sprintf('[演练] 应写入自动指派日志 %d 条(未落库)', \count($logRows)));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (array_chunk($logRows, 500) as $chunk) {
|
||||||
|
Db::name('tcm_diagnosis_auto_assign_log')->insertAll($chunk);
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('已写入自动指派日志 %d 条', \count($logRows)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ use think\console\Output;
|
|||||||
* 使用方法:
|
* 使用方法:
|
||||||
* php think express:auto-update
|
* php think express:auto-update
|
||||||
*
|
*
|
||||||
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货/已签收」核对释放诊单医助(与是否甘草单无关)
|
* 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货」核对释放诊单医助(跳过已完成/已签收业务单,与是否甘草单无关)
|
||||||
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
|
* 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1
|
||||||
*
|
*
|
||||||
* 已对「业务订单创建人非二中心」(或创建人为开方医生时按待释放身份判定)执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
* 已对「业务订单创建人非二中心」(或创建人为开方医生时按待释放身份判定)执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||||
@@ -38,7 +38,8 @@ class ExpressAutoUpdate extends Command
|
|||||||
try {
|
try {
|
||||||
$result = ExpressTrackingService::autoUpdateBatch(1000);
|
$result = ExpressTrackingService::autoUpdateBatch(1000);
|
||||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(1000);
|
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(1000);
|
||||||
|
|
||||||
|
|
||||||
$duration = round(microtime(true) - $startTime, 2);
|
$duration = round(microtime(true) - $startTime, 2);
|
||||||
|
|
||||||
$output->writeln("更新完成!");
|
$output->writeln("更新完成!");
|
||||||
@@ -46,7 +47,7 @@ class ExpressAutoUpdate extends Command
|
|||||||
$output->writeln("成功: {$result['success']}");
|
$output->writeln("成功: {$result['success']}");
|
||||||
$output->writeln("失败: {$result['failed']}");
|
$output->writeln("失败: {$result['failed']}");
|
||||||
$output->writeln("医助已移除(物流任务+指派日志): " . (int) ($result['assistant_cleared'] ?? 0));
|
$output->writeln("医助已移除(物流任务+指派日志): " . (int) ($result['assistant_cleared'] ?? 0));
|
||||||
$output->writeln("医助已移除(履约已发货/签收核对): " . (int) ($recon['cleared'] ?? 0) . " (扫描 " . (int) ($recon['scanned'] ?? 0) . " 单)");
|
$output->writeln("医助已移除(履约已发货核对): " . (int) ($recon['cleared'] ?? 0) . " (扫描 " . (int) ($recon['scanned'] ?? 0) . " 单)");
|
||||||
$lines = array_merge($result['assistant_lines'] ?? [], $recon['lines'] ?? []);
|
$lines = array_merge($result['assistant_lines'] ?? [], $recon['lines'] ?? []);
|
||||||
if ($lines === []) {
|
if ($lines === []) {
|
||||||
$output->writeln('医助明细: (无)');
|
$output->writeln('医助明细: (无)');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\command;
|
namespace app\command;
|
||||||
|
|
||||||
|
use app\common\model\tcm\PrescriptionOrder;
|
||||||
use app\common\service\ExpressTrackingService;
|
use app\common\service\ExpressTrackingService;
|
||||||
use app\common\service\gancao\GancaoLogisticsRouteService;
|
use app\common\service\gancao\GancaoLogisticsRouteService;
|
||||||
use app\common\service\gancao\GancaoScmRecipelService;
|
use app\common\service\gancao\GancaoScmRecipelService;
|
||||||
@@ -18,18 +19,21 @@ use think\console\Output;
|
|||||||
* 数据流:
|
* 数据流:
|
||||||
* zyt_tcm_prescription_order (甘草已上传)
|
* zyt_tcm_prescription_order (甘草已上传)
|
||||||
* ↓ 调用 igc_scm.logistics.client_opt.pull / GET_TASK_ROUTE_LIST
|
* ↓ 调用 igc_scm.logistics.client_opt.pull / GET_TASK_ROUTE_LIST
|
||||||
|
* ↓ 甘草报快递任务不存在等(如 10101)且业务单已有运单号 → 降级快递100
|
||||||
* zyt_express_tracking + zyt_express_trace + zyt_express_state_log + zyt_express_query_log
|
* zyt_express_tracking + zyt_express_trace + zyt_express_state_log + zyt_express_query_log
|
||||||
*
|
*
|
||||||
* 使用方法:
|
* 使用方法:
|
||||||
* php think gancao:sync-logistics 默认拉 100 单
|
* php think gancao:sync-logistics 默认拉 2000 单(跳过已完成/已签收)
|
||||||
* php think gancao:sync-logistics --limit=200 自定义拉取上限
|
* php think gancao:sync-logistics --limit=500 自定义拉取上限
|
||||||
* php think gancao:sync-logistics --order-id=123 只跑指定订单
|
* php think gancao:sync-logistics --order-id=1424 只跑指定订单(数字 id)
|
||||||
|
* php think gancao:sync-logistics --order-id=PO20260530165158645023 或 PO 业务单号
|
||||||
* php think gancao:sync-logistics -t SF1234567890 按快递单号走快递100 查询并落库
|
* php think gancao:sync-logistics -t SF1234567890 按快递单号走快递100 查询并落库
|
||||||
* php think gancao:sync-logistics --detail 打印每单详细
|
* php think gancao:sync-logistics --detail 打印每单详细
|
||||||
*
|
*
|
||||||
* 建议 crontab(每 30 分钟执行一次):
|
* 建议 crontab(每 30 分钟执行一次):
|
||||||
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics --limit=200 >> runtime/log/gancao_sync.log 2>&1
|
* 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics >> runtime/log/gancao_sync.log 2>&1
|
||||||
*
|
*
|
||||||
|
* 跳过履约状态为已完成(3)、已签收(6) 的业务订单,不再拉取甘草路由。
|
||||||
* 已对「业务订单创建人非二中心」(发货/签收自动释放规则见 ExpressTrackingService)的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
* 已对「业务订单创建人非二中心」(发货/签收自动释放规则见 ExpressTrackingService)的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||||
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
* 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。
|
||||||
*/
|
*/
|
||||||
@@ -39,8 +43,8 @@ class GancaoSyncLogisticsRoute extends Command
|
|||||||
{
|
{
|
||||||
$this->setName('gancao:sync-logistics')
|
$this->setName('gancao:sync-logistics')
|
||||||
->setDescription('同步甘草订单的物流路由(GET_TASK_ROUTE_LIST)到本地物流追踪表')
|
->setDescription('同步甘草订单的物流路由(GET_TASK_ROUTE_LIST)到本地物流追踪表')
|
||||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单', 100)
|
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单(跳过已完成/已签收)', 2000)
|
||||||
->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定 prescription_order.id', null)
|
->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定订单:prescription_order.id 或 PO 业务单号 order_no', null)
|
||||||
->addOption('tracking-number', 't', Option::VALUE_REQUIRED, '按快递单号走快递100 查询并落库')
|
->addOption('tracking-number', 't', Option::VALUE_REQUIRED, '按快递单号走快递100 查询并落库')
|
||||||
->addOption('detail', 'd', Option::VALUE_NONE, '打印每单详细结果');
|
->addOption('detail', 'd', Option::VALUE_NONE, '打印每单详细结果');
|
||||||
}
|
}
|
||||||
@@ -48,8 +52,19 @@ class GancaoSyncLogisticsRoute extends Command
|
|||||||
protected function execute(Input $input, Output $output)
|
protected function execute(Input $input, Output $output)
|
||||||
{
|
{
|
||||||
$limit = max(1, (int) $input->getOption('limit'));
|
$limit = max(1, (int) $input->getOption('limit'));
|
||||||
$onlyOrderId = $input->getOption('order-id');
|
$onlyOrderId = null;
|
||||||
$onlyOrderId = $onlyOrderId !== null ? (int) $onlyOrderId : null;
|
$onlyOrderNo = '';
|
||||||
|
$orderIdArg = $input->getOption('order-id');
|
||||||
|
if ($orderIdArg !== null && trim((string) $orderIdArg) !== '') {
|
||||||
|
$resolved = self::resolvePrescriptionOrderId((string) $orderIdArg);
|
||||||
|
if ($resolved === null) {
|
||||||
|
$output->error('未找到订单:' . trim((string) $orderIdArg) . '(--order-id 支持数字 id 或 PO 业务单号)');
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$onlyOrderId = $resolved['id'];
|
||||||
|
$onlyOrderNo = $resolved['order_no'];
|
||||||
|
}
|
||||||
$trackingNumber = trim((string) $input->getOption('tracking-number'));
|
$trackingNumber = trim((string) $input->getOption('tracking-number'));
|
||||||
$verbose = (bool) $input->getOption('detail');
|
$verbose = (bool) $input->getOption('detail');
|
||||||
|
|
||||||
@@ -71,7 +86,11 @@ class GancaoSyncLogisticsRoute extends Command
|
|||||||
if ($trackingNumber !== '') {
|
if ($trackingNumber !== '') {
|
||||||
$output->writeln('开始查询...(快递100,tracking_number=' . $trackingNumber . ')');
|
$output->writeln('开始查询...(快递100,tracking_number=' . $trackingNumber . ')');
|
||||||
} else {
|
} else {
|
||||||
$output->writeln('开始同步...(limit=' . $limit . ($onlyOrderId ? ', order_id=' . $onlyOrderId : '') . ')');
|
if ($onlyOrderId !== null) {
|
||||||
|
$output->writeln('开始同步...(指定单 order_id=' . $onlyOrderId . ($onlyOrderNo !== '' ? ', order_no=' . $onlyOrderNo : '') . ')');
|
||||||
|
} else {
|
||||||
|
$output->writeln('开始同步...(limit=' . $limit . ')');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -82,10 +101,16 @@ class GancaoSyncLogisticsRoute extends Command
|
|||||||
$stats['reconcile_lines'] = [];
|
$stats['reconcile_lines'] = [];
|
||||||
} else {
|
} else {
|
||||||
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
|
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
|
||||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(2000);
|
if ($onlyOrderId !== null) {
|
||||||
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
|
$stats['reconcile_cleared'] = 0;
|
||||||
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
|
$stats['reconcile_scanned'] = 0;
|
||||||
$stats['reconcile_lines'] = $recon['lines'] ?? [];
|
$stats['reconcile_lines'] = [];
|
||||||
|
} else {
|
||||||
|
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(2000);
|
||||||
|
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
|
||||||
|
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
|
||||||
|
$stats['reconcile_lines'] = $recon['lines'] ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$output->error('同步异常:' . $e->getMessage());
|
$output->error('同步异常:' . $e->getMessage());
|
||||||
@@ -120,10 +145,13 @@ class GancaoSyncLogisticsRoute extends Command
|
|||||||
$output->writeln('同步完成');
|
$output->writeln('同步完成');
|
||||||
$output->writeln('========================================');
|
$output->writeln('========================================');
|
||||||
$output->writeln('总数:' . $stats['total']);
|
$output->writeln('总数:' . $stats['total']);
|
||||||
|
if (isset($stats['skipped'])) {
|
||||||
|
$output->writeln('跳过:' . (int) $stats['skipped'] . '(已完成/已签收)');
|
||||||
|
}
|
||||||
$output->writeln('成功:' . $stats['success']);
|
$output->writeln('成功:' . $stats['success']);
|
||||||
$output->writeln('失败:' . $stats['failed']);
|
$output->writeln('失败:' . $stats['failed']);
|
||||||
$output->writeln('医助已移除(甘草同步+指派日志):' . (int) ($stats['assistant_cleared'] ?? 0));
|
$output->writeln('医助已移除(甘草同步+指派日志):' . (int) ($stats['assistant_cleared'] ?? 0));
|
||||||
$output->writeln('医助已移除(履约已发货/签收核对):' . (int) ($stats['reconcile_cleared'] ?? 0) . '(扫描 ' . (int) ($stats['reconcile_scanned'] ?? 0) . ' 单)');
|
$output->writeln('医助已移除(履约已发货核对):' . (int) ($stats['reconcile_cleared'] ?? 0) . '(扫描 ' . (int) ($stats['reconcile_scanned'] ?? 0) . ' 单)');
|
||||||
$lines = array_merge($stats['assistant_lines'] ?? [], $stats['reconcile_lines'] ?? []);
|
$lines = array_merge($stats['assistant_lines'] ?? [], $stats['reconcile_lines'] ?? []);
|
||||||
if ($lines === []) {
|
if ($lines === []) {
|
||||||
$output->writeln('医助明细:(无)');
|
$output->writeln('医助明细:(无)');
|
||||||
@@ -137,4 +165,35 @@ class GancaoSyncLogisticsRoute extends Command
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id:int, order_no:string}|null
|
||||||
|
*/
|
||||||
|
private static function resolvePrescriptionOrderId(string $raw): ?array
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = PrescriptionOrder::whereNull('delete_time');
|
||||||
|
if (preg_match('/^\d+$/', $raw) === 1) {
|
||||||
|
$id = (int) $raw;
|
||||||
|
if ($id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$row = (clone $q)->where('id', $id)->field('id,order_no')->find();
|
||||||
|
} else {
|
||||||
|
$row = (clone $q)->where('order_no', $raw)->field('id,order_no')->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $row->id,
|
||||||
|
'order_no' => (string) $row->order_no,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,29 +63,95 @@ class ExpressTrackService
|
|||||||
|
|
||||||
$cfg = Config::get('logistics.kuaidi100', []);
|
$cfg = Config::get('logistics.kuaidi100', []);
|
||||||
$enable = !empty($cfg['enable']);
|
$enable = !empty($cfg['enable']);
|
||||||
|
|
||||||
|
$result = $out;
|
||||||
if (! $enable) {
|
if (! $enable) {
|
||||||
$out['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
$result['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
||||||
|
} elseif ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
|
||||||
return $out;
|
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
|
||||||
}
|
$result['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
|
||||||
|
} else {
|
||||||
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
|
$matched = null;
|
||||||
if ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
|
$lastFail = null;
|
||||||
$out['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
|
foreach ($comCandidates as $tryCom) {
|
||||||
|
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
|
||||||
return $out;
|
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
|
||||||
}
|
$matched = $tryOut;
|
||||||
|
break;
|
||||||
$lastFail = null;
|
}
|
||||||
foreach ($comCandidates as $tryCom) {
|
$lastFail = $tryOut;
|
||||||
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
|
|
||||||
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
|
|
||||||
return $tryOut;
|
|
||||||
}
|
}
|
||||||
$lastFail = $tryOut;
|
$result = $matched ?? $lastFail ?? $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $lastFail ?? $out;
|
// 京东自营单(JDVE…)兜底:快递100 无轨迹/陈旧时,用京东官方接口(更新或更全才采用)。
|
||||||
|
// 未配置京东官方接口时 isConfigured()=false,本段跳过,行为与原先一致。
|
||||||
|
if ($carrier === 'jd' && JdLogisticsService::isConfigured()) {
|
||||||
|
try {
|
||||||
|
$jdPhone = $overrideDigits !== '' ? $overrideDigits : $recipientDigits;
|
||||||
|
$jd = JdLogisticsService::queryTrace($num, $jdPhone);
|
||||||
|
if ($jd !== null && !empty($jd['traces']) && self::jdResultPreferred($jd, $result)) {
|
||||||
|
$result['traces'] = $jd['traces'];
|
||||||
|
$result['state'] = (string) $jd['state'];
|
||||||
|
$result['state_text'] = (string) $jd['state_text'];
|
||||||
|
$result['source'] = 'jd_official';
|
||||||
|
$result['hint'] = '';
|
||||||
|
// carrier / carrier_label / official_url / kuaidi_com 保留原值
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('ExpressTrackService jd official fallback failed', [
|
||||||
|
'num' => $num,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 京东官方轨迹是否应优先于快递100 结果采用:
|
||||||
|
* 快递100 无轨迹 → 直接用;否则京东更「新」(最新轨迹时间更晚)或同样新但条目更多 → 用。
|
||||||
|
*
|
||||||
|
* @param array{traces?:array,newest_unix?:int} $jd
|
||||||
|
* @param array{traces?:array} $kuaidi
|
||||||
|
*/
|
||||||
|
private static function jdResultPreferred(array $jd, array $kuaidi): bool
|
||||||
|
{
|
||||||
|
$kuaidiTraces = is_array($kuaidi['traces'] ?? null) ? $kuaidi['traces'] : [];
|
||||||
|
if ($kuaidiTraces === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jdNewest = (int) ($jd['newest_unix'] ?? 0);
|
||||||
|
$kuaidiNewest = self::newestUnixFromTraces($kuaidiTraces);
|
||||||
|
if ($jdNewest > $kuaidiNewest) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($jdNewest === $kuaidiNewest && $jdNewest > 0) {
|
||||||
|
return count($jd['traces'] ?? []) > count($kuaidiTraces);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{time?:string}> $traces
|
||||||
|
*/
|
||||||
|
private static function newestUnixFromTraces(array $traces): int
|
||||||
|
{
|
||||||
|
$best = 0;
|
||||||
|
foreach ($traces as $t) {
|
||||||
|
if (!is_array($t)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$p = strtotime((string) ($t['time'] ?? ''));
|
||||||
|
if ($p !== false && (int) $p > $best) {
|
||||||
|
$best = (int) $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $best;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -162,6 +162,76 @@ class ExpressTrackingService
|
|||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 甘草路由不可用时的降级:按业务订单运单号走快递100 查询并落库(自发货等场景)
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* success: bool,
|
||||||
|
* message: string,
|
||||||
|
* traces_count: int,
|
||||||
|
* state: string,
|
||||||
|
* source: string,
|
||||||
|
* assistant_sync?: array|null
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function queryKuaidiForPrescriptionOrder(PrescriptionOrder $order, bool $isAuto = true): array
|
||||||
|
{
|
||||||
|
$fail = static fn (string $msg): array => [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $msg,
|
||||||
|
'traces_count' => 0,
|
||||||
|
'state' => '',
|
||||||
|
'source' => 'kuaidi100_fallback',
|
||||||
|
'assistant_sync' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$tn = trim((string) ($order->tracking_number ?? ''));
|
||||||
|
if ($tn === '') {
|
||||||
|
return $fail('甘草无快递任务且业务单未填写快递单号,无法走快递100');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracking = self::resolveTrackingByNumber($tn);
|
||||||
|
if (!$tracking) {
|
||||||
|
return $fail('未找到运单对应的物流追踪记录');
|
||||||
|
}
|
||||||
|
|
||||||
|
self::backfillRecipientPhoneFromPrescriptionOrder($tracking);
|
||||||
|
self::syncTrackingExpressCompanyFromNumber($tracking);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = self::queryAndUpdate((int) $tracking->id, $isAuto);
|
||||||
|
$ok = self::isKuaidiQuerySuccessful($result);
|
||||||
|
$tracking = ExpressTracking::where('id', (int) $tracking->id)->whereNull('delete_time')->find();
|
||||||
|
$state = $tracking ? (string) ($tracking->current_state ?? '') : '';
|
||||||
|
$hint = trim((string) ($result['hint'] ?? ''));
|
||||||
|
$message = $ok
|
||||||
|
? 'ok(甘草无快递任务,已降级快递100)'
|
||||||
|
: ($hint !== '' ? $hint : '快递100 查询未返回有效轨迹');
|
||||||
|
|
||||||
|
$assistantSync = null;
|
||||||
|
if (is_array($result) && isset($result['_assistant_sync']) && is_array($result['_assistant_sync'])) {
|
||||||
|
$assistantSync = $result['_assistant_sync'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $ok,
|
||||||
|
'message' => $message,
|
||||||
|
'traces_count' => count($result['traces'] ?? []),
|
||||||
|
'state' => $state,
|
||||||
|
'source' => 'kuaidi100_fallback',
|
||||||
|
'assistant_sync' => $assistantSync,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('queryKuaidiForPrescriptionOrder failed', [
|
||||||
|
'order_id' => (int) $order->id,
|
||||||
|
'tracking_number' => $tn,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $fail('快递100 降级异常:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确保 express_tracking 存在(不发起查询)
|
* 确保 express_tracking 存在(不发起查询)
|
||||||
*/
|
*/
|
||||||
@@ -283,6 +353,93 @@ class ExpressTrackingService
|
|||||||
|
|
||||||
$responseTime = (int) ((microtime(true) - $startTime) * 1000);
|
$responseTime = (int) ((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
return self::persistTrackingResult($tracking, $result, $isAuto, $responseTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接调用京东官方物流接口(绕过快递100),将轨迹落库到 express_tracking / express_trace。
|
||||||
|
* 供后台「京东接口更新」按钮使用;未配置京东官方接口或无轨迹时返回失败。
|
||||||
|
*
|
||||||
|
* @return array{success:bool, message:string, traces_count:int, state:string, source:string}
|
||||||
|
*/
|
||||||
|
public static function queryJdOfficialAndUpdate(string $trackingNumber): array
|
||||||
|
{
|
||||||
|
$fail = static fn (string $msg): array => [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $msg,
|
||||||
|
'traces_count' => 0,
|
||||||
|
'state' => '',
|
||||||
|
'source' => 'jd_official',
|
||||||
|
];
|
||||||
|
|
||||||
|
$tn = trim($trackingNumber);
|
||||||
|
if ($tn === '') {
|
||||||
|
return $fail('快递单号不能为空');
|
||||||
|
}
|
||||||
|
if (!JdLogisticsService::isConfigured()) {
|
||||||
|
return $fail('未配置京东官方物流接口(请在 .env 配置 JD_LOGISTICS_* 后重试)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracking = self::resolveTrackingByNumber($tn);
|
||||||
|
if (!$tracking) {
|
||||||
|
return $fail('未找到该快递单号对应的业务订单或物流追踪记录');
|
||||||
|
}
|
||||||
|
|
||||||
|
$startTime = microtime(true);
|
||||||
|
try {
|
||||||
|
$jd = JdLogisticsService::queryTrace($tn, (string) ($tracking->recipient_phone ?? ''));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('queryJdOfficialAndUpdate failed', [
|
||||||
|
'tracking_number' => $tn,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $fail('京东官方接口请求异常:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
$responseTime = (int) ((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
if ($jd === null || empty($jd['traces'])) {
|
||||||
|
self::logQuery($tn, (string) $tracking->express_company, 'manual', [
|
||||||
|
'source' => 'jd_official',
|
||||||
|
'hint' => '京东官方接口未返回有效轨迹',
|
||||||
|
'traces' => [],
|
||||||
|
], $responseTime);
|
||||||
|
|
||||||
|
return $fail('京东官方接口未返回有效轨迹,请确认运单号或稍后重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'carrier' => 'jd',
|
||||||
|
'carrier_label' => '京东快递',
|
||||||
|
'kuaidi_com' => 'jd',
|
||||||
|
'traces' => $jd['traces'],
|
||||||
|
'state' => (string) ($jd['state'] ?? '0'),
|
||||||
|
'state_text' => (string) ($jd['state_text'] ?? ''),
|
||||||
|
'source' => 'jd_official',
|
||||||
|
'hint' => '',
|
||||||
|
'official_url' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
self::persistTrackingResult($tracking, $result, false, $responseTime);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => '京东物流轨迹已更新',
|
||||||
|
'traces_count' => count($jd['traces']),
|
||||||
|
'state' => (string) ($jd['state_text'] ?? ''),
|
||||||
|
'source' => 'jd_official',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将一次轨迹查询结果(快递100 / 京东官方等)落库并推进订单状态。
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $result query 返回的轨迹结构
|
||||||
|
* @param bool $isAuto 是否定时任务触发(影响查询日志类型与医助释放)
|
||||||
|
* @return array<string, mixed>|null 查询结果;自动任务时可能含 _assistant_sync
|
||||||
|
*/
|
||||||
|
private static function persistTrackingResult(ExpressTracking $tracking, array $result, bool $isAuto, int $responseTime): ?array
|
||||||
|
{
|
||||||
// 记录查询日志
|
// 记录查询日志
|
||||||
self::logQuery(
|
self::logQuery(
|
||||||
$tracking->tracking_number,
|
$tracking->tracking_number,
|
||||||
@@ -873,7 +1030,7 @@ class ExpressTrackingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按处方订单履约核对:已发货(5)/已签收(6) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder。
|
* 按处方订单履约核对:仅已发货(5) 时符合条件的订单调用 applyAssistantReleaseForShippedPrescriptionOrder(已完成/已签收不再扫描)。
|
||||||
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看业务单创建人**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
|
* 与快递 100 / 甘草来自哪个物流渠道无关;是否清空医助以该方法为准(**二中心豁免看业务单创建人**参见 apply;已 shipped 释放且 assistant_id=0 视为已处理)。
|
||||||
*
|
*
|
||||||
* @return array{scanned: int, cleared: int, lines: list<string>}
|
* @return array{scanned: int, cleared: int, lines: list<string>}
|
||||||
@@ -888,7 +1045,7 @@ class ExpressTrackingService
|
|||||||
->whereNull('po.delete_time')
|
->whereNull('po.delete_time')
|
||||||
->whereNull('d.delete_time')
|
->whereNull('d.delete_time')
|
||||||
->whereRaw('(IFNULL(d.shipped_non_er_assistant_cleared_at, 0) = 0 OR d.assistant_id > 0)')
|
->whereRaw('(IFNULL(d.shipped_non_er_assistant_cleared_at, 0) = 0 OR d.assistant_id > 0)')
|
||||||
->whereIn('po.fulfillment_status', [5, 6])
|
->where('po.fulfillment_status', 5)
|
||||||
->where('po.diagnosis_id', '>', 0)
|
->where('po.diagnosis_id', '>', 0)
|
||||||
->where(function ($w) {
|
->where(function ($w) {
|
||||||
$w->where('d.assistant_id', '>', 0)
|
$w->where('d.assistant_id', '>', 0)
|
||||||
@@ -977,7 +1134,7 @@ class ExpressTrackingService
|
|||||||
->field('et.*')
|
->field('et.*')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->select();
|
->select();
|
||||||
|
|
||||||
$success = 0;
|
$success = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
$assistantCleared = 0;
|
$assistantCleared = 0;
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use think\facade\Config;
|
||||||
|
use think\facade\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 京东官方物流轨迹查询(京东物流开放平台 LOP,https://api.jdl.com)
|
||||||
|
*
|
||||||
|
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源,
|
||||||
|
* 同时供后台「京东接口更新」按钮直接拉取并落库。
|
||||||
|
* 仅在 config/logistics.php 的 jd.enable=true(填好 app_key/app_secret/access_token)时生效;
|
||||||
|
* 未配置时 isConfigured()=false,ExpressTrackService 完全沿用快递100 逻辑,互不影响。
|
||||||
|
*
|
||||||
|
* 接口:京东物流标准轨迹服务 /jd/tracking/query(2025-04-29 改版,对接方案编码 Tracking_JD)。
|
||||||
|
* 调用走 LOP 统一网关,鉴权/签名规则与官方 SDK(IsvFilter) 完全一致:
|
||||||
|
* - 公共参数(app_key/access_token/timestamp/v/sign/algorithm/LOP-DN)以 query string 拼到 URL;
|
||||||
|
* - 业务参数 JSON 字符串作为请求体;待签串固定顺序拼接并首尾包 app_secret。
|
||||||
|
* 加签算法由 .env JD_LOGISTICS_ALGORITHM 控制(默认 md5-salt=md5(content),另支持 HMacMD5/SHA1/SHA256/SHA512)。
|
||||||
|
* 注意:后台「报文加解密密钥」的 RSA 公私钥仅用于报文加解密,与本网关签名无关。
|
||||||
|
*
|
||||||
|
* 网关/path/对接方案编码/单号类型 走 .env(JD_LOGISTICS_GATEWAY / METHOD / LOP_DN / REFERENCE_TYPE)。
|
||||||
|
* 响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构。
|
||||||
|
*/
|
||||||
|
final class JdLogisticsService
|
||||||
|
{
|
||||||
|
/** 轨迹行「时间」候选字段(按优先级) */
|
||||||
|
private const TIME_FIELDS = [
|
||||||
|
'operationTime', 'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
|
||||||
|
'time', 'createTime', 'waybillStateTime', 'orderTime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 轨迹行「描述」候选字段(按优先级) */
|
||||||
|
private const CONTEXT_FIELDS = [
|
||||||
|
'remark', 'operateRemark', 'opeRemark', 'content', 'opeTitle',
|
||||||
|
'operationCodeName', 'operationTypeName', 'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function isConfigured(): bool
|
||||||
|
{
|
||||||
|
$cfg = Config::get('logistics.jd', []);
|
||||||
|
|
||||||
|
// LOP 网关签名用 app_secret(md5-salt / HMAC),不需要 RSA 私钥(私钥仅用于报文加解密)
|
||||||
|
return !empty($cfg['enable'])
|
||||||
|
&& trim((string) ($cfg['app_key'] ?? '')) !== ''
|
||||||
|
&& trim((string) ($cfg['app_secret'] ?? '')) !== ''
|
||||||
|
&& trim((string) ($cfg['access_token'] ?? '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询京东官方轨迹,返回与 ExpressTrackService::query 兼容的结构(失败/未配置返回 null)。
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* traces: list<array{time:string,context:string,status:string}>,
|
||||||
|
* state: string,
|
||||||
|
* state_text: string,
|
||||||
|
* source: string,
|
||||||
|
* hint: string,
|
||||||
|
* newest_unix: int
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public static function queryTrace(string $waybillCode, string $phoneTail = ''): ?array
|
||||||
|
{
|
||||||
|
$num = trim($waybillCode);
|
||||||
|
if ($num === '' || !self::isConfigured()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = Config::get('logistics.jd', []);
|
||||||
|
|
||||||
|
// 京东物流标准轨迹服务 /jd/tracking/query:body 为 JSON 数组 [{referenceNumber, referenceType, phone}]
|
||||||
|
$row = [
|
||||||
|
(string) ($cfg['reference_field'] ?? 'referenceNumber') => $num,
|
||||||
|
'referenceType' => (string) ($cfg['reference_type'] ?? '20000'),
|
||||||
|
];
|
||||||
|
$tail = substr(preg_replace('/\D/', '', $phoneTail) ?? '', -4);
|
||||||
|
if ($tail !== '') {
|
||||||
|
$row['phone'] = $tail;
|
||||||
|
}
|
||||||
|
$customerCode = trim((string) ($cfg['customer_code'] ?? ''));
|
||||||
|
if ($customerCode !== '') {
|
||||||
|
$row['customerCode'] = $customerCode;
|
||||||
|
}
|
||||||
|
$body = json_encode([$row], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$raw = self::request($cfg, (string) $body);
|
||||||
|
if ($raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($raw, true);
|
||||||
|
if (!is_array($json)) {
|
||||||
|
Log::warning('JdLogisticsService invalid json', ['raw' => mb_substr($raw, 0, 500), 'num' => $num]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOP 网关/业务错误:code 非 1000(成功)时记录原始报文,便于排查鉴权/单号/权限问题
|
||||||
|
$code = (string) ($json['code'] ?? $json['resultCode'] ?? '');
|
||||||
|
if ($code !== '' && !in_array($code, ['1000', '0000', '0'], true)) {
|
||||||
|
Log::warning('JdLogisticsService lop error', [
|
||||||
|
'num' => $num,
|
||||||
|
'code' => $code,
|
||||||
|
'message' => (string) ($json['msg'] ?? $json['message'] ?? $json['resultMessage'] ?? ''),
|
||||||
|
'raw' => mb_substr($raw, 0, 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 兼容 JOS 网关层错误结构
|
||||||
|
if (isset($json['error_response'])) {
|
||||||
|
Log::warning('JdLogisticsService gateway error', [
|
||||||
|
'num' => $num,
|
||||||
|
'error' => $json['error_response'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = self::extractTraceRows($json);
|
||||||
|
if ($rows === []) {
|
||||||
|
Log::info('JdLogisticsService no trace rows', ['num' => $num, 'json' => mb_substr($raw, 0, 800)]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$traces = self::normalizeRows($rows);
|
||||||
|
if ($traces === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间倒序(最新在前),与快递100 输出一致
|
||||||
|
usort($traces, static function (array $a, array $b): int {
|
||||||
|
return ($b['_unix'] ?? 0) <=> ($a['_unix'] ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
$newestUnix = (int) ($traces[0]['_unix'] ?? 0);
|
||||||
|
$signed = false;
|
||||||
|
foreach ($traces as $t) {
|
||||||
|
if (self::looksSigned((string) $t['context'])) {
|
||||||
|
$signed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$state = $signed ? '3' : '0';
|
||||||
|
|
||||||
|
// 去掉内部辅助字段
|
||||||
|
$clean = [];
|
||||||
|
foreach ($traces as $t) {
|
||||||
|
$clean[] = [
|
||||||
|
'time' => (string) $t['time'],
|
||||||
|
'context' => (string) $t['context'],
|
||||||
|
'status' => (string) ($t['status'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'traces' => $clean,
|
||||||
|
'state' => $state,
|
||||||
|
'state_text' => $signed ? '已签收' : '在途',
|
||||||
|
'source' => 'jd_official',
|
||||||
|
'hint' => '',
|
||||||
|
'newest_unix' => $newestUnix,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 LOP 网关(统一鉴权/签名,与官方 SDK IsvFilter 一致)。
|
||||||
|
*
|
||||||
|
* 公共参数以 query string 拼到 URL;业务参数(JSON 字符串)作为请求体;
|
||||||
|
* 网关靠 LOP-DN(对接方案编码) 路由到对应服务。
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $cfg
|
||||||
|
* @param string $body 业务参数 JSON 字符串(param_json)
|
||||||
|
*/
|
||||||
|
private static function request(array $cfg, string $body): ?string
|
||||||
|
{
|
||||||
|
$appKey = (string) ($cfg['app_key'] ?? '');
|
||||||
|
$appSecret = (string) ($cfg['app_secret'] ?? '');
|
||||||
|
$accessToken = (string) ($cfg['access_token'] ?? '');
|
||||||
|
$path = (string) ($cfg['method'] ?? '/jd/tracking/query');
|
||||||
|
$version = (string) ($cfg['api_version'] ?? '2.0');
|
||||||
|
$algorithm = trim((string) ($cfg['algorithm'] ?? 'md5-salt')) ?: 'md5-salt';
|
||||||
|
$lopDn = (string) ($cfg['lop_dn'] ?? 'Tracking_JD');
|
||||||
|
// 时间戳与时区必须自洽(否则网关报 471 时间戳已失效):统一用北京时间 + lop-tz=8
|
||||||
|
$now = new \DateTime('now', new \DateTimeZone('Asia/Shanghai'));
|
||||||
|
$timestamp = $now->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
// 待签串:固定顺序拼接,首尾包 appSecret(method=接口path,param_json=业务体)
|
||||||
|
$content = implode('', [
|
||||||
|
$appSecret,
|
||||||
|
'access_token', $accessToken,
|
||||||
|
'app_key', $appKey,
|
||||||
|
'method', $path,
|
||||||
|
'param_json', $body,
|
||||||
|
'timestamp', $timestamp,
|
||||||
|
'v', $version,
|
||||||
|
$appSecret,
|
||||||
|
]);
|
||||||
|
$sign = self::sign($algorithm, $content, $appSecret);
|
||||||
|
if ($sign === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = [
|
||||||
|
'LOP-DN' => $lopDn,
|
||||||
|
'app_key' => $appKey,
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'v' => $version,
|
||||||
|
'sign' => $sign,
|
||||||
|
'algorithm' => $algorithm,
|
||||||
|
];
|
||||||
|
|
||||||
|
$base = rtrim((string) ($cfg['gateway'] ?? 'https://api.jdl.com'), '/');
|
||||||
|
$url = $base . $path . '?' . http_build_query($query);
|
||||||
|
|
||||||
|
// lop-tz:与 timestamp 同源(北京时间 = 东八区 = 8)
|
||||||
|
$offsetHours = (int) ($now->getOffset() / 3600);
|
||||||
|
|
||||||
|
return self::httpPostJson($url, $body, [
|
||||||
|
'Content-Type: application/json;charset=utf-8',
|
||||||
|
'User-Agent: lop-http/php',
|
||||||
|
'lop-tz: ' . $offsetHours,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LOP 网关签名(与官方 SDK Utils::sign 一致):
|
||||||
|
* - md5-salt :md5(content) 的小写十六进制
|
||||||
|
* - HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512:base64(hmac(算法, content, appSecret))
|
||||||
|
* 不支持的算法返回 null。
|
||||||
|
*/
|
||||||
|
private static function sign(string $algorithm, string $content, string $secret): ?string
|
||||||
|
{
|
||||||
|
switch (trim($algorithm)) {
|
||||||
|
case 'md5-salt':
|
||||||
|
return md5($content);
|
||||||
|
case 'HMacMD5':
|
||||||
|
return base64_encode(hash_hmac('md5', $content, $secret, true));
|
||||||
|
case 'HMacSHA1':
|
||||||
|
return base64_encode(hash_hmac('sha1', $content, $secret, true));
|
||||||
|
case 'HMacSHA256':
|
||||||
|
return base64_encode(hash_hmac('sha256', $content, $secret, true));
|
||||||
|
case 'HMacSHA512':
|
||||||
|
return base64_encode(hash_hmac('sha512', $content, $secret, true));
|
||||||
|
default:
|
||||||
|
Log::warning('JdLogisticsService unsupported algorithm', ['algorithm' => $algorithm]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归在响应 JSON 中找出「轨迹行数组」:取出现轨迹行最多的一组。
|
||||||
|
* 兼容字段被序列化成 JSON 字符串(如 querytrace_result 为 string)的情况。
|
||||||
|
*
|
||||||
|
* @param mixed $node
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function extractTraceRows($node): array
|
||||||
|
{
|
||||||
|
$best = [];
|
||||||
|
|
||||||
|
$walk = function ($n) use (&$walk, &$best): void {
|
||||||
|
if (is_string($n)) {
|
||||||
|
$trimmed = trim($n);
|
||||||
|
if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) {
|
||||||
|
$decoded = json_decode($trimmed, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$walk($decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!is_array($n)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否为「轨迹行的列表」:连续数字键、且元素是带时间/描述字段的关联数组
|
||||||
|
if (self::isList($n)) {
|
||||||
|
$rows = [];
|
||||||
|
foreach ($n as $item) {
|
||||||
|
if (is_array($item) && self::rowHasTraceFields($item)) {
|
||||||
|
$rows[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($rows) > count($best)) {
|
||||||
|
$best = $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($n as $v) {
|
||||||
|
$walk($v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$walk($node);
|
||||||
|
|
||||||
|
return $best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $row
|
||||||
|
*/
|
||||||
|
private static function rowHasTraceFields(array $row): bool
|
||||||
|
{
|
||||||
|
$hasTime = false;
|
||||||
|
foreach (self::TIME_FIELDS as $f) {
|
||||||
|
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||||
|
$hasTime = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasTime) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (self::CONTEXT_FIELDS as $f) {
|
||||||
|
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string,mixed>> $rows
|
||||||
|
* @return list<array{time:string,context:string,status:string,_unix:int}>
|
||||||
|
*/
|
||||||
|
private static function normalizeRows(array $rows): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$time = '';
|
||||||
|
foreach (self::TIME_FIELDS as $f) {
|
||||||
|
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||||
|
$time = trim((string) $row[$f]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$context = '';
|
||||||
|
foreach (self::CONTEXT_FIELDS as $f) {
|
||||||
|
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||||
|
$context = trim((string) $row[$f]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($time === '' && $context === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$unix = self::parseTimeToUnix($time);
|
||||||
|
$out[] = [
|
||||||
|
'time' => $time !== '' ? self::formatTime($time, $unix) : '',
|
||||||
|
'context' => $context,
|
||||||
|
'status' => '',
|
||||||
|
'_unix' => $unix,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parseTimeToUnix(string $time): int
|
||||||
|
{
|
||||||
|
$t = trim($time);
|
||||||
|
if ($t === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 毫秒时间戳
|
||||||
|
if (preg_match('/^\d{13}$/', $t)) {
|
||||||
|
return (int) ((int) $t / 1000);
|
||||||
|
}
|
||||||
|
// 秒时间戳
|
||||||
|
if (preg_match('/^\d{10}$/', $t)) {
|
||||||
|
return (int) $t;
|
||||||
|
}
|
||||||
|
$p = strtotime($t);
|
||||||
|
|
||||||
|
return $p !== false ? (int) $p : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function formatTime(string $raw, int $unix): string
|
||||||
|
{
|
||||||
|
// 纯时间戳统一格式化成可读时间,便于落库/前端展示
|
||||||
|
if ($unix > 0 && preg_match('/^\d{10,13}$/', trim($raw))) {
|
||||||
|
return date('Y-m-d H:i:s', $unix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $arr
|
||||||
|
*/
|
||||||
|
private static function isList(array $arr): bool
|
||||||
|
{
|
||||||
|
if ($arr === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (function_exists('array_is_list')) {
|
||||||
|
return array_is_list($arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($arr) === range(0, count($arr) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function looksSigned(string $hay): bool
|
||||||
|
{
|
||||||
|
if ($hay === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (['准备签收', '待签收', '等待签收', '预计', '即将送达'] as $neg) {
|
||||||
|
if (mb_stripos($hay, $neg) !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['签收', '妥投', '送达', '已放在'] as $k) {
|
||||||
|
if (mb_stripos($hay, $k) !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $headers
|
||||||
|
*/
|
||||||
|
private static function httpPostJson(string $url, string $body, array $headers): ?string
|
||||||
|
{
|
||||||
|
if (!function_exists('curl_init')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||||
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($resp === false) {
|
||||||
|
Log::warning('JdLogisticsService http error', ['url' => $url, 'error' => $err]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,11 @@ final class GancaoLogisticsRouteService
|
|||||||
|
|
||||||
$resp = self::callRouteApi($appOrderNo);
|
$resp = self::callRouteApi($appOrderNo);
|
||||||
if (!$resp['success']) {
|
if (!$resp['success']) {
|
||||||
self::logQuery($order, '', 'auto', false, 0, $resp['message']);
|
self::logQuery($order, '', 'auto', false, (int) ($resp['runtime_ms'] ?? 0), $resp['message']);
|
||||||
|
if (self::shouldFallbackToKuaidi100($resp)) {
|
||||||
|
return self::syncOneViaKuaidi100($order, (string) $resp['message']);
|
||||||
|
}
|
||||||
|
|
||||||
return ['success' => false, 'message' => $resp['message'], 'traces_count' => 0, 'state' => ''];
|
return ['success' => false, 'message' => $resp['message'], 'traces_count' => 0, 'state' => ''];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +91,10 @@ final class GancaoLogisticsRouteService
|
|||||||
|
|
||||||
if ($trackingNumber === '') {
|
if ($trackingNumber === '') {
|
||||||
self::logQuery($order, '', 'auto', false, $resp['runtime_ms'], '甘草未返回 sp_order_no(快递单号),可能尚未发货');
|
self::logQuery($order, '', 'auto', false, $resp['runtime_ms'], '甘草未返回 sp_order_no(快递单号),可能尚未发货');
|
||||||
|
if (trim((string) ($order->tracking_number ?? '')) !== '') {
|
||||||
|
return self::syncOneViaKuaidi100($order, '甘草未返回快递单号,业务单已填写运单号');
|
||||||
|
}
|
||||||
|
|
||||||
return ['success' => false, 'message' => '尚未发货(无快递单号)', 'traces_count' => 0, 'state' => ''];
|
return ['success' => false, 'message' => '尚未发货(无快递单号)', 'traces_count' => 0, 'state' => ''];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,11 +144,54 @@ final class GancaoLogisticsRouteService
|
|||||||
'message' => 'ok',
|
'message' => 'ok',
|
||||||
'traces_count' => $tracesCount,
|
'traces_count' => $tracesCount,
|
||||||
'state' => $newState,
|
'state' => $newState,
|
||||||
|
'source' => 'gancao',
|
||||||
'raw' => $result,
|
'raw' => $result,
|
||||||
'assistant_sync' => $assistantSync,
|
'assistant_sync' => $assistantSync,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 甘草侧无快递任务 / 路由拉取失败时,按业务单运单号降级快递100(自发货)
|
||||||
|
*
|
||||||
|
* @return array{success:bool, message:string, traces_count:int, state:string, source?:string, assistant_sync?:array|null}
|
||||||
|
*/
|
||||||
|
private static function syncOneViaKuaidi100(PrescriptionOrder $order, string $gancaoReason): array
|
||||||
|
{
|
||||||
|
$ret = ExpressTrackingService::queryKuaidiForPrescriptionOrder($order, true);
|
||||||
|
if (!$ret['success']) {
|
||||||
|
$ret['message'] = trim((string) $ret['message']) . ';甘草:' . mb_substr($gancaoReason, 0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否因甘草「快递任务」类错误降级走快递100
|
||||||
|
*
|
||||||
|
* @param array{success?:bool, message?:string, api_code?:string} $gancaoResp
|
||||||
|
*/
|
||||||
|
private static function shouldFallbackToKuaidi100(array $gancaoResp): bool
|
||||||
|
{
|
||||||
|
$code = trim((string) ($gancaoResp['api_code'] ?? ''));
|
||||||
|
if ($code === '10101') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = (string) ($gancaoResp['message'] ?? '');
|
||||||
|
if ($msg === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$needles = ['快递任务', '任务id不存在', '任务不存在', '无快递任务', '物流任务'];
|
||||||
|
foreach ($needles as $needle) {
|
||||||
|
if (mb_strpos($msg, $needle) !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用甘草 GET_TASK_ROUTE_LIST 接口
|
* 调用甘草 GET_TASK_ROUTE_LIST 接口
|
||||||
*
|
*
|
||||||
@@ -181,6 +232,7 @@ final class GancaoLogisticsRouteService
|
|||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'API 错误:' . GancaoScmRecipelService::apiStatusMessage($body),
|
'message' => 'API 错误:' . GancaoScmRecipelService::apiStatusMessage($body),
|
||||||
|
'api_code' => (string) ($body['status']['code'] ?? ''),
|
||||||
'runtime_ms' => $runtimeMs,
|
'runtime_ms' => $runtimeMs,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -435,7 +487,7 @@ final class GancaoLogisticsRouteService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量同步:选取候选订单(已上传甘草、且未签收/未取消的)
|
* 批量同步:选取候选订单(已上传甘草、且未完成/未签收/未取消的)
|
||||||
*
|
*
|
||||||
* @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>}
|
* @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>}
|
||||||
*/
|
*/
|
||||||
@@ -464,8 +516,8 @@ final class GancaoLogisticsRouteService
|
|||||||
$w->whereIn('gancao_order_state', [110, 20, 30, 90])
|
$w->whereIn('gancao_order_state', [110, 20, 30, 90])
|
||||||
->whereOr('tracking_number', '<>', '');
|
->whereOr('tracking_number', '<>', '');
|
||||||
});
|
});
|
||||||
// 排除已取消(4)、已签收(6);已完成(3)仍跑一次以补齐路由
|
// 排除已完成(3)、已取消(4)、已签收(6)
|
||||||
$q->whereNotIn('fulfillment_status', [4, 6]);
|
$q->whereNotIn('fulfillment_status', [3, 4, 6]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$orders = $q->order('id', 'desc')->limit($limit)->select();
|
$orders = $q->order('id', 'desc')->limit($limit)->select();
|
||||||
@@ -481,6 +533,12 @@ final class GancaoLogisticsRouteService
|
|||||||
*/
|
*/
|
||||||
private static function appendSyncOneResult(array &$stats, PrescriptionOrder $order): void
|
private static function appendSyncOneResult(array &$stats, PrescriptionOrder $order): void
|
||||||
{
|
{
|
||||||
|
$fs = (int) ($order->fulfillment_status ?? 0);
|
||||||
|
if (in_array($fs, [3, 6], true)) {
|
||||||
|
$stats['skipped']++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$stats['total']++;
|
$stats['total']++;
|
||||||
try {
|
try {
|
||||||
$r = self::syncOne($order);
|
$r = self::syncOne($order);
|
||||||
@@ -493,6 +551,7 @@ final class GancaoLogisticsRouteService
|
|||||||
'message' => $r['message'],
|
'message' => $r['message'],
|
||||||
'traces' => $r['traces_count'],
|
'traces' => $r['traces_count'],
|
||||||
'state' => $r['state'],
|
'state' => $r['state'],
|
||||||
|
'source' => $r['source'] ?? 'gancao',
|
||||||
];
|
];
|
||||||
if ($r['success']) {
|
if ($r['success']) {
|
||||||
$stats['success']++;
|
$stats['success']++;
|
||||||
@@ -501,8 +560,10 @@ final class GancaoLogisticsRouteService
|
|||||||
$act = (string) ($sync['action'] ?? '');
|
$act = (string) ($sync['action'] ?? '');
|
||||||
if ($act === 'cleared') {
|
if ($act === 'cleared') {
|
||||||
$stats['assistant_cleared']++;
|
$stats['assistant_cleared']++;
|
||||||
|
$channel = (($r['source'] ?? '') === 'kuaidi100_fallback') ? '快递100降级' : '甘草路由';
|
||||||
$stats['assistant_lines'][] = sprintf(
|
$stats['assistant_lines'][] = sprintf(
|
||||||
'[移除医助+指派日志][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
|
'[移除医助+指派日志][%s] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
|
||||||
|
$channel,
|
||||||
(int) ($sync['diagnosis_id'] ?? 0),
|
(int) ($sync['diagnosis_id'] ?? 0),
|
||||||
(int) ($sync['prescription_order_id'] ?? 0),
|
(int) ($sync['prescription_order_id'] ?? 0),
|
||||||
(string) ($sync['tracking_number'] ?? ''),
|
(string) ($sync['tracking_number'] ?? ''),
|
||||||
|
|||||||
@@ -40,5 +40,7 @@ return [
|
|||||||
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
|
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
|
||||||
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
|
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
|
||||||
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
|
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
|
||||||
|
// 待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配(>70% 3条/60~70% 2条/50~60% 1条),写自动指派日志
|
||||||
|
'tcm:auto-assign-pending' => 'app\\command\\AutoAssignPendingDiagnosis',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -41,6 +41,34 @@ $forceDisable = filter_var($forceDisableRaw, FILTER_VALIDATE_BOOLEAN);
|
|||||||
// 已填 CUSTOMER+KEY 即启用;需临时关闭时设 LOGISTICS_KUAIDI100_DISABLE=true(LOGISTICS_KUAIDI100_ENABLE 已废弃,可删)
|
// 已填 CUSTOMER+KEY 即启用;需临时关闭时设 LOGISTICS_KUAIDI100_DISABLE=true(LOGISTICS_KUAIDI100_ENABLE 已废弃,可删)
|
||||||
$kuaidiEnabled = ! $forceDisable && $hasCreds;
|
$kuaidiEnabled = ! $forceDisable && $hasCreds;
|
||||||
|
|
||||||
|
// 京东物流开放平台 LOP(https://api.jdl.com)
|
||||||
|
// 仅当填好 APP_KEY / APP_SECRET / ACCESS_TOKEN(RSA2 模式还需 RSA_PRIVATE_KEY)时启用;
|
||||||
|
// 作为快递100 对京东自营单(JDVE…)轨迹陈旧时的兜底数据源,并供后台「京东接口更新」按钮使用。
|
||||||
|
// 未配置时本段 enable=false,ExpressTrackService 完全沿用现有快递100 逻辑,互不影响。
|
||||||
|
// 加签方式由 JD_LOGISTICS_ALGORITHM 控制:rsa2(默认,需私钥) / md5(摘要,无需私钥)。
|
||||||
|
$envJd = static function (string $suffix, $default = '') use ($stripQuotes) {
|
||||||
|
$top = 'JD_LOGISTICS_' . $suffix;
|
||||||
|
$v = env($top, null);
|
||||||
|
if ($v !== null && $v !== '') {
|
||||||
|
return $stripQuotes((string) $v);
|
||||||
|
}
|
||||||
|
// 兼容误写进 [trtc] 等分区的情况
|
||||||
|
$nested = env('trtc.' . $top, $default);
|
||||||
|
|
||||||
|
return $stripQuotes((string) $nested);
|
||||||
|
};
|
||||||
|
|
||||||
|
$jdAppKey = $envJd('APP_KEY', '');
|
||||||
|
$jdAppSecret = $envJd('APP_SECRET', '');
|
||||||
|
$jdAccessToken = $envJd('ACCESS_TOKEN', '');
|
||||||
|
$jdHasCreds = $jdAppKey !== '' && $jdAppSecret !== '' && $jdAccessToken !== '';
|
||||||
|
$jdDisableRaw = env('JD_LOGISTICS_DISABLE', null);
|
||||||
|
if ($jdDisableRaw === null || $jdDisableRaw === '') {
|
||||||
|
$jdDisableRaw = env('trtc.JD_LOGISTICS_DISABLE', false);
|
||||||
|
}
|
||||||
|
$jdDisabled = filter_var($jdDisableRaw, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
$jdEnabled = ! $jdDisabled && $jdHasCreds;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'kuaidi100' => [
|
'kuaidi100' => [
|
||||||
'enable' => $kuaidiEnabled,
|
'enable' => $kuaidiEnabled,
|
||||||
@@ -48,4 +76,23 @@ return [
|
|||||||
'key' => $key,
|
'key' => $key,
|
||||||
'query_url' => $envKuaidi('QUERY_URL', 'https://poll.kuaidi100.com/poll/query.do'),
|
'query_url' => $envKuaidi('QUERY_URL', 'https://poll.kuaidi100.com/poll/query.do'),
|
||||||
],
|
],
|
||||||
|
'jd' => [
|
||||||
|
'enable' => $jdEnabled,
|
||||||
|
'app_key' => $jdAppKey,
|
||||||
|
'app_secret' => $jdAppSecret,
|
||||||
|
'access_token' => $jdAccessToken,
|
||||||
|
// LOP 网关签名算法(与官方 SDK 一致):md5-salt(默认) / HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512
|
||||||
|
'algorithm' => $envJd('ALGORITHM', 'md5-salt'),
|
||||||
|
// 生产网关基址(不含路径)
|
||||||
|
'gateway' => $envJd('GATEWAY', 'https://api.jdl.com'),
|
||||||
|
// 接口 path(即签名里的 method);京东物流标准轨迹服务(2025-04-29 改版)
|
||||||
|
'method' => $envJd('METHOD', '/jd/tracking/query'),
|
||||||
|
// LOP-DN:对接方案编码(订阅记录可查),标准轨迹服务为 Tracking_JD
|
||||||
|
'lop_dn' => $envJd('LOP_DN', 'Tracking_JD'),
|
||||||
|
// 业务体字段:单号字段名 / 单号类型(20000=运单号) / 商家编码(可选)
|
||||||
|
'reference_field' => $envJd('REFERENCE_FIELD', 'referenceNumber'),
|
||||||
|
'reference_type' => $envJd('REFERENCE_TYPE', '20000'),
|
||||||
|
'customer_code' => $envJd('CUSTOMER_CODE', ''),
|
||||||
|
'api_version' => $envJd('API_VERSION', '2.0'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user