Compare commits

..
Author SHA1 Message Date
Your Name c0e3cbd52e 更新 2026-07-10 09:23:23 +08:00
Your Name c07dfe4e36 更新 2026-07-05 08:59:47 +08:00
Your Name 1777fe7d8b 新增美颜 2026-07-03 16:51:02 +08:00
443 changed files with 38179 additions and 8009 deletions
-11
View File
@@ -223,17 +223,6 @@
"backgroundColor": "#c7d2fe",
"disableScroll": true
}
},
{
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "控糖消消乐",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
]
}
@@ -1,40 +0,0 @@
# 控糖消消乐平台接入说明
## 已接入能力
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
- 按周一日期、性别自动分配最多 7 人的同行组。
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
- 以真实平台昵称、头像、认糖数和本周最高分排序。
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
## 接口
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key``learned_count``score``ended`
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`
## 部署顺序
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php``TcmController.php`
3. 重新构建并上传小程序前端。
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
## 上线前检查
- 用男女各两个测试账号进入,确认被分入对应性别组。
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
- 周一验证新周重新分组,旧周成绩不带入新周。
@@ -1,60 +0,0 @@
# 控糖消消乐独立功能包
此目录包含“控糖消消乐”无尽三消版的全部运行文件,可以整体复制到另一个 uni-app Vue 3 项目的 `tongji/endless-game/` 下。
## 目录内容
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
- `game-endless.scss`:完整页面与动画样式。
- `components/TongjiIcon.vue`:本功能包使用的图标组件。
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
- `utils/svgDataUrl.js`:图标编码工具。
- `assets/food/`:本游戏使用的全部食品与驼乳粉图片。
- `viteAssetOutput.js`:把食品图输出到本功能分包,避免增加微信小程序主包体积。
## 迁移步骤
1. 整体复制 `endless-game` 文件夹到目标项目的 `tongji/` 目录。
2. 在目标项目的 `vite.config` 中引入素材输出规则,并放到 `build.rollupOptions.output`
```js
import { endlessGameAssetFileNames } from './tongji/endless-game/viteAssetOutput.js'
// defineConfig 内
build: {
rollupOptions: {
output: { assetFileNames: endlessGameAssetFileNames }
}
}
```
3. 在目标项目 `pages.json``tongji` 分包 `pages` 数组中加入:
```json
{
"path": "endless-game/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "控糖消消乐",
"backgroundColor": "#eefbf4",
"disableScroll": false,
"enableShareAppMessage": true,
"enableShareTimeline": true
}
}
```
4. 使用 `/tongji/endless-game/index` 打开游戏。
5. 微信小程序后台需要允许音频域名:`https://gz-1349751149.cos.ap-guangzhou.myqcloud.com`
## 说明
- 旧地址 `/tongji/pages/game``/tongji/pages/game-endless` 只是当前项目的兼容跳转,迁移时不需要复制。
- 当前项目原来的“糖分突袭”源码保存在 `tongji/legacy-game/game.vue`,没有注册为页面,不参与打包。
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`
- 本功能包依赖 uni-app Vue 3 和 `@dcloudio/uni-app`,不依赖当前项目其他业务组件。
- 食物风险等级和科普文案集中在 `index.vue``FOODS` 配置中,正式上线前仍需由医院医生审核。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,190 +0,0 @@
<template>
<view class="tj-icon-wrap" :class="[`tj-icon-wrap--${size}`]">
<image
v-if="!useFallback"
class="tj-icon"
:class="[`tj-icon--${name}`, `tj-icon--${size}`]"
:src="iconSrc"
mode="aspectFit"
@error="onImageError"
/>
<!-- <text
v-else
class="tj-icon-fallback"
:class="[`tj-icon-fallback--${size}`]"
:style="{ color }"
>{{ fallbackGlyph }}</text> -->
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { svgToDataUrl } from '../utils/svgDataUrl.js'
const props = defineProps({
name: { type: String, required: true },
size: { type: String, default: 'md' },
color: { type: String, default: '#204E2B' }
})
const useFallback = ref(false)
/** Lucide 风格描边路径 */
const ICON_PATHS = {
view: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
calendar: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
glucose: '<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
person: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
minus: '<path d="M5 12h14"/>',
droplet: '<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.5-2.5-5.5C15 7 12 2 12 2S9 7 7.5 9.5 5 13 5 15a7 7 0 0 0 7 7z"/>',
sparkles: '<path d="m12 3-1.9 5.8L4 12l5.8 1.9L12 21l1.9-5.8L20 12l-5.8-1.9L12 3Z"/><path d="M5 3v4M19 17v4M3 5h4M17 19h4"/>',
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
share: '<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13"/>',
plus: '<path d="M5 12h14M12 5v14"/>',
refresh: '<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16M21 21v-5h-5"/>',
volume: '<path d="M11 5 6 9H2v6h4l5 4V5zM15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"/>',
pause: '<rect width="4" height="16" x="14" y="4" rx="1"/><rect width="4" height="16" x="6" y="4" rx="1"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41-1.41M17.66 6.34l1.41-1.41M6.34 4.93l1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
sunset: '<path d="M12 10V2M18.364 5.636l-2.12 2.12M5.636 18.364l2.12-2.12M22 18h-3M5 18H2M18.364 18.364l-2.12-2.12M5.636 5.636l2.12 2.12M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>',
mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/>',
camera: '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/>',
bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
leaf: '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>',
utensils: '<path d="M3 2v7c0 1.1.9 2 2 2a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-3 4.5v6a2 2 0 0 0 2 2h1Z"/><path d="M18 15v7"/>',
egg: '<path d="M12 22c4.97 0 8-3.27 8-7.31C20 9.65 16.42 2 12 2S4 9.65 4 14.69C4 18.73 7.03 22 12 22Z"/>',
home: '<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
'plus-circle': '<circle cx="12" cy="12" r="10"/><path d="M8 12h8M12 8v8"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
syringe: '<path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/>',
zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>'
}
/** 图片加载失败时的 emoji 回退 */
const ICON_FALLBACK = {
view: '👁',
ticket: '🎫',
calendar: '📅',
flame: '🔥',
'check-circle': '✓',
glucose: '💧',
heart: '❤',
activity: '🏃',
users: '👥',
user: '👤',
person: '👤',
send: '➤',
'alert-triangle': '⚠',
minus: '—',
droplet: '💧',
sparkles: '✨',
trophy: '🏆',
share: '↗',
plus: '',
refresh: '↻',
volume: '🔊',
pause: '⏸',
play: '▶',
info: '!',
sun: '☀',
moon: '🌙',
sunset: '☀',
mic: '🎤',
camera: '📷',
bulb: '💡',
leaf: '🥬',
utensils: '🍴',
egg: '🍳',
home: '🏠',
'plus-circle': '⊕',
'chevron-right': '',
'chevron-left': '',
check: '✓',
settings: '⚙',
syringe: '💉',
zap: '⚡'
}
const strokeColor = computed(() => {
const c = String(props.color || '#204E2B').trim()
return /^#[0-9A-Fa-f]{3,8}$/.test(c) ? c : '#204E2B'
})
const iconSrc = computed(() => {
const path = ICON_PATHS[props.name] || ICON_PATHS.view
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor.value}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
return svgToDataUrl(svg)
})
const fallbackGlyph = computed(() => ICON_FALLBACK[props.name] || ICON_FALLBACK.view)
function onImageError() {
useFallback.value = true
}
</script>
<style lang="scss" scoped>
.tj-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tj-icon-wrap--sm {
width: 32rpx;
height: 32rpx;
}
.tj-icon-wrap--md {
width: 40rpx;
height: 40rpx;
}
.tj-icon-wrap--lg {
width: 48rpx;
height: 48rpx;
}
.tj-icon-wrap--xl {
width: 56rpx;
height: 56rpx;
}
.tj-icon {
display: block;
width: 100%;
height: 100%;
}
/* Lucide send 路径重心偏右上,微调使圆形按钮内视觉居中 */
.tj-icon--send {
transform: translate(-10%, 10%);
}
.tj-icon-fallback {
display: block;
line-height: 1;
font-weight: 700;
text-align: center;
}
.tj-icon-fallback--sm {
font-size: 24rpx;
}
.tj-icon-fallback--md {
font-size: 30rpx;
}
.tj-icon-fallback--lg {
font-size: 36rpx;
}
.tj-icon-fallback--xl {
font-size: 42rpx;
}
</style>
@@ -1,71 +0,0 @@
/**
* 控糖消消乐独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
*/
export function useGameAuth(proxy) {
let loginPromise = null
function hasToken() {
return !!String(uni.getStorageSync('token') || '').trim()
}
function clearToken() {
uni.removeStorageSync('token')
}
function wxLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
fail: reject
})
})
}
async function loginWithCode(code) {
const res = await proxy.apiUrl({
url: '/api/login/mnpLogin',
method: 'POST',
data: { code }
}, false)
if (res?.code !== 1 || !res.data?.token) {
clearToken()
throw new Error(res?.msg || '登录失败')
}
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userData', res.data)
return true
}
async function verifyOrLogin() {
if (hasToken()) {
try {
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
if (res?.code === 1 && res.data) {
uni.setStorageSync('userData', res.data)
return true
}
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
if (res?.code !== -1) return true
} catch (_) {
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
return true
}
clearToken()
}
const code = await wxLoginCode()
return loginWithCode(code)
}
async function ensureLoggedIn() {
if (loginPromise) return loginPromise
loginPromise = verifyOrLogin()
.then(ok => !!ok)
.catch(() => false)
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
.finally(() => { loginPromise = null })
return loginPromise
}
return { ensureLoggedIn }
}
@@ -1,251 +0,0 @@
import { ref } from 'vue'
const EMPTY_BOARD = {
week_start: '',
week_end: '',
sex_label: '同行',
group_size: 7,
member_count: 0,
players: [],
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
invite_code: ''
}
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
function createSessionKey() {
const random = Math.random().toString(36).slice(2, 12)
return `game_${Date.now().toString(36)}_${random}`
}
function readPendingPayloads() {
try {
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
if (!Array.isArray(stored)) return []
return stored.filter(item => (
item
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
&& Number(item.learned_count) >= 0
)).slice(-8)
} catch (_) {
return []
}
}
/**
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
*/
export function useGamePlatform(proxy, ensureLoggedIn) {
const connected = ref(false)
const loading = ref(false)
const syncStatus = ref('idle')
const leaderboard = ref({ ...EMPTY_BOARD })
const confirmedSessionLearned = ref(0)
let sessionKey = createSessionKey()
let syncTimer = null
let syncing = false
let queuedPayloads = readPendingPayloads()
let connectPromise = null
function persistPendingPayloads() {
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
}
async function api(request) {
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
return proxy.apiUrl(request, false)
}
function applyLeaderboard(data, confirmedForSession = '') {
if (!data || !Array.isArray(data.players)) return
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
if (!inactiveSessionResponse) {
leaderboard.value = {
...EMPTY_BOARD,
...data,
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
players: data.players
}
}
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
}
connected.value = true
syncStatus.value = 'synced'
}
async function refreshLeaderboard() {
const res = await api({
url: '/api/tcm/gameWeeklyLeaderboard',
method: 'GET'
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '同行榜加载失败')
}
applyLeaderboard(res.data)
return res.data
}
async function acceptShare(inviteCode) {
const code = String(inviteCode || '').trim().toUpperCase()
if (!code) return
try {
await api({
url: '/api/tcm/gameAcceptShare',
method: 'POST',
data: { invite_code: code }
})
} catch (_) {
// 分享关系是辅助能力,不阻断进入游戏。
}
}
async function connect(inviteCode = '') {
if (connectPromise) return connectPromise
connectPromise = (async () => {
loading.value = true
try {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('登录失败')
await acceptShare(inviteCode)
await refreshLeaderboard()
if (queuedPayloads.length) flushProgress()
return true
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
return false
} finally {
loading.value = false
connectPromise = null
}
})()
return connectPromise
}
function beginSession() {
if (syncTimer) clearTimeout(syncTimer)
sessionKey = createSessionKey()
confirmedSessionLearned.value = 0
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
return sessionKey
}
function queueProgress(learnedCount, score, ended = false) {
const nextPayload = {
session_key: sessionKey,
learned_count: Math.max(0, Number(learnedCount) || 0),
score: Math.max(0, Number(score) || 0),
ended: ended ? 1 : 0
}
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
if (existingIndex >= 0) {
const existing = queuedPayloads[existingIndex]
queuedPayloads[existingIndex] = {
...nextPayload,
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
score: Math.max(existing.score, nextPayload.score),
ended: Math.max(existing.ended, nextPayload.ended)
}
} else {
queuedPayloads.push(nextPayload)
}
persistPendingPayloads()
syncStatus.value = 'pending'
if (syncTimer) clearTimeout(syncTimer)
if (ended) {
flushProgress()
} else {
syncTimer = setTimeout(flushProgress, 1000)
}
}
async function flushProgress() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
if (syncing || !queuedPayloads.length) return
const payload = queuedPayloads[0]
syncing = true
syncStatus.value = 'syncing'
try {
if (!connected.value) {
const loggedIn = await ensureLoggedIn()
if (!loggedIn) throw new Error('未登录')
}
const res = await api({
url: '/api/tcm/gameSubmitProgress',
method: 'POST',
data: payload
})
if (res?.code !== 1 || !res.data) {
throw new Error(res?.msg || '成绩保存失败')
}
applyLeaderboard(res.data, payload.session_key)
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
queuedPayloads = queuedPayloads.filter(item => (
item.session_key !== payload.session_key
|| Number(item.learned_count) > Number(payload.learned_count)
|| Number(item.score) > Number(payload.score)
|| Number(item.ended) > Number(payload.ended)
))
persistPendingPayloads()
} catch (_) {
connected.value = false
syncStatus.value = 'offline'
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
persistPendingPayloads()
} finally {
syncing = false
if (queuedPayloads.length && connected.value) {
setTimeout(flushProgress, 80)
}
}
}
async function syncAndRefresh(learnedCount, score) {
queueProgress(learnedCount, score, false)
await flushProgress()
if (!connected.value) {
await connect()
if (queuedPayloads.length) await flushProgress()
} else {
try { await refreshLeaderboard() } catch (_) {}
}
}
async function recordShare() {
try {
if (!connected.value && !(await connect())) return
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
if (res?.code === 1 && res.data?.invite_code) {
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
}
} catch (_) {
// 分享本身仍可进行,统计失败不影响用户。
}
}
function dispose() {
if (syncTimer) clearTimeout(syncTimer)
syncTimer = null
persistPendingPayloads()
}
return {
connected,
loading,
syncStatus,
leaderboard,
confirmedSessionLearned,
connect,
beginSession,
queueProgress,
flushProgress,
syncAndRefresh,
refreshLeaderboard,
recordShare,
dispose
}
}
@@ -1,343 +0,0 @@
/**
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
*/
import { ref, onUnmounted } from 'vue'
const STORAGE_KEY = 'tongji_game_sfx_enabled'
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
const SRC = {
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
}
/**
* 音效表:src / volume / rate(playbackRate) / pool
* rate 不同可在同一素材上听出明显差异
*/
const SFX = {
// —— 交互 ——
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
// —— 消除(按连数)——
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
// —— 消除(按 GI 主色)——
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
// —— 连消 ——
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
// —— 道具 ——
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
// —— 反馈 ——
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
// 兼容旧名
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
}
const WARMUP_SFX = [
'select', 'swap', 'swapFail', 'drop', 'dropLight',
'match3', 'match4', 'match5', 'combo2', 'combo3', 'comboMega',
'insulin', 'sparkle', 'reshuffle', 'lose'
]
function createAudioContext() {
if (typeof Audio !== 'undefined') {
const audio = new Audio()
audio.preload = 'auto'
return {
get src() { return audio.src },
set src(value) { audio.src = value },
get volume() { return audio.volume },
set volume(value) { audio.volume = value },
get playbackRate() { return audio.playbackRate },
set playbackRate(value) { audio.playbackRate = value },
play() {
const promise = audio.play()
if (promise?.catch) promise.catch(() => {})
},
stop() {
audio.pause()
try { audio.currentTime = 0 } catch (_) {}
},
seek(time) {
try { audio.currentTime = time } catch (_) {}
},
destroy() {
audio.pause()
audio.removeAttribute('src')
}
}
}
return uni.createInnerAudioContext()
}
class SfxPool {
constructor(src, size, volume, rate = 1) {
this.src = src
this.size = size
this.volume = volume
this.rate = rate
this.list = []
this.cursor = 0
this.warmedUp = false
}
createOne() {
const ctx = createAudioContext()
ctx.src = this.src
ctx.obeyMuteSwitch = false
ctx.autoplay = false
ctx.volume = this.volume
try {
ctx.playbackRate = this.rate
} catch (_) {}
this.list.push(ctx)
return ctx
}
create() {
while (this.list.length < this.size) this.createOne()
}
warmUp() {
if (this.warmedUp) return
const ctx = this.list[0] || this.createOne()
try {
ctx.volume = 0
ctx.play()
setTimeout(() => {
try {
ctx.stop()
ctx.volume = this.volume
} catch (_) {}
}, 60)
} catch (_) {}
if (this.size > 1) this.cursor = 1
this.warmedUp = true
}
play(scale = 1, rateMul = 1) {
if (this.list.length < this.size) this.create()
const ctx = this.list[this.cursor % this.list.length]
this.cursor += 1
const vol = Math.min(1, this.volume * scale)
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
try {
ctx.volume = vol
try {
ctx.playbackRate = rate
} catch (_) {}
ctx.stop()
ctx.seek(0)
ctx.play()
} catch (_) {
try {
ctx.play()
} catch (e) {}
}
}
destroy() {
this.list.forEach((ctx) => {
try {
ctx.stop()
ctx.destroy()
} catch (_) {}
})
this.list = []
this.warmedUp = false
}
}
function dominantGi(tiles = []) {
const cnt = { low: 0, mid: 0, high: 0 }
tiles.forEach((t) => {
const g = t?.type?.gi
if (g && cnt[g] !== undefined) cnt[g] += 1
})
let best = 'mid'
let max = 0
Object.entries(cnt).forEach(([k, v]) => {
if (v > max) {
max = v
best = k
}
})
return best
}
export function useGameSfx() {
const enabled = ref(true)
const pools = {}
try {
const stored = uni.getStorageSync(STORAGE_KEY)
if (stored === false || stored === '0' || stored === 0) {
enabled.value = false
}
} catch (_) {}
function ensureAudioOption() {
// #ifdef MP-WEIXIN
try {
uni.setInnerAudioOption({
obeyMuteSwitch: false,
mixWithOther: true
})
} catch (_) {}
// #endif
}
function getPool(name) {
const cfg = SFX[name]
if (!cfg) return null
if (!pools[name]) {
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
}
return pools[name]
}
function warmUp() {
if (!enabled.value) return
ensureAudioOption()
const names = typeof Audio !== 'undefined' ? ['select'] : WARMUP_SFX
names.forEach((name) => {
getPool(name)?.warmUp()
})
}
function play(name, scale = 1, rateMul = 1) {
if (!enabled.value) return
getPool(name)?.play(scale, rateMul)
}
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
play(primary)
if (secondary) {
setTimeout(() => play(secondary, secScale), delayMs)
}
}
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
if (!enabled.value) return
const giTrack = (() => {
const gi = dominantGi(tiles)
if (gi === 'low') return 'matchLowGi'
if (gi === 'high') return 'matchHighGi'
return 'matchMidGi'
})()
if (chain >= 3) {
playLayer('comboMega', giTrack, 90, 0.55)
return
}
if (chain === 2) {
playLayer('combo3', giTrack, 80, 0.5)
return
}
if (chain === 1) {
playLayer('combo2', giTrack, 70, 0.45)
return
}
if (matchCount >= 5) {
playLayer('match5', 'matchHighGi', 85, 0.5)
} else if (matchCount >= 4) {
playLayer('match4', giTrack, 65, 0.48)
} else {
play(giTrack, 1, 1)
setTimeout(() => play('match3', 0.85), 40)
}
}
function playDrop(count = 1) {
if (!enabled.value || count <= 0) return
const name = count >= 4 ? 'drop' : 'dropLight'
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
}
function playMeter(delta, level) {
if (!enabled.value) return
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
else if (level < 30) play('meterWarn', 0.9, 0.95)
else if (delta > 8) play('meterWarn', 0.75)
else if (delta < -5) play('meterStable', 1.1)
}
function toggleEnabled() {
enabled.value = !enabled.value
try {
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
} catch (_) {}
if (enabled.value) {
warmUp()
play('select')
}
}
function destroyAll() {
Object.keys(pools).forEach((key) => {
pools[key]?.destroy()
delete pools[key]
})
}
onUnmounted(() => {
destroyAll()
})
return {
enabled,
play,
playLayer,
playMatch,
playDrop,
playMeter,
warmUp,
toggleEnabled,
destroyAll
}
}
@@ -1,565 +0,0 @@
.eg-page {
min-height: 100vh;
color: #173b32;
background: linear-gradient(180deg, #eefbf4 0%, #f9f3dd 56%, #fffaf1 100%);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.eg-confetti-layer { position: fixed; z-index: 80; inset: 0; overflow: hidden; pointer-events: none; }
.eg-confetti-piece {
position: absolute;
top: -70rpx;
display: block;
border-radius: 3rpx;
box-shadow: 0 2rpx 4rpx rgba(0,0,0,.12);
animation-name: egConfettiFall;
animation-timing-function: cubic-bezier(.18,.72,.35,1);
animation-fill-mode: both;
}
.eg-confetti-piece.is-emoji { width: auto !important; height: auto !important; background: transparent !important; box-shadow: none; font-size: 42rpx; }
.eg-nav {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 28rpx;
padding-bottom: 16rpx;
padding-left: 28rpx;
}
.eg-nav-btn {
display: flex;
width: 72rpx;
height: 72rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(21, 94, 75, .12);
border-radius: 24rpx;
background: rgba(255,255,255,.82);
}
.eg-nav-actions { display: flex; width: 156rpx; justify-content: flex-end; gap: 10rpx; }
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
.eg-title-wrap { position: absolute; left: 50%; display: flex; flex-direction: column; align-items: center; transform: translateX(-50%); }
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
.eg-content { padding: 0 16rpx 48rpx; }
.eg-score-card {
display: flex;
align-items: center;
margin-bottom: 16rpx;
padding: 18rpx 24rpx;
border: 2rpx solid rgba(21, 94, 75, .1);
border-radius: 28rpx;
background: rgba(255,255,255,.9);
box-shadow: 0 10rpx 28rpx rgba(32, 78, 43, .08);
}
.eg-stat { display: flex; flex: 1; flex-direction: column; align-items: center; }
.eg-stat--main { border-right: 2rpx solid #e2eee8; border-left: 2rpx solid #e2eee8; }
.eg-stat-label { color: #71847d; font-size: 24rpx; }
.eg-stat-value { margin-top: 4rpx; color: #204e2b; font-size: 38rpx; font-weight: 800; }
.eg-stat-score { margin-top: 2rpx; color: #e16f24; font-size: 46rpx; font-weight: 900; }
.eg-stat--rank { position: relative; cursor: pointer; }
.eg-stat--rank.is-locked { opacity: .52; }
.eg-stat-rank-value { margin-top: 1rpx; color: #176b52; font-size: 35rpx; font-weight: 1000; line-height: 1.08; }
.eg-stat-rank-hint { margin-top: 2rpx; color: #df7427; font-size: 18rpx; font-weight: 800; }
.eg-task-card {
margin-bottom: 16rpx;
padding: 18rpx 22rpx;
border-radius: 24rpx;
background: rgba(255, 249, 223, .94);
box-shadow: 0 8rpx 22rpx rgba(118, 89, 25, .08);
}
.eg-task-card { position: relative; overflow: visible; }
.eg-task-reward-flight {
position: absolute;
z-index: 72;
top: 4rpx;
left: 42%;
display: flex;
width: 112rpx;
height: 92rpx;
align-items: center;
justify-content: center;
border: 4rpx solid rgba(255,255,255,.96);
border-radius: 24rpx;
background: #fff7e8;
box-shadow: 0 10rpx 24rpx rgba(159,94,23,.28);
animation: egTaskRewardFlight 1.12s cubic-bezier(.18,.78,.22,1) both;
pointer-events: none;
}
.eg-task-reward-flight > image { position: relative; z-index: 2; width: 78rpx; height: 66rpx; }
.eg-task-reward-flight > text { position: absolute; z-index: 3; top: -14rpx; right: -16rpx; display: flex; width: 48rpx; height: 48rpx; align-items: center; justify-content: center; border: 4rpx solid #fff; border-radius: 50%; color: #fff; background: #ed7625; box-shadow: 0 5rpx 12rpx rgba(191,75,19,.3); font-size: 25rpx; font-weight: 1000; }
.eg-task-reward-glow { position: absolute; z-index: 1; inset: -15rpx; border-radius: 32rpx; background: radial-gradient(circle, rgba(255,210,67,.52), rgba(255,210,67,0) 68%); animation: egRewardGlow .42s ease-in-out infinite alternate; }
.eg-task-head { display: flex; align-items: center; justify-content: space-between; color: #7c5b1b; font-size: 27rpx; font-weight: 700; }
.eg-task-name { display: flex; align-items: center; gap: 10rpx; }
.eg-task-count { color: #9a6718; font-size: 29rpx; }
.eg-progress { height: 14rpx; margin-top: 12rpx; overflow: hidden; border-radius: 999rpx; background: #eadfbd; }
.eg-progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #f0a128, #f4c34f); transition: width .25s; }
.eg-board-shell {
position: relative;
padding: 14rpx;
border: 2rpx solid rgba(34, 103, 79, .16);
border-radius: 34rpx;
background: rgba(255,255,255,.92);
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
}
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
.eg-risk-dots { display: flex; gap: 5rpx; }
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
.eg-risk-dot.on { background: #e96b3b; box-shadow: 0 0 0 3rpx rgba(233,107,59,.12); }
.eg-board { display: flex; flex-direction: column; gap: 8rpx; opacity: 1; transition: opacity .15s; }
.eg-board.is-busy { opacity: .82; }
.eg-row { display: flex; gap: 8rpx; }
.eg-cell {
position: relative;
display: flex;
height: 166rpx;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
border: 5rpx solid transparent;
border-radius: 26rpx;
box-shadow: inset 0 -5rpx 0 rgba(0,0,0,.05), 0 5rpx 12rpx rgba(31, 66, 53, .08);
transition: transform .16s cubic-bezier(.2,.8,.2,1), border-color .15s, opacity .16s, filter .16s;
will-change: transform;
}
.eg-cell.is-dragging { transition: transform .055s linear, border-color .15s; }
.eg-cell.is-selected { z-index: 2; border-color: #167d61; transform: scale(1.06); box-shadow: 0 0 0 6rpx rgba(22,125,97,.14); }
.eg-cell.is-camel-target { border-color: #f59e0b; animation: egPulse 1s infinite; }
.eg-replace-target { position: absolute; z-index: 7; right: 7rpx; bottom: 6rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; background: #1570a6; box-shadow: 0 3rpx 9rpx rgba(20,91,135,.28); font-size: 20rpx; font-weight: 900; }
.eg-cell.is-clearing {
z-index: 8;
border-color: #fff;
animation: egHit .22s cubic-bezier(.2,.9,.3,1) forwards;
filter: brightness(1.28) saturate(1.25);
}
.eg-cell.is-yellow,
.eg-cell.is-cream,
.eg-cell.is-red,
.eg-cell.is-purple { background: #fffaf0; }
.eg-cell.is-level-low { background: #f4faf6; }
.eg-cell.is-level-mid { background: #fff9eb; }
.eg-cell.is-orange,
.eg-cell.is-level-high { background: #fff0e4; }
.eg-cell.is-milk { background: #edf7ff; }
.eg-cell.is-high { border-color: #ef7b45; }
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
.eg-sugar-tag.is-mid { color: #735a15; border-color: rgba(255,255,255,.9); background: #f7e7a8; box-shadow: 0 2rpx 6rpx rgba(137,91,0,.15); }
.eg-sugar-tag.is-high { background: #d9431f; box-shadow: 0 4rpx 10rpx rgba(163,45,21,.32); }
.eg-sugar-tag.is-prop { background: #2563a8; }
.eg-special-mark { position: absolute; z-index: 12; bottom: 5rpx; left: 6rpx; display: flex; width: 40rpx; height: 40rpx; align-items: center; justify-content: center; border: 3rpx solid rgba(255,255,255,.96); border-radius: 50%; color: #fff; background: #6d28d9; box-shadow: 0 4rpx 11rpx rgba(73,31,130,.3); font-size: 23rpx; font-weight: 900; }
.eg-special-mark.is-row { width: 58rpx; height: 40rpx; border-radius: 999rpx; background: linear-gradient(135deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
.eg-special-mark.is-col { width: 40rpx; height: 58rpx; border-radius: 999rpx; background: linear-gradient(180deg, #ffd64d, #ff8a18 48%, #e83b22); box-shadow: 0 0 0 4rpx rgba(255,172,30,.2), 0 4rpx 15rpx rgba(203,59,26,.45); animation: egFlameReady .7s ease-in-out infinite alternate; }
.eg-special-mark.is-burst { width: 48rpx; height: 48rpx; border-color: #fff3a8; border-radius: 15rpx 50% 50%; background: linear-gradient(145deg, #7c3aed 5%, #dd3b70 48%, #ff8a18 100%); box-shadow: 0 4rpx 13rpx rgba(108,35,155,.38), 0 0 12rpx rgba(255,179,38,.3); animation: egLReady 1.25s ease-in-out infinite alternate; }
.eg-mini-flame { position: relative; width: 25rpx; height: 30rpx; border-radius: 70% 32% 68% 40%; background: linear-gradient(135deg, #fff8a8 8%, #ffc21f 42%, #f04420 84%); box-shadow: 0 0 14rpx rgba(255,230,92,.95); transform: rotate(43deg); }
.eg-mini-flame-core { position: absolute; right: 4rpx; bottom: 3rpx; width: 10rpx; height: 15rpx; border-radius: 70% 35% 70% 42%; background: #fffbd1; box-shadow: 0 0 7rpx rgba(255,255,210,.95); }
.eg-l-special { position: relative; width: 29rpx; height: 29rpx; filter: drop-shadow(0 1rpx 2rpx rgba(73,22,105,.3)); }
.eg-l-arm { position: absolute; left: 3rpx; bottom: 3rpx; border-radius: 999rpx; background: #fff9b8; box-shadow: 0 0 6rpx rgba(255,249,184,.92); }
.eg-l-arm.is-vertical { width: 8rpx; height: 25rpx; }
.eg-l-arm.is-horizontal { width: 25rpx; height: 8rpx; }
.eg-l-core { position: absolute; left: 1rpx; bottom: 1rpx; width: 12rpx; height: 12rpx; border: 2rpx solid #fff; border-radius: 50%; background: #ff5b25; }
.eg-cell.is-fire-clearing { border-color: #ff7a1a; box-shadow: inset 0 0 25rpx rgba(255,102,18,.36), 0 0 18rpx rgba(255,105,20,.48); filter: brightness(1.35) saturate(1.42); }
.eg-cell.is-clearing.is-fire-clearing { animation: egFireCellHit .36s cubic-bezier(.2,.78,.22,1) forwards; }
.eg-fire-clear { position: absolute; z-index: 14; inset: 2rpx; overflow: hidden; border: 3rpx solid rgba(255,210,71,.92); border-radius: 22rpx; opacity: .96; background: linear-gradient(180deg, rgba(255,230,86,.12), rgba(239,63,27,.22)); pointer-events: none; }
.eg-fire-glow { position: absolute; inset: 6%; border-radius: 18rpx; background: radial-gradient(circle, rgba(255,246,177,.74), rgba(255,131,22,.32) 47%, rgba(222,47,23,0) 75%); animation: egFireGlow .34s ease-out both; }
.eg-fire-sweep { position: absolute; top: 23%; left: -48%; width: 168%; height: 55%; border-radius: 60% 45% 55% 42%; background: linear-gradient(90deg, rgba(255,184,38,0), rgba(255,238,124,.94) 28%, rgba(255,133,22,.92) 53%, rgba(226,54,25,.82) 72%, rgba(255,184,38,0)); box-shadow: 0 0 27rpx rgba(255,91,17,.72); animation: egFireSweep .34s cubic-bezier(.2,.78,.22,1) both; }
.eg-fire-flame { position: absolute; bottom: 13%; width: 25rpx; height: 39rpx; border-radius: 72% 35% 68% 42%; background: linear-gradient(145deg, #fff7a6 4%, #ffc329 39%, #ff7419 66%, #dc321f 100%); box-shadow: 0 0 15rpx rgba(255,124,21,.88); transform: rotate(42deg); animation: egFireFlame .34s ease-out both; }
.eg-fire-flame > view { position: absolute; right: 5rpx; bottom: 4rpx; width: 10rpx; height: 18rpx; border-radius: 70% 35% 70% 42%; background: #fffbd6; }
.eg-fire-flame.is-left { left: 16%; animation-delay: .01s; }
.eg-fire-flame.is-center { left: 43%; bottom: 19%; transform: rotate(42deg) scale(1.18); animation-delay: .035s; }
.eg-fire-flame.is-right { right: 15%; animation-delay: .065s; }
.eg-fire-spark { position: absolute; width: 10rpx; height: 17rpx; border-radius: 70% 35% 70% 42%; background: #ffe45e; box-shadow: 0 0 11rpx rgba(255,139,28,.9); animation: egFireSpark .34s ease-out both; }
.eg-fire-spark.is-one { top: 46%; left: 25%; }
.eg-fire-spark.is-two { top: 34%; left: 62%; animation-delay: .035s; }
.eg-fire-spark.is-three { top: 52%; left: 78%; animation-delay: .07s; }
.eg-board-tip { display: flex; align-items: center; justify-content: center; gap: 8rpx; min-height: 58rpx; margin-top: 12rpx; color: #684817; font-size: 26rpx; font-weight: 700; text-align: center; }
.eg-impact-text {
position: absolute;
z-index: 20;
top: 46%;
left: 50%;
padding: 10rpx 24rpx;
border: 4rpx solid rgba(255,255,255,.9);
border-radius: 999rpx;
color: #fff;
background: linear-gradient(135deg, #f59e0b, #e4572e);
box-shadow: 0 10rpx 28rpx rgba(190, 70, 25, .35);
font-size: 34rpx;
font-weight: 900;
transform: translate(-50%, -50%);
animation: egImpact .52s cubic-bezier(.16,.82,.3,1) forwards;
pointer-events: none;
}
.eg-impact-text.is-level-2 { font-size: 40rpx; background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.eg-impact-text.is-level-3 { font-size: 46rpx; background: linear-gradient(135deg, #ef4444, #f59e0b); box-shadow: 0 12rpx 36rpx rgba(239,68,68,.42); }
.eg-jackpot {
position: absolute;
z-index: 30;
top: 48%;
left: 50%;
width: 540rpx;
padding: 24rpx 22rpx 20rpx;
overflow: hidden;
border: 7rpx solid #ffd83d;
border-radius: 36rpx;
color: #fff;
background: radial-gradient(circle at 50% 0%, #ef4444, #9f1239 65%, #64102a);
box-shadow: 0 0 0 7rpx #fff2a8, 0 22rpx 52rpx rgba(100,16,42,.5);
text-align: center;
transform: translate(-50%, -50%);
animation: egJackpotIn 1.35s cubic-bezier(.16,.85,.25,1) forwards;
pointer-events: none;
}
.eg-jackpot.is-match-4 { background: radial-gradient(circle at 50% 0%, #8b5cf6, #5b21b6 68%, #35106d); }
.eg-jackpot.is-match-5 { border-color: #fff36a; background: radial-gradient(circle at 50% 0%, #ff6b24, #d7193f 58%, #760c2a); animation-duration: 1.9s; }
.eg-jackpot.is-match-3 {
width: 414rpx;
padding: 20rpx 22rpx 18rpx;
border-width: 4rpx;
border-color: #f1c663;
color: #31574b;
background: linear-gradient(155deg, #f7fff9, #fff5d9);
box-shadow: 0 0 0 4rpx rgba(255,255,255,.84), 0 14rpx 34rpx rgba(98,82,39,.2);
animation: egTripleIn 1.12s cubic-bezier(.2,.82,.28,1) forwards;
}
.eg-jackpot.is-match-3 .eg-jackpot-lights { display: none; }
.eg-jackpot.is-match-3 .eg-jackpot-kicker { color: #5b8c73; font-size: 21rpx; letter-spacing: 3rpx; text-shadow: none; }
.eg-jackpot.is-match-3 .eg-jackpot-title { color: #365b4e; font-size: 31rpx; text-shadow: none; }
.eg-jackpot.is-match-3 .eg-jackpot-reward { color: #ad7021; font-size: 25rpx; }
.eg-jackpot.is-match-3 .eg-jackpot-note { color: #829087; }
.eg-triple-cans { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 12rpx 0 10rpx; }
.eg-triple-can { display: flex; width: 78rpx; height: 76rpx; align-items: center; justify-content: center; border: 3rpx solid #f4d996; border-radius: 19rpx; background: rgba(255,255,255,.9); box-shadow: 0 5rpx 12rpx rgba(128,92,30,.12); animation: egTripleCan .42s cubic-bezier(.2,.86,.26,1) both; }
.eg-triple-can:nth-child(2) { animation-delay: .07s; }
.eg-triple-can:nth-child(3) { animation-delay: .14s; }
.eg-triple-can > image { width: 62rpx; height: 58rpx; }
.eg-jackpot.is-match-4 {
width: 548rpx;
padding: 25rpx 22rpx 22rpx;
border-color: #ffd95b;
background: radial-gradient(circle at 50% -10%, #a97cff, #6031b5 58%, #341066);
box-shadow: 0 0 0 6rpx #fff0a9, 0 0 36rpx rgba(255,210,76,.5), 0 24rpx 58rpx rgba(53,16,109,.48);
animation: egFourIn 1.88s cubic-bezier(.16,.84,.24,1) forwards;
}
.eg-jackpot.is-match-4 .eg-jackpot-kicker { color: #ffef8d; font-size: 24rpx; letter-spacing: 3rpx; }
.eg-jackpot.is-match-4 .eg-jackpot-title { margin-top: 9rpx; color: #fff; font-size: 39rpx; }
.eg-jackpot.is-match-4 .eg-jackpot-reward { color: #fff2a8; }
.eg-four-teaser { position: relative; margin-top: 13rpx; }
.eg-four-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.eg-four-can { display: flex; width: 76rpx; height: 92rpx; align-items: center; justify-content: center; border: 4rpx solid #ffe078; border-radius: 18rpx; background: linear-gradient(180deg, #fffef7, #ffe9a5); box-shadow: inset 0 -5rpx 0 rgba(171,106,17,.12), 0 7rpx 13rpx rgba(34,12,67,.28); animation: egFourCan .48s cubic-bezier(.18,.9,.25,1.2) both; }
.eg-four-can:nth-child(2) { animation-delay: .07s; }
.eg-four-can:nth-child(3) { animation-delay: .14s; }
.eg-four-can:nth-child(4) { animation-delay: .21s; }
.eg-four-can > image { width: 64rpx; height: 70rpx; }
.eg-four-can.is-locked { border-style: dashed; border-color: rgba(255,241,161,.86); color: #fff6ae; background: rgba(48,18,91,.54); box-shadow: inset 0 0 16rpx rgba(255,225,91,.18), 0 0 18rpx rgba(255,222,91,.36); font-size: 48rpx; font-weight: 1000; animation: egLockedCan .68s .34s ease-in-out infinite alternate; }
.eg-four-promise { display: inline-flex; margin-top: 15rpx; padding: 8rpx 18rpx; border: 2rpx solid rgba(255,247,184,.72); border-radius: 999rpx; color: #3d176f; background: linear-gradient(90deg, #fff3a3, #ffd85b); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); font-size: 25rpx; font-weight: 1000; animation: egPromisePulse .72s ease-in-out infinite alternate; }
.eg-five-celebration { position: relative; margin: 15rpx 0 10rpx; }
.eg-five-cans { display: flex; align-items: center; justify-content: center; gap: 8rpx; }
.eg-five-can { display: flex; width: 78rpx; height: 93rpx; align-items: center; justify-content: center; border: 4rpx solid #fff17b; border-radius: 18rpx; background: linear-gradient(180deg, #fff, #fff2ac); box-shadow: inset 0 -5rpx 0 rgba(184,91,12,.13), 0 7rpx 0 #bd5b13, 0 0 16rpx rgba(255,240,101,.5); animation: egFiveCan .56s cubic-bezier(.16,.92,.24,1.2) both; }
.eg-five-can:nth-child(2) { animation-delay: .07s; }
.eg-five-can:nth-child(3) { animation-delay: .14s; }
.eg-five-can:nth-child(4) { animation-delay: .21s; }
.eg-five-can:nth-child(5) { animation-delay: .28s; }
.eg-five-can > image { width: 65rpx; height: 72rpx; }
.eg-five-sevens { display: flex; justify-content: center; gap: 8rpx; margin-top: 13rpx; }
.eg-five-sevens > text { display: flex; width: 58rpx; height: 55rpx; align-items: center; justify-content: center; border: 3rpx solid #fff5a7; border-radius: 13rpx; color: #fff36a; background: linear-gradient(155deg, #d7193f, #9f1239); box-shadow: 0 5rpx 0 #70102b, 0 0 13rpx rgba(255,243,106,.45); font-size: 43rpx; font-weight: 1000; line-height: 1; text-shadow: 0 3rpx 0 #8f1733; }
.eg-jackpot-lights { position: absolute; inset: 8rpx; border: 4rpx dotted rgba(255,255,255,.88); border-radius: 25rpx; animation: egLights .24s steps(2) infinite; }
.eg-jackpot-kicker { position: relative; display: block; color: #fff36a; font-size: 28rpx; font-weight: 900; letter-spacing: 6rpx; text-shadow: 0 3rpx 0 rgba(83,19,25,.45); }
.eg-jackpot-reels { position: relative; display: flex; justify-content: center; gap: 12rpx; margin: 13rpx 0; }
.eg-jackpot-reel { display: flex; width: 112rpx; height: 126rpx; flex-direction: column; align-items: center; justify-content: center; border: 6rpx solid #ffcf31; border-radius: 20rpx; background: linear-gradient(180deg, #fff 0%, #fff7cf 48%, #ffd55e 50%, #fff 53%, #fff8dc 100%); box-shadow: inset 0 0 18rpx rgba(137,78,10,.25), 0 7rpx 0 #a85a0a; animation: egReelStop .52s cubic-bezier(.2,.9,.25,1) both; }
.eg-jackpot-reel:nth-child(2) { animation-delay: .09s; }
.eg-jackpot-reel:nth-child(3) { animation-delay: .18s; }
.eg-jackpot-seven { color: #e11d48; font-size: 61rpx; font-weight: 1000; line-height: .85; text-shadow: 0 3rpx 0 #ffd1d8; }
.eg-jackpot-milk { width: 54rpx; height: 36rpx; margin-top: 3rpx; border-radius: 7rpx; }
.eg-jackpot-title { position: relative; display: block; font-size: 37rpx; font-weight: 900; text-shadow: 0 4rpx 0 rgba(73,10,30,.5); }
.eg-jackpot-points { position: relative; display: block; margin-top: 2rpx; color: #fff36a; font-size: 66rpx; font-weight: 1000; line-height: 1.05; letter-spacing: 2rpx; text-shadow: 0 5rpx 0 #9e2813, 0 0 18rpx rgba(255,243,106,.85); animation: egPointsPop .62s .42s cubic-bezier(.16,.9,.25,1.25) both; }
.eg-jackpot-reward { position: relative; display: block; margin-top: 7rpx; color: #fff5a5; font-size: 27rpx; font-weight: 800; line-height: 1.35; }
.eg-jackpot-note { position: relative; display: block; margin-top: 8rpx; color: rgba(255,255,255,.8); font-size: 22rpx; }
.eg-tools { margin-top: 16rpx; }
.eg-tool { display: flex; align-items: center; padding: 16rpx 18rpx; border: 3rpx solid #c9e0d6; border-radius: 26rpx; background: rgba(255,255,255,.92); box-shadow: 0 7rpx 18rpx rgba(24, 84, 63, .07); }
.eg-tool.active { border-color: #f59e0b; background: #fffbeb; }
.eg-tool.disabled { opacity: .45; }
.eg-tool-icon { position: relative; display: flex; width: 88rpx; height: 88rpx; align-items: center; justify-content: center; overflow: visible; border-radius: 24rpx; font-size: 46rpx; background: linear-gradient(145deg, #e8f7ff, #d4ecff); box-shadow: inset 0 -4rpx 0 rgba(33,104,151,.08); }
.eg-tool-food-image { width: 76rpx; height: 58rpx; border-radius: 14rpx; }
.eg-replace-badge { position: absolute; right: -12rpx; bottom: -8rpx; display: flex; min-width: 78rpx; height: 38rpx; align-items: center; justify-content: center; gap: 2rpx; padding: 0 8rpx; border: 3rpx solid #fff; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #2389b9, #145d91); box-shadow: 0 5rpx 12rpx rgba(20,93,145,.3); }
.eg-replace-badge .tj-icon-wrap { width: 22rpx; height: 22rpx; }
.eg-replace-badge > text { font-size: 20rpx; font-weight: 900; line-height: 1; }
.eg-tool-copy { display: flex; flex: 1; flex-direction: column; margin-left: 16rpx; }
.eg-tool-name { color: #244a3d; font-size: 29rpx; font-weight: 800; }
.eg-tool-desc { margin-top: 5rpx; color: #6c8179; font-size: 25rpx; }
.eg-tool-count { color: #155e4b; font-size: 32rpx; font-weight: 900; }
.eg-legend { display: flex; justify-content: center; gap: 18rpx; margin-top: 18rpx; color: #53685f; font-size: 25rpx; font-weight: 700; }
.eg-dot--safe { color: #4cae7b; }
.eg-dot--mid { color: #e2aa19; }
.eg-dot--high { color: #e4572e; }
.eg-weekly-overlay { position: fixed; z-index: 90; inset: 0; display: flex; align-items: center; justify-content: center; padding: 28rpx; background: rgba(16, 55, 43, .66); backdrop-filter: blur(5px); }
.eg-weekly-card { width: 100%; max-width: 664rpx; max-height: calc(100vh - 56rpx); overflow-y: auto; padding: 28rpx 26rpx 22rpx; border: 5rpx solid rgba(255,255,255,.96); border-radius: 38rpx; background: linear-gradient(160deg, #f5fff9 0%, #fffdf3 56%, #fff4db 100%); box-shadow: 0 30rpx 80rpx rgba(5,45,32,.38); animation: egWeeklyIn .36s cubic-bezier(.18,.88,.27,1.08) both; }
.eg-weekly-head { display: flex; align-items: center; justify-content: space-between; }
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
.eg-weekly-progress-label { display: block; color: #657b73; font-size: 22rpx; font-weight: 700; }
.eg-weekly-progress-value { display: block; margin-top: -2rpx; color: #e06b24; font-size: 43rpx; font-weight: 1000; }
.eg-weekly-progress-copy { display: flex; flex-direction: column; align-items: flex-end; color: #235b49; font-size: 24rpx; font-weight: 900; line-height: 1.55; }
.eg-weekly-progress-copy text:last-child { color: #b86820; }
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
.eg-weekly-avatar.is-tone-5 { background: #a673bd; }
.eg-weekly-avatar.is-tone-6 { background: #448e9c; }
.eg-weekly-avatar.is-tone-7 { background: #c27b49; }
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
.eg-cheer-top { display: flex; align-items: center; justify-content: space-between; }
.eg-cheer-source { color: #8e5c18; font-size: 21rpx; font-weight: 900; }
.eg-cheer-card.is-family .eg-cheer-source { color: #77539c; }
.eg-cheer-change { padding: 5rpx 12rpx; border-radius: 999rpx; color: #176b52; background: rgba(255,255,255,.75); font-size: 19rpx; font-weight: 900; }
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-weekly-share::after { border: 0; }
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
.eg-stage-card { width: 100%; max-width: 620rpx; padding: 34rpx 28rpx 30rpx; border: 6rpx solid #fff; border-radius: 38rpx; background: linear-gradient(155deg, #f5fff9, #fff8da); box-shadow: 0 26rpx 70rpx rgba(10,56,41,.3); text-align: center; animation: egStageIn 1.8s cubic-bezier(.16,.86,.28,1) both; }
.eg-stage-kicker { display: block; color: #16815f; font-size: 23rpx; font-weight: 900; letter-spacing: 3rpx; }
.eg-stage-number { display: block; margin-top: 5rpx; color: #e47722; font-size: 48rpx; font-weight: 1000; line-height: 1.1; }
.eg-stage-title { display: block; margin-top: 7rpx; color: #194e3d; font-size: 38rpx; font-weight: 900; }
.eg-stage-foods { display: flex; justify-content: center; gap: 9rpx; margin-top: 22rpx; }
.eg-stage-food { display: flex; width: 92rpx; min-height: 110rpx; flex-direction: column; align-items: center; justify-content: center; border: 3rpx solid #dbece4; border-radius: 20rpx; color: #315b4e; background: #fff; font-size: 20rpx; font-weight: 800; }
.eg-stage-food > image { width: 72rpx; height: 52rpx; border-radius: 12rpx; }
.eg-stage-food > text { margin-top: 8rpx; }
.eg-stage-food.is-danger { border-color: #ef8b52; color: #a43c20; background: #fff1e6; box-shadow: 0 0 0 4rpx rgba(239,139,82,.12); }
.eg-stage-tip { display: block; margin-top: 20rpx; color: #6e776d; font-size: 21rpx; font-weight: 700; }
.eg-reshuffle-overlay { position: fixed; z-index: 65; inset: 0; display: flex; align-items: center; justify-content: center; padding: 46rpx; background: rgba(19,55,45,.5); }
.eg-reshuffle-card { width: 100%; max-width: 540rpx; padding: 38rpx 30rpx; border: 5rpx solid rgba(255,255,255,.95); border-radius: 36rpx; background: linear-gradient(155deg, #f4fff9, #e6f6ef); box-shadow: 0 24rpx 64rpx rgba(10,48,37,.3); text-align: center; }
.eg-reshuffle-icon { display: flex; width: 94rpx; height: 94rpx; align-items: center; justify-content: center; margin: 0 auto 18rpx; border-radius: 50%; background: linear-gradient(135deg, #2aa87b, #126a50); box-shadow: 0 10rpx 24rpx rgba(18,106,80,.28); animation: egReshuffleSpin .85s linear infinite; }
.eg-reshuffle-title { display: block; color: #174e3d; font-size: 36rpx; font-weight: 900; }
.eg-reshuffle-desc { display: block; margin-top: 10rpx; color: #5b746b; font-size: 24rpx; font-weight: 700; }
.eg-overlay { position: fixed; z-index: 50; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; padding: 36rpx; background: rgba(20, 48, 40, .62); }
.eg-modal { width: 100%; max-width: 650rpx; padding: 34rpx 28rpx 28rpx; border-radius: 38rpx; background: #fffdf8; box-shadow: 0 28rpx 70rpx rgba(0,0,0,.22); }
.eg-modal-icon { display: block; font-size: 68rpx; text-align: center; }
.eg-modal-title { display: block; margin-top: 6rpx; color: #204e3d; font-size: 38rpx; font-weight: 900; text-align: center; }
.eg-modal-sub { display: block; margin: 8rpx 0 22rpx; color: #718078; font-size: 24rpx; text-align: center; }
.eg-result-warning { display: flex; width: 92rpx; height: 92rpx; align-items: center; justify-content: center; margin: 0 auto; border-radius: 50%; color: #fff; background: #e4572e; font-size: 60rpx; font-weight: 900; }
.eg-result-food { display: block; margin: 12rpx 0 18rpx; color: #c34b27; font-size: 28rpx; font-weight: 800; text-align: center; }
.eg-knowledge-card { padding: 22rpx; border-radius: 24rpx; background: #fff3e7; }
.eg-knowledge-title { display: block; color: #8d4427; font-size: 26rpx; font-weight: 800; }
.eg-knowledge-copy { display: block; margin-top: 9rpx; color: #5f514a; font-size: 23rpx; line-height: 1.65; }
.eg-knowledge-review { display: block; margin-top: 12rpx; color: #9b7768; font-size: 19rpx; }
.eg-result-score { display: flex; align-items: center; justify-content: space-between; margin: 20rpx 6rpx; color: #466258; font-size: 25rpx; }
.eg-result-score text:last-child { color: #e16f24; font-size: 36rpx; font-weight: 900; }
.eg-result-best { display: flex; align-items: center; justify-content: space-between; margin: -12rpx 6rpx 20rpx; color: #75867f; font-size: 22rpx; }
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
.eg-share-btn::after { border: 0; }
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
@keyframes egPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
50% { box-shadow: 0 0 0 8rpx rgba(245,158,11,.12); }
}
@keyframes egWeeklyIn {
0% { opacity: 0; transform: translateY(34rpx) scale(.91); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes egHit {
0% { opacity: 1; transform: scale(1); }
42% { opacity: 1; transform: scale(1.18) rotate(-3deg); }
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
}
@keyframes egTaskRewardFlight {
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
72% { opacity: 1; transform: translate3d(270rpx, 900rpx, 0) scale(.68) rotate(8deg); }
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
}
@keyframes egRewardGlow {
from { opacity: .4; transform: scale(.86); }
to { opacity: .9; transform: scale(1.12); }
}
@keyframes egFireSweep {
0% { opacity: 0; transform: translate3d(-38%, 20rpx, 0) scaleX(.6); }
28% { opacity: 1; }
72% { opacity: .96; }
100% { opacity: 0; transform: translate3d(58%, -15rpx, 0) scaleX(1.08); }
}
@keyframes egFireSpark {
0% { opacity: 0; transform: translateY(14rpx) scale(.5) rotate(35deg); }
38% { opacity: 1; }
100% { opacity: 0; transform: translateY(-38rpx) scale(1.05) rotate(58deg); }
}
@keyframes egFireGlow {
0% { opacity: 0; transform: scale(.72); }
38% { opacity: 1; transform: scale(1.08); }
100% { opacity: 0; transform: scale(1.28); }
}
@keyframes egFireCellHit {
0% { opacity: 1; transform: scale(1); }
24% { opacity: 1; transform: scale(1.09); }
72% { opacity: 1; transform: scale(1.02); }
100% { opacity: 0; transform: scale(.48); }
}
@keyframes egFireFlame {
0% { opacity: 0; transform: translateY(20rpx) rotate(42deg) scale(.55); }
38% { opacity: 1; transform: translateY(0) rotate(42deg) scale(1.05); }
100% { opacity: 0; transform: translateY(-31rpx) rotate(48deg) scale(.86); }
}
@keyframes egFlameReady {
from { transform: scale(.94); filter: brightness(.96); }
to { transform: scale(1.08); filter: brightness(1.18); }
}
@keyframes egLReady {
from { transform: scale(.94) rotate(-2deg); filter: brightness(.96); }
to { transform: scale(1.06) rotate(2deg); filter: brightness(1.12); }
}
@keyframes egImpact {
0% { opacity: 0; transform: translate(-50%, -42%) scale(.55); }
35% { opacity: 1; transform: translate(-50%, -50%) scale(1.16); }
72% { opacity: 1; transform: translate(-50%, -54%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -76%) scale(.92); }
}
@keyframes egJackpotIn {
0% { opacity: 0; transform: translate(-50%, -45%) scale(.48) rotate(-4deg); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.09) rotate(2deg); }
31% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
78% { opacity: 1; transform: translate(-50%, -53%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -68%) scale(.92); }
}
@keyframes egTripleIn {
0% { opacity: 0; transform: translate(-50%, -44%) scale(.82); }
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
74% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -60%) scale(.96); }
}
@keyframes egTripleCan {
0% { opacity: 0; transform: translateY(18rpx) scale(.72); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes egFourIn {
0% { opacity: 0; transform: translate(-50%, -44%) scale(.62) rotate(-3deg); }
18% { opacity: 1; transform: translate(-50%, -50%) scale(1.08) rotate(1deg); }
29% { transform: translate(-50%, -50%) scale(.98) rotate(0); }
78% { opacity: 1; transform: translate(-50%, -52%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -65%) scale(.94); }
}
@keyframes egFourCan {
0% { opacity: 0; transform: translateY(-22rpx) rotate(-7deg) scale(.65); }
75% { opacity: 1; transform: translateY(5rpx) rotate(2deg) scale(1.05); }
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
}
@keyframes egFiveCan {
0% { opacity: 0; transform: translateY(-40rpx) scale(.52) rotate(-8deg); filter: brightness(1.5); }
70% { opacity: 1; transform: translateY(7rpx) scale(1.08) rotate(2deg); }
100% { opacity: 1; transform: translateY(0) scale(1) rotate(0); filter: brightness(1); }
}
@keyframes egLockedCan {
from { opacity: .62; transform: scale(.92); filter: brightness(.9); }
to { opacity: 1; transform: scale(1.06); filter: brightness(1.2); }
}
@keyframes egPromisePulse {
from { transform: scale(.97); box-shadow: 0 5rpx 15rpx rgba(28,7,62,.22); }
to { transform: scale(1.03); box-shadow: 0 7rpx 21rpx rgba(255,221,89,.38); }
}
@keyframes egReelStop {
0% { opacity: .2; transform: translateY(-48rpx) scaleY(1.4); filter: blur(5rpx); }
70% { opacity: 1; transform: translateY(7rpx) scaleY(.94); filter: blur(0); }
100% { transform: translateY(0) scaleY(1); }
}
@keyframes egLights {
0% { opacity: .35; }
100% { opacity: 1; }
}
@keyframes egConfettiFall {
0% { opacity: 0; transform: translate3d(0, -8vh, 0) rotate(0deg) scale(.7); }
9% { opacity: 1; }
48% { transform: translate3d(42rpx, 48vh, 0) rotate(420deg) scale(1); }
100% { opacity: .92; transform: translate3d(-28rpx, 112vh, 0) rotate(920deg) scale(.86); }
}
@keyframes egPointsPop {
0% { opacity: 0; transform: scale(.35) rotate(-7deg); }
55% { opacity: 1; transform: scale(1.28) rotate(3deg); }
78% { transform: scale(.92) rotate(0); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes egStageIn {
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
18% { opacity: 1; transform: scale(1.06) translateY(0); }
30%, 76% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.96) translateY(-22rpx); }
}
@keyframes egReshuffleSpin {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
@media screen and (max-height: 760px) {
.eg-task-card { padding-top: 13rpx; padding-bottom: 13rpx; }
}
File diff suppressed because it is too large Load Diff
@@ -1,49 +0,0 @@
/**
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
*/
function utf8ToBytes(str) {
const encoded = encodeURIComponent(str)
const bytes = []
for (let i = 0; i < encoded.length; i++) {
if (encoded.charCodeAt(i) === 37) {
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
i += 2
} else {
bytes.push(encoded.charCodeAt(i))
}
}
return new Uint8Array(bytes)
}
function bytesToBase64(bytes) {
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
return uni.arrayBufferToBase64(bytes.buffer)
}
if (typeof btoa !== 'undefined') {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
let result = ''
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i]
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
result += chars[a >> 2]
result += chars[((a & 3) << 4) | (b >> 4)]
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
result += i + 2 < bytes.length ? chars[c & 63] : '='
}
return result
}
export function svgToDataUrl(svg) {
const normalized = String(svg || '').trim()
if (!normalized) return ''
const base64 = bytesToBase64(utf8ToBytes(normalized))
return `data:image/svg+xml;base64,${base64}`
}
@@ -1,33 +0,0 @@
const FOOD_ASSET_NAMES = new Set([
'apple.jpg', 'banana.jpg', 'blackCoffee.jpg', 'broccoli.jpg', 'brownRice.jpg',
'camel.jpg', 'celery.jpg', 'centuryCongee.jpg', 'chicken.jpg', 'coffee.jpg',
'congee.jpg', 'corn.jpg', 'cucumber.jpg', 'egg.jpg', 'eightCongee.jpg',
'grainMantou.jpg', 'greenBean.jpg', 'lettuce.jpg', 'mango.jpg', 'milk.jpg',
'milkTea.jpg', 'milletCongee.jpg', 'mushroom.jpg', 'noodleSoup.jpg', 'onion.jpg',
'orangeJuice.jpg', 'pepper.jpg', 'pineapple.jpg', 'potato.jpg', 'riceNoodleSoup.jpg',
'shrimp.jpg', 'soda.jpg', 'strawberry.jpg', 'taro.jpg', 'tea.jpg', 'tomato.jpg',
'udon.jpg', 'wonton.jpg'
])
/**
* 让游戏食品图进入 tongji 分包,避免增加微信小程序主包体积。
* 迁移到其他 uni-app 项目时,在 vite.config 中复用此函数即可。
*/
export function endlessGameAssetFileNames(assetInfo = {}) {
const candidates = [
assetInfo.name,
...(assetInfo.names || []),
assetInfo.originalFileName,
...(assetInfo.originalFileNames || [])
].filter(Boolean)
const isEndlessFood = candidates.some((value) => {
const normalized = String(value).replace(/\\/g, '/')
const basename = normalized.split('/').pop()
return normalized.includes('tongji/endless-game/assets/food/') || FOOD_ASSET_NAMES.has(basename)
})
return isEndlessFood
? 'tongji/endless-game/assets/food/[name].[hash][extname]'
: 'assets/[name].[hash][extname]'
}
+1 -1
View File
@@ -1514,7 +1514,7 @@ async function goMorePage() {
function goGamePage() {
uni.navigateTo({
url: '/tongji/endless-game/index',
url: '/tongji/pages/game',
fail() {
uni.showToast({ title: '暂时无法打开游戏', icon: 'none' })
}
-8
View File
@@ -1,18 +1,10 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
import { endlessGameAssetFileNames } from './tongji/endless-game/viteAssetOutput.js'
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
// 编译产物默认输出到 ./dist/<mode>/<platform>
export default defineConfig({
plugins: [uni()],
build: {
rollupOptions: {
output: {
assetFileNames: endlessGameAssetFileNames,
},
},
},
css: {
preprocessorOptions: {
scss: {
-283
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -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",
+1
View File
@@ -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.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-5
View File
@@ -227,11 +227,6 @@ export function revisitRateVisitOrderLines(params: {
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
}
/** 待分配诊单自动指派日志列表(定时命令 tcm:auto-assign-pending 写入,含分配/未分配原因) */
export function autoAssignLogLists(params: Record<string, any>) {
return request.get({ url: '/stats.autoAssignLog/lists', params })
}
/** 医助个人业绩概览 */
export function assistantPerformanceOverview(params: {
time_type?: string
-31
View File
@@ -61,14 +61,6 @@ export function tcmDiagnosisDetail(params: any) {
return request.get({ url: '/tcm.diagnosis/detail', params })
}
/** 设置复诊接诊率统计起始偏移(统计诊次=实单序号+偏移;1=二诊起,2=三诊起) */
export function tcmDiagnosisSetRevisitSlotStartOffset(params: {
id: number
revisit_slot_start_offset: number
}) {
return request.post({ url: '/tcm.diagnosis/setRevisitSlotStartOffset', params })
}
/** 诊单挂号 / 取消挂号 操作日志 */
export function tcmDiagnosisGuahaoLogList(params: { id: number }) {
return request.get({ url: '/tcm.diagnosis/guahaoLogList', params })
@@ -433,17 +425,6 @@ export function prescriptionOrderPatchPrescriptionPatient(params: {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionPatient', params })
}
export function prescriptionOrderPatchPrescriptionUsage(params: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
}) {
return request.post({ url: '/tcm.prescriptionOrder/patchPrescriptionUsage', params })
}
export function prescriptionOrderAuditPrescription(params: {
id: number
action: 'approve' | 'reject'
@@ -542,18 +523,6 @@ export function prescriptionOrderLogs(params: { id: number }) {
return request.get({ url: '/tcm.prescriptionOrder/logs', params })
}
/** 手工新增操作日志(可选调整处方/支付单审核状态) */
export function prescriptionOrderAddLog(params: {
id: number
summary: string
prescription_audit_status?: number | ''
payment_slip_audit_status?: number | ''
prescription_audit_remark?: string
payment_slip_audit_remark?: string
}) {
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
}
/** 修改订单金额 */
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
@@ -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
/**
* 基础美颜下发到 TUICallEngineTRTC 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>
+33 -1
View File
@@ -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)
},
// 须先 endCallDeleteCloudRecording),再 await 本地上传;否则上传 WebM 耗时数分钟会拖住云端停录,控制台房间长期「尚未结束」
afterCalling: () => {
+35 -1
View File
@@ -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,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
+191
View File
@@ -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-1000 为关闭) */
lipstickStrength: number
/** 口红颜色(hex */
lipstickColor: string
/** 腮红浓度 0-1000 为关闭) */
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
}
}
+15
View File
@@ -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: '',
@@ -327,27 +327,7 @@
</div>
</div>
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="flex items-center gap-1">
<span>服用方式</span>
<el-button
v-if="
!readonly &&
detailData.prescription_id &&
detailPrescription &&
!String(detailData.prescription_detail_error || '').trim()
"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
@click="openPatchUsageDialog"
>
修改
</el-button>
</div>
</template>
<el-descriptions-item label="服用方式">
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
<template v-if="detailHasAuxHerbs">
<div>
@@ -651,7 +631,7 @@
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</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 v-if="showInternalCost" label="内部成本">
@@ -800,18 +780,7 @@
class="po-panel border-gray-100 mt-4"
>
<template #header>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-[15px]">操作日志</span>
<el-button
v-if="canAddPrescriptionOrderLog()"
type="primary"
link
size="small"
@click="openAddLogDialog"
>
新增日志
</el-button>
</div>
<span class="font-medium text-[15px]">操作日志</span>
</template>
<el-timeline v-if="detailLogs.length" class="mt-2 pl-2">
<el-timeline-item
@@ -831,182 +800,19 @@
</el-timeline>
<el-empty v-else description="暂无操作日志" :image-size="64" />
</el-card>
<!-- 新增操作日志 -->
<el-dialog
v-model="addLogVisible"
title="新增操作日志"
width="520px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
@closed="resetAddLogForm"
>
<el-form ref="addLogFormRef" :model="addLogForm" :rules="addLogRules" label-width="108px">
<el-form-item label="日志内容" prop="summary">
<el-input
v-model="addLogForm.summary"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="记录本次操作说明、沟通结果等"
/>
</el-form-item>
<el-form-item v-if="canSetRxAuditOnAddLog" label="处方审核">
<el-select v-model="addLogForm.prescription_audit_status" class="w-full" clearable placeholder="不修改">
<el-option label="待审核" :value="0" />
<el-option label="已通过" :value="1" />
<el-option label="已驳回" :value="2" />
</el-select>
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
当前:{{ auditStatusText(detailData.prescription_audit_status) }}
</div>
</el-form-item>
<el-form-item
v-if="canSetRxAuditOnAddLog && addLogForm.prescription_audit_status !== '' && addLogForm.prescription_audit_status !== null && addLogForm.prescription_audit_status !== undefined"
label="处方审核意见"
>
<el-input
v-model="addLogForm.prescription_audit_remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填"
/>
</el-form-item>
<el-form-item v-if="canSetPayAuditOnAddLog" label="支付单审核">
<el-select v-model="addLogForm.payment_slip_audit_status" class="w-full" clearable placeholder="不修改">
<el-option label="待审核" :value="0" />
<el-option label="已通过" :value="1" />
<el-option label="已驳回" :value="2" />
</el-select>
<div v-if="detailData" class="text-xs text-gray-400 mt-1">
当前:{{ auditStatusText(detailData.payment_slip_audit_status) }}
</div>
</el-form-item>
<el-form-item
v-if="canSetPayAuditOnAddLog && addLogForm.payment_slip_audit_status !== '' && addLogForm.payment_slip_audit_status !== null && addLogForm.payment_slip_audit_status !== undefined"
label="支付审核意见"
>
<el-input
v-model="addLogForm.payment_slip_audit_remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="选填"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addLogVisible = false">取消</el-button>
<el-button type="primary" :loading="addLogSaving" @click="submitAddLog">保存</el-button>
</template>
</el-dialog>
<!-- 修改服用参数:主方 / 辅方 / 订单设置 -->
<el-dialog
v-model="patchUsageVisible"
title="修改服用参数"
width="480px"
:close-on-click-modal="false"
destroy-on-close
@closed="resetPatchUsageForm"
>
<el-form
ref="patchUsageFormRef"
:model="patchUsageForm"
:rules="patchUsageRules"
label-width="108px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0">天</span>
</div>
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="aux_usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.aux_usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0">天</span>
</div>
</el-form-item>
</template>
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
<el-form-item label="服用天数" prop="medication_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.medication_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0">天</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="patchUsageVisible = false">取消</el-button>
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
</template>
</el-dialog>
</div>
</el-drawer>
</template>
<script lang="ts" setup name="PrescriptionOrderDetailDrawer">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { Refresh, Loading, Search, Van } from '@element-plus/icons-vue'
import {
prescriptionOrderDetail,
prescriptionOrderLogs,
prescriptionOrderAddLog,
prescriptionOrderLogisticsTrace,
prescriptionOrderLogisticsJdUpdate,
prescriptionOrderPaidPayOrders,
prescriptionOrderPatchPrescriptionUsage
prescriptionOrderPaidPayOrders
} from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
@@ -1025,7 +831,6 @@ import {
consumerRxAuditTag,
expressCompanyLabel,
logActionText,
auditStatusText,
formatPayOrderSource,
normalizeBizPhone,
recipientVsPrescriptionPhoneMismatch,
@@ -1036,10 +841,7 @@ import {
analyzeLogisticsPayloadUrgent,
parseLogisticsTracePayload,
canUpdateAmount,
formatDietaryTaboo,
type ServicePackageOption,
normalizeServicePackageOptions,
formatServicePackageLabels
formatDietaryTaboo
} from './prescription-order-utils'
const props = withDefaults(
@@ -1071,7 +873,6 @@ const emit = defineEmits<{
(e: 'view-prescription'): void
(e: 'test-gancao-preview'): void
(e: 'view-patient'): void
(e: 'detail-changed'): void
}>()
const userStore = useUserStore()
@@ -1287,24 +1088,36 @@ const detailFullAddress = computed(() => {
})
// ─── 服务套餐字典 ───
const servicePackageOptions = ref<ServicePackageOption[]>([])
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
async function loadServicePackageOptions() {
if (servicePackageOptions.value.length > 0) return
try {
const data: any = await getDictData({ type: 'server_order' })
const opts = normalizeServicePackageOptions(data?.server_order)
if (opts.length > 0) {
servicePackageOptions.value = opts
}
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
} catch {
/* 请求被同参数请求取消或失败时保留现值,open() 时会重试 */
servicePackageOptions.value = []
}
}
const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
)
function formatServicePackage(value: any): string {
if (!value) return '—'
let packages: string[] = []
if (Array.isArray(value)) {
packages = value
} else if (typeof value === 'string') {
packages = value.split(',').filter((v) => v.trim() !== '')
}
if (packages.length === 0) return '—'
const names = packages.map((val) => {
const option = servicePackageOptions.value.find((opt) => opt.value === val)
return option ? option.name : val
})
return names.join('、')
}
onMounted(() => {
loadServicePackageOptions()
@@ -1329,196 +1142,6 @@ async function fetchLogs(id: number) {
}
}
function hasPerm(perm: string) {
const p = userStore.perms || []
return p.includes('*') || p.includes(perm)
}
function canAddPrescriptionOrderLog() {
return hasPerm('tcm.prescriptionOrder/addLog')
}
/** 与后端 canAuditPrescriptionOrder 同档:超管或 prescription_audit_roles */
const canSetRxAuditOnAddLog = 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)) : []
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
})
/** 与后端 canAuditPaymentSlipOrder 同档:超管或 prescription_order_payment_audit_roles 默认 0,3 */
const canSetPayAuditOnAddLog = 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)) : []
return ids.some((id) => [0, 3].includes(id))
})
const addLogVisible = ref(false)
const addLogSaving = ref(false)
const addLogFormRef = ref<FormInstance>()
const addLogForm = reactive({
summary: '',
prescription_audit_status: '' as number | '',
payment_slip_audit_status: '' as number | '',
prescription_audit_remark: '',
payment_slip_audit_remark: ''
})
const addLogRules: FormRules = {
summary: [{ required: true, message: '请填写日志内容', trigger: 'blur' }]
}
const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
const rules: FormRules = {
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
}
return rules
})
function openPatchUsageDialog() {
const rx = detailPrescription.value
const ord = detailData.value
if (!rx || !ord?.id || !ord.prescription_id) {
feedback.msgWarning('无处方数据')
return
}
const aux = detailAuxUsage.value
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageForm.aux_times_per_day =
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.medication_days = undefined
}
async function submitPatchUsage() {
const form = patchUsageFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
const ordId = detailData.value?.id
if (!ordId) return
patchUsageSaving.value = true
try {
const payload: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
patchUsageVisible.value = false
await refresh()
emit('detail-changed')
} catch {
/* 拦截器已提示 */
} finally {
patchUsageSaving.value = false
}
}
function resetAddLogForm() {
addLogForm.summary = ''
addLogForm.prescription_audit_status = ''
addLogForm.payment_slip_audit_status = ''
addLogForm.prescription_audit_remark = ''
addLogForm.payment_slip_audit_remark = ''
addLogFormRef.value?.clearValidate()
}
function openAddLogDialog() {
if (!detailData.value?.id) return
resetAddLogForm()
const d = detailData.value
addLogForm.prescription_audit_remark = String(d.prescription_audit_remark || '')
addLogForm.payment_slip_audit_remark = String(d.payment_slip_audit_remark || '')
addLogVisible.value = true
}
async function submitAddLog() {
if (!addLogFormRef.value || !detailData.value?.id) return
await addLogFormRef.value.validate()
addLogSaving.value = true
try {
const payload: Record<string, unknown> = {
id: detailData.value.id,
summary: addLogForm.summary.trim()
}
if (
canSetRxAuditOnAddLog.value &&
addLogForm.prescription_audit_status !== '' &&
addLogForm.prescription_audit_status !== null &&
addLogForm.prescription_audit_status !== undefined
) {
payload.prescription_audit_status = addLogForm.prescription_audit_status
payload.prescription_audit_remark = addLogForm.prescription_audit_remark
}
if (
canSetPayAuditOnAddLog.value &&
addLogForm.payment_slip_audit_status !== '' &&
addLogForm.payment_slip_audit_status !== null &&
addLogForm.payment_slip_audit_status !== undefined
) {
payload.payment_slip_audit_status = addLogForm.payment_slip_audit_status
payload.payment_slip_audit_remark = addLogForm.payment_slip_audit_remark
}
await prescriptionOrderAddLog(payload as any)
feedback.msgSuccess('日志已添加')
addLogVisible.value = false
await refresh()
emit('detail-changed')
} catch {
/* 拦截器已提示 */
} finally {
addLogSaving.value = false
}
}
// ─── 未关联支付单 ───
async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrderId: number, linkedIds: number[]) {
if (!diagnosisId) {
@@ -1643,8 +1266,6 @@ async function updateJdLogistics() {
// ─── 打开 / 刷新 ───
async function open(id: number) {
// 页面级同参数字典请求会取消抽屉挂载时的那次(axios 去重取消),打开时兜底重试
void loadServicePackageOptions()
// 显式彻底清空缓存,防止前一次弹窗的数据残留
detailData.value = null
detailUnlinkedPayOrders.value = []
@@ -135,25 +135,13 @@ export function logActionText(act: string) {
revoke_pay_audit: '撤回支付审核',
gancao_submit: '甘草下单',
patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数',
update_amount: '修改订单金额',
complete: '完成订单',
refund: '退款',
manual_log: '手工备注',
assign_assistant: '改派医助',
add_pay_order: '补齐支付单',
set_ship_mode: '发货类型'
refund: '退款'
}
return m[act] || act
}
/** 处方/支付单审核状态文案(0 待审核 / 1 已通过 / 2 已驳回) */
export function auditStatusText(s: number | undefined) {
if (s === 1) return '已通过'
if (s === 2) return '已驳回'
return '待审核'
}
/** 支付单来源/方式:企微对外收款、付呗、快递代收等创建链路 + 支付方式回退 */
export function formatPayOrderSource(row: { payment_method?: unknown; create_type?: unknown }) {
const createType = String(row?.create_type || '')
@@ -369,85 +357,3 @@ export function formatDietaryTaboo(raw: unknown): string {
}
return ''
}
/** 服务套餐 dictserver_order */
export type ServicePackageOption = { name: string; value: string; status?: number }
export function normalizeServicePackageValue(v: unknown): string {
return String(v ?? '').trim()
}
/** 解析订单 service_package(逗号串 / 数组 / 单值数字) */
export function parseServicePackageValues(raw: unknown): string[] {
if (raw == null || raw === '') return []
if (Array.isArray(raw)) {
return raw.map(normalizeServicePackageValue).filter(Boolean)
}
if (typeof raw === 'string') {
return raw.split(',').map((v) => v.trim()).filter(Boolean)
}
const one = normalizeServicePackageValue(raw)
return one ? [one] : []
}
export function servicePackageValueEquals(a: unknown, b: unknown): boolean {
const sa = normalizeServicePackageValue(a)
const sb = normalizeServicePackageValue(b)
if (!sa || !sb) return false
if (sa === sb) return true
const na = Number(sa)
const nb = Number(sb)
return Number.isFinite(na) && Number.isFinite(nb) && na === nb
}
export function normalizeServicePackageOptions(raw: unknown): ServicePackageOption[] {
if (!Array.isArray(raw)) return []
return raw
.map((item: any) => ({
name: String(item?.name ?? '').trim() || normalizeServicePackageValue(item?.value),
value: normalizeServicePackageValue(item?.value),
status: Number(item?.status ?? 1)
}))
.filter((item) => item.value !== '')
}
export function findServicePackageOption(
options: ServicePackageOption[],
value: unknown
): ServicePackageOption | undefined {
const key = normalizeServicePackageValue(value)
if (!key) return undefined
return options.find((opt) => servicePackageValueEquals(opt.value, key))
}
/** 展示用:value → 字典 name,多选用「、」连接 */
export function formatServicePackageLabels(
value: unknown,
options: ServicePackageOption[],
emptyText = '—'
): string {
const packages = parseServicePackageValues(value)
if (packages.length === 0) return emptyText
const names = packages.map((val) => {
const option = findServicePackageOption(options, val)
return option?.name || val
})
return names.join('、')
}
/** 编辑下拉:字典项 + 当前已选但字典缺失的兜底项 */
export function mergeServicePackageSelectOptions(
options: ServicePackageOption[],
selected: unknown[]
): ServicePackageOption[] {
const known = new Set(options.map((o) => o.value))
const extras: ServicePackageOption[] = []
for (const raw of selected) {
const val = normalizeServicePackageValue(raw)
if (!val || known.has(val)) continue
const matched = findServicePackageOption(options, val)
extras.push(matched ?? { name: val, value: val, status: 0 })
known.add(val)
}
return extras.length ? [...options, ...extras] : options
}
@@ -298,7 +298,7 @@
:fetch-fun="prescriptionOrderExport"
:params="prescriptionOrderExportParams"
:page-size="pager.size"
export-hint="导出范围与上方筛选一致履约状态创建时间及其他条件均会生效自媒体渠道挂号渠道来源优先取该单关联处方登记的挂号无则诊单下同患者挂号取 id 最大的一条与前台挂号选择的记录一致业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源服务套餐按字典 server_order 解析展示处方导出主方/辅方药材明细主方/辅方服用方式天数与详情侧栏同口径主方/辅方天数分别取处方 usage_days辅方 aux_usage.usage_days天数列为订单 medication_days关联收款记录与详情侧栏同源已支付/已退款/待审核每笔两行展示摘要行+明细行多笔空行分隔单元格自动换行签收日期与详情/业绩看板同源仅读物流库轨迹/签收时间导出不再实时查快递100速度只取决于数据库签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库刚发货尚未同步的单子会暂时为空待下次回填/定时任务刷新后显示"
export-hint="导出范围与上方筛选一致履约状态创建时间及其他条件均会生效自媒体渠道挂号渠道来源优先取该单关联处方登记的挂号无则诊单下同患者挂号取 id 最大的一条与前台挂号选择的记录一致业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源服务套餐按字典 server_order 解析展示关联收款记录与详情侧栏同源已支付/已退款/待审核每笔两行展示摘要行+明细行多笔空行分隔单元格自动换行签收日期与详情/业绩看板同源仅读物流库轨迹/签收时间导出不再实时查快递100速度只取决于数据库签收时间由 `tcm:backfill-sign-time` 命令与物流自动更新定时任务落库刚发货尚未同步的单子会暂时为空待下次回填/定时任务刷新后显示"
/>
</el-form-item>
</el-form>
@@ -623,7 +623,6 @@
@view-prescription="detailData && openPrescriptionView(detailData)"
@test-gancao-preview="testGancaoPreviewFromDetail"
@view-patient="openDiagnosisPatientDetailFromOrder"
@detail-changed="getLists"
>
<template #header-extra="{ detail }">
<div class="flex items-center gap-2 ml-4 shrink-0">
@@ -1025,11 +1024,10 @@
class="w-full"
>
<el-option
v-for="item in editServicePackageSelectOptions"
v-for="item in servicePackageOptions"
:key="item.value"
:label="item.name"
:value="item.value"
:disabled="item.status === 0"
/>
</el-select>
</el-form-item>
@@ -2227,11 +2225,7 @@ import {
canUpdateAmount,
formatDietaryTaboo,
type SlipFormulaType,
type SlipAuxUsageForm,
type ServicePackageOption,
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions
type SlipAuxUsageForm
} from './components/prescription-order-utils'
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import {
@@ -2420,8 +2414,8 @@ async function submitReassign() {
// 省市区数据
const regionOptions = ref([])
// 服务套餐选项(含已停用项,便于编辑时回显历史值)
const servicePackageOptions = ref<ServicePackageOption[]>([])
// 服务套餐选项
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
@@ -2542,7 +2536,8 @@ const loadRegionData = async () => {
const loadServicePackageOptions = async () => {
try {
const data = await getDictData({ type: 'server_order' })
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
} catch (error) {
console.error('加载服务套餐选项失败:', error)
servicePackageOptions.value = []
@@ -3200,17 +3195,10 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
}
}
function canAddPayOrderRow(row: {
fulfillment_status?: number
amount?: number | string
linked_pay_paid_total?: number | string
}) {
// 已发货(5) / 已签收(6) 状态可补齐支付单;总金额已付清则不允许
function canAddPayOrderRow(row: { fulfillment_status?: number }) {
// 已发货(5) / 已签收(6) 状态可补齐支付单
const fs = Number(row.fulfillment_status)
if (fs !== 5 && fs !== 6) return false
const orderAmount = Math.round((Number(row.amount) || 0) * 100) / 100
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
return paidTotal < orderAmount
return fs === 5 || fs === 6
}
function canCompleteRow(row: { fulfillment_status?: number; payment_slip_audit_status?: number }) {
@@ -3461,11 +3449,6 @@ const editForm = reactive({
diagnosis_creator_dept_path: ''
})
/** 编辑弹窗下拉:字典项 + 当前已选但字典中缺失的兜底项 */
const editServicePackageSelectOptions = computed(() =>
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
)
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
@@ -3698,7 +3681,18 @@ async function openEdit(row: {
editForm.dose_unit = d.dose_unit || '剂'
editForm.prev_staff = d.prev_staff || ''
editForm.service_channel = d.service_channel || ''
editForm.service_package = parseServicePackageValues(d.service_package)
// 处理服务套餐:如果是字符串,转换为数组
if (d.service_package) {
if (Array.isArray(d.service_package)) {
editForm.service_package = d.service_package
} else if (typeof d.service_package === 'string') {
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
} else {
editForm.service_package = []
}
} else {
editForm.service_package = []
}
editForm.express_company = String(d.express_company || 'auto') || 'auto'
editForm.tracking_number = d.tracking_number || ''
editForm.fee_type = Number(d.fee_type) || 3
@@ -4515,18 +4509,7 @@ async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: n
}
}
function openAddPayOrder(row: {
id: number
diagnosis_id?: number
pay_order_ids?: number[]
fulfillment_status?: number
amount?: number | string
linked_pay_paid_total?: number | string
}) {
if (!canAddPayOrderRow(row)) {
feedback.msgWarning('订单总金额与已付金额一致,无需补齐支付单')
return
}
function openAddPayOrder(row: { id: number; diagnosis_id?: number; pay_order_ids?: number[] }) {
addPayOrderRowId.value = row.id
addPayOrderForm.add_mode = 'create'
addPayOrderForm.order_type = 3
@@ -858,94 +858,31 @@
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用量">
<div class="flex flex-col gap-1 text-sm leading-relaxed">
<div>
<span v-if="detailHasAuxHerbs" class="text-gray-500 mr-1">主方</span>
<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>
</div>
<div v-if="detailHasAuxHerbs && detailAuxUsage">
<span class="text-gray-500 mr-1">辅方</span>
<template v-if="detailAuxUsage.dosage_amount != null && detailAuxUsage.dosage_amount !== 0">
{{ detailAuxUsage.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
· {{ Number(detailAuxUsage.dosage_bag_count) > 0 ? Number(detailAuxUsage.dosage_bag_count) : 1 }}
</template>
<span v-if="detailPrescription.prescription_type === '饮片'" class="ml-2 text-gray-500">
({{ detailAuxUsage.need_decoction ? '代煎' : '不代煎' }})
</span>
</template>
<template v-else></template>
</div>
</div>
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="flex items-center gap-1 flex-wrap">
<span>服用方式</span>
<el-button
v-if="
detailData.prescription_id &&
detailPrescription &&
!String(detailData.prescription_detail_error || '').trim()
"
v-perms="['tcm.prescriptionOrder/patchPrescriptionUsage']"
type="primary"
size="small"
link
@click="openPatchUsageDialog"
>
修改
</el-button>
</div>
<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">
<template v-if="detailHasAuxHerbs">
<div>
<span class="text-gray-500 mr-1">主方</span>
每天
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '— 次' }}
· 处方开立
{{
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
? detailPrescription.usage_days + ' 天'
: '—'
}}
</div>
<div v-if="detailAuxUsage">
<span class="text-gray-500 mr-1">辅方</span>
每天
{{ detailAuxUsage.times_per_day ? detailAuxUsage.times_per_day + ' 次' : '— 次' }}
· 处方开立
{{
detailAuxUsage.usage_days != null && Number(detailAuxUsage.usage_days) > 0
? detailAuxUsage.usage_days + ' 天'
: '— 天'
}}
</div>
</template>
<template v-else>
<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>
</template>
<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>
{{
@@ -1202,7 +1139,7 @@
<el-descriptions-item label="上次医护">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
<el-descriptions-item label="服务套餐">{{ detailServicePackageText }}</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 v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本">
@@ -1588,11 +1525,10 @@
class="w-full"
>
<el-option
v-for="item in editServicePackageSelectOptions"
v-for="item in servicePackageOptions"
:key="item.value"
:label="item.name"
:value="item.value"
:disabled="item.status === 0"
/>
</el-select>
</el-form-item>
@@ -2215,93 +2151,6 @@
</template>
</el-dialog>
<!-- 修改服用参数主方 / 辅方 / 订单设置 -->
<el-dialog
v-model="patchUsageVisible"
title="修改服用参数"
width="92%"
:close-on-click-modal="false"
destroy-on-close
class="po-h5-dialog"
@closed="resetPatchUsageForm"
>
<el-form
ref="patchUsageFormRef"
:model="patchUsageForm"
:rules="patchUsageRules"
label-width="96px"
>
<div v-if="detailHasAuxHerbs" class="text-xs font-medium text-gray-500 mb-3">主方</div>
<el-form-item label="每天次数" prop="times_per_day">
<el-input-number
v-model="patchUsageForm.times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
<template v-if="detailHasAuxHerbs">
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">辅方</div>
<el-form-item label="每天次数" prop="aux_times_per_day">
<el-input-number
v-model="patchUsageForm.aux_times_per_day"
:min="1"
:max="6"
:step="1"
controls-position="right"
class="w-full"
/>
</el-form-item>
<el-form-item label="处方开立" prop="aux_usage_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.aux_usage_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</template>
<div class="text-xs font-medium text-gray-500 mb-3 mt-2 pt-2 border-t border-gray-100">订单设置</div>
<el-form-item label="服用天数" prop="medication_days">
<div class="flex items-center gap-1 w-full">
<el-input-number
v-model="patchUsageForm.medication_days"
:min="1"
:max="999"
:step="1"
controls-position="right"
class="flex-1 min-w-0"
/>
<span class="text-gray-500 shrink-0"></span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="patchUsageVisible = false">取消</el-button>
<el-button type="primary" :loading="patchUsageSaving" @click="submitPatchUsage">保存</el-button>
</template>
</el-dialog>
<!-- 处方详情查看处方单样式 -->
<el-drawer
v-model="prescriptionViewVisible"
@@ -2716,7 +2565,6 @@ import {
prescriptionOrderRevokeRxAudit,
prescriptionOrderRevokePayAudit,
prescriptionOrderPatchPrescriptionPatient,
prescriptionOrderPatchPrescriptionUsage,
prescriptionOrderLinkPayOrder,
prescriptionOrderRequestCompletion,
prescriptionOrderSubmitGancaoRecipel,
@@ -2725,16 +2573,7 @@ import {
getDoctors,
getAssistants
} from '@/api/tcm'
import {
formatDietaryTaboo,
type ServicePackageOption,
normalizeServicePackageOptions,
parseServicePackageValues,
mergeServicePackageSelectOptions,
formatServicePackageLabels,
normalizeSlipAuxUsageForm,
prescriptionHasAuxFormula
} from './components/prescription-order-utils'
import { formatDietaryTaboo } from './components/prescription-order-utils'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import { getDictData } from '@/api/app'
@@ -2830,7 +2669,7 @@ const canViewFinanceFields = () => {
const regionOptions = ref([])
//
const servicePackageOptions = ref<ServicePackageOption[]>([])
const servicePackageOptions = ref<Array<{ name: string; value: string }>>([])
/** 筛选:开方医生、诊单医助(与列表接口 doctor_id / assistant_id 一致) */
const doctorOptions = ref<Array<{ id: number; name: string }>>([])
@@ -2951,7 +2790,8 @@ const loadRegionData = async () => {
const loadServicePackageOptions = async () => {
try {
const data = await getDictData({ type: 'server_order' })
servicePackageOptions.value = normalizeServicePackageOptions(data?.server_order)
servicePackageOptions.value = (data?.server_order || []).filter((item: any) => item.status !== 0)
console.log('服务套餐选项已加载:', servicePackageOptions.value.length)
} catch (error) {
console.error('加载服务套餐选项失败:', error)
servicePackageOptions.value = []
@@ -3359,6 +3199,28 @@ function feeTypeText(t: number | undefined) {
return m[Number(t)] ?? '—'
}
//
function formatServicePackage(value: any): string {
if (!value) return '—'
let packages: string[] = []
if (Array.isArray(value)) {
packages = value
} else if (typeof value === 'string') {
packages = value.split(',').filter(v => v.trim() !== '')
}
if (packages.length === 0) return '—'
//
const names = packages.map(val => {
const option = servicePackageOptions.value.find(opt => opt.value === val)
return option ? option.name : val
})
return names.join('、')
}
function auditStatusText(s: number | undefined) {
if (s === 1) return '已通过'
if (s === 2) return '已驳回'
@@ -3611,10 +3473,6 @@ const detailVisible = ref(false)
const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
)
// --- ---
const workflowActiveStep = computed(() => {
if (!detailData.value) return 0
@@ -3771,16 +3629,6 @@ const detailLinkedAppointmentResolvedFromTag = computed(() => {
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
const detailHasAuxHerbs = computed(() => prescriptionHasAuxFormula(detailPrescription.value as any))
const detailAuxUsage = computed(() => {
const rx = detailPrescription.value as any
if (!rx || !prescriptionHasAuxFormula(rx)) return null
const raw = rx.aux_usage
if (raw == null || raw === '' || (Array.isArray(raw) && raw.length === 0)) return null
return normalizeSlipAuxUsageForm(raw, rx.prescription_type || '浓缩水丸')
})
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
@@ -4007,101 +3855,6 @@ const patchRxPatientRules: FormRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
const patchUsageVisible = ref(false)
const patchUsageSaving = ref(false)
const patchUsageFormRef = ref<FormInstance>()
const patchUsageForm = reactive({
times_per_day: 3 as number | undefined,
usage_days: 7 as number | undefined,
aux_times_per_day: 3 as number | undefined,
aux_usage_days: 7 as number | undefined,
medication_days: undefined as number | undefined
})
const patchUsageRules = computed<FormRules>(() => {
const rules: FormRules = {
times_per_day: [{ required: true, message: '请填写主方每天次数', trigger: 'change' }],
usage_days: [{ required: true, message: '请填写主方开立天数', trigger: 'change' }],
medication_days: [{ required: true, message: '请填写订单服用天数', trigger: 'change' }]
}
if (detailHasAuxHerbs.value) {
rules.aux_times_per_day = [{ required: true, message: '请填写辅方每天次数', trigger: 'change' }]
rules.aux_usage_days = [{ required: true, message: '请填写辅方开立天数', trigger: 'change' }]
}
return rules
})
function openPatchUsageDialog() {
const rx = detailPrescription.value
const ord = detailData.value
if (!rx || !ord?.id || !ord.prescription_id) {
feedback.msgWarning('无处方数据')
return
}
const aux = detailAuxUsage.value
patchUsageForm.times_per_day =
Number(rx.times_per_day) > 0 ? Number(rx.times_per_day) : 3
patchUsageForm.usage_days =
Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageForm.aux_times_per_day =
aux && Number(aux.times_per_day) > 0 ? Number(aux.times_per_day) : 3
patchUsageForm.aux_usage_days =
aux && Number(aux.usage_days) > 0 ? Number(aux.usage_days) : 7
const md = Number(ord.medication_days)
patchUsageForm.medication_days = md > 0 ? md : Number(rx.usage_days) > 0 ? Number(rx.usage_days) : 7
patchUsageVisible.value = true
nextTick(() => patchUsageFormRef.value?.clearValidate())
}
function resetPatchUsageForm() {
patchUsageForm.times_per_day = 3
patchUsageForm.usage_days = 7
patchUsageForm.aux_times_per_day = 3
patchUsageForm.aux_usage_days = 7
patchUsageForm.medication_days = undefined
}
async function submitPatchUsage() {
const form = patchUsageFormRef.value
if (!form) return
try {
await form.validate()
} catch {
return
}
const ordId = detailData.value?.id
if (!ordId) return
patchUsageSaving.value = true
try {
const payload: {
id: number
times_per_day: number
usage_days: number
medication_days: number
aux_times_per_day?: number
aux_usage_days?: number
} = {
id: ordId,
times_per_day: Number(patchUsageForm.times_per_day),
usage_days: Number(patchUsageForm.usage_days),
medication_days: Number(patchUsageForm.medication_days)
}
if (detailHasAuxHerbs.value) {
payload.aux_times_per_day = Number(patchUsageForm.aux_times_per_day)
payload.aux_usage_days = Number(patchUsageForm.aux_usage_days)
}
await prescriptionOrderPatchPrescriptionUsage(payload)
feedback.msgSuccess('保存成功')
patchUsageVisible.value = false
await refreshCurrentPrescriptionOrderDetail()
await fetchLogs(ordId)
getLists()
} catch {
/* 拦截器已提示 */
} finally {
patchUsageSaving.value = false
}
}
function openPatchRxPatientDialog() {
const rx = detailPrescription.value
const ord = detailData.value
@@ -4307,10 +4060,6 @@ const editForm = reactive({
diagnosis_creator_dept_path: ''
})
const editServicePackageSelectOptions = computed(() =>
mergeServicePackageSelectOptions(servicePackageOptions.value, editForm.service_package)
)
/** 诊单创建人部门:拆成面包屑。多部门为「;」分隔;路径内为「 / 」含父级(与后端 buildDeptPath 一致) */
const editDiagnosisCreatorDeptBreadcrumbs = computed(() => {
const raw = String(editForm.diagnosis_creator_dept_path || '').trim()
@@ -4561,7 +4310,18 @@ async function openEdit(row: {
editForm.dose_unit = d.dose_unit || '剂'
editForm.prev_staff = d.prev_staff || ''
editForm.service_channel = d.service_channel || ''
editForm.service_package = parseServicePackageValues(d.service_package)
//
if (d.service_package) {
if (Array.isArray(d.service_package)) {
editForm.service_package = d.service_package
} else if (typeof d.service_package === 'string') {
editForm.service_package = d.service_package.split(',').filter(v => v.trim() !== '')
} else {
editForm.service_package = []
}
} else {
editForm.service_package = []
}
editForm.express_company = String(d.express_company || 'auto') || 'auto'
editForm.tracking_number = d.tracking_number || ''
editForm.fee_type = Number(d.fee_type) || 3
@@ -1,154 +0,0 @@
<template>
<div class="auto-assign-log-page">
<el-card class="!border-none" shadow="never">
<el-form :inline="true" class="log-filter-form">
<el-form-item label="执行日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
@change="doSearch"
/>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="queryParams.action" clearable placeholder="全部" class="w-[120px]" @change="doSearch">
<el-option label="已分配" value="1" />
<el-option label="未分配" value="0" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="患者/手机号/医助/诊单ID"
clearable
class="w-[220px]"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="doSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="log-hint">
由每日定时任务待分配诊单自动指派写入按上月二诊复诊接诊率分档轮询分配&gt;70% 每人3条/60%~70% 每人2条/50%~60% 每人1条/&lt;50% 不分每条待指派诊单一行含分配 / 不分配原因
</div>
</el-card>
<el-card class="!border-none mt-3" shadow="never">
<el-table :data="pager.lists" v-loading="pager.loading" size="default" stripe>
<el-table-column label="记录时间" width="160">
<template #default="{ row }">{{ row.create_time_text || '—' }}</template>
</el-table-column>
<el-table-column label="诊单" width="90" prop="diagnosis_id" />
<el-table-column label="患者" min-width="110">
<template #default="{ row }">
<div>{{ row.patient_name || '—' }}</div>
<div class="cell-sub">{{ row.patient_phone || '' }}</div>
</template>
</el-table-column>
<el-table-column label="结果" width="90" align="center">
<template #default="{ row }">
<el-tag :type="Number(row.action) === 1 ? 'success' : 'info'" size="small">
{{ row.action_text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="医助" min-width="110">
<template #default="{ row }">
<template v-if="Number(row.assistant_id) > 0">
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="档位" width="100" align="center">
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
</el-table-column>
<el-table-column label="轮次" width="70" align="center">
<template #default="{ row }">{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}</template>
</el-table-column>
<el-table-column label="原因" min-width="360" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason }}</template>
</el-table-column>
<el-table-column label="统计月" width="90" prop="stat_month" align="center" />
<el-table-column label="批次" width="180">
<template #default="{ row }">
<span class="cell-sub">{{ row.batch_no }}</span>
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<pagination v-model="pager" @change="getLists" />
</div>
</el-card>
</div>
</template>
<script setup lang="ts" name="statsAutoAssignLog">
import { autoAssignLogLists } from '@/api/stats'
import { usePaging } from '@/hooks/usePaging'
import { onMounted, reactive, ref, watch } from 'vue'
const dateRange = ref<[string, string] | null>(null)
const queryParams = reactive({
start_date: '',
end_date: '',
action: '' as '' | '0' | '1',
keyword: ''
})
watch(dateRange, (val) => {
queryParams.start_date = val?.[0] ?? ''
queryParams.end_date = val?.[1] ?? ''
})
const { pager, getLists, resetPage } = usePaging({
fetchFun: autoAssignLogLists,
params: queryParams
})
const doSearch = () => resetPage()
const handleReset = () => {
dateRange.value = null
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.action = ''
queryParams.keyword = ''
resetPage()
}
const formatRate = (rate: number | null | undefined) =>
rate === null || rate === undefined ? '—' : `${rate}%`
onMounted(() => getLists())
</script>
<style lang="scss" scoped>
.auto-assign-log-page {
.log-filter-form {
:deep(.el-form-item) {
margin-bottom: 8px;
}
}
.log-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>
+4 -4
View File
@@ -18,7 +18,7 @@
<el-tree-select
v-model="deptId"
:data="deptTreeOptions"
placeholder="二中心(全部)"
placeholder="全部部门"
clearable
filterable
check-strictly
@@ -43,10 +43,10 @@
</template>
<div class="rate-caliber">
<p>
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重<b>再剔除</b>名下存在履约拒收 / 退款业务订单的诊单
<b>当月被指派总数</b>当月内诊单被指派给医助按指派操作时间落月<b>剔除勾选继承的指派</b>)的诊单数医助 × 诊单去重部门行 / 合计行按诊单去重
</p>
<p>
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序编号为实单序号<b>统计诊次 = 实单序号 + 诊单偏移</b>默认偏移 0 1 笔实单为一诊偏移 1 1 笔实单为二诊偏移 2 1 笔实单为三诊5 笔实单等价七诊诊次<b>跨月累计不重置</b>诊单可在业务订单tab 配置偏移量
<b>诊次 N 次下单</b>患者诊单名下计入业绩的业务订单剔除已取消 / 拒收 / 退款按下单时间升序的全局序号<b>跨月累计不重置</b> 5 月指派后旗下成交 4 单为二诊~五诊下月再成交即为六诊
</p>
<p>
<b>当月 N 诊单数</b>当月内下单且诊次为 N 的订单数归属下单时点<b>持有该患者的医助</b>指派可在往月释放后不再归属继承指派会转移持有人但不计被指派数
@@ -55,7 +55,7 @@
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数往月指派当月成交会推高分子比率可能超过 100%医助当月无新指派但旗下有成交时被指派数为 0比率显示
</p>
<p>
医助按人事部门归组<b>仅统计二中心及其组织下级</b>部门下拉与未选时的默认范围均限定在该子树选定部门时含其组织下级
医助按人事部门归组选定部门时含其组织下级
</p>
</div>
</el-popover>
+1 -45
View File
@@ -83,23 +83,6 @@
<el-radio-button value="0">未确认</el-radio-button>
</el-radio-group>
</div>
<div class="filter-group">
<span class="filter-label">部门</span>
<el-tree-select
v-model="formData.assistant_dept_id"
:data="departmentTreeRaw"
class="filter-dept-select"
clearable
filterable
check-strictly
:default-expand-all="true"
node-key="id"
size="small"
:props="assistantDeptTreeProps"
placeholder="选父级含子级"
@change="handleAssistantDeptChange"
/>
</div>
</div>
<el-dialog
@@ -559,7 +542,6 @@
import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
import { deptAll } from '@/api/org/department'
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
import { getDictData } from '@/api/app'
import { addDoctorNote } from '@/api/patient'
@@ -621,19 +603,10 @@ const formData = reactive({
end_date: '',
date_preset: 'today' as '' | 'yesterday' | 'day_before' | 'today' | 'tomorrow' | 'day_after',
diagnosis_confirmed: '' as '' | '0' | '1', // ''= 1= 0=
/** 接诊医生 / 诊单医助 / 挂号医助所属部门(选父级含子级) */
assistant_dept_id: '' as number | '',
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
include_status_counts: 0 as 0 | 1
})
const departmentTreeRaw = ref<unknown[]>([])
const assistantDeptTreeProps = {
value: 'id',
label: 'name',
children: 'children'
}
const activeTab = ref('1')
const dateCustomVisible = ref(false)
const statusCount = ref<Record<number, number>>({
@@ -748,12 +721,6 @@ const handleDiagnosisConfirmedChange = () => {
loadData()
}
//
const handleAssistantDeptChange = () => {
pager.page = 1
loadData()
}
//
const handleDatePresetChange = (val: string | number | boolean | undefined) => {
const v = String(val || '')
@@ -803,7 +770,6 @@ const handleReset = () => {
formData.doctor_name = ''
formData.date_preset = 'today'
formData.diagnosis_confirmed = ''
formData.assistant_dept_id = ''
const t = new Date()
const p = (n: number) => String(n).padStart(2, '0')
formData.start_date = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
@@ -1133,13 +1099,7 @@ formData.start_date = `${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${
formData.end_date = formData.start_date
formData.status = 1
onMounted(async () => {
try {
const deptTree = await deptAll()
departmentTreeRaw.value = Array.isArray(deptTree) ? deptTree : []
} catch {
departmentTreeRaw.value = []
}
onMounted(() => {
loadData()
listPollTimer = setInterval(() => {
loadData({ silent: true })
@@ -1268,10 +1228,6 @@ onUnmounted(() => {
padding: 6px 14px;
}
}
.filter-dept-select {
width: 200px;
}
}
}
@@ -4,43 +4,6 @@
<el-empty description="当前诊单未携带患者ID,无法列出业务订单" />
</div>
<template v-else>
<div v-if="diagnosisId > 0" class="po-revisit-offset-bar mb-3">
<div class="po-revisit-offset-bar__main">
<span class="text-sm text-gray-600">复诊统计起始偏移</span>
<el-tooltip placement="top">
<template #content>
<div class="max-w-xs leading-relaxed">
在实单诊次序号上叠加偏移量设为 0默认 1 笔实单计为一诊设为 1 1 笔实单计为二诊设为 2 1 笔实单计为三诊若有 5 笔实单且偏移 2则统计上相当于计至七诊5+2
</div>
</template>
<el-icon class="text-gray-400 align-middle ml-1"><QuestionFilled /></el-icon>
</el-tooltip>
<el-input-number
v-model="revisitSlotStartOffset"
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
:min="0"
:max="20"
:step="1"
controls-position="right"
class="w-[120px] ml-3"
:disabled="offsetSaving"
/>
<span class="text-xs text-gray-500 ml-2">
1 笔实单计为{{ visitSlotStartLabel }}
</span>
</div>
<el-button
v-perms="['tcm.diagnosis/setRevisitSlotStartOffset']"
type="primary"
size="small"
:loading="offsetSaving"
:disabled="!offsetDirty"
@click="saveRevisitSlotStartOffset"
>
保存
</el-button>
</div>
<el-table
v-loading="pager.loading"
:data="pager.lists"
@@ -49,23 +12,6 @@
empty-text="暂无业务订单"
>
<el-table-column label="订单编号" prop="order_no" min-width="200" show-overflow-tooltip />
<el-table-column label="全局诊次" width="96" align="center">
<template #default="{ row }">
<span v-if="row.global_visit_seq">{{ row.global_visit_seq }}</span>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="计入统计" width="96" align="center">
<template #default="{ row }">
<el-tag
v-if="row.counts_for_revisit_rate"
type="success"
size="small"
effect="plain"
></el-tag>
<el-tag v-else type="info" size="small" effect="plain"></el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">
<span class="text-red-500 font-semibold">¥{{ formatAmount(row.amount) }}</span>
@@ -117,11 +63,9 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { QuestionFilled } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import { prescriptionOrderLists, tcmDiagnosisDetail, tcmDiagnosisSetRevisitSlotStartOffset } from '@/api/tcm'
import { prescriptionOrderLists } from '@/api/tcm'
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
import feedback from '@/utils/feedback'
import {
formatTime,
fulfillmentText,
@@ -147,26 +91,6 @@ const { pager, getLists, resetPage } = usePaging({
size: 10
})
const revisitSlotStartOffset = ref(0)
const savedRevisitSlotStartOffset = ref(0)
const offsetSaving = ref(false)
const offsetLoading = ref(false)
const offsetDirty = computed(
() => Number(revisitSlotStartOffset.value) !== Number(savedRevisitSlotStartOffset.value)
)
const visitSlotStartLabel = computed(() => {
const raw = Number(revisitSlotStartOffset.value)
const offset = Number.isFinite(raw) ? raw : 0
const slot = offset + 1
const cn = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
if (slot >= 1 && slot <= 10) {
return cn[slot] + '诊'
}
return `${slot}`
})
const buildParams = () => {
Object.keys(queryParams).forEach((k) => delete queryParams[k])
if (props.diagnosisId > 0) {
@@ -178,47 +102,6 @@ const buildParams = () => {
queryParams.scene = 'diagnosis_edit'
}
async function loadRevisitSlotStartOffset() {
if (props.diagnosisId <= 0) return
offsetLoading.value = true
try {
const res: any = await tcmDiagnosisDetail({ id: props.diagnosisId })
const d = res?.data ?? res ?? {}
const offset = Number(d.revisit_slot_start_offset)
const val = Number.isFinite(offset) && offset >= 0 && offset <= 20 ? offset : 0
revisitSlotStartOffset.value = val
savedRevisitSlotStartOffset.value = val
} catch {
revisitSlotStartOffset.value = 0
savedRevisitSlotStartOffset.value = 0
} finally {
offsetLoading.value = false
}
}
async function saveRevisitSlotStartOffset() {
if (props.diagnosisId <= 0) return
const offset = Number(revisitSlotStartOffset.value)
if (!Number.isFinite(offset) || offset < 0 || offset > 20) {
feedback.msgWarning('起始偏移须在 020 之间')
return
}
offsetSaving.value = true
try {
await tcmDiagnosisSetRevisitSlotStartOffset({
id: props.diagnosisId,
revisit_slot_start_offset: offset
})
savedRevisitSlotStartOffset.value = offset
feedback.msgSuccess('保存成功')
getLists()
} catch {
/* 拦截器已提示 */
} finally {
offsetSaving.value = false
}
}
// /
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
@@ -234,13 +117,8 @@ const formatAmount = (value: unknown) => {
watch(
() => [props.diagnosisId, patientIdNum.value] as const,
() => {
if (!patientIdAvailable.value) {
pager.lists = []
pager.count = 0
return
}
if (!patientIdAvailable.value) { pager.lists = []; pager.count = 0; return }
buildParams()
void loadRevisitSlotStartOffset()
resetPage()
},
{ immediate: true }
@@ -256,21 +134,4 @@ defineExpose({ refresh: () => getLists() })
.po-empty-tip {
padding: 24px 0;
}
.po-revisit-offset-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.po-revisit-offset-bar__main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
</style>
+23 -125
View File
@@ -121,16 +121,6 @@
end-placeholder="最近挂号结束"
@change="handleLatestAppointmentFilterChange"
/>
<daterange-picker
class="latest-assign-range"
v-model:startTime="formData.latest_assign_start_date"
v-model:endTime="formData.latest_assign_end_date"
picker-type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="最近指派开始"
end-placeholder="最近指派结束"
@change="handleLatestAssignFilterChange"
/>
<el-select
v-model="formData.latest_appointment_channel_source"
placeholder="最近挂号渠道"
@@ -183,7 +173,6 @@
v-loading="pager.loading"
@selection-change="handleSelectionChange"
@row-dblclick="goReadonly"
@sort-change="handleTableSortChange"
:row-class-name="getRowClassName"
class="diagnosis-table"
stripe
@@ -294,14 +283,7 @@
<span v-else class="status-unprescribed">未开方</span>
</template>
</el-table-column>
<el-table-column
label="未服务天数"
prop="unserved_days"
width="110"
align="center"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<el-table-column label="未服务天数" width="110" align="center">
<template #default="{ row }">
<el-tooltip
v-if="row.last_blood_record_at"
@@ -780,10 +762,6 @@ const formData = reactive({
latest_appointment_start_date: '' as string,
latest_appointment_end_date: '' as string,
latest_appointment_channel_source: '' as string,
latest_assign_start_date: '' as string,
latest_assign_end_date: '' as string,
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
sort_unserved_days: '' as '' | 'asc' | 'desc',
diagnosis_confirmed: '' as '' | '0' | '1',
appointment_date: '' as string,
has_appointment: '' as '' | '0' | '1',
@@ -871,50 +849,22 @@ function resolvePendingAssignOrderMonthForRequest(): string {
return dayjs().format('YYYY-MM')
}
/** 除顶部 Tab 专属条件外,与主列表共用的「更多筛选」参数(角标 count 需同步) */
function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
return {
keyword: formData.keyword,
diagnosis_type: formData.diagnosis_type,
syndrome_type: formData.syndrome_type,
assistant_id: formData.assistant_id,
diagnosis_confirmed: formData.diagnosis_confirmed,
has_appointment: formData.has_appointment,
latest_appointment_start_date: formData.latest_appointment_start_date,
latest_appointment_end_date: formData.latest_appointment_end_date,
latest_appointment_channel_source: formData.latest_appointment_channel_source,
latest_assign_start_date: formData.latest_assign_start_date,
latest_assign_end_date: formData.latest_assign_end_date
}
}
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
page_no: 1,
page_size: 1,
...buildSharedDiagnosisFilterPayload(),
appointment_date: '',
pending_booking: '',
completed_appointment: '',
pending_assign: '',
pending_assign_order_month: '',
pending_assign_keyword: '',
sort_unserved_days: '',
...overrides
}
}
/** 待分配角标 count 请求:与列表同条件,且去掉其它顶部 Tab 残留(如默认「当天挂号」) */
function buildPendingAssignCountPayload(): Record<string, unknown> {
return buildTcmDiagnosisListRequestPayload(
buildDateCountRequestPayload({
pending_assign: 1,
has_appointment: '',
pending_assign_order_month: resolvePendingAssignOrderMonthForRequest(),
pending_assign_keyword: formData.pending_assign_keyword
}) as Record<string, unknown>
) as Record<string, unknown>
return buildTcmDiagnosisListRequestPayload({
...formData,
page_no: 1,
page_size: 1,
pending_assign: 1,
appointment_date: '',
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>
}
const fetchTcmDiagnosisListsForPaging = (req: Record<string, unknown>) =>
@@ -943,9 +893,6 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = ''
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
@@ -1047,26 +994,14 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
const fetchDateCounts = async () => {
try {
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: dayBeforeStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: todayStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: tomorrowStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: dayAfterStr.value, has_appointment: '' }) as any
),
tcmDiagnosisLists(buildDateCountRequestPayload() as any),
tcmDiagnosisLists(buildDateCountRequestPayload({ has_appointment: 0 }) as any),
tcmDiagnosisLists(
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
),
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
])
dateCounts.value = {
@@ -1200,11 +1135,6 @@ const clearLatestAppointmentFilters = () => {
formData.latest_appointment_channel_source = ''
}
const clearLatestAssignFilters = () => {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
}
const hasLatestAppointmentFilter = () =>
!!(
formData.latest_appointment_start_date ||
@@ -1212,9 +1142,6 @@ const hasLatestAppointmentFilter = () =>
formData.latest_appointment_channel_source
)
const hasLatestAssignFilter = () =>
!!(formData.latest_assign_start_date || formData.latest_assign_end_date)
const handleLatestAppointmentFilterChange = () => {
if (hasLatestAppointmentFilter()) {
formData.appointment_date = ''
@@ -1227,27 +1154,6 @@ const handleLatestAppointmentFilterChange = () => {
doSearch()
}
const handleLatestAssignFilterChange = () => {
doSearch()
}
const handleTableSortChange = ({
prop,
order
}: {
prop: string
order: 'ascending' | 'descending' | null
}) => {
if (prop === 'unserved_days') {
formData.sort_unserved_days =
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
} else {
formData.sort_unserved_days = ''
}
pager.page = 1
getLists()
}
const latestAppointmentChannelText = (row: any) => {
const desc = String(row?.latest_appointment_channel_source_desc || '').trim()
const raw = String(row?.latest_appointment_channel_source || '').trim()
@@ -1294,9 +1200,6 @@ const handleReset = () => {
formData.latest_appointment_start_date = ''
formData.latest_appointment_end_date = ''
formData.latest_appointment_channel_source = ''
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = ''
formData.has_appointment = ''
@@ -2395,11 +2298,6 @@ onUnmounted(() => {
max-width: 100%;
}
.latest-assign-range {
width: 260px;
max-width: 100%;
}
.latest-appointment-channel {
width: 170px;
}
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\stats;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\stats\AutoAssignLogLists;
/**
* 待分配诊单自动指派日志
*
* - GET stats.autoAssignLog/lists 日志列表(每条待指派诊单一行:分配结果 + 原因)
*/
class AutoAssignLogController extends BaseAdminController
{
public function lists()
{
return $this->dataLists(new AutoAssignLogLists());
}
}
@@ -94,24 +94,6 @@ class DiagnosisController extends BaseAdminController
return $this->fail(DiagnosisLogic::getError());
}
/**
* @notes 设置复诊接诊率统计起始偏移(业务订单 tab
*/
public function setRevisitSlotStartOffset()
{
$params = (new DiagnosisValidate())->post()->goCheck('setRevisitSlotStartOffset');
$ok = DiagnosisLogic::setRevisitSlotStartOffset(
(int) $params['id'],
(int) $params['revisit_slot_start_offset'],
$this->adminInfo
);
if (!$ok) {
return $this->fail(DiagnosisLogic::getError());
}
return $this->success('保存成功');
}
/**
* @notes 删除诊单
* @return \think\response\Json
@@ -181,20 +181,6 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('保存成功');
}
/**
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
*/
public function patchPrescriptionUsage()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('patchPrescriptionUsage');
$ok = PrescriptionOrderLogic::patchPrescriptionUsage($params, $this->adminId, $this->adminInfo);
if (!$ok) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('保存成功');
}
public function auditPrescription()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('auditPrescription');
@@ -342,20 +328,6 @@ class PrescriptionOrderController extends BaseAdminController
return $this->success('', $result);
}
/**
* 手工新增操作日志(可选同步调整处方/支付单审核状态)
*/
public function addLog()
{
$params = (new PrescriptionOrderValidate())->post()->goCheck('addLog');
$result = PrescriptionOrderLogic::addLog($params, $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(PrescriptionOrderLogic::getError());
}
return $this->success('日志已添加', $result);
}
/**
* 为「已发货」订单新增一条关联支付单,并重置支付单审核状态为待审核
*/
@@ -3,8 +3,6 @@
namespace app\adminapi\lists\doctor;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\tcm\Prescription;
@@ -77,35 +75,6 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
});
}
/**
* 按部门筛选:接诊医生、诊单医助或挂号医助所属部门命中子树即可(选父级含子级)
*
* @param mixed $query
*/
private function applyAssistantDeptIdFilter($query): void
{
if (!isset($this->params['assistant_dept_id']) || $this->params['assistant_dept_id'] === '' || (int) $this->params['assistant_dept_id'] <= 0) {
return;
}
$rootDeptId = (int) $this->params['assistant_dept_id'];
$deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId);
$deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool {
return $id > 0;
}));
if ($deptIds === []) {
$query->whereRaw('0 = 1');
return;
}
$inList = implode(',', $deptIds);
$adTbl = (new AdminDept())->getTable();
$query->whereRaw(
"(EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`doctor_id` AND ad.`dept_id` IN ({$inList}))"
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = u.`assistant_id` AND ad.`dept_id` IN ({$inList}))"
. " OR EXISTS (SELECT 1 FROM `{$adTbl}` ad WHERE ad.`admin_id` = a.`assistant_id` AND ad.`dept_id` IN ({$inList})))"
);
}
/**
* 渠道筛选:与 AppointmentLogic 一致,兼容仅有 channel_source、仅有 channels、或两者皆有的表结构
*
@@ -222,8 +191,6 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyAssistantIdFilter($query);
$this->applyAssistantDeptIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter);
// 是否确认诊单:1=已确认 0=未确认
@@ -406,8 +373,6 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
$this->applyAssistantIdFilter($query);
$this->applyAssistantDeptIdFilter($query);
$this->applyChannelSourceFilter($query, $chFilter);
if ((int) ($this->params['exclude_cancelled'] ?? 0) === 1) {
@@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\stats;
use app\adminapi\lists\BaseAdminDataLists;
use app\common\lists\ListsSearchInterface;
use think\facade\Db;
/**
* 待分配诊单自动指派日志列表
*
* 数据来源:tcm_diagnosis_auto_assign_log(定时命令 tcm:auto-assign-pending 写入)
* 筛选:run_date(执行日期)/ action1=已分配 0=未分配)/ assistant_id / keyword(患者姓名、手机号、医助姓名,纯数字兼容诊单ID)
*/
class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterface
{
private const TIER_LABELS = [
'gt70' => '>70%',
'60_70' => '60%~70%',
'50_60' => '50%~60%',
];
/**
* @notes 设置搜索条件
*/
public function setSearch(): array
{
return [
'=' => ['run_date', 'action', 'assistant_id', 'batch_no', 'stat_month'],
];
}
/**
* @notes 获取列表
*/
public function lists(): array
{
$rows = $this->buildQuery()
->order(['id' => 'desc'])
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
foreach ($rows as &$row) {
$ct = (int) ($row['create_time'] ?? 0);
$row['create_time_text'] = $ct > 0 ? date('Y-m-d H:i:s', $ct) : '';
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
}
unset($row);
return $rows;
}
/**
* @notes 获取数量
*/
public function count(): int
{
return $this->buildQuery()->count();
}
/**
* @return \think\db\Query
*/
private function buildQuery()
{
$query = Db::name('tcm_diagnosis_auto_assign_log')->where($this->searchWhere);
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->whereLike('patient_name', '%' . $keyword . '%')
->whereOr('patient_phone', 'like', '%' . $keyword . '%')
->whereOr('assistant_name', 'like', '%' . $keyword . '%');
if (preg_match('/^\d+$/', $keyword) === 1 && (int) $keyword > 0) {
$q->whereOr('diagnosis_id', '=', (int) $keyword);
}
});
}
$startDate = trim((string) ($this->params['start_date'] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) === 1) {
$query->where('run_date', '>=', $startDate);
}
$endDate = trim((string) ($this->params['end_date'] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) === 1) {
$query->where('run_date', '<=', $endDate);
}
return $query;
}
}
@@ -140,7 +140,6 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
$this->applyLatestAssignFilters($query, $pendingWideSearch);
$this->applyPendingAssignBusinessOrderMonthFilter($query);
@@ -168,7 +167,30 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
}
$orderRaw = $this->resolveListOrderRaw($pendingWideSearch);
// 按挂号状态优先级排序:已过号(4) > 已预约(1) > 已完成(3),然后按挂号日期+时间升序
// 若传了 appointment_date(当天/明天等筛选),只按「该日」的挂号排序与展示
$diagTbl = (new Diagnosis())->getTable();
$aptTbl = (new Appointment())->getTable();
$minAptDateCond = '';
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
$sortAptDate = addslashes((string) $this->params['appointment_date']);
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
}
// 获取最早的挂号状态(用于排序优先级)
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ' ORDER BY CASE apt.status WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END, apt.appointment_date ASC, apt.appointment_time ASC LIMIT 1)';
// 获取最早的挂号时间(用于同状态内排序)
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
// 「已完成」Tab:按最近一条「已完成」(status=3) 挂号日期+时间降序…
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
if ($isCompletedTab) {
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
} else {
$orderRaw = $diagTbl . '.assign_read_at IS NULL DESC, CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
}
$lists = $query
->with(['DiagnosisViewRecord'])
@@ -549,7 +571,6 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
$this->applyLatestAssignFilters($query, $pendingWideSearch);
// 仅已开方(待分配+关键词检索时不限制)
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
@@ -623,99 +644,6 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$query->whereExists("SELECT 1 FROM {$aptTbl} latest_apt WHERE " . implode(' AND ', $conditions));
}
/**
* 最近一次成功指派过滤:按 create_time DESC, id DESC to_assistant_id>0 的一条。
*
* @param mixed $query
*/
private function applyLatestAssignFilters($query, bool $pendingWideSearch): void
{
if ($pendingWideSearch) {
return;
}
$startDate = $this->normalizeYmd($this->params['latest_assign_start_date'] ?? '');
$endDate = $this->normalizeYmd($this->params['latest_assign_end_date'] ?? '');
if ($startDate === '' && $endDate === '') {
return;
}
$logTbl = Db::name('tcm_diagnosis_assign_log')->getTable();
$diagTbl = (new Diagnosis())->getTable();
$latestIdSql = $this->latestAssignLogIdSubSql($logTbl, $diagTbl);
$conditions = ["latest_lg.id = ({$latestIdSql})"];
if ($startDate !== '') {
$startTs = (int) strtotime($startDate . ' 00:00:00');
$conditions[] = "latest_lg.create_time >= {$startTs}";
}
if ($endDate !== '') {
$endTs = (int) strtotime($endDate . ' 23:59:59');
$conditions[] = "latest_lg.create_time <= {$endTs}";
}
$query->whereExists("SELECT 1 FROM {$logTbl} latest_lg WHERE " . implode(' AND ', $conditions));
}
private function latestAssignLogIdSubSql(string $logTbl, string $diagTbl): string
{
return "SELECT lg_latest.id FROM {$logTbl} lg_latest "
. "WHERE lg_latest.diagnosis_id = {$diagTbl}.id "
. 'AND lg_latest.to_assistant_id > 0 '
. 'ORDER BY lg_latest.create_time DESC, lg_latest.id DESC LIMIT 1';
}
/**
* 列表默认排序;支持 sort_unserved_days=asc|desc 按未服务天数排序。
*/
private function resolveListOrderRaw(bool $pendingWideSearch): string
{
$diagTbl = (new Diagnosis())->getTable();
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
$nullLast = "CASE WHEN IFNULL({$anchorExpr}, 0) = 0 THEN 1 ELSE 0 END ASC";
if ($sortUnserved === 'desc') {
return "{$nullLast}, IFNULL({$anchorExpr}, 0) ASC, {$diagTbl}.id DESC";
}
return "{$nullLast}, IFNULL({$anchorExpr}, 0) DESC, {$diagTbl}.id DESC";
}
$aptTbl = (new Appointment())->getTable();
$minAptDateCond = '';
if (!$pendingWideSearch && !empty($this->params['appointment_date'])) {
$sortAptDate = addslashes((string) $this->params['appointment_date']);
$minAptDateCond = " AND apt.appointment_date = '{$sortAptDate}'";
}
$minAptStatusExpr = '(SELECT apt.status FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ' ORDER BY CASE apt.status WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END, apt.appointment_date ASC, apt.appointment_time ASC LIMIT 1)';
$minAptExpr = '(SELECT MIN(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status IN (1,3,4)' . $minAptDateCond . ')';
$isCompletedTab = !$pendingWideSearch && isset($this->params['completed_appointment']) && (string) $this->params['completed_appointment'] === '1';
if ($isCompletedTab) {
$maxCompletedAptExpr = '(SELECT MAX(CONCAT(apt.appointment_date, \' \', IFNULL(NULLIF(TRIM(apt.appointment_time), \'\'), \'00:00:00\'))) FROM ' . $aptTbl . ' apt WHERE apt.patient_id = ' . $diagTbl . '.id AND apt.status = 3' . $minAptDateCond . ')';
return $diagTbl . '.assign_read_at IS NULL DESC, IFNULL(' . $maxCompletedAptExpr . ", '1970-01-01 00:00:00') DESC, {$diagTbl}.id DESC";
}
return $diagTbl . '.assign_read_at IS NULL DESC, CASE IFNULL(' . $minAptStatusExpr . ', 999) WHEN 4 THEN 1 WHEN 1 THEN 2 WHEN 3 THEN 3 ELSE 4 END ASC, IFNULL(' . $minAptExpr . ", '9999-12-31 23:59:59') ASC, {$diagTbl}.id DESC";
}
/** 未服务天数锚点:血糖/饮食/运动记录最近 record_date 的最大值 */
private function unservedAnchorExpr(string $diagTbl): string
{
$bloodTbl = (new BloodRecord())->getTable();
$dietTbl = (new DietRecord())->getTable();
$exerciseTbl = (new ExerciseRecord())->getTable();
return 'GREATEST('
. "COALESCE((SELECT MAX(br.record_date) FROM {$bloodTbl} br WHERE br.diagnosis_id = {$diagTbl}.id AND br.delete_time IS NULL), 0), "
. "COALESCE((SELECT MAX(dr.record_date) FROM {$dietTbl} dr WHERE dr.diagnosis_id = {$diagTbl}.id AND dr.delete_time IS NULL), 0), "
. "COALESCE((SELECT MAX(er.record_date) FROM {$exerciseTbl} er WHERE er.diagnosis_id = {$diagTbl}.id AND er.delete_time IS NULL), 0)"
. ')';
}
private function latestAppointmentIdSubSql(string $aptTbl, string $diagTbl): string
{
$statuses = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
@@ -741,10 +741,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
unset($item);
if ($this->shouldBypassListVisibilityForDiagnosisEdit()) {
$this->appendDiagnosisEditVisitSeqFields($lists);
}
$this->appendPrescriptionOrderAssignSnapshotErCenterFlags($lists);
if ((int) ($this->params['yeji_order_drawer'] ?? 0) === 1) {
@@ -824,11 +820,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)',
'export_medication_form' => '药品形态',
'export_prescription_name' => '药方名称',
'export_prescription_herbs' => '处方',
'export_main_usage' => '主方服用方式',
'export_main_usage_days' => '主方天数',
'export_aux_usage' => '辅方服用方式',
'export_aux_usage_days' => '辅方天数',
'export_service_package' => '服务套餐',
'export_medication_days' => '天数',
'export_amount' => '总金额',
@@ -918,9 +909,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$wrapWideKeys = [
'export_linked_pay_records',
'export_prescription_name',
'export_prescription_herbs',
'export_main_usage',
'export_aux_usage',
'export_guahao_channel_source',
'export_assistant_dept',
'export_service_package',
@@ -931,8 +919,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_patient_gender' => 6,
'export_patient_age' => 6,
'export_medication_days' => 6,
'export_main_usage_days' => 8,
'export_aux_usage_days' => 8,
'export_amount' => 10,
'export_paid_amount' => 10,
'export_refund_amount' => 10,
@@ -941,9 +927,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
'export_supply_mode' => 10,
'export_linked_pay_records' => 52,
'export_prescription_name' => 34,
'export_prescription_herbs' => 36,
'export_main_usage' => 28,
'export_aux_usage' => 28,
'export_guahao_channel_source' => 22,
'export_assistant_dept' => 24,
'export_service_package' => 18,
@@ -1421,69 +1404,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
);
}
/**
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)
*
* @param array<int, array<string, mixed>> $lists
*/
private function appendDiagnosisEditVisitSeqFields(array &$lists): void
{
if ($lists === []) {
return;
}
$diagIds = [];
foreach ($lists as $row) {
$d = (int) ($row['diagnosis_id'] ?? 0);
if ($d > 0) {
$diagIds[$d] = true;
}
}
$contextDid = (int) ($this->params['context_diagnosis_id'] ?? 0);
if ($contextDid > 0) {
$diagIds[$contextDid] = true;
}
$diagIdList = array_keys($diagIds);
if ($diagIdList === []) {
return;
}
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
$offsetMap = [];
foreach ($offsetRows as $id => $offset) {
$offsetMap[(int) $id] = max(0, min(20, (int) $offset));
}
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
$seqByOrderId = [];
foreach ($diagIdList as $did) {
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
$orderIds = $q
->order(['create_time' => 'asc', 'id' => 'asc'])
->column('id');
$seq = 0;
foreach ($orderIds as $oid) {
$seq++;
$seqByOrderId[(int) $oid] = $seq;
}
}
foreach ($lists as &$row) {
$oid = (int) ($row['id'] ?? 0);
$did = (int) ($row['diagnosis_id'] ?? 0);
$seq = (int) ($seqByOrderId[$oid] ?? 0);
$offset = (int) ($offsetMap[$did] ?? 0);
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
$row['counts_for_revisit_rate'] = $effectiveSlot >= 2 ? 1 : 0;
}
unset($row);
}
/**
* 列表行标注:指派日志快照(related_po_creator_id + related_po_create_time)是否指向本业务单,
* 以及该次操作的新医助(to_assistant_id)是否归属「二中心」部门子树(与 DeptLogic / 业绩看板一致)。
@@ -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字符串转换为数组,供前端组件使用
+1 -32
View File
@@ -151,24 +151,6 @@ class DeptLogic extends BaseLogic
* @return list<int>
*/
private static function findErCenterRootDeptIds(): array
{
return self::findCenterRootDeptIdsByNameKeyword('二中心');
}
/**
* 名称含「一中心」的部门 id。
*
* @return list<int>
*/
private static function findYiCenterRootDeptIds(): array
{
return self::findCenterRootDeptIdsByNameKeyword('一中心');
}
/**
* @return list<int>
*/
private static function findCenterRootDeptIdsByNameKeyword(string $keyword): array
{
$rows = Dept::whereNull('delete_time')
->field(['id', 'name'])
@@ -177,7 +159,7 @@ class DeptLogic extends BaseLogic
$out = [];
foreach ($rows as $r) {
$name = (string) ($r['name'] ?? '');
if ($name !== '' && mb_strpos($name, $keyword) !== false) {
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
$out[] = (int) $r['id'];
}
}
@@ -219,19 +201,6 @@ class DeptLogic extends BaseLogic
return array_fill_keys($subtreeIds, true);
}
/**
* 名称含「一中心」的部门及其全部下级 idmap)。
*
* @return array<int, true>
*/
public static function getYiCenterSubtreeDeptIdSet(): array
{
$yiRoots = self::findYiCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($yiRoots);
return array_fill_keys($subtreeIds, true);
}
/**
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
*
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use think\facade\Db;
/**
@@ -13,11 +12,9 @@ use think\facade\Db;
* 口径说明:
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
* 同一诊单当月被多次指派给同一医助只计 1
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
* 同一诊单当月被多次指派给同一医助只计 1
* - N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
@@ -25,8 +22,7 @@ use think\facade\Db;
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
* - 分档动态产出:N 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;部门筛选(dept_ids,含组织下级)按该归属部门过滤。
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
*/
class RevisitRateLogic
@@ -298,19 +294,14 @@ class RevisitRateLogic
}
/**
* 部门下拉:仅「二中心」及其组织下级(与业绩看板 ErCenter 子树一致)。
* 部门下拉(全量未删除部门,前端组树)。
*
* @return array{rows: list<array{id:int,pid:int,name:string}>}
*/
public static function deptOptions(): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return ['rows' => []];
}
$rows = Db::name('dept')
->whereNull('delete_time')
->whereIn('id', array_keys($erSet))
->field(['id', 'pid', 'name'])
->order('sort', 'desc')
->order('id', 'asc')
@@ -331,9 +322,9 @@ class RevisitRateLogic
/**
* 核心统计上下文:
* 1. 全量指派日志(≤ 月末)构建持有时间线;
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊)
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
* 2. 分母:当月非继承指派的「医助 × 诊单」;
* 3. 分子:曾被指派诊单的当月订单按全局序号 ≥2 归属持有医助
* 4. 应用部门筛选(含组织下级)。
*
* @param array{month?:string,dept_ids?:int[]|string} $params
*
@@ -384,35 +375,9 @@ class RevisitRateLogic
}
}
// 分母:剔除名下存在拒收(9)/退款(10) 业务订单的诊单(与明细 assignLines 同口径)
$assignedDiagIds = [];
foreach ($diagsByAssistant as $diagSet) {
foreach ($diagSet as $did => $_) {
$assignedDiagIds[(int) $did] = true;
}
}
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(array_keys($assignedDiagIds));
if ($refundRejectDiagSet !== []) {
foreach ($diagsByAssistant as $aid => $diagSet) {
foreach ($diagSet as $did => $_) {
if (isset($refundRejectDiagSet[$did])) {
unset($diagsByAssistant[$aid][$did]);
}
}
if ($diagsByAssistant[$aid] === []) {
unset($diagsByAssistant[$aid]);
}
}
$pairsRaw = array_values(array_filter(
$pairsRaw,
static fn (array $p): bool => !isset($refundRejectDiagSet[(int) $p['diagnosis_id']])
));
}
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 0 → 第 1 笔实单为一诊)
// 分子:曾被指派诊单的当月订单(全局序号 ≥2),归属下单时点的持有医助
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
$slotOrdersByAssistant = [];
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
$orderRows = self::fetchOrderSeqRows(
$chunk,
@@ -422,7 +387,6 @@ class RevisitRateLogic
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = 0;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
@@ -433,10 +397,8 @@ class RevisitRateLogic
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$seq++;
$effectiveSlot = $seq + $offset;
$ct = (int) ($r['create_time'] ?? 0);
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
$tl = $timeline[$did] ?? [];
@@ -445,27 +407,24 @@ class RevisitRateLogic
$holder = (int) $tl[$ptr]['to'];
$ptr++;
}
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
if ($seq < 2 || $seq > self::MAX_VISIT_SLOT) {
continue;
}
if ($ct < $startTs || $ct > $endTs) {
continue;
}
if ($holder > 0) {
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
$slotOrdersByAssistant[$holder][$seq][] = $r;
}
}
}
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
// 医助归属部门 + 部门筛选(含组织下级)
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
if ($subtreeSet === []) {
// 无二中心部门时整表为空,避免误展示其它中心数据
$diagsByAssistant = [];
$slotOrdersByAssistant = [];
} else {
$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])) {
@@ -584,45 +543,6 @@ class RevisitRateLogic
return [$canonical, $names];
}
/**
* 部门筛选集合:始终落在「二中心」子树内。
* - 未传 dept_ids:整棵二中心子树
* - 已传:所选部门及其下级 二中心子树(非法/非二中心 id 被忽略)
*
* @param mixed $raw
*
* @return array<int, true>
*/
private static function resolveDeptFilterSet(mixed $raw): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return [];
}
$deptFilterIds = self::parseDeptIds($raw);
if ($deptFilterIds === []) {
return $erSet;
}
$allowedRoots = [];
foreach ($deptFilterIds as $id) {
if (isset($erSet[$id])) {
$allowedRoots[] = $id;
}
}
if ($allowedRoots === []) {
return [];
}
$expanded = self::expandDeptSubtreeSet($allowedRoots);
$out = [];
foreach ($expanded as $id => $_) {
if (isset($erSet[$id])) {
$out[$id] = true;
}
}
return $out;
}
/**
* @param mixed $raw int[] | 逗号分隔字符串
*
@@ -676,35 +596,6 @@ class RevisitRateLogic
return $set;
}
/**
* 名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单集合。
* 用于「当月被指派总数」分母过滤;不限订单创建月份。
*
* @param list<int> $diagIds
*
* @return array<int, true>
*/
private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$ids = Db::name('tcm_prescription_order')
->whereIn('diagnosis_id', $chunk)
->whereNull('delete_time')
->whereIn('fulfillment_status', [9, 10])
->group('diagnosis_id')
->column('diagnosis_id');
foreach ($ids as $id) {
$out[(int) $id] = true;
}
}
return $out;
}
/**
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
*
@@ -728,48 +619,6 @@ class RevisitRateLogic
->toArray();
}
/**
* @param list<int> $diagIds
*
* @return array<int, int> diagnosis_id => revisit_slot_start_offset
*/
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $chunk)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
foreach ($rows as $id => $offset) {
$out[(int) $id] = (int) $offset;
}
}
return $out;
}
/**
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移)
*
* @param array<int, int> $offsetMap
*/
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
{
$offset = (int) ($offsetMap[$diagId] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > 20) {
$offset = 20;
}
return $offset;
}
/**
* @param list<int> $diagIds
*
@@ -748,49 +748,6 @@ class DiagnosisLogic extends BaseLogic
}
}
/**
* @notes 诊单指派医助操作记录列表
*/
public static function setRevisitSlotStartOffset(int $diagnosisId, int $offset, array $adminInfo): bool
{
self::$error = '';
if ($diagnosisId <= 0) {
self::setError('诊单不存在');
return false;
}
if ($offset < 0 || $offset > 20) {
self::setError('起始偏移须在 020 之间');
return false;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->find();
if (!$diagnosis) {
self::setError('诊单不存在');
return false;
}
$old = (int) ($diagnosis->revisit_slot_start_offset ?? 0);
if ($old < 0) {
$old = 0;
}
if ($old > 20) {
$old = 20;
}
if ($old === $offset) {
return true;
}
try {
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* @notes 诊单指派医助操作记录列表
*/
@@ -2375,123 +2375,6 @@ class PrescriptionOrderLogic
->toArray();
}
/**
* 手工新增操作日志;可选单独调整处方审核 / 支付单审核状态(不触发常规审核流程副作用)
*
* @param array<string,mixed> $params id, summary, prescription_audit_status?, payment_slip_audit_status?, prescription_audit_remark?, payment_slip_audit_remark?
* @return array<string,mixed>|false
*/
public static function addLog(array $params, int $adminId, array $adminInfo)
{
self::$error = '';
$id = (int) ($params['id'] ?? 0);
$summary = mb_substr(trim((string) ($params['summary'] ?? '')), 0, 500);
if ($summary === '') {
self::$error = '请填写日志内容';
return false;
}
$order = PrescriptionOrder::where('id', $id)->whereNull('delete_time')->find();
if (!$order) {
self::$error = '订单不存在';
return false;
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::$error = '无权限操作';
return false;
}
$changeParts = [];
$hasRxChange = array_key_exists('prescription_audit_status', $params)
&& $params['prescription_audit_status'] !== ''
&& $params['prescription_audit_status'] !== null;
$hasPayChange = array_key_exists('payment_slip_audit_status', $params)
&& $params['payment_slip_audit_status'] !== ''
&& $params['payment_slip_audit_status'] !== null;
if ($hasRxChange) {
if (!self::canAuditPrescriptionOrder($adminInfo)) {
self::$error = '无处方审核权限,不能调整处方审核状态';
return false;
}
$newRx = (int) $params['prescription_audit_status'];
if (!in_array($newRx, [0, 1, 2], true)) {
self::$error = '处方审核状态无效';
return false;
}
$oldRx = (int) $order->prescription_audit_status;
if ($newRx !== $oldRx) {
$order->prescription_audit_status = $newRx;
$changeParts[] = '处方审核:' . self::auditStatusLabelForLog($oldRx)
. ' → ' . self::auditStatusLabelForLog($newRx);
}
if (array_key_exists('prescription_audit_remark', $params)) {
$order->prescription_audit_remark = mb_substr(trim((string) $params['prescription_audit_remark']), 0, 500);
}
}
if ($hasPayChange) {
if (!self::canAuditPaymentSlipOrder($adminInfo)) {
self::$error = '无支付单审核权限,不能调整支付单审核状态';
return false;
}
$newPay = (int) $params['payment_slip_audit_status'];
if (!in_array($newPay, [0, 1, 2], true)) {
self::$error = '支付单审核状态无效';
return false;
}
$oldPay = (int) $order->payment_slip_audit_status;
if ($newPay !== $oldPay) {
$order->payment_slip_audit_status = $newPay;
$changeParts[] = '支付单审核:' . self::auditStatusLabelForLog($oldPay)
. ' → ' . self::auditStatusLabelForLog($newPay);
}
if (array_key_exists('payment_slip_audit_remark', $params)) {
$order->payment_slip_audit_remark = mb_substr(trim((string) $params['payment_slip_audit_remark']), 0, 500);
}
}
if ($hasRxChange || $hasPayChange) {
self::syncFulfillmentStatus($order);
try {
$order->save();
} catch (\Throwable $e) {
self::$error = $e->getMessage();
return false;
}
}
$logSummary = $summary;
if ($changeParts !== []) {
$logSummary .= '' . implode('', $changeParts) . '';
}
self::writeLog($id, $adminId, $adminInfo, 'manual_log', $logSummary);
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
}
private static function auditStatusLabelForLog(int $status): string
{
return match ($status) {
1 => '已通过',
2 => '已驳回',
default => '待审核',
};
}
/**
* 为「已发货/已签收」(fulfillment_status=5/6) 的业务订单新增一条关联支付单(zyt_order),
* 创建后将支付单链接到业务订单,并将处方/支付审核状态重置为待审核以启动再次审核流程。
@@ -3224,207 +3107,6 @@ class PrescriptionOrderLogic
return $type;
}
/**
* 导出用:处方药材明细(主方/辅方分行,与处方笺一致)
*
* @param array<string, mixed> $rx
*/
public static function formatPrescriptionHerbsForExport(array $rx): string
{
[$mainHerbs, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rx);
$formatList = static function (array $herbs): string {
$parts = [];
foreach ($herbs as $h) {
if (!\is_array($h)) {
continue;
}
$name = trim((string) ($h['name'] ?? ''));
if ($name === '') {
continue;
}
$parts[] = $name . ' ' . self::formatExportDosageNumber((float) ($h['dosage'] ?? 0)) . 'g';
}
return implode('、', $parts);
};
$sections = [];
$mainText = $formatList($mainHerbs);
if ($mainText !== '') {
$sections[] = '主方:' . $mainText;
}
$auxText = $formatList($auxHerbs);
if ($auxText !== '') {
$sections[] = '辅方:' . $auxText;
}
return implode("\n", $sections);
}
/**
* 导出用:主方/辅方服用方式(与前端 buildUsageSegmentText / 处方笺同口径)
*
* @param array<string, mixed> $usage
*/
public static function formatUsageSegmentForExport(
array $usage,
string $prescriptionType = '浓缩水丸',
string $fallbackWay = '',
string $fallbackTime = ''
): string {
$pt = trim($prescriptionType) !== '' ? trim($prescriptionType) : '浓缩水丸';
$times = (int) ($usage['times_per_day'] ?? 0);
if ($times <= 0) {
$times = 3;
}
$amount = isset($usage['dosage_amount']) && $usage['dosage_amount'] !== '' && $usage['dosage_amount'] !== null
? (float) $usage['dosage_amount']
: 10.0;
$unit = trim((string) ($usage['usage_dosage_unit'] ?? ($usage['dosage_unit'] ?? '')));
if ($unit === '') {
$unit = $pt === '饮片' ? 'ml' : 'g';
}
$usageWay = trim((string) ($usage['usage_way'] ?? ''));
if ($usageWay === '') {
$usageWay = $fallbackWay !== '' ? $fallbackWay : '温水送服';
}
$usageTime = trim((string) ($usage['usage_time'] ?? ''));
if ($usageTime === '') {
$usageTime = $fallbackTime;
}
$seg = ['每天' . $times . '次'];
if ($pt === '浓缩水丸') {
$bags = (int) ($usage['dosage_bag_count'] ?? 0);
if ($bags <= 0) {
$bags = 1;
}
$seg[] = '一次' . $bags . '袋';
$seg[] = '每袋' . self::formatExportDosageNumber($amount) . $unit;
} else {
$seg[] = '一次' . self::formatExportDosageNumber($amount) . $unit;
}
$seg[] = $usageWay;
if ($usageTime !== '') {
$seg[] = $usageTime;
}
return implode(', ', $seg);
}
/**
* 导出用:辅方用法 JSON 规范化(与前端 normalizeSlipAuxUsageForm 默认值一致)
*
* @return array<string, mixed>
*/
private static function normalizeAuxUsageForExport($raw, string $prescriptionType): array
{
$pt = trim($prescriptionType) !== '' ? trim($prescriptionType) : '浓缩水丸';
if ($pt === '饮片') {
$base = [
'dosage_amount' => 50.0,
'dosage_bag_count' => 1,
'times_per_day' => 3,
'usage_days' => 7,
];
} elseif ($pt === '浓缩水丸') {
$base = [
'dosage_amount' => 5.0,
'dosage_bag_count' => 1,
'times_per_day' => 3,
'usage_days' => 7,
];
} else {
$base = [
'dosage_amount' => 1.0,
'dosage_bag_count' => 1,
'times_per_day' => 3,
'usage_days' => 7,
];
}
if (\is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
$raw = \is_array($decoded) ? $decoded : null;
}
if (!\is_array($raw)) {
return $base;
}
return [
'dosage_amount' => isset($raw['dosage_amount']) && $raw['dosage_amount'] !== '' && $raw['dosage_amount'] !== null
? (float) $raw['dosage_amount']
: $base['dosage_amount'],
'dosage_bag_count' => (int) ($raw['dosage_bag_count'] ?? 0) > 0
? (int) $raw['dosage_bag_count']
: $base['dosage_bag_count'],
'times_per_day' => (int) ($raw['times_per_day'] ?? 0) > 0
? (int) $raw['times_per_day']
: $base['times_per_day'],
'usage_days' => (int) ($raw['usage_days'] ?? 0) > 0
? (int) $raw['usage_days']
: $base['usage_days'],
];
}
/**
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
*/
private static function splitPrescriptionHerbsFromRx(array $rx): array
{
$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;
}
}
return [$mainHerbs, $auxHerbs];
}
private static function formatExportDosageNumber(float $dosage): string
{
if (floor($dosage) === $dosage) {
return (string) (int) $dosage;
}
return rtrim(rtrim(number_format($dosage, 4, '.', ''), '0'), '.');
}
/**
* 导出用:主方/辅方开立天数(与详情侧栏「处方开立」同口径:主方取处方 usage_days,辅方取 aux_usage.usage_days
* 订单服用天数单独导出在 export_medication_days 列,不在此混用。
*
* @param array<string, mixed> $rx
* @param array<string, mixed>|null $auxUsage
*/
private static function resolveExportUsageDays(array $rx, ?array $auxUsage, bool $isAux): string
{
if ($isAux) {
$days = (int) ($auxUsage['usage_days'] ?? 0);
return $days > 0 ? (string) $days : '';
}
$days = (int) ($rx['usage_days'] ?? 0);
return $days > 0 ? (string) $days : '';
}
/**
* 导出列:挂号表渠道来源展示(与 AppointmentLists channel_source_desc 同字典口径)
*
@@ -3721,12 +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',
'prescription_name', 'aux_usage', 'herbs', 'creator_id',
'dosage_amount', 'dosage_unit', 'dosage_bag_count', 'times_per_day', 'usage_days',
'usage_way', 'usage_time',
])
->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) {
@@ -3960,38 +3637,6 @@ class PrescriptionOrderLogic
}
$item['export_prescription_name'] = implode(' ', $rxNameParts);
$rxArr = \is_array($rx) ? $rx : [];
$rxType = trim((string) ($rxArr['prescription_type'] ?? '')) ?: '浓缩水丸';
$item['export_prescription_herbs'] = self::formatPrescriptionHerbsForExport($rxArr);
$item['export_main_usage'] = $rxArr !== []
? self::formatUsageSegmentForExport($rxArr, $rxType)
: '';
[, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rxArr);
$auxUsageNorm = $auxHerbs !== []
? self::normalizeAuxUsageForExport($rxArr['aux_usage'] ?? null, $rxType)
: null;
if ($auxHerbs !== [] && $auxUsageNorm !== null) {
$item['export_aux_usage'] = self::formatUsageSegmentForExport(
[
'dosage_amount' => $auxUsageNorm['dosage_amount'],
'dosage_bag_count' => $auxUsageNorm['dosage_bag_count'],
'times_per_day' => $auxUsageNorm['times_per_day'],
'usage_dosage_unit' => $rxArr['dosage_unit'] ?? '',
'usage_way' => $rxArr['usage_way'] ?? '',
'usage_time' => $rxArr['usage_time'] ?? '',
],
$rxType,
(string) ($rxArr['usage_way'] ?? ''),
(string) ($rxArr['usage_time'] ?? '')
);
} else {
$item['export_aux_usage'] = '';
}
$item['export_main_usage_days'] = self::resolveExportUsageDays($rxArr, $auxUsageNorm, false);
$item['export_aux_usage_days'] = $auxHerbs !== []
? self::resolveExportUsageDays($rxArr, $auxUsageNorm, true)
: '';
$item['export_service_package'] = self::formatServicePackageForExport(
$item['service_package'] ?? '',
$packageNameByValue
@@ -4162,7 +3807,27 @@ class PrescriptionOrderLogic
$doctorId = (int) ($rx['creator_id'] ?? 0);
[$mainHerbs, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rx);
$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 === []) {
@@ -4881,175 +4546,4 @@ class PrescriptionOrderLogic
{
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_patient', $summary);
}
/**
* 业务订单详情场景:更新主方/辅方服用次数与开立天数,以及订单服用天数
*
* @param array<string, mixed> $params
*/
public static function patchPrescriptionUsage(array $params, int $adminId, array $adminInfo): bool
{
self::$error = '';
$prescriptionOrderId = (int) ($params['id'] ?? 0);
$order = PrescriptionOrder::where('id', $prescriptionOrderId)->whereNull('delete_time')->find();
if (!$order) {
self::setError('订单不存在');
return false;
}
if (!self::canAccessOrder($order, $adminId, $adminInfo)) {
self::setError('无权限操作');
return false;
}
if ((int) $order->fulfillment_status === 4) {
self::setError('已取消的订单不可修改');
return false;
}
$rxId = (int) ($order->prescription_id ?? 0);
if ($rxId <= 0) {
self::setError('该订单未关联处方');
return false;
}
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
if (!$rx) {
self::setError('处方不存在');
return false;
}
if (!PrescriptionLogic::canViewPrescription($rx, $adminId, $adminInfo)) {
self::setError('无权限修改此处方');
return false;
}
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
$usageDays = (int) ($params['usage_days'] ?? 0);
$medDays = (int) ($params['medication_days'] ?? 0);
if ($timesPerDay < 1 || $timesPerDay > 6) {
self::setError('主方每天次数须在 16 之间');
return false;
}
if ($usageDays < 1 || $usageDays > 999) {
self::setError('主方开立天数须在 1999 之间');
return false;
}
if ($medDays < 1 || $medDays > 999) {
self::setError('订单服用天数须在 1999 之间');
return false;
}
$hasAux = self::prescriptionHasAuxFormula($rx);
$auxTimesPerDay = null;
$auxUsageDays = null;
if ($hasAux) {
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
self::setError('含辅方处方须填写辅方服用参数');
return false;
}
$auxTimesPerDay = (int) $params['aux_times_per_day'];
$auxUsageDays = (int) $params['aux_usage_days'];
if ($auxTimesPerDay < 1 || $auxTimesPerDay > 6) {
self::setError('辅方每天次数须在 16 之间');
return false;
}
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
self::setError('辅方开立天数须在 1999 之间');
return false;
}
}
$oldTimes = (int) ($rx->times_per_day ?? 0);
$oldUsageDays = (int) ($rx->usage_days ?? 0);
$oldMedDays = (int) ($order->medication_days ?? 0);
$oldAuxUsage = $rx->aux_usage;
if (is_string($oldAuxUsage)) {
$decoded = json_decode($oldAuxUsage, true);
$oldAuxUsage = is_array($decoded) ? $decoded : [];
}
if (!is_array($oldAuxUsage)) {
$oldAuxUsage = [];
}
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
$oldAuxUsageDays = (int) ($oldAuxUsage['usage_days'] ?? 0);
try {
$rxUpdates = [
'times_per_day' => $timesPerDay,
'usage_days' => $usageDays,
];
if ($hasAux) {
$auxUsage = $oldAuxUsage;
$auxUsage['times_per_day'] = $auxTimesPerDay;
$auxUsage['usage_days'] = $auxUsageDays;
$rxUpdates['aux_usage'] = $auxUsage;
}
$rx->save($rxUpdates);
$order->medication_days = $medDays;
$order->save();
$parts = [
sprintf(
'主方 每天%d次/开立%d天 → 每天%d次/开立%d天',
$oldTimes > 0 ? $oldTimes : 0,
$oldUsageDays > 0 ? $oldUsageDays : 0,
$timesPerDay,
$usageDays
),
];
if ($hasAux) {
$parts[] = sprintf(
'辅方 每天%d次/开立%d天 → 每天%d次/开立%d天',
$oldAuxTimes > 0 ? $oldAuxTimes : 0,
$oldAuxUsageDays > 0 ? $oldAuxUsageDays : 0,
$auxTimesPerDay,
$auxUsageDays
);
}
$parts[] = sprintf(
'订单设置 %d天 → %d天',
$oldMedDays > 0 ? $oldMedDays : 0,
$medDays
);
$summary = '服用参数:' . implode('', $parts);
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary);
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
return false;
}
}
/**
* 处方是否含辅方药材(与列表/详情 has_aux_formula 口径一致)
*/
private static function prescriptionHasAuxFormula(Prescription $rx): bool
{
$herbs = $rx->herbs;
if (is_string($herbs)) {
$decoded = json_decode($herbs, true);
$herbs = is_array($decoded) ? $decoded : [];
}
if (!is_array($herbs)) {
return false;
}
foreach ($herbs as $h) {
if (is_array($h) && (string) ($h['formula_type'] ?? '') === '辅方') {
return true;
}
}
return false;
}
}
@@ -44,7 +44,6 @@ class DiagnosisValidate extends BaseValidate
'end_date' => 'date|checkDateRange',
'diagnosis_id' => 'require|integer|checkDiagnosisId',
'tracking_content' => 'require|length:1,1000',
'revisit_slot_start_offset' => 'integer|between:0,20',
];
protected $message = [
@@ -140,13 +139,6 @@ class DiagnosisValidate extends BaseValidate
return $this->only(['id']);
}
/** 业务订单 tab:设置复诊接诊率统计起始偏移 */
public function sceneSetRevisitSlotStartOffset()
{
return $this->only(['id', 'revisit_slot_start_offset'])
->append('revisit_slot_start_offset', 'require|integer|between:0,20');
}
protected function checkDiagnosis($value)
{
$diagnosis = Diagnosis::findOrEmpty($value);
@@ -32,11 +32,6 @@ class PrescriptionOrderValidate extends BaseValidate
'remark_assistant' => 'max:500',
'action' => 'require|in:approve,reject',
'remark' => 'max:500',
'summary' => 'require|max:500',
'prescription_audit_status' => 'in:0,1,2',
'payment_slip_audit_status' => 'in:0,1,2',
'prescription_audit_remark' => 'max:500',
'payment_slip_audit_remark' => 'max:500',
'fulfillment_status' => 'require|integer|in:3,7,8,9,11,12',
'reason' => 'require|max:500',
'refund_amount' => 'float|egt:0',
@@ -79,7 +74,6 @@ class PrescriptionOrderValidate extends BaseValidate
'withdraw' => ['id'],
'ship' => ['id', 'tracking_number', 'express_company', 'ship_mode'],
'logs' => ['id'],
'addLog' => ['id', 'summary'],
'paidPayOrders' => ['diagnosis_id'],
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
'linkPayOrder' => ['id', 'pay_order_id'],
@@ -89,7 +83,6 @@ class PrescriptionOrderValidate extends BaseValidate
'submitGancaoRecipel' => ['id'],
'previewGancaoRecipel' => ['id'],
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
'updateAmount' => ['id', 'amount'],
'setShipMode' => ['id', 'ship_mode'],
];
@@ -100,15 +93,4 @@ class PrescriptionOrderValidate extends BaseValidate
->append('id', 'require|integer|gt:0')
->append('amount', 'require|float|egt:0');
}
public function patchPrescriptionUsage(): PrescriptionOrderValidate
{
return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'])
->append('id', 'require|integer|gt:0')
->append('times_per_day', 'require|integer|between:1,6')
->append('usage_days', 'require|integer|between:1,999')
->append('medication_days', 'require|integer|between:1,999')
->append('aux_times_per_day', 'integer|between:1,6')
->append('aux_usage_days', 'integer|between:1,999');
}
}
@@ -21,7 +21,6 @@ use app\api\logic\tcm\DailyFamilyLikeLogic;
use app\api\logic\tcm\DailyShareLogic;
use app\api\logic\tcm\DailyPhoneLogic;
use app\api\logic\tcm\DailyDietAiLogic;
use app\api\logic\tcm\GamePlatformLogic;
use app\adminapi\logic\ConfigLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\BloodRecord;
@@ -40,54 +39,6 @@ class TcmController extends BaseApiController
* @var array
*/
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
/** 获取当前登录用户的控糖消消乐每周7人同行榜。 */
public function gameWeeklyLeaderboard()
{
$result = GamePlatformLogic::leaderboard((int) $this->userId);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
return $this->data($result);
}
/** 幂等上报一局的认糖进度和分数。 */
public function gameSubmitProgress()
{
$result = GamePlatformLogic::submitProgress((int) $this->userId, [
'session_key' => (string) $this->request->post('session_key', ''),
'learned_count' => (int) $this->request->post('learned_count', 0),
'score' => (int) $this->request->post('score', 0),
'ended' => (int) $this->request->post('ended', 0),
]);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
return $this->data($result);
}
/** 记录发起分享,返回本周不包含用户ID的分享码。 */
public function gameRecordShare()
{
$result = GamePlatformLogic::recordShare((int) $this->userId);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
return $this->data($result);
}
/** 登录用户从微信分享卡片进入时,记录一次轻量同行关系。 */
public function gameAcceptShare()
{
$result = GamePlatformLogic::acceptShare(
(int) $this->userId,
(string) $this->request->post('invite_code', '')
);
if ($result === false) {
return $this->fail(GamePlatformLogic::getError());
}
return $this->data($result);
}
/**
* @notes 获取患者签名(供小程序调用)
@@ -1,530 +0,0 @@
<?php
namespace app\api\logic\tcm;
use app\common\service\FileService;
use think\facade\Db;
use think\facade\Log;
/**
* 控糖消消乐平台能力:真实用户、每周7人同行榜、幂等成绩上报和微信分享。
*/
class GamePlatformLogic
{
private const GROUP_SIZE = 7;
private const MAX_SESSION_LEARNED = 20000;
private const MAX_SCORE = 100000000;
protected static string $error = '';
public static function getError(): string
{
return self::$error;
}
protected static function fail(string $message): bool
{
self::$error = $message;
return false;
}
/**
* 获取当前用户所在的真实周榜;首次进入会分配到当周同性别7人组。
*/
public static function leaderboard(int $userId): array|false
{
self::$error = '';
if ($userId <= 0) {
return self::fail('请先登录');
}
try {
Db::startTrans();
$score = self::ensureWeeklyScore($userId, self::weekStart());
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
Db::commit();
return self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
} catch (\Throwable $e) {
Db::rollback();
self::logException('leaderboard', $e);
return self::fail('同行榜暂时不可用,请稍后再试');
}
}
/**
* 上报每局绝对进度。session_key + learned_count 共同保证网络重试不会重复计分。
*
* @param array{session_key:string,learned_count:int,score:int,ended:int|bool} $params
*/
public static function submitProgress(int $userId, array $params): array|false
{
self::$error = '';
if ($userId <= 0) {
return self::fail('请先登录');
}
$sessionKey = trim((string) ($params['session_key'] ?? ''));
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
return self::fail('游戏局标识无效');
}
$learned = max(0, min(self::MAX_SESSION_LEARNED, (int) ($params['learned_count'] ?? 0)));
$scoreValue = max(0, min(self::MAX_SCORE, (int) ($params['score'] ?? 0)));
$ended = !empty($params['ended']) ? 1 : 0;
$weekStart = self::weekStart();
try {
Db::startTrans();
$weeklyScore = self::ensureWeeklyScore($userId, $weekStart);
$session = Db::name('tcm_game_session')
->where('session_key', $sessionKey)
->lock(true)
->find();
$now = time();
if ($session && (int) $session['user_id'] !== $userId) {
Db::rollback();
return self::fail('游戏局标识已被使用');
}
if (!$session) {
$sessionId = Db::name('tcm_game_session')->insertGetId([
'session_key' => $sessionKey,
'user_id' => $userId,
'week_start' => $weekStart,
'learned_count' => 0,
'last_score' => 0,
'ended' => 0,
'create_time' => $now,
'update_time' => $now,
]);
$session = [
'id' => $sessionId,
'week_start' => $weekStart,
'learned_count' => 0,
'last_score' => 0,
'ended' => 0,
];
}
$sessionWeek = (string) $session['week_start'];
if ($sessionWeek !== $weekStart) {
$weeklyScore = self::ensureWeeklyScore($userId, $sessionWeek);
}
$confirmedLearned = (int) $session['learned_count'];
$nextLearned = max($confirmedLearned, $learned);
$delta = $nextLearned - $confirmedLearned;
$wasEnded = (int) $session['ended'] === 1;
$markEnded = $wasEnded || $ended === 1;
Db::name('tcm_game_session')->where('id', (int) $session['id'])->update([
'learned_count' => $nextLearned,
'last_score' => max((int) $session['last_score'], $scoreValue),
'ended' => $markEnded ? 1 : 0,
'update_time' => $now,
]);
$weeklyLearned = (int) $weeklyScore['learned_count'] + $delta;
$weeklyBest = max((int) $weeklyScore['best_score'], $scoreValue);
$gamesPlayed = (int) $weeklyScore['games_played'] + (!$wasEnded && $ended === 1 ? 1 : 0);
Db::name('tcm_game_weekly_score')->where('id', (int) $weeklyScore['id'])->update([
'learned_count' => $weeklyLearned,
'best_score' => $weeklyBest,
'games_played' => $gamesPlayed,
'update_time' => $now,
]);
$inviteCode = self::ensureShareInvite($userId, $sessionWeek);
Db::commit();
$result = self::buildLeaderboard((int) $weeklyScore['group_id'], $userId, $inviteCode, $sessionWeek);
$result['confirmed_session_learned'] = $nextLearned;
return $result;
} catch (\Throwable $e) {
Db::rollback();
self::logException('submitProgress', $e);
return self::fail('成绩保存失败,请稍后再试');
}
}
/** 记录用户发起一次微信分享,并返回当前分享码。 */
public static function recordShare(int $userId): array|false
{
self::$error = '';
if ($userId <= 0) {
return self::fail('请先登录');
}
try {
Db::startTrans();
$score = self::ensureWeeklyScore($userId, self::weekStart());
Db::name('tcm_game_weekly_score')->where('id', (int) $score['id'])->inc('share_count')->update([
'update_time' => time(),
]);
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
Db::commit();
return ['invite_code' => $inviteCode];
} catch (\Throwable $e) {
Db::rollback();
self::logException('recordShare', $e);
return self::fail('分享记录失败');
}
}
/**
* 记录从分享卡片进入。仅记录一次轻量同行关系,不做家庭/好友强绑定。
*/
public static function acceptShare(int $userId, string $inviteCode): array|false
{
self::$error = '';
$inviteCode = strtoupper(trim($inviteCode));
if ($userId <= 0) {
return self::fail('请先登录');
}
if (!preg_match('/^[A-F0-9]{12}$/', $inviteCode)) {
return self::fail('分享码无效');
}
try {
$invite = Db::name('tcm_game_share_invite')->where('invite_code', $inviteCode)->find();
if (!$invite) {
return self::fail('分享已失效');
}
$inviterUserId = (int) $invite['user_id'];
if ($inviterUserId === $userId) {
return ['accepted' => false, 'message' => '这是您自己的分享'];
}
Db::startTrans();
$inserted = Db::name('tcm_game_share_visit')->duplicate([
'invite_code',
])->insert([
'invite_code' => $inviteCode,
'inviter_user_id' => $inviterUserId,
'visitor_user_id' => $userId,
'create_time' => time(),
]);
$accepted = $inserted === 1;
if ($accepted) {
Db::name('tcm_game_share_invite')->where('id', (int) $invite['id'])->inc('open_count')->update([
'update_time' => time(),
]);
}
Db::commit();
$inviter = Db::name('user')->where('id', $inviterUserId)->field('nickname')->find();
return [
'accepted' => $accepted,
'message' => '已加入控糖消消乐',
'inviter' => self::displayName((string) ($inviter['nickname'] ?? ''), $inviterUserId),
];
} catch (\Throwable $e) {
Db::rollback();
self::logException('acceptShare', $e);
return self::fail('分享关系记录失败');
}
}
private static function ensureWeeklyScore(int $userId, string $weekStart): array
{
$profile = self::userProfile($userId);
$now = time();
$existing = Db::name('tcm_game_weekly_score')
->where('week_start', $weekStart)
->where('user_id', $userId)
->find();
if ($existing) {
$existing = Db::name('tcm_game_weekly_score')
->where('id', (int) $existing['id'])
->lock(true)
->find();
$profileChanged = (string) $existing['nickname'] !== $profile['nickname']
|| (string) $existing['avatar'] !== $profile['avatar'];
if ($profileChanged) {
Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([
'nickname' => $profile['nickname'],
'avatar' => $profile['avatar'],
'update_time'=> $now,
]);
$existing['nickname'] = $profile['nickname'];
$existing['avatar'] = $profile['avatar'];
}
return $existing;
}
// 每周、每个性别使用一行分配锁串行化首次入组。直接 upsert 锁行,
// 避免空分组上的间隙锁导致首批并发请求互相等待或偶发死锁。
Db::name('tcm_game_weekly_allocator')->duplicate([
'update_time',
])->insert([
'week_start' => $weekStart,
'sex' => $profile['sex'],
'create_time'=> $now,
'update_time'=> $now,
]);
$allocator = Db::name('tcm_game_weekly_allocator')
->where('week_start', $weekStart)
->where('sex', $profile['sex'])
->lock(true)
->find();
if (!$allocator) {
throw new \RuntimeException('同行分配锁创建失败');
}
// 等待分配锁期间,同一用户的另一个请求可能已经完成分配。
$existing = Db::name('tcm_game_weekly_score')
->where('week_start', $weekStart)
->where('user_id', $userId)
->lock(true)
->find();
if ($existing) {
return $existing;
}
$group = Db::name('tcm_game_weekly_group')
->where('week_start', $weekStart)
->where('sex', $profile['sex'])
->where('member_count', '<', self::GROUP_SIZE)
->order('group_no', 'asc')
->lock(true)
->find();
if (!$group) {
$maxGroupNo = (int) Db::name('tcm_game_weekly_group')
->where('week_start', $weekStart)
->where('sex', $profile['sex'])
->max('group_no');
$groupNo = $maxGroupNo + 1;
// 首批用户并发进入时可能同时算出相同 group_no。利用唯一键 upsert
// 让请求汇合到同一组,再锁定该组继续分配,避免偶发 1062/死锁。
Db::name('tcm_game_weekly_group')->duplicate([
'update_time',
])->insert([
'week_start' => $weekStart,
'sex' => $profile['sex'],
'group_no' => $groupNo,
'member_count'=> 0,
'create_time' => $now,
'update_time' => $now,
]);
$group = Db::name('tcm_game_weekly_group')
->where('week_start', $weekStart)
->where('sex', $profile['sex'])
->where('group_no', $groupNo)
->lock(true)
->find();
if (!$group) {
throw new \RuntimeException('同行分组创建失败');
}
}
$scoreRow = [
'group_id' => (int) $group['id'],
'week_start' => $weekStart,
'user_id' => $userId,
'learned_count' => 0,
'best_score' => 0,
'games_played' => 0,
'share_count' => 0,
'nickname' => $profile['nickname'],
'avatar' => $profile['avatar'],
'sex' => $profile['sex'],
'create_time' => $now,
'update_time' => $now,
];
$inserted = Db::name('tcm_game_weekly_score')->duplicate([
'nickname',
'avatar',
'sex',
'update_time',
])->insert($scoreRow);
$score = Db::name('tcm_game_weekly_score')
->where('week_start', $weekStart)
->where('user_id', $userId)
->lock(true)
->find();
if (!$score) {
throw new \RuntimeException('同行成绩创建失败');
}
// 仅新插入时刷新人数;使用实际成绩行数纠正历史并发造成的计数漂移。
if ($inserted === 1) {
$memberCount = (int) Db::name('tcm_game_weekly_score')
->where('group_id', (int) $group['id'])
->count();
Db::name('tcm_game_weekly_group')->where('id', (int) $group['id'])->update([
'member_count' => min(self::GROUP_SIZE, $memberCount),
'update_time' => $now,
]);
}
return $score;
}
private static function buildLeaderboard(
int $groupId,
int $userId,
string $inviteCode,
?string $weekStart = null
): array {
$weekStart = $weekStart ?: self::weekStart();
$rows = Db::name('tcm_game_weekly_score')
->where('group_id', $groupId)
->where('week_start', $weekStart)
->order('learned_count', 'desc')
->order('best_score', 'desc')
->order('create_time', 'asc')
->limit(self::GROUP_SIZE)
->select()
->toArray();
$players = [];
$myIndex = 0;
foreach ($rows as $index => $row) {
$isMe = (int) $row['user_id'] === $userId;
if ($isMe) {
$myIndex = $index;
}
$players[] = [
// 前端只需要稳定列表键,不暴露平台内部 user_id。
'id' => (int) $row['id'],
'name' => self::displayName((string) $row['nickname'], (int) $row['user_id']),
'avatar' => self::avatarUrl((string) $row['avatar']),
'count' => (int) $row['learned_count'],
'best_score' => (int) $row['best_score'],
'rank' => $index + 1,
'is_me' => $isMe,
];
}
$me = $players[$myIndex] ?? [
'count' => 0,
'rank' => 1,
'best_score' => 0,
];
$distance = $myIndex > 0
? max(1, (int) $players[$myIndex - 1]['count'] - (int) $me['count'] + 1)
: 0;
$group = Db::name('tcm_game_weekly_group')->where('id', $groupId)->find();
$sex = (int) ($group['sex'] ?? 0);
return [
'week_start' => $weekStart,
'week_end' => date('Y-m-d', strtotime($weekStart . ' +6 days')),
'sex' => $sex,
'sex_label' => $sex === 1 ? '男士同行' : ($sex === 2 ? '女士同行' : '同行'),
'group_size' => self::GROUP_SIZE,
'member_count'=> count($players),
'players' => $players,
'me' => [
'count' => (int) ($me['count'] ?? 0),
'rank' => (int) ($me['rank'] ?? 1),
'best_score' => (int) ($me['best_score'] ?? 0),
'distance' => $distance,
'is_first' => $myIndex === 0,
],
'invite_code' => $inviteCode,
];
}
/** @return array{nickname:string,avatar:string,sex:int} */
private static function userProfile(int $userId): array
{
$user = Db::name('user')
->where('id', $userId)
->whereNull('delete_time')
->field('id,sn,nickname,avatar,sex')
->find();
if (!$user) {
throw new \RuntimeException('用户不存在');
}
$sex = (int) ($user['sex'] ?? 0);
if ($sex !== 1 && $sex !== 2) {
$gender = Db::name('diagnosis_view_records')->alias('v')
->join('tcm_diagnosis d', 'd.id = v.diagnosis_id')
->where('v.user_id', $userId)
->whereNull('v.delete_time')
->whereNull('d.delete_time')
->order('v.id', 'desc')
->value('d.gender');
if ($gender !== null && $gender !== '') {
$sex = (int) $gender === 1 ? 1 : 2;
}
}
return [
'nickname' => self::displayName((string) ($user['nickname'] ?? ''), $userId),
'avatar' => (string) ($user['avatar'] ?? ''),
'sex' => in_array($sex, [1, 2], true) ? $sex : 0,
];
}
private static function displayName(string $nickname, int $userId): string
{
$nickname = trim(strip_tags($nickname));
if ($nickname === '') {
return '控糖好友' . substr((string) $userId, -2);
}
return mb_substr($nickname, 0, 12);
}
private static function avatarUrl(string $avatar): string
{
return $avatar === '' ? '' : FileService::getFileUrl($avatar);
}
private static function ensureShareInvite(int $userId, string $weekStart): string
{
$existing = Db::name('tcm_game_share_invite')
->where('week_start', $weekStart)
->where('user_id', $userId)
->find();
if ($existing) {
return (string) $existing['invite_code'];
}
for ($attempt = 0; $attempt < 5; $attempt++) {
$code = strtoupper(bin2hex(random_bytes(6)));
$now = time();
// 同一用户并发打开榜单时,以 week_start + user_id 唯一键汇合;
// 极小概率随机码撞车时,查询不到本人的记录就继续生成新码。
Db::name('tcm_game_share_invite')->duplicate([
'update_time',
])->insert([
'invite_code' => $code,
'user_id' => $userId,
'week_start' => $weekStart,
'open_count' => 0,
'create_time' => $now,
'update_time' => $now,
]);
$invite = Db::name('tcm_game_share_invite')
->where('week_start', $weekStart)
->where('user_id', $userId)
->find();
if ($invite) {
return (string) $invite['invite_code'];
}
}
throw new \RuntimeException('分享码生成失败');
}
private static function weekStart(?int $timestamp = null): string
{
$timestamp = $timestamp ?: time();
$day = (int) date('N', $timestamp);
return date('Y-m-d', strtotime('-' . ($day - 1) . ' days', $timestamp));
}
private static function logException(string $action, \Throwable $e): void
{
Log::error(sprintf(
'tcm endless game %s failed: %s at %s:%d',
$action,
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
}
}
@@ -1,571 +0,0 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\RevisitRateLogic;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Db;
use think\facade\Log;
/**
* 待分配诊单自动指派(每天定时执行)
*
* 使用方法:
* php think tcm:auto-assign-pending # 正式执行
* php think tcm:auto-assign-pending --dry-run # 只演练输出分配计划,不写库
*
* 推荐 cron(每天 09:00 执行一次;或经 zyt_dev_crontab 表调度):
* 0 9 * * * cd /path/to/server && php think tcm:auto-assign-pending >> /var/log/zyt-auto-assign.log 2>&1
*
* 分配规则(依据上个自然月「二诊复诊接诊率」,与 RevisitRateLogic::overview 口径完全一致):
* - 接诊率 > 70% :第一优先档,当日每人最多 3
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
*
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
* 例:当天 20 条,>70% 3 人、60%~70% 7 人、50%~60% 5
* 第一轮 3+7+5=15 条;剩余 5 条进第二轮:>70% 再各 1 条(3 条),余 2 条给 60%~70% 档前 2 人。
*
* 日上限跨执行累计:同一天重复执行命令时,会先从 tcm_diagnosis_auto_assign_log 扣减当日已自动分配数,不会超额。
*
* 日志:无论分配与否,每条待指派诊单都会写入 tcm_diagnosis_auto_assign_log,记录原因(为什么分配 / 为什么不分配)。
* 成功分配同时写 tcm_diagnosis_assign_logis_inherit=0,计入次月接诊率分母),与手动指派同口径。
*/
class AutoAssignPendingDiagnosis extends Command
{
/** 档位标识 */
private const TIER_GT70 = 'gt70';
private const TIER_60_70 = '60_70';
private const TIER_50_60 = '50_60';
/** 各档位当日每人分配上限(按优先级排列,先高档后低档) */
private const TIER_DAILY_CAPS = [
self::TIER_GT70 => 3,
self::TIER_60_70 => 2,
self::TIER_50_60 => 1,
];
private const TIER_LABELS = [
self::TIER_GT70 => '>70%',
self::TIER_60_70 => '60%~70%',
self::TIER_50_60 => '50%~60%',
];
protected function configure()
{
$this->setName('tcm:auto-assign-pending')
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
}
protected function execute(Input $input, Output $output): int
{
$now = time();
$dryRun = (bool) $input->getOption('dry-run');
$runDate = date('Y-m-d', $now);
$statMonth = date('Y-m', strtotime(date('Y-m-01', $now) . ' -1 month'));
$batchNo = date('YmdHis', $now) . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
$output->writeln(sprintf(
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
date('Y-m-d H:i:s', $now),
$batchNo,
$statMonth,
$dryRun ? '(演练模式,不写库)'
: ''
));
try {
// 1. 上月二诊复诊接诊率 → 医助分档
$tiers = $this->buildAssistantTiers($statMonth);
$tierTotal = array_sum(array_map('count', $tiers));
$output->writeln(sprintf(
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
\count($tiers[self::TIER_GT70]),
\count($tiers[self::TIER_60_70]),
\count($tiers[self::TIER_50_60])
));
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
$remaining = $this->buildRemainingQuota($tiers, $runDate);
// 3. 待指派池:与「待分配医助」Tab 同口径(assistant_id 空/0 + 当月有业务订单),先到先分
[$pool, $ineligible] = $this->fetchPendingPool($now);
$output->writeln(sprintf('待指派池:符合条件 %d 条,不符合条件 %d 条', \count($pool), \count($ineligible)));
$logRows = [];
// 不符合条件的待指派诊单:不分配,逐条记录原因
foreach ($ineligible as $item) {
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diag'], [
'action' => 0,
'reason' => $item['reason'],
], $now);
}
if ($pool === []) {
$this->flushLogs($logRows, $dryRun, $output);
$output->writeln('待指派池为空,本次无需分配。');
return 0;
}
if ($tierTotal === 0) {
$reason = sprintf('未分配:上月(%s)无二诊复诊接诊率≥50%%的医助,本批次不执行分配', $statMonth);
foreach ($pool as $diag) {
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
'action' => 0,
'reason' => $reason,
], $now);
}
$this->flushLogs($logRows, $dryRun, $output);
$output->writeln($reason);
return 0;
}
// 4. 轮询排分配计划
[$plan, $leftover] = $this->buildAssignPlan($pool, $tiers, $remaining);
// 5. 执行计划(逐条事务 + 行锁复核,落库诊单/指派日志/自动指派日志)
$assigned = 0;
$skipped = 0;
foreach ($plan as $item) {
if ($dryRun) {
$assigned++;
$output->writeln(sprintf(
'[演练] 诊单#%d(%s) → %s%s,第%d轮,当日第%d/%d条)',
$item['diagnosis']['id'],
(string) $item['diagnosis']['patient_name'],
$item['assistant']['name'],
self::TIER_LABELS[$item['tier']],
$item['round'],
$item['day_seq'],
self::TIER_DAILY_CAPS[$item['tier']]
));
continue;
}
$ok = $this->applyAssignment($item, $now);
if ($ok) {
$assigned++;
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
'action' => 1,
'assistant_id' => $item['assistant']['id'],
'assistant_name' => $item['assistant']['name'],
'tier' => $item['tier'],
'visit2_rate' => $item['assistant']['rate'],
'round_no' => $item['round'],
'reason' => sprintf(
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
$item['assistant']['name'],
$item['assistant']['id'],
$statMonth,
$item['assistant']['rate'],
self::TIER_LABELS[$item['tier']],
self::TIER_DAILY_CAPS[$item['tier']],
$item['round'],
$item['day_seq']
),
], $now);
} else {
$skipped++;
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
'action' => 0,
'reason' => '未分配:执行时诊单已被指派给其他医助(并发/人工抢先),本次跳过',
], $now);
}
}
// 6. 额度用尽后剩余的待指派诊单:不分配,记录原因
foreach ($leftover as $diag) {
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
'action' => 0,
'reason' => '未分配:各档位医助当日剩余额度已用尽(>70%每人3条、60%~70%每人2条、50%~60%每人1条),顺延至下次执行',
], $now);
}
$this->flushLogs($logRows, $dryRun, $output);
$output->writeln(sprintf(
'执行完成。计划分配: %d, 实际分配: %d, 并发跳过: %d, 额度不足未分: %d, 不符合条件: %d',
\count($plan),
$assigned,
$skipped,
\count($leftover),
\count($ineligible)
));
return 0;
} catch (\Throwable $e) {
Log::error('待分配诊单自动指派异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$output->error('执行异常: ' . $e->getMessage());
return 1;
}
}
/**
* 按上月二诊复诊接诊率把医助分档(复用 RevisitRateLogic 口径),并剔除已禁用/已删除账号。
*
* @return array{gt70: list<array{id:int,name:string,rate:float}>, 60_70: list<array{id:int,name:string,rate:float}>, 50_60: list<array{id:int,name:string,rate:float}>}
*/
private function buildAssistantTiers(string $statMonth): array
{
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
/** @var list<array{id:int,name:string,rate:float}> $candidates */
$candidates = [];
foreach ($overview['rows'] ?? [] as $deptRow) {
foreach ($deptRow['children'] ?? [] as $row) {
$aid = (int) ($row['assistant_id'] ?? 0);
$rate = $row['visit2_rate'] ?? null;
// 上月无被指派数据(rate=null)或接诊率低于 50% 的医助不参与分配
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
continue;
}
$candidates[] = [
'id' => $aid,
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
'rate' => (float) $rate,
];
}
}
if ($candidates === []) {
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
}
// 只分配给当前在职可用的医助账号(role_id=2 且未禁用未删除),
// 且当前部门须在「二中心」子树内(统计月在二中心、后来调离的不再参与)
$erDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erDeptSet === []) {
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
}
$activeIds = Db::name('admin')
->alias('a')
->join('admin_role ar', 'a.id = ar.admin_id')
->join('admin_dept ad', 'a.id = ad.admin_id')
->where('ar.role_id', 2)
->where('a.disable', 0)
->whereNull('a.delete_time')
->whereIn('ad.dept_id', array_keys($erDeptSet))
->whereIn('a.id', array_column($candidates, 'id'))
->group('a.id')
->column('a.id');
$activeSet = array_fill_keys(array_map('intval', $activeIds), true);
$tiers = [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
foreach ($candidates as $c) {
if (!isset($activeSet[$c['id']])) {
continue;
}
if ($c['rate'] > 70.0) {
$tiers[self::TIER_GT70][] = $c;
} elseif ($c['rate'] > 60.0) {
$tiers[self::TIER_60_70][] = $c;
} else { // 50 <= rate <= 60
$tiers[self::TIER_50_60][] = $c;
}
}
// 档内按接诊率降序、id 升序,保证分配顺序确定可复现
foreach ($tiers as &$list) {
usort($list, static function (array $a, array $b): int {
if ($a['rate'] !== $b['rate']) {
return $b['rate'] <=> $a['rate'];
}
return $a['id'] <=> $b['id'];
});
}
unset($list);
return $tiers;
}
/**
* 各医助当日剩余额度 = 档位日上限 - 当日已自动分配条数(同日重复执行不超额)。
*
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
*
* @return array<int, int> assistant_id => 剩余额度
*/
private function buildRemainingQuota(array $tiers, string $runDate): array
{
$usedToday = Db::name('tcm_diagnosis_auto_assign_log')
->where('run_date', $runDate)
->where('action', 1)
->where('assistant_id', '>', 0)
->group('assistant_id')
->column('COUNT(*)', 'assistant_id');
$remaining = [];
foreach (self::TIER_DAILY_CAPS as $tier => $cap) {
foreach ($tiers[$tier] as $assistant) {
$used = (int) ($usedToday[$assistant['id']] ?? 0);
$remaining[$assistant['id']] = max(0, $cap - $used);
}
}
return $remaining;
}
/**
* 待指派池:与后台「待分配医助」Tab 同口径 —— assistant_id 为空/0、未删除,
* 且当月内存在业务订单(order.patient_id = 诊单 idTab 默认按当月过滤)。先到先分。
* 返回 [符合条件, 不符合条件(附原因)] 两组;不符合条件的诊单不分配,只记日志。
*
* @return array{0: list<array<string,mixed>>, 1: list<array{diag:array<string,mixed>,reason:string}>}
*/
private function fetchPendingPool(int $now): array
{
$rows = Db::name('tcm_diagnosis')
->whereRaw('(assistant_id IS NULL OR assistant_id = 0)')
->whereNull('delete_time')
->field(['id', 'patient_name', 'phone', 'status', 'create_time'])
->order(['create_time' => 'asc', 'id' => 'asc'])
->select()
->toArray();
if ($rows === []) {
return [[], []];
}
// 当月存在业务订单的诊单集合(order.create_time 兼容整型时间戳与 datetime 字符串,与 DiagnosisLists 一致)
$tStart = (int) strtotime(date('Y-m-01 00:00:00', $now));
$tEnd = (int) strtotime(date('Y-m-t 23:59:59', $now));
$dsStart = date('Y-m-d H:i:s', $tStart);
$dsEnd = date('Y-m-d H:i:s', $tEnd);
$hasOrderSet = [];
foreach (array_chunk(array_column($rows, 'id'), 2000) as $chunk) {
$ids = Db::name('order')
->whereIn('patient_id', $chunk)
->whereNull('delete_time')
->where(static function ($q) use ($tStart, $tEnd, $dsStart, $dsEnd) {
$q->whereBetween('create_time', [$tStart, $tEnd])
->whereOr(static function ($q2) use ($dsStart, $dsEnd) {
$q2->where('create_time', '>=', $dsStart)->where('create_time', '<=', $dsEnd);
});
})
->group('patient_id')
->column('patient_id');
foreach ($ids as $id) {
$hasOrderSet[(int) $id] = true;
}
}
$curMonth = date('Y-m', $now);
$eligible = [];
$ineligible = [];
foreach ($rows as $r) {
$did = (int) ($r['id'] ?? 0);
if (!isset($hasOrderSet[$did])) {
$ineligible[] = [
'diag' => $r,
'reason' => sprintf('未分配:诊单当月(%s)无业务订单,不在「待分配医助」列表范围内,不满足自动分配条件', $curMonth),
];
continue;
}
if ((int) ($r['status'] ?? 0) !== 1) {
$ineligible[] = [
'diag' => $r,
'reason' => '未分配:诊单未启用(status≠1),不满足自动分配条件',
];
continue;
}
$eligible[] = $r;
}
return [$eligible, $ineligible];
}
/**
* 轮询排分配计划:每轮内先 >70% 档每人 1 条,再 60%~70%,再 50%~60%;受各自当日剩余额度约束。
*
* @param list<array<string,mixed>> $pool 待指派诊单(先到先分)
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
* @param array<int, int> $remaining assistant_id => 剩余额度(会被消耗)
*
* @return array{
* 0: list<array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float},tier:string,round:int,day_seq:int}>,
* 1: list<array<string,mixed>>
* } [分配计划, 额度用尽后剩余诊单]
*/
private function buildAssignPlan(array $pool, array $tiers, array $remaining): array
{
$plan = [];
$poolIdx = 0;
$poolCount = \count($pool);
/** @var array<int, int> $daySeq 医助当日已排序号(含历史已用额度) */
$daySeq = [];
foreach ($remaining as $aid => $left) {
// 起始序号 = 日上限 - 剩余额度(同日多次执行时序号衔接)
$cap = 0;
foreach (self::TIER_DAILY_CAPS as $tier => $tierCap) {
foreach ($tiers[$tier] as $assistant) {
if ($assistant['id'] === $aid) {
$cap = $tierCap;
break 2;
}
}
}
$daySeq[$aid] = $cap - $left;
}
$round = 0;
while ($poolIdx < $poolCount) {
$round++;
$assignedThisRound = 0;
foreach (self::TIER_DAILY_CAPS as $tier => $_cap) {
foreach ($tiers[$tier] as $assistant) {
if ($poolIdx >= $poolCount) {
break 2;
}
$aid = $assistant['id'];
if (($remaining[$aid] ?? 0) <= 0) {
continue;
}
$remaining[$aid]--;
$daySeq[$aid]++;
$plan[] = [
'diagnosis' => $pool[$poolIdx],
'assistant' => $assistant,
'tier' => $tier,
'round' => $round,
'day_seq' => $daySeq[$aid],
];
$poolIdx++;
$assignedThisRound++;
}
}
if ($assignedThisRound === 0) {
// 所有医助额度用尽,剩余诊单不再分配
break;
}
}
return [$plan, \array_slice($pool, $poolIdx)];
}
/**
* 落库单条分配:事务 + 行锁复核诊单仍未指派,更新诊单并写指派日志(与手动指派 DiagnosisLogic::assign 同口径)。
*
* @param array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float}} $item
*/
private function applyAssignment(array $item, int $now): bool
{
$diagnosisId = (int) $item['diagnosis']['id'];
$toAssistantId = (int) $item['assistant']['id'];
Db::startTrans();
try {
$diagLock = Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->lock(true)
->field(['id', 'assistant_id'])
->find();
if ($diagLock === null || $diagLock === [] || (int) ($diagLock['assistant_id'] ?? 0) > 0) {
Db::rollback();
return false;
}
Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->update([
'assistant_id' => $toAssistantId,
'assign_read_at' => null,
]);
$poSnap = Db::name('tcm_prescription_order')
->where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order(['create_time' => 'desc', 'id' => 'desc'])
->field(['creator_id', 'create_time'])
->find();
$relatedPoCreatorId = (int) ($poSnap['creator_id'] ?? 0);
$relatedPoCreateTime = (int) ($poSnap['create_time'] ?? 0);
if ($relatedPoCreateTime <= 0) {
$relatedPoCreateTime = $now;
$relatedPoCreatorId = 0;
}
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => 0,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => 0,
'operator_name' => '系统自动分配',
'operator_account' => 'system',
'ip' => '',
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'is_inherit' => 0,
'create_time' => $now,
]);
Db::commit();
return true;
} catch (\Throwable $e) {
Db::rollback();
Log::error(sprintf('自动指派落库失败 diagnosis_id=%d assistant_id=%d msg=%s', $diagnosisId, $toAssistantId, $e->getMessage()));
return false;
}
}
/**
* @param array<string,mixed> $diag 诊单行(含 id/patient_name/phone
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/round_no/reason
*
* @return array<string,mixed>
*/
private function buildLogRow(string $batchNo, string $runDate, string $statMonth, array $diag, array $extra, int $now): array
{
return [
'batch_no' => $batchNo,
'run_date' => $runDate,
'stat_month' => $statMonth,
'diagnosis_id' => (int) ($diag['id'] ?? 0),
'patient_name' => mb_substr(trim((string) ($diag['patient_name'] ?? '')), 0, 64),
'patient_phone' => mb_substr(trim((string) ($diag['phone'] ?? '')), 0, 32),
'action' => (int) ($extra['action'] ?? 0),
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
'tier' => (string) ($extra['tier'] ?? ''),
'visit2_rate' => $extra['visit2_rate'] ?? null,
'round_no' => (int) ($extra['round_no'] ?? 0),
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
'create_time' => $now,
];
}
/**
* @param list<array<string,mixed>> $logRows
*/
private function flushLogs(array $logRows, bool $dryRun, Output $output): void
{
if ($logRows === []) {
return;
}
if ($dryRun) {
$output->writeln(sprintf('[演练] 应写入自动指派日志 %d 条(未落库)', \count($logRows)));
return;
}
foreach (array_chunk($logRows, 500) as $chunk) {
Db::name('tcm_diagnosis_auto_assign_log')->insertAll($chunk);
}
$output->writeln(sprintf('已写入自动指派日志 %d 条', \count($logRows)));
}
}
-2
View File
@@ -40,7 +40,5 @@ return [
'migrate:images-to-doctor-note' => 'app\\command\\MigrateImagesToDoctorNote',
// 诊单待办事项:扫描到点的待执行项并向创建人发送企业微信消息
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
// 待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配(>70% 3条/60~70% 2条/50~60% 1条),写自动指派日志
'tcm:auto-assign-pending' => 'app\\command\\AutoAssignPendingDiagnosis',
],
];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

@@ -1 +1 @@
import r from"./error-B96pOmhv.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-CzSP4TPL.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
import r from"./error-BT3t0AXw.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-CXrrycLn.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-BAghejlR.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-CO3hhC4Q.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
@@ -1 +1 @@
import o from"./error-B96pOmhv.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-Bolc0EfP.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-B0jSCQ-G.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-CzSP4TPL.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
import o from"./error-BT3t0AXw.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-CXrrycLn.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-BAghejlR.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-CO3hhC4Q.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};

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