Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e3cbd52e | ||
|
|
c07dfe4e36 | ||
|
|
1777fe7d8b | ||
|
|
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
|
||||
/.codex
|
||||
/.agents
|
||||
/.claude
|
||||
/.claude
|
||||
/.claude
|
||||
/.kbctx
|
||||
/server/.spool
|
||||
/server/.claude
|
||||
/.spool
|
||||
TUICallKit-Vue3/.env
|
||||
/.codegraph
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
alias ll='ls -alhG'
|
||||
|
||||
# Starship prompt
|
||||
eval "$(starship init zsh)"
|
||||
|
||||
# zsh-autosuggestions 自动补全
|
||||
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
|
||||
|
||||
# NVM 懒加载 - 只在第一次使用相关命令时再初始化
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
_nvm_default_bin() {
|
||||
local default_alias version_dir
|
||||
[ -r "$NVM_DIR/alias/default" ] || return 1
|
||||
|
||||
default_alias="$(<"$NVM_DIR/alias/default")"
|
||||
default_alias="${default_alias#"${default_alias%%[![:space:]]*}"}"
|
||||
default_alias="${default_alias%"${default_alias##*[![:space:]]}"}"
|
||||
|
||||
case "$default_alias" in
|
||||
v[0-9]*)
|
||||
version_dir="$NVM_DIR/versions/node/$default_alias"
|
||||
;;
|
||||
[0-9]*)
|
||||
version_dir="$(ls -d "$NVM_DIR"/versions/node/v"$default_alias".* 2>/dev/null | tail -n 1)"
|
||||
;;
|
||||
node|stable|lts/*)
|
||||
version_dir="$(ls -d "$NVM_DIR"/versions/node/v* 2>/dev/null | sort -V | tail -n 1)"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -n "$version_dir" ] && [ -x "$version_dir/bin/node" ] || return 1
|
||||
printf '%s\n' "$version_dir/bin"
|
||||
}
|
||||
if _nvm_default_node_bin="$(_nvm_default_bin)"; then
|
||||
export PATH="$_nvm_default_node_bin:$PATH"
|
||||
fi
|
||||
unset _nvm_default_node_bin
|
||||
_lazy_load_nvm() {
|
||||
unset -f nvm node npm npx pnpm yarn 2>/dev/null
|
||||
[ -s "$NVM_DIR/nvm.sh" ] || return 1
|
||||
\. "$NVM_DIR/nvm.sh"
|
||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
||||
hash -r 2>/dev/null
|
||||
}
|
||||
nvm() { _lazy_load_nvm && nvm "$@"; }
|
||||
node() { _lazy_load_nvm && node "$@"; }
|
||||
npm() { _lazy_load_nvm && npm "$@"; }
|
||||
npx() { _lazy_load_nvm && npx "$@"; }
|
||||
pnpm() { _lazy_load_nvm && pnpm "$@"; }
|
||||
yarn() { _lazy_load_nvm && yarn "$@"; }
|
||||
export PATH="/Applications/EServer/bin:$PATH"
|
||||
function EC_start(){
|
||||
/Applications/EasyConnect.app/Contents/Resources/bin/EasyMonitor > /dev/null 2>&1 &
|
||||
/Applications/EasyConnect.app/Contents/MacOS/EasyConnect > /dev/null 2>&1 &
|
||||
open /Applications/EasyConnect.app
|
||||
}
|
||||
|
||||
function EC_kill(){
|
||||
pkill EasyMonitor
|
||||
pkill ECAgent
|
||||
pkill ECAgentProxy
|
||||
pkill EasyConnect
|
||||
}
|
||||
# The following lines have been added by Docker Desktop to enable Docker CLI completions.
|
||||
fpath=(/Users/long/.docker/completions $fpath)
|
||||
autoload -Uz compinit
|
||||
compinit
|
||||
# End of Docker CLI completions
|
||||
|
||||
|
||||
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/
|
||||
|
||||
# pyenv 懒加载 - 只在第一次使用 Python 相关命令时再初始化
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
_lazy_load_pyenv() {
|
||||
unset -f pyenv python python3 pip pip3 2>/dev/null
|
||||
|
||||
local pyenv_bin
|
||||
pyenv_bin="$(command -v pyenv 2>/dev/null)"
|
||||
if [ -z "$pyenv_bin" ] && [ -x /opt/homebrew/bin/pyenv ]; then
|
||||
pyenv_bin=/opt/homebrew/bin/pyenv
|
||||
fi
|
||||
|
||||
[ -n "$pyenv_bin" ] || return 1
|
||||
|
||||
eval "$("$pyenv_bin" init --path)"
|
||||
eval "$("$pyenv_bin" init - zsh)"
|
||||
hash -r 2>/dev/null
|
||||
}
|
||||
pyenv() { _lazy_load_pyenv && pyenv "$@"; }
|
||||
python() { _lazy_load_pyenv && python "$@"; }
|
||||
python3() { _lazy_load_pyenv && python3 "$@"; }
|
||||
pip() { _lazy_load_pyenv && pip "$@"; }
|
||||
pip3() { _lazy_load_pyenv && pip3 "$@"; }
|
||||
|
||||
# 引入 bash 配置
|
||||
if [ -f ~/.bash_profile ]; then
|
||||
source ~/.bash_profile
|
||||
fi
|
||||
if [ -f ~/.bashrc ]; then
|
||||
source ~/.bashrc
|
||||
fi
|
||||
|
||||
# Added by Windsurf
|
||||
export PATH="/Users/long/.codeium/windsurf/bin:$PATH"
|
||||
|
||||
# Added by Antigravity
|
||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# MindOS Desktop — CLI (mindos)
|
||||
export PATH="$HOME/.mindos/bin:$PATH"
|
||||
|
||||
source "$HOME/.cargo/env"
|
||||
|
||||
# Added by Antigravity
|
||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
||||
@@ -1,74 +0,0 @@
|
||||
alias ll='ls -alhG'
|
||||
|
||||
# Starship prompt
|
||||
eval "$(starship init zsh)"
|
||||
|
||||
# zsh-autosuggestions 自动补全
|
||||
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
|
||||
|
||||
# NVM
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||
\. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
||||
export PATH="/Applications/EServer/bin:$PATH"
|
||||
function EC_start(){
|
||||
/Applications/EasyConnect.app/Contents/Resources/bin/EasyMonitor > /dev/null 2>&1 &
|
||||
/Applications/EasyConnect.app/Contents/MacOS/EasyConnect > /dev/null 2>&1 &
|
||||
open /Applications/EasyConnect.app
|
||||
}
|
||||
|
||||
function EC_kill(){
|
||||
pkill EasyMonitor
|
||||
pkill ECAgent
|
||||
pkill ECAgentProxy
|
||||
pkill EasyConnect
|
||||
}
|
||||
# The following lines have been added by Docker Desktop to enable Docker CLI completions.
|
||||
fpath=(/Users/long/.docker/completions $fpath)
|
||||
autoload -Uz compinit
|
||||
compinit
|
||||
# End of Docker CLI completions
|
||||
|
||||
|
||||
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles/
|
||||
|
||||
# pyenv
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="/opt/homebrew/bin:$PYENV_ROOT/bin:$PATH"
|
||||
if command -v pyenv >/dev/null 2>&1; then
|
||||
eval "$(pyenv init --path)"
|
||||
eval "$(pyenv init - zsh)"
|
||||
fi
|
||||
|
||||
# 引入 bash 配置
|
||||
if [ -f ~/.bash_profile ]; then
|
||||
source ~/.bash_profile
|
||||
fi
|
||||
if [ -f ~/.bashrc ]; then
|
||||
source ~/.bashrc
|
||||
fi
|
||||
|
||||
# Added by Windsurf
|
||||
export PATH="/Users/long/.codeium/windsurf/bin:$PATH"
|
||||
|
||||
# Added by Antigravity
|
||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
||||
|
||||
if command -v nvm >/dev/null 2>&1; then
|
||||
_nvm_default_version="$(nvm version default 2>/dev/null)"
|
||||
if [ -n "$_nvm_default_version" ] && [ -d "$NVM_DIR/versions/node/$_nvm_default_version/bin" ]; then
|
||||
export PATH="$NVM_DIR/versions/node/$_nvm_default_version/bin:$PATH"
|
||||
fi
|
||||
unset _nvm_default_version
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# MindOS Desktop — CLI (mindos)
|
||||
export PATH="$HOME/.mindos/bin:$PATH"
|
||||
|
||||
source "$HOME/.cargo/env"
|
||||
|
||||
# Added by Antigravity
|
||||
export PATH="/Users/long/.antigravity/antigravity/bin:$PATH"
|
||||
@@ -1,42 +1,65 @@
|
||||
<template>
|
||||
<!-- 页面内嵌模块(如 weekly 统计页主内容区) -->
|
||||
<view v-if="visible && props.inline" class="dev-entry-inline">
|
||||
<view class="inline-head">
|
||||
<view class="inline-head-text">
|
||||
<text class="inline-title">居家耗糖训练</text>
|
||||
<text class="inline-sub">低强度动起来,配合血糖管理</text>
|
||||
<view
|
||||
v-if="visible && props.inline"
|
||||
class="dev-entry-inline"
|
||||
:class="{ 'dev-entry-inline--weekly': props.tone === 'weekly' }"
|
||||
>
|
||||
<view class="inline-hero">
|
||||
<view class="inline-hero-icon" aria-hidden="true">
|
||||
<view class="inline-hero-wave" />
|
||||
</view>
|
||||
<view class="inline-wave" aria-hidden="true" />
|
||||
</view>
|
||||
<view class="inline-grid">
|
||||
<view
|
||||
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 class="inline-hero-text">
|
||||
<view class="inline-hero-title-row">
|
||||
<text class="inline-title">居家耗糖训练</text>
|
||||
<text v-if="props.tone === 'weekly'" class="inline-pill">体验</text>
|
||||
</view>
|
||||
<text class="inline-item-title">{{ item.title }}</text>
|
||||
<text class="inline-item-sub">{{ item.sub }}</text>
|
||||
<text class="inline-sub">低强度动起来,帮助身体消耗糖分</text>
|
||||
</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 v-else-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
||||
@@ -124,15 +147,24 @@
|
||||
</template>
|
||||
|
||||
<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/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/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
|
||||
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro' },
|
||||
] as const
|
||||
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
|
||||
]
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -140,10 +172,20 @@ const props = withDefaults(
|
||||
bottom?: number
|
||||
/** 内嵌到页面主内容区,非 fixed 悬浮 */
|
||||
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 => {
|
||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||
try {
|
||||
@@ -515,28 +557,59 @@ $brand-light: #34d399;
|
||||
* ============================================================ */
|
||||
.dev-entry-inline {
|
||||
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;
|
||||
}
|
||||
|
||||
.inline-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.dev-entry-inline--weekly {
|
||||
padding: 32rpx 28rpx 24rpx;
|
||||
border-radius: 48rpx;
|
||||
background: var(--surface-container-lowest, #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);
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.inline-hero-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.inline-title {
|
||||
@@ -546,75 +619,119 @@ $brand-light: #34d399;
|
||||
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 {
|
||||
font-size: 24rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
line-height: 1.35;
|
||||
color: #475569;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.inline-wave {
|
||||
width: 120rpx;
|
||||
height: 48rpx;
|
||||
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 {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 五行并列,一屏展示完整(不横向滑动) */
|
||||
.inline-grid {
|
||||
display: flex;
|
||||
.inline-scroll-track {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 8rpx;
|
||||
width: 100%;
|
||||
gap: 16rpx;
|
||||
padding: 4rpx 2rpx 8rpx;
|
||||
}
|
||||
|
||||
.inline-item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
.inline-card {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 14rpx 4rpx 12rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #f0fdf4;
|
||||
align-items: flex-start;
|
||||
width: 212rpx;
|
||||
min-height: 220rpx;
|
||||
padding: 20rpx 18rpx 18rpx;
|
||||
border-radius: 28rpx;
|
||||
background: #f4f7f5;
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
background: #e8f3ee;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
margin-bottom: 8rpx;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
margin-bottom: 14rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-item-title {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
.inline-card-badge {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 12rpx;
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #006c49;
|
||||
|
||||
text {
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-item-sub {
|
||||
margin-top: 2rpx;
|
||||
font-size: 18rpx;
|
||||
.inline-card-title {
|
||||
font-size: 28rpx;
|
||||
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;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.inline-card:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,14 +52,14 @@
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
},
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : ["audio"],
|
||||
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
|
||||
@@ -204,7 +204,15 @@
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "日常护理",
|
||||
"backgroundColor": "#f4fbf4",
|
||||
"enablePullDownRefresh": true
|
||||
"enablePullDownRefresh": true,
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 45 KiB |
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<view class="food-tile-icon" :class="[`food-tile-icon--${size}`]">
|
||||
<text class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||
<view
|
||||
class="food-tile-icon"
|
||||
:class="[`food-tile-icon--${size}`, { 'is-custom-img': hasCustomImg }]"
|
||||
>
|
||||
<text v-if="showEmojiFallback" class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||
<image
|
||||
v-if="src && !imageFailed"
|
||||
class="food-tile-icon__img"
|
||||
:src="src"
|
||||
:mode="size === 'tile' ? 'aspectFill' : 'aspectFit'"
|
||||
:mode="imageMode"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</view>
|
||||
@@ -27,12 +30,20 @@ const fallbackIcon = computed(() => {
|
||||
return '🍽'
|
||||
})
|
||||
|
||||
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
|
||||
|
||||
const src = computed(() => {
|
||||
if (typeof props.food === 'object' && props.food?.img) return props.food.img
|
||||
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
|
||||
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
|
||||
|
||||
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
|
||||
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
|
||||
|
||||
watch(
|
||||
() => src.value,
|
||||
() => {
|
||||
@@ -66,11 +77,21 @@ function onImageError() {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 58%;
|
||||
height: 58%;
|
||||
width: 75%;
|
||||
height: 75%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.food-tile-icon--tile.is-custom-img .food-tile-icon__img {
|
||||
width: 82%;
|
||||
height: 82%;
|
||||
}
|
||||
|
||||
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
|
||||
.food-tile-icon--tile .food-tile-icon__img {
|
||||
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
|
||||
}
|
||||
|
||||
.food-tile-icon--goal {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
@@ -104,6 +125,11 @@ function onImageError() {
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon--goal.is-custom-img .food-tile-icon__img,
|
||||
.food-tile-icon--tooltip.is-custom-img .food-tile-icon__img {
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon__img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view class="vsm" :class="[`vsm--${size}`, { 'vsm--active': active, 'vsm--on-dark': onDark }]">
|
||||
<view class="vsm-body">
|
||||
<view class="vsm-leaf" aria-hidden="true" />
|
||||
<view class="vsm-face">
|
||||
<view class="vsm-eye vsm-eye--l" />
|
||||
<view class="vsm-eye vsm-eye--r" />
|
||||
<view class="vsm-blush vsm-blush--l" />
|
||||
<view class="vsm-blush vsm-blush--r" />
|
||||
<view class="vsm-mouth" />
|
||||
</view>
|
||||
<view v-if="active" class="vsm-waves" aria-hidden="true">
|
||||
<view class="vsm-wave vsm-wave--1" />
|
||||
<view class="vsm-wave vsm-wave--2" />
|
||||
<view class="vsm-wave vsm-wave--3" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 正在按住说话 / 录音中 */
|
||||
active: { type: Boolean, default: false },
|
||||
/** 深色条背景上使用浅色卡通 */
|
||||
onDark: { type: Boolean, default: false },
|
||||
size: { type: String, default: 'md' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vsm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vsm--md { width: 72rpx; height: 72rpx; }
|
||||
.vsm--sm { width: 64rpx; height: 64rpx; }
|
||||
|
||||
.vsm-body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vsm-leaf {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 50%;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
margin-left: -8rpx;
|
||||
border-radius: 0 100% 0 100%;
|
||||
background: #34d399;
|
||||
transform: rotate(-18deg);
|
||||
z-index: 2;
|
||||
}
|
||||
.vsm--on-dark .vsm-leaf {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.vsm-face {
|
||||
position: relative;
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
background: #fef9c3;
|
||||
border: 3rpx solid #047857;
|
||||
box-shadow: 0 4rpx 10rpx rgba(4, 120, 87, 0.18);
|
||||
z-index: 1;
|
||||
}
|
||||
.vsm--sm .vsm-face {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
.vsm--on-dark .vsm-face {
|
||||
background: #fffbeb;
|
||||
border-color: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.vsm-eye {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
width: 6rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #064e3b;
|
||||
}
|
||||
.vsm--sm .vsm-eye { top: 14rpx; width: 5rpx; height: 7rpx; }
|
||||
.vsm--on-dark .vsm-eye { background: #134e4a; }
|
||||
.vsm-eye--l { left: 12rpx; }
|
||||
.vsm-eye--r { right: 12rpx; }
|
||||
.vsm--sm .vsm-eye--l { left: 10rpx; }
|
||||
.vsm--sm .vsm-eye--r { right: 10rpx; }
|
||||
|
||||
.vsm-blush {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
width: 10rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(251, 113, 133, 0.45);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.vsm--sm .vsm-blush { top: 21rpx; width: 8rpx; height: 5rpx; }
|
||||
.vsm-blush--l { left: 6rpx; }
|
||||
.vsm-blush--r { right: 6rpx; }
|
||||
.vsm--on-dark .vsm-blush { background: rgba(255, 255, 255, 0.35); }
|
||||
|
||||
.vsm-mouth {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 10rpx;
|
||||
width: 18rpx;
|
||||
height: 8rpx;
|
||||
margin-left: -9rpx;
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
background: #047857;
|
||||
transform-origin: center top;
|
||||
}
|
||||
.vsm--sm .vsm-mouth {
|
||||
bottom: 9rpx;
|
||||
width: 16rpx;
|
||||
height: 7rpx;
|
||||
margin-left: -8rpx;
|
||||
}
|
||||
.vsm--on-dark .vsm-mouth { background: #ecfdf5; }
|
||||
|
||||
.vsm-waves {
|
||||
position: absolute;
|
||||
right: -2rpx;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
height: 28rpx;
|
||||
margin-top: -14rpx;
|
||||
z-index: 0;
|
||||
}
|
||||
.vsm-wave {
|
||||
width: 5rpx;
|
||||
border-radius: 4rpx;
|
||||
background: #047857;
|
||||
animation: vsm-wave-jump 0.72s ease-in-out infinite;
|
||||
}
|
||||
.vsm--on-dark .vsm-wave { background: rgba(255, 255, 255, 0.9); }
|
||||
.vsm-wave--1 { height: 10rpx; animation-delay: 0s; }
|
||||
.vsm-wave--2 { height: 18rpx; animation-delay: 0.12s; }
|
||||
.vsm-wave--3 { height: 12rpx; animation-delay: 0.24s; }
|
||||
|
||||
.vsm--active .vsm-body {
|
||||
animation: vsm-bob 0.9s ease-in-out infinite;
|
||||
}
|
||||
.vsm--active .vsm-mouth {
|
||||
animation: vsm-talk 0.32s ease-in-out infinite alternate;
|
||||
}
|
||||
.vsm--active .vsm-eye {
|
||||
animation: vsm-blink 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes vsm-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4rpx); }
|
||||
}
|
||||
@keyframes vsm-talk {
|
||||
0% {
|
||||
transform: scaleY(0.45);
|
||||
border-radius: 0 0 18rpx 18rpx;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1.15);
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
@keyframes vsm-blink {
|
||||
0%, 42%, 46%, 100% { transform: scaleY(1); }
|
||||
44% { transform: scaleY(0.15); }
|
||||
}
|
||||
@keyframes vsm-wave-jump {
|
||||
0%, 100% { transform: scaleY(0.45); opacity: 0.55; }
|
||||
50% { transform: scaleY(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { requestAiStream, parsePartialBloodCarePlainText } from '../utils/aiStreamRequest.js'
|
||||
|
||||
const emptyBloodCarePlan = () => ({
|
||||
summary: '',
|
||||
blood_advice: '',
|
||||
tcm_plan: '',
|
||||
watch_points: [],
|
||||
next_steps: '',
|
||||
control_level: '',
|
||||
control_label: '',
|
||||
disclaimer: '',
|
||||
analysis: [],
|
||||
source: '',
|
||||
date: ''
|
||||
})
|
||||
|
||||
/** AI 血糖照护建议(累计 7 天有记录后) */
|
||||
export function useBloodCareAi(proxy, { diagnosisId, chartUseMockData, showUserToast, formatUserMessage }) {
|
||||
const bloodCarePlan = ref(emptyBloodCarePlan())
|
||||
const bloodCareLoading = ref(false)
|
||||
const bloodCareStreaming = ref(false)
|
||||
const bloodCareStreamField = ref('')
|
||||
let careStreamBuffer = ''
|
||||
/** 用户点击后才展开照护建议卡片 */
|
||||
const bloodCarePanelOpen = ref(false)
|
||||
|
||||
const bloodCareEligible = computed(() => {
|
||||
return !!diagnosisId.value && !chartUseMockData.value
|
||||
})
|
||||
|
||||
const hasBloodCareContent = computed(() => {
|
||||
const p = bloodCarePlan.value
|
||||
return !!(p.summary || p.blood_advice || p.tcm_plan)
|
||||
})
|
||||
|
||||
const bloodCareAnalysis = computed(() => {
|
||||
const list = bloodCarePlan.value?.analysis
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
|
||||
const bloodCareControlLabel = computed(() => {
|
||||
const label = String(bloodCarePlan.value?.control_label || '').trim()
|
||||
return label
|
||||
})
|
||||
|
||||
const bloodCareDisclaimer = computed(() => {
|
||||
return String(bloodCarePlan.value?.disclaimer || '').trim()
|
||||
})
|
||||
|
||||
function detectCareStreamField(text) {
|
||||
const tail = String(text || '').match(/(?:^|\n)(总评|血糖|中医|留意|复诊)[::]\s*([^\n]*)$/)
|
||||
if (!tail) return 'summary'
|
||||
const map = { '总评': 'summary', '血糖': 'blood_advice', '中医': 'tcm_plan', '留意': 'watch_points', '复诊': 'next_steps' }
|
||||
return map[tail[1]] || 'summary'
|
||||
}
|
||||
|
||||
function mergePartialCareFromStream() {
|
||||
const partial = parsePartialBloodCarePlainText(careStreamBuffer)
|
||||
const next = { ...bloodCarePlan.value }
|
||||
for (const key of ['summary', 'blood_advice', 'tcm_plan', 'next_steps']) {
|
||||
if (partial[key] != null) next[key] = partial[key]
|
||||
}
|
||||
if (Array.isArray(partial.watch_points)) next.watch_points = partial.watch_points
|
||||
bloodCarePlan.value = next
|
||||
bloodCareStreamField.value = detectCareStreamField(careStreamBuffer)
|
||||
}
|
||||
|
||||
function applyBloodCarePayload(payload) {
|
||||
if (!payload) return
|
||||
const prevAnalysis = bloodCarePlan.value.analysis
|
||||
bloodCarePlan.value = {
|
||||
...emptyBloodCarePlan(),
|
||||
...payload,
|
||||
watch_points: Array.isArray(payload.watch_points) ? payload.watch_points : [],
|
||||
analysis: Array.isArray(payload.analysis) && payload.analysis.length
|
||||
? payload.analysis
|
||||
: (Array.isArray(prevAnalysis) ? prevAnalysis : [])
|
||||
}
|
||||
}
|
||||
|
||||
function onBloodCareStreamEvent(event, payload) {
|
||||
if (event === 'analysis') {
|
||||
if (Array.isArray(payload?.analysis)) {
|
||||
bloodCarePlan.value = { ...bloodCarePlan.value, analysis: payload.analysis }
|
||||
}
|
||||
} else if (event === 'delta') {
|
||||
bloodCareStreaming.value = true
|
||||
if (payload.buffer != null) {
|
||||
careStreamBuffer = payload.buffer
|
||||
} else {
|
||||
careStreamBuffer += payload.text || ''
|
||||
}
|
||||
mergePartialCareFromStream()
|
||||
} else if (event === 'done') {
|
||||
applyBloodCarePayload(payload)
|
||||
careStreamBuffer = ''
|
||||
bloodCareStreamField.value = ''
|
||||
} else if (event === 'error') {
|
||||
showUserToast(formatUserMessage(payload?.message, '照护建议加载失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBloodCareAiFallback(refresh) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/tcm/dailyBloodCareAiRecommend',
|
||||
method: 'GET',
|
||||
timeout: 35000,
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
refresh: refresh ? 1 : 0,
|
||||
...(refresh ? { _t: Date.now() } : {})
|
||||
}
|
||||
}, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
applyBloodCarePayload(res.data)
|
||||
} else if (res?.msg) {
|
||||
showUserToast(res.msg)
|
||||
}
|
||||
}
|
||||
|
||||
function closeBloodCarePanel() {
|
||||
bloodCarePanelOpen.value = false
|
||||
}
|
||||
|
||||
/** 趋势图 AI 按钮:未展开则打开并生成;已展开则收起 */
|
||||
async function toggleBloodCareFromChart() {
|
||||
if (!bloodCareEligible.value || bloodCareLoading.value) return
|
||||
if (bloodCarePanelOpen.value) {
|
||||
closeBloodCarePanel()
|
||||
return
|
||||
}
|
||||
bloodCarePanelOpen.value = true
|
||||
if (!hasBloodCareContent.value) {
|
||||
await fetchBloodCareAi(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBloodCareAi(refresh = false) {
|
||||
if (!bloodCareEligible.value || bloodCareLoading.value) return
|
||||
bloodCareLoading.value = true
|
||||
bloodCareStreaming.value = false
|
||||
careStreamBuffer = ''
|
||||
bloodCareStreamField.value = ''
|
||||
if (refresh) {
|
||||
applyBloodCarePayload(emptyBloodCarePlan())
|
||||
}
|
||||
try {
|
||||
await requestAiStream({
|
||||
baseUrl: proxy.$url,
|
||||
url: '/api/tcm/dailyBloodCareAiRecommendStream',
|
||||
method: 'GET',
|
||||
data: {
|
||||
diagnosis_id: diagnosisId.value,
|
||||
refresh: refresh ? 1 : 0,
|
||||
...(refresh ? { _t: Date.now() } : {})
|
||||
},
|
||||
onEvent: onBloodCareStreamEvent,
|
||||
fallback: () => fetchBloodCareAiFallback(refresh)
|
||||
})
|
||||
} catch (e) {
|
||||
try {
|
||||
await fetchBloodCareAiFallback(refresh)
|
||||
} catch (e2) {
|
||||
showUserToast('照护建议加载失败')
|
||||
}
|
||||
} finally {
|
||||
bloodCareLoading.value = false
|
||||
bloodCareStreaming.value = false
|
||||
bloodCareStreamField.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function refreshBloodCareAi() {
|
||||
if (bloodCareLoading.value) return
|
||||
fetchBloodCareAi(true)
|
||||
}
|
||||
|
||||
function resetBloodCarePlan() {
|
||||
applyBloodCarePayload(emptyBloodCarePlan())
|
||||
careStreamBuffer = ''
|
||||
bloodCareStreamField.value = ''
|
||||
bloodCarePanelOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
bloodCarePlan,
|
||||
bloodCareLoading,
|
||||
bloodCareStreaming,
|
||||
bloodCareStreamField,
|
||||
bloodCareEligible,
|
||||
bloodCarePanelOpen,
|
||||
hasBloodCareContent,
|
||||
bloodCareAnalysis,
|
||||
bloodCareControlLabel,
|
||||
bloodCareDisclaimer,
|
||||
fetchBloodCareAi,
|
||||
toggleBloodCareFromChart,
|
||||
closeBloodCarePanel,
|
||||
refreshBloodCareAi,
|
||||
resetBloodCarePlan
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const DIET_AI_PREFILL_KEY = 'tongji_diet_ai_prefill'
|
||||
|
||||
const emptyDietAiRecommend = () => ({
|
||||
breakfast: '',
|
||||
drinks: '',
|
||||
lunch: '',
|
||||
dinner: '',
|
||||
tips: '',
|
||||
@@ -31,20 +32,20 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
||||
|
||||
const hasDietAiMealContent = computed(() => {
|
||||
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) {
|
||||
const tail = String(text || '').match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||
const tail = String(text || '').match(/(?:^|\n)(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/)
|
||||
if (!tail) return 'breakfast'
|
||||
const map = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
||||
const map = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'tips' }
|
||||
return map[tail[1]] || 'breakfast'
|
||||
}
|
||||
|
||||
function mergePartialDietFromStream() {
|
||||
const partial = parsePartialDietPlainText(dietStreamBuffer)
|
||||
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 (Array.isArray(partial.avoid)) next.avoid = partial.avoid
|
||||
@@ -80,6 +81,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
||||
...emptyDietAiRecommend(),
|
||||
...payload,
|
||||
breakfast: streamed.breakfast,
|
||||
drinks: streamed.drinks || payload.drinks || '',
|
||||
lunch: streamed.lunch || payload.lunch || '',
|
||||
dinner: streamed.dinner || payload.dinner || '',
|
||||
tips: streamed.tips || payload.tips || '',
|
||||
@@ -205,6 +207,8 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
||||
return
|
||||
}
|
||||
if (!diagnosisId.value) return
|
||||
// 发送后立即清空输入框
|
||||
dietAiAskText.value = ''
|
||||
dietAiAsking.value = true
|
||||
dietAiAskResult.value = { advice: '', level: '', level_label: '', portion: '', food: '' }
|
||||
try {
|
||||
@@ -259,6 +263,7 @@ export function useDietAi(proxy, { diagnosisId, showUserToast, formatUserMessage
|
||||
try {
|
||||
uni.setStorageSync(DIET_AI_PREFILL_KEY, {
|
||||
breakfast: r.breakfast,
|
||||
drinks: r.drinks || '',
|
||||
lunch: r.lunch,
|
||||
dinner: r.dinner,
|
||||
tips: r.tips || ''
|
||||
|
||||
@@ -2,22 +2,106 @@ import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const MIN_HOLD_MS = 280
|
||||
|
||||
/** 短按/过早松手时插件常见错误,不应打扰用户 */
|
||||
function isBenignSttError(msg, retcode) {
|
||||
const m = String(msg || '').toLowerCase()
|
||||
if (/internal voice data failed/i.test(m)) return true
|
||||
if (/voice data failed/i.test(m)) return true
|
||||
if (/recordfailed|record failed|record manager/i.test(m)) return true
|
||||
if (/please stop after start/i.test(m)) return true
|
||||
if (/no recognition|not start|未开始|无识别/i.test(m)) return true
|
||||
const code = Number(retcode)
|
||||
if (code === -30002 || code === -30003 || code === -30012) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function friendlySttError(msg) {
|
||||
const m = String(msg || '').trim()
|
||||
if (!m || isBenignSttError(m)) return ''
|
||||
if (/not allowed|auth|permission|权限|麦克风/i.test(m)) return '请允许使用麦克风'
|
||||
if (/network|网络|timeout/i.test(m)) return '网络异常,请稍后再试'
|
||||
return '语音识别失败,请重试'
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// WechatSI 录音管理器是全局单例:回调只在模块级绑定一次,
|
||||
// 再分发给「当前活跃实例」。否则跨页面(如 more → weekly)后,
|
||||
// 回调仍指向已销毁页面的闭包,新页面收不到 onStart/onStop,
|
||||
// 其本地状态卡在 recording,表现为一直录制/无法再次语音。
|
||||
let sharedManager = null
|
||||
let sharedManagerInited = false
|
||||
let sharedRecording = false
|
||||
let activeCtl = null
|
||||
|
||||
function getSharedRecordManager() {
|
||||
if (sharedManagerInited) return sharedManager
|
||||
sharedManagerInited = true
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
const plugin = requirePlugin('WechatSI')
|
||||
sharedManager = plugin.getRecordRecognitionManager()
|
||||
} catch (e) {
|
||||
console.warn('WechatSI record recognition not available', e)
|
||||
sharedManager = null
|
||||
return null
|
||||
}
|
||||
if (!sharedManager) return null
|
||||
|
||||
sharedManager.onStart = () => {
|
||||
sharedRecording = true
|
||||
if (activeCtl) activeCtl.handleStart()
|
||||
}
|
||||
sharedManager.onRecognize = (res) => {
|
||||
if (activeCtl) activeCtl.handleRecognize(res)
|
||||
}
|
||||
sharedManager.onStop = (res) => {
|
||||
sharedRecording = false
|
||||
if (activeCtl) activeCtl.handleStop(res)
|
||||
}
|
||||
sharedManager.onError = (res) => {
|
||||
sharedRecording = false
|
||||
if (activeCtl) activeCtl.handleError(res)
|
||||
}
|
||||
return sharedManager
|
||||
}
|
||||
// #endif
|
||||
|
||||
/**
|
||||
* 语音转文字(长按说话):微信小程序 WechatSI;H5 Web Speech API。
|
||||
*
|
||||
* @param {{ onResult?: (text: string) => void, showToast?: (msg: string) => void }} options
|
||||
* @param {{
|
||||
* onResult?: (text: string) => void,
|
||||
* onSettle?: (text: string, info: { heldMs: number }) => boolean | void,
|
||||
* onPartial?: (text: string) => void,
|
||||
* onError?: (msg: string) => boolean | void,
|
||||
* showToast?: (msg: string) => void
|
||||
* }} options
|
||||
*
|
||||
* onSettle 在每次录音结束时都会触发(含空结果),用于语音一问一答等需要
|
||||
* 完全接管识别结果的场景。若 onSettle 返回 true,则跳过默认的 onResult 回调
|
||||
* 与「没听清」提示,避免与上层流程重复处理。
|
||||
*
|
||||
* onPartial 在录音过程中实时返回中间识别结果(微信 onRecognize / H5 interim),
|
||||
* 用于「抢答打断」等场景:检测到用户已开口作答即可提前结束播报/录音。
|
||||
*
|
||||
* onError 在录音/识别出错时触发(如 "record manager recordfailed")。
|
||||
* 若返回 true,则跳过默认的错误 Toast,由上层自行处理(如自动重试)。
|
||||
*/
|
||||
export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
export function useSpeechToText({ onResult, onSettle, onPartial, onError, showToast } = {}) {
|
||||
const sttListening = ref(false)
|
||||
const sttHolding = ref(false)
|
||||
const sttSupported = ref(false)
|
||||
|
||||
let wxRecordManager = null
|
||||
let h5Recognition = null
|
||||
let h5GotResult = false
|
||||
let holdStartTs = 0
|
||||
let holdSessionId = 0
|
||||
let startRequested = false
|
||||
let recordAuthorized = null
|
||||
// 快速点按可能残留会话:用排队标记把 start/stop 串行化,
|
||||
// 避免 "please stop after start"
|
||||
let pendingStartSession = 0
|
||||
|
||||
function notify(msg) {
|
||||
if (!msg) return
|
||||
@@ -31,6 +115,11 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
function emitResult(text) {
|
||||
const t = String(text || '').trim()
|
||||
const heldMs = Date.now() - holdStartTs
|
||||
// onSettle 总会收到结果(含空),返回 true 表示已完全接管
|
||||
if (typeof onSettle === 'function') {
|
||||
const handled = onSettle(t, { heldMs })
|
||||
if (handled === true) return
|
||||
}
|
||||
if (!t) {
|
||||
if (heldMs < MIN_HOLD_MS) return
|
||||
notify('没听清,请再试一次')
|
||||
@@ -43,7 +132,9 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
|
||||
function stopListening() {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (wxRecordManager && (sttListening.value || startRequested)) {
|
||||
const ownsRecording = sharedRecording && activeCtl === controller
|
||||
// 仅在识别已真正开始后再 stop,避免 -30012 / internal voice data failed
|
||||
if (wxRecordManager && (sttListening.value || ownsRecording)) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
@@ -59,6 +150,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
// #endif
|
||||
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
sttListening.value = false
|
||||
}
|
||||
|
||||
@@ -95,8 +187,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
// #endif
|
||||
}
|
||||
|
||||
function startWechatRecord(session) {
|
||||
// #ifdef MP-WEIXIN
|
||||
// #ifdef MP-WEIXIN
|
||||
function actuallyStartWechat(session) {
|
||||
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||||
return
|
||||
}
|
||||
@@ -108,9 +200,28 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
})
|
||||
} catch (e) {
|
||||
startRequested = false
|
||||
sharedRecording = false
|
||||
sttHolding.value = false
|
||||
notify('无法开始录音')
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
function startWechatRecord(session) {
|
||||
// #ifdef MP-WEIXIN
|
||||
if (!wxRecordManager || session !== holdSessionId || !sttHolding.value) {
|
||||
return
|
||||
}
|
||||
// 上一段会话还没结束(含全局单例残留):先停止并排队,
|
||||
// 待 onStop 回调后再真正开始,避免 "please stop after start"
|
||||
if (sharedRecording || startRequested) {
|
||||
pendingStartSession = session
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
return
|
||||
}
|
||||
actuallyStartWechat(session)
|
||||
// #endif
|
||||
}
|
||||
|
||||
@@ -123,6 +234,7 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
}
|
||||
try {
|
||||
startRequested = true
|
||||
h5GotResult = false
|
||||
h5Recognition.start()
|
||||
sttListening.value = true
|
||||
} catch (e) {
|
||||
@@ -146,6 +258,8 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
sttHolding.value = true
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 把全局录音回调切到当前实例
|
||||
activeCtl = controller
|
||||
ensureRecordAuth(() => startWechatRecord(session))
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
@@ -159,50 +273,95 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 松手结束:手指抬起 */
|
||||
function endHoldSpeech() {
|
||||
if (!sttHolding.value && !sttListening.value && !startRequested) {
|
||||
/** 松手结束:手指抬起;force=true 时强制取消尚未真正开始的按住会话 */
|
||||
function endHoldSpeech(force = false) {
|
||||
let recording = false
|
||||
// #ifdef MP-WEIXIN
|
||||
recording = sharedRecording && activeCtl === controller
|
||||
// #endif
|
||||
if (!force && !sttHolding.value && !sttListening.value && !startRequested && !recording) {
|
||||
return
|
||||
}
|
||||
sttHolding.value = false
|
||||
if (sttListening.value || startRequested) {
|
||||
// 松手后不再补开排队的录音
|
||||
pendingStartSession = 0
|
||||
if (sttListening.value || recording) {
|
||||
stopListening()
|
||||
return
|
||||
}
|
||||
if (startRequested) {
|
||||
// start 已发出但 onStart 未到:取消会话,勿调 stop()
|
||||
startRequested = false
|
||||
holdSessionId += 1
|
||||
return
|
||||
}
|
||||
holdSessionId += 1
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
const plugin = requirePlugin('WechatSI')
|
||||
wxRecordManager = plugin.getRecordRecognitionManager()
|
||||
sttSupported.value = !!wxRecordManager
|
||||
|
||||
wxRecordManager.onStart = () => {
|
||||
// 当前实例的回调控制器:全局单例 manager 的事件由模块级分发器
|
||||
// 转发给 activeCtl(最后一次 beginHoldSpeech 的实例)
|
||||
const controller = {
|
||||
handleStart() {
|
||||
if (!sttHolding.value) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
// 用户已松手,忽略迟到的 onStart,不再 stop() 以免触发插件报错
|
||||
startRequested = false
|
||||
sttListening.value = false
|
||||
return
|
||||
}
|
||||
startRequested = false
|
||||
sttListening.value = true
|
||||
}
|
||||
wxRecordManager.onStop = (res) => {
|
||||
},
|
||||
handleRecognize(res) {
|
||||
if (typeof onPartial !== 'function') return
|
||||
const t = String(res?.result || '').trim()
|
||||
if (t) onPartial(t)
|
||||
},
|
||||
handleStop(res) {
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
// 有排队的开始请求(残留会话已停止 / 用户仍按住):现在再真正开始
|
||||
if (pendingStartSession && pendingStartSession === holdSessionId && sttHolding.value) {
|
||||
const s = pendingStartSession
|
||||
pendingStartSession = 0
|
||||
actuallyStartWechat(s)
|
||||
return
|
||||
}
|
||||
pendingStartSession = 0
|
||||
emitResult(res?.result)
|
||||
}
|
||||
wxRecordManager.onError = (res) => {
|
||||
},
|
||||
handleError(res) {
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
const msg = (res && res.msg) || ''
|
||||
const retcode = res && (res.retcode ?? res.errCode ?? res.code)
|
||||
// 上一段未停止就再次 start 触发:停止后若仍按住则自动重试,不打扰用户
|
||||
if (/please stop after start/i.test(msg)) {
|
||||
try {
|
||||
wxRecordManager.stop()
|
||||
} catch (e) {}
|
||||
pendingStartSession = sttHolding.value ? holdSessionId : 0
|
||||
return
|
||||
}
|
||||
pendingStartSession = 0
|
||||
sttHolding.value = false
|
||||
notify(res?.msg || '语音识别失败')
|
||||
const heldMs = Date.now() - holdStartTs
|
||||
if (isBenignSttError(msg, retcode) || heldMs < MIN_HOLD_MS) {
|
||||
if (typeof onError === 'function') onError(msg || '')
|
||||
return
|
||||
}
|
||||
// 上层(如一问一答流程)可接管错误并自行重试,返回 true 时不再弹默认提示
|
||||
if (typeof onError === 'function') {
|
||||
const handled = onError(msg || '')
|
||||
if (handled === true) return
|
||||
}
|
||||
const tip = friendlySttError(msg)
|
||||
if (tip) notify(tip)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('WechatSI record recognition not available', e)
|
||||
}
|
||||
|
||||
wxRecordManager = getSharedRecordManager()
|
||||
sttSupported.value = !!wxRecordManager
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
@@ -212,27 +371,49 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
if (SR) {
|
||||
h5Recognition = new SR()
|
||||
h5Recognition.lang = 'zh-CN'
|
||||
h5Recognition.interimResults = false
|
||||
h5Recognition.interimResults = true
|
||||
h5Recognition.continuous = false
|
||||
h5Recognition.maxAlternatives = 1
|
||||
sttSupported.value = true
|
||||
|
||||
h5Recognition.onresult = (event) => {
|
||||
const item = event.results?.[0]?.[0]
|
||||
emitResult(item?.transcript || '')
|
||||
let interim = ''
|
||||
let finalText = ''
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const r = event.results[i]
|
||||
const txt = r?.[0]?.transcript || ''
|
||||
if (r.isFinal) finalText += txt
|
||||
else interim += txt
|
||||
}
|
||||
if (interim && typeof onPartial === 'function') onPartial(interim)
|
||||
if (finalText) {
|
||||
h5GotResult = true
|
||||
emitResult(finalText)
|
||||
}
|
||||
}
|
||||
h5Recognition.onend = () => {
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
// 无识别结果也要兜底触发一次 settle,便于一问一答流程重试
|
||||
if (!h5GotResult) {
|
||||
emitResult('')
|
||||
}
|
||||
h5GotResult = false
|
||||
}
|
||||
h5Recognition.onerror = (event) => {
|
||||
sttListening.value = false
|
||||
startRequested = false
|
||||
sttHolding.value = false
|
||||
if (event?.error === 'not-allowed') {
|
||||
const err = event?.error || ''
|
||||
if (err === 'aborted') return
|
||||
if (typeof onError === 'function') {
|
||||
const handled = onError(err)
|
||||
if (handled === true) return
|
||||
}
|
||||
if (err === 'not-allowed') {
|
||||
notify('请允许浏览器使用麦克风')
|
||||
} else if (event?.error !== 'aborted') {
|
||||
} else {
|
||||
notify('语音识别失败')
|
||||
}
|
||||
}
|
||||
@@ -246,7 +427,14 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
onUnmounted(() => {
|
||||
sttHolding.value = false
|
||||
holdSessionId += 1
|
||||
pendingStartSession = 0
|
||||
stopListening()
|
||||
// #ifdef MP-WEIXIN
|
||||
// 释放全局回调指向,避免事件继续派发给已销毁的实例
|
||||
if (activeCtl === controller) {
|
||||
activeCtl = null
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -258,3 +446,5 @@ export function useSpeechToText({ onResult, showToast } = {}) {
|
||||
stopSpeechToText: endHoldSpeech
|
||||
}
|
||||
}
|
||||
|
||||
export { isBenignSttError, friendlySttError }
|
||||
|
||||
@@ -32,7 +32,8 @@ export function warmUpFoodImgs(foods) {
|
||||
const list = Array.isArray(foods) ? foods : []
|
||||
list.forEach((f) => {
|
||||
const icon = typeof f === 'string' ? f : f?.icon
|
||||
const url = getFoodImgUrl(icon)
|
||||
// 优先预热自定义图片素材,缺省回退 emoji PNG
|
||||
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
|
||||
if (!url) return
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
|
||||
@@ -58,7 +58,20 @@ const FOOD_TIP_OVERRIDE = {
|
||||
apple: '带皮整果,升糖较慢',
|
||||
milk: '低糖,含蛋白,适量喝好',
|
||||
fish: '优质蛋白,升糖低',
|
||||
beef: '蛋白为主,升糖低'
|
||||
beef: '蛋白为主,升糖低',
|
||||
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
|
||||
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
|
||||
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
|
||||
friedNoodles: '油多又是精面,升糖快',
|
||||
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
|
||||
sandwich: '夹心面包精制碳多,升糖快',
|
||||
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
|
||||
milletCongee: '小米粥熬软升糖快,糖友控量',
|
||||
shandongPancake: '煎饼加油面,升糖中等偏快',
|
||||
cake: '蛋糕又甜又油,升糖快',
|
||||
biscuit: '饼干又甜又碎,升糖快',
|
||||
wonton: '馄饨面皮精白,升糖中等,别多吃',
|
||||
blackCoffee: '不加糖的黑咖啡,升糖很低'
|
||||
}
|
||||
|
||||
/** 含糖等级通用科普文案 */
|
||||
@@ -76,6 +89,9 @@ export function getFoodTip(food) {
|
||||
return GI_TIP_DEFAULT[food.gi] || ''
|
||||
}
|
||||
|
||||
/** 自定义图片素材目录(腾讯云 COS,优先使用真实图片而非 emoji) */
|
||||
const GAMES_IMG_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games'
|
||||
|
||||
const RAW_FOODS = [
|
||||
// 低 GI
|
||||
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
|
||||
@@ -101,7 +117,7 @@ const RAW_FOODS = [
|
||||
|
||||
// 中 GI
|
||||
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39' },
|
||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39', img: `${GAMES_IMG_BASE}/%E7%95%AA%E8%96%AF.png` },
|
||||
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
|
||||
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
|
||||
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
|
||||
@@ -118,14 +134,24 @@ const RAW_FOODS = [
|
||||
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
|
||||
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
|
||||
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
|
||||
{ key: 'blackCoffee', icon: '☕', name: '黑咖啡', gi: 'low', val: -1, color: '#4E342E', img: `${GAMES_IMG_BASE}/%E9%BB%91%E5%92%96%E5%95%A1.png` },
|
||||
{ key: 'wonton', icon: '🥟', name: '馄饨', gi: 'mid', val: 5, color: '#E8DCC8', img: `${GAMES_IMG_BASE}/%E9%A6%84%E9%A5%A8.png` },
|
||||
{ key: 'milletCongee', icon: '🥣', name: '小米粥', gi: 'mid', val: 5, color: '#F5E6B8', img: `${GAMES_IMG_BASE}/%E5%B0%8F%E7%B1%B3%E7%B2%A5.png` },
|
||||
{ key: 'shandongPancake', icon: '🥞', name: '山东煎饼', gi: 'high', val: 16, color: '#D4A574', img: `${GAMES_IMG_BASE}/%E5%B1%B1%E4%B8%9C%E7%85%8E%E9%A5%BC.png` },
|
||||
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
|
||||
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
|
||||
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
|
||||
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
|
||||
|
||||
// 高 GI
|
||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0' },
|
||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0', img: `${GAMES_IMG_BASE}/%E7%B1%B3%E9%A5%AD.png` },
|
||||
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
|
||||
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
|
||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F' },
|
||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F', img: `${GAMES_IMG_BASE}/%E6%B2%B9%E6%9D%A1.png` },
|
||||
{ key: 'sandwich', icon: '🥪', name: '三明治', gi: 'high', val: 16, color: '#D9B88F', img: `${GAMES_IMG_BASE}/%E4%B8%89%E6%98%8E%E6%B2%BB.png` },
|
||||
{ key: 'biscuit', icon: '🍪', name: '饼干', gi: 'high', val: 18, color: '#C9A063', img: `${GAMES_IMG_BASE}/%E9%A5%BC%E5%B9%B2.png` },
|
||||
{ key: 'cake', icon: '🎂', name: '蛋糕', gi: 'high', val: 22, color: '#F48FB1', img: `${GAMES_IMG_BASE}/%E8%9B%8B%E7%B3%95.png` },
|
||||
{ key: 'eightTreasureCongee', icon: '🥣', name: '八宝粥', gi: 'high', val: 17, color: '#E8C4A0', img: `${GAMES_IMG_BASE}/%E5%85%AB%E5%AE%9D%E7%B2%A5.png` },
|
||||
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
|
||||
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
|
||||
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
|
||||
@@ -136,7 +162,10 @@ const RAW_FOODS = [
|
||||
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
|
||||
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
|
||||
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
|
||||
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' }
|
||||
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
|
||||
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
|
||||
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
|
||||
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
|
||||
]
|
||||
|
||||
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||
@@ -149,7 +178,8 @@ export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||
clearVal,
|
||||
giLabel: GI_LABEL[f.gi],
|
||||
get img() {
|
||||
return getFoodImgUrl(f.icon)
|
||||
// 优先使用自定义图片素材,缺省回退到 emoji PNG
|
||||
return f.img || getFoodImgUrl(f.icon)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -170,6 +200,6 @@ export function cloneFoodType(food) {
|
||||
giLabel: food.giLabel,
|
||||
val: food.val,
|
||||
color: food.color,
|
||||
img: getFoodImgUrl(food.icon)
|
||||
img: food.img || getFoodImgUrl(food.icon)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
|
||||
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
|
||||
*/
|
||||
/**
|
||||
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
|
||||
* 第一关固定为这 16 种带真实图片素材的食物。
|
||||
*/
|
||||
const FIXED_LEVEL_FOODS = {
|
||||
1: [
|
||||
'sandwich',
|
||||
'eightTreasureCongee',
|
||||
'milkOats',
|
||||
'milletCongee',
|
||||
'shandongPancake',
|
||||
'grainMantou',
|
||||
'riceNoodleSoup',
|
||||
'youtiao',
|
||||
'friedNoodles',
|
||||
'sweetPotato',
|
||||
'centuryEggCongee',
|
||||
'whiteRice',
|
||||
'cake',
|
||||
'biscuit',
|
||||
'wonton',
|
||||
'blackCoffee'
|
||||
]
|
||||
}
|
||||
|
||||
export function getLevelConfig(levelId) {
|
||||
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
|
||||
const tier = Math.floor((lv - 1) / 5)
|
||||
@@ -14,6 +39,8 @@ export function getLevelConfig(levelId) {
|
||||
goalTargets: [6 + tier, 8 + tier],
|
||||
scoreTarget: 2000 + lv * 300,
|
||||
startGlucose: 50,
|
||||
// 固定食材(可选):存在时本关只用这些食物
|
||||
foods: FIXED_LEVEL_FOODS[lv] || null,
|
||||
boosters: {
|
||||
insulin: 3,
|
||||
fiber: 1,
|
||||
|
||||
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 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>
|
||||
</view>
|
||||
<view class="ai-diet-meal-list">
|
||||
@@ -434,6 +434,20 @@
|
||||
{{ dietAiRecommend.breakfast || (dietAiLoading ? '…' : '—') }}<text v-if="dietAiLoading && dietAiStreamField === 'breakfast'" class="ai-stream-cursor">▍</text>
|
||||
</text>
|
||||
</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-head">
|
||||
<view class="ai-diet-meal-icon" aria-hidden="true">
|
||||
@@ -2657,16 +2671,13 @@ async function bootstrap(options) {
|
||||
if (!target) target = list[0]
|
||||
await applyCard(target)
|
||||
} else {
|
||||
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
|
||||
if (passedPatientId) patientId.value = passedPatientId
|
||||
if (passedPatientName) patientName.value = passedPatientName
|
||||
if (passedAge) age.value = passedAge
|
||||
if (passedGender) gender.value = passedGender
|
||||
if (!diagnosisId.value || !patientId.value) {
|
||||
readUserContext()
|
||||
}
|
||||
diagnosisId.value = 0
|
||||
patientId.value = 0
|
||||
patientName.value = ''
|
||||
age.value = 0
|
||||
gender.value = 0
|
||||
readUserMobile()
|
||||
if (!diagnosisId.value && userMobile.value) {
|
||||
if (userMobile.value) {
|
||||
await ensureDailyContextByPhone()
|
||||
}
|
||||
}
|
||||
@@ -6558,6 +6569,12 @@ async function onFamilyLike() {
|
||||
.ai-diet-meal-card.meal-breakfast::before {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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">
|
||||
<!-- 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-head">
|
||||
<view>
|
||||
<view style="padding: 0 20rpx;">
|
||||
<text class="st-chart-title">血糖趋势</text>
|
||||
<text class="st-chart-sub">{{ rangeText }} · mmol/L</text>
|
||||
|
||||
@@ -94,9 +96,9 @@
|
||||
</view>
|
||||
</view>
|
||||
<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 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">
|
||||
<TongjiIcon name="activity" size="sm" color="#006c49" />
|
||||
</view>
|
||||
@@ -107,9 +109,9 @@
|
||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="st-training-module">
|
||||
<dev-training-entry inline />
|
||||
</view>
|
||||
<view class="st-training-section">
|
||||
<dev-training-entry inline tone="weekly" />
|
||||
</view>
|
||||
<!-- 3. AI 饮食建议 -->
|
||||
<view v-if="diagnosisId" class="st-ai-section">
|
||||
<view class="st-ai-deco" aria-hidden="true" />
|
||||
@@ -129,7 +131,7 @@
|
||||
<text>{{ dietAiLoading ? '生成中' : '换一换' }}</text>
|
||||
</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-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>
|
||||
</text>
|
||||
</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-head">
|
||||
<TongjiIcon name="leaf" size="sm" color="#006c49" />
|
||||
@@ -179,19 +193,26 @@
|
||||
<view class="st-float-close" @click="floatAskClosed = true">
|
||||
<text class="st-float-close-icon">×</text>
|
||||
</view>
|
||||
<view class="st-float-inner">
|
||||
<view class="st-float-inner" :class="{ 'is-expanded': dietAiAskExpanded }">
|
||||
<view class="st-float-icon">
|
||||
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||||
</view>
|
||||
<input
|
||||
<textarea
|
||||
class="st-float-input"
|
||||
type="text"
|
||||
:value="dietAiAskText"
|
||||
placeholder="长按麦克风说话,或输入文字咨询 AI..."
|
||||
placeholder="输入或语音咨询 AI…"
|
||||
placeholder-class="st-float-placeholder"
|
||||
:maxlength="-1"
|
||||
auto-height
|
||||
:show-confirm-bar="false"
|
||||
:disable-default-padding="true"
|
||||
:cursor-spacing="24"
|
||||
confirm-type="send"
|
||||
:confirm-hold="false"
|
||||
@input="onDietAiAskInput"
|
||||
@confirm="askDietAiFood"
|
||||
@focus="onDietAiAskFocus"
|
||||
@blur="onDietAiAskBlur"
|
||||
/>
|
||||
<view
|
||||
class="st-float-mic"
|
||||
@@ -248,6 +269,9 @@
|
||||
</view>
|
||||
</view>
|
||||
<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>
|
||||
<view class="phone-gate-gender">
|
||||
<text class="phone-gate-gender-label">性别</text>
|
||||
@@ -432,11 +456,42 @@ function showUserToast(title, options = {}) {
|
||||
uni.showToast({
|
||||
title: text,
|
||||
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)
|
||||
/** 手机号快捷建档失败:页面常驻提示(避免 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 patientName = ref('')
|
||||
const displayPatientName = computed(() => {
|
||||
@@ -471,6 +526,19 @@ const {
|
||||
/** 底部 AI 浮动卡片是否被用户收起 */
|
||||
const floatAskClosed = ref(false)
|
||||
|
||||
/** AI 输入框是否聚焦:聚焦时输入框变大 */
|
||||
const dietAiAskFocused = ref(false)
|
||||
function onDietAiAskFocus() {
|
||||
dietAiAskFocused.value = true
|
||||
}
|
||||
function onDietAiAskBlur() {
|
||||
dietAiAskFocused.value = false
|
||||
}
|
||||
/** 聚焦或已有内容时展开为多行输入(内容多则自动增高) */
|
||||
const dietAiAskExpanded = computed(
|
||||
() => dietAiAskFocused.value || String(dietAiAskText.value || '').length > 0
|
||||
)
|
||||
|
||||
/** 键盘高度(px):录入弹窗据此整体上移,避免输入框被键盘遮挡 */
|
||||
const keyboardHeight = ref(0)
|
||||
function onKeyboardHeightChange(res) {
|
||||
@@ -632,6 +700,19 @@ const currentRange = computed(() => {
|
||||
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 { start, end } = currentRange.value
|
||||
if (!start || !end) return []
|
||||
@@ -794,27 +875,37 @@ const recordDays = computed(() => {
|
||||
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(() => {
|
||||
return chartDates.value.some((date) => {
|
||||
const b = bloodByDate.value[date]
|
||||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
||||
})
|
||||
return chartDates.value.some((date) => bloodDayHasSugar(bloodByDate.value[date]))
|
||||
})
|
||||
|
||||
/** 当前时间范围内有真实录入的天数 */
|
||||
/** 当前图表时间范围内有真实录入的天数(用于小结统计) */
|
||||
const bloodRecordDayCount = computed(() => {
|
||||
return chartDates.value.filter((date) => {
|
||||
const b = bloodByDate.value[date]
|
||||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
||||
}).length
|
||||
return chartDates.value.filter((date) => bloodDayHasSugar(bloodByDate.value[date])).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
|
||||
|
||||
/** 无就诊卡或真实录入不足 7 天时,图表用示例数据 */
|
||||
/** 无就诊卡或累计录入不足 7 天时,图表用示例数据 */
|
||||
const chartUseMockData = computed(() => {
|
||||
if (!diagnosisId.value) return true
|
||||
return bloodRecordDayCount.value < REAL_BLOOD_DAYS_MIN
|
||||
return bloodCumulativeDayCount.value < REAL_BLOOD_DAYS_MIN
|
||||
})
|
||||
|
||||
/** 示例曲线:7 天内有波动的空腹/餐后,便于老人理解趋势图形态 */
|
||||
@@ -1154,8 +1245,14 @@ function syncPhoneGateSexFromUser() {
|
||||
}
|
||||
}
|
||||
|
||||
function openPhoneGate() {
|
||||
async function openPhoneGate() {
|
||||
syncPhoneGateSexFromUser()
|
||||
readUserMobile()
|
||||
if (userMobile.value) {
|
||||
phoneGateVisible.value = false
|
||||
await resolveDailyPhoneContext(false)
|
||||
return
|
||||
}
|
||||
phoneGateVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1172,8 +1269,11 @@ async function refreshUserMobile() {
|
||||
return userMobile.value
|
||||
}
|
||||
|
||||
async function ensureDailyContextByPhone(sex) {
|
||||
if (diagnosisId.value) return true
|
||||
async function ensureDailyContextByPhone(sex, options = {}) {
|
||||
if (diagnosisId.value) {
|
||||
clearDailyContextError()
|
||||
return true
|
||||
}
|
||||
|
||||
const payload = {}
|
||||
const sexVal = sex ?? phoneGateSex.value
|
||||
@@ -1188,6 +1288,7 @@ async function ensureDailyContextByPhone(sex) {
|
||||
}, false)
|
||||
|
||||
if (res && res.code === 1 && res.data && res.data.diagnosis_id) {
|
||||
clearDailyContextError()
|
||||
await applyCard({
|
||||
id: res.data.diagnosis_id,
|
||||
patient_id: res.data.patient_id,
|
||||
@@ -1195,17 +1296,32 @@ async function ensureDailyContextByPhone(sex) {
|
||||
age: res.data.age || 0,
|
||||
gender: res.data.gender || 0
|
||||
})
|
||||
if (res.data.created) {
|
||||
if (res.data.created || res.data.linked_existing) {
|
||||
await fetchCardList()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (res && res.data && res.data.need_mobile) {
|
||||
clearDailyContextError()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1218,7 +1334,7 @@ async function ensureCanRecordGlucose() {
|
||||
return false
|
||||
}
|
||||
|
||||
const ok = await ensureDailyContextByPhone()
|
||||
const ok = await ensureDailyContextByPhone(undefined, { alert: true })
|
||||
if (ok === 'need_mobile') {
|
||||
openPhoneGate()
|
||||
return false
|
||||
@@ -1251,6 +1367,13 @@ async function onPhoneGateAuthorize(e) {
|
||||
|
||||
phoneBinding.value = true
|
||||
try {
|
||||
readUserMobile()
|
||||
if (userMobile.value) {
|
||||
phoneGateVisible.value = false
|
||||
await resolveDailyPhoneContext(true)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/user/getMobileByMnp',
|
||||
method: 'POST',
|
||||
@@ -1262,12 +1385,7 @@ async function onPhoneGateAuthorize(e) {
|
||||
}
|
||||
await refreshUserMobile()
|
||||
phoneGateVisible.value = false
|
||||
const ok = await ensureDailyContextByPhone(phoneGateSex.value)
|
||||
if (ok === true) {
|
||||
await openInputForm(true)
|
||||
} else if (ok === 'need_mobile') {
|
||||
openPhoneGate()
|
||||
}
|
||||
await resolveDailyPhoneContext(true)
|
||||
} catch (err) {
|
||||
showUserToast('网络异常,请稍后再试')
|
||||
} finally {
|
||||
@@ -1290,7 +1408,7 @@ async function onSelectCard(card) {
|
||||
async function fetchAll() {
|
||||
if (!hasAuthToken() || !diagnosisId.value) return
|
||||
loading.value = true
|
||||
const { start, end } = currentRange.value
|
||||
const { start, end } = fetchRange.value
|
||||
try {
|
||||
const [winRes, noteRes] = await Promise.all([
|
||||
proxy.apiUrl({
|
||||
@@ -2490,15 +2608,15 @@ async function bootstrap(options) {
|
||||
if (!target) target = list[0]
|
||||
await applyCard(target)
|
||||
} else {
|
||||
diagnosisId.value = passedDiagnosisId || 0
|
||||
patientId.value = passedPatientId || 0
|
||||
if (passedPatientName) patientName.value = passedPatientName
|
||||
if (passedAge) age.value = passedAge
|
||||
if (passedGender) gender.value = passedGender
|
||||
if (!patientName.value) readUserContext()
|
||||
// 无可见就诊卡:勿用 URL/缓存里的隐藏诊单 ID
|
||||
diagnosisId.value = 0
|
||||
patientId.value = 0
|
||||
patientName.value = ''
|
||||
age.value = 0
|
||||
gender.value = 0
|
||||
readUserMobile()
|
||||
if (!diagnosisId.value && userMobile.value) {
|
||||
await ensureDailyContextByPhone()
|
||||
if (userMobile.value) {
|
||||
await ensureDailyContextByPhone(undefined, { alert: false })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -639,7 +639,8 @@
|
||||
}
|
||||
|
||||
.more-page .mc-tasks-section {
|
||||
margin-top: 40rpx;
|
||||
margin-top: 0;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-tasks-grid {
|
||||
@@ -1167,3 +1168,259 @@
|
||||
font-size: 22rpx;
|
||||
color: rgba(108, 122, 113, 0.6);
|
||||
}
|
||||
|
||||
.more-page .mc-tree-species-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
margin-left: 8rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(0, 108, 73, 0.08);
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel,
|
||||
.more-page .mc-species-panel {
|
||||
width: calc(100% - 48rpx);
|
||||
max-width: 680rpx;
|
||||
margin: 0 auto;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 78vh;
|
||||
background: var(--surface);
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 32rpx 28rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -12rpx 40rpx rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-container-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-summary {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
background: var(--surface-container-low);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.08);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-water {
|
||||
border-color: rgba(0, 108, 73, 0.22);
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-claimed {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-name {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-row.is-pending .mc-water-panel-row-tag {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-growth {
|
||||
padding: 16rpx 18rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.45;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
background: var(--on-surface);
|
||||
color: var(--surface);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.more-page .mc-water-panel-btn.ghost {
|
||||
background: var(--surface-container-low);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-species-panel-tip {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
color: var(--outline);
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-scroll {
|
||||
max-height: 56vh;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card {
|
||||
padding: 20rpx;
|
||||
border-radius: 24rpx;
|
||||
border: 2rpx solid rgba(0, 108, 73, 0.1);
|
||||
background: var(--surface-container-low);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(0, 108, 73, 0.06);
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-emoji {
|
||||
font-size: 56rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-name {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.more-page .mc-species-card-tagline {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: var(--outline);
|
||||
}
|
||||
|
||||
.more-page .mc-species-stages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage {
|
||||
width: calc(20% - 8rpx);
|
||||
min-width: 96rpx;
|
||||
padding: 10rpx 6rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage.reached {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage.current {
|
||||
box-shadow: 0 0 0 2rpx var(--primary);
|
||||
background: rgba(0, 108, 73, 0.1);
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-emoji {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-lv {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.more-page .mc-species-stage-name {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
color: var(--outline);
|
||||
margin-top: 2rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,22 @@
|
||||
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 {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
|
||||
@@ -108,10 +108,25 @@
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
/* ===== 居家训练(DEV 内嵌,与 user 页同源入口) ===== */
|
||||
.weekly-page .st-training-module {
|
||||
padding: 10rpx var(--st-container-margin);
|
||||
margin: -10rpx -30rpx 10rpx -30rpx;
|
||||
/* ===== 居家训练(DEV 内嵌) ===== */
|
||||
.weekly-page .st-training-section {
|
||||
width: 100%;
|
||||
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 ===== */
|
||||
@@ -123,6 +138,21 @@
|
||||
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 {
|
||||
height: 32rpx;
|
||||
}
|
||||
@@ -149,7 +179,7 @@
|
||||
|
||||
/* 糖分突袭游戏入口 */
|
||||
.weekly-page .st-game-entry {
|
||||
width: 100%;
|
||||
|
||||
margin-top: 24rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
display: flex;
|
||||
@@ -626,6 +656,11 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -705,13 +740,22 @@
|
||||
.weekly-page .st-float-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 16rpx 16rpx 16rpx 40rpx;
|
||||
gap: 16rpx;
|
||||
padding: 16rpx 16rpx 16rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(20rpx);
|
||||
border: 1rpx solid rgba(0, 108, 73, 0.1);
|
||||
box-shadow: var(--st-shadow-float);
|
||||
transition: border-radius 0.22s ease, padding 0.22s ease, border-color 0.22s ease;
|
||||
}
|
||||
|
||||
/* 聚焦/有内容时展开:底部对齐让输入框向上增高,圆角收敛为圆角矩形 */
|
||||
.weekly-page .st-float-inner.is-expanded {
|
||||
align-items: flex-end;
|
||||
border-radius: 32rpx;
|
||||
padding: 18rpx 16rpx 18rpx 32rpx;
|
||||
border-color: rgba(0, 108, 73, 0.28);
|
||||
}
|
||||
|
||||
.weekly-page .st-float-icon {
|
||||
@@ -728,15 +772,30 @@
|
||||
.weekly-page .st-float-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 64rpx;
|
||||
width: 100%;
|
||||
min-height: 64rpx;
|
||||
max-height: 240rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
color: var(--on-surface);
|
||||
background: transparent;
|
||||
padding: 12rpx 0;
|
||||
box-sizing: border-box;
|
||||
transition: min-height 0.2s ease;
|
||||
}
|
||||
|
||||
/* 聚焦时输入框变大;内容超长时由 auto-height 继续向上增高,超过上限可滚动 */
|
||||
.weekly-page .st-float-inner.is-expanded .st-float-input {
|
||||
min-height: 104rpx;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-placeholder {
|
||||
color: rgba(60, 74, 66, 0.6);
|
||||
font-size: 28rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.weekly-page .st-float-mic {
|
||||
|
||||
@@ -64,10 +64,10 @@ export function parsePartialDietPlainText(text) {
|
||||
const out = {}
|
||||
if (!text) return out
|
||||
|
||||
const lineRe = /^(早餐|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm
|
||||
const lineRe = /^(早餐|喝的|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm
|
||||
let match
|
||||
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()
|
||||
if (key === 'avoid') {
|
||||
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) {
|
||||
const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
|
||||
const key = { '早餐': 'breakfast', '喝的': 'drinks', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]]
|
||||
const val = (tail[2] || '').trim()
|
||||
if (key === 'avoid') {
|
||||
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(' · ')
|
||||
}
|
||||
Generated
+7
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@mediapipe/tasks-vision": "^0.10.35",
|
||||
"@tencentcloud/call-uikit-vue": "^4.0.12",
|
||||
"@tencentcloud/chat-uikit-vue3": "^4.5.4",
|
||||
"@trtc/calls-uikit-vue": "^4.4.6",
|
||||
@@ -2484,6 +2485,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.35",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@mediapipe/tasks-vision": "^0.10.35",
|
||||
"@tencentcloud/call-uikit-vue": "^4.0.12",
|
||||
"@tencentcloud/chat-uikit-vue3": "^4.5.4",
|
||||
"@trtc/calls-uikit-vue": "^4.4.6",
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -96,6 +96,21 @@ export function yejiStatsRevisitBreakdown(params: {
|
||||
return request.get({ url: '/stats.yejiStats/revisitBreakdown', params })
|
||||
}
|
||||
|
||||
/** 被指派数明细:与看板「被指派数」同口径;部门行传 dept_id,医助排行榜传 assistant_id */
|
||||
export function yejiStatsAssignLines(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
dept_ids?: string
|
||||
channel_code?: string
|
||||
tag_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/assignLines', params })
|
||||
}
|
||||
|
||||
export function doctorDailyStatsOverview(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
@@ -181,6 +196,37 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
||||
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
||||
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:部门下拉(前端组树) */
|
||||
export function revisitRateDeptOptions() {
|
||||
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
||||
export function revisitRateAssignLines(params: {
|
||||
month?: string
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||
}
|
||||
|
||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
||||
export function revisitRateVisitOrderLines(params: {
|
||||
month?: string
|
||||
slot: number
|
||||
dept_ids?: string
|
||||
assistant_id?: number
|
||||
dept_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
|
||||
+15
-3
@@ -403,6 +403,11 @@ export function prescriptionOrderLogisticsTrace(params: {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
|
||||
}
|
||||
|
||||
/** 直接调用京东官方物流接口刷新轨迹并落库(绕过快递100缓存) */
|
||||
export function prescriptionOrderLogisticsJdUpdate(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/logisticsJdUpdate', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/detail', params })
|
||||
}
|
||||
@@ -441,6 +446,11 @@ export function prescriptionOrderRevokeRxAudit(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/revokeRxAudit', params })
|
||||
}
|
||||
|
||||
/** 批量将处方业务订单(创建人/医助归属)改派给其他医助 */
|
||||
export function prescriptionOrderBatchAssignAssistant(params: { order_ids: number[]; assistant_id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/batchAssignAssistant', params })
|
||||
}
|
||||
|
||||
/** 撤回支付单审核 */
|
||||
export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/revokePayAudit', params })
|
||||
@@ -463,6 +473,8 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
/** 创建方式:fubei 付呗(默认) / express_cod 快递代收 */
|
||||
pay_create_type?: 'fubei' | 'express_cod'
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||
}
|
||||
@@ -482,13 +494,13 @@ export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||
}
|
||||
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态,不含退款) */
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态;退款请走 refund 或完成弹窗选「退款」) */
|
||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||
}
|
||||
|
||||
/** 业务订单退款(须填写原因;关联收款单标记已退款、已付总额清零) */
|
||||
export function prescriptionOrderRefund(params: { id: number; reason: string }) {
|
||||
/** 业务订单退款(须填写原因;refund_amount 可选,不传则退满可退上限) */
|
||||
export function prescriptionOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/refund', params })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
<template>
|
||||
<span class="beauty-trigger" @click="openPanel">
|
||||
<slot name="reference">
|
||||
<el-button type="primary" link size="small">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span class="beauty-btn-text">美颜</span>
|
||||
</el-button>
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="beauty-window"
|
||||
:style="windowStyle"
|
||||
@mousedown.stop
|
||||
>
|
||||
<div class="beauty-window-header" @mousedown="onDragStart">
|
||||
<span class="beauty-window-title">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
美颜设置
|
||||
</span>
|
||||
<el-button type="danger" link class="beauty-window-close" @mousedown.stop @click="closePanel">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="beauty-window-body">
|
||||
<!-- 左侧:大预览 -->
|
||||
<div class="beauty-preview-col">
|
||||
<div ref="previewStageRef" class="beauty-preview-stage">
|
||||
<video
|
||||
v-show="previewing"
|
||||
ref="previewVideoRef"
|
||||
class="beauty-preview-video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div v-if="!previewing" class="beauty-preview-placeholder">
|
||||
<el-icon :size="36"><VideoCamera /></el-icon>
|
||||
<span v-if="previewLoading">正在打开摄像头...</span>
|
||||
<span v-else-if="previewError" class="beauty-preview-error-text">{{ previewError }}</span>
|
||||
<span v-else>摄像头预览未开启</span>
|
||||
</div>
|
||||
<span v-if="previewing" class="beauty-preview-badge">对方看到的效果(镜像显示)</span>
|
||||
<el-button
|
||||
v-if="previewing"
|
||||
class="beauty-fullscreen-btn"
|
||||
circle
|
||||
size="small"
|
||||
title="全屏预览(按 Esc 退出)"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="beauty-preview-toolbar">
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="previewLoading"
|
||||
@click="togglePreview"
|
||||
>
|
||||
{{ previewing ? '关闭预览' : '打开预览' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="previewing"
|
||||
size="small"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
<span style="margin-left: 2px">全屏</span>
|
||||
</el-button>
|
||||
<span class="beauty-preview-note">预览效果即对方所见(画面镜像显示)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:调节项 -->
|
||||
<div class="beauty-control-col">
|
||||
<div class="beauty-section-header">
|
||||
<span class="beauty-section-title">基础美颜</span>
|
||||
<el-switch v-model="settings.enabled" size="small" />
|
||||
</div>
|
||||
<div class="beauty-section-body" :class="{ 'is-disabled': !settings.enabled }">
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">磨皮算法</span>
|
||||
<el-radio-group
|
||||
v-model="settings.style"
|
||||
size="small"
|
||||
:disabled="!settings.enabled"
|
||||
>
|
||||
<el-radio-button :value="BEAUTY_STYLE_NATURE">自然</el-radio-button>
|
||||
<el-radio-button :value="BEAUTY_STYLE_SMOOTH">光滑</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<el-tooltip content="磨皮仅作用于人脸皮肤,眉眼/背景/衣物不受影响" placement="left">
|
||||
<span class="beauty-label has-tip">磨皮</span>
|
||||
</el-tooltip>
|
||||
<el-slider
|
||||
v-model="settings.beautyLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">美白</span>
|
||||
<el-slider
|
||||
v-model="settings.whitenessLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">红润</span>
|
||||
<el-slider
|
||||
v-model="settings.ruddinessLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<el-tooltip content="淡化脸上色斑 / 黑色素沉着,仅作用于人脸皮肤" placement="left">
|
||||
<span class="beauty-label has-tip">祛斑</span>
|
||||
</el-tooltip>
|
||||
<el-slider
|
||||
v-model="settings.spotLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<el-tooltip content="淡化脸上小面积深色痣点,仅作用于人脸皮肤" placement="left">
|
||||
<span class="beauty-label has-tip">去痣</span>
|
||||
</el-tooltip>
|
||||
<el-slider
|
||||
v-model="settings.moleLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<el-tooltip content="提亮眼下区域,减轻黑眼圈" placement="left">
|
||||
<span class="beauty-label has-tip">去黑眼圈</span>
|
||||
</el-tooltip>
|
||||
<el-slider
|
||||
v-model="settings.darkCircleLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<el-tooltip content="提升画面整体亮度,适合光线偏暗环境" placement="left">
|
||||
<span class="beauty-label has-tip">亮度</span>
|
||||
</el-tooltip>
|
||||
<el-slider
|
||||
v-model="settings.brightnessLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<el-tooltip content="锐化细节,让画面更清晰" placement="left">
|
||||
<span class="beauty-label has-tip">清晰度</span>
|
||||
</el-tooltip>
|
||||
<el-slider
|
||||
v-model="settings.clarityLevel"
|
||||
:min="0"
|
||||
:max="BEAUTY_LEVEL_MAX"
|
||||
:step="1"
|
||||
:disabled="!settings.enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider class="beauty-divider" />
|
||||
|
||||
<div class="beauty-section-header">
|
||||
<span class="beauty-section-title">
|
||||
高级美颜
|
||||
<el-tooltip
|
||||
placement="top"
|
||||
content="瘦脸/口红/腮红由本机 AI 实时处理(免费,无需腾讯付费特效)。开关在下次开启摄像头时生效,通话中可关闭摄像头再打开。"
|
||||
>
|
||||
<el-icon class="beauty-help-icon"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
<el-switch
|
||||
v-model="settings.advanced.enabled"
|
||||
size="small"
|
||||
:disabled="!advancedSupported"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!advancedSupported" class="beauty-unsupported">
|
||||
当前浏览器不支持高级美颜(需要 WebGL),请使用新版 Chrome / Edge
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="beauty-section-body"
|
||||
:class="{ 'is-disabled': !settings.advanced.enabled }"
|
||||
>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">瘦脸</span>
|
||||
<el-slider
|
||||
v-model="settings.advanced.slimStrength"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">口红</span>
|
||||
<el-slider
|
||||
v-model="settings.advanced.lipstickStrength"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
<el-color-picker
|
||||
v-model="settings.advanced.lipstickColor"
|
||||
size="small"
|
||||
:predefine="LIPSTICK_PRESETS"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="beauty-item">
|
||||
<span class="beauty-label">腮红</span>
|
||||
<el-slider
|
||||
v-model="settings.advanced.blushStrength"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
<el-color-picker
|
||||
v-model="settings.advanced.blushColor"
|
||||
size="small"
|
||||
:predefine="BLUSH_PRESETS"
|
||||
:disabled="!settings.advanced.enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="needCameraRestartHint" class="beauty-restart-hint">
|
||||
通话中如果摄像头已按原始画面开启,需关闭摄像头再打开才能生效
|
||||
</div>
|
||||
|
||||
<div class="beauty-window-footer">
|
||||
<span class="beauty-tip">全部为免费能力,通话中实时生效</span>
|
||||
<el-button link type="primary" size="small" @click="handleReset">
|
||||
恢复默认
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch, computed, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { MagicStick, QuestionFilled, Close, VideoCamera, FullScreen } from '@element-plus/icons-vue'
|
||||
import {
|
||||
BEAUTY_LEVEL_MAX,
|
||||
BEAUTY_STYLE_NATURE,
|
||||
BEAUTY_STYLE_SMOOTH,
|
||||
applyBeautyToEngine,
|
||||
defaultBeautySettings,
|
||||
hasAnyBeautyEffect,
|
||||
loadBeautySettings,
|
||||
saveBeautySettings
|
||||
} from '@/utils/call-beauty'
|
||||
import {
|
||||
getRawUserMedia,
|
||||
installBeautyMediaInterceptor,
|
||||
preloadBeautyModel
|
||||
} from '@/utils/beauty/beauty-media-interceptor'
|
||||
import {
|
||||
FaceBeautyPipeline,
|
||||
hasActiveBeautyPipeline,
|
||||
isAdvancedBeautySupported,
|
||||
updateActiveBeautyPipelines
|
||||
} from '@/utils/beauty/face-beauty-pipeline'
|
||||
|
||||
const LIPSTICK_PRESETS = ['#c94f5e', '#b03a48', '#d96a76', '#a52a3c', '#e0788a', '#8f2436']
|
||||
const BLUSH_PRESETS = ['#e88193', '#f0a3b0', '#dd6b7f', '#f2b8c0', '#e5949e']
|
||||
|
||||
const WINDOW_WIDTH = 780
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 获取当前 TUICallEngine 实例(未初始化时返回 null 即可) */
|
||||
getEngine: () => any
|
||||
/** 面板 z-index,需高于承载视频通话的浮窗 */
|
||||
popperZIndex?: number
|
||||
}>(),
|
||||
{
|
||||
popperZIndex: 300001
|
||||
}
|
||||
)
|
||||
|
||||
// 拦截器必须先于 SDK 申请摄像头安装;组件被通话相关页面引入时即安装
|
||||
installBeautyMediaInterceptor()
|
||||
|
||||
const settings = reactive(loadBeautySettings())
|
||||
const advancedSupported = isAdvancedBeautySupported()
|
||||
|
||||
// 面板挂载即预热模型,避免首次打开预览时同步加载 WASM/模型卡死
|
||||
if (advancedSupported) preloadBeautyModel()
|
||||
|
||||
/** 美颜已开启但当前没有活跃通话管线 → 摄像头是在开启前用原始画面打开的 */
|
||||
const needCameraRestartHint = computed(() => {
|
||||
return hasAnyBeautyEffect(settings) && !hasActiveBeautyPipeline()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 浮动窗口(可拖拽)
|
||||
// ---------------------------------------------------------------------------
|
||||
const visible = ref(false)
|
||||
const windowPos = reactive({ x: 0, y: 0 })
|
||||
|
||||
const windowStyle = computed(() => ({
|
||||
left: `${windowPos.x}px`,
|
||||
top: `${windowPos.y}px`,
|
||||
width: `${WINDOW_WIDTH}px`,
|
||||
zIndex: props.popperZIndex
|
||||
}))
|
||||
|
||||
const dragState = { active: false, offsetX: 0, offsetY: 0 }
|
||||
|
||||
const onDragMove = (e: MouseEvent) => {
|
||||
if (!dragState.active) return
|
||||
windowPos.x = Math.max(0, Math.min(window.innerWidth - 120, e.clientX - dragState.offsetX))
|
||||
windowPos.y = Math.max(0, Math.min(window.innerHeight - 60, e.clientY - dragState.offsetY))
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragState.active = false
|
||||
document.removeEventListener('mousemove', onDragMove)
|
||||
document.removeEventListener('mouseup', onDragEnd)
|
||||
}
|
||||
|
||||
const onDragStart = (e: MouseEvent) => {
|
||||
dragState.active = true
|
||||
dragState.offsetX = e.clientX - windowPos.x
|
||||
dragState.offsetY = e.clientY - windowPos.y
|
||||
document.addEventListener('mousemove', onDragMove)
|
||||
document.addEventListener('mouseup', onDragEnd)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const openPanel = async () => {
|
||||
if (visible.value) return
|
||||
windowPos.x = Math.max(16, Math.floor((window.innerWidth - WINDOW_WIDTH) / 2))
|
||||
windowPos.y = Math.max(16, Math.floor(window.innerHeight * 0.12))
|
||||
visible.value = true
|
||||
// 先预热模型,并等面板先画出「正在打开摄像头」再启预览,避免首开卡死
|
||||
if (advancedSupported) {
|
||||
preloadBeautyModel()
|
||||
await nextTick()
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
void startPreview()
|
||||
}
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
stopPreview()
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 基础美颜:下发到 TUICallEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
let applyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* 基础美颜下发到 TUICallEngine(TRTC BasicBeauty 插件)。
|
||||
* 仅作为 WebGL 不可用时的回退——正常情况下全部效果走本地管线。
|
||||
* WebGL 可用时始终向引擎下发全 0,确保引擎侧插件保持关闭,
|
||||
* 不会与本地管线叠加,也保证「关闭美颜」时引擎侧残留效果被清掉。
|
||||
*/
|
||||
const applyNow = async () => {
|
||||
if (advancedSupported) {
|
||||
await applyBeautyToEngine(props.getEngine(), { ...settings, enabled: false })
|
||||
return
|
||||
}
|
||||
await applyBeautyToEngine(props.getEngine(), settings)
|
||||
}
|
||||
|
||||
/** 通话建立后调用:仅在启用了美颜时下发,避免无意义地拉起插件 */
|
||||
const applyIfEnabled = async () => {
|
||||
if (!settings.enabled) return
|
||||
await applyNow()
|
||||
}
|
||||
|
||||
watch(
|
||||
settings,
|
||||
() => {
|
||||
saveBeautySettings(settings)
|
||||
// 全部美颜参数对活跃管线(含预览)实时生效
|
||||
updateActiveBeautyPipelines({ ...settings, advanced: { ...settings.advanced } })
|
||||
if (settings.advanced.enabled) preloadBeautyModel()
|
||||
if (applyTimer) clearTimeout(applyTimer)
|
||||
// 滑杆拖动会高频触发,防抖后再下发到引擎(仅 WebGL 回退场景实际生效)
|
||||
applyTimer = setTimeout(() => {
|
||||
applyTimer = null
|
||||
void applyNow()
|
||||
}, 200)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 效果预览:本地摄像头 → 同一条美颜管线 → 大画面播放,不发起任何通话
|
||||
// ---------------------------------------------------------------------------
|
||||
const previewing = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref('')
|
||||
const previewVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const previewStageRef = ref<HTMLElement | null>(null)
|
||||
let previewPipeline: FaceBeautyPipeline | null = null
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen().catch(() => {})
|
||||
} else {
|
||||
void previewStageRef.value?.requestFullscreen().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const startPreview = async () => {
|
||||
if (previewing.value || previewLoading.value) return
|
||||
previewError.value = ''
|
||||
previewLoading.value = true
|
||||
try {
|
||||
// 720p 采集保证预览清晰;管线内部会做适度降采样控制处理开销
|
||||
const raw = await getRawUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 30 }
|
||||
},
|
||||
audio: false
|
||||
})
|
||||
const track = raw.getVideoTracks()[0]
|
||||
if (!track) throw new Error('未获取到摄像头画面')
|
||||
|
||||
// 让出一帧,保证 loading 文案先渲染
|
||||
await nextTick()
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
previewPipeline = new FaceBeautyPipeline(
|
||||
track,
|
||||
{ ...settings, advanced: { ...settings.advanced } },
|
||||
{ isPreview: true }
|
||||
)
|
||||
const processedTrack = await previewPipeline.start()
|
||||
|
||||
previewing.value = true
|
||||
await nextTick()
|
||||
if (previewVideoRef.value) {
|
||||
previewVideoRef.value.srcObject = new MediaStream([processedTrack])
|
||||
await previewVideoRef.value.play().catch(() => {})
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[call-beauty] 预览启动失败', err)
|
||||
previewPipeline?.destroy()
|
||||
previewPipeline = null
|
||||
previewing.value = false
|
||||
previewError.value =
|
||||
err?.name === 'NotAllowedError'
|
||||
? '摄像头权限被拒绝,请在浏览器地址栏允许摄像头访问'
|
||||
: err?.name === 'NotReadableError'
|
||||
? '摄像头被其他程序占用,无法打开预览'
|
||||
: '预览启动失败,请检查摄像头设备'
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopPreview = () => {
|
||||
if (document.fullscreenElement && document.fullscreenElement === previewStageRef.value) {
|
||||
void document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
if (previewVideoRef.value) previewVideoRef.value.srcObject = null
|
||||
// destroy 会同时停掉输出轨和源摄像头轨,释放摄像头占用
|
||||
previewPipeline?.destroy()
|
||||
previewPipeline = null
|
||||
previewing.value = false
|
||||
previewLoading.value = false
|
||||
}
|
||||
|
||||
const togglePreview = () => {
|
||||
if (previewing.value) {
|
||||
stopPreview()
|
||||
} else {
|
||||
void startPreview()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults = defaultBeautySettings()
|
||||
Object.assign(settings.advanced, defaults.advanced, { enabled: settings.advanced.enabled })
|
||||
Object.assign(settings, {
|
||||
style: defaults.style,
|
||||
beautyLevel: defaults.beautyLevel,
|
||||
whitenessLevel: defaults.whitenessLevel,
|
||||
ruddinessLevel: defaults.ruddinessLevel,
|
||||
spotLevel: defaults.spotLevel,
|
||||
moleLevel: defaults.moleLevel,
|
||||
darkCircleLevel: defaults.darkCircleLevel,
|
||||
brightnessLevel: defaults.brightnessLevel,
|
||||
clarityLevel: defaults.clarityLevel
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (applyTimer) clearTimeout(applyTimer)
|
||||
stopPreview()
|
||||
onDragEnd()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
applyNow,
|
||||
applyIfEnabled,
|
||||
openPanel
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.beauty-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.beauty-btn-text {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.beauty-window {
|
||||
position: fixed;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100vh - 24px);
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.beauty-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
|
||||
.beauty-window-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.beauty-window-close {
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
color: #ffd4d4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-window-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 820px) {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-preview-col {
|
||||
flex: 1 1 440px;
|
||||
min-width: 0;
|
||||
|
||||
.beauty-preview-stage {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
.beauty-preview-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* contain 完整显示画面,与实际发送给对方的内容一致,不裁切 */
|
||||
object-fit: contain;
|
||||
/* 本地自拍习惯镜像显示;实际发送给对方的画面不镜像 */
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.beauty-preview-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #8a8f99;
|
||||
font-size: 13px;
|
||||
padding: 0 24px;
|
||||
text-align: center;
|
||||
|
||||
.beauty-preview-error-text {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-preview-badge {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.beauty-fullscreen-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全屏时铺满屏幕 */
|
||||
&:fullscreen {
|
||||
aspect-ratio: auto;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
|
||||
.beauty-preview-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-control-col {
|
||||
flex: 0 0 300px;
|
||||
min-width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.beauty-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.beauty-section-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.beauty-help-icon {
|
||||
color: #a8abb2;
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-divider {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.beauty-section-body {
|
||||
&.is-disabled {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.beauty-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.beauty-label {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
|
||||
&.has-tip {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted #c0c4cc;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-slider {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.el-color-picker {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.beauty-item-hint {
|
||||
margin-top: -2px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.beauty-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.beauty-unsupported {
|
||||
padding: 4px 0 8px;
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.beauty-restart-hint {
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: #fdf6ec;
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.beauty-window-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
|
||||
.beauty-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -64,6 +64,20 @@
|
||||
>
|
||||
群视频
|
||||
</el-button>
|
||||
<!-- 美颜设置(免费基础美颜 + 本地 AI 瘦脸/口红/腮红),带大窗口实时预览;仅对开启了美颜功能的医生显示 -->
|
||||
<CallBeautyPanel
|
||||
v-if="isCallReady && beautyAllowed"
|
||||
ref="beautyPanelRef"
|
||||
:get-engine="getCallEngine"
|
||||
:popper-z-index="300002"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span style="margin-left: 2px">美颜</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</CallBeautyPanel>
|
||||
</div>
|
||||
</template>
|
||||
</MessageInput>
|
||||
@@ -127,7 +141,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Loading, Close, Rank, Camera, Fold, Expand } from '@element-plus/icons-vue'
|
||||
import { Loading, Close, Rank, Camera, Fold, Expand, MagicStick } from '@element-plus/icons-vue'
|
||||
import CallBeautyPanel from '@/components/call-beauty-panel/index.vue'
|
||||
import { uploadImageBlob, uploadVideoBlob } from '@/api/file'
|
||||
import { addDoctorNote } from '@/api/patient'
|
||||
import {
|
||||
@@ -187,6 +202,17 @@ const patientUserId = ref('')
|
||||
const conversationId = ref('')
|
||||
const isCallReady = ref(false)
|
||||
const showCallKitWindow = ref(false) // 仅在通话中显示视频窗口
|
||||
/** 美颜面板(本地 AI 管线 + 引擎兜底,非付费特效 SDK) */
|
||||
const beautyPanelRef = ref<InstanceType<typeof CallBeautyPanel> | null>(null)
|
||||
/** 医生编辑页「美颜功能」开关:关闭时不显示美颜入口 */
|
||||
const beautyAllowed = computed(() => userStore.userInfo?.enable_beauty !== 0)
|
||||
const getCallEngine = () => {
|
||||
try {
|
||||
return TUICallKitServer.getTUICallEngineInstance()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const { login, logout } = useLoginState()
|
||||
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
|
||||
const userStore = useUserStore()
|
||||
@@ -723,6 +749,8 @@ function setupCallRoomBinding() {
|
||||
}
|
||||
if (next === CALL_STATUS_CONNECTED) {
|
||||
void tryBindRoomIfReady()
|
||||
// 接通后重新下发已保存的美颜设置(摄像头此时已开启,插件可正常启动)
|
||||
void beautyPanelRef.value?.applyIfEnabled()
|
||||
clearLocalRecordingStartTimer()
|
||||
if (!isLochostVodEnabled.value) {
|
||||
previousCallStatus = next
|
||||
@@ -1091,6 +1119,10 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
showCallKitWindow.value = true
|
||||
pendingCallRecordStart = ensureCallRecordStarted()
|
||||
void pendingCallRecordStart
|
||||
// 呼叫阶段本地摄像头预览已开启,延迟下发美颜让医生在预览中即可看到效果
|
||||
setTimeout(() => {
|
||||
void beautyPanelRef.value?.applyIfEnabled()
|
||||
}, 1500)
|
||||
},
|
||||
// 须先 endCall(DeleteCloudRecording),再 await 本地上传;否则上传 WebM 耗时数分钟会拖住云端停录,控制台房间长期「尚未结束」
|
||||
afterCalling: () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
:end-placeholder="endPlaceholder"
|
||||
:value-format="valueFormat"
|
||||
clearable
|
||||
@change="emit('change', $event)"
|
||||
></el-date-picker>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +30,7 @@ const props = withDefaults(
|
||||
endPlaceholder: '结束时间'
|
||||
}
|
||||
)
|
||||
const emit = defineEmits(['update:startTime', 'update:endTime'])
|
||||
const emit = defineEmits(['update:startTime', 'update:endTime', 'change'])
|
||||
|
||||
const content = computed<any>({
|
||||
get: () => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<template v-if="variant === 'split'">
|
||||
<el-form-item :label="label" :class="{ 'is-compact': compact }">
|
||||
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
|
||||
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">本月</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="timeType === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="dateRangeModel"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:size="compact ? 'small' : 'default'"
|
||||
:style="{ width: pickerWidth }"
|
||||
@change="emit('custom-change')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else :label="label" :class="{ 'is-compact': compact }">
|
||||
<div class="list-time-filter-inline">
|
||||
<el-radio-group v-model="timeType" :size="compact ? 'small' : 'default'" @change="emit('time-type-change')">
|
||||
<el-radio-button v-if="showAll" label="all">全部</el-radio-button>
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">本月</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-date-picker
|
||||
v-if="timeType === 'custom'"
|
||||
v-model="dateRangeModel"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:size="compact ? 'small' : 'default'"
|
||||
:style="{ width: pickerWidth }"
|
||||
@change="emit('custom-change')"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ListTimeType } from '@/hooks/useListTimeFilter'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
/** split:与自录统计一致,两个 form-item;inline:处方订单筛选用单行 */
|
||||
variant?: 'split' | 'inline'
|
||||
pickerWidth?: string
|
||||
/** 紧凑模式:small 尺寸 radio / 日期 */
|
||||
compact?: boolean
|
||||
/** 是否显示「全部」(不限时间)选项 */
|
||||
showAll?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '时间范围',
|
||||
variant: 'split',
|
||||
pickerWidth: '260px',
|
||||
compact: false,
|
||||
showAll: false,
|
||||
}
|
||||
)
|
||||
|
||||
const timeType = defineModel<ListTimeType>('timeType', { required: true })
|
||||
const dateRangeModel = defineModel<string[]>('dateRange', { default: () => [] })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'time-type-change': []
|
||||
'custom-change': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.list-time-filter-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
|
||||
:deep(.is-compact.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -88,13 +88,17 @@
|
||||
共 <strong>{{ formData.herbs.length }}</strong> 味(主方 {{ mainHerbCount }} / 辅方
|
||||
{{ auxHerbCount }})· 可手动添加、从处方库或粘贴药方批量录入
|
||||
</p>
|
||||
<p v-if="herbsLocked" class="rp-toolbar__lock-tip">
|
||||
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
|
||||
含「禁用修改」处方库模板,药材已锁定,不可增删改,如需调整请重新「从处方库导入」覆盖
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rp-toolbar__actions" role="toolbar" aria-label="处方药材快捷操作">
|
||||
<el-button type="primary" size="small" :icon="CirclePlus" @click="handleAddHerb('主方')">
|
||||
<el-button type="primary" size="small" :icon="CirclePlus" :disabled="herbsLocked" @click="handleAddHerb('主方')">
|
||||
添加主方
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="handleAddHerb('辅方')">
|
||||
<el-button type="primary" plain size="small" :disabled="herbsLocked" @click="handleAddHerb('辅方')">
|
||||
添加辅方
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -135,7 +139,7 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -148,11 +152,12 @@
|
||||
:min="0.1"
|
||||
:step="1"
|
||||
:precision="1"
|
||||
:disabled="herbsLocked"
|
||||
placeholder="剂量(克)"
|
||||
class="herb-editor-card__dose"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">克</span>
|
||||
<el-button type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -171,7 +176,7 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">辅方</div>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" class="herb-editor-card__select" />
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -184,11 +189,12 @@
|
||||
:min="0.1"
|
||||
:step="1"
|
||||
:precision="1"
|
||||
:disabled="herbsLocked"
|
||||
placeholder="剂量(克)"
|
||||
class="herb-editor-card__dose"
|
||||
/>
|
||||
<span class="text-gray-500 shrink-0">克</span>
|
||||
<el-button type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||
<el-button v-if="!herbsLocked" type="danger" size="small" link @click="handleRemoveHerb(row.index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -553,7 +559,11 @@
|
||||
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
||||
{{
|
||||
prescriptionTabType === 'user'
|
||||
? '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
|
||||
: '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
|
||||
}}
|
||||
</span>
|
||||
<span class="rx-notice-meta">
|
||||
<span>日期:{{ rxDateText }}</span>
|
||||
@@ -672,22 +682,30 @@
|
||||
</div>
|
||||
|
||||
<div class="rx-text">
|
||||
<p v-if="prescriptionTabType === 'internal' || savedAuxUsageText">主方服法:{{ rxUsageText }}</p>
|
||||
<p v-else>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="savedAuxUsageText">{{ prescriptionTabType === 'user' ? '辅服法' : '辅方服法' }}:{{ savedAuxUsageText }}</p>
|
||||
<template v-if="prescriptionTabType === 'internal'">
|
||||
<p v-if="savedAuxUsageText">主方服法:{{ rxUsageText }}</p>
|
||||
<p v-else>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="savedAuxUsageText">辅方服法:{{ savedAuxUsageText }}</p>
|
||||
</template>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
|
||||
忌口:{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('、') : savedPrescription.dietary_taboo }}
|
||||
</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">药房备注:{{ rxPharmacyRemarkText }}</p>
|
||||
<p v-if="rxOutPelletText && savedPrescription.prescription_type !== '饮片'" class="rx-text-warn">
|
||||
<p
|
||||
v-if="prescriptionTabType === 'internal' && rxOutPelletText && savedPrescription.prescription_type !== '饮片'"
|
||||
class="rx-text-warn"
|
||||
>
|
||||
出丸:{{ rxOutPelletText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rx-bottom">
|
||||
<div class="rx-bot-row rx-bot-row-1">
|
||||
<div
|
||||
class="rx-bot-row rx-bot-row-1"
|
||||
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
|
||||
>
|
||||
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
|
||||
<div class="rx-bot-label">医师</div>
|
||||
<div class="rx-bot-doctor-body">
|
||||
@@ -697,10 +715,12 @@
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<div class="rx-bot-doctor-name">{{ savedPrescription.doctor_name || '—' }}</div>
|
||||
<div v-if="!savedPrescription.doctor_signature" class="rx-bot-doctor-name">
|
||||
{{ savedPrescription.doctor_name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">类型:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||
</div>
|
||||
@@ -708,7 +728,7 @@
|
||||
<span class="rx-bot-meta-key">天数:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">剂量:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||
</div>
|
||||
@@ -1028,6 +1048,8 @@ interface Herb {
|
||||
name: string
|
||||
dosage: number
|
||||
formula_type?: FormulaType
|
||||
/** 来自「禁用修改」处方库模板:锁定后不可增删改,只能再次导入覆盖 */
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
type AuxUsageForm = {
|
||||
@@ -1044,11 +1066,15 @@ function normalizeFormulaType(v: unknown): FormulaType {
|
||||
}
|
||||
|
||||
function normalizeHerbRow(raw: any): Herb {
|
||||
return {
|
||||
const row: Herb = {
|
||||
name: String(raw?.name ?? '').trim(),
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
if (raw?.locked === true || raw?.locked === 1) {
|
||||
row.locked = true
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
|
||||
@@ -1430,6 +1456,12 @@ const auxHerbRows = computed(() =>
|
||||
const mainHerbCount = computed(() => mainHerbRows.value.length)
|
||||
const auxHerbCount = computed(() => auxHerbRows.value.length)
|
||||
|
||||
/**
|
||||
* 整张处方是否被锁定:只要任一药材来自「禁用修改」模板即锁定。
|
||||
* 锁定后:不可增删改任意药材,仅可再次「从处方库导入」覆盖。
|
||||
*/
|
||||
const herbsLocked = computed(() => formData.herbs.some((h) => (h as Herb).locked === true))
|
||||
|
||||
const libraryDoctorId = computed(() => {
|
||||
const uid = Number(userStore.userInfo?.id)
|
||||
return uid > 0 ? uid : 0
|
||||
@@ -2029,10 +2061,18 @@ function findDuplicateHerbNames(): string[] {
|
||||
}
|
||||
|
||||
const handleAddHerb = (formulaType: FormulaType = '主方') => {
|
||||
if (herbsLocked.value) {
|
||||
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
|
||||
return
|
||||
}
|
||||
formData.herbs.push({ name: '', dosage: 6, formula_type: formulaType })
|
||||
}
|
||||
|
||||
const handleRemoveHerb = (index: number) => {
|
||||
if (herbsLocked.value) {
|
||||
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
|
||||
return
|
||||
}
|
||||
formData.herbs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -2089,10 +2129,18 @@ const handleImportLibrary = (row: any) => {
|
||||
}
|
||||
|
||||
const formulaType = normalizeFormulaType(row.formula_type)
|
||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
|
||||
...normalizeHerbRow(h),
|
||||
formula_type: formulaType
|
||||
}))
|
||||
// 「禁用修改」模板:导入的药材打上锁定标记 → 整张处方锁定(不可增删改,只能再次导入覆盖)
|
||||
const isLockedTemplate = Number(row?.disable_edit) === 1
|
||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
|
||||
const herb = normalizeHerbRow(h)
|
||||
herb.formula_type = formulaType
|
||||
if (isLockedTemplate) {
|
||||
herb.locked = true
|
||||
} else {
|
||||
delete herb.locked
|
||||
}
|
||||
return herb
|
||||
})
|
||||
if (libraryImportMode.value === 'replace') {
|
||||
const kept = formData.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||
formData.herbs = [...kept, ...imported]
|
||||
@@ -2532,6 +2580,17 @@ defineExpose({
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.rp-toolbar__lock-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.rp-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2925,6 +2984,10 @@ defineExpose({
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.rx-bot-row-1--user {
|
||||
grid-template-columns: 1.4fr 0.6fr;
|
||||
}
|
||||
|
||||
.rx-hospital-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -55,6 +55,20 @@
|
||||
|
||||
<!-- 窗口底部操作栏 -->
|
||||
<div class="window-footer">
|
||||
<!-- 美颜设置(本地 AI 管线,免费);仅对开启了美颜功能的医生显示 -->
|
||||
<CallBeautyPanel
|
||||
v-if="isInitialized && !callRejected && beautyAllowed"
|
||||
ref="beautyPanelRef"
|
||||
:get-engine="getCallEngine"
|
||||
:popper-z-index="2100"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span style="margin-left: 2px">美颜</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</CallBeautyPanel>
|
||||
<el-button
|
||||
@click="handleClose"
|
||||
:disabled="calling && !callRejected"
|
||||
@@ -92,11 +106,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, nextTick, computed } from 'vue'
|
||||
import { VideoCamera, CircleClose, Phone } from '@element-plus/icons-vue'
|
||||
import { VideoCamera, CircleClose, Phone, MagicStick } from '@element-plus/icons-vue'
|
||||
import { getCallSignature, endCall } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { formatTUICallUserError } from '@/utils/tuicall-error'
|
||||
import { TUICallKitAPI, TUICallKit, TUICallType, STATUS } from '@trtc/calls-uikit-vue'
|
||||
import CallBeautyPanel from '@/components/call-beauty-panel/index.vue'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
interface CallInfo {
|
||||
diagnosisId: number
|
||||
@@ -124,6 +140,18 @@ const callInfo = ref<CallInfo>({
|
||||
isGroup: false
|
||||
})
|
||||
|
||||
/** 美颜面板(本地 AI 管线 + 引擎兜底,非付费特效 SDK) */
|
||||
const beautyPanelRef = ref<InstanceType<typeof CallBeautyPanel> | null>(null)
|
||||
/** 医生编辑页「美颜功能」开关:关闭时不显示美颜入口 */
|
||||
const beautyAllowed = computed(() => useUserStore().userInfo?.enable_beauty !== 0)
|
||||
const getCallEngine = () => {
|
||||
try {
|
||||
return TUICallKitAPI.getTUICallEngineInstance()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口位置和大小
|
||||
const windowRef = ref<HTMLElement>()
|
||||
const windowPosition = ref({ x: 100, y: 100 })
|
||||
@@ -233,6 +261,8 @@ const handleStatusChange = ({ oldStatus, newStatus }: any) => {
|
||||
shouldAutoClose.value = false // 通话接通时,不自动关闭
|
||||
statusText.value = '通话中...'
|
||||
feedback.msgSuccess('通话已接通')
|
||||
// 接通后重新下发已保存的美颜设置(摄像头此时已开启,插件可正常启动)
|
||||
void beautyPanelRef.value?.applyIfEnabled()
|
||||
|
||||
// 如果是多人通话且还有其他用户需要邀请
|
||||
if (callInfo.value.isGroup && callInfo.value.userIds && callInfo.value.userIds.length > 1) {
|
||||
@@ -247,6 +277,10 @@ const handleStatusChange = ({ oldStatus, newStatus }: any) => {
|
||||
calling.value = true
|
||||
shouldAutoClose.value = false
|
||||
statusText.value = '等待对方接听...'
|
||||
// 呼叫阶段本地摄像头预览已开启,延迟下发美颜让医生在预览中即可看到效果
|
||||
setTimeout(() => {
|
||||
void beautyPanelRef.value?.applyIfEnabled()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 通话结束,回到空闲状态
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* getUserMedia 拦截器:给通话视频流透明注入本地 AI 高级美颜
|
||||
*
|
||||
* TUICallKit / TRTC SDK 申请摄像头时走 navigator.mediaDevices.getUserMedia,
|
||||
* 这里在其外层包一层:拿到真实摄像头流后,若高级美颜已开启,则把视频轨送入
|
||||
* FaceBeautyPipeline 处理,返回处理后的流。SDK 对此无感知,无需改动任何 SDK 代码。
|
||||
*
|
||||
* 仅处理"摄像头视频"请求;纯音频、屏幕共享(getDisplayMedia)不受影响。
|
||||
* 管线创建失败时始终回退原始流,不会因美颜故障导致通话不可用。
|
||||
*/
|
||||
import { hasAnyBeautyEffect, loadBeautySettings } from '@/utils/call-beauty'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import {
|
||||
FaceBeautyPipeline,
|
||||
getFaceLandmarker,
|
||||
isAdvancedBeautySupported
|
||||
} from './face-beauty-pipeline'
|
||||
|
||||
/**
|
||||
* 当前登录医生是否被允许使用美颜(la_admin.enable_beauty,医生编辑页配置)。
|
||||
* 字段缺失(旧数据/旧接口)时视为允许,与后端默认值保持一致。
|
||||
*/
|
||||
export function isBeautyAllowedForCurrentUser(): boolean {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
return userStore.userInfo?.enable_beauty !== 0
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let installed = false
|
||||
let originalGetUserMedia: ((constraints?: MediaStreamConstraints) => Promise<MediaStream>) | null =
|
||||
null
|
||||
|
||||
/**
|
||||
* 绕过拦截器直接获取原始摄像头流。
|
||||
* 供美颜预览使用:预览自己管理管线,避免被拦截器再包一层造成双重处理。
|
||||
*/
|
||||
export function getRawUserMedia(constraints?: MediaStreamConstraints): Promise<MediaStream> {
|
||||
if (originalGetUserMedia) return originalGetUserMedia(constraints)
|
||||
return navigator.mediaDevices.getUserMedia(constraints)
|
||||
}
|
||||
|
||||
async function wrapStreamWithBeauty(stream: MediaStream): Promise<MediaStream> {
|
||||
const videoTrack = stream.getVideoTracks()[0]
|
||||
if (!videoTrack) return stream
|
||||
|
||||
const settings = loadBeautySettings()
|
||||
const pipeline = new FaceBeautyPipeline(videoTrack, settings)
|
||||
const processedTrack = await pipeline.start()
|
||||
|
||||
// SDK 停用处理后的轨道时,同步销毁管线并释放真实摄像头
|
||||
const originalStop = processedTrack.stop.bind(processedTrack)
|
||||
processedTrack.stop = () => {
|
||||
originalStop()
|
||||
pipeline.destroy()
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
}
|
||||
|
||||
return new MediaStream([processedTrack, ...stream.getAudioTracks()])
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装拦截器(幂等)。需在 SDK 发起摄像头请求前调用——
|
||||
* 目前由美颜面板组件挂载时触发,早于任何通话开始。
|
||||
*/
|
||||
export function installBeautyMediaInterceptor() {
|
||||
if (installed) return
|
||||
if (!navigator.mediaDevices?.getUserMedia) return
|
||||
installed = true
|
||||
|
||||
const original = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices)
|
||||
originalGetUserMedia = original
|
||||
|
||||
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
|
||||
const stream = await original(constraints)
|
||||
if (!constraints?.video) return stream
|
||||
|
||||
const settings = loadBeautySettings()
|
||||
if (
|
||||
!isBeautyAllowedForCurrentUser() ||
|
||||
!hasAnyBeautyEffect(settings) ||
|
||||
!isAdvancedBeautySupported()
|
||||
) {
|
||||
return stream
|
||||
}
|
||||
|
||||
try {
|
||||
return await wrapStreamWithBeauty(stream)
|
||||
} catch (err) {
|
||||
console.warn('[face-beauty] 美颜管线创建失败,使用原始摄像头画面', err)
|
||||
return stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 高级美颜开启时提前预热模型(约 4MB,加载一次全局复用),避免首帧卡顿 */
|
||||
export function preloadBeautyModel() {
|
||||
if (!isAdvancedBeautySupported()) return
|
||||
getFaceLandmarker().catch(() => {
|
||||
// 预热失败不打扰用户,实际使用时还会重试并有兜底
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 视频通话美颜设置
|
||||
*
|
||||
* 全部效果(磨皮/美白/红润/祛斑/去痣/去黑眼圈/亮度/清晰度/瘦脸/口红/腮红)默认由本地管线处理:
|
||||
* MediaPipe Face Landmarker(开源免费)识别人脸关键点 + WebGL/Canvas 逐帧渲染,
|
||||
* 经 getUserMedia 拦截注入通话视频流(见 utils/beauty/ 目录),预览所见即所得。
|
||||
*
|
||||
* 仅当浏览器不支持 WebGL 时,磨皮/美白/红润回退到腾讯云 TRTC 免费 BasicBeauty
|
||||
* 插件(TUICallEngine.setBeautyLevel)。不依赖付费的腾讯特效 SDK。
|
||||
*/
|
||||
|
||||
/** 高级美颜(本地 AI 处理)设置 */
|
||||
export interface AdvancedBeautySettings {
|
||||
/** 总开关;通话中途开启需重新开关摄像头才能接管视频流 */
|
||||
enabled: boolean
|
||||
/** 瘦脸强度 0-100 */
|
||||
slimStrength: number
|
||||
/** 口红浓度 0-100(0 为关闭) */
|
||||
lipstickStrength: number
|
||||
/** 口红颜色(hex) */
|
||||
lipstickColor: string
|
||||
/** 腮红浓度 0-100(0 为关闭) */
|
||||
blushStrength: number
|
||||
/** 腮红颜色(hex) */
|
||||
blushColor: string
|
||||
}
|
||||
|
||||
export interface CallBeautySettings {
|
||||
/** 是否启用基础美颜(关闭时以全 0 参数下发,引擎会停用 BasicBeauty 插件) */
|
||||
enabled: boolean
|
||||
/** 磨皮算法:0 光滑(TRTCBeautyStyleSmooth)/ 1 自然(TRTCBeautyStyleNature) */
|
||||
style: number
|
||||
/** 磨皮级别 0-9 */
|
||||
beautyLevel: number
|
||||
/** 美白级别 0-9 */
|
||||
whitenessLevel: number
|
||||
/** 红润级别 0-9 */
|
||||
ruddinessLevel: number
|
||||
/** 祛斑 / 淡化黑色素 0-9(仅人脸,压暗斑点向周围肤色靠拢) */
|
||||
spotLevel: number
|
||||
/** 去痣 0-9(仅人脸,强力淡化小面积深色痣点) */
|
||||
moleLevel: number
|
||||
/** 去黑眼圈 0-9(仅眼下区域提亮淡化) */
|
||||
darkCircleLevel: number
|
||||
/** 摄像头亮度 0-9(整帧提亮,0 为原亮度) */
|
||||
brightnessLevel: number
|
||||
/** 清晰度 0-9(锐化细节,0 为关闭) */
|
||||
clarityLevel: number
|
||||
/** 高级美颜(瘦脸/口红/腮红,本地 AI) */
|
||||
advanced: AdvancedBeautySettings
|
||||
}
|
||||
|
||||
export const BEAUTY_STYLE_SMOOTH = 0
|
||||
export const BEAUTY_STYLE_NATURE = 1
|
||||
export const BEAUTY_LEVEL_MAX = 9
|
||||
|
||||
const STORAGE_KEY = 'tcm_call_beauty_settings'
|
||||
|
||||
export const defaultAdvancedBeautySettings = (): AdvancedBeautySettings => ({
|
||||
enabled: false,
|
||||
slimStrength: 30,
|
||||
lipstickStrength: 0,
|
||||
lipstickColor: '#c94f5e',
|
||||
blushStrength: 0,
|
||||
blushColor: '#e88193'
|
||||
})
|
||||
|
||||
export const defaultBeautySettings = (): CallBeautySettings => ({
|
||||
enabled: false,
|
||||
style: BEAUTY_STYLE_NATURE,
|
||||
beautyLevel: 5,
|
||||
whitenessLevel: 3,
|
||||
ruddinessLevel: 2,
|
||||
spotLevel: 0,
|
||||
moleLevel: 0,
|
||||
darkCircleLevel: 0,
|
||||
brightnessLevel: 0,
|
||||
clarityLevel: 0,
|
||||
advanced: defaultAdvancedBeautySettings()
|
||||
})
|
||||
|
||||
const clampLevel = (value: unknown): number => {
|
||||
const num = Math.round(Number(value))
|
||||
if (!Number.isFinite(num)) return 0
|
||||
return Math.min(BEAUTY_LEVEL_MAX, Math.max(0, num))
|
||||
}
|
||||
|
||||
const clampPercent = (value: unknown, fallback: number): number => {
|
||||
const num = Math.round(Number(value))
|
||||
if (!Number.isFinite(num)) return fallback
|
||||
return Math.min(100, Math.max(0, num))
|
||||
}
|
||||
|
||||
const normalizeHexColor = (value: unknown, fallback: string): string => {
|
||||
if (typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value)) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 高级美颜是否有任一效果实际开启 */
|
||||
export function hasAnyAdvancedEffect(advanced: AdvancedBeautySettings): boolean {
|
||||
return (
|
||||
advanced.enabled &&
|
||||
(advanced.slimStrength > 0 || advanced.lipstickStrength > 0 || advanced.blushStrength > 0)
|
||||
)
|
||||
}
|
||||
|
||||
/** 全部美颜(基础 + 高级)是否有任一效果实际开启(决定是否接管摄像头流) */
|
||||
export function hasAnyBeautyEffect(settings: CallBeautySettings): boolean {
|
||||
const basicOn =
|
||||
settings.enabled &&
|
||||
(settings.beautyLevel > 0 ||
|
||||
settings.whitenessLevel > 0 ||
|
||||
settings.ruddinessLevel > 0 ||
|
||||
settings.spotLevel > 0 ||
|
||||
settings.moleLevel > 0 ||
|
||||
settings.darkCircleLevel > 0 ||
|
||||
settings.brightnessLevel > 0 ||
|
||||
settings.clarityLevel > 0)
|
||||
return basicOn || hasAnyAdvancedEffect(settings.advanced)
|
||||
}
|
||||
|
||||
export function loadBeautySettings(): CallBeautySettings {
|
||||
const defaults = defaultBeautySettings()
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return defaults
|
||||
const parsed = JSON.parse(raw)
|
||||
const advDefaults = defaults.advanced
|
||||
const adv = parsed?.advanced ?? {}
|
||||
return {
|
||||
enabled: Boolean(parsed?.enabled),
|
||||
style:
|
||||
parsed?.style === BEAUTY_STYLE_SMOOTH
|
||||
? BEAUTY_STYLE_SMOOTH
|
||||
: BEAUTY_STYLE_NATURE,
|
||||
beautyLevel: clampLevel(parsed?.beautyLevel ?? defaults.beautyLevel),
|
||||
whitenessLevel: clampLevel(parsed?.whitenessLevel ?? defaults.whitenessLevel),
|
||||
ruddinessLevel: clampLevel(parsed?.ruddinessLevel ?? defaults.ruddinessLevel),
|
||||
spotLevel: clampLevel(parsed?.spotLevel ?? defaults.spotLevel),
|
||||
moleLevel: clampLevel(parsed?.moleLevel ?? defaults.moleLevel),
|
||||
darkCircleLevel: clampLevel(parsed?.darkCircleLevel ?? defaults.darkCircleLevel),
|
||||
brightnessLevel: clampLevel(parsed?.brightnessLevel ?? defaults.brightnessLevel),
|
||||
clarityLevel: clampLevel(parsed?.clarityLevel ?? defaults.clarityLevel),
|
||||
advanced: {
|
||||
enabled: Boolean(adv?.enabled),
|
||||
slimStrength: clampPercent(adv?.slimStrength, advDefaults.slimStrength),
|
||||
lipstickStrength: clampPercent(adv?.lipstickStrength, advDefaults.lipstickStrength),
|
||||
lipstickColor: normalizeHexColor(adv?.lipstickColor, advDefaults.lipstickColor),
|
||||
blushStrength: clampPercent(adv?.blushStrength, advDefaults.blushStrength),
|
||||
blushColor: normalizeHexColor(adv?.blushColor, advDefaults.blushColor)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
export function saveBeautySettings(settings: CallBeautySettings) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch {
|
||||
// localStorage 不可用时静默忽略,仅本次会话生效
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将美颜设置应用到 TUICallEngine 实例。
|
||||
* 引擎内部对 setBeautyLevel 自带 try-catch(摄像头未开启等场景不会抛错),
|
||||
* 这里只兜底引擎不存在 / 接口缺失的情况。
|
||||
*/
|
||||
export async function applyBeautyToEngine(
|
||||
engine: any,
|
||||
settings: CallBeautySettings
|
||||
): Promise<boolean> {
|
||||
if (!engine || typeof engine.setBeautyLevel !== 'function') {
|
||||
return false
|
||||
}
|
||||
const enabled = settings.enabled
|
||||
try {
|
||||
await engine.setBeautyLevel({
|
||||
style: settings.style === BEAUTY_STYLE_SMOOTH ? BEAUTY_STYLE_SMOOTH : BEAUTY_STYLE_NATURE,
|
||||
beautyLevel: enabled ? clampLevel(settings.beautyLevel) : 0,
|
||||
whitenessLevel: enabled ? clampLevel(settings.whitenessLevel) : 0,
|
||||
ruddinessLevel: enabled ? clampLevel(settings.ruddinessLevel) : 0
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[call-beauty] 应用美颜设置失败', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,12 @@
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
<span v-if="selectedIds.length > 0" class="text-sm text-gray-500">已选 {{ selectedIds.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||
@@ -42,7 +46,8 @@
|
||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<el-table :data="tableData" v-loading="loading" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="title" label="标题" min-width="80" />
|
||||
<el-table-column label="预览" width="100">
|
||||
@@ -92,7 +97,10 @@
|
||||
<el-input v-model="formData.title" placeholder="请输入资源标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
|
||||
<material-picker v-if="formData.type === 1" v-model="formData.file_url" :limit="1" type="image" />
|
||||
<div v-if="formData.type === 1" class="w-full">
|
||||
<material-picker v-model="formData.file_url" :limit="9" type="image" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">支持批量上传多张图片,一次最多 9 张,每张图片将生成一条独立资源(多张时标题自动追加序号)</div>
|
||||
</div>
|
||||
<upload v-else-if="formData.type === 2" type="video" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||
<el-button type="primary">上传视频 (OSS直传)</el-button>
|
||||
</upload>
|
||||
@@ -126,8 +134,9 @@ import MaterialPicker from '@/components/material/picker.vue'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const tableData = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const searchData = reactive<any>({
|
||||
title: '',
|
||||
@@ -156,6 +165,15 @@ const formData = reactive<any>({
|
||||
user_ids: []
|
||||
})
|
||||
|
||||
const validateFileUrl = (_rule: any, value: any, callback: any) => {
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return callback(new Error('请上传资源文件'))
|
||||
} else if (!value) {
|
||||
return callback(new Error('请输入或上传文件获取链接'))
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const computedRules = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return {
|
||||
@@ -164,7 +182,7 @@ const computedRules = computed(() => {
|
||||
}
|
||||
return {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
file_url: [{ required: true, message: '请输入或上传文件获取链接', trigger: 'blur' }]
|
||||
file_url: [{ required: true, validator: validateFileUrl, trigger: 'change' }]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,6 +204,7 @@ const getList = async () => {
|
||||
const res = await apiAssetResourceList(buildQueryParams())
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
selectedIds.value = []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
@@ -257,6 +276,22 @@ const handleDelete = async (row: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedIds.value = rows.map((row) => Number(row.id)).filter((id) => id > 0)
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (!selectedIds.value.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个资源吗?用户将无法再看到。`, '提示', { type: 'warning' })
|
||||
await apiAssetResourceDelete({ id: selectedIds.value })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
|
||||
@@ -27,6 +27,20 @@
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column label="备注" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<span class="truncate text-gray-600" :title="row.remark || ''">
|
||||
{{ row.remark || '—' }}
|
||||
</span>
|
||||
<el-tooltip content="编辑备注" placement="top">
|
||||
<el-icon class="shrink-0 cursor-pointer text-gray-400 hover:text-primary" @click="openQuickRemark(row)">
|
||||
<Edit />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -70,6 +84,16 @@
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="可填写用户相关信息,仅管理员可见"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
@@ -79,12 +103,30 @@
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 快捷备注 -->
|
||||
<el-dialog v-model="remarkDialogVisible" title="快捷备注" width="420px" destroy-on-close>
|
||||
<div class="text-sm text-gray-500 mb-3">账号:{{ remarkTargetPhone }}</div>
|
||||
<el-input
|
||||
v-model="remarkDraft"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="可填写用户相关信息,仅管理员可见"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="remarkDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="remarkSaving" @click="saveQuickRemark">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Edit } from '@element-plus/icons-vue'
|
||||
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -105,10 +147,17 @@ const dialogTitle = ref('')
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const remarkDialogVisible = ref(false)
|
||||
const remarkSaving = ref(false)
|
||||
const remarkDraft = ref('')
|
||||
const remarkTargetId = ref<number | string>('')
|
||||
const remarkTargetPhone = ref('')
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
remark: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
@@ -154,16 +203,41 @@ const handleStatusChange = async (row: any) => {
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = '新增账号'
|
||||
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
|
||||
Object.assign(formData, { id: '', phone: '', password: '', remark: '', status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑账号'
|
||||
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
|
||||
Object.assign(formData, { id: row.id, phone: row.phone, password: '', remark: row.remark || '', status: row.status })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openQuickRemark = (row: any) => {
|
||||
remarkTargetId.value = row.id
|
||||
remarkTargetPhone.value = row.phone || ''
|
||||
remarkDraft.value = row.remark || ''
|
||||
remarkDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveQuickRemark = async () => {
|
||||
if (!remarkTargetId.value) return
|
||||
remarkSaving.value = true
|
||||
try {
|
||||
await apiAssetUserEdit({ id: remarkTargetId.value, remark: remarkDraft.value })
|
||||
const target = tableData.value.find((item: any) => item.id === remarkTargetId.value)
|
||||
if (target) {
|
||||
target.remark = remarkDraft.value
|
||||
}
|
||||
ElMessage.success('备注已保存')
|
||||
remarkDialogVisible.value = false
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
remarkSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
|
||||
|
||||
@@ -146,6 +146,19 @@
|
||||
<span class="ml-2 text-gray-500">{{ formData.enable_charge === 1 ? '已开启' : '已关闭' }}</span>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 美颜功能 -->
|
||||
<el-form-item label="美颜功能">
|
||||
<div>
|
||||
<el-switch
|
||||
v-model="formData.enable_beauty"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
<span class="ml-2 text-gray-500">{{ formData.enable_beauty === 1 ? '已开启' : '已关闭' }}</span>
|
||||
<div class="form-tips">开启后该医生视频通话时可配置美颜(磨皮/美白/瘦脸/口红等);关闭则不显示美颜入口</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 所属科室 -->
|
||||
<el-form-item label="所属科室" prop="department">
|
||||
<el-select v-model="formData.department" placeholder="请选择所属科室" clearable class="flex-1">
|
||||
@@ -295,6 +308,7 @@ const formData = reactive({
|
||||
enable_image_consult: 1, // 是否开启图文问诊
|
||||
enable_video_consult: 1, // 是否开启视频问诊
|
||||
enable_charge: 0, // 是否开启收费
|
||||
enable_beauty: 1, // 是否开启美颜功能
|
||||
department: '', // 所属科室
|
||||
specialty: '', // 擅长领域
|
||||
education: '', // 教育背景
|
||||
@@ -436,6 +450,7 @@ const open = (type = 'add') => {
|
||||
enable_image_consult: 1,
|
||||
enable_video_consult: 1,
|
||||
enable_charge: 0,
|
||||
enable_beauty: 1,
|
||||
department: '',
|
||||
specialty: '',
|
||||
education: '',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* 处方业务订单——纯展示/格式化工具
|
||||
*
|
||||
* 唯一数据源:order_list.vue(处方业务订单)、PatientOrderList.vue(诊单业务订单 Tab)
|
||||
* 与 PrescriptionOrderDetailDrawer.vue(共享详情抽屉)共用,禁止在页面内重复定义同名函数,
|
||||
* 否则两个详情页面会再次出现文案/口径漂移。
|
||||
*/
|
||||
|
||||
/** 与诊间医助角色 ID 一致(server 角色表) */
|
||||
export const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
export function formatTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||
const d = new Date(v * 1000)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
export function formatMoney(v: unknown) {
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return '—'
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatOrderTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'string' && String(v).includes('-')) return String(v)
|
||||
return formatTime(v)
|
||||
}
|
||||
|
||||
export function feeTypeText(t: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '挂号费',
|
||||
2: '问诊费',
|
||||
3: '药品费用',
|
||||
4: '首付费用',
|
||||
5: '尾款费用',
|
||||
6: '其他费用',
|
||||
7: '全部费用',
|
||||
8: '驼奶费用'
|
||||
}
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
export function fulfillmentText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待双审通过',
|
||||
2: '待发货',
|
||||
3: '已完成',
|
||||
4: '已取消',
|
||||
5: '已发货',
|
||||
6: '已签收',
|
||||
7: '进行中',
|
||||
8: '暂不制药',
|
||||
9: '拒收',
|
||||
10: '退款',
|
||||
11: '保留药方',
|
||||
12: '制药缓发'
|
||||
}
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
export function fulfillmentTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
const n = Number(s)
|
||||
if (n === 3) return 'success'
|
||||
if (n === 6) return 'success'
|
||||
if (n === 5) return 'primary'
|
||||
if (n === 4) return 'danger'
|
||||
if (n === 2) return 'warning'
|
||||
if (n === 7) return 'warning'
|
||||
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||
if (n === 9 || n === 10) return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function orderStatusText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待支付',
|
||||
2: '已支付',
|
||||
3: '已取消',
|
||||
4: '已退款',
|
||||
5: '待审核'
|
||||
}
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
export function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 2) return 'success'
|
||||
if (n === 1) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function consumerRxAuditText(s: number | undefined) {
|
||||
const n = Number(s)
|
||||
if (n === 1) return '已通过'
|
||||
if (n === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
export function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 1) return 'success'
|
||||
if (n === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
export function expressCompanyLabel(v: unknown) {
|
||||
const s = String(v || '').toLowerCase()
|
||||
if (s === 'sf') return '顺丰速运'
|
||||
if (s === 'jd') return '京东快递'
|
||||
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||
return '自动识别'
|
||||
}
|
||||
|
||||
export function logActionText(act: string) {
|
||||
const m: Record<string, string> = {
|
||||
create: '创建',
|
||||
edit: '编辑',
|
||||
audit_rx_approve: '处方审核',
|
||||
audit_rx_reject: '处方审核',
|
||||
audit_pay_approve: '支付审核',
|
||||
audit_pay_reject: '支付审核',
|
||||
fill_tracking: '填快递单',
|
||||
ship: '确认发货',
|
||||
withdraw: '撤销',
|
||||
link_pay_order: '关联支付单',
|
||||
completion_request: '完单申请',
|
||||
auto_complete: '自动完成',
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
refund: '退款'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
|
||||
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
|
||||
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||
const createType = String(row?.create_type || '')
|
||||
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||
if (createType === 'fubei') return '付呗'
|
||||
if (createType === 'express_cod') return '快递代收'
|
||||
const paymentMethod = String(row?.payment_method || '')
|
||||
const methodMap: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信',
|
||||
wechat_work: '企业微信',
|
||||
fubei: '付呗',
|
||||
express_cod: '快递代收',
|
||||
manual: '手动确认到账'
|
||||
}
|
||||
if (paymentMethod && methodMap[paymentMethod]) {
|
||||
return methodMap[paymentMethod]
|
||||
}
|
||||
return '普通订单'
|
||||
}
|
||||
|
||||
export function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
}
|
||||
|
||||
export function recipientVsPrescriptionPhoneMismatch(recipient: unknown, rxPhone: unknown): boolean {
|
||||
const a = normalizeBizPhone(recipient)
|
||||
const b = normalizeBizPhone(rxPhone)
|
||||
if (!a || !b) return false
|
||||
return a !== b
|
||||
}
|
||||
|
||||
export function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((x: any) => ({
|
||||
name: String(x?.name ?? '').trim(),
|
||||
dosage: Number(x?.dosage) || 0
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export type SlipFormulaType = '主方' | '辅方'
|
||||
|
||||
export interface SlipAuxUsageForm {
|
||||
dosage_amount?: number
|
||||
dosage_bag_count: number
|
||||
need_decoction: boolean
|
||||
bags_per_dose: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
}
|
||||
|
||||
export function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
|
||||
return v === '辅方' ? '辅方' : '主方'
|
||||
}
|
||||
|
||||
export function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
|
||||
if (prescriptionType === '饮片') {
|
||||
return {
|
||||
dosage_amount: 50,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
if (prescriptionType === '浓缩水丸') {
|
||||
return {
|
||||
dosage_amount: 5,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
return {
|
||||
dosage_amount: 1,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
|
||||
const base = defaultSlipAuxUsage(prescriptionType)
|
||||
if (!raw || typeof raw !== 'object') return { ...base }
|
||||
const o = raw as Record<string, unknown>
|
||||
return {
|
||||
dosage_amount:
|
||||
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
|
||||
? Number(o.dosage_amount)
|
||||
: base.dosage_amount,
|
||||
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
|
||||
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
|
||||
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
|
||||
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
|
||||
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否含辅方药材(仅 formula_type=辅方;医助无药材权限时 herbs 被剥离,用后端 has_aux_formula) */
|
||||
export function prescriptionHasAuxFormula(rx: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!rx) return false
|
||||
const herbs = rx.herbs
|
||||
if (Array.isArray(herbs)) {
|
||||
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
|
||||
}
|
||||
return Number(rx.has_aux_formula) === 1
|
||||
}
|
||||
|
||||
/** 物流轨迹中需人工立即跟进的常见异常关键词 */
|
||||
export const LOGISTICS_URGENT_KEYWORDS = [
|
||||
'拒收',
|
||||
'拒签',
|
||||
'退件',
|
||||
'客户拒收',
|
||||
'拦截退回',
|
||||
'退回发件',
|
||||
'派件退回',
|
||||
'无人签收',
|
||||
'退回快件'
|
||||
]
|
||||
|
||||
export function logisticsStringHasUrgentKeyword(s: unknown): boolean {
|
||||
const t = String(s ?? '')
|
||||
return LOGISTICS_URGENT_KEYWORDS.some((k) => t.includes(k))
|
||||
}
|
||||
|
||||
export function logisticsTraceLineUrgent(context: unknown): boolean {
|
||||
return logisticsStringHasUrgentKeyword(context)
|
||||
}
|
||||
|
||||
/** 根据单次拉取的物流 payload 判断是否含拒收/退回等(详情抽屉、完成订单弹窗共用) */
|
||||
export function analyzeLogisticsPayloadUrgent(p: Record<string, any> | null | undefined): {
|
||||
show: boolean
|
||||
keywords: string[]
|
||||
lines: string[]
|
||||
} {
|
||||
if (!p) {
|
||||
return { show: false, keywords: [], lines: [] }
|
||||
}
|
||||
const traces = Array.isArray(p.traces)
|
||||
? (p.traces as Array<{ time?: string; context?: string }>)
|
||||
: []
|
||||
const chunks: string[] = []
|
||||
if (p.state_text) chunks.push(String(p.state_text))
|
||||
if (p.hint) chunks.push(String(p.hint))
|
||||
for (const row of traces) {
|
||||
if (row.context) chunks.push(String(row.context))
|
||||
}
|
||||
const joined = chunks.join('\n')
|
||||
const keywords = LOGISTICS_URGENT_KEYWORDS.filter((k) => joined.includes(k))
|
||||
if (!keywords.length) {
|
||||
return { show: false, keywords: [], lines: [] }
|
||||
}
|
||||
const lines: string[] = []
|
||||
for (const row of traces) {
|
||||
const ctx = String(row.context || '')
|
||||
if (logisticsStringHasUrgentKeyword(ctx)) {
|
||||
lines.push(ctx)
|
||||
if (lines.length >= 5) break
|
||||
}
|
||||
}
|
||||
if (!lines.length && logisticsStringHasUrgentKeyword(p.state_text)) {
|
||||
lines.push(`最新状态:${p.state_text}`)
|
||||
}
|
||||
if (!lines.length && logisticsStringHasUrgentKeyword(p.hint)) {
|
||||
lines.push(String(p.hint))
|
||||
}
|
||||
return { show: true, keywords, lines }
|
||||
}
|
||||
|
||||
/** 校验物流轨迹响应与请求上下文匹配(防止快速切换订单时旧响应串单) */
|
||||
export function parseLogisticsTracePayload(
|
||||
res: unknown,
|
||||
expectedTrackingNumber: string,
|
||||
expectedOrderId: number
|
||||
): Record<string, any> | null {
|
||||
const raw = (res as { data?: unknown })?.data ?? res
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const payload = raw as Record<string, any>
|
||||
const respNum = String(payload.tracking_number || '').trim()
|
||||
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||
const respOrderId = Number(payload.order_id)
|
||||
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
export function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | null | undefined) {
|
||||
if (!row?.id) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4
|
||||
}
|
||||
|
||||
/** 处方忌口:库内逗号分隔字符串或前端多选数组,统一为「、」连接展示 */
|
||||
export function formatDietaryTaboo(raw: unknown): string {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((v) => String(v ?? '').trim()).filter(Boolean).join('、')
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join('、')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
<el-radio-button label="rejected">驳回</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="pl-toolbar__sep" aria-hidden="true" />
|
||||
<span class="pl-toolbar__k" title="与列表「来源」列一致:系统代开 / 手工">来源</span>
|
||||
<span class="pl-toolbar__k" title="与列表「来源」列一致:空白处方 / 手工">来源</span>
|
||||
<el-radio-group
|
||||
v-model="formData.source_filter"
|
||||
size="small"
|
||||
@@ -48,7 +48,7 @@
|
||||
>
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="manual">手工</el-radio-button>
|
||||
<el-radio-button label="system">系统代开</el-radio-button>
|
||||
<el-radio-button label="system">空白处方</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="pl-toolbar__line pl-toolbar__line--second">
|
||||
@@ -146,7 +146,7 @@
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
系统代开
|
||||
空白处方
|
||||
</el-tag>
|
||||
<span v-else class="text-gray-400 text-sm">手工</span>
|
||||
</template>
|
||||
@@ -290,7 +290,7 @@
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>系统代开</el-tag>
|
||||
>空白处方</el-tag>
|
||||
<el-tag v-if="slipView && Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
|
||||
<el-tag
|
||||
v-else-if="slipView && Number(slipView.business_prescription_audit_rejected) === 1"
|
||||
@@ -437,6 +437,7 @@
|
||||
<p>主方服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
@@ -458,7 +459,9 @@
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<div class="rx-bot-doctor-name">{{ slipView.doctor_name || '—' }}</div>
|
||||
<div v-if="!slipView.doctor_signature" class="rx-bot-doctor-name">
|
||||
{{ slipView.doctor_name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
@@ -679,17 +682,21 @@
|
||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="herbsLocked" class="flex items-center gap-2 flex-wrap mt-2 text-xs text-[var(--el-color-danger)]">
|
||||
<el-tag type="danger" size="small" effect="plain">禁用修改</el-tag>
|
||||
含「禁用修改」处方库模板,药材已锁定,不可增删改,如需调整请重新「从处方库导入」覆盖
|
||||
</div>
|
||||
|
||||
<div class="herb-formula-block mt-3">
|
||||
<div class="herb-formula-block__head">
|
||||
<el-tag type="primary" size="small">主方</el-tag>
|
||||
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
|
||||
<el-button type="primary" size="small" :disabled="herbsLocked" @click="addHerb('主方')">添加主方药材</el-button>
|
||||
</div>
|
||||
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -699,6 +706,7 @@
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
:disabled="herbsLocked"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
@@ -706,7 +714,8 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
<span v-else class="text-gray-400 text-xs">已锁定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -715,7 +724,7 @@
|
||||
<div class="herb-formula-block mt-4">
|
||||
<div class="herb-formula-block__head">
|
||||
<el-tag type="warning" size="small">辅方</el-tag>
|
||||
<el-button type="primary" size="small" plain @click="addHerb('辅方')">
|
||||
<el-button type="primary" size="small" plain :disabled="herbsLocked" @click="addHerb('辅方')">
|
||||
添加辅方药材
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -723,7 +732,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -733,6 +742,7 @@
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
:disabled="herbsLocked"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
@@ -740,7 +750,8 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
<el-button v-if="!herbsLocked" type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
<span v-else class="text-gray-400 text-xs">已锁定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -1698,7 +1709,7 @@ import jsPDF from 'jspdf'
|
||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
type FormulaType = '主方' | '辅方'
|
||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType }
|
||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||
|
||||
type AuxUsageForm = {
|
||||
dosage_amount?: number
|
||||
@@ -1798,11 +1809,15 @@ function normalizeFormulaType(v: unknown): FormulaType {
|
||||
}
|
||||
|
||||
function normalizeHerbRow(raw: any): HerbRow {
|
||||
return {
|
||||
const row: HerbRow = {
|
||||
name: String(raw?.name ?? '').trim(),
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
if (raw?.locked === true || raw?.locked === 1) {
|
||||
row.locked = true
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
|
||||
@@ -1842,7 +1857,7 @@ const formData = reactive({
|
||||
creator_ids: [] as number[],
|
||||
/** all | passed | not_passed | pending | rejected(all 表示不限,接口传参会转为空) */
|
||||
audit_filter: 'all' as string,
|
||||
/** all | manual | system — 与 is_system_auto:手工(0) / 系统代开(1) */
|
||||
/** all | manual | system — 与 is_system_auto:手工(0) / 空白处方(1) */
|
||||
source_filter: 'all' as string,
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
@@ -2481,6 +2496,12 @@ const auxHerbRows = computed(() =>
|
||||
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
|
||||
)
|
||||
|
||||
/**
|
||||
* 整张处方是否被锁定:只要任一药材来自「禁用修改」处方库模板即锁定。
|
||||
* 锁定后不可增删改任意药材,只能再次「从处方库导入」覆盖。
|
||||
*/
|
||||
const herbsLocked = computed(() => editForm.herbs.some((h) => (h as HerbRow).locked === true))
|
||||
|
||||
const slipDietaryText = computed(() => {
|
||||
const d = slipView.value?.dietary_taboo
|
||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||
@@ -3441,10 +3462,18 @@ const handleImportLibrary = (row: any) => {
|
||||
return
|
||||
}
|
||||
const formulaType = normalizeFormulaType(row.formula_type)
|
||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
|
||||
...normalizeHerbRow(h),
|
||||
formula_type: formulaType
|
||||
}))
|
||||
// 「禁用修改」模板:导入药材打上锁定标记 → 整张处方锁定(不可增删改,只能再次导入覆盖)
|
||||
const isLockedTemplate = Number(row?.disable_edit) === 1
|
||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => {
|
||||
const herb = normalizeHerbRow(h)
|
||||
herb.formula_type = formulaType
|
||||
if (isLockedTemplate) {
|
||||
herb.locked = true
|
||||
} else {
|
||||
delete herb.locked
|
||||
}
|
||||
return herb
|
||||
})
|
||||
if (libraryImportMode.value === 'replace') {
|
||||
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||
editForm.herbs = [...kept, ...imported]
|
||||
@@ -3650,6 +3679,10 @@ async function handlePasteRecipeImport() {
|
||||
|
||||
// 添加药材(主方 / 辅方)
|
||||
const addHerb = (formulaType: FormulaType = '主方') => {
|
||||
if (herbsLocked.value) {
|
||||
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,请重新从处方库导入覆盖')
|
||||
return
|
||||
}
|
||||
editForm.herbs.push({
|
||||
name: '',
|
||||
dosage: 0,
|
||||
@@ -3659,6 +3692,10 @@ const addHerb = (formulaType: FormulaType = '主方') => {
|
||||
|
||||
// 删除药材
|
||||
const removeHerb = (index: number) => {
|
||||
if (herbsLocked.value) {
|
||||
feedback.msgWarning('该处方含「禁用修改」模板,药材已锁定,不可删除')
|
||||
return
|
||||
}
|
||||
editForm.herbs.splice(index, 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,13 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="禁用修改" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.disable_edit ? 'danger' : 'info'">
|
||||
{{ row.disable_edit ? '已禁用' : '可修改' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建人" prop="creator_name" min-width="100" />
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="160" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
@@ -193,6 +200,21 @@
|
||||
勾选后,所有医生都可以查看和使用此处方模板
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="禁用修改" prop="disable_edit">
|
||||
<div class="flex flex-col gap-1 leading-none">
|
||||
<el-switch
|
||||
v-model="editForm.disable_edit"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 leading-relaxed">
|
||||
开启后,导入此模板到处方时将锁定整张处方的药材,不可增删改,只能再次导入覆盖
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer v-if="editMode !== 'view'">
|
||||
@@ -236,7 +258,8 @@ const editForm = reactive({
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
is_public: 0
|
||||
is_public: 0,
|
||||
disable_edit: 0
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
@@ -293,6 +316,7 @@ const resetForm = () => {
|
||||
editForm.formula_type = '主方'
|
||||
editForm.herbs = []
|
||||
editForm.is_public = 0
|
||||
editForm.disable_edit = 0
|
||||
}
|
||||
|
||||
// 新增处方
|
||||
@@ -310,6 +334,7 @@ const handleView = (row: any) => {
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
editForm.disable_edit = row.disable_edit ?? 0
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
@@ -321,6 +346,7 @@ const handleEdit = (row: any) => {
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
editForm.disable_edit = row.disable_edit ?? 0
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -190,18 +190,17 @@
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</div>
|
||||
<el-form v-show="!h5FilterCollapsed" class="ls-form" :model="queryParams" inline>
|
||||
<el-form-item class="w-[360px] min-w-[240px] max-w-full" label="创建时间">
|
||||
<DaterangePicker
|
||||
v-model:startTime="queryParams.start_time"
|
||||
v-model:endTime="queryParams.end_time"
|
||||
picker-type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
class="!w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form v-show="!h5FilterCollapsed" class="ls-form stats-filter-form" :model="queryParams" inline>
|
||||
<list-time-filter
|
||||
v-model:time-type="time_type"
|
||||
v-model:date-range="dateRange"
|
||||
label="创建时间"
|
||||
variant="inline"
|
||||
show-all
|
||||
picker-width="260px"
|
||||
@time-type-change="onTimeTypeChange"
|
||||
@custom-change="onCustomDateChange"
|
||||
/>
|
||||
<el-form-item class="w-[260px]" label="订单号">
|
||||
<el-input
|
||||
v-model="queryParams.order_no"
|
||||
@@ -703,49 +702,50 @@
|
||||
</div>
|
||||
|
||||
<!-- 顶部数据概览卡片 -->
|
||||
<el-row :gutter="16" class="mb-5">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="stat-title text-gray-500 text-xs">总金额</div>
|
||||
<el-button
|
||||
v-if="canUpdateAmount(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/updateAmount']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openUpdateAmount"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">已付总额</div>
|
||||
<div class="stat-value text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">需代收</div>
|
||||
<div
|
||||
class="stat-value font-bold text-xl"
|
||||
:class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'"
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-5">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="stat-title text-gray-500 text-xs">总金额</div>
|
||||
<el-button
|
||||
v-if="canUpdateAmount(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/updateAmount']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="openUpdateAmount"
|
||||
>
|
||||
¥{{ formatMoney(detailAgencyToCollect) }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">已付笔数</div>
|
||||
<div class="stat-value text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} 笔</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
修改
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="stat-value text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">已付总额</div>
|
||||
<div class="stat-value text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">退款金额</div>
|
||||
<div
|
||||
class="stat-value font-bold text-xl"
|
||||
:class="Number(detailData.refund_amount) > 0 ? 'text-rose-500' : 'text-gray-400'"
|
||||
>
|
||||
¥{{ formatMoney(detailData.refund_amount || 0) }}
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">需代收</div>
|
||||
<div
|
||||
class="stat-value font-bold text-xl"
|
||||
:class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'"
|
||||
>
|
||||
¥{{ formatMoney(detailAgencyToCollect) }}
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="stat-card border border-gray-100 bg-gray-50/50">
|
||||
<div class="stat-title text-gray-500 text-xs mb-1">已付笔数</div>
|
||||
<div class="stat-value text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} 笔</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<!-- 左侧:处方单 -->
|
||||
@@ -894,6 +894,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detailDietaryText" label="忌口" :span="2">
|
||||
{{ detailDietaryText }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">
|
||||
{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}
|
||||
@@ -962,6 +965,11 @@
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方式" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ formatPayOrderSource(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="136">
|
||||
<template #default="{ row }">
|
||||
@@ -1020,6 +1028,11 @@
|
||||
<el-tag v-if="row.is_exempt === 1" type="warning" size="small" class="ml-1">豁免</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方式" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ formatPayOrderSource(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="136">
|
||||
<template #default="{ row }">
|
||||
@@ -1285,7 +1298,7 @@
|
||||
<el-form-item label="新金额" prop="amount">
|
||||
<el-input-number
|
||||
v-model="updateAmountForm.amount"
|
||||
:min="0.01"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
@@ -1555,7 +1568,7 @@
|
||||
<el-option
|
||||
v-for="o in editPaidOrders"
|
||||
:key="o.id"
|
||||
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)}`"
|
||||
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)} · ${formatPayOrderSource(o)}`"
|
||||
:value="o.id"
|
||||
class="!h-auto py-1.5"
|
||||
>
|
||||
@@ -1565,7 +1578,7 @@
|
||||
<span class="text-orange-500 font-medium ml-4">¥{{ o.amount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-gray-400 mt-1">
|
||||
<span class="truncate pr-2">{{ formatPoCategory(o.order_type) }}<template v-if="o.remark"> · {{ o.remark }}</template></span>
|
||||
<span class="truncate pr-2">{{ formatPoCategory(o.order_type) }} · {{ formatPayOrderSource(o) }}<template v-if="o.remark"> · {{ o.remark }}</template></span>
|
||||
<span class="shrink-0">{{ String(o.create_time || '').substring(0, 16) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1845,9 +1858,11 @@
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
class="po-h5-dialog"
|
||||
@closed="completeOrderId = 0"
|
||||
@closed="onCompleteOrderDialogClosed"
|
||||
>
|
||||
<p class="text-sm text-gray-600 mb-3">请选择本单结案状态。选择「已完成」时,实付将按已关联的支付单金额汇总。</p>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
选择「已完成」按关联收款汇总实付;选择「退款」将关联收款标记已退款并清零已付。
|
||||
</p>
|
||||
<el-select
|
||||
v-model="completeFulfillmentStatus"
|
||||
class="w-full"
|
||||
@@ -1860,10 +1875,54 @@
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<template v-if="completeFulfillmentStatus === 10">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mt-3"
|
||||
title="关联收款将标记已退款,本单已付清零,不可撤销。"
|
||||
/>
|
||||
<p v-if="completeOrderLinkedPays.length" class="text-xs text-amber-800 mt-2">
|
||||
将退款 {{ completeOrderLinkedPays.length }} 笔关联收款,合计 ¥{{ formatMoney(completeOrderRefundTotal) }}
|
||||
</p>
|
||||
<el-form
|
||||
ref="completeRefundFormRef"
|
||||
:model="completeRefundForm"
|
||||
:rules="completeRefundRules"
|
||||
label-width="80px"
|
||||
class="mt-2"
|
||||
>
|
||||
<el-form-item label="退款金额" prop="refund_amount">
|
||||
<el-input-number
|
||||
v-model="completeRefundForm.refund_amount"
|
||||
:min="0.01"
|
||||
:max="completeOrderRefundMax > 0 ? completeOrderRefundMax : undefined"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">可退上限 ¥{{ formatMoney(completeOrderRefundMax) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="退款原因" prop="reason">
|
||||
<el-input
|
||||
v-model="completeRefundForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
placeholder="必填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="completeOrderDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="completeOrderSubmitting" @click="submitCompleteOrder">
|
||||
确定
|
||||
<el-button
|
||||
:type="completeFulfillmentStatus === 10 ? 'danger' : 'primary'"
|
||||
:loading="completeOrderSubmitting"
|
||||
@click="submitCompleteOrder"
|
||||
>
|
||||
{{ completeFulfillmentStatus === 10 ? '确认退款' : '确定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -1882,9 +1941,20 @@
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4 !items-start"
|
||||
title="确认退款后,关联收款记录将全部标记为「已退款」,已付总额清零,且不可撤销。"
|
||||
:title="refundOrderAlertTitle"
|
||||
/>
|
||||
<el-form ref="refundOrderFormRef" :model="refundOrderForm" :rules="refundOrderRules" label-width="88px">
|
||||
<el-form-item label="退款金额" prop="refund_amount">
|
||||
<el-input-number
|
||||
v-model="refundOrderForm.refund_amount"
|
||||
:min="0.01"
|
||||
:max="refundOrderMaxAmount > 0 ? refundOrderMaxAmount : undefined"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 mt-1">可退上限 ¥{{ formatMoney(refundOrderMaxAmount) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="退款原因" prop="reason">
|
||||
<el-input
|
||||
v-model="refundOrderForm.reason"
|
||||
@@ -1914,7 +1984,7 @@
|
||||
@closed="addPayOrderFormRef?.clearValidate()"
|
||||
>
|
||||
<el-alert
|
||||
title="可以手动创建新支付单,或关联已存在但未绑定的支付单。"
|
||||
:title="addPayOrderAlertTitle"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@@ -1930,13 +2000,15 @@
|
||||
>
|
||||
<el-form-item label="添加方式" prop="add_mode">
|
||||
<el-radio-group v-model="addPayOrderForm.add_mode">
|
||||
<el-radio value="create">手动创建</el-radio>
|
||||
<el-radio value="create">付呗</el-radio>
|
||||
<el-radio value="create_express">快递代收</el-radio>
|
||||
<el-radio value="link">关联已有</el-radio>
|
||||
<el-radio value="completion_only">直接完单申请</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 手动创建模式 -->
|
||||
<template v-if="addPayOrderForm.add_mode === 'create'">
|
||||
<!-- 付呗 / 快递代收(手动创建支付单) -->
|
||||
<template v-if="addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express'">
|
||||
<el-form-item label="费用类别" prop="order_type">
|
||||
<el-select v-model="addPayOrderForm.order_type" class="w-full">
|
||||
<el-option label="挂号费" :value="1" />
|
||||
@@ -1971,6 +2043,20 @@
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 直接完单申请(不创建/关联支付单) -->
|
||||
<template v-else-if="addPayOrderForm.add_mode === 'completion_only'">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="!mb-0"
|
||||
>
|
||||
<template #title>
|
||||
不新增或关联支付单,仅向审核人员提交「完成订单」申请;提交后支付审核将变为待审核,审核通过后将自动结案。
|
||||
</template>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<!-- 关联已有模式 -->
|
||||
<template v-else>
|
||||
<el-form-item label="选择支付单" prop="link_pay_order_id">
|
||||
@@ -1986,12 +2072,17 @@
|
||||
<el-option
|
||||
v-for="o in addPayOrderAvailableList"
|
||||
:key="o.id"
|
||||
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)}`"
|
||||
:label="`${o.order_no} · ¥${o.amount} · ${formatPoCategory(o.order_type)} · ${formatPayOrderSource(o)}`"
|
||||
:value="o.id"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{{ o.order_no }}</span>
|
||||
<span class="text-orange-500 font-medium ml-4">¥{{ o.amount }}</span>
|
||||
<div class="flex flex-col gap-0.5 py-0.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{{ o.order_no }}</span>
|
||||
<span class="text-orange-500 font-medium ml-4">¥{{ o.amount }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ formatPoCategory(o.order_type) }} · {{ formatPayOrderSource(o) }}
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
@@ -2002,7 +2093,7 @@
|
||||
</template>
|
||||
|
||||
<!-- 完单申请选项 -->
|
||||
<el-form-item label="完单申请">
|
||||
<el-form-item v-if="addPayOrderForm.add_mode !== 'completion_only'" label="完单申请">
|
||||
<el-radio-group v-model="addPayOrderForm.completion_request">
|
||||
<el-radio :value="0">不申请</el-radio>
|
||||
<el-radio :value="1">申请完成订单</el-radio>
|
||||
@@ -2223,6 +2314,7 @@
|
||||
<div class="rx-text">
|
||||
<p>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
@@ -2243,7 +2335,10 @@
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<!-- <span class="rx-bot-doctor-name">{{ prescriptionViewData.doctor_name || '—' }}</span> -->
|
||||
<span
|
||||
v-if="!prescriptionViewData.doctor_signature"
|
||||
class="rx-bot-doctor-name"
|
||||
>{{ prescriptionViewData.doctor_name || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2450,7 +2545,8 @@ import {
|
||||
Wallet,
|
||||
User
|
||||
} from '@element-plus/icons-vue'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
prescriptionOrderAuditPrescription,
|
||||
@@ -2470,12 +2566,14 @@ import {
|
||||
prescriptionOrderRevokePayAudit,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderRequestCompletion,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
prescriptionOrderPreviewGancaoRecipel,
|
||||
prescriptionDetail,
|
||||
getDoctors,
|
||||
getAssistants
|
||||
} from '@/api/tcm'
|
||||
import { formatDietaryTaboo } from './components/prescription-order-utils'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -2748,9 +2846,6 @@ function handleExpressCompanyTabClick(value: '' | 'sf' | 'jd') {
|
||||
}
|
||||
|
||||
const queryParams = reactive({
|
||||
/** 创建时间区间(与列表筛选一致;不影响「今日」统计) */
|
||||
start_time: '' as string,
|
||||
end_time: '' as string,
|
||||
order_no: '',
|
||||
prescription_id: '' as string | number,
|
||||
/** 诊单患者姓名/手机号(后台关联 diagnosis) */
|
||||
@@ -2829,8 +2924,26 @@ function handleFocusBoardSearch(key: 'pendingRx' | 'pendingPay' | 'pendingShip'
|
||||
resetPage()
|
||||
}
|
||||
|
||||
const {
|
||||
time_type,
|
||||
dateRange,
|
||||
resolve: resolveCreateTimeRange,
|
||||
handleTimeTypeChange,
|
||||
handleCustomDateChange,
|
||||
resetTimeFilter,
|
||||
} = useListTimeFilter('all')
|
||||
|
||||
const handleQuery = () => {
|
||||
pager.page = 1
|
||||
getLists()
|
||||
}
|
||||
|
||||
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
|
||||
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
|
||||
|
||||
async function fetchLists(params: Record<string, unknown>) {
|
||||
const p: Record<string, unknown> = { ...params }
|
||||
const [start_time, end_time] = resolveCreateTimeRange()
|
||||
const p: Record<string, unknown> = { ...params, start_time, end_time }
|
||||
if (p.prescription_id === '' || p.prescription_id === undefined) {
|
||||
delete p.prescription_id
|
||||
} else {
|
||||
@@ -2984,9 +3097,7 @@ const listStats = computed(() => {
|
||||
scopeHint =
|
||||
'统计口径:本人创建的订单 + 关联处方开方人为本人的订单(含医助代建);与下方筛选项(不含「创建时间」)一致'
|
||||
}
|
||||
const hasCustomTime =
|
||||
String(queryParams.start_time || '').trim() !== '' && String(queryParams.end_time || '').trim() !== ''
|
||||
const headPrefix = hasCustomTime ? '区间' : '今日'
|
||||
const headPrefix = time_type.value === 'all' ? '全部' : time_type.value === 'today' ? '今日' : '区间'
|
||||
return {
|
||||
order: n(o),
|
||||
pay: n(pay),
|
||||
@@ -3057,8 +3168,7 @@ watch(
|
||||
|
||||
function handleReset() {
|
||||
activeFocusKey.value = ''
|
||||
queryParams.start_time = ''
|
||||
queryParams.end_time = ''
|
||||
resetTimeFilter()
|
||||
queryParams.order_no = ''
|
||||
queryParams.prescription_id = ''
|
||||
queryParams.patient_keyword = ''
|
||||
@@ -3288,7 +3398,8 @@ function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_s
|
||||
}
|
||||
|
||||
function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
|
||||
return canCompleteRow(row)
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
@@ -3323,11 +3434,11 @@ const h5FilterCollapsed = ref(true)
|
||||
const h5ActiveFilterCount = computed(() => {
|
||||
const q = queryParams as any
|
||||
const keys = [
|
||||
'start_time', 'end_time', 'order_no', 'prescription_id',
|
||||
'order_no', 'prescription_id',
|
||||
'patient_keyword', 'express_keyword', 'doctor_id',
|
||||
'assistant_dept_id', 'assistant_id'
|
||||
]
|
||||
let n = 0
|
||||
let n = time_type.value !== 'all' ? 1 : 0
|
||||
for (const k of keys) {
|
||||
const v = q[k]
|
||||
if (v === null || v === undefined) continue
|
||||
@@ -3421,10 +3532,14 @@ const detailLinkedPayOrders = computed(() => {
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
/** 需代收:业务订单总金额 − 关联已付总额 */
|
||||
/** 需代收:优先用关联时记录的固定快照值;历史单(无快照)回退实时计算 总金额 − 关联已付总额 */
|
||||
const detailAgencyToCollect = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return 0
|
||||
const snap = d.agency_collect_amount
|
||||
if (snap !== null && snap !== undefined && snap !== '') {
|
||||
return Number(snap) || 0
|
||||
}
|
||||
const total = Number(d.amount) || 0
|
||||
const paid = Number(d.linked_pay_paid_total) || 0
|
||||
return Math.round((total - paid) * 100) / 100
|
||||
@@ -3469,6 +3584,8 @@ const detailPrescription = computed(() => {
|
||||
return p && typeof p === 'object' ? p : null
|
||||
})
|
||||
|
||||
const detailDietaryText = computed(() => formatDietaryTaboo(detailPrescription.value?.dietary_taboo))
|
||||
|
||||
function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
@@ -3616,8 +3733,8 @@ const updateAmountRules: FormRules = {
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
callback(new Error('订单金额必须大于0'))
|
||||
if (!Number.isFinite(num) || num < 0) {
|
||||
callback(new Error('订单金额不能为负数'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
@@ -3769,7 +3886,8 @@ function openUpdateAmount() {
|
||||
return
|
||||
}
|
||||
updateAmountForm.id = Number(row?.id) || 0
|
||||
updateAmountForm.amount = Number(row?.amount) || undefined
|
||||
const amt = Number(row?.amount)
|
||||
updateAmountForm.amount = Number.isFinite(amt) ? amt : undefined
|
||||
updateAmountVisible.value = true
|
||||
nextTick(() => updateAmountFormRef.value?.clearValidate())
|
||||
}
|
||||
@@ -3967,7 +4085,7 @@ const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
|
||||
.filter((segs) => segs.length > 0)
|
||||
})
|
||||
|
||||
const editPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string; order_type?: number | string; remark?: string; create_time?: string }>>([])
|
||||
const editPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string; order_type?: number | string; remark?: string; create_time?: string; payment_method?: string; create_type?: string }>>([])
|
||||
const editPaidOrdersLoading = ref(false)
|
||||
const editDepositMin = ref(0)
|
||||
|
||||
@@ -3983,6 +4101,26 @@ function formatPoCategory(t: unknown) {
|
||||
return m[Number(t)] || '未知'
|
||||
}
|
||||
|
||||
function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
|
||||
const createType = String(row?.create_type || '')
|
||||
if (createType === 'wechat_work') return '企业微信对外收款'
|
||||
if (createType === 'fubei') return '付呗'
|
||||
if (createType === 'express_cod') return '快递代收'
|
||||
const paymentMethod = String(row?.payment_method || '')
|
||||
const methodMap: Record<string, string> = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信',
|
||||
wechat_work: '企业微信',
|
||||
fubei: '付呗',
|
||||
express_cod: '快递代收',
|
||||
manual: '手动确认到账'
|
||||
}
|
||||
if (paymentMethod && methodMap[paymentMethod]) {
|
||||
return methodMap[paymentMethod]
|
||||
}
|
||||
return '普通订单'
|
||||
}
|
||||
|
||||
const editLinkedPaidTotal = computed(() => {
|
||||
const ids = new Set(editForm.pay_order_ids.map((x) => Number(x)))
|
||||
let s = 0
|
||||
@@ -4609,6 +4747,7 @@ const completeOrderStatusOptions = [
|
||||
{ value: 7, label: '进行中' },
|
||||
{ value: 8, label: '暂不制药' },
|
||||
{ value: 9, label: '拒收' },
|
||||
{ value: 10, label: '退款' },
|
||||
{ value: 11, label: '保留药方' },
|
||||
{ value: 12, label: '制药缓发' }
|
||||
] as const
|
||||
@@ -4617,10 +4756,98 @@ const completeOrderDialogVisible = ref(false)
|
||||
const completeOrderId = ref(0)
|
||||
const completeFulfillmentStatus = ref<number>(3)
|
||||
const completeOrderSubmitting = ref(false)
|
||||
const completeOrderLinkedPays = ref<any[]>([])
|
||||
const completeRefundFormRef = ref<FormInstance>()
|
||||
function calcRefundableFromLinkedPays(list: any[]) {
|
||||
let sum = 0
|
||||
for (const o of list || []) {
|
||||
const st = Number(o?.status)
|
||||
if (st === 2 || st === 5) sum += Number(o?.amount) || 0
|
||||
}
|
||||
return Math.round(sum * 100) / 100
|
||||
}
|
||||
|
||||
const completeRefundForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
|
||||
const completeOrderRefundMax = ref(0)
|
||||
const completeOrderRefundTotal = computed(() => calcRefundableFromLinkedPays(completeOrderLinkedPays.value))
|
||||
const completeRefundRules = computed<FormRules>(() => ({
|
||||
refund_amount: [
|
||||
{ required: true, message: '请输入退款金额', trigger: 'change' },
|
||||
{
|
||||
validator: (_r, v, cb) => {
|
||||
const n = Number(v)
|
||||
const cap = completeOrderRefundMax.value
|
||||
if (!Number.isFinite(n) || n <= 0) return cb(new Error('退款金额须大于 0'))
|
||||
if (cap > 0 && n > cap + 0.009) return cb(new Error(`不能超过 ¥${formatMoney(cap)}`))
|
||||
cb()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
|
||||
}))
|
||||
|
||||
function onCompleteOrderDialogClosed() {
|
||||
completeOrderId.value = 0
|
||||
completeOrderLinkedPays.value = []
|
||||
completeRefundForm.reason = ''
|
||||
completeRefundForm.refund_amount = undefined
|
||||
completeOrderRefundMax.value = 0
|
||||
completeRefundFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function loadCompleteOrderPayContext(orderId: number) {
|
||||
if (!orderId) {
|
||||
completeOrderLinkedPays.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r: any = await prescriptionOrderDetail({ id: orderId })
|
||||
const d = r?.data ?? r ?? null
|
||||
completeOrderLinkedPays.value = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
|
||||
const linkedMax = calcRefundableFromLinkedPays(completeOrderLinkedPays.value)
|
||||
const paid = Number(d?.paid) || 0
|
||||
completeOrderRefundMax.value = linkedMax > 0 ? linkedMax : paid
|
||||
completeRefundForm.refund_amount = completeOrderRefundMax.value
|
||||
} catch {
|
||||
completeOrderLinkedPays.value = []
|
||||
completeOrderRefundMax.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCompleteOrder() {
|
||||
const id = completeOrderId.value
|
||||
if (!id) return
|
||||
|
||||
if (completeFulfillmentStatus.value === 10) {
|
||||
const valid = await completeRefundFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
completeOrderSubmitting.value = true
|
||||
try {
|
||||
await prescriptionOrderRefund({
|
||||
id,
|
||||
reason: completeRefundForm.reason.trim(),
|
||||
refund_amount: Number(completeRefundForm.refund_amount)
|
||||
})
|
||||
feedback.msgSuccess('退款成功')
|
||||
completeOrderDialogVisible.value = false
|
||||
getLists()
|
||||
if (detailVisible.value && detailData.value?.id === id) {
|
||||
try {
|
||||
const r: any = await prescriptionOrderDetail({ id })
|
||||
const nd = r?.data ?? r ?? null
|
||||
if (nd) detailData.value = nd
|
||||
await fetchLogs(id)
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
completeOrderSubmitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
completeOrderSubmitting.value = true
|
||||
try {
|
||||
await prescriptionOrderComplete({ id, fulfillment_status: completeFulfillmentStatus.value })
|
||||
@@ -4645,21 +4872,57 @@ const refundOrderDialogVisible = ref(false)
|
||||
const refundOrderId = ref(0)
|
||||
const refundOrderSubmitting = ref(false)
|
||||
const refundOrderFormRef = ref<FormInstance>()
|
||||
const refundOrderForm = reactive({ reason: '' })
|
||||
const refundOrderRules: FormRules = {
|
||||
const refundOrderMaxAmount = ref(0)
|
||||
const refundOrderForm = reactive({ reason: '', refund_amount: undefined as number | undefined })
|
||||
const refundOrderAlertTitle = computed(() => {
|
||||
const cap = formatMoney(refundOrderMaxAmount.value)
|
||||
const amt = formatMoney(refundOrderForm.refund_amount)
|
||||
if (Number(refundOrderForm.refund_amount) >= refundOrderMaxAmount.value - 0.009) {
|
||||
return `全额退款 ¥${cap}:关联收款标记已退款,已付清零。`
|
||||
}
|
||||
return `部分退款 ¥${amt}(上限 ¥${cap})`
|
||||
})
|
||||
const refundOrderRules = computed<FormRules>(() => ({
|
||||
refund_amount: [
|
||||
{ required: true, message: '请输入退款金额', trigger: 'change' },
|
||||
{
|
||||
validator: (_r, v, cb) => {
|
||||
const n = Number(v)
|
||||
const cap = refundOrderMaxAmount.value
|
||||
if (!Number.isFinite(n) || n <= 0) return cb(new Error('退款金额须大于 0'))
|
||||
if (cap > 0 && n > cap + 0.009) return cb(new Error(`不能超过 ¥${formatMoney(cap)}`))
|
||||
cb()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
reason: [{ required: true, message: '请填写退款原因', trigger: 'blur' }]
|
||||
}
|
||||
}))
|
||||
|
||||
function onRefundOrderDialogClosed() {
|
||||
refundOrderId.value = 0
|
||||
refundOrderForm.reason = ''
|
||||
refundOrderForm.refund_amount = undefined
|
||||
refundOrderMaxAmount.value = 0
|
||||
refundOrderFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openRefundOrder(row: { id: number }) {
|
||||
async function openRefundOrder(row: { id: number }) {
|
||||
refundOrderId.value = row.id
|
||||
refundOrderForm.reason = ''
|
||||
refundOrderMaxAmount.value = 0
|
||||
refundOrderDialogVisible.value = true
|
||||
try {
|
||||
const r: any = await prescriptionOrderDetail({ id: row.id })
|
||||
const d = r?.data ?? r ?? null
|
||||
const linked = Array.isArray(d?.linked_pay_orders) ? d.linked_pay_orders : []
|
||||
const linkedMax = calcRefundableFromLinkedPays(linked)
|
||||
const paid = Number(d?.paid) || 0
|
||||
refundOrderMaxAmount.value = linkedMax > 0 ? linkedMax : paid
|
||||
refundOrderForm.refund_amount = refundOrderMaxAmount.value
|
||||
} catch {
|
||||
refundOrderMaxAmount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRefundOrder() {
|
||||
@@ -4669,7 +4932,11 @@ async function submitRefundOrder() {
|
||||
if (!valid) return
|
||||
refundOrderSubmitting.value = true
|
||||
try {
|
||||
await prescriptionOrderRefund({ id, reason: refundOrderForm.reason.trim() })
|
||||
await prescriptionOrderRefund({
|
||||
id,
|
||||
reason: refundOrderForm.reason.trim(),
|
||||
refund_amount: Number(refundOrderForm.refund_amount)
|
||||
})
|
||||
feedback.msgSuccess('退款成功')
|
||||
refundOrderDialogVisible.value = false
|
||||
getLists()
|
||||
@@ -4695,8 +4962,17 @@ const addPayOrderRowId = ref(0)
|
||||
const addPayOrderFormRef = ref<FormInstance>()
|
||||
const addPayOrderAvailableLoading = ref(false)
|
||||
const addPayOrderAvailableList = ref<any[]>([])
|
||||
const addPayOrderAlertTitle = computed(() => {
|
||||
if (addPayOrderForm.add_mode === 'completion_only') {
|
||||
return '不创建或关联支付单,仅提交完单申请,由审核人员在支付审核时处理。'
|
||||
}
|
||||
if (addPayOrderForm.add_mode === 'create_express') {
|
||||
return '通过快递代收创建新支付单(流转同付呗),或关联已存在但未绑定的支付单。'
|
||||
}
|
||||
return '可以通过付呗创建新支付单,或关联已存在但未绑定的支付单。'
|
||||
})
|
||||
const addPayOrderForm = reactive({
|
||||
add_mode: 'create' as 'create' | 'link',
|
||||
add_mode: 'create' as 'create' | 'create_express' | 'link' | 'completion_only',
|
||||
order_type: 3,
|
||||
pay_amount: undefined as number | undefined,
|
||||
pay_remark: '',
|
||||
@@ -4704,38 +4980,44 @@ const addPayOrderForm = reactive({
|
||||
completion_request: 0
|
||||
})
|
||||
|
||||
const addPayOrderRules: FormRules = {
|
||||
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }],
|
||||
order_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }],
|
||||
pay_amount: [
|
||||
{ required: true, message: '请输入支付金额', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, v, cb) => {
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
cb(new Error('金额须大于 0'))
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
link_pay_order_ids: [
|
||||
{
|
||||
required: true,
|
||||
message: '请至少选择一个支付单',
|
||||
trigger: 'change',
|
||||
validator: (_rule, v, cb) => {
|
||||
if (!Array.isArray(v) || v.length === 0) {
|
||||
cb(new Error('请至少选择一个支付单'))
|
||||
} else {
|
||||
cb()
|
||||
const addPayOrderRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {
|
||||
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }]
|
||||
}
|
||||
if (addPayOrderForm.add_mode === 'create' || addPayOrderForm.add_mode === 'create_express') {
|
||||
rules.order_type = [{ required: true, message: '请选择费用类别', trigger: 'change' }]
|
||||
rules.pay_amount = [
|
||||
{ required: true, message: '请输入支付金额', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, v, cb) => {
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
cb(new Error('金额须大于 0'))
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
} else if (addPayOrderForm.add_mode === 'link') {
|
||||
rules.link_pay_order_ids = [
|
||||
{
|
||||
required: true,
|
||||
message: '请至少选择一个支付单',
|
||||
trigger: 'change',
|
||||
validator: (_rule, v, cb) => {
|
||||
if (!Array.isArray(v) || v.length === 0) {
|
||||
cb(new Error('请至少选择一个支付单'))
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return rules
|
||||
})
|
||||
|
||||
async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: number[] = []) {
|
||||
if (!diagnosisId) {
|
||||
@@ -4783,14 +5065,22 @@ async function submitAddPayOrder() {
|
||||
await addPayOrderFormRef.value.validate()
|
||||
addPayOrderSaving.value = true
|
||||
try {
|
||||
if (addPayOrderForm.add_mode === 'create') {
|
||||
// 手动创建新支付单
|
||||
if (addPayOrderForm.add_mode === 'completion_only') {
|
||||
await prescriptionOrderRequestCompletion({ id: addPayOrderRowId.value })
|
||||
feedback.msgSuccess('完单申请已提交,请等待支付审核')
|
||||
} else if (
|
||||
addPayOrderForm.add_mode === 'create' ||
|
||||
addPayOrderForm.add_mode === 'create_express'
|
||||
) {
|
||||
// 手动创建新支付单(付呗 / 快递代收)
|
||||
await prescriptionOrderAddPayOrder({
|
||||
id: addPayOrderRowId.value,
|
||||
order_type: addPayOrderForm.order_type,
|
||||
pay_amount: addPayOrderForm.pay_amount!,
|
||||
pay_remark: addPayOrderForm.pay_remark || '',
|
||||
completion_request: addPayOrderForm.completion_request
|
||||
completion_request: addPayOrderForm.completion_request,
|
||||
pay_create_type:
|
||||
addPayOrderForm.add_mode === 'create_express' ? 'express_cod' : 'fubei'
|
||||
})
|
||||
feedback.msgSuccess('支付单已新增,请等待审核')
|
||||
} else {
|
||||
@@ -4830,7 +5120,10 @@ async function submitAddPayOrder() {
|
||||
function confirmComplete(row: { id: number }) {
|
||||
completeOrderId.value = row.id
|
||||
completeFulfillmentStatus.value = 3
|
||||
completeRefundForm.reason = ''
|
||||
completeOrderLinkedPays.value = []
|
||||
completeOrderDialogVisible.value = true
|
||||
void loadCompleteOrderPayContext(row.id)
|
||||
}
|
||||
|
||||
// ─── 撤回处方审核 ────────────────────────────────────────────
|
||||
@@ -4902,12 +5195,7 @@ const slipHerbsList = computed(() => {
|
||||
return Array.isArray(h) ? h : []
|
||||
})
|
||||
|
||||
const slipDietaryText = computed(() => {
|
||||
const d = prescriptionViewData.value?.dietary_taboo
|
||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||
if (typeof d === 'string' && d.trim()) return d.trim()
|
||||
return ''
|
||||
})
|
||||
const slipDietaryText = computed(() => formatDietaryTaboo(prescriptionViewData.value?.dietary_taboo))
|
||||
|
||||
const slipChuwanVisible = computed(() => {
|
||||
const d = prescriptionViewData.value
|
||||
@@ -5330,6 +5618,12 @@ async function downloadPrescriptionSlipPdf() {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-order-page {
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -231,8 +231,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { adminLists } from '@/api/perms/admin'
|
||||
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||
import { doctorLists, rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
@@ -491,10 +490,9 @@ async function removeSegment(row: RosterSegment) {
|
||||
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const res = await adminLists({
|
||||
const res = await doctorLists({
|
||||
page_no: 1,
|
||||
page_size: 1000,
|
||||
role_id: 1
|
||||
page_size: 1000
|
||||
})
|
||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||
doctorId: doctor.id,
|
||||
|
||||
@@ -283,7 +283,15 @@
|
||||
<td class="col-name">{{ r.name }}</td>
|
||||
<td class="col-consult">{{ formatLeaderboardInt(r.consult_count) }}</td>
|
||||
<td class="col-deal">{{ formatLeaderboardInt(r.deal_order_count) }}</td>
|
||||
<td class="col-assign">{{ formatLeaderboardInt(r.assign_count ?? 0) }}</td>
|
||||
<td class="col-assign" @click.stop="onLeaderboardAssignCellClick(r)">
|
||||
<span
|
||||
v-if="Number(r.assign_count ?? 0) > 0"
|
||||
class="yeji-lead-cell--link"
|
||||
>{{ formatLeaderboardInt(r.assign_count ?? 0) }}</span>
|
||||
<template v-else>{{
|
||||
formatLeaderboardInt(r.assign_count ?? 0)
|
||||
}}</template>
|
||||
</td>
|
||||
<td class="col-appointment" @click.stop="onLeaderboardAppointmentCellClick(r)">
|
||||
<span
|
||||
v-if="Number(r.appointment_count ?? 0) > 0"
|
||||
@@ -390,7 +398,7 @@
|
||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||
<template #content>
|
||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||
<b>tcm_diagnosis_assign_log</b> 行数(成功指派:<b>to_assistant_id > 0</b>),**仅按 <b>related_po_create_time</b>** 落在所选区间内(与日志表快照一致;诊单手动指派已写入该字段)。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||||
<b>tcm_diagnosis_assign_log</b> 中成功指派(<b>to_assistant_id > 0</b>,<b>剔除勾选「继承」的指派</b>)按<b>指派操作时间</b>落在所选区间内,「医助 × 诊单」去重、剔除已删诊单,与「复诊接诊率」页面同口径。按<b>被指派医助</b>归属展示部门。<template v-if="tb.channel_name">
|
||||
选定渠道时收窄与「{{ tb.channel_name }}」业绩同口径(标签诊单或 channels EXISTS)。<br />
|
||||
<b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>。
|
||||
</template>
|
||||
@@ -561,7 +569,13 @@
|
||||
>{{ formatInt(r.lead_count) }}</span>
|
||||
<template v-else>{{ formatInt(r.lead_count) }}</template>
|
||||
</td>
|
||||
<td>{{ formatInt(r.assign_count ?? 0) }}</td>
|
||||
<td @click.stop="onAssignCountCellClick($event, tb, r)">
|
||||
<span
|
||||
v-if="yejiAssignCountCellClickable(r)"
|
||||
class="yeji-lead-cell--link"
|
||||
>{{ formatInt(r.assign_count ?? 0) }}</span>
|
||||
<template v-else>{{ formatInt(r.assign_count ?? 0) }}</template>
|
||||
</td>
|
||||
<td @click.stop="onYejiRevisitTotalCellClick(tb, r)">
|
||||
<span
|
||||
v-if="r.dept_id > 0 && Number(r.revisit_count ?? 0) > 0"
|
||||
@@ -1356,6 +1370,59 @@
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="assignLinesDialogVisible"
|
||||
width="min(960px, 96vw)"
|
||||
destroy-on-close
|
||||
class="yeji-leadlines-dialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="yeji-unassigned-dialog__head">
|
||||
<span class="yeji-unassigned-dialog__title">被指派明细</span>
|
||||
<p class="yeji-unassigned-dialog__sub">{{ assignLinesSubtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="assignLinesApiNote" class="yeji-unassigned-dialog__note">{{ assignLinesApiNote }}</p>
|
||||
<div v-loading="assignLinesLoading" class="yeji-unassigned-dialog__body">
|
||||
<el-table
|
||||
v-if="assignLinesRows.length > 0 || assignLinesLoading"
|
||||
:data="assignLinesRows"
|
||||
size="small"
|
||||
stripe
|
||||
border
|
||||
max-height="440"
|
||||
class="yeji-unassigned-dialog__table"
|
||||
:empty-text="assignLinesLoading ? '加载中…' : '暂无数据'"
|
||||
>
|
||||
<el-table-column type="index" label="#" width="46" :index="assignLinesIndexMethod" />
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="88" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="assistant_name" label="被指派医助" min-width="108" show-overflow-tooltip />
|
||||
<el-table-column prop="assign_count" label="指派次数" width="88" align="right" />
|
||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="156" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-empty
|
||||
v-if="!assignLinesLoading && assignLinesRows.length === 0"
|
||||
description="暂无被指派明细"
|
||||
/>
|
||||
<div v-if="assignLinesCount > 0" class="yeji-leadlines-dialog__pager">
|
||||
<el-pagination
|
||||
:current-page="assignLinesPage"
|
||||
:page-size="assignLinesPageSize"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="assignLinesCount"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:disabled="assignLinesLoading"
|
||||
:hide-on-single-page="false"
|
||||
@current-change="onAssignLinesPageChange"
|
||||
@size-change="onAssignLinesPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1376,6 +1443,7 @@ import {
|
||||
yejiStatsLeadLines,
|
||||
yejiStatsAppointmentLines,
|
||||
yejiStatsRevisitBreakdown,
|
||||
yejiStatsAssignLines,
|
||||
} from '@/api/stats'
|
||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
@@ -1491,6 +1559,17 @@ interface YejiLeadLineRow {
|
||||
external_contact_name: string
|
||||
}
|
||||
|
||||
interface YejiAssignLineRow {
|
||||
diagnosis_id: number
|
||||
patient_name: string
|
||||
patient_phone: string
|
||||
assistant_id: number
|
||||
assistant_name: string
|
||||
assign_count: number
|
||||
last_assign_time: number
|
||||
last_assign_time_text: string
|
||||
}
|
||||
|
||||
interface YejiRow {
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
@@ -1709,6 +1788,31 @@ type AppointmentLinesCtx =
|
||||
}
|
||||
const appointmentLinesContext = ref<AppointmentLinesCtx | null>(null)
|
||||
|
||||
const assignLinesDialogVisible = ref(false)
|
||||
const assignLinesLoading = ref(false)
|
||||
const assignLinesSubtitle = ref('')
|
||||
const assignLinesApiNote = ref('')
|
||||
const assignLinesRows = ref<YejiAssignLineRow[]>([])
|
||||
const assignLinesCount = ref(0)
|
||||
const assignLinesPage = ref(1)
|
||||
const assignLinesPageSize = ref(20)
|
||||
type AssignLinesCtx =
|
||||
| {
|
||||
mode: 'assistant'
|
||||
assistant_id: number
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
| {
|
||||
mode: 'dept'
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
const assignLinesContext = ref<AssignLinesCtx | null>(null)
|
||||
|
||||
const appointmentLinesShowChannelColumn = computed(() =>
|
||||
appointmentLinesRows.value.some(r => String(r.channel_source ?? '') !== '')
|
||||
)
|
||||
@@ -3193,6 +3297,124 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||||
void openLeadLinesDialog(tb, row)
|
||||
}
|
||||
|
||||
function yejiAssignCountCellClickable(r: YejiRow): boolean {
|
||||
return r.dept_id > 0 && Number(r.assign_count ?? 0) > 0
|
||||
}
|
||||
|
||||
function onAssignCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||||
if (!yejiAssignCountCellClickable(row)) {
|
||||
return
|
||||
}
|
||||
ev.stopPropagation()
|
||||
void openAssignLinesDialogForDept(tb, row)
|
||||
}
|
||||
|
||||
function assignLinesIndexMethod(index: number) {
|
||||
return (assignLinesPage.value - 1) * assignLinesPageSize.value + index + 1
|
||||
}
|
||||
|
||||
function buildAssignLinesRequestParams(page: number, pageSize: number): Record<string, string | number> | null {
|
||||
const ctx = assignLinesContext.value
|
||||
if (!ctx) {
|
||||
return null
|
||||
}
|
||||
const p: Record<string, string | number> = {
|
||||
start_date: ctx.start_date,
|
||||
end_date: ctx.end_date,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (ctx.mode === 'dept') {
|
||||
p.dept_id = ctx.dept_id
|
||||
} else {
|
||||
p.assistant_id = ctx.assistant_id
|
||||
}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
async function fetchAssignLinesPage() {
|
||||
const params = buildAssignLinesRequestParams(assignLinesPage.value, assignLinesPageSize.value)
|
||||
if (!params) {
|
||||
return
|
||||
}
|
||||
assignLinesLoading.value = true
|
||||
try {
|
||||
const res: any = await yejiStatsAssignLines(params as any)
|
||||
assignLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||||
assignLinesCount.value = Number(res?.count ?? 0)
|
||||
assignLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||||
} catch (e: unknown) {
|
||||
if (axios.isCancel(e)) return
|
||||
const any = e as any
|
||||
ElMessage.error(any?.msg || any?.message || '加载被指派明细失败')
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesApiNote.value = ''
|
||||
} finally {
|
||||
assignLinesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openAssignLinesDialogForDept(tb: YejiTable, row: YejiRow) {
|
||||
assignLinesContext.value = {
|
||||
mode: 'dept',
|
||||
dept_id: row.dept_id,
|
||||
dept_name: row.dept_name,
|
||||
start_date: tb.start_date,
|
||||
end_date: tb.end_date,
|
||||
}
|
||||
assignLinesSubtitle.value = `${row.dept_name} · ${tb.start_date} ~ ${tb.end_date}`
|
||||
assignLinesApiNote.value = ''
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesPage.value = 1
|
||||
assignLinesPageSize.value = 20
|
||||
assignLinesDialogVisible.value = true
|
||||
await fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onLeaderboardAssignCellClick(r: LeaderboardPack['leaderboards'][0]['rows'][0]) {
|
||||
if (!r || r.admin_id <= 0 || !leaderboardBlock.value) {
|
||||
return
|
||||
}
|
||||
if (Number(r.assign_count ?? 0) <= 0) {
|
||||
return
|
||||
}
|
||||
const lb = leaderboardBlock.value
|
||||
assignLinesContext.value = {
|
||||
mode: 'assistant',
|
||||
assistant_id: r.admin_id,
|
||||
name: r.name,
|
||||
start_date: lb.start_date,
|
||||
end_date: lb.end_date,
|
||||
}
|
||||
assignLinesSubtitle.value = `${r.name} · 医助 · ${lb.start_date} ~ ${lb.end_date}`
|
||||
assignLinesApiNote.value = ''
|
||||
assignLinesRows.value = []
|
||||
assignLinesCount.value = 0
|
||||
assignLinesPage.value = 1
|
||||
assignLinesPageSize.value = 20
|
||||
assignLinesDialogVisible.value = true
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onAssignLinesPageChange(p: number) {
|
||||
assignLinesPage.value = p
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
function onAssignLinesPageSizeChange(size: number) {
|
||||
assignLinesPageSize.value = size
|
||||
assignLinesPage.value = 1
|
||||
void fetchAssignLinesPage()
|
||||
}
|
||||
|
||||
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
|
||||
{ key: 'event_time_text', label: '进线时间' },
|
||||
{ key: 'reception_admin_name', label: '接待' },
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
||||
<el-form-item label="日期范围">
|
||||
<daterange-picker
|
||||
v-model:startTime="queryParams.start_date"
|
||||
v-model:endTime="queryParams.end_date"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||
<list-time-filter
|
||||
v-model:time-type="time_type"
|
||||
v-model:date-range="dateRange"
|
||||
@time-type-change="onTimeTypeChange"
|
||||
@custom-change="onCustomDateChange"
|
||||
/>
|
||||
<el-form-item label="自媒体渠道">
|
||||
<el-select
|
||||
v-model="queryParams.media_channel_code"
|
||||
@@ -67,11 +67,11 @@
|
||||
v-model="queryParams.remark"
|
||||
placeholder="备注 / 创建人 / 修改人"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -148,7 +148,9 @@
|
||||
<script lang="ts" setup name="accountCostList">
|
||||
import { accountCostDelete, accountCostLists } from '@/api/finance'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import feedback from '@/utils/feedback'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
|
||||
import EditPopup from './edit.vue'
|
||||
|
||||
@@ -172,18 +174,40 @@ const editRef = shallowRef<InstanceType<typeof EditPopup>>()
|
||||
const showEdit = ref(false)
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
media_channel_code: '',
|
||||
dept_id: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: accountCostLists,
|
||||
const {
|
||||
time_type,
|
||||
dateRange,
|
||||
resolve,
|
||||
handleTimeTypeChange,
|
||||
handleCustomDateChange,
|
||||
resetTimeFilter,
|
||||
} = useListTimeFilter('today')
|
||||
|
||||
const { pager, getLists } = usePaging({
|
||||
fetchFun: async (params: Record<string, any>) => {
|
||||
const [start_date, end_date] = resolve()
|
||||
return accountCostLists({
|
||||
...params,
|
||||
start_date,
|
||||
end_date,
|
||||
})
|
||||
},
|
||||
params: queryParams,
|
||||
})
|
||||
|
||||
const handleQuery = () => {
|
||||
pager.page = 1
|
||||
getLists()
|
||||
}
|
||||
|
||||
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
|
||||
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
|
||||
|
||||
const mediaChannelOptionsFlat = computed<MediaChannelOption[]>(() => {
|
||||
const options = pager.extend.media_channel_options
|
||||
if (!Array.isArray(options)) return []
|
||||
@@ -258,18 +282,27 @@ const handleDelete = async (id: number) => {
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
resetTimeFilter()
|
||||
queryParams.media_channel_code = ''
|
||||
queryParams.dept_id = ''
|
||||
queryParams.remark = ''
|
||||
resetPage()
|
||||
pager.page = 1
|
||||
pager.size = 15
|
||||
getLists()
|
||||
}
|
||||
|
||||
getLists()
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.account-cost-channel-select {
|
||||
:deep(.channel-opt-row) {
|
||||
display: flex;
|
||||
|
||||
@@ -174,9 +174,9 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="支付方式" width="100">
|
||||
<el-table-column label="支付方式" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getPaymentMethodText(row.payment_method) }}
|
||||
{{ getCreateTypeText(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || row.status === 2 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/split']"
|
||||
type="primary"
|
||||
link
|
||||
@@ -237,7 +237,7 @@
|
||||
小程序码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/pay']"
|
||||
type="success"
|
||||
link
|
||||
@@ -255,7 +255,7 @@
|
||||
退款
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1 || (row.status === 5 && row.payment_method === 'fubei')"
|
||||
v-if="row.status === 1 || (row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod'))"
|
||||
v-perms="['order.order/cancel']"
|
||||
type="danger"
|
||||
link
|
||||
@@ -327,7 +327,7 @@
|
||||
<span class="text-red-500 font-semibold">¥{{ detailData.amount }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付方式">
|
||||
{{ getPaymentMethodText(detailData.payment_method) }}
|
||||
{{ getCreateTypeText(detailData) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付时间">
|
||||
{{ detailData.payment_time || '-' }}
|
||||
@@ -434,7 +434,7 @@
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="mb-3"
|
||||
title="该单为付呗·待审核,请选择实际到账方式后确认「已支付」"
|
||||
title="该单为待审核(付呗/快递代收),请选择实际到账方式后确认「已支付」"
|
||||
/>
|
||||
<el-form-item v-if="!payForm.fubeiPendingAudit" label="支付类型" required>
|
||||
<el-radio-group v-model="payForm.payType">
|
||||
@@ -502,6 +502,7 @@
|
||||
<el-radio label="normal">普通订单</el-radio>
|
||||
<el-radio label="wechat_work">企业微信对外收款</el-radio>
|
||||
<el-radio label="fubei">付呗</el-radio>
|
||||
<el-radio label="express_cod">快递代收</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
@@ -524,7 +525,20 @@
|
||||
<template #title>付呗</template>
|
||||
通过付呗收款的支付单,创建后请按实际对账/审核流程在列表中处理。
|
||||
</el-alert>
|
||||
<el-form-item v-if="createForm.createType === 'fubei'" label="支付单审核">
|
||||
<el-alert
|
||||
v-if="createForm.createType === 'express_cod'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
>
|
||||
<template #title>快递代收</template>
|
||||
通过快递代收的支付单,流转与付呗一致;审核通过/确认到账后将更新为已支付。
|
||||
</el-alert>
|
||||
<el-form-item
|
||||
v-if="createForm.createType === 'fubei' || createForm.createType === 'express_cod'"
|
||||
label="支付单审核"
|
||||
>
|
||||
<el-switch
|
||||
v-model="createForm.requirePaymentSlipAudit"
|
||||
active-text="申请审核"
|
||||
@@ -980,8 +994,8 @@ const patientLoading = ref(false)
|
||||
const patientList = ref<any[]>([])
|
||||
|
||||
const createForm = reactive({
|
||||
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei',
|
||||
/** 仅「付呗」:开启后订单为待审核(5) */
|
||||
createType: 'normal' as 'normal' | 'wechat_work' | 'fubei' | 'express_cod',
|
||||
/** 「付呗」「快递代收」:开启后订单为待审核(5) */
|
||||
requirePaymentSlipAudit: false,
|
||||
patient_id: '',
|
||||
order_type: '',
|
||||
@@ -1148,7 +1162,8 @@ const submitCreateOrder = async () => {
|
||||
patient_id: createForm.patient_id,
|
||||
order_type: createForm.order_type,
|
||||
amount: createForm.amount,
|
||||
remark: createForm.remark
|
||||
remark: createForm.remark,
|
||||
create_type: 'wechat_work'
|
||||
}
|
||||
const res: any = await orderCreateForWechatWork(params)
|
||||
const orderNo = res?.order_no ?? res?.data?.order_no
|
||||
@@ -1160,18 +1175,23 @@ const submitCreateOrder = async () => {
|
||||
patient_id: createForm.patient_id,
|
||||
order_type: createForm.order_type,
|
||||
amount: createForm.amount,
|
||||
remark: createForm.remark
|
||||
remark: createForm.remark,
|
||||
create_type: createForm.createType
|
||||
}
|
||||
if (createForm.createType === 'fubei') {
|
||||
params.payment_channel = 'fubei'
|
||||
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
|
||||
} else if (createForm.createType === 'express_cod') {
|
||||
params.payment_channel = 'express_cod'
|
||||
params.require_payment_slip_audit = createForm.requirePaymentSlipAudit ? 1 : 0
|
||||
} else {
|
||||
params.payment_channel = 'normal'
|
||||
params.require_payment_slip_audit = 0
|
||||
}
|
||||
await orderCreate(params)
|
||||
const tip =
|
||||
createForm.createType === 'fubei' && createForm.requirePaymentSlipAudit
|
||||
(createForm.createType === 'fubei' || createForm.createType === 'express_cod') &&
|
||||
createForm.requirePaymentSlipAudit
|
||||
? '订单已创建,支付状态为「待审核」'
|
||||
: '订单创建成功'
|
||||
feedback.msgSuccess(tip)
|
||||
@@ -1247,6 +1267,27 @@ const getPaymentMethodText = (method: string | null) => {
|
||||
return methodMap[method] || '未知'
|
||||
}
|
||||
|
||||
// 支付方式展示:优先按「创建方式」三值显示;历史单/无 create_type 时回退真实支付渠道文案
|
||||
const getCreateTypeText = (row: any) => {
|
||||
const createTypeMap: Record<string, string> = {
|
||||
normal: '普通订单',
|
||||
wechat_work: '企业微信对外收款',
|
||||
fubei: '付呗',
|
||||
express_cod: '快递代收'
|
||||
}
|
||||
const ct = row?.create_type
|
||||
if (ct === 'wechat_work' || ct === 'fubei' || ct === 'express_cod') {
|
||||
return createTypeMap[ct]
|
||||
}
|
||||
if (ct === 'normal' && row?.payment_method) {
|
||||
return getPaymentMethodText(row.payment_method)
|
||||
}
|
||||
if (ct && createTypeMap[ct]) {
|
||||
return createTypeMap[ct]
|
||||
}
|
||||
return getPaymentMethodText(row?.payment_method)
|
||||
}
|
||||
|
||||
// 编辑订单
|
||||
const handleEditOrder = (row: any) => {
|
||||
editOrderForm.value = {
|
||||
@@ -1373,7 +1414,8 @@ const handleDetail = async (row: any) => {
|
||||
|
||||
// 支付订单
|
||||
const handlePay = (row: any) => {
|
||||
const fubeiPending = row.status === 5 && row.payment_method === 'fubei'
|
||||
const fubeiPending =
|
||||
row.status === 5 && (row.payment_method === 'fubei' || row.create_type === 'express_cod')
|
||||
payForm.value = {
|
||||
order_id: row.id,
|
||||
order_no: row.order_no,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div class="note-timeline-wrap">
|
||||
<div v-if="!readonly && diagnosisId" class="timeline-actions">
|
||||
<el-button
|
||||
v-if="hasAddDoctorNotePermission"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
@click="showNoteDialog = true"
|
||||
>
|
||||
添加备注
|
||||
</el-button>
|
||||
<material-picker
|
||||
v-model="tongueModel"
|
||||
:limit="99"
|
||||
@@ -95,16 +104,32 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!notes.length && readonly" description="暂无备注" :image-size="48" />
|
||||
|
||||
<el-dialog v-model="showNoteDialog" title="添加备注" width="480px" append-to-body>
|
||||
<el-input
|
||||
v-model="noteContent"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="输入今日备注..."
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="showNoteDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="noteSaving" @click="handleSaveNote">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CircleClose, Document } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import useAppStore from '@/stores/modules/app'
|
||||
import { addDoctorNote, deleteDoctorNoteImage } from '@/api/patient'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
export interface DoctorNoteItem {
|
||||
id: number
|
||||
@@ -122,6 +147,13 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
|
||||
/** 与接诊台悬浮「备注」按钮一致 */
|
||||
const hasAddDoctorNotePermission = computed(() => hasPermission(['doctor.appointment/addDoctorNote']))
|
||||
|
||||
const showNoteDialog = ref(false)
|
||||
const noteContent = ref('')
|
||||
const noteSaving = ref(false)
|
||||
|
||||
const appStore = useAppStore()
|
||||
const getImageUrl = (url: string) => appStore.getImageUrl(url)
|
||||
|
||||
@@ -201,6 +233,30 @@ watch(() => props.notes, () => {
|
||||
prevReportCount.value = 0
|
||||
})
|
||||
|
||||
const handleSaveNote = async () => {
|
||||
if (!props.diagnosisId) {
|
||||
feedback.msgWarning('当前无诊单,无法添加备注')
|
||||
return
|
||||
}
|
||||
const text = noteContent.value.trim()
|
||||
if (!text) {
|
||||
feedback.msgWarning('请输入备注内容')
|
||||
return
|
||||
}
|
||||
noteSaving.value = true
|
||||
try {
|
||||
await addDoctorNote({ diagnosis_id: props.diagnosisId, content: text })
|
||||
feedback.msgSuccess('备注保存成功')
|
||||
noteContent.value = ''
|
||||
showNoteDialog.value = false
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '保存失败')
|
||||
} finally {
|
||||
noteSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'report_files', imagePath: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' })
|
||||
@@ -217,6 +273,7 @@ const handleDeleteImage = async (noteId: number, imageType: 'tongue_images' | 'r
|
||||
.timeline-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>诊单金额占比</span>
|
||||
<span>{{ amountPieTitle }}</span>
|
||||
<span class="card-hint">TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -146,17 +146,17 @@
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>加粉占比</span>
|
||||
<span>{{ secondaryPieTitle }}</span>
|
||||
<span class="card-hint">TOP 10</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="fanPieHasData"
|
||||
v-if="secondaryPieHasData"
|
||||
class="stats-chart"
|
||||
:option="fanPieOption"
|
||||
:option="secondaryPieOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无加粉数据" />
|
||||
<el-empty v-else :description="secondaryPieEmptyText" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -311,10 +311,13 @@ const overview = reactive<Record<string, any>>({
|
||||
amounts: [],
|
||||
order_counts: [],
|
||||
fan_counts: [],
|
||||
rois: []
|
||||
rois: [],
|
||||
appointment_counts: [],
|
||||
interview_counts: []
|
||||
},
|
||||
amount_share: [],
|
||||
fan_share: []
|
||||
fan_share: [],
|
||||
order_share: []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -392,7 +395,7 @@ const currentEntityId = computed({
|
||||
}
|
||||
})
|
||||
|
||||
const summaryCards: MetricCard[] = [
|
||||
const defaultSummaryCards: MetricCard[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||
@@ -410,7 +413,15 @@ const summaryCards: MetricCard[] = [
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio' }
|
||||
]
|
||||
|
||||
const tableColumns: MetricColumn[] = [
|
||||
const doctorSummaryCards: MetricCard[] = [
|
||||
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||
{ key: 'completed_order_amount', label: '开药金额', type: 'money' },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent' }
|
||||
]
|
||||
|
||||
const defaultTableColumns: MetricColumn[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
|
||||
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
|
||||
@@ -431,6 +442,18 @@ const tableColumns: MetricColumn[] = [
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
|
||||
]
|
||||
|
||||
const doctorTableColumns: MetricColumn[] = [
|
||||
{ key: 'appointment_total_count', label: '挂号数', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊数', type: 'count' },
|
||||
{ key: 'completed_order_count', label: '接诊单数', type: 'count' },
|
||||
{ key: 'completed_order_amount', label: '开药金额', type: 'money', minWidth: 120 },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 }
|
||||
]
|
||||
|
||||
const isDoctorDimension = computed(() => queryParams.dimension === 'doctor')
|
||||
const summaryCards = computed(() => isDoctorDimension.value ? doctorSummaryCards : defaultSummaryCards)
|
||||
const tableColumns = computed(() => isDoctorDimension.value ? doctorTableColumns : defaultTableColumns)
|
||||
|
||||
const dateRangeText = computed(() => {
|
||||
if (!overview.date_range?.length) return '未选择'
|
||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||
@@ -438,7 +461,14 @@ const dateRangeText = computed(() => {
|
||||
|
||||
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
|
||||
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
|
||||
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
|
||||
const secondaryPieHasData = computed(() =>
|
||||
isDoctorDimension.value
|
||||
? (overview.charts.order_share?.length || 0) > 0
|
||||
: (overview.charts.fan_share?.length || 0) > 0
|
||||
)
|
||||
const amountPieTitle = computed(() => isDoctorDimension.value ? '开药金额占比' : '诊单金额占比')
|
||||
const secondaryPieTitle = computed(() => isDoctorDimension.value ? '接诊单数占比' : '加粉占比')
|
||||
const secondaryPieEmptyText = computed(() => isDoctorDimension.value ? '暂无接诊数据' : '暂无加粉数据')
|
||||
|
||||
const rankingChartData = computed(() => {
|
||||
const ranking = overview.charts?.ranking || {}
|
||||
@@ -448,56 +478,110 @@ const rankingChartData = computed(() => {
|
||||
return {
|
||||
names,
|
||||
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
|
||||
appointmentCounts: normalize(Array.isArray(ranking.appointment_counts) ? ranking.appointment_counts : []),
|
||||
interviewCounts: normalize(Array.isArray(ranking.interview_counts) ? ranking.interview_counts : []),
|
||||
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
|
||||
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
|
||||
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : [])
|
||||
}
|
||||
})
|
||||
|
||||
const rankingChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rankingChartData.value.names,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额 / 数量' }
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
const rankingChartOption = computed(() => {
|
||||
const common = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rankingChartData.value.names,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '接诊诊单',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '加粉数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.fanCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
yAxis: [
|
||||
{ type: 'value', name: '金额 / 数量' }
|
||||
]
|
||||
}
|
||||
|
||||
if (!isDoctorDimension.value) {
|
||||
return {
|
||||
...common,
|
||||
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
},
|
||||
{
|
||||
name: '接诊诊单',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '加粉数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.fanCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
...common,
|
||||
legend: { data: ['开药金额', '挂号数', '面诊数', '接诊单数'] },
|
||||
series: [
|
||||
{
|
||||
name: '开药金额',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rankingChartData.value.amounts,
|
||||
itemStyle: { color: '#4a78ff' }
|
||||
},
|
||||
{
|
||||
name: '挂号数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.appointmentCounts,
|
||||
itemStyle: { color: '#15b8a6' }
|
||||
},
|
||||
{
|
||||
name: '面诊数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.interviewCounts,
|
||||
itemStyle: { color: '#ff9f43' }
|
||||
},
|
||||
{
|
||||
name: '接诊单数',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 8,
|
||||
lineStyle: { width: 3 },
|
||||
data: rankingChartData.value.orderCounts,
|
||||
itemStyle: { color: '#7c3aed' }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const amountPieOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
@@ -511,7 +595,7 @@ const amountPieOption = computed(() => ({
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '诊单金额',
|
||||
name: amountPieTitle.value,
|
||||
type: 'pie',
|
||||
radius: ['42%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
@@ -531,7 +615,7 @@ const amountPieOption = computed(() => ({
|
||||
]
|
||||
}))
|
||||
|
||||
const fanPieOption = computed(() => ({
|
||||
const secondaryPieOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
@@ -543,7 +627,7 @@ const fanPieOption = computed(() => ({
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '加粉数',
|
||||
name: secondaryPieTitle.value,
|
||||
type: 'pie',
|
||||
radius: ['42%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
@@ -558,7 +642,7 @@ const fanPieOption = computed(() => ({
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
data: overview.charts.fan_share
|
||||
data: isDoctorDimension.value ? overview.charts.order_share : overview.charts.fan_share
|
||||
}
|
||||
]
|
||||
}))
|
||||
@@ -658,9 +742,18 @@ const handleReset = () => {
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
const calcDoctorReceiveRate = (source: Record<string, any>) => {
|
||||
const completedOrderCount = Number(source?.completed_order_count || 0)
|
||||
const interviewCount = Number(source?.interview_count || 0)
|
||||
if (interviewCount <= 0) return 0
|
||||
return (completedOrderCount / interviewCount) * 100
|
||||
}
|
||||
|
||||
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
|
||||
if (placeholder) return '—'
|
||||
const value = source?.[key] ?? 0
|
||||
const value = isDoctorDimension.value && key === 'receive_rate'
|
||||
? calcDoctorReceiveRate(source)
|
||||
: source?.[key] ?? 0
|
||||
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
<template>
|
||||
<div class="revisit-rate-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="rate-filter-form">
|
||||
<el-form-item label="统计月份">
|
||||
<el-date-picker
|
||||
v-model="month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择月份"
|
||||
:clearable="false"
|
||||
:disabled-date="disableFutureMonth"
|
||||
style="width: 160px"
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="deptId"
|
||||
:data="deptTreeOptions"
|
||||
placeholder="全部部门"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:props="deptTreeProps"
|
||||
style="width: 220px"
|
||||
@change="loadData"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
||||
<template #reference>
|
||||
<span class="rate-caliber-trigger">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
统计口径
|
||||
</span>
|
||||
</template>
|
||||
<div class="rate-caliber">
|
||||
<p>
|
||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重。
|
||||
</p>
|
||||
<p>
|
||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序的全局序号,<b>跨月累计不重置</b>——如 5 月指派后旗下成交 4 单为二诊~五诊,下月再成交即为六诊。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
||||
</p>
|
||||
<p>
|
||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
||||
</p>
|
||||
<p>
|
||||
医助按人事部门归组;选定部门时含其组织下级。
|
||||
</p>
|
||||
</div>
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="rate-kpi-grid">
|
||||
<div
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
class="rate-kpi-card"
|
||||
:class="{ 'is-clickable': card.clickable }"
|
||||
@click="card.clickable && card.onClick && card.onClick()"
|
||||
>
|
||||
<div class="rate-kpi-label">{{ card.label }}</div>
|
||||
<div class="rate-kpi-value">{{ card.value }}</div>
|
||||
<div v-if="card.sub" class="rate-kpi-sub">{{ card.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="rate-card-header">
|
||||
<span>部门 · 医助明细({{ monthText }})</span>
|
||||
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="rows"
|
||||
size="small"
|
||||
border
|
||||
row-key="row_key"
|
||||
:tree-props="{ children: 'children' }"
|
||||
:default-expand-all="true"
|
||||
show-summary
|
||||
:summary-method="getSummaries"
|
||||
>
|
||||
<el-table-column label="部门 / 医助" min-width="200" fixed>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.is_dept" class="rate-dept-cell">
|
||||
{{ row.dept_name }}
|
||||
<span class="rate-dept-count">{{ row.assistant_count }} 人</span>
|
||||
</span>
|
||||
<span v-else>{{ row.assistant_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openAssignDetail(row)"
|
||||
>
|
||||
{{ row.assigned_count }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.assigned_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template v-for="slot in visitSlots" :key="slot">
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_count`"
|
||||
:label="`${slotLabel(slot)}单数`"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ slotLabel(slot) }}单数</span>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="`当月内下单、诊次为第 ${slot} 次的订单数(诊次跨月累计;归属下单时点持有患者的医助)`"
|
||||
>
|
||||
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderLines && row[`visit${slot}_count`] > 0"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderDetail(slot, row)"
|
||||
>
|
||||
{{ row[`visit${slot}_count`] }}
|
||||
</el-link>
|
||||
<span v-else>{{ row[`visit${slot}_count`] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:prop="`visit${slot}_rate`"
|
||||
:label="`${slotLabel(slot)}接诊率`"
|
||||
min-width="96"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 下钻明细 -->
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
:title="detailTitle"
|
||||
width="980px"
|
||||
top="6vh"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-table
|
||||
v-loading="detailLoading"
|
||||
:data="detailRows"
|
||||
size="small"
|
||||
border
|
||||
stripe
|
||||
max-height="60vh"
|
||||
>
|
||||
<template v-if="detailType === 'assign'">
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>
|
||||
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="patient_phone" label="联系电话" min-width="120">
|
||||
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewOrderDetail && row.order_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openOrderInfo(row.order_id)"
|
||||
>
|
||||
{{ row.order_no || `#${row.order_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="canViewPatientDetail && row.diagnosis_id"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openPatientDetail(row.diagnosis_id)"
|
||||
>
|
||||
{{ row.patient_name || `诊单#${row.diagnosis_id}` }}
|
||||
</el-link>
|
||||
<span v-else>{{ row.patient_name || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" label="金额" width="100" align="right">
|
||||
<template #default="{ row }">¥ {{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time_text" label="下单时间" min-width="160" />
|
||||
<el-table-column prop="assistant_name" label="持有医助" min-width="100" />
|
||||
<el-table-column prop="creator_name" label="订单创建人" min-width="100" />
|
||||
</template>
|
||||
</el-table>
|
||||
<div class="rate-detail-footer">共 {{ detailRows.length }} 条</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 订单详情抽屉(共享组件,与处方订单列表详情一致;只读) -->
|
||||
<order-detail-drawer ref="orderDetailRef" readonly append-to-body />
|
||||
|
||||
<!-- 诊单(患者)详情:编辑页只读模式 -->
|
||||
<diagnosis-edit-popup ref="diagnosisEditRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="revisitRateStatsPage">
|
||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
|
||||
import {
|
||||
revisitRateAssignLines,
|
||||
revisitRateDeptOptions,
|
||||
revisitRateOverview,
|
||||
revisitRateVisitOrderLines
|
||||
} from '@/api/stats'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
/** 业务订单详情抽屉(共享组件,readonly 模式,与 order_list.vue 详情同源) */
|
||||
const OrderDetailDrawer = defineAsyncComponent(
|
||||
() => import('@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue')
|
||||
)
|
||||
/** 诊单编辑弹窗(只读打开) */
|
||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
interface MetricPack extends Record<string, any> {
|
||||
assigned_count: number
|
||||
}
|
||||
|
||||
interface StatsRow extends MetricPack {
|
||||
row_key: string
|
||||
is_dept: 0 | 1
|
||||
dept_id: number
|
||||
dept_name: string
|
||||
assistant_id?: number
|
||||
assistant_name?: string
|
||||
assistant_count?: number
|
||||
children?: StatsRow[]
|
||||
}
|
||||
|
||||
interface DeptTreeNode {
|
||||
id: number
|
||||
name: string
|
||||
pid: number
|
||||
children?: DeptTreeNode[]
|
||||
}
|
||||
|
||||
const canViewAssignLines = hasPermission(['stats.revisitRate/assignLines'])
|
||||
const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||
|
||||
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
||||
const visitSlots = ref<number[]>([2, 3, 4])
|
||||
|
||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||
const slotLabel = (slot: number) => {
|
||||
if (slot >= 2 && slot <= 10) {
|
||||
return `${cnDigits[slot]}诊`
|
||||
}
|
||||
if (slot > 10 && slot < 20) {
|
||||
return `十${cnDigits[slot - 10]}诊`
|
||||
}
|
||||
return `${slot}诊`
|
||||
}
|
||||
|
||||
const initNow = new Date()
|
||||
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||
const month = ref<string>(currentMonth)
|
||||
const deptId = ref<number | undefined>(undefined)
|
||||
const loading = ref(false)
|
||||
const rows = ref<StatsRow[]>([])
|
||||
const total = ref<MetricPack | null>(null)
|
||||
|
||||
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
|
||||
const monthText = computed(() => {
|
||||
const [y, m] = month.value.split('-')
|
||||
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||
})
|
||||
|
||||
const formatRate = (rate: number | null | undefined) =>
|
||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||
|
||||
const baseQuery = () => ({
|
||||
month: month.value,
|
||||
dept_ids: deptId.value ? String(deptId.value) : ''
|
||||
})
|
||||
|
||||
interface SummaryCard {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
sub: string
|
||||
clickable: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const summaryCards = computed<SummaryCard[]>(() => {
|
||||
const t = total.value
|
||||
const cards: SummaryCard[] = [
|
||||
{
|
||||
key: 'assigned',
|
||||
label: '当月被指派总数',
|
||||
value: t ? String(t.assigned_count) : '—',
|
||||
sub: '',
|
||||
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||
onClick: () => openAssignDetail(null)
|
||||
}
|
||||
]
|
||||
for (const slot of visitSlots.value) {
|
||||
const cnt = t ? t[`visit${slot}_count`] : null
|
||||
const rate = t ? t[`visit${slot}_rate`] : null
|
||||
cards.push({
|
||||
key: `visit${slot}`,
|
||||
label: `当月${slotLabel(slot)}接诊率`,
|
||||
value: t ? formatRate(rate) : '—',
|
||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
||||
clickable: !!(canViewOrderLines && cnt > 0),
|
||||
onClick: () => openOrderDetail(slot, null)
|
||||
})
|
||||
}
|
||||
return cards
|
||||
})
|
||||
|
||||
/** 底栏合计:直接展示后端按诊单去重后的合计,不做各行求和 */
|
||||
const getSummaries = ({ columns }: { columns: any[] }) => {
|
||||
const t = total.value
|
||||
return columns.map((col, index) => {
|
||||
if (index === 0) {
|
||||
return '合计'
|
||||
}
|
||||
if (!t) {
|
||||
return ''
|
||||
}
|
||||
const prop = String(col.property || '')
|
||||
if (prop === 'assigned_count') {
|
||||
return String(t.assigned_count)
|
||||
}
|
||||
if (/^visit\d+_count$/.test(prop)) {
|
||||
return String(t[prop] ?? '')
|
||||
}
|
||||
if (/^visit\d+_rate$/.test(prop)) {
|
||||
return formatRate(t[prop])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
const disableFutureMonth = (date: Date) => {
|
||||
const now = new Date()
|
||||
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await revisitRateOverview(baseQuery())
|
||||
rows.value = res?.rows ?? []
|
||||
total.value = res?.total ?? null
|
||||
if (Array.isArray(res?.slots) && res.slots.length) {
|
||||
visitSlots.value = res.slots.map((s: any) => Number(s)).filter((s: number) => s >= 2)
|
||||
}
|
||||
if (res?.month) {
|
||||
month.value = res.month
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||
const map = new Map<number, DeptTreeNode>()
|
||||
for (const d of flat) {
|
||||
map.set(d.id, { ...d, children: [] })
|
||||
}
|
||||
const roots: DeptTreeNode[] = []
|
||||
for (const d of flat) {
|
||||
const node = map.get(d.id)!
|
||||
if (d.pid > 0 && map.has(d.pid)) {
|
||||
map.get(d.pid)!.children!.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
const strip = (n: DeptTreeNode) => {
|
||||
if (n.children?.length) {
|
||||
n.children.forEach(strip)
|
||||
} else {
|
||||
delete n.children
|
||||
}
|
||||
}
|
||||
roots.forEach(strip)
|
||||
return roots
|
||||
}
|
||||
|
||||
const loadDeptOptions = async () => {
|
||||
try {
|
||||
const res = await revisitRateDeptOptions()
|
||||
deptTreeOptions.value = buildDeptTree(res?.rows ?? [])
|
||||
} catch {
|
||||
deptTreeOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ── 下钻明细 ──────────────────────────────────────────
|
||||
const detailVisible = ref(false)
|
||||
const detailType = ref<'assign' | 'orders'>('assign')
|
||||
const detailTitle = ref('')
|
||||
const detailLoading = ref(false)
|
||||
const detailRows = ref<any[]>([])
|
||||
|
||||
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||
if (!row) {
|
||||
return { label: '合计' }
|
||||
}
|
||||
if (row.is_dept) {
|
||||
return { dept_id: row.dept_id, label: row.dept_name }
|
||||
}
|
||||
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||
}
|
||||
|
||||
const openAssignDetail = async (row: StatsRow | null) => {
|
||||
if (!canViewAssignLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'assign'
|
||||
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailRows.value = []
|
||||
try {
|
||||
const res = await revisitRateAssignLines({
|
||||
...baseQuery(),
|
||||
assistant_id: scope.assistant_id,
|
||||
dept_id: scope.dept_id
|
||||
})
|
||||
detailRows.value = res?.rows ?? []
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||
if (!canViewOrderLines) {
|
||||
return
|
||||
}
|
||||
const scope = rowScope(row)
|
||||
detailType.value = 'orders'
|
||||
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailRows.value = []
|
||||
try {
|
||||
const res = await revisitRateVisitOrderLines({
|
||||
...baseQuery(),
|
||||
slot,
|
||||
assistant_id: scope.assistant_id,
|
||||
dept_id: scope.dept_id
|
||||
})
|
||||
detailRows.value = res?.rows ?? []
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 订单详情(复用处方订单列表的详情抽屉) ──────────────
|
||||
const orderDetailRef = ref<{ open: (id: number) => void } | null>(null)
|
||||
|
||||
const openOrderInfo = (orderId: number) => {
|
||||
if (!canViewOrderDetail || !orderId) {
|
||||
return
|
||||
}
|
||||
orderDetailRef.value?.open(orderId)
|
||||
}
|
||||
|
||||
// ── 患者(诊单)详情:诊单编辑页只读打开 ────────────────
|
||||
const diagnosisEditRef = ref<{ openViewOnly: (id: number) => void } | null>(null)
|
||||
|
||||
const openPatientDetail = (diagnosisId: number) => {
|
||||
if (!canViewPatientDetail || !diagnosisId) {
|
||||
return
|
||||
}
|
||||
diagnosisEditRef.value?.openViewOnly(diagnosisId)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
month.value = currentMonth
|
||||
deptId.value = undefined
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDeptOptions()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revisit-rate-page {
|
||||
.rate-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-caliber-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.rate-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.rate-kpi-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
|
||||
&.is-clickable {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-kpi-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rate-kpi-value {
|
||||
margin-top: 6px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rate-kpi-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
|
||||
.rate-card-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-dept-cell {
|
||||
font-weight: 600;
|
||||
|
||||
.rate-dept-count {
|
||||
margin-left: 6px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.rate-th-hint {
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-detail-footer {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rate-caliber {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
p + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,13 +14,14 @@
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
||||
<el-form-item label="日期范围">
|
||||
<daterange-picker
|
||||
v-model:startTime="queryParams.start_date"
|
||||
v-model:endTime="queryParams.end_date"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||
<list-time-filter
|
||||
v-model:time-type="time_type"
|
||||
v-model:date-range="dateRange"
|
||||
@time-type-change="onTimeTypeChange"
|
||||
@custom-change="onCustomDateChange"
|
||||
/>
|
||||
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="queryParams.dept_id"
|
||||
@@ -33,7 +34,7 @@
|
||||
clearable
|
||||
filterable
|
||||
class="w-[220px]"
|
||||
@change="resetPage"
|
||||
@change="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自媒体来源">
|
||||
@@ -44,7 +45,7 @@
|
||||
placeholder="全部来源"
|
||||
:allow-create="false"
|
||||
select-class="w-[220px]"
|
||||
@change="resetPage"
|
||||
@change="handleQuery"
|
||||
@visible-change="onMediaSourceDropdownVisible"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -53,11 +54,11 @@
|
||||
v-model="queryParams.remark"
|
||||
placeholder="备注 / 创建人"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
||||
</el-form-item>
|
||||
@@ -146,6 +147,8 @@ import feedback from '@/utils/feedback'
|
||||
import CostEditPopup from './cost-edit.vue'
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
|
||||
const router = useRouter()
|
||||
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
|
||||
@@ -173,25 +176,48 @@ const goSelfInput = () => {
|
||||
}
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
media_source: '',
|
||||
dept_id: undefined as number | undefined,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const {
|
||||
time_type,
|
||||
dateRange,
|
||||
resolve,
|
||||
handleTimeTypeChange,
|
||||
handleCustomDateChange,
|
||||
resetTimeFilter,
|
||||
} = useListTimeFilter('today')
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: personalAccountCostLists,
|
||||
fetchFun: async (params: Record<string, any>) => {
|
||||
const [start_date, end_date] = resolve()
|
||||
return personalAccountCostLists({
|
||||
...params,
|
||||
start_date,
|
||||
end_date,
|
||||
})
|
||||
},
|
||||
params: queryParams,
|
||||
})
|
||||
|
||||
const handleQuery = () => {
|
||||
pager.page = 1
|
||||
getLists()
|
||||
}
|
||||
|
||||
const onTimeTypeChange = () => handleTimeTypeChange(handleQuery)
|
||||
const onCustomDateChange = () => handleCustomDateChange(handleQuery)
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
resetTimeFilter()
|
||||
queryParams.media_source = ''
|
||||
queryParams.dept_id = undefined
|
||||
queryParams.remark = ''
|
||||
resetPage()
|
||||
pager.page = 1
|
||||
pager.size = 15
|
||||
getLists()
|
||||
}
|
||||
|
||||
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||
@@ -236,9 +262,10 @@ const loadDeptOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadDeptOptions()
|
||||
loadMediaSourceOptions()
|
||||
await getLists()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -247,6 +274,12 @@ onMounted(() => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-cell {
|
||||
cursor: help;
|
||||
border-bottom: 1px dashed #c0c4cc;
|
||||
|
||||
@@ -556,7 +556,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
}
|
||||
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
if (!value) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
@@ -572,6 +576,7 @@ const formRules = {
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
||||
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
|
||||
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
|
||||
@@ -56,298 +56,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 业务订单详情抽屉(只读,与 order_list.vue 详情保持一致) -->
|
||||
<el-drawer
|
||||
v-model="detailVisible"
|
||||
size="80%"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<span class="font-semibold text-lg">业务订单详情</span>
|
||||
<el-tag v-if="detailData?.order_no" type="primary" effect="plain" round class="ml-3">{{ detailData.order_no }}</el-tag>
|
||||
<el-tag v-if="detailData?.gancao_reciperl_order_no" type="success" effect="plain" round class="ml-2">甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-skeleton v-if="detailLoading" :rows="12" animated class="px-6 py-4" />
|
||||
<div v-else-if="detailData" class="px-6 pb-6 overflow-y-auto">
|
||||
|
||||
<!-- 顶部数据概览 -->
|
||||
<el-row :gutter="16" class="mb-5">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">总金额</div>
|
||||
<div class="text-red-500 font-bold text-xl">¥{{ detailData.amount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">已付总额</div>
|
||||
<div class="text-green-600 font-bold text-xl">¥{{ detailData.linked_pay_paid_total || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">需代收</div>
|
||||
<div class="font-bold text-xl" :class="detailAgencyToCollect > 0 ? 'text-amber-600' : 'text-gray-500'">¥{{ formatMoney(detailAgencyToCollect) }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="border border-gray-100 bg-gray-50/50">
|
||||
<div class="text-gray-500 text-xs mb-1">已付笔数</div>
|
||||
<div class="text-primary font-bold text-xl">{{ detailLinkedPayOrders.length }} 笔</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<!-- 左侧:处方单(不含测试价格、改患者信息、药材明细) -->
|
||||
<el-card shadow="never" class="border-gray-100 h-full relative overflow-hidden">
|
||||
<div v-if="detailData.prescription_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
|
||||
<div v-if="detailData.prescription_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-[15px]">处方详情</span>
|
||||
<span v-if="detailData.doctor_name" class="text-xs text-gray-500">
|
||||
开方人:<span class="text-primary font-medium">{{ detailData.doctor_name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">ID: {{ detailData.prescription_id || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="detailPrescription">
|
||||
<el-descriptions :column="2" border size="small" class="bg-white">
|
||||
<el-descriptions-item label="处方编号" :span="2">{{ detailPrescription.sn || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="患者">{{ detailPrescription.patient_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别 / 年龄">{{ detailPrescription.gender_desc || '—' }} · {{ detailPrescription.age ?? '—' }}岁</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">
|
||||
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold' : ''">{{ detailPrescription.phone || '—' }}</span>
|
||||
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与订单手机不一致</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
|
||||
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
|
||||
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方金额">
|
||||
<span class="text-red-500 font-medium">¥{{ detailPrescription.amount ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="消费者审核">
|
||||
<el-tag :type="consumerRxAuditTag(detailPrescription.audit_status)" size="small">{{ consumerRxAuditText(detailPrescription.audit_status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="服用方式">
|
||||
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
||||
<div>
|
||||
<span class="text-gray-500">每天次数:</span>
|
||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处方开立:</span>
|
||||
{{ detailPrescription.usage_days != null && detailPrescription.usage_days !== '' ? detailPrescription.usage_days + ' 天' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">订单设置:</span>
|
||||
{{ detailData.medication_days != null && String(detailData.medication_days).trim() !== '' ? detailData.medication_days + ' 天' : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="2">
|
||||
<el-tag :type="Number(detailPrescription.void_status) === 1 ? 'danger' : 'success'" size="small">{{ Number(detailPrescription.void_status) === 1 ? '已作废' : '正常' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<el-empty v-else description="无处方数据" :image-size="72" />
|
||||
</el-card>
|
||||
|
||||
<!-- 右侧:未关联 + 已关联支付单 -->
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<el-card shadow="never" class="border-gray-100">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-[15px]">未关联的收款记录</span>
|
||||
<span class="text-xs text-gray-400">本诊单下未被占用的支付单</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table v-if="detailUnlinkedPayOrders.length" :data="detailUnlinkedPayOrders" size="small" border stripe max-height="280">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="86" align="right">
|
||||
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="136">
|
||||
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="无未关联的收款单" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="border-gray-100 relative overflow-hidden">
|
||||
<div v-if="detailData.payment_slip_audit_status === 1" class="audit-stamp stamp-pass"><div class="stamp-inner">审核通过</div></div>
|
||||
<div v-if="detailData.payment_slip_audit_status === 2" class="audit-stamp stamp-reject"><div class="stamp-inner">已驳回</div></div>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-[15px]">关联收款记录</span>
|
||||
<span v-if="detailData.creator_name" class="text-xs text-gray-500">创建人:<span class="text-primary font-medium">{{ detailData.creator_name }}</span></span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table v-if="detailLinkedPayOrders.length" :data="detailLinkedPayOrders" size="small" border stripe max-height="280">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="order_no" label="单号" min-width="135" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">{{ row.order_type_desc || feeTypeText(row.order_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="86" align="right">
|
||||
<template #default="{ row }"><span class="text-red-500">¥{{ row.amount }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="82" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="payOrderStatusTag(row.status)" size="small">{{ row.status_desc || orderStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator_name" label="创建人" width="80" show-overflow-tooltip />
|
||||
<el-table-column label="创建时间" min-width="136">
|
||||
<template #default="{ row }">{{ formatOrderTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="未关联收款单" :image-size="60" />
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 履约与收货信息 -->
|
||||
<el-card shadow="never" class="border-gray-100 mb-4">
|
||||
<template #header><span class="font-medium text-[15px]">履约与收货信息</span></template>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="诊单ID" :span="3"><span class="font-mono">{{ detailData.diagnosis_id }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务订单创建人" :span="2">
|
||||
<div v-if="detailData.creator_name" class="text-[13px] space-y-1">
|
||||
<div><span class="font-medium text-gray-900">{{ detailData.creator_name || '—' }}</span></div>
|
||||
<div v-if="detailOrderCreatorMetaLine" class="text-gray-600 text-xs">{{ detailOrderCreatorMetaLine }}</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人所属部门" :span="3">
|
||||
<div v-if="detailOrderCreatorDeptBreadcrumbs.length" class="space-y-1.5">
|
||||
<div v-for="(segments, idx) in detailOrderCreatorDeptBreadcrumbs" :key="idx">
|
||||
<el-breadcrumb v-if="segments.length" class="text-[13px]" separator="/">
|
||||
<el-breadcrumb-item v-for="(name, j) in segments" :key="j"><span class="text-gray-800">{{ name }}</span></el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货手机">
|
||||
<span :class="detailRecipientRxPhoneMismatch ? 'text-red-600 font-bold font-mono' : 'font-mono'">{{ detailData.recipient_phone || '—' }}</span>
|
||||
<el-tag v-if="detailRecipientRxPhoneMismatch" type="danger" size="small" effect="dark" class="ml-2">与处方手机不一致</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地址">{{ detailFullAddress }}</el-descriptions-item>
|
||||
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用药疗程">{{ detailData.medication_days ? detailData.medication_days + ' 天' : '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ formatServicePackage(detailData.service_package) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="快递单号">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="快递公司">{{ expressCompanyLabel(detailData.express_company) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-perms="['tcm.prescriptionOrder/editRemarkExtra']" label="药房备注" :span="3">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医助备注" :span="3">{{ detailData.remark_assistant || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核意见" :span="3">{{ detailData.prescription_audit_remark || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付审核意见" :span="3">{{ detailData.payment_slip_audit_remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 物流轨迹(只读) -->
|
||||
<el-card v-if="String(detailData.tracking_number || '').trim()" shadow="never" class="border-gray-100 mb-4">
|
||||
<template #header><span class="font-medium text-[15px]">物流轨迹</span></template>
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<el-input v-model="logisticsTracePhoneTail" clearable maxlength="20" placeholder="收件手机" class="w-[180px]" @keyup.enter="fetchLogisticsTrace" />
|
||||
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
|
||||
<el-option label="自动识别" value="auto" />
|
||||
<el-option label="顺丰速运" value="sf" />
|
||||
<el-option label="京东快递" value="jd" />
|
||||
<el-option label="极兔速递" value="jt" />
|
||||
</el-select>
|
||||
<el-button v-perms="['tcm.prescriptionOrder/logisticsTrace']" type="primary" plain size="default" :loading="logisticsTraceLoading" @click="fetchLogisticsTrace">刷新轨迹</el-button>
|
||||
</div>
|
||||
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="py-6 text-center text-sm text-gray-500">正在加载物流轨迹...</div>
|
||||
<div v-else-if="logisticsTracePayload" class="bg-gray-50/50 p-4 rounded-md">
|
||||
<div v-if="logisticsTracePayload.state_text" class="text-sm mb-4 font-medium flex items-center gap-2">
|
||||
<span class="text-gray-800">状态:{{ logisticsTracePayload.state_text }}</span>
|
||||
<el-tag v-if="logisticsTracePayload.carrier_label" size="small" type="info">{{ logisticsTracePayload.carrier_label }}</el-tag>
|
||||
</div>
|
||||
<el-timeline v-if="logisticsTraceList.length" class="mt-2 pl-2">
|
||||
<el-timeline-item v-for="(t, idx) in logisticsTraceList" :key="idx" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="t.time" placement="top">
|
||||
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ t.context }}</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无轨迹节点记录" :image-size="64" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<el-card v-perms="['tcm.prescriptionOrder/logs']" shadow="never" class="border-gray-100">
|
||||
<template #header><span class="font-medium text-[15px]">操作日志</span></template>
|
||||
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
|
||||
<el-timeline-item v-for="(log, idx) in detailLogs" :key="log.id" :type="idx === 0 ? 'primary' : 'info'" :hollow="idx !== 0" :timestamp="formatTime(log.create_time)" placement="top">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-[13px]">{{ log.admin_name }}</span>
|
||||
<el-tag size="small" type="info">{{ logActionText(log.action) }}</el-tag>
|
||||
</div>
|
||||
<span :class="idx === 0 ? 'text-gray-800 font-medium' : 'text-gray-500'">{{ log.summary }}</span>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无操作日志" :image-size="64" />
|
||||
</el-card>
|
||||
</div>
|
||||
</el-drawer>
|
||||
<!-- 业务订单详情抽屉:共享组件的 readonly 受限版(展示范围由组件内 v-if="!readonly" 门控) -->
|
||||
<prescription-order-detail-drawer ref="detailDrawerRef" readonly append-to-body />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref, onMounted } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||
import {
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderLogs,
|
||||
prescriptionOrderLogisticsTrace,
|
||||
prescriptionOrderPaidPayOrders
|
||||
} from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
formatTime,
|
||||
fulfillmentText,
|
||||
fulfillmentTagType
|
||||
} from '@/views/consumer/prescription/components/prescription-order-utils'
|
||||
|
||||
const props = defineProps<{
|
||||
diagnosisId: number
|
||||
@@ -379,175 +102,11 @@ const buildParams = () => {
|
||||
queryParams.scene = 'diagnosis_edit'
|
||||
}
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
// ─── 详情抽屉(共享组件,数据拉取/展示全部在组件内) ───
|
||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」相关字段
|
||||
*/
|
||||
const showPrescriptionAuditFilter = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
|
||||
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||
})
|
||||
|
||||
// ─── 详情抽屉 ───
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<any>(null)
|
||||
const detailLogs = ref<any[]>([])
|
||||
const detailUnlinkedPayOrders = ref<any[]>([])
|
||||
|
||||
const detailPrescription = computed(() => {
|
||||
const p = detailData.value?.prescription
|
||||
return p && typeof p === 'object' ? p : null
|
||||
})
|
||||
|
||||
const detailLinkedPayOrders = computed(() => {
|
||||
const arr = detailData.value?.linked_pay_orders
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const detailAgencyToCollect = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return 0
|
||||
const total = Number(d.amount) || 0
|
||||
const paid = Number(d.linked_pay_paid_total) || 0
|
||||
return Math.round((total - paid) * 100) / 100
|
||||
})
|
||||
|
||||
const detailRecipientRxPhoneMismatch = computed(() => {
|
||||
const rx = normalizeBizPhone(detailPrescription.value?.phone)
|
||||
const biz = normalizeBizPhone(detailData.value?.recipient_phone)
|
||||
if (!rx || !biz) return false
|
||||
return rx !== biz
|
||||
})
|
||||
|
||||
const detailFullAddress = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return '—'
|
||||
const parts = []
|
||||
if (d.shipping_province) parts.push(d.shipping_province)
|
||||
if (d.shipping_city) parts.push(d.shipping_city)
|
||||
if (d.shipping_district) parts.push(d.shipping_district)
|
||||
if (d.shipping_address) parts.push(d.shipping_address)
|
||||
return parts.length > 0 ? parts.join(' ') : '—'
|
||||
})
|
||||
|
||||
const detailOrderCreatorMetaLine = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return ''
|
||||
const account = String(d.creator_account ?? '').trim()
|
||||
const mobile = String(d.creator_mobile ?? '').trim()
|
||||
const parts: string[] = []
|
||||
if (account) parts.push(`登录账号:${account}`)
|
||||
if (mobile) parts.push(`手机:${mobile}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
const detailOrderCreatorDeptBreadcrumbs = computed(() => {
|
||||
const raw = String(detailData.value?.order_creator_dept_path ?? detailData.value?.diagnosis_creator_dept_path ?? '').trim()
|
||||
if (!raw) return [] as string[][]
|
||||
return raw.split(';').map((s) => s.trim()).filter(Boolean)
|
||||
.map((p) => p.includes(' / ') ? p.split(' / ').map((x) => x.trim()).filter(Boolean) : p.split('/').map((x) => x.trim()).filter(Boolean))
|
||||
.filter((segs) => segs.length > 0)
|
||||
})
|
||||
|
||||
// ─── 物流轨迹 ───
|
||||
const detailLogisticsExpress = ref<string>('auto')
|
||||
const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
|
||||
const logisticsTraceList = computed(() => {
|
||||
const p = logisticsTracePayload.value
|
||||
if (!p || !Array.isArray(p.traces)) return []
|
||||
return p.traces as Array<{ time: string; context: string }>
|
||||
})
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id,
|
||||
express_company: detailLogisticsExpress.value
|
||||
}
|
||||
if (digits.length >= 4) params.phone_tail = digits
|
||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
||||
} catch {
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 打开详情 ───
|
||||
async function openDetail(id: number) {
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
logisticsTracePayload.value = null
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
logisticsTracePhoneTail.value = ''
|
||||
detailLogs.value = []
|
||||
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const d = res?.data ?? res ?? null
|
||||
detailData.value = d
|
||||
if (d) {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (String(d.tracking_number || '').trim()) fetchLogisticsTrace()
|
||||
fetchLogs(id)
|
||||
if (d.diagnosis_id) {
|
||||
loadDetailUnlinkedPayOrders(d.diagnosis_id, id, d.pay_order_ids || [])
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '加载详情失败')
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
|
||||
if (!diagnosisId) { detailUnlinkedPayOrders.value = []; return }
|
||||
try {
|
||||
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: diagnosisId, prescription_order_id: prescriptionOrderId })
|
||||
const lists = res?.lists ?? res?.data?.lists ?? []
|
||||
detailUnlinkedPayOrders.value = Array.isArray(lists) ? lists.filter((item: any) => !linkedIds.includes(item.id)) : []
|
||||
} catch { detailUnlinkedPayOrders.value = [] }
|
||||
}
|
||||
|
||||
function canViewPrescriptionOrderLogs() {
|
||||
const p = userStore.perms || []
|
||||
return p.includes('*') || p.includes('tcm.prescriptionOrder/logs')
|
||||
}
|
||||
|
||||
async function fetchLogs(id: number) {
|
||||
if (!canViewPrescriptionOrderLogs()) { detailLogs.value = []; return }
|
||||
try {
|
||||
const res: any = await prescriptionOrderLogs({ id })
|
||||
detailLogs.value = res?.data ?? res ?? []
|
||||
} catch { detailLogs.value = [] }
|
||||
}
|
||||
|
||||
// ─── 工具函数 ───
|
||||
function normalizeBizPhone(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
return String(v).replace(/\s/g, '').trim()
|
||||
function openDetail(id: number) {
|
||||
detailDrawerRef.value?.open(id)
|
||||
}
|
||||
|
||||
const formatAmount = (value: unknown) => {
|
||||
@@ -555,126 +114,6 @@ const formatAmount = (value: unknown) => {
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
|
||||
}
|
||||
|
||||
function formatMoney(v: unknown) {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
|
||||
}
|
||||
|
||||
const formatTime = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (typeof value === 'number' && value > 1e9 && value < 1e11) {
|
||||
const d = new Date(value * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatOrderTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'string' && String(v).includes('-')) return String(v)
|
||||
return formatTime(v)
|
||||
}
|
||||
|
||||
function feeTypeText(t: number | undefined) {
|
||||
const m: Record<number, string> = { 1: '药费', 2: '诊费', 3: '药费+诊费', 4: '其他' }
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
// 服务套餐字典选项(与 order_list.vue 数据源一致:dict 类型 server_order)
|
||||
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
async function loadServicePackageOptions() {
|
||||
try {
|
||||
const data: any = await getDictData({ type: 'server_order' })
|
||||
const list = (data?.server_order || []) as Array<{ name: string; value: string; status?: number }>
|
||||
servicePackageOptions.value = list.filter((item) => item.status !== 0)
|
||||
} catch {
|
||||
servicePackageOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatServicePackage(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
let packages: string[] = []
|
||||
if (Array.isArray(value)) {
|
||||
packages = value.map((v) => String(v)).filter((v) => v !== '')
|
||||
} else if (typeof value === 'string') {
|
||||
packages = value.split(',').map((v) => v.trim()).filter((v) => v !== '')
|
||||
}
|
||||
if (packages.length === 0) return '—'
|
||||
const names = packages.map((val) => {
|
||||
const opt = servicePackageOptions.value.find((o) => o.value === val)
|
||||
return opt ? opt.name : val
|
||||
})
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadServicePackageOptions()
|
||||
})
|
||||
|
||||
function expressCompanyLabel(v: unknown) {
|
||||
const s = String(v || '').toLowerCase()
|
||||
if (s === 'sf') return '顺丰速运'
|
||||
if (s === 'jd') return '京东快递'
|
||||
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||
return '自动识别'
|
||||
}
|
||||
|
||||
function consumerRxAuditText(s: number | undefined) {
|
||||
const n = Number(s)
|
||||
if (n === 1) return '已通过'
|
||||
if (n === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
function consumerRxAuditTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 1) return 'success'
|
||||
if (n === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function payOrderStatusTag(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
const n = Number(s)
|
||||
if (n === 2) return 'success'
|
||||
if (n === 1) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function orderStatusText(s: number | undefined) {
|
||||
const m: Record<number, string> = { 0: '待支付', 1: '待支付', 2: '已支付', 3: '已取消' }
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
const fulfillmentText = (s: number | undefined) => {
|
||||
const m: Record<number, string> = { 1: '待双审通过', 2: '待发货', 3: '已完成', 4: '已取消', 5: '已发货', 6: '已签收', 7: '进行中', 8: '暂不制药', 9: '拒收', 10: '退款', 11: '保留药方', 12: '制药缓发' }
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
const fulfillmentTagType = (s: number | undefined): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
|
||||
const n = Number(s)
|
||||
if (n === 3 || n === 6) return 'success'
|
||||
if (n === 5) return 'primary'
|
||||
if (n === 4 || n === 9 || n === 10) return 'danger'
|
||||
if (n === 2 || n === 7) return 'warning'
|
||||
if (n === 8 || n === 11 || n === 12) return 'info'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function logActionText(act: string) {
|
||||
const m: Record<string, string> = {
|
||||
create: '创建', edit: '编辑', audit_rx_approve: '处方审核', audit_rx_reject: '处方审核',
|
||||
audit_pay_approve: '支付审核', audit_pay_reject: '支付审核', fill_tracking: '填快递单',
|
||||
ship: '确认发货', withdraw: '撤销', link_pay_order: '关联支付单',
|
||||
revoke_rx_audit: '撤回处方审核', revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单', patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额', complete: '完成订单'
|
||||
}
|
||||
return m[act] || act
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.diagnosisId, patientIdNum.value] as const,
|
||||
() => {
|
||||
@@ -695,22 +134,4 @@ defineExpose({ refresh: () => getLists() })
|
||||
.po-empty-tip {
|
||||
padding: 24px 0;
|
||||
}
|
||||
.audit-stamp {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
transform: rotate(15deg);
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.stamp-inner {
|
||||
border: 3px solid currentColor;
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.stamp-pass { color: #22c55e; }
|
||||
.stamp-reject { color: #ef4444; }
|
||||
</style>
|
||||
|
||||
@@ -67,26 +67,66 @@
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
|
||||
<el-alert
|
||||
v-if="mode === 'edit' && patientBasicLocked && !canEditPatientBasicFields"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-3"
|
||||
>
|
||||
<template v-if="latestPrescriptionOrder">
|
||||
最近业务订单 #{{ latestPrescriptionOrder.id }} 状态为「{{ latestPrescriptionOrder.fulfillment_status_text }}」,患者基本信息不可修改
|
||||
</template>
|
||||
<template v-else>
|
||||
存在未完成的业务订单,患者基本信息不可修改
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-form-item label="诊单ID">
|
||||
<el-input v-model="formData.patient_id" disabled placeholder="系统自动生成" />
|
||||
</el-form-item>
|
||||
|
||||
<fieldset
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
class="diagnosis-patient-basic-fieldset border-0 min-w-0 p-0 m-0"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓名" prop="patient_name">
|
||||
<el-input
|
||||
v-model="formData.patient_name"
|
||||
placeholder="请输入姓名"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="身份证号" prop="id_card">
|
||||
<template v-if="showIdCardMaskedEdit">
|
||||
<el-input
|
||||
:model-value="
|
||||
idCardRevealUnlocked ? originalIdCard || '' : maskIdCard(originalIdCard || '')
|
||||
"
|
||||
readonly
|
||||
placeholder="点击可查看完整身份证号"
|
||||
maxlength="18"
|
||||
class="cursor-pointer phone-mask-toggle"
|
||||
@click="toggleIdCardReveal"
|
||||
/>
|
||||
<p
|
||||
v-if="originalIdCard && !idCardRevealUnlocked"
|
||||
class="text-xs text-gray-400 mt-1"
|
||||
>
|
||||
当前为脱敏显示,点击输入框可查看完整号码
|
||||
</p>
|
||||
</template>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="formData.id_card"
|
||||
placeholder="请输入身份证号"
|
||||
maxlength="18"
|
||||
:disabled="!canEditIdCard"
|
||||
@focus="handleIdCardFocus"
|
||||
@blur="handleIdCardBlur"
|
||||
/>
|
||||
@@ -120,6 +160,7 @@
|
||||
v-model="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
@focus="handlePhoneFocus"
|
||||
@blur="handlePhoneBlur"
|
||||
/>
|
||||
@@ -127,7 +168,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-radio-group v-model="formData.gender">
|
||||
<el-radio-group v-model="formData.gender" :disabled="!canEditPatientBasicFields">
|
||||
<el-radio :label="1">男</el-radio>
|
||||
<el-radio :label="0">女</el-radio>
|
||||
</el-radio-group>
|
||||
@@ -144,11 +185,14 @@
|
||||
:max="150"
|
||||
placeholder="请输入年龄"
|
||||
class="w-full"
|
||||
:disabled="!canEditPatientBasicFields"
|
||||
:controls="canEditPatientBasicFields"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
</fieldset>
|
||||
|
||||
|
||||
<!-- 生命体征 -->
|
||||
@@ -778,9 +822,40 @@ const submitting = ref(false)
|
||||
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
|
||||
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||
/** 编辑且无明文权限:只读脱敏,点击切换查看完整号 */
|
||||
const showPhoneMaskedEdit = computed(() => mode.value === 'edit' && !hasPhonePlainPermission.value)
|
||||
const patientBasicLocked = ref(false)
|
||||
const canEditPatientBasicFromApi = ref(true)
|
||||
const latestPrescriptionOrder = ref<{
|
||||
id: number
|
||||
fulfillment_status: number
|
||||
fulfillment_status_text: string
|
||||
} | null>(null)
|
||||
const canEditPatientBasicFields = computed(() => {
|
||||
if (viewOnly.value) return false
|
||||
if (mode.value === 'add') return true
|
||||
return canEditPatientBasicFromApi.value
|
||||
})
|
||||
/** 编辑且无明文权限或基本信息锁定:只读脱敏,点击切换查看完整号 */
|
||||
const showPhoneMaskedEdit = computed(() => {
|
||||
if (mode.value !== 'edit') return false
|
||||
if (!canEditPatientBasicFields.value) return true
|
||||
return !hasPhonePlainPermission.value
|
||||
})
|
||||
const phoneRevealUnlocked = ref(false)
|
||||
/** 编辑且已有身份证号:无明文权限或基本信息锁定时只读脱敏;空身份证号仍可编辑 */
|
||||
const showIdCardMaskedEdit = computed(() => {
|
||||
if (mode.value !== 'edit') return false
|
||||
if (!originalIdCard.value) return false
|
||||
if (!canEditPatientBasicFields.value) return true
|
||||
return !hasPhonePlainPermission.value
|
||||
})
|
||||
/** 身份证号是否可编辑:空号允许补录;已有号需明文权限 */
|
||||
const canEditIdCard = computed(() => {
|
||||
if (!canEditPatientBasicFields.value) return false
|
||||
if (mode.value === 'add') return true
|
||||
if (!originalIdCard.value) return true
|
||||
return hasPhonePlainPermission.value
|
||||
})
|
||||
const idCardRevealUnlocked = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
const drawerTitle = computed(() => {
|
||||
if (viewOnly.value) return '诊单详情'
|
||||
@@ -920,6 +995,11 @@ const togglePhoneReveal = () => {
|
||||
phoneRevealUnlocked.value = !phoneRevealUnlocked.value
|
||||
}
|
||||
|
||||
const toggleIdCardReveal = () => {
|
||||
if (!originalIdCard.value) return
|
||||
idCardRevealUnlocked.value = !idCardRevealUnlocked.value
|
||||
}
|
||||
|
||||
const maskIdCard = (idCard: string) => {
|
||||
if (!idCard) return idCard
|
||||
if (idCard.length === 15) {
|
||||
@@ -990,43 +1070,40 @@ const handlePhoneBlur = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证聚焦 - 显示完整数据
|
||||
// 身份证聚焦 - 显示完整数据(无「明文」权限时仅新增模式在失焦后可再次展开编辑)
|
||||
const handleIdCardFocus = () => {
|
||||
isIdCardFocused.value = true
|
||||
if (originalIdCard.value) {
|
||||
if (!originalIdCard.value) return
|
||||
if (hasPhonePlainPermission.value) {
|
||||
formData.value.id_card = originalIdCard.value
|
||||
return
|
||||
}
|
||||
if (mode.value === 'add') {
|
||||
formData.value.id_card = originalIdCard.value
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证失焦 - 恢复脱敏并验证
|
||||
// 身份证失焦:有明文权限则保持明文并校验;新增且无明文权限时仍脱敏展示
|
||||
const handleIdCardBlur = async () => {
|
||||
isIdCardFocused.value = false
|
||||
|
||||
// 保存原始数据并脱敏显示
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
|
||||
// 验证身份证格式并计算年龄
|
||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
||||
// 从身份证提取出生日期计算年龄
|
||||
const id = formData.value.id_card
|
||||
const birthYear = parseInt(id.substring(6, 10))
|
||||
const birthMonth = parseInt(id.substring(10, 12))
|
||||
const birthDay = parseInt(id.substring(12, 14))
|
||||
|
||||
const processIdCard = async (idCard: string) => {
|
||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCard)) {
|
||||
const birthYear = parseInt(idCard.substring(6, 10))
|
||||
const birthMonth = parseInt(idCard.substring(10, 12))
|
||||
const birthDay = parseInt(idCard.substring(12, 14))
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
||||
age--
|
||||
}
|
||||
formData.value.age = age
|
||||
|
||||
// 验证身份证唯一性
|
||||
|
||||
try {
|
||||
const result = await checkIdCard({
|
||||
id_card: formData.value.id_card,
|
||||
id_card: idCard,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
@@ -1034,8 +1111,24 @@ const handleIdCardBlur = async () => {
|
||||
console.error('检查身份证号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 脱敏显示
|
||||
}
|
||||
|
||||
if (hasPhonePlainPermission.value) {
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
await processIdCard(formData.value.id_card)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 编辑模式下已有身份证号且无明文权限时不处理
|
||||
if (mode.value === 'edit' && originalIdCard.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
await processIdCard(formData.value.id_card)
|
||||
formData.value.id_card = maskIdCard(formData.value.id_card)
|
||||
}
|
||||
}
|
||||
@@ -1058,7 +1151,11 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
// 使用原始数据进行验证
|
||||
const idCardToValidate = originalIdCard.value || value
|
||||
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||
if (!idCardToValidate) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
@@ -1073,6 +1170,7 @@ const formRules = {
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }],
|
||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
@@ -1167,9 +1265,12 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
|
||||
originalPhone.value = data.phone || ''
|
||||
originalIdCard.value = data.id_card || ''
|
||||
patientBasicLocked.value = !!data.patient_basic_locked
|
||||
canEditPatientBasicFromApi.value = data.can_edit_patient_basic !== false
|
||||
latestPrescriptionOrder.value = data.latest_prescription_order ?? null
|
||||
|
||||
data.phone = hasPhonePlainPermission.value ? data.phone || '' : maskPhone(data.phone || '')
|
||||
data.id_card = maskIdCard(data.id_card || '')
|
||||
data.id_card = hasPhonePlainPermission.value ? data.id_card || '' : maskIdCard(data.id_card || '')
|
||||
|
||||
formData.value = data
|
||||
formData.value.create_source = data.create_source ?? ''
|
||||
@@ -1178,12 +1279,20 @@ async function loadDiagnosisDetailIntoForm(id: number): Promise<void> {
|
||||
formData.value.diabetes_discovery_year = y == null || y === '' ? '' : String(y)
|
||||
}
|
||||
|
||||
const resetPatientBasicLockState = () => {
|
||||
patientBasicLocked.value = false
|
||||
canEditPatientBasicFromApi.value = true
|
||||
latestPrescriptionOrder.value = null
|
||||
}
|
||||
|
||||
const open = async (type: string, id?: number) => {
|
||||
viewOnly.value = false
|
||||
mode.value = type
|
||||
visible.value = true
|
||||
activeTab.value = 'basic' // 重置到基本信息标签页
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
|
||||
// 加载字典数据
|
||||
await getDictOptions()
|
||||
@@ -1206,6 +1315,8 @@ const openViewOnly = async (id: number) => {
|
||||
visible.value = true
|
||||
activeTab.value = 'basic'
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
resetPatientBasicLockState()
|
||||
await getDictOptions()
|
||||
await loadDiagnosisDetailIntoForm(id)
|
||||
}
|
||||
@@ -1230,6 +1341,7 @@ const handleSubmit = async () => {
|
||||
if (result && result.id) {
|
||||
mode.value = 'edit'
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
formData.value.id = result.id
|
||||
|
||||
// 重新获取详情,确保patient_id等字段正确
|
||||
@@ -1244,7 +1356,9 @@ const handleSubmit = async () => {
|
||||
formData.value.phone = hasPhonePlainPermission.value
|
||||
? detail.phone || ''
|
||||
: maskPhone(detail.phone || '')
|
||||
formData.value.id_card = maskIdCard(detail.id_card || '')
|
||||
formData.value.id_card = hasPhonePlainPermission.value
|
||||
? detail.id_card || ''
|
||||
: maskIdCard(detail.id_card || '')
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
}
|
||||
@@ -1262,7 +1376,7 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
const handleViewCase = (row: any) => {
|
||||
prescriptionRef.value?.openById(row.id, { slipType: 'user', hideCaseRecord: true })
|
||||
prescriptionRef.value?.openById(row.id, { slipType: 'user' })
|
||||
}
|
||||
|
||||
// 开方:传入当前诊单数据,包含详细病历(深拷贝避免响应式引用)
|
||||
@@ -1346,6 +1460,7 @@ const handleClose = () => {
|
||||
isPhoneFocused.value = false
|
||||
isIdCardFocused.value = false
|
||||
phoneRevealUnlocked.value = false
|
||||
idCardRevealUnlocked.value = false
|
||||
viewOnly.value = false
|
||||
|
||||
visible.value = false
|
||||
@@ -1370,6 +1485,19 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.diagnosis-patient-basic-fieldset:disabled {
|
||||
opacity: 1;
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-input-number),
|
||||
:deep(.el-input-number__decrease),
|
||||
:deep(.el-input-number__increase),
|
||||
:deep(.el-radio),
|
||||
:deep(.el-radio__input) {
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form) {
|
||||
padding-bottom: 20px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
formData.pending_assign !== '1' &&
|
||||
formData.pending_booking !== '1' &&
|
||||
formData.completed_appointment !== '1' &&
|
||||
!hasLatestAppointmentFilter() &&
|
||||
formData.appointment_date === tab.value
|
||||
}
|
||||
]"
|
||||
@@ -110,6 +111,32 @@
|
||||
<el-select v-model="formData.assistant_id" placeholder="医助" clearable filterable size="small" @change="doSearch">
|
||||
<el-option v-for="item in assistantOptions" :key="item.id" :label="item.name" :value="Number(item.id)" />
|
||||
</el-select>
|
||||
<daterange-picker
|
||||
class="latest-appointment-range"
|
||||
v-model:startTime="formData.latest_appointment_start_date"
|
||||
v-model:endTime="formData.latest_appointment_end_date"
|
||||
picker-type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="最近挂号开始"
|
||||
end-placeholder="最近挂号结束"
|
||||
@change="handleLatestAppointmentFilterChange"
|
||||
/>
|
||||
<el-select
|
||||
v-model="formData.latest_appointment_channel_source"
|
||||
placeholder="最近挂号渠道"
|
||||
clearable
|
||||
filterable
|
||||
size="small"
|
||||
class="latest-appointment-channel"
|
||||
@change="handleLatestAppointmentFilterChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="String(item.value)"
|
||||
:label="item.name"
|
||||
:value="String(item.value)"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button size="small" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -213,6 +240,9 @@
|
||||
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
|
||||
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
|
||||
</template>
|
||||
<div v-if="latestAppointmentChannelText(row)" class="apt-latest-channel">
|
||||
最近渠道:{{ latestAppointmentChannelText(row) }}
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="apt-none">未挂号</span>
|
||||
</div>
|
||||
@@ -689,6 +719,7 @@ import useUserStore from '@/stores/modules/user'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
|
||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||
@@ -728,6 +759,9 @@ const formData = reactive({
|
||||
assistant_id: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
latest_appointment_start_date: '' as string,
|
||||
latest_appointment_end_date: '' as string,
|
||||
latest_appointment_channel_source: '' as string,
|
||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||
appointment_date: '' as string,
|
||||
has_appointment: '' as '' | '0' | '1',
|
||||
@@ -760,6 +794,9 @@ const setHasAppointment = (v: string) => {
|
||||
formData.has_appointment = v as '' | '0' | '1'
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
if (v === '0') {
|
||||
clearLatestAppointmentFilters()
|
||||
}
|
||||
pager.page = 1
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
@@ -823,6 +860,9 @@ function buildPendingAssignCountPayload(): Record<string, unknown> {
|
||||
has_appointment: '',
|
||||
pending_booking: '',
|
||||
completed_appointment: '',
|
||||
latest_appointment_start_date: '',
|
||||
latest_appointment_end_date: '',
|
||||
latest_appointment_channel_source: '',
|
||||
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest()
|
||||
} as Record<string, unknown>) as Record<string, unknown>
|
||||
}
|
||||
@@ -850,6 +890,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
||||
formData.assistant_id = ''
|
||||
formData.start_time = ''
|
||||
formData.end_time = ''
|
||||
formData.latest_appointment_start_date = ''
|
||||
formData.latest_appointment_end_date = ''
|
||||
formData.latest_appointment_channel_source = ''
|
||||
formData.pending_assign_order_month = ''
|
||||
activeTab.value = 'all'
|
||||
if (kw1 !== '') {
|
||||
@@ -886,6 +929,7 @@ const handleDateTabClick = async (value: string) => {
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
clearLatestAppointmentFilters()
|
||||
formData.appointment_date = value
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
@@ -901,6 +945,7 @@ const handlePendingBookingTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = '0'
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
@@ -915,6 +960,7 @@ const handleCompletedVisitTabClick = async () => {
|
||||
formData.completed_appointment = '1'
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
clearLatestAppointmentFilters()
|
||||
pager.page = 1
|
||||
await getLists()
|
||||
fetchDateCounts()
|
||||
@@ -926,6 +972,7 @@ const handlePendingAssignTabClick = async () => {
|
||||
formData.completed_appointment = ''
|
||||
formData.has_appointment = ''
|
||||
formData.appointment_date = ''
|
||||
clearLatestAppointmentFilters()
|
||||
if (!formData.pending_assign_order_month) {
|
||||
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
||||
}
|
||||
@@ -1047,16 +1094,29 @@ const handleMoreCommand = (cmd: string) => {
|
||||
const diagnosisTypeOptions = ref<any[]>([])
|
||||
const syndromeTypeOptions = ref<any[]>([])
|
||||
const assistantOptions = ref<any[]>([])
|
||||
const channelOptions = ref<Array<{ name: string; value: string }>>([])
|
||||
|
||||
const getDictOptions = async () => {
|
||||
try {
|
||||
const [diagnosisType, syndromeType, assistants] = await Promise.all([
|
||||
const [diagnosisType, syndromeType, channels, assistants] = await Promise.all([
|
||||
getDictData({ type: 'diagnosis_type' }),
|
||||
getDictData({ type: 'syndrome_type' }),
|
||||
getDictData({ type: 'channels' }),
|
||||
getAssistants()
|
||||
])
|
||||
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
|
||||
syndromeTypeOptions.value = syndromeType?.syndrome_type || []
|
||||
channelOptions.value = (channels?.channels || [])
|
||||
.filter((row: any) => row.status !== 0)
|
||||
.sort((a: any, b: any) => {
|
||||
const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0)
|
||||
if (ds !== 0) return ds
|
||||
return Number(b?.id ?? 0) - Number(a?.id ?? 0)
|
||||
})
|
||||
.map((row: any) => ({
|
||||
name: String(row?.name ?? row?.value ?? ''),
|
||||
value: row?.value != null && row.value !== '' ? String(row.value) : ''
|
||||
}))
|
||||
assistantOptions.value = assistants || []
|
||||
} catch (error) {
|
||||
console.error('获取字典数据失败:', error)
|
||||
@@ -1069,6 +1129,40 @@ const getDictLabel = (options: any[], value: string) => {
|
||||
return item ? item.name : value || '-'
|
||||
}
|
||||
|
||||
const clearLatestAppointmentFilters = () => {
|
||||
formData.latest_appointment_start_date = ''
|
||||
formData.latest_appointment_end_date = ''
|
||||
formData.latest_appointment_channel_source = ''
|
||||
}
|
||||
|
||||
const hasLatestAppointmentFilter = () =>
|
||||
!!(
|
||||
formData.latest_appointment_start_date ||
|
||||
formData.latest_appointment_end_date ||
|
||||
formData.latest_appointment_channel_source
|
||||
)
|
||||
|
||||
const handleLatestAppointmentFilterChange = () => {
|
||||
if (hasLatestAppointmentFilter()) {
|
||||
formData.appointment_date = ''
|
||||
formData.pending_booking = ''
|
||||
formData.completed_appointment = ''
|
||||
if (formData.has_appointment === '0') {
|
||||
formData.has_appointment = ''
|
||||
}
|
||||
}
|
||||
doSearch()
|
||||
}
|
||||
|
||||
const latestAppointmentChannelText = (row: any) => {
|
||||
const desc = String(row?.latest_appointment_channel_source_desc || '').trim()
|
||||
const raw = String(row?.latest_appointment_channel_source || '').trim()
|
||||
const detail = String(row?.latest_appointment_channel_source_detail || '').trim()
|
||||
const base = desc || raw
|
||||
if (!base) return ''
|
||||
return detail ? `${base}(${detail})` : base
|
||||
}
|
||||
|
||||
// 获取医助名称
|
||||
const getAssistantName = (assistantId: number | string) => {
|
||||
// 如果没有assistant_id,返回"-"
|
||||
@@ -1103,6 +1197,9 @@ const handleReset = () => {
|
||||
formData.diagnosis_type = ''
|
||||
formData.syndrome_type = ''
|
||||
formData.assistant_id = ''
|
||||
formData.latest_appointment_start_date = ''
|
||||
formData.latest_appointment_end_date = ''
|
||||
formData.latest_appointment_channel_source = ''
|
||||
formData.diagnosis_confirmed = ''
|
||||
formData.appointment_date = ''
|
||||
formData.has_appointment = ''
|
||||
@@ -2190,10 +2287,20 @@ onUnmounted(() => {
|
||||
.filter-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
|
||||
.latest-appointment-range {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.latest-appointment-channel {
|
||||
width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-card {
|
||||
@@ -2363,6 +2470,13 @@ onUnmounted(() => {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.apt-latest-channel {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.status-confirmed {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -55,34 +55,59 @@ class AssetResourceController extends BaseAdminController
|
||||
public function add()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
if (empty($params['type']) || empty($params['title']) || empty($params['file_url'])) {
|
||||
$type = $params['type'] ?? 0;
|
||||
$title = trim((string)($params['title'] ?? ''));
|
||||
|
||||
// 兼容单图(字符串)与多图批量上传(数组)
|
||||
$fileUrl = $params['file_url'] ?? '';
|
||||
if (is_array($fileUrl)) {
|
||||
$fileUrls = array_values(array_filter($fileUrl, fn($v) => trim((string)$v) !== ''));
|
||||
} else {
|
||||
$fileUrls = trim((string)$fileUrl) !== '' ? [$fileUrl] : [];
|
||||
}
|
||||
|
||||
if (empty($type) || $title === '' || empty($fileUrls)) {
|
||||
return $this->fail('请填写完整的资源信息');
|
||||
}
|
||||
|
||||
|
||||
// 仅图片支持批量;视频/语音只取首个
|
||||
if ($type != 1) {
|
||||
$fileUrls = [$fileUrls[0]];
|
||||
}
|
||||
|
||||
$multi = count($fileUrls) > 1;
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$resource = AssetResource::create([
|
||||
'type' => $params['type'],
|
||||
'title' => $params['title'],
|
||||
'file_url' => $params['file_url'],
|
||||
'cover_url' => $params['cover_url'] ?? '',
|
||||
]);
|
||||
|
||||
// 绑定用户
|
||||
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
|
||||
$userResources = [];
|
||||
foreach ($params['user_ids'] as $userId) {
|
||||
$userResources[] = [
|
||||
'user_id' => $userId,
|
||||
'resource_id' => $resource->id,
|
||||
'create_time' => time(),
|
||||
];
|
||||
$createdIds = [];
|
||||
$index = 0;
|
||||
foreach ($fileUrls as $url) {
|
||||
$index++;
|
||||
$resource = AssetResource::create([
|
||||
'type' => $type,
|
||||
'title' => $multi ? $title . '_' . $index : $title,
|
||||
'file_url' => $url,
|
||||
'cover_url' => $params['cover_url'] ?? '',
|
||||
]);
|
||||
|
||||
// 绑定用户
|
||||
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
|
||||
$userResources = [];
|
||||
foreach ($params['user_ids'] as $userId) {
|
||||
$userResources[] = [
|
||||
'user_id' => $userId,
|
||||
'resource_id' => $resource->id,
|
||||
'create_time' => time(),
|
||||
];
|
||||
}
|
||||
(new AssetUserResource())->saveAll($userResources);
|
||||
}
|
||||
(new AssetUserResource())->saveAll($userResources);
|
||||
|
||||
$createdIds[] = $resource->id;
|
||||
}
|
||||
|
||||
|
||||
Db::commit();
|
||||
return $this->success('添加并分配成功', ['id' => $resource->id]);
|
||||
return $this->success('添加并分配成功', ['ids' => $createdIds]);
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return $this->fail('操作失败: ' . $e->getMessage());
|
||||
@@ -144,14 +169,19 @@ class AssetResourceController extends BaseAdminController
|
||||
public function delete()
|
||||
{
|
||||
$id = $this->request->post('id');
|
||||
if (empty($id)) {
|
||||
$ids = is_array($id) ? $id : [$id];
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $item): bool {
|
||||
return $item > 0;
|
||||
})));
|
||||
|
||||
if (empty($ids)) {
|
||||
return $this->fail('缺少参数');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
AssetResource::destroy($id);
|
||||
AssetUserResource::where('resource_id', $id)->delete();
|
||||
AssetResource::destroy($ids);
|
||||
AssetUserResource::whereIn('resource_id', $ids)->delete();
|
||||
Db::commit();
|
||||
return $this->success('删除成功');
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -57,6 +57,7 @@ class AssetUserController extends BaseAdminController
|
||||
'phone' => $params['phone'],
|
||||
'password' => $passwordHash,
|
||||
'status' => $params['status'] ?? 1,
|
||||
'remark' => trim((string)($params['remark'] ?? '')),
|
||||
]);
|
||||
|
||||
return $this->success('添加成功', ['id' => $user->id]);
|
||||
@@ -92,6 +93,10 @@ class AssetUserController extends BaseAdminController
|
||||
if (isset($params['status'])) {
|
||||
$user->status = $params['status'];
|
||||
}
|
||||
|
||||
if (array_key_exists('remark', $params)) {
|
||||
$user->remark = trim((string)$params['remark']);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
return $this->success('修改成功');
|
||||
|
||||
@@ -207,6 +207,17 @@ class OrderController extends BaseAdminController
|
||||
$params['creator_id'] = $this->adminId;
|
||||
$params['payment_channel'] = (string)$this->request->post('payment_channel', 'normal');
|
||||
$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);
|
||||
if (!$result) {
|
||||
@@ -227,6 +238,7 @@ class OrderController extends BaseAdminController
|
||||
{
|
||||
$params = (new OrderValidate())->post()->goCheck('create');
|
||||
$params['creator_id'] = $this->adminId;
|
||||
$params['create_type'] = 'wechat_work';
|
||||
|
||||
$result = OrderLogic::createForWechatWork($params);
|
||||
if (!$result) {
|
||||
|
||||
@@ -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/appointmentLines 医助排行榜「预约诊单」挂号逐条明细
|
||||
* - GET stats.yejiStats/revisitBreakdown 二中心复诊下钻:医助 × 业务订单笔数
|
||||
* - GET stats.yejiStats/assignLines 被指派数明细(医助×诊单去重,与看板同口径)
|
||||
*/
|
||||
class YejiStatsController extends BaseAdminController
|
||||
{
|
||||
@@ -141,4 +142,13 @@ class YejiStatsController extends BaseAdminController
|
||||
|
||||
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,7 +87,7 @@ class DiagnosisController extends BaseAdminController
|
||||
public function edit()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('edit');
|
||||
$result = DiagnosisLogic::edit($params);
|
||||
$result = DiagnosisLogic::edit($params, $this->adminInfo);
|
||||
if ($result) {
|
||||
return $this->success('编辑成功', [], 1, 1);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ class DiagnosisController extends BaseAdminController
|
||||
{
|
||||
|
||||
$params = (new DiagnosisValidate())->goCheck('id');
|
||||
$result = DiagnosisLogic::detail($params);
|
||||
$result = DiagnosisLogic::detail($params, $this->adminInfo);
|
||||
DiagnosisLogic::markAssignRead((int) ($params['id'] ?? 0), $this->adminId);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,24 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
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()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('edit');
|
||||
@@ -268,6 +286,32 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务订单操作日志
|
||||
*/
|
||||
@@ -351,11 +395,17 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
public function refund()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('refund');
|
||||
$rawRefundAmount = $params['refund_amount'] ?? null;
|
||||
$refundAmount = ($rawRefundAmount === null || $rawRefundAmount === '')
|
||||
? null
|
||||
: round((float) $rawRefundAmount, 2);
|
||||
|
||||
$result = PrescriptionOrderLogic::refund(
|
||||
(int) $params['id'],
|
||||
(string) ($params['reason'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
$this->adminInfo,
|
||||
$refundAmount
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
|
||||
@@ -126,6 +126,12 @@ class AuthMiddleware
|
||||
return true;
|
||||
}
|
||||
|
||||
// 京东物流接口刷新:与「物流轨迹」同一数据域,复用 logisticsTrace 权限
|
||||
if ($accessUri === 'tcm.prescriptionorder/logisticsjdupdate'
|
||||
&& in_array('tcm.prescriptionorder/logisticstrace', $adminUris, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 挂号列表批量改渠道:与单条编辑同一数据域,复用 doctor.appointment/edit
|
||||
if ($accessUri === 'doctor.appointment/batcheditchannel'
|
||||
&& in_array('doctor.appointment/edit', $adminUris, true)) {
|
||||
|
||||
@@ -28,8 +28,10 @@ use app\common\model\tcm\CallRecord;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\dict\DictData;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
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
|
||||
{
|
||||
use HasDataScopeFilter;
|
||||
|
||||
/** 最近挂号筛选只统计这些有效状态:已预约、已完成、已过号 */
|
||||
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
|
||||
|
||||
/**
|
||||
* @notes 设置搜索条件
|
||||
* @return array
|
||||
@@ -133,6 +139,8 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
||||
}
|
||||
|
||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||
|
||||
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||
|
||||
// 仅已开方(待分配+关键词检索时不限制)
|
||||
@@ -210,6 +218,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
->order('appointment_time', 'asc')
|
||||
->order('id', 'asc');
|
||||
$appointments = $subQuery->select()->toArray();
|
||||
$latestAppointmentMap = $this->buildLatestAppointmentMap($diagnosisIds);
|
||||
|
||||
foreach ($appointments as $apt) {
|
||||
$did = (int) ($apt['patient_id'] ?? 0);
|
||||
@@ -240,6 +249,8 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
// 合并到诊单列表
|
||||
foreach ($lists as &$item) {
|
||||
$latestAppointment = $latestAppointmentMap[(int) $item['id']] ?? null;
|
||||
$this->appendLatestAppointmentSummary($item, $latestAppointment);
|
||||
$aptList = $appointmentMap[(int) $item['id']] ?? [];
|
||||
if (!empty($aptList)) {
|
||||
$apt = $aptList[0];
|
||||
@@ -280,6 +291,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
} else {
|
||||
foreach ($lists as &$item) {
|
||||
$this->appendLatestAppointmentSummary($item, null);
|
||||
$item['has_appointment'] = 0;
|
||||
$item['appointment_id'] = null;
|
||||
$item['appointment_status'] = 0;
|
||||
@@ -558,6 +570,8 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3");
|
||||
}
|
||||
|
||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||
|
||||
// 仅已开方(待分配+关键词检索时不限制)
|
||||
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
|
||||
$rxTbl = (new Prescription())->getTable();
|
||||
@@ -587,6 +601,204 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
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));
|
||||
}
|
||||
|
||||
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` 判定一致)。
|
||||
*/
|
||||
|
||||
@@ -56,7 +56,7 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
|
||||
public function lists(): array
|
||||
{
|
||||
$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'
|
||||
];
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\common\enum\ExportEnum;
|
||||
use app\common\cache\ExportCache;
|
||||
use app\common\lists\ListsExcelInterface;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
@@ -26,6 +27,12 @@ use app\common\service\gancao\GancaoScmRecipelService;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
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
|
||||
{
|
||||
@@ -108,6 +115,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$this->applyExpressKeywordFilter($query);
|
||||
$this->applyServiceChannelFilter($query);
|
||||
$this->applySupplyModeFilter($query);
|
||||
$this->applyHasAuxFormulaFilter($query);
|
||||
$this->applyAuditAdminFilter($query);
|
||||
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 则额外包含关联处方开方人为本人的订单。
|
||||
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
|
||||
@@ -778,10 +819,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
'export_channel' => '渠道',
|
||||
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
|
||||
'export_medication_form' => '药品形态',
|
||||
'export_prescription_name' => '药方名称',
|
||||
'export_service_package' => '服务套餐',
|
||||
'export_medication_days' => '天数',
|
||||
'export_amount' => '总金额',
|
||||
'export_paid_amount' => '已付金额',
|
||||
'export_linked_pay_records' => '关联收款记录',
|
||||
'export_refund_amount' => '退款金额',
|
||||
'export_agency_collect' => '代收金额',
|
||||
'export_tracking_number' => '快递单号',
|
||||
'export_sign_time' => '签收日期',
|
||||
@@ -793,6 +837,133 @@ 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_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_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_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 同口径
|
||||
* (doctor_appointment.status=3,appointment_date 落入区间,有效医助 COALESCE(挂号.assistant_id,诊单.assistant_id))。
|
||||
|
||||
@@ -86,6 +86,7 @@ class AdminLogic extends BaseLogic
|
||||
'enable_image_consult' => $params['enable_image_consult'] ?? 1,
|
||||
'enable_video_consult' => $params['enable_video_consult'] ?? 1,
|
||||
'enable_charge' => $params['enable_charge'] ?? 0,
|
||||
'enable_beauty' => $params['enable_beauty'] ?? 1,
|
||||
]);
|
||||
|
||||
// 角色
|
||||
@@ -151,6 +152,7 @@ class AdminLogic extends BaseLogic
|
||||
'enable_image_consult' => $params['enable_image_consult'] ?? 1,
|
||||
'enable_video_consult' => $params['enable_video_consult'] ?? 1,
|
||||
'enable_charge' => $params['enable_charge'] ?? 0,
|
||||
'enable_beauty' => $params['enable_beauty'] ?? 1,
|
||||
];
|
||||
|
||||
// 头像
|
||||
@@ -286,7 +288,7 @@ class AdminLogic extends BaseLogic
|
||||
'gender', 'age', 'phone', 'title', 'department',
|
||||
'specialty', 'education', 'experience', 'honors',
|
||||
'license_no', 'qualification_images', 'enable_image_consult', 'enable_video_consult', 'enable_charge',
|
||||
'work_wechat_userid'
|
||||
'enable_beauty', 'work_wechat_userid'
|
||||
])->findOrEmpty($params['id'])->toArray();
|
||||
|
||||
// 将资质图片JSON字符串转换为数组,供前端组件使用
|
||||
|
||||
@@ -22,6 +22,11 @@ use think\facade\Log;
|
||||
*/
|
||||
class OrderLogic
|
||||
{
|
||||
/**
|
||||
* 创建方式:normal 普通订单 / wechat_work 企业微信对外收款 / fubei 付呗 / express_cod 快递代收
|
||||
*/
|
||||
private const CREATE_TYPES = ['normal', 'wechat_work', 'fubei', 'express_cod'];
|
||||
|
||||
/**
|
||||
* @notes 生成订单号
|
||||
* @return string
|
||||
@@ -31,6 +36,43 @@ class OrderLogic
|
||||
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 创建订单
|
||||
* @param array $params
|
||||
@@ -40,9 +82,16 @@ class OrderLogic
|
||||
{
|
||||
try {
|
||||
$channel = (string)($params['payment_channel'] ?? 'normal');
|
||||
if (!in_array($channel, ['normal', 'fubei'], true)) {
|
||||
if (!in_array($channel, ['normal', 'fubei', 'express_cod'], true)) {
|
||||
$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;
|
||||
|
||||
$order = new Order();
|
||||
@@ -51,10 +100,14 @@ class OrderLogic
|
||||
$order->creator_id = $params['creator_id'];
|
||||
$order->order_type = $params['order_type'];
|
||||
$order->amount = $params['amount'];
|
||||
// 付呗且申请审核支付单:待审核(5);否则待支付(1)
|
||||
$order->create_type = $createType;
|
||||
// 付呗/快递代收且申请审核支付单:待审核(5);否则待支付(1)
|
||||
if ($channel === 'fubei') {
|
||||
$order->payment_method = 'fubei';
|
||||
$order->status = $requirePaymentSlipAudit ? 5 : 1;
|
||||
} elseif ($channel === 'express_cod') {
|
||||
// payment_method 留空,待人工确认到账时再写真实支付方式
|
||||
$order->status = $requirePaymentSlipAudit ? 5 : 1;
|
||||
} else {
|
||||
$order->status = 1; // 待支付
|
||||
}
|
||||
@@ -183,6 +236,10 @@ class OrderLogic
|
||||
$order->payment_method = 'wechat';
|
||||
$needSave = true;
|
||||
}
|
||||
if ((string) ($order->create_type ?? '') !== 'wechat_work') {
|
||||
$order->create_type = 'wechat_work';
|
||||
$needSave = true;
|
||||
}
|
||||
if ($paymentTimeStr !== '' && (string)$order->payment_time !== $paymentTimeStr) {
|
||||
$order->payment_time = $paymentTimeStr;
|
||||
$needSave = true;
|
||||
@@ -235,6 +292,7 @@ class OrderLogic
|
||||
$order->amount = $amount;
|
||||
$order->status = $targetStatus;
|
||||
$order->payment_method = 'wechat';
|
||||
$order->create_type = 'wechat_work';
|
||||
$order->payment_time = $paymentTimeStr;
|
||||
$order->trade_no = $tradeNo;
|
||||
$remark = '企业微信直接收款同步,待关联患者';
|
||||
@@ -283,6 +341,7 @@ class OrderLogic
|
||||
$order->amount = $amount;
|
||||
$order->status = 2; // 已支付
|
||||
$order->payment_method = 'wechat';
|
||||
$order->create_type = 'wechat_work';
|
||||
$order->payment_time = date('Y-m-d H:i:s');
|
||||
$order->trade_no = $tradeNo;
|
||||
$order->remark = '企业微信直接收款,待关联患者';
|
||||
@@ -345,11 +404,11 @@ class OrderLogic
|
||||
}
|
||||
|
||||
$st = (int)$order->status;
|
||||
// 1=待支付;5=待审核(付呗+申请支付单审核) 时允许录入手动到账
|
||||
// 1=待支付;5=待审核(付呗/快递代收+申请支付单审核) 时允许录入手动到账
|
||||
if ($st === 1) {
|
||||
// 正常待支付
|
||||
} elseif ($st === 5 && (string)($order->payment_method ?? '') === 'fubei') {
|
||||
// 待审核的付呗单,通过人工确认后标记已支付
|
||||
} elseif ($st === 5 && self::isPendingManualAudit($order)) {
|
||||
// 待审核的付呗/快递代收单,通过人工确认后标记已支付
|
||||
} else {
|
||||
self::setError('订单状态不允许支付');
|
||||
return false;
|
||||
@@ -382,8 +441,8 @@ class OrderLogic
|
||||
}
|
||||
|
||||
$st = (int)$order->status;
|
||||
if ($st !== 1 && !($st === 5 && (string)($order->payment_method ?? '') === 'fubei')) {
|
||||
self::setError('只有待支付或待审核(付呗)的订单才能取消');
|
||||
if ($st !== 1 && !($st === 5 && self::isPendingManualAudit($order))) {
|
||||
self::setError('只有待支付或待审核(付呗/快递代收)的订单才能取消');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -467,13 +526,13 @@ class OrderLogic
|
||||
}
|
||||
|
||||
$st = (int) $order->status;
|
||||
if ($st === 5 && (string) ($order->payment_method ?? '') !== 'fubei') {
|
||||
self::setError('待审核订单仅支持付呗渠道拆分');
|
||||
if ($st === 5 && !self::isPendingManualAudit($order)) {
|
||||
self::setError('待审核订单仅支持付呗/快递代收渠道拆分');
|
||||
|
||||
return false;
|
||||
}
|
||||
if (! in_array($st, [1, 5, 2], true)) {
|
||||
self::setError('仅「待支付」「待审核(付呗)」或「已支付」的订单可拆分');
|
||||
self::setError('仅「待支付」「待审核(付呗/快递代收)」或「已支付」的订单可拆分');
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -536,8 +595,18 @@ class OrderLogic
|
||||
$n->creator_id = $order->creator_id;
|
||||
$n->order_type = (int) $typeList[$i];
|
||||
$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) {
|
||||
$n->payment_method = 'fubei';
|
||||
// 付呗子单沿用 payment_method=fubei;快递代收子单 payment_method 留空,靠 create_type 标记
|
||||
if ((string) ($order->payment_method ?? '') === 'fubei') {
|
||||
$n->payment_method = 'fubei';
|
||||
}
|
||||
$n->status = 5;
|
||||
} elseif ($st === 2) {
|
||||
$n->status = 2;
|
||||
@@ -1213,7 +1282,7 @@ class OrderLogic
|
||||
}
|
||||
|
||||
$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')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
@@ -48,8 +48,8 @@ class ConversionLogic
|
||||
$emptyResult = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
@@ -57,8 +57,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -75,8 +75,8 @@ class ConversionLogic
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
@@ -84,8 +84,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -124,8 +124,8 @@ class ConversionLogic
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
@@ -133,8 +133,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary([]),
|
||||
'charts' => self::buildCharts([]),
|
||||
'summary' => self::buildSummary([], $dimension),
|
||||
'charts' => self::buildCharts([], $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -191,8 +191,8 @@ class ConversionLogic
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary($allRows),
|
||||
'charts' => self::buildCharts($chartRows),
|
||||
'summary' => self::buildSummary($allRows, $dimension),
|
||||
'charts' => self::buildCharts($chartRows, $dimension),
|
||||
'lists' => $pagedRows,
|
||||
'count' => count($allRows),
|
||||
'page_no' => $pageNo,
|
||||
@@ -200,8 +200,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary($allRows),
|
||||
'charts' => self::buildCharts($chartRows),
|
||||
'summary' => self::buildSummary($allRows, $dimension),
|
||||
'charts' => self::buildCharts($chartRows, $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -226,8 +226,8 @@ class ConversionLogic
|
||||
$result = [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary($rows),
|
||||
'charts' => self::buildCharts($rows),
|
||||
'summary' => self::buildSummary($rows, $dimension),
|
||||
'charts' => self::buildCharts($rows, $dimension),
|
||||
'lists' => array_slice($rows, $offset, $pageSize),
|
||||
'count' => $count,
|
||||
'page_no' => $pageNo,
|
||||
@@ -235,8 +235,8 @@ class ConversionLogic
|
||||
'extend' => [
|
||||
'dimension' => $dimension,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => self::buildSummary($rows),
|
||||
'charts' => self::buildCharts($rows),
|
||||
'summary' => self::buildSummary($rows, $dimension),
|
||||
'charts' => self::buildCharts($rows, $dimension),
|
||||
],
|
||||
];
|
||||
if ($includeFilters) {
|
||||
@@ -1226,7 +1226,7 @@ class ConversionLogic
|
||||
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||
$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['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||
@@ -1459,7 +1459,7 @@ class ConversionLogic
|
||||
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||
$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['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||
@@ -2010,7 +2010,12 @@ class ConversionLogic
|
||||
'paid_appointment_rate' => self::percent($paidAppointmentCount, $addFansCount),
|
||||
'open_appointment_rate' => self::percent($paidAppointmentCount, $totalOpenCount),
|
||||
'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),
|
||||
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
||||
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
||||
@@ -2149,7 +2154,7 @@ class ConversionLogic
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildSummary(array $rows): array
|
||||
private static function buildSummary(array $rows, string $dimension = 'dept'): array
|
||||
{
|
||||
$summary = [
|
||||
'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['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['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['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']);
|
||||
@@ -2310,7 +2320,7 @@ class ConversionLogic
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @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))));
|
||||
$topRows = array_slice($chartableRows, 0, 10);
|
||||
@@ -2319,29 +2329,56 @@ class ConversionLogic
|
||||
'names' => [],
|
||||
'amounts' => [],
|
||||
'order_counts' => [],
|
||||
'fan_counts' => [],
|
||||
'rois' => [],
|
||||
];
|
||||
if ($dimension === 'doctor') {
|
||||
$ranking['appointment_counts'] = [];
|
||||
$ranking['interview_counts'] = [];
|
||||
} else {
|
||||
$ranking['fan_counts'] = [];
|
||||
$ranking['rois'] = [];
|
||||
}
|
||||
|
||||
foreach ($topRows as $row) {
|
||||
$ranking['names'][] = $row['name'];
|
||||
$ranking['amounts'][] = $row['completed_order_amount'];
|
||||
$ranking['order_counts'][] = $row['completed_order_count'];
|
||||
$ranking['fan_counts'][] = $row['add_fans_count'];
|
||||
$ranking['rois'][] = $row['roi'];
|
||||
if ($dimension === 'doctor') {
|
||||
$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,
|
||||
'amount_share' => array_map(
|
||||
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)
|
||||
),
|
||||
'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']],
|
||||
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
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 复诊接诊率统计(按月)
|
||||
*
|
||||
* 口径说明:
|
||||
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
||||
* 同一诊单当月被多次指派给同一医助只计 1 次。
|
||||
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
||||
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
|
||||
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
|
||||
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
|
||||
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
|
||||
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%;
|
||||
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
||||
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
||||
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
||||
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;部门筛选(dept_ids,含组织下级)按该归属部门过滤。
|
||||
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门下拉(全量未删除部门,前端组树)。
|
||||
*
|
||||
* @return array{rows: list<array{id:int,pid:int,name:string}>}
|
||||
*/
|
||||
public static function deptOptions(): array
|
||||
{
|
||||
$rows = Db::name('dept')
|
||||
->whereNull('delete_time')
|
||||
->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. 分母:当月非继承指派的「医助 × 诊单」;
|
||||
* 3. 分子:曾被指派诊单的当月订单按全局序号 ≥2 归属持有医助;
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分子:曾被指派诊单的当月订单(全局序号 ≥2),归属下单时点的持有医助
|
||||
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
|
||||
$slotOrdersByAssistant = [];
|
||||
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;
|
||||
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;
|
||||
}
|
||||
$seq++;
|
||||
$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 ($seq < 2 || $seq > self::MAX_VISIT_SLOT) {
|
||||
continue;
|
||||
}
|
||||
if ($ct < $startTs || $ct > $endTs) {
|
||||
continue;
|
||||
}
|
||||
if ($holder > 0) {
|
||||
$slotOrdersByAssistant[$holder][$seq][] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 医助归属部门 + 部门筛选(含组织下级)
|
||||
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
||||
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
||||
$deptFilterIds = self::parseDeptIds($params['dept_ids'] ?? null);
|
||||
if ($deptFilterIds !== []) {
|
||||
$subtreeSet = self::expandDeptSubtreeSet($deptFilterIds);
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
|
||||
*
|
||||
* @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, 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) 归属部门
|
||||
* · 与 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**(与进线一致)
|
||||
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
||||
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
||||
@@ -857,7 +858,7 @@ class YejiStatsLogic
|
||||
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
||||
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
|
||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
||||
|
||||
$leaderboards = [];
|
||||
@@ -1777,8 +1778,9 @@ class YejiStatsLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0),**仅**按 **related_po_create_time** 落入区间(须 >0)。
|
||||
* 选定字典渠道时 INNER JOIN 诊单以满足挂号 channels EXISTS;tag 退化口径只过滤日志字段,不强制挂诊单。
|
||||
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
|
||||
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
|
||||
* 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
||||
*
|
||||
* @param array<int, int> $adminToPrimary
|
||||
* @param int[]|null $tagDiagIds
|
||||
@@ -1801,11 +1803,13 @@ class YejiStatsLogic
|
||||
return [];
|
||||
}
|
||||
|
||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||
$query = Db::name('tcm_diagnosis_assign_log')
|
||||
->alias('lg')
|
||||
->where('lg.related_po_create_time', '>', 0)
|
||||
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
|
||||
->where('lg.to_assistant_id', '>', 0);
|
||||
->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);
|
||||
if ($channelFilterActive) {
|
||||
if ($appointmentChannelValues !== []) {
|
||||
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
|
||||
@@ -1816,8 +1820,6 @@ class YejiStatsLogic
|
||||
if ($pack === null) {
|
||||
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]);
|
||||
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
||||
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');
|
||||
|
||||
$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>
|
||||
*/
|
||||
@@ -1883,11 +1885,13 @@ class YejiStatsLogic
|
||||
?array $dataScopeAdminIds = null,
|
||||
?array $channelInfo = null
|
||||
): array {
|
||||
$diagTable = self::tableWithPrefix('tcm_diagnosis');
|
||||
$query = Db::name('tcm_diagnosis_assign_log')
|
||||
->alias('lg')
|
||||
->where('lg.related_po_create_time', '>', 0)
|
||||
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
|
||||
->where('lg.to_assistant_id', '>', 0);
|
||||
->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);
|
||||
|
||||
if ($channelFilterActive) {
|
||||
if ($appointmentChannelValues !== []) {
|
||||
@@ -1899,8 +1903,6 @@ class YejiStatsLogic
|
||||
if ($pack === null) {
|
||||
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]);
|
||||
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
|
||||
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');
|
||||
|
||||
$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 映射同源)。
|
||||
*/
|
||||
|
||||
@@ -28,6 +28,7 @@ use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\doctor\DoctorNoteLogic;
|
||||
use app\adminapi\logic\doctor\AppointmentLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
@@ -73,8 +74,8 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
// 生成患者ID
|
||||
$params['patient_id'] = self::generatePatientId();
|
||||
// 新患者先占位 0,写入后 patient_id 与自增 id 对齐
|
||||
$params['patient_id'] = 0;
|
||||
|
||||
// 处理既往史数组
|
||||
if (isset($params['past_history']) && is_array($params['past_history'])) {
|
||||
@@ -119,6 +120,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
$model = Diagnosis::create($params);
|
||||
$model = self::syncPatientIdWithDiagnosisId($model);
|
||||
|
||||
// 图片写入 doctor_note 表
|
||||
if (!empty($newTongueImages) || !empty($newReportFiles)) {
|
||||
@@ -130,7 +132,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
// 自动为患者创建 TRTC 账号
|
||||
self::createPatientTrtcAccount($model->id);
|
||||
self::createPatientTrtcAccount($model->patient_id);
|
||||
|
||||
return $model->id;
|
||||
} catch (\Exception $e) {
|
||||
@@ -140,16 +142,20 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 生成患者ID
|
||||
* @return int
|
||||
* @notes 新患者首张诊单:patient_id 与自增 id 对齐
|
||||
* @param Diagnosis $model
|
||||
* @return Diagnosis
|
||||
*/
|
||||
private static function generatePatientId(): int
|
||||
private static function syncPatientIdWithDiagnosisId(Diagnosis $model): Diagnosis
|
||||
{
|
||||
// 获取当前最大的患者ID
|
||||
$maxPatientId = Diagnosis::max('id') ?? 10000000;
|
||||
|
||||
// 返回下一个患者ID
|
||||
return $maxPatientId + 1;
|
||||
if ((int) $model->patient_id === (int) $model->id) {
|
||||
return $model;
|
||||
}
|
||||
|
||||
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
|
||||
* @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 {
|
||||
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'])) {
|
||||
$exists = Diagnosis::where('phone', $params['phone'])
|
||||
@@ -210,6 +230,10 @@ class DiagnosisLogic extends BaseLogic
|
||||
$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);
|
||||
|
||||
// 如果是编辑,也确保患者有 TRTC 账号
|
||||
@@ -246,7 +270,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
* @param $params
|
||||
* @return array
|
||||
*/
|
||||
public static function detail($params): array
|
||||
public static function detail($params, array $adminInfo = []): array
|
||||
{
|
||||
$diagnosis = Diagnosis::findOrEmpty($params['id'])->toArray();
|
||||
|
||||
@@ -313,7 +337,17 @@ class DiagnosisLogic extends BaseLogic
|
||||
$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;
|
||||
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return null;
|
||||
@@ -496,19 +530,88 @@ class DiagnosisLogic extends BaseLogic
|
||||
->whereNull('delete_time')
|
||||
->order('create_time', 'desc')
|
||||
->order('id', 'desc')
|
||||
->field(['creator_id', 'create_time'])
|
||||
->field(['id', 'creator_id', 'create_time', 'fulfillment_status'])
|
||||
->find();
|
||||
|
||||
if ($row === null || $row === []) {
|
||||
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 [
|
||||
'creator_id' => (int) ($row['creator_id'] ?? 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)。
|
||||
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
|
||||
@@ -3088,8 +3191,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
unset($params['user_id']); // 诊单表无此字段,仅用于创建 view_record
|
||||
|
||||
$patientId = $params['patient_id'] ?? 0;
|
||||
if (!$patientId) {
|
||||
$params['patient_id'] = self::generatePatientId();
|
||||
$isNewPatient = !$patientId;
|
||||
if ($isNewPatient) {
|
||||
$params['patient_id'] = 0;
|
||||
}
|
||||
$params['status'] = 1;
|
||||
|
||||
@@ -3119,6 +3223,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
|
||||
$model = Diagnosis::create($params);
|
||||
if ($isNewPatient) {
|
||||
$model = self::syncPatientIdWithDiagnosisId($model);
|
||||
}
|
||||
self::createPatientTrtcAccount($model->patient_id);
|
||||
|
||||
// 图片写入 doctor_note
|
||||
|
||||
@@ -11,6 +11,7 @@ use app\common\model\Order;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\DiagnosisAssignLog;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\model\tcm\PrescriptionLibrary;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
@@ -695,6 +696,8 @@ class PrescriptionOrderLogic
|
||||
{
|
||||
PrescriptionOrderPayOrder::where('prescription_order_id', (int) $order->id)->delete();
|
||||
$order->linked_pay_order_id = null;
|
||||
// 解除关联后刷新「需代收」固定快照(已付归零 → 需代收=总金额)
|
||||
self::refreshAgencyCollectSnapshot((int) $order->id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -767,7 +770,7 @@ class PrescriptionOrderLogic
|
||||
return [];
|
||||
}
|
||||
$rows = Order::whereIn('id', $ids)->whereNull('delete_time')
|
||||
->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', 'asc')
|
||||
->whereIn('status', [2, 4, 5])
|
||||
->select()
|
||||
@@ -836,6 +839,35 @@ class PrescriptionOrderLogic
|
||||
$arr['deposit_min_amount'] = self::depositMinAmount();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新「需代收」固定快照:按当前 总金额 − 关联已付总额(支付单 status ∈ {2 已支付, 5 待审核})写入 agency_collect_amount。
|
||||
* 在关联/新增支付单、建单、改总金额、解除关联等写操作末尾调用;之后退款/审核状态变化不再影响该值。
|
||||
*/
|
||||
private static function refreshAgencyCollectSnapshot(int $id): void
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
$amount = round((float) $order->amount, 2);
|
||||
$paid = 0.0;
|
||||
$ids = self::linkedPayOrderIdList($id);
|
||||
if ($ids !== []) {
|
||||
$payOrders = Order::whereIn('id', $ids)->whereNull('delete_time')->field(['amount', 'status'])->select();
|
||||
foreach ($payOrders as $po) {
|
||||
$st = (int) $po->status;
|
||||
if ($st === 2 || $st === 5) {
|
||||
$paid += (float) $po->amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
$order->agency_collect_amount = round($amount - $paid, 2);
|
||||
$order->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $params
|
||||
* @return array<string,mixed>|false
|
||||
@@ -968,7 +1000,9 @@ class PrescriptionOrderLogic
|
||||
self::replacePayOrderLinks((int) $order->id, $payOrderIds);
|
||||
}
|
||||
|
||||
$out = $order->toArray();
|
||||
self::refreshAgencyCollectSnapshot((int) $order->id);
|
||||
|
||||
$out = PrescriptionOrder::where('id', (int) $order->id)->find()->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
@@ -1036,7 +1070,17 @@ class PrescriptionOrderLogic
|
||||
$canHerbs = self::canViewPrescriptionHerbsInOrderDetail($adminInfo);
|
||||
$arr['prescription_detail_herbs_visible'] = $canHerbs;
|
||||
if (! $canHerbs && isset($arr['prescription']) && is_array($arr['prescription'])) {
|
||||
unset($arr['prescription']['herbs']);
|
||||
$rx = &$arr['prescription'];
|
||||
$herbs = is_array($rx['herbs'] ?? null) ? $rx['herbs'] : [];
|
||||
$hasAux = false;
|
||||
foreach ($herbs as $h) {
|
||||
if (is_array($h) && (string) ($h['formula_type'] ?? '') === '辅方') {
|
||||
$hasAux = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$rx['has_aux_formula'] = $hasAux ? 1 : 0;
|
||||
unset($rx['herbs']);
|
||||
}
|
||||
|
||||
// 诊单医助 / 诊单创建人姓名(tcm_diagnosis.admin_id);部门见下方:优先业务订单 creator_id
|
||||
@@ -1514,6 +1558,48 @@ class PrescriptionOrderLogic
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接调用京东官方物流接口刷新轨迹并落库(后台「京东接口更新」按钮)。
|
||||
*
|
||||
* @return array{success:bool, message:string, traces_count:int, state:string}|null
|
||||
*/
|
||||
public static function logisticsJdUpdate(int $id, int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
self::$error = '';
|
||||
$row = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$row) {
|
||||
self::$error = '订单不存在';
|
||||
|
||||
return null;
|
||||
}
|
||||
if (!self::canAccessOrder($row, $adminId, $adminInfo)) {
|
||||
self::$error = '无权限操作';
|
||||
|
||||
return null;
|
||||
}
|
||||
$num = trim((string) ($row->tracking_number ?? ''));
|
||||
if ($num === '') {
|
||||
self::$error = '未填写快递单号';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$ret = \app\common\service\ExpressTrackingService::queryJdOfficialAndUpdate($num);
|
||||
|
||||
if (empty($ret['success'])) {
|
||||
self::$error = (string) ($ret['message'] ?? '京东物流更新失败');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => (string) ($ret['message'] ?? '京东物流轨迹已更新'),
|
||||
'traces_count' => (int) ($ret['traces_count'] ?? 0),
|
||||
'state' => (string) ($ret['state'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $params
|
||||
* @return array<string,mixed>|false
|
||||
@@ -1684,7 +1770,9 @@ class PrescriptionOrderLogic
|
||||
|
||||
self::writeLog((int) $order->id, $adminId, $adminInfo, 'edit', '编辑了业务订单及相关信息');
|
||||
|
||||
$out = $order->toArray();
|
||||
self::refreshAgencyCollectSnapshot((int) $order->id);
|
||||
|
||||
$out = PrescriptionOrder::where('id', (int) $order->id)->find()->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
@@ -2228,12 +2316,17 @@ class PrescriptionOrderLogic
|
||||
$order->payment_slip_audit_remark = '';
|
||||
self::syncFulfillmentStatus($order);
|
||||
|
||||
// 将关联的已支付支付单(status=2,且payment_method='manual')恢复为待审核(status=5)
|
||||
// 将关联的已支付补齐支付单恢复为待审核:兼容旧 manual 与新 fubei/create_type=fubei 两种标记
|
||||
$payOrderIds = self::linkedPayOrderIdList($id);
|
||||
if (!empty($payOrderIds)) {
|
||||
Order::whereIn('id', $payOrderIds)
|
||||
->where('status', 2) // 只恢复已支付状态的
|
||||
->where('payment_method', 'manual') // 只恢复手动创建的
|
||||
->where(function ($query) {
|
||||
$query->where('payment_method', 'manual')
|
||||
->whereOr('payment_method', 'fubei')
|
||||
->whereOr('create_type', 'fubei')
|
||||
->whereOr('create_type', 'express_cod');
|
||||
})
|
||||
->update([
|
||||
'status' => 5, // 待审核
|
||||
'payment_time' => null
|
||||
@@ -2311,6 +2404,9 @@ class PrescriptionOrderLogic
|
||||
$amount = round((float) ($params['pay_amount'] ?? 0), 2);
|
||||
$remark = mb_substr(trim((string) ($params['pay_remark'] ?? '')), 0, 200);
|
||||
$completionRequest = (int) ($params['completion_request'] ?? 0);
|
||||
// 创建方式:付呗(默认) 或 快递代收(express_cod)
|
||||
$payCreateType = (string) ($params['pay_create_type'] ?? 'fubei');
|
||||
$isExpressCod = $payCreateType === 'express_cod';
|
||||
|
||||
if ($amount < 0) {
|
||||
self::$error = '支付单金额不能为负数';
|
||||
@@ -2325,7 +2421,13 @@ class PrescriptionOrderLogic
|
||||
$payOrder->order_type = $orderType;
|
||||
$payOrder->amount = $amount;
|
||||
$payOrder->status = 5; // 待审核
|
||||
$payOrder->payment_method = 'manual';
|
||||
if ($isExpressCod) {
|
||||
// 快递代收:payment_method 留空,审核通过/到账后再写;用 create_type 标记
|
||||
$payOrder->create_type = 'express_cod';
|
||||
} else {
|
||||
$payOrder->payment_method = 'fubei'; // 补齐支付单按「付呗」记账
|
||||
$payOrder->create_type = 'fubei';
|
||||
}
|
||||
$payOrder->payment_time = null; // 审核通过后再设置支付时间
|
||||
$payOrder->remark = $remark;
|
||||
|
||||
@@ -2367,7 +2469,9 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
self::writeLog($id, $adminId, $adminInfo, 'add_pay_order', $logMsg);
|
||||
|
||||
$out = $order->toArray();
|
||||
self::refreshAgencyCollectSnapshot($id);
|
||||
|
||||
$out = PrescriptionOrder::where('id', $id)->find()->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
@@ -2478,7 +2582,9 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
self::writeLog($id, $adminId, $adminInfo, 'link_pay_order', $logMsg);
|
||||
|
||||
$out = $order->toArray();
|
||||
self::refreshAgencyCollectSnapshot($id);
|
||||
|
||||
$out = PrescriptionOrder::where('id', $id)->find()->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
@@ -2626,11 +2732,43 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单退款:须填写原因;关联收款单标记为已退款,实付清零。
|
||||
* 关联收款单中可退款的记录(已支付/待审核)
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function linkedPayRefundableRows(int $prescriptionOrderId): array
|
||||
{
|
||||
$ids = self::linkedPayOrderIdList($prescriptionOrderId);
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Order::whereIn('id', $ids)
|
||||
->whereNull('delete_time')
|
||||
->whereIn('status', [2, 5])
|
||||
->field(['id', 'amount'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private static function linkedPayRefundableTotal(int $prescriptionOrderId): float
|
||||
{
|
||||
$sum = 0.0;
|
||||
foreach (self::linkedPayRefundableRows($prescriptionOrderId) as $row) {
|
||||
$sum += (float) ($row['amount'] ?? 0);
|
||||
}
|
||||
|
||||
return round($sum, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单退款:须填写原因;可指定退款金额(部分退)或退满可退上限(全额)。
|
||||
*
|
||||
* @param float|null $refundAmount 为空或 0 表示退满可退上限
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function refund(int $id, string $reason, int $adminId, array $adminInfo)
|
||||
public static function refund(int $id, string $reason, int $adminId, array $adminInfo, ?float $refundAmount = null)
|
||||
{
|
||||
self::$error = '';
|
||||
$reason = trim($reason);
|
||||
@@ -2652,8 +2790,8 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
$curFs = (int) $order->fulfillment_status;
|
||||
if ($curFs !== 5 && $curFs !== 6) {
|
||||
self::$error = '仅「已发货」或「已签收」状态可退款';
|
||||
if (!in_array($curFs, [3, 5, 6, 9], true)) {
|
||||
self::$error = '仅「已发货」「已签收」「已完成」或「拒收」状态可退款';
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -2663,17 +2801,72 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$linkedPayOrderIds = self::linkedPayOrderIdList($id);
|
||||
$refundedPayCount = 0;
|
||||
$maxRefundable = self::linkedPayRefundableTotal($id);
|
||||
$currentPaid = round((float) $order->paid, 2);
|
||||
$cap = $maxRefundable > 0 ? $maxRefundable : $currentPaid;
|
||||
if ($cap <= 0) {
|
||||
self::$error = '当前无可退金额';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($refundAmount === null || $refundAmount <= 0) {
|
||||
$refundAmount = $cap;
|
||||
} else {
|
||||
$refundAmount = round($refundAmount, 2);
|
||||
if ($refundAmount > $cap + 0.009) {
|
||||
self::$error = '退款金额不能超过可退上限 ¥' . $cap;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$isFullRefund = $refundAmount >= $cap - 0.009;
|
||||
$toRefundRows = self::linkedPayRefundableRows($id);
|
||||
$refundedPayCount = 0;
|
||||
$markedPayTotal = 0.0;
|
||||
|
||||
try {
|
||||
if ($linkedPayOrderIds !== []) {
|
||||
$refundedPayCount = Order::whereIn('id', $linkedPayOrderIds)
|
||||
if ($isFullRefund && $toRefundRows !== []) {
|
||||
$ids = array_map('intval', array_column($toRefundRows, 'id'));
|
||||
$refundedPayCount = Order::whereIn('id', $ids)
|
||||
->whereNull('delete_time')
|
||||
->update(['status' => 4]);
|
||||
foreach ($toRefundRows as $pr) {
|
||||
$markedPayTotal += (float) ($pr['amount'] ?? 0);
|
||||
}
|
||||
$markedPayTotal = round($markedPayTotal, 2);
|
||||
} elseif (!$isFullRefund && $toRefundRows !== []) {
|
||||
$remaining = $refundAmount;
|
||||
foreach ($toRefundRows as $pr) {
|
||||
if ($remaining < 0.01) {
|
||||
break;
|
||||
}
|
||||
$amt = round((float) ($pr['amount'] ?? 0), 2);
|
||||
if ($amt <= $remaining + 0.001) {
|
||||
Order::where('id', (int) $pr['id'])
|
||||
->whereNull('delete_time')
|
||||
->update(['status' => 4]);
|
||||
$remaining -= $amt;
|
||||
$markedPayTotal += $amt;
|
||||
$refundedPayCount++;
|
||||
}
|
||||
}
|
||||
$markedPayTotal = round($markedPayTotal, 2);
|
||||
}
|
||||
$order->fulfillment_status = 10;
|
||||
$order->paid = 0;
|
||||
|
||||
if ($isFullRefund) {
|
||||
$order->fulfillment_status = 10;
|
||||
$order->paid = 0;
|
||||
} else {
|
||||
$order->paid = round(max(0, $currentPaid - $refundAmount), 2);
|
||||
if ($order->paid <= 0.009) {
|
||||
$order->paid = 0;
|
||||
$order->fulfillment_status = 10;
|
||||
}
|
||||
}
|
||||
$prevStoredRefund = round((float) ($order->refund_amount ?? 0), 2);
|
||||
$order->refund_amount = round($prevStoredRefund + $refundAmount, 2);
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = $e->getMessage();
|
||||
@@ -2681,16 +2874,34 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$unassignSummary = self::clearDiagnosisAssistantOnComplete((int) $order->diagnosis_id);
|
||||
$logLine = '订单退款,状态变更为「退款」;原因:' . $reason;
|
||||
$unassignSummary = '';
|
||||
if ($isFullRefund || (float) $order->paid <= 0.009) {
|
||||
$unassignSummary = self::clearDiagnosisAssistantOnComplete((int) $order->diagnosis_id);
|
||||
}
|
||||
|
||||
if ($isFullRefund) {
|
||||
$logLine = '订单全额退款,状态变更为「退款」;原因:' . $reason . ';退款金额 ¥' . $refundAmount;
|
||||
} else {
|
||||
$logLine = '订单部分退款;原因:' . $reason . ';本次退款 ¥' . $refundAmount . ';已付余额 ¥' . $order->paid;
|
||||
}
|
||||
if ($refundedPayCount > 0) {
|
||||
$logLine .= ';关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
|
||||
$logLine .= ';关联收款单 ' . $refundedPayCount . ' 笔已标记为已退款';
|
||||
if ($markedPayTotal > 0) {
|
||||
$logLine .= '(收款侧合计 ¥' . $markedPayTotal . ')';
|
||||
}
|
||||
} elseif ($toRefundRows !== []) {
|
||||
$logLine .= ';关联收款单无匹配的已支付/待审核记录可标记';
|
||||
}
|
||||
if ($isFullRefund) {
|
||||
$logLine .= ';已付总额清零' . $unassignSummary;
|
||||
}
|
||||
$logLine .= ';已付总额清零' . $unassignSummary;
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'refund', $logLine);
|
||||
|
||||
$out = $order->toArray();
|
||||
$out['this_refund_amount'] = $refundAmount;
|
||||
$out['refund_full'] = $isFullRefund;
|
||||
$out['refundable_cap'] = $cap;
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
@@ -2950,6 +3161,197 @@ class PrescriptionOrderLogic
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:统一解析时间(支持 Unix 秒/毫秒与 Y-m-d H:i:s 字符串,与前端 formatOrderTime 同口径)
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
private static function formatDateTimeForExport($value, string $format = 'Y-m-d H:i'): string
|
||||
{
|
||||
if ($value === null || $value === '' || $value === false) {
|
||||
return '';
|
||||
}
|
||||
if (\is_int($value) || \is_float($value)) {
|
||||
$ts = (int) $value;
|
||||
if ($ts > 9999999999) {
|
||||
$ts = (int) floor($ts / 1000);
|
||||
}
|
||||
|
||||
return $ts > 0 ? date($format, $ts) : '';
|
||||
}
|
||||
$str = trim((string) $value);
|
||||
if ($str === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^\d+$/', $str)) {
|
||||
$ts = (int) $str;
|
||||
if ($ts > 9999999999) {
|
||||
$ts = (int) floor($ts / 1000);
|
||||
}
|
||||
|
||||
return $ts > 0 ? date($format, $ts) : '';
|
||||
}
|
||||
if (str_contains($str, '-') || str_contains($str, '/')) {
|
||||
$ts = strtotime($str);
|
||||
|
||||
return $ts !== false ? date($format, $ts) : $str;
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:支付单来源/方式(与前端 formatPayOrderSource 同口径)
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private static function formatPayOrderSourceForExport(array $row): string
|
||||
{
|
||||
$createType = trim((string) ($row['create_type'] ?? ''));
|
||||
if ($createType === 'wechat_work') {
|
||||
return '企业微信对外收款';
|
||||
}
|
||||
if ($createType === 'fubei') {
|
||||
return '付呗';
|
||||
}
|
||||
if ($createType === 'express_cod') {
|
||||
return '快递代收';
|
||||
}
|
||||
$paymentMethod = trim((string) ($row['payment_method'] ?? ''));
|
||||
$methodMap = [
|
||||
'alipay' => '支付宝',
|
||||
'wechat' => '微信',
|
||||
'wechat_work' => '企业微信',
|
||||
'fubei' => '付呗',
|
||||
'express_cod' => '快递代收',
|
||||
'manual' => '手动确认到账',
|
||||
];
|
||||
if ($paymentMethod !== '' && isset($methodMap[$paymentMethod])) {
|
||||
return $methodMap[$paymentMethod];
|
||||
}
|
||||
|
||||
return '普通订单';
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:单条关联收款可读文本(两行结构,便于 Excel 自动换行阅读)
|
||||
*
|
||||
* @param array<string, mixed> $pay
|
||||
* @param array<int|string, string> $creatorNames
|
||||
*/
|
||||
private static function formatLinkedPayRecordLineForExport(array $pay, array $creatorNames, int $index = 1): string
|
||||
{
|
||||
$typeMap = [
|
||||
1 => '挂号费',
|
||||
2 => '问诊费',
|
||||
3 => '药品费用',
|
||||
4 => '首付费用',
|
||||
5 => '尾款费用',
|
||||
6 => '其他费用',
|
||||
7 => '全部费用',
|
||||
8 => '驼奶费用',
|
||||
];
|
||||
$statusMap = [
|
||||
1 => '待支付',
|
||||
2 => '已支付',
|
||||
3 => '已取消',
|
||||
4 => '已退款',
|
||||
5 => '待审核',
|
||||
];
|
||||
$orderNo = trim((string) ($pay['order_no'] ?? ''));
|
||||
$amount = number_format(round((float) ($pay['amount'] ?? 0), 2), 2, '.', '');
|
||||
$typeDesc = $typeMap[(int) ($pay['order_type'] ?? 0)] ?? '—';
|
||||
$statusDesc = $statusMap[(int) ($pay['status'] ?? 0)] ?? '—';
|
||||
$source = self::formatPayOrderSourceForExport($pay);
|
||||
$cid = (int) ($pay['creator_id'] ?? 0);
|
||||
$creator = $cid > 0 ? (string) ($creatorNames[$cid] ?? '') : '—';
|
||||
$timeStr = self::formatDateTimeForExport($pay['create_time'] ?? '') ?: '—';
|
||||
|
||||
$line1 = sprintf('[%d] %s ¥%s %s %s', $index, $orderNo, $amount, $typeDesc, $statusDesc);
|
||||
$line2 = sprintf(' %s %s %s', $source, $creator, $timeStr);
|
||||
|
||||
return $line1 . "\n" . $line2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:批量解析业务订单关联收款记录(与详情 linked_pay_orders 同口径:status ∈ {2,4,5})
|
||||
*
|
||||
* @param int[] $poIds
|
||||
*
|
||||
* @return array<int, string> prescription_order_id => 多笔以空行分隔的可读文本
|
||||
*/
|
||||
private static function batchLinkedPayRecordsExportTextByPoIds(array $poIds): array
|
||||
{
|
||||
$poIds = array_values(array_filter(array_unique(array_map('intval', $poIds)), static fn (int $id): bool => $id > 0));
|
||||
if ($poIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$linksByPo = [];
|
||||
$allPayIds = [];
|
||||
$linkRows = PrescriptionOrderPayOrder::whereIn('prescription_order_id', $poIds)
|
||||
->field(['prescription_order_id', 'pay_order_id'])
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($linkRows as $l) {
|
||||
$poid = (int) ($l['prescription_order_id'] ?? 0);
|
||||
$payId = (int) ($l['pay_order_id'] ?? 0);
|
||||
if ($poid <= 0 || $payId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$linksByPo[$poid][] = $payId;
|
||||
$allPayIds[$payId] = true;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($poIds as $poid) {
|
||||
$out[$poid] = '';
|
||||
}
|
||||
if ($allPayIds === []) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$payRows = Order::whereIn('id', array_keys($allPayIds))
|
||||
->whereNull('delete_time')
|
||||
->whereIn('status', [2, 4, 5])
|
||||
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'payment_method', 'create_type'])
|
||||
->select()
|
||||
->toArray();
|
||||
$payById = [];
|
||||
$creatorIds = [];
|
||||
foreach ($payRows as $r) {
|
||||
$id = (int) ($r['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$payById[$id] = $r;
|
||||
$cid = (int) ($r['creator_id'] ?? 0);
|
||||
if ($cid > 0) {
|
||||
$creatorIds[$cid] = true;
|
||||
}
|
||||
}
|
||||
$creatorNames = $creatorIds !== []
|
||||
? Admin::whereIn('id', array_keys($creatorIds))->column('name', 'id')
|
||||
: [];
|
||||
|
||||
foreach ($poIds as $poid) {
|
||||
$lines = [];
|
||||
$seq = 0;
|
||||
foreach ($linksByPo[$poid] ?? [] as $payId) {
|
||||
$pay = $payById[$payId] ?? null;
|
||||
if (!\is_array($pay)) {
|
||||
continue;
|
||||
}
|
||||
++$seq;
|
||||
$lines[] = self::formatLinkedPayRecordLineForExport($pay, $creatorNames, $seq);
|
||||
}
|
||||
$out[$poid] = implode("\n\n", $lines);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单列表导出:写入与 PrescriptionOrderLists::setExcelFields 对应的字段(export=2 时由列表类调用)
|
||||
* 「挂号渠道来源」取值与详情/列表解析一致:处方 appointment_id → 否则诊单下 MAX(挂号.id);业绩抽屉带渠道时与列表同源高亮。
|
||||
@@ -3001,7 +3403,7 @@ class PrescriptionOrderLogic
|
||||
$rxById = [];
|
||||
if ($rxIdList !== []) {
|
||||
$rxRows = Prescription::whereIn('id', $rxIdList)->whereNull('delete_time')
|
||||
->field(['id', 'prescription_type', 'need_decoction', 'dose_unit', 'assistant_id', 'appointment_id'])
|
||||
->field(['id', 'prescription_type', 'need_decoction', 'dose_unit', 'assistant_id', 'appointment_id', 'prescription_name', 'aux_usage', 'herbs', 'creator_id'])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rxRows as $xr) {
|
||||
@@ -3009,6 +3411,10 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
// 药方名称反查:处方库(开方医师 creator_id + 公开模板),按 formula_type + 药材集合匹配,
|
||||
// 复刻前端 resolveSlipAuxLibraryName 口径(主方/辅方均如此;处方记录 prescription_name 仅做兜底)。
|
||||
[$libByDoctor, $libPublic] = self::buildPrescriptionLibraryNameIndexForExport($rxById);
|
||||
|
||||
if ($needGuahaoChannel) {
|
||||
$yejiChannelHighlightByDiag = [];
|
||||
if (
|
||||
@@ -3177,6 +3583,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$firstVisitAssistantByDiag = self::batchFirstVisitAssistantNameByDiagnosis($diagIdList, $diagById);
|
||||
$assistantDeptCache = [];
|
||||
$linkedPayExportByPo = self::batchLinkedPayRecordsExportTextByPoIds($poIds);
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
$poId = (int) ($item['id'] ?? 0);
|
||||
@@ -3214,6 +3621,22 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
$item['export_medication_form'] = self::formatMedicationFormForExport(\is_array($rx) ? $rx : []);
|
||||
|
||||
// 药方名称:主方/辅方均反查处方库取名称(与处方笺一致),带标签;甘草/无匹配留空
|
||||
[$mainRxName, $auxRxName] = self::resolvePrescriptionNamesForExport(
|
||||
\is_array($rx) ? $rx : [],
|
||||
$libByDoctor,
|
||||
$libPublic
|
||||
);
|
||||
$rxNameParts = [];
|
||||
if ($mainRxName !== '') {
|
||||
$rxNameParts[] = '主方:' . $mainRxName;
|
||||
}
|
||||
if ($auxRxName !== '') {
|
||||
$rxNameParts[] = '辅方:' . $auxRxName;
|
||||
}
|
||||
$item['export_prescription_name'] = implode(' ', $rxNameParts);
|
||||
|
||||
$item['export_service_package'] = self::formatServicePackageForExport(
|
||||
$item['service_package'] ?? '',
|
||||
$packageNameByValue
|
||||
@@ -3233,7 +3656,16 @@ class PrescriptionOrderLogic
|
||||
$paid = round((float) ($item['linked_pay_paid_total'] ?? 0), 2);
|
||||
$item['export_amount'] = number_format($amt, 2, '.', '');
|
||||
$item['export_paid_amount'] = number_format($paid, 2, '.', '');
|
||||
$item['export_agency_collect'] = number_format(round($amt - $paid, 2), 2, '.', '');
|
||||
$item['export_linked_pay_records'] = $linkedPayExportByPo[$poId] ?? '';
|
||||
$refundStored = round((float) ($item['refund_amount'] ?? 0), 2);
|
||||
$item['export_refund_amount'] = $refundStored > 0
|
||||
? number_format($refundStored, 2, '.', '')
|
||||
: '';
|
||||
// 需代收:优先用固定快照值 agency_collect_amount;历史单为 NULL 时回退实时 amt − paid
|
||||
$agencySnap = $item['agency_collect_amount'] ?? null;
|
||||
$item['export_agency_collect'] = ($agencySnap !== null && $agencySnap !== '')
|
||||
? number_format(round((float) $agencySnap, 2), 2, '.', '')
|
||||
: number_format(round($amt - $paid, 2), 2, '.', '');
|
||||
|
||||
$item['export_tracking_number'] = (string) ($item['tracking_number'] ?? '');
|
||||
$signTs = $poId > 0 ? (int) ($signTsByPoId[$poId] ?? 0) : 0;
|
||||
@@ -3282,6 +3714,186 @@ class PrescriptionOrderLogic
|
||||
unset($item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出药方名称反查:批量加载相关开方医师处方库(creator_id 命中 + 公开模板),
|
||||
* 建立 [creator_id][formula_type][herbKey] => name 与 公开 [formula_type][herbKey] => name 两级索引。
|
||||
*
|
||||
* @param array<int, array> $rxById 处方 id => 处方行(需含 herbs / creator_id)
|
||||
*
|
||||
* @return array{0: array<int, array<string, array<string, string>>>, 1: array<string, array<string, string>>}
|
||||
*/
|
||||
private static function buildPrescriptionLibraryNameIndexForExport(array $rxById): array
|
||||
{
|
||||
$libByDoctor = [];
|
||||
$libPublic = [];
|
||||
if ($rxById === []) {
|
||||
return [$libByDoctor, $libPublic];
|
||||
}
|
||||
|
||||
$doctorIds = [];
|
||||
foreach ($rxById as $rx) {
|
||||
$cid = (int) ($rx['creator_id'] ?? 0);
|
||||
if ($cid > 0) {
|
||||
$doctorIds[$cid] = true;
|
||||
}
|
||||
}
|
||||
$doctorIdList = array_keys($doctorIds);
|
||||
|
||||
try {
|
||||
$libRows = PrescriptionLibrary::whereNull('delete_time')
|
||||
->where(function ($q) use ($doctorIdList) {
|
||||
if ($doctorIdList !== []) {
|
||||
$q->whereIn('creator_id', $doctorIdList)->whereOr('is_public', 1);
|
||||
} else {
|
||||
$q->where('is_public', 1);
|
||||
}
|
||||
})
|
||||
->field(['prescription_name', 'formula_type', 'herbs', 'creator_id', 'is_public'])
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('buildPrescriptionLibraryNameIndexForExport failed: ' . $e->getMessage());
|
||||
|
||||
return [$libByDoctor, $libPublic];
|
||||
}
|
||||
|
||||
foreach ($libRows as $lib) {
|
||||
$name = trim((string) ($lib['prescription_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$herbsRaw = $lib['herbs'] ?? null;
|
||||
if (\is_string($herbsRaw) && $herbsRaw !== '') {
|
||||
$decoded = json_decode($herbsRaw, true);
|
||||
$herbsRaw = \is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
if (!\is_array($herbsRaw) || $herbsRaw === []) {
|
||||
continue;
|
||||
}
|
||||
$key = self::herbSetMatchKeyForExport($herbsRaw);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$ft = ((string) ($lib['formula_type'] ?? '')) === '辅方' ? '辅方' : '主方';
|
||||
|
||||
if ((int) ($lib['is_public'] ?? 0) === 1) {
|
||||
$libPublic[$ft][$key] ??= $name;
|
||||
}
|
||||
$cid = (int) ($lib['creator_id'] ?? 0);
|
||||
if ($cid > 0) {
|
||||
$libByDoctor[$cid][$ft][$key] ??= $name;
|
||||
}
|
||||
}
|
||||
|
||||
return [$libByDoctor, $libPublic];
|
||||
}
|
||||
|
||||
/**
|
||||
* 反查单张处方的主方 / 辅方名称(与处方笺展示一致):
|
||||
* - 主方:主方药材集合匹配开方医师/公开处方库(formula_type=主方);兜底取处方 prescription_name。
|
||||
* - 辅方:优先 aux_usage.prescription_name / library_name,否则辅方药材集合匹配(formula_type=辅方)。
|
||||
*
|
||||
* @param array $rx 处方行(含 herbs / creator_id / prescription_name / aux_usage)
|
||||
* @param array<int, array<string, array<string, string>>> $libByDoctor
|
||||
* @param array<string, array<string, string>> $libPublic
|
||||
*
|
||||
* @return array{0: string, 1: string} [主方名称, 辅方名称]
|
||||
*/
|
||||
private static function resolvePrescriptionNamesForExport(array $rx, array $libByDoctor, array $libPublic): array
|
||||
{
|
||||
if ($rx === []) {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
$doctorId = (int) ($rx['creator_id'] ?? 0);
|
||||
|
||||
$herbs = $rx['herbs'] ?? null;
|
||||
if (\is_string($herbs) && $herbs !== '') {
|
||||
$decoded = json_decode($herbs, true);
|
||||
$herbs = \is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
if (!\is_array($herbs)) {
|
||||
$herbs = [];
|
||||
}
|
||||
|
||||
$mainHerbs = [];
|
||||
$auxHerbs = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!\is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
if (((string) ($h['formula_type'] ?? '')) === '辅方') {
|
||||
$auxHerbs[] = $h;
|
||||
} else {
|
||||
$mainHerbs[] = $h;
|
||||
}
|
||||
}
|
||||
|
||||
$lookup = static function (string $ft, array $hs) use ($doctorId, $libByDoctor, $libPublic): string {
|
||||
if ($hs === []) {
|
||||
return '';
|
||||
}
|
||||
$key = self::herbSetMatchKeyForExport($hs);
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
if ($doctorId > 0 && isset($libByDoctor[$doctorId][$ft][$key])) {
|
||||
return $libByDoctor[$doctorId][$ft][$key];
|
||||
}
|
||||
|
||||
return (string) ($libPublic[$ft][$key] ?? '');
|
||||
};
|
||||
|
||||
// 主方
|
||||
$mainName = $lookup('主方', $mainHerbs);
|
||||
if ($mainName === '') {
|
||||
$mainName = trim((string) ($rx['prescription_name'] ?? ''));
|
||||
}
|
||||
|
||||
// 辅方:先读持久化字段
|
||||
$auxName = '';
|
||||
$auxUsage = $rx['aux_usage'] ?? null;
|
||||
if (\is_string($auxUsage) && $auxUsage !== '') {
|
||||
$decodedAux = json_decode($auxUsage, true);
|
||||
$auxUsage = \is_array($decodedAux) ? $decodedAux : null;
|
||||
}
|
||||
if (\is_array($auxUsage)) {
|
||||
$auxName = trim((string) ($auxUsage['prescription_name'] ?? ($auxUsage['library_name'] ?? '')));
|
||||
}
|
||||
if ($auxName === '') {
|
||||
$auxName = $lookup('辅方', $auxHerbs);
|
||||
}
|
||||
|
||||
return [$mainName, $auxName];
|
||||
}
|
||||
|
||||
/**
|
||||
* 药材集合匹配 key:每味 name:dosage(dosage 规整去尾零)排序后拼接,与处方库反查双方同口径。
|
||||
*/
|
||||
private static function herbSetMatchKeyForExport(array $herbs): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($herbs as $h) {
|
||||
if (!\is_array($h)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($h['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$dosage = (float) ($h['dosage'] ?? 0);
|
||||
if (floor($dosage) === $dosage) {
|
||||
$dStr = (string) (int) $dosage;
|
||||
} else {
|
||||
$dStr = rtrim(rtrim(number_format($dosage, 4, '.', ''), '0'), '.');
|
||||
}
|
||||
$parts[] = $name . ':' . $dStr;
|
||||
}
|
||||
sort($parts);
|
||||
|
||||
return implode('|', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成订单时把关联诊单的 assistant_id 清空,让患者回到「待分配」状态
|
||||
* 仅当该患者没有其它进行中的处方订单且无指派日志时才清空,避免误覆盖
|
||||
@@ -3787,6 +4399,9 @@ class PrescriptionOrderLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
// 总金额变化后刷新「需代收」固定快照,保持与新总额一致
|
||||
self::refreshAgencyCollectSnapshot($id);
|
||||
|
||||
// 记录日志
|
||||
$summary = sprintf('将订单金额从 ¥%.2f 修改为 ¥%.2f', $oldAmount, $newAmount);
|
||||
self::writeLog($id, $adminId, $adminInfo, 'update_amount', $summary);
|
||||
@@ -3794,6 +4409,112 @@ class PrescriptionOrderLogic
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量将处方业务订单「创建人(医助归属)」改派给其他医助。
|
||||
* 仅改写 creator_id(业务归属/医助筛选口径),逐单记操作日志;与支付单批量改派口径一致。
|
||||
*
|
||||
* @param int[] $orderIds
|
||||
* @param int $assistantAdminId 目标医助 admin_id(须含医助角色)
|
||||
* @return array{success:int, fail:int, errors:string[], per_log:array<int,array{order_id:int,summary:string}>}|false
|
||||
*/
|
||||
public static function batchAssignAssistant(array $orderIds, int $assistantAdminId, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$assistantAdminId = (int) $assistantAdminId;
|
||||
if ($assistantAdminId <= 0) {
|
||||
self::$error = '请选择医助';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 目标须为医助角色(与支付单批量改派一致)
|
||||
$assistantRoleId = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
|
||||
if (
|
||||
\app\common\model\auth\AdminRole::where('admin_id', $assistantAdminId)
|
||||
->where('role_id', $assistantRoleId)
|
||||
->count() === 0
|
||||
) {
|
||||
self::$error = '目标账号不是医助角色';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_filter(
|
||||
array_map('intval', $orderIds),
|
||||
static fn (int $id) => $id > 0
|
||||
)));
|
||||
if ($ids === []) {
|
||||
self::$error = '请选择订单';
|
||||
|
||||
return false;
|
||||
}
|
||||
if (count($ids) > 200) {
|
||||
self::$error = '单次最多改派200单';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetName = (string) Admin::where('id', $assistantAdminId)->whereNull('delete_time')->value('name');
|
||||
if ($targetName === '') {
|
||||
$targetName = (string) $assistantAdminId;
|
||||
}
|
||||
|
||||
$perLog = [];
|
||||
$errors = [];
|
||||
$success = 0;
|
||||
foreach ($ids as $oid) {
|
||||
$order = PrescriptionOrder::where('id', $oid)->whereNull('delete_time')->find();
|
||||
if (! $order) {
|
||||
$errors[] = "订单{$oid}不存在或已删除";
|
||||
continue;
|
||||
}
|
||||
$label = (string) ($order->order_no ?: $oid);
|
||||
if (! self::canAccessOrder($order, $adminId, $adminInfo)) {
|
||||
$errors[] = "订单{$label}无操作权限";
|
||||
continue;
|
||||
}
|
||||
$oldCreatorId = (int) $order->creator_id;
|
||||
if ($oldCreatorId === $assistantAdminId) {
|
||||
$errors[] = "订单{$label}创建人已是该医助,已跳过";
|
||||
continue;
|
||||
}
|
||||
$oldName = $oldCreatorId > 0
|
||||
? (string) Admin::where('id', $oldCreatorId)->whereNull('delete_time')->value('name')
|
||||
: '';
|
||||
if ($oldName === '' && $oldCreatorId > 0) {
|
||||
$oldName = (string) $oldCreatorId;
|
||||
} elseif ($oldName === '') {
|
||||
$oldName = '—';
|
||||
}
|
||||
|
||||
$order->creator_id = $assistantAdminId;
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = "订单{$label}保存失败";
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = '创建人(医助)由「' . $oldName . '」改派为「' . $targetName . '」';
|
||||
self::writeLog($oid, $adminId, $adminInfo, 'assign_assistant', $summary);
|
||||
$perLog[] = ['order_id' => $oid, 'summary' => $summary];
|
||||
$success++;
|
||||
}
|
||||
|
||||
if ($success === 0) {
|
||||
self::$error = $errors !== [] ? implode(';', $errors) : '未能改派任何订单';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'fail' => count($errors),
|
||||
'errors' => $errors,
|
||||
'per_log' => $perLog,
|
||||
];
|
||||
}
|
||||
|
||||
private static function writeLog(int $orderId, int $adminId, array $adminInfo, string $action, string $summary): void
|
||||
{
|
||||
$adminName = $adminInfo['name'] ?? '';
|
||||
|
||||
@@ -32,7 +32,9 @@ class DiagnosisValidate extends BaseValidate
|
||||
'gender' => 'require|in:0,1',
|
||||
'age' => 'require|number|between:0,150',
|
||||
'diagnosis_type' => 'require',
|
||||
'local_hospital_name' => 'require|max:255',
|
||||
'status' => 'in:0,1',
|
||||
'show_card' => 'in:0,1',
|
||||
'create_source' => 'max:32',
|
||||
'current_medications' => 'max:2000',
|
||||
'tongue_images' => 'array',
|
||||
@@ -49,6 +51,7 @@ class DiagnosisValidate extends BaseValidate
|
||||
'patient_name.require' => '请输入患者姓名',
|
||||
'patient_name.length' => '患者姓名长度须在1-50位字符',
|
||||
'id_card.length' => '身份证号长度不正确',
|
||||
'id_card.require' => '请输入身份证号',
|
||||
'phone.require' => '请输入手机号',
|
||||
'phone.mobile' => '手机号格式不正确',
|
||||
'gender.require' => '请选择性别',
|
||||
@@ -57,6 +60,8 @@ class DiagnosisValidate extends BaseValidate
|
||||
'age.number' => '年龄必须为数字',
|
||||
'age.between' => '年龄范围0-150',
|
||||
'diagnosis_type.require' => '请选择诊断类型',
|
||||
'local_hospital_name.require' => '请输入当地就诊医院名称',
|
||||
'local_hospital_name.max' => '当地就诊医院名称最多255个字符',
|
||||
'status.in' => '状态参数错误',
|
||||
'create_source.max' => '渠道来源长度不能超过32个字符',
|
||||
'current_medications.max' => '在用药物最多2000个字符',
|
||||
@@ -78,7 +83,7 @@ class DiagnosisValidate extends BaseValidate
|
||||
|
||||
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()
|
||||
|
||||
@@ -16,7 +16,8 @@ class PrescriptionLibraryValidate extends BaseValidate
|
||||
'prescription_name' => 'require|max:100',
|
||||
'formula_type' => 'in:主方,辅方',
|
||||
'herbs' => 'require|array',
|
||||
'is_public' => 'in:0,1'
|
||||
'is_public' => 'in:0,1',
|
||||
'disable_edit' => 'in:0,1'
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
@@ -27,7 +28,8 @@ class PrescriptionLibraryValidate extends BaseValidate
|
||||
'formula_type.in' => '处方类型只能是主方或辅方',
|
||||
'herbs.require' => '药材列表不能为空',
|
||||
'herbs.array' => '药材列表格式错误',
|
||||
'is_public.in' => '是否公开参数错误'
|
||||
'is_public.in' => '是否公开参数错误',
|
||||
'disable_edit.in' => '禁用修改参数错误'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -35,7 +37,7 @@ class PrescriptionLibraryValidate extends BaseValidate
|
||||
*/
|
||||
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()
|
||||
{
|
||||
return $this->only(['id', 'prescription_name', 'formula_type', 'herbs', 'is_public']);
|
||||
return $this->only(['id', 'prescription_name', 'formula_type', 'herbs', 'is_public', 'disable_edit']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'remark' => 'max:500',
|
||||
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
|
||||
'reason' => 'require|max:500',
|
||||
'refund_amount' => 'float|egt:0',
|
||||
'patient_name' => 'require|max:50',
|
||||
'phone' => 'require|max:20',
|
||||
'phone_tail' => 'max:20|regex:/^\\d*$/',
|
||||
@@ -67,6 +68,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'tracking_number', 'express_company', 'fee_type', 'amount', 'remark_extra', 'remark_assistant', 'pay_order_ids',
|
||||
],
|
||||
'logisticsTrace' => ['id', 'phone_tail'],
|
||||
'logisticsJdUpdate' => ['id'],
|
||||
'auditPrescription' => ['id', 'action', 'remark'],
|
||||
'auditPayment' => ['id', 'action', 'remark'],
|
||||
'withdraw' => ['id'],
|
||||
@@ -77,7 +79,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'linkPayOrder' => ['id', 'pay_order_id'],
|
||||
'requestCompletion' => ['id'],
|
||||
'complete' => ['id', 'fulfillment_status'],
|
||||
'refund' => ['id', 'reason'],
|
||||
'refund' => ['id', 'reason', 'refund_amount'],
|
||||
'submitGancaoRecipel' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||
@@ -89,6 +91,6 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
{
|
||||
return $this->only(['id', 'amount'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('amount', 'require|float|gt:0');
|
||||
->append('amount', 'require|float|egt:0');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +457,10 @@ class TcmController extends BaseApiController
|
||||
return ['ok' => false, 'error' => '诊单不存在'];
|
||||
}
|
||||
|
||||
if ((int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||
return ['ok' => false, 'error' => '该就诊卡已在统计端隐藏'];
|
||||
}
|
||||
|
||||
// 通过 diagnosis_view_records 校验当前小程序用户与该诊单的归属关系
|
||||
// 该表是「就诊卡列表」的来源(参考 DiagnosisLogic::getCardList)
|
||||
$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. 严禁:白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料、白稀饭(杂面窝头、玉米碴粥除外)。
|
||||
4. 低 GI 优先;农村家常;禁止轻食/沙拉/藜麦等词。
|
||||
5. lunch 字段写法:蛋白菜放前面,淀粉(如有)放最后且注明「小半碗/一个」。
|
||||
6. 只输出 JSON:
|
||||
{"breakfast":"...","lunch":"...","dinner":"...","tips":"...","avoid":["...","..."]}
|
||||
6. 早餐必须包含「驼奶粉」(温水冲服,适量),可与粥、蛋、菜并列写。
|
||||
7. drinks 单独写全天喝什么:温开水为主;可写驼奶粉冲饮时间;严禁含糖饮料、果汁、酒。
|
||||
8. 只输出 JSON:
|
||||
{"breakfast":"...","drinks":"...","lunch":"...","dinner":"...","tips":"...","avoid":["...","..."]}
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
@@ -544,9 +546,11 @@ SYS;
|
||||
2. 淀粉(粥/饭/面/饼/窝头/薯/南瓜)每餐最多 1 种,早中晚互不相同。
|
||||
3. 严禁白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料。
|
||||
4. 农村家常饭,禁止轻食/沙拉/藜麦。
|
||||
5. 早餐必须含驼奶粉(温水冲服);喝的单独一行。
|
||||
|
||||
严格按下面 5 行格式输出,不要 JSON、不要 markdown、不要多余解释:
|
||||
早餐:(具体食物)
|
||||
严格按下面 6 行格式输出,不要 JSON、不要 markdown、不要多余解释:
|
||||
早餐:(具体食物,须含驼奶粉)
|
||||
喝的:(温开水、驼奶粉冲饮安排等,忌甜饮)
|
||||
午餐:(具体食物,蛋白放前)
|
||||
晚餐:(具体食物)
|
||||
提示:(一句土话提醒)
|
||||
@@ -593,6 +597,7 @@ SYS;
|
||||
|
||||
$map = [
|
||||
'breakfast' => null,
|
||||
'drinks' => null,
|
||||
'lunch' => null,
|
||||
'dinner' => null,
|
||||
'tips' => null,
|
||||
@@ -602,6 +607,9 @@ SYS;
|
||||
if (preg_match('/早餐[::]\s*(.+)$/mu', $text, $m)) {
|
||||
$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)) {
|
||||
$map['lunch'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
|
||||
}
|
||||
@@ -623,6 +631,7 @@ SYS;
|
||||
|
||||
$out = [
|
||||
'breakfast' => (string) $map['breakfast'],
|
||||
'drinks' => (string) ($map['drinks'] ?? ''),
|
||||
'lunch' => (string) ($map['lunch'] ?? ''),
|
||||
'dinner' => (string) ($map['dinner'] ?? ''),
|
||||
'tips' => (string) ($map['tips'] ?? ''),
|
||||
@@ -632,6 +641,29 @@ SYS;
|
||||
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 文案(避免流式展示后被规则模板覆盖)
|
||||
*
|
||||
@@ -640,6 +672,7 @@ SYS;
|
||||
*/
|
||||
private static function finalizeAiRecommendPlan(array $parsed): array
|
||||
{
|
||||
$parsed = self::applyDietProductDefaults($parsed);
|
||||
$checked = DietMealValidator::validateAndNormalize($parsed);
|
||||
$data = $checked['plan'];
|
||||
$data['source'] = 'ai';
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\DailyFamilyLike;
|
||||
use app\common\model\tcm\DailyShareInvite;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
|
||||
/**
|
||||
* 家人点赞(邀请观看页 → 患者日常页展示)
|
||||
@@ -204,6 +205,11 @@ class DailyFamilyLikeLogic
|
||||
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');
|
||||
$count = self::countToday($diagnosisId, $likeDate);
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ class DailyGamifyLogic
|
||||
if (!$owned) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\common\model\DiagnosisViewRecord;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\user\User;
|
||||
|
||||
/**
|
||||
@@ -74,19 +76,38 @@ class DailyPhoneLogic
|
||||
|
||||
if (!empty($cardList)) {
|
||||
$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 [
|
||||
'ok' => true,
|
||||
'ok' => false,
|
||||
'need_mobile' => false,
|
||||
'created' => false,
|
||||
'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'] ?? ''),
|
||||
'error' => '该手机号对应就诊卡因隐私设置已隐藏,请联系医生',
|
||||
];
|
||||
}
|
||||
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'] ?? '')));
|
||||
if ($displayName === '') {
|
||||
@@ -114,17 +135,66 @@ class DailyPhoneLogic
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'need_mobile' => false,
|
||||
'created' => true,
|
||||
'diagnosis_id' => (int) $result['id'],
|
||||
return self::buildContextOk([
|
||||
'id' => (int) $result['id'],
|
||||
'patient_id' => (int) $result['patient_id'],
|
||||
'patient_name' => $displayName,
|
||||
'gender' => $gender,
|
||||
'age' => max(0, (int) ($user['age'] ?? 0)),
|
||||
'mobile' => $mobile,
|
||||
'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) {
|
||||
return self::setError('诊单不存在') ? false : false;
|
||||
}
|
||||
if ((int) ($diagnosis['show_card'] ?? 1) !== 1) {
|
||||
return self::setError('该就诊卡已在统计端隐藏') ? false : false;
|
||||
}
|
||||
|
||||
$inviteDate = date('Y-m-d');
|
||||
$code = self::generateUniqueCode();
|
||||
|
||||
@@ -114,6 +114,7 @@ class DietMealValidator
|
||||
|
||||
$normalized = [
|
||||
'breakfast' => $breakfast,
|
||||
'drinks' => trim((string) ($plan['drinks'] ?? '')),
|
||||
'lunch' => $lunch,
|
||||
'dinner' => $dinner,
|
||||
'tips' => $tips,
|
||||
|
||||
@@ -18,7 +18,7 @@ class GiKnowledge
|
||||
'蔬菜类' => ['魔芋', '粟米', '大白菜', '黄瓜', '芹菜', '茄子', '青椒', '海带', '金针菇', '香菇', '菠菜', '番茄', '豆芽', '芦笋', '花椰菜', '洋葱', '生菜'],
|
||||
'豆类' => ['黄豆', '眉豆', '鸡心豆', '豆腐', '豆角', '绿豆', '扁豆', '四季豆'],
|
||||
'水果类' => ['苹果', '水梨', '橙子', '桃', '提子', '沙田柚', '雪梨', '车厘子', '柚子', '草莓', '樱桃', '金桔', '葡萄'],
|
||||
'奶类' => ['牛奶', '低脂奶', '脱脂奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'],
|
||||
'奶类' => ['牛奶', '低脂奶', '脱脂奶', '驼奶粉', '驼奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'],
|
||||
],
|
||||
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 = [
|
||||
[
|
||||
'breakfast' => '玉米碴子粥、煮鸡蛋、拌黄瓜',
|
||||
'breakfast' => '驼奶粉(温水冲服)、玉米碴子粥、煮鸡蛋、拌黄瓜',
|
||||
'drinks' => '白天多喝温开水;早餐冲驼奶粉,别喝甜饮料、果汁。',
|
||||
'lunch' => '清炖鲤鱼、豆腐炖白菜、清炒豆角、米饭小半碗',
|
||||
'dinner' => '玉米面饼一个、瘦肉丝、凉拌茄子',
|
||||
'tips' => '中午鱼豆肉吃足;早中晚淀粉别重复,一顿一种主食就够。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '小米粥、煮鸡蛋、小碟咸菜(少盐)',
|
||||
'breakfast' => '驼奶粉(温水冲服)、小米粥、煮鸡蛋、小碟咸菜(少盐)',
|
||||
'drinks' => '温开水为主;驼奶粉跟早餐一起吃,下午也记得喝水。',
|
||||
'lunch' => '去皮炖鸡肉、芹菜炒豆干、木耳炒鸡蛋',
|
||||
'dinner' => '蒸红薯(小半块)、清炒菠菜、豆腐汤',
|
||||
'tips' => '午餐最讲究蛋白;晚上才吃薯,别跟中午再配大米饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '豆腐脑(少卤)、鸡蛋、一个苹果',
|
||||
'breakfast' => '驼奶粉(温水冲服)、豆腐脑(少卤)、煮鸡蛋',
|
||||
'drinks' => '早餐饮驼奶粉;白天喝白开水,别碰含糖饮料。',
|
||||
'lunch' => '清炖鸡肉、炖芸豆、番茄炒蛋、贴饼子一个',
|
||||
'dinner' => '挂面一小碗、清蒸小鱼、拌海带丝',
|
||||
'tips' => '中午饼配肉豆,蛋白管够;面条放晚上,别三顿都是粥饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '高粱米粥、茶叶蛋、拌萝卜丝',
|
||||
'breakfast' => '驼奶粉(温水冲服)、高粱米粥、茶叶蛋、拌萝卜丝',
|
||||
'drinks' => '温开水;早餐驼奶粉按说明冲,别加糖。',
|
||||
'lunch' => '炖牛肉(瘦)、豆腐炒韭菜、清炒油菜、杂豆饭小半碗',
|
||||
'dinner' => '蒸南瓜(小半碗)、鸡蛋羹、拍黄瓜',
|
||||
'tips' => '中午肉蛋豆要多吃;南瓜算晚饭主食,别再加馒头稀饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '棒子面粥、水煮蛋、大拌菜(少油)',
|
||||
'breakfast' => '驼奶粉(温水冲服)、棒子面粥、水煮蛋、大拌菜(少油)',
|
||||
'drinks' => '多喝温开水;驼奶粉放早餐,忌可乐汽水果汁。',
|
||||
'lunch' => '清炖鲫鱼、番茄炒蛋、炖扁豆、糙米饭小半碗',
|
||||
'dinner' => '玉米面饼一个、清炒豆芽、豆腐脑',
|
||||
'tips' => '午饭鱼蛋豆齐上;玉米面放晚上,跟中午米饭分开。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '小米粥、煮鸡蛋、拌生菜',
|
||||
'breakfast' => '驼奶粉(温水冲服)、小米粥、煮鸡蛋、拌生菜',
|
||||
'drinks' => '白天小口多次喝温水;早餐冲好驼奶粉再吃饭。',
|
||||
'lunch' => '白切鸡(去皮)、炖豆腐、清炒豆角、二米饭小半碗',
|
||||
'dinner' => '杂面馒头一个、瘦肉炒芹菜、凉拌黄瓜',
|
||||
'tips' => '中午蛋白打主力;三顿别都喝粥吃面,一顿一种淀粉。',
|
||||
@@ -92,7 +98,8 @@ class GiKnowledge
|
||||
private static array $aliases = [
|
||||
'棒子面' => '荞麦面', '玉米碴' => '粟米', '地瓜' => '番薯', '红薯' => '番薯',
|
||||
'洋芋' => '芋头', '土豆' => '芋头', '窝头' => '通心粉', '贴饼子' => '通心粉',
|
||||
'二米饭' => '红米饭', '稀饭' => '白饭', '白稀饭' => '白饭', '糖糕' => '麦芽糖',
|
||||
'二米饭' => '红米饭', '稀饭' => '白饭', '白稀饭' => '白饭', '糖糕' => '麦芽糖',
|
||||
'驼奶' => '驼奶粉',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -132,6 +139,8 @@ class GiKnowledge
|
||||
public static function ruralMealHints(): string
|
||||
{
|
||||
return implode("\n", [
|
||||
'【早餐】须安排驼奶粉温水冲服(低 GI 奶类,稳血糖),可与粥、蛋、菜同餐。',
|
||||
'【喝的】单独说明:温开水为主;早餐驼奶粉;严禁含糖饮料、果汁、酒。',
|
||||
'【午餐】蛋白质要最高:至少 2 样高蛋白(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),肉蛋豆是午饭主角。',
|
||||
'【淀粉不重复】粥/饭/面/饼/窝头/红薯/土豆/玉米/南瓜等算淀粉;早中晚各最多 1 种,且三顿不能同一种、不要顿顿大主食。',
|
||||
'推荐蛋白:鸡蛋、豆腐、豆干、自家鸡鸭蛋、瘦肉、鲫鱼鲤鱼;',
|
||||
@@ -177,6 +186,7 @@ class GiKnowledge
|
||||
|
||||
$plan = [
|
||||
'breakfast' => $meal['breakfast'],
|
||||
'drinks' => $meal['drinks'] ?? '白天多喝温开水;早餐冲驼奶粉,别喝甜饮。',
|
||||
'lunch' => $meal['lunch'],
|
||||
'dinner' => $meal['dinner'],
|
||||
'tips' => $meal['tips'],
|
||||
|
||||
@@ -15,7 +15,7 @@ use think\console\Output;
|
||||
* 使用方法:
|
||||
* 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
|
||||
*
|
||||
* 已对「业务订单创建人非二中心」(或创建人为开方医生时按待释放身份判定)执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at,
|
||||
@@ -38,7 +38,8 @@ class ExpressAutoUpdate extends Command
|
||||
try {
|
||||
$result = ExpressTrackingService::autoUpdateBatch(1000);
|
||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(1000);
|
||||
|
||||
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
|
||||
$output->writeln("更新完成!");
|
||||
@@ -46,7 +47,7 @@ class ExpressAutoUpdate extends Command
|
||||
$output->writeln("成功: {$result['success']}");
|
||||
$output->writeln("失败: {$result['failed']}");
|
||||
$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'] ?? []);
|
||||
if ($lines === []) {
|
||||
$output->writeln('医助明细: (无)');
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\service\ExpressTrackingService;
|
||||
use app\common\service\gancao\GancaoLogisticsRouteService;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
@@ -18,18 +19,21 @@ use think\console\Output;
|
||||
* 数据流:
|
||||
* zyt_tcm_prescription_order (甘草已上传)
|
||||
* ↓ 调用 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
|
||||
*
|
||||
* 使用方法:
|
||||
* php think gancao:sync-logistics 默认拉 100 单
|
||||
* php think gancao:sync-logistics --limit=200 自定义拉取上限
|
||||
* php think gancao:sync-logistics --order-id=123 只跑指定订单
|
||||
* php think gancao:sync-logistics 默认拉 2000 单(跳过已完成/已签收)
|
||||
* php think gancao:sync-logistics --limit=500 自定义拉取上限
|
||||
* 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 --detail 打印每单详细
|
||||
*
|
||||
* 建议 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,
|
||||
* 后续履约核对不再重复扫描(需先执行 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')
|
||||
->setDescription('同步甘草订单的物流路由(GET_TASK_ROUTE_LIST)到本地物流追踪表')
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单', 100)
|
||||
->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定 prescription_order.id', null)
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单(跳过已完成/已签收)', 2000)
|
||||
->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定订单:prescription_order.id 或 PO 业务单号 order_no', null)
|
||||
->addOption('tracking-number', 't', Option::VALUE_REQUIRED, '按快递单号走快递100 查询并落库')
|
||||
->addOption('detail', 'd', Option::VALUE_NONE, '打印每单详细结果');
|
||||
}
|
||||
@@ -48,8 +52,19 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$onlyOrderId = $input->getOption('order-id');
|
||||
$onlyOrderId = $onlyOrderId !== null ? (int) $onlyOrderId : null;
|
||||
$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'));
|
||||
$verbose = (bool) $input->getOption('detail');
|
||||
|
||||
@@ -71,7 +86,11 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
if ($trackingNumber !== '') {
|
||||
$output->writeln('开始查询...(快递100,tracking_number=' . $trackingNumber . ')');
|
||||
} 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 {
|
||||
@@ -82,10 +101,16 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
$stats['reconcile_lines'] = [];
|
||||
} else {
|
||||
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
|
||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(2000);
|
||||
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
|
||||
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
|
||||
$stats['reconcile_lines'] = $recon['lines'] ?? [];
|
||||
if ($onlyOrderId !== null) {
|
||||
$stats['reconcile_cleared'] = 0;
|
||||
$stats['reconcile_scanned'] = 0;
|
||||
$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) {
|
||||
$output->error('同步异常:' . $e->getMessage());
|
||||
@@ -120,10 +145,13 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
$output->writeln('同步完成');
|
||||
$output->writeln('========================================');
|
||||
$output->writeln('总数:' . $stats['total']);
|
||||
if (isset($stats['skipped'])) {
|
||||
$output->writeln('跳过:' . (int) $stats['skipped'] . '(已完成/已签收)');
|
||||
}
|
||||
$output->writeln('成功:' . $stats['success']);
|
||||
$output->writeln('失败:' . $stats['failed']);
|
||||
$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'] ?? []);
|
||||
if ($lines === []) {
|
||||
$output->writeln('医助明细:(无)');
|
||||
@@ -137,4 +165,35 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
|
||||
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', []);
|
||||
$enable = !empty($cfg['enable']);
|
||||
|
||||
$result = $out;
|
||||
if (! $enable) {
|
||||
$out['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
|
||||
if ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
|
||||
$out['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$lastFail = null;
|
||||
foreach ($comCandidates as $tryCom) {
|
||||
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
|
||||
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
|
||||
return $tryOut;
|
||||
$result['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
||||
} elseif ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
|
||||
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
|
||||
$result['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
|
||||
} else {
|
||||
$matched = null;
|
||||
$lastFail = null;
|
||||
foreach ($comCandidates as $tryCom) {
|
||||
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
|
||||
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
|
||||
$matched = $tryOut;
|
||||
break;
|
||||
}
|
||||
$lastFail = $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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user