Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b1ce61e72 | ||
|
|
33f8f669ad | ||
|
|
4f066b560e | ||
|
|
13f58a5fdc | ||
|
|
36be8fedad | ||
|
|
19af50c344 | ||
|
|
8b0fcd7050 | ||
|
|
57e4892140 | ||
|
|
95fff1262b | ||
|
|
0dd9cdcba9 | ||
|
|
5b233fb0ab | ||
|
|
3325be0622 | ||
|
|
9af5c5be63 | ||
|
|
0e46bf8e0d | ||
|
|
b93313d935 | ||
|
|
c1dfb01f53 | ||
|
|
3f999b312d | ||
|
|
50abe5dece | ||
|
|
58c224a081 | ||
|
|
f718df833e | ||
|
|
f068acf390 | ||
|
|
769d912eef | ||
|
|
5aa0cda252 | ||
|
|
552da3aec2 | ||
|
|
ed5285b58a | ||
|
|
0d35d1b6c3 | ||
|
|
27c2ada4ec | ||
|
|
6be5b4c45d | ||
|
|
fbf3d47b2a | ||
|
|
82c4d7e0a2 | ||
|
|
867a6a97cf | ||
|
|
a3ee72b34d | ||
|
|
a08031b901 | ||
|
|
665481a6d8 | ||
|
|
30b344b776 | ||
|
|
e104249707 | ||
|
|
9b6d9ce007 | ||
|
|
69da8066a6 | ||
|
|
edc9993403 | ||
|
|
8f14f3190e | ||
|
|
71a3c63182 | ||
|
|
d10678763f | ||
|
|
2beace89f4 | ||
|
|
e5d0d7db93 | ||
|
|
35a42078ce | ||
|
|
e8e28d19fb | ||
|
|
163e40c73f | ||
|
|
d46cfff079 | ||
|
|
275a7a550d | ||
|
|
5430fe0417 | ||
|
|
f314e2e271 | ||
|
|
a4fb55d164 | ||
|
|
295c8f0623 | ||
|
|
4b749a1ee0 | ||
|
|
679e61057b | ||
|
|
7f7b8f0a64 | ||
|
|
7e94c5dfd7 | ||
|
|
003810114b | ||
|
|
37c5849103 | ||
|
|
55521b8e11 | ||
|
|
e4f181e4fe | ||
|
|
bba989c0af | ||
|
|
d5e0ba04b7 | ||
|
|
22ec53c070 | ||
|
|
183b6a1acf | ||
|
|
49c8c95966 | ||
|
|
c7c264497e | ||
|
|
436f69c272 | ||
|
|
dc72a8ccaf | ||
|
|
499c969233 | ||
|
|
3b44300216 | ||
|
|
8bb7b5d30a | ||
|
|
4c3becf33e | ||
|
|
a42fc6453a | ||
|
|
dad06304f1 | ||
|
|
03b80310f6 | ||
|
|
8d015fa962 | ||
|
|
f639ef2fbb | ||
|
|
ea8e6d7c1b | ||
|
|
ba1c067dce | ||
|
|
5e96fda88f | ||
|
|
076cd0fcf9 | ||
|
|
44e97861d2 | ||
|
|
93e6d02ce4 | ||
|
|
60cb2b80cd | ||
|
|
752eddb0ba | ||
|
|
61d14d1b45 | ||
|
|
a83c583a28 | ||
|
|
485ba7a380 | ||
|
|
1005859404 | ||
|
|
ea6c73c063 | ||
|
|
3e4039efb0 | ||
|
|
c9ad4ae2ed | ||
|
|
abf8026d45 | ||
|
|
26b442b5da | ||
|
|
1cb00e9fbe | ||
|
|
9208e7eaaa | ||
|
|
a1392f9491 | ||
|
|
2ea0e1d54b | ||
|
|
d96fa6f4a1 |
@@ -33,3 +33,4 @@ bin-release/
|
||||
/server/.spool
|
||||
/server/.claude
|
||||
/.spool
|
||||
TUICallKit-Vue3/.env
|
||||
|
||||
@@ -15,6 +15,19 @@ const globalData = {
|
||||
let avatarUrl = ref(""); // 声明为响应式变量
|
||||
uni.CallManager = new CallManager();
|
||||
onLaunch(() => {
|
||||
// iOS 静音键模式下 InnerAudioContext 默认不发声,
|
||||
// 必须在 onLaunch 全局调用此 API,单实例 obeyMuteSwitch 在 iOS 不可靠
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true,
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('setInnerAudioOption failed:', e)
|
||||
}
|
||||
// #endif
|
||||
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: function (loginRes) {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<view v-if="visible" class="dev-entry-wrap" :style="{ bottom: props.bottom + 'rpx' }">
|
||||
<!-- 展开后的菜单卡片(从下往上动画) -->
|
||||
<view v-if="expanded" class="menu-list" @click.stop>
|
||||
<!-- "练一练" 暂时隐藏,后续恢复时取消注释即可
|
||||
<view class="menu-item" @click="goto('/training/pages/index')">
|
||||
<view class="menu-icon menu-icon--train">
|
||||
<text class="menu-icon-text">💪</text>
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">练一练</text>
|
||||
<text class="menu-sub">器械跟练</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
-->
|
||||
<view class="menu-item" @click="goto('/training/pages/metronome')">
|
||||
<view class="menu-icon menu-icon--metro">
|
||||
<view class="metro-bar metro-bar-1" />
|
||||
<view class="metro-bar metro-bar-2" />
|
||||
<view class="metro-bar metro-bar-3" />
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">节拍器</text>
|
||||
<text class="menu-sub">健走配速</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主浮动按钮(单字"练" + 呼吸光环) -->
|
||||
<view class="fab-container">
|
||||
<view v-if="!expanded" class="fab-pulse" />
|
||||
<view v-if="!expanded" class="fab-pulse fab-pulse-2" />
|
||||
<view class="entry-fab" :class="{ expanded }" @click="toggle">
|
||||
<view v-if="expanded" class="icon-close">
|
||||
<view class="cross-bar bar-1" />
|
||||
<view class="cross-bar bar-2" />
|
||||
</view>
|
||||
<!-- 心跳波形 icon: 健康主题,SVG 折线 -->
|
||||
<view v-else class="fab-icon" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
bottom?: number
|
||||
}>(),
|
||||
{ bottom: 200 },
|
||||
)
|
||||
|
||||
const isDevMode = (): boolean => {
|
||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||
try {
|
||||
return process.env.NODE_ENV !== 'production'
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const visible = ref(isDevMode())
|
||||
const expanded = ref(false)
|
||||
|
||||
const toggle = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
const goto = (url: string) => {
|
||||
expanded.value = false
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$brand: #10b981;
|
||||
$brand-deep: #047857;
|
||||
$brand-light: #34d399;
|
||||
|
||||
.dev-entry-wrap {
|
||||
position: fixed;
|
||||
right: 24rpx;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主浮动按钮
|
||||
* ============================================================ */
|
||||
.fab-container {
|
||||
position: relative;
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
}
|
||||
|
||||
.entry-fab {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(140deg, $brand-light 0%, $brand-deep 100%);
|
||||
box-shadow:
|
||||
0 8rpx 20rpx rgba(16, 185, 129, 0.42),
|
||||
0 2rpx 6rpx rgba(15, 23, 42, 0.12),
|
||||
inset 0 -8rpx 16rpx rgba(0, 0, 0, 0.12),
|
||||
inset 0 8rpx 16rpx rgba(255, 255, 255, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
background: linear-gradient(140deg, #94a3b8 0%, #475569 100%);
|
||||
transform: rotate(135deg);
|
||||
box-shadow:
|
||||
0 6rpx 16rpx rgba(15, 23, 42, 0.2),
|
||||
inset 0 -6rpx 14rpx rgba(0, 0, 0, 0.12),
|
||||
inset 0 6rpx 14rpx rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='2,12 7,12 9.5,7 12,17 14.5,9 16.5,12 22,12'/></svg>");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.22));
|
||||
animation: fab-icon-beat 1.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@keyframes fab-icon-beat {
|
||||
0%, 60%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 关闭态 X 图标 (CSS 几何) */
|
||||
.icon-close {
|
||||
position: relative;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
|
||||
.cross-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 5rpx;
|
||||
background: #fff;
|
||||
border-radius: 3rpx;
|
||||
transform-origin: center;
|
||||
}
|
||||
.bar-1 {
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
}
|
||||
.bar-2 {
|
||||
transform: translateY(-50%) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 呼吸光环(2 圈错相位扩散) */
|
||||
.fab-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(16, 185, 129, 0.25);
|
||||
animation: fab-ring 2.4s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fab-pulse-2 {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
@keyframes fab-ring {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.55);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 展开菜单
|
||||
* ============================================================ */
|
||||
.menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14rpx;
|
||||
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20rpx) scale(0.92);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 14rpx 20rpx 14rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
box-shadow:
|
||||
0 8rpx 24rpx rgba(15, 23, 42, 0.1),
|
||||
0 1rpx 2rpx rgba(15, 23, 42, 0.06);
|
||||
min-width: 280rpx;
|
||||
transition: transform 0.15s;
|
||||
backdrop-filter: blur(12rpx);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2rpx 8rpx rgba(16, 185, 129, 0.32);
|
||||
|
||||
&--train {
|
||||
background: linear-gradient(140deg, $brand-light, $brand-deep);
|
||||
}
|
||||
&--metro {
|
||||
background: linear-gradient(140deg, #fbbf24, #d97706);
|
||||
box-shadow: 0 2rpx 8rpx rgba(245, 158, 11, 0.36);
|
||||
gap: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon-text {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
|
||||
.metro-bar {
|
||||
width: 4rpx;
|
||||
background: #fff;
|
||||
border-radius: 2rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.metro-bar-1 {
|
||||
height: 16rpx;
|
||||
}
|
||||
.metro-bar-2 {
|
||||
height: 28rpx;
|
||||
}
|
||||
.metro-bar-3 {
|
||||
height: 22rpx;
|
||||
}
|
||||
|
||||
.menu-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 28rpx;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.menu-sub {
|
||||
font-size: 20rpx;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
font-size: 32rpx;
|
||||
color: #cbd5e1;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
import App from './App'
|
||||
var baseUrl ='https://admin.zhenyangtang.com.cn/';
|
||||
var baseUrl ='https://css.zhenyangtang.com.cn/';
|
||||
// #ifndef VUE3
|
||||
import Vue from 'vue'
|
||||
import './uni.promisify.adaptor'
|
||||
|
||||
@@ -57,7 +57,14 @@
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true
|
||||
"usingComponents" : true,
|
||||
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
|
||||
@@ -105,6 +105,60 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "training",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "练一练"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/metronome",
|
||||
"style": {
|
||||
"navigationBarTitleText": "节拍器",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc",
|
||||
"requiredBackgroundModes": ["audio"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "tongji",
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "血糖记录",
|
||||
"navigationBarBackgroundColor": "#204e2b",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#faf9f5",
|
||||
"enablePullDownRefresh": true,
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/more",
|
||||
"style": {
|
||||
"navigationBarTitleText": "日常护理",
|
||||
"navigationBarBackgroundColor": "#204e2b",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#faf9f5",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
|
||||
@@ -53,6 +53,18 @@
|
||||
<text class="time-value">{{ card.create_time || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<view class="card-actions">
|
||||
<view class="card-action-btn primary" @click.stop="viewDailyRecord(card)">
|
||||
<text class="card-action-icon">📈</text>
|
||||
<text class="card-action-text">日常记录</text>
|
||||
</view>
|
||||
<view class="card-action-btn" @click.stop="viewCardDetail(card)">
|
||||
<text class="card-action-icon">📝</text>
|
||||
<text class="card-action-text">查看 / 编辑</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
@@ -138,6 +150,16 @@ const viewCardDetail = (card) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 查看日常记录(血糖血压 / 饮食 / 运动 / 跟踪备注 + 波浪图)
|
||||
const viewDailyRecord = (card) => {
|
||||
const url = `/tongji/pages/index?diagnosis_id=${card.id}` +
|
||||
`&patient_id=${card.patient_id || ''}` +
|
||||
`&patient_name=${encodeURIComponent(card.patient_name || '')}` +
|
||||
`&age=${card.age || ''}` +
|
||||
`&gender=${card.gender || ''}`
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
// 新建就诊卡(patient_id 创建后自动生成,仅需登录)
|
||||
const createCard = () => {
|
||||
const token = uni.getStorageSync('token')
|
||||
@@ -310,6 +332,54 @@ const formatDate = (timestamp) => {
|
||||
border-top: 4rpx solid #e8eaed;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 28rpx;
|
||||
padding-top: 28rpx;
|
||||
border-top: 4rpx solid #e8eaed;
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
padding: 24rpx 12rpx;
|
||||
background: #f6faff;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #d6e6fb;
|
||||
min-height: 80rpx;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, #1890ff, #0ea5a4);
|
||||
border-color: transparent;
|
||||
|
||||
.card-action-icon,
|
||||
.card-action-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-action-icon {
|
||||
font-size: 36rpx;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.card-action-text {
|
||||
font-size: 30rpx;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -778,6 +778,7 @@ const showDatePicker = ref(false)
|
||||
const diagnosisId = ref(null)
|
||||
const patientId = ref(null)
|
||||
const isAddMode = ref(false) // 新建模式
|
||||
const returnUrl = ref('') // 保存成功后跳回(如日常记录页)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
@@ -1023,6 +1024,13 @@ const loadCardDetail = async () => {
|
||||
const options = currentPage.options || {}
|
||||
diagnosisId.value = options.id
|
||||
isAddMode.value = options.add === '1' || options.add === 1
|
||||
if (options.returnUrl) {
|
||||
try {
|
||||
returnUrl.value = decodeURIComponent(String(options.returnUrl))
|
||||
} catch (e) {
|
||||
returnUrl.value = String(options.returnUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const userData = uni.getStorageSync('userData')
|
||||
patientId.value = userData?.diagnosis?.patient_id
|
||||
@@ -1302,7 +1310,12 @@ const submitForm = async () => {
|
||||
}
|
||||
uni.showToast({ title: isAddMode.value ? '创建成功' : '保存成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
const back = returnUrl.value
|
||||
if (back && back.startsWith('/')) {
|
||||
uni.redirectTo({ url: back })
|
||||
} else {
|
||||
uni.navigateBack()
|
||||
}
|
||||
}, 1500)
|
||||
} else {
|
||||
uni.showToast({ title: res.msg || '保存失败', icon: 'none' })
|
||||
|
||||
@@ -88,13 +88,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DEV 开发悬浮入口(生产环境自动隐藏) -->
|
||||
<dev-training-entry />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import DevTrainingEntry from '@/components/dev-training-entry/index.vue'
|
||||
export default {
|
||||
name: 'profileB',
|
||||
components: { DevTrainingEntry },
|
||||
data() {
|
||||
return {
|
||||
userInfo: {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* 用小米 MiMo TTS 生成训练语音素材
|
||||
*
|
||||
* 文档: https://platform.xiaomimimo.com/docs/en-US/usage-guide/speech-synthesis-v2.5
|
||||
* 接口: POST https://api.xiaomimimo.com/v1/chat/completions
|
||||
*
|
||||
* 准备:
|
||||
* export MIMO_API_KEY=sk-your-key-here
|
||||
* (或 export XIAOMI_API_KEY=...)
|
||||
*
|
||||
* 运行:
|
||||
* cd uniapp
|
||||
* node scripts/generate-voice.mjs # 默认音色 冰糖
|
||||
* node scripts/generate-voice.mjs --voice 茉莉
|
||||
* node scripts/generate-voice.mjs --force # 已存在的也重新生成
|
||||
*
|
||||
* 输出(分包):
|
||||
* training/static/voice/numbers/{1..30}.mp3
|
||||
* training/static/voice/prompts/{key}.mp3
|
||||
*
|
||||
* 需要 Node 18+(用内置 fetch)
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile, access, stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const ROOT = join(__dirname, '..')
|
||||
const VOICE_DIR = join(ROOT, 'training/static/voice')
|
||||
const NUMBERS_DIR = join(VOICE_DIR, 'numbers')
|
||||
const PROMPTS_DIR = join(VOICE_DIR, 'prompts')
|
||||
|
||||
// ============ 参数 ============
|
||||
const args = process.argv.slice(2)
|
||||
const FORCE = args.includes('--force')
|
||||
const VOICE = pickArg('--voice') || '冰糖'
|
||||
const MODEL = pickArg('--model') || 'mimo-v2.5-tts'
|
||||
const FORMAT = pickArg('--format') || 'mp3'
|
||||
|
||||
const API_KEY =
|
||||
process.env.MIMO_API_KEY ||
|
||||
process.env.XIAOMI_API_KEY ||
|
||||
process.env.XIAOMI_MIMO_API_KEY
|
||||
const ENDPOINT =
|
||||
process.env.MIMO_ENDPOINT || 'https://api.xiaomimimo.com/v1/chat/completions'
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error('❌ 未找到 API Key')
|
||||
console.error(' 请先 export MIMO_API_KEY=your-key')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function pickArg(name) {
|
||||
const idx = args.indexOf(name)
|
||||
if (idx === -1) return null
|
||||
return args[idx + 1]
|
||||
}
|
||||
|
||||
// ============ 语音内容 ============
|
||||
|
||||
// 数字报数:简短的"教练点数"风格
|
||||
const NUMBER_STYLE =
|
||||
'用简短有力的健身教练口吻报数,干净利落,节奏明快,每个数字独立清晰。'
|
||||
|
||||
// 口令:温柔有鼓励性的女教练
|
||||
const PROMPT_STYLE =
|
||||
'用温柔但有力量的女教练口吻说话,语调亲切自然,节奏适中,像在带学员训练。'
|
||||
|
||||
const PROMPTS = {
|
||||
start: '开始',
|
||||
ready: '准备',
|
||||
rest: '休息一下',
|
||||
'next-set': '下一组开始',
|
||||
'last-rep': '最后一次',
|
||||
'keep-it-up': '继续坚持',
|
||||
'good-job': '很棒,训练完成',
|
||||
'breathe-in': '吸气',
|
||||
'breathe-out': '呼气',
|
||||
}
|
||||
|
||||
// ============ 工具 ============
|
||||
|
||||
async function fileExists(p) {
|
||||
try {
|
||||
await access(p)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDir(d) {
|
||||
await mkdir(d, { recursive: true })
|
||||
}
|
||||
|
||||
async function tts(text, outPath, styleInstruction) {
|
||||
if (!FORCE && (await fileExists(outPath))) {
|
||||
const s = await stat(outPath)
|
||||
if (s.size > 0) {
|
||||
console.log(`[skip] ${outPath} (已存在)`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'user', content: styleInstruction },
|
||||
{ role: 'assistant', content: text },
|
||||
],
|
||||
audio: { format: FORMAT, voice: VOICE },
|
||||
}
|
||||
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'api-key': API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '')
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}: ${errText.slice(0, 300)}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
const audioBase64 = json?.choices?.[0]?.message?.audio?.data
|
||||
if (!audioBase64) {
|
||||
throw new Error(
|
||||
`响应里找不到 audio.data 字段。完整响应: ${JSON.stringify(json).slice(0, 500)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const buf = Buffer.from(audioBase64, 'base64')
|
||||
await writeFile(outPath, buf)
|
||||
console.log(`[ok] ${outPath} (${buf.length} bytes) → "${text}"`)
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
// ============ 主流程 ============
|
||||
|
||||
async function main() {
|
||||
console.log('====== 小米 MiMo TTS 语音生成 ======')
|
||||
console.log(`Endpoint : ${ENDPOINT}`)
|
||||
console.log(`Model : ${MODEL}`)
|
||||
console.log(`Voice : ${VOICE}`)
|
||||
console.log(`Format : ${FORMAT}`)
|
||||
console.log(`Force : ${FORCE}`)
|
||||
console.log(`Output : ${VOICE_DIR}`)
|
||||
console.log('====================================\n')
|
||||
|
||||
await ensureDir(NUMBERS_DIR)
|
||||
await ensureDir(PROMPTS_DIR)
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
console.log('--- 数字 1-30 ---')
|
||||
for (let n = 1; n <= 30; n++) {
|
||||
try {
|
||||
await tts(String(n), join(NUMBERS_DIR, `${n}.${FORMAT}`), NUMBER_STYLE)
|
||||
successCount++
|
||||
await sleep(120)
|
||||
} catch (e) {
|
||||
failCount++
|
||||
console.error(`[fail] 数字 ${n}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- 口令 ---')
|
||||
for (const [key, text] of Object.entries(PROMPTS)) {
|
||||
try {
|
||||
await tts(text, join(PROMPTS_DIR, `${key}.${FORMAT}`), PROMPT_STYLE)
|
||||
successCount++
|
||||
await sleep(120)
|
||||
} catch (e) {
|
||||
failCount++
|
||||
console.error(`[fail] ${key}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n====================================')
|
||||
console.log(`成功 ${successCount} | 失败 ${failCount}`)
|
||||
console.log('====================================')
|
||||
|
||||
if (FORMAT !== 'mp3') {
|
||||
console.log(
|
||||
`\n⚠️ 当前生成格式是 ${FORMAT},但 hooks/useVoiceCoach.ts 默认引用 .mp3。`,
|
||||
)
|
||||
console.log(' 要么换 --format mp3 重跑,要么修改 hooks 里的 src 后缀。')
|
||||
}
|
||||
|
||||
console.log('\n下一步:')
|
||||
console.log(' 1. 准备 click 音 → training/static/audio/click.mp3')
|
||||
console.log(' 2. 准备 BGM → training/static/bgm/{train-light,rest-meditation}.mp3')
|
||||
console.log(' 3. 编译运行 → 进入 /training/pages/index')
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\n❌ 致命错误:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<view v-if="show" class="celebrate-root" @touchmove.stop.prevent>
|
||||
<view
|
||||
v-for="p in particles"
|
||||
:key="p.id"
|
||||
class="celebrate-particle"
|
||||
:style="p.style"
|
||||
/>
|
||||
<view v-if="title" class="celebrate-card" :class="{ pop: cardPop }">
|
||||
<view class="celebrate-card-glow" />
|
||||
<TongjiIcon name="sparkles" size="lg" color="#204E2B" />
|
||||
<text class="celebrate-card-title">{{ title }}</text>
|
||||
<text v-if="subtitle" class="celebrate-card-sub">{{ subtitle }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import TongjiIcon from './TongjiIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const particles = ref([])
|
||||
const cardPop = ref(false)
|
||||
|
||||
const COLORS = ['#204E2B', '#386641', '#AFE2B3', '#FBBF24', '#727970', '#DC2626']
|
||||
|
||||
function buildParticles() {
|
||||
const list = []
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const left = 8 + Math.random() * 84
|
||||
const delay = Math.random() * 0.35
|
||||
const hue = COLORS[i % COLORS.length]
|
||||
const size = 10 + Math.floor(Math.random() * 14)
|
||||
list.push({
|
||||
id: `${Date.now()}_${i}`,
|
||||
style: {
|
||||
left: `${left}%`,
|
||||
width: `${size}rpx`,
|
||||
height: `${size}rpx`,
|
||||
background: hue,
|
||||
animationDelay: `${delay}s`
|
||||
}
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) {
|
||||
particles.value = buildParticles()
|
||||
cardPop.value = false
|
||||
setTimeout(() => {
|
||||
cardPop.value = true
|
||||
}, 30)
|
||||
} else {
|
||||
particles.value = []
|
||||
cardPop.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.celebrate-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.celebrate-particle {
|
||||
position: absolute;
|
||||
top: -20rpx;
|
||||
border-radius: 4rpx;
|
||||
opacity: 0.9;
|
||||
animation: celebrate-fall 1.6s ease-in forwards;
|
||||
}
|
||||
@keyframes celebrate-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(110vh) rotate(540deg) scale(0.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.celebrate-card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 38%;
|
||||
transform: translate(-50%, -50%) scale(0.82);
|
||||
width: 78%;
|
||||
max-width: 560rpx;
|
||||
padding: 36rpx 32rpx 32rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 20rpx 60rpx rgba(8, 145, 178, 0.22);
|
||||
border: 2rpx solid rgba(8, 145, 178, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||
&.pop {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
.celebrate-card-glow {
|
||||
position: absolute;
|
||||
inset: -20rpx;
|
||||
border-radius: 36rpx;
|
||||
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.celebrate-card-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 16rpx;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #1b1c1a;
|
||||
}
|
||||
.celebrate-card-sub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
color: #475569;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<view
|
||||
class="sugar-tree-graphic"
|
||||
:class="[`lv-${clampedLevel}`, `tier-${visualTier}`, `size-${size}`, { watering: watering, 'is-max': clampedLevel >= MAX_LEVEL }]"
|
||||
>
|
||||
<view class="stg-glow" />
|
||||
<view v-if="clampedLevel >= 7" class="stg-aura" />
|
||||
<image v-if="!treeUseFallback" class="stg-image" :src="treeImageSrc" mode="aspectFit" @error="treeUseFallback = true" />
|
||||
<text v-else class="stg-emoji">{{ treeEmoji }}</text>
|
||||
<view v-if="clampedLevel >= 7" class="stg-sparkle stg-sparkle-a" />
|
||||
<view v-if="clampedLevel >= 8" class="stg-sparkle stg-sparkle-b" />
|
||||
<view v-if="clampedLevel >= MAX_LEVEL" class="stg-sparkle stg-sparkle-c" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
import { TREE_MAX_LEVEL, TREE_LEVELS } from '../utils/treeLevels.js'
|
||||
|
||||
const MAX_LEVEL = TREE_MAX_LEVEL
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const treeUseFallback = ref(true)
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
const treeUseFallback = ref(false)
|
||||
// #endif
|
||||
|
||||
const props = defineProps({
|
||||
level: { type: Number, default: 0 },
|
||||
size: { type: String, default: 'md' },
|
||||
watering: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const clampedLevel = computed(() => Math.min(MAX_LEVEL, Math.max(0, Number(props.level) || 0)))
|
||||
const visualTier = computed(() => {
|
||||
const lv = clampedLevel.value
|
||||
if (lv <= 0) return 0
|
||||
if (lv <= 2) return 1
|
||||
if (lv <= 4) return 2
|
||||
if (lv <= 6) return 3
|
||||
if (lv <= 8) return 4
|
||||
return 5
|
||||
})
|
||||
const treeEmoji = computed(() => (TREE_LEVELS[clampedLevel.value] || TREE_LEVELS[0]).emoji)
|
||||
|
||||
function buildTreeSvg(level) {
|
||||
const pot = `
|
||||
<ellipse cx="24" cy="50" rx="15" ry="3" fill="#204e2b" opacity="0.12"/>
|
||||
<path d="M11 46h26c1.2 0 2 1 2 2.2v3.8c0 1-.8 1.8-1.8 1.8H10.8c-1 0-1.8-.8-1.8-1.8v-3.8c0-1.2.8-2.2 2-2.2z" fill="#E7E5E4"/>
|
||||
<path d="M12.5 46h23c.8 0 1.5.7 1.5 1.5v2.2c0 .6-.5 1.1-1.1 1.1H12.1c-.6 0-1.1-.5-1.1-1.1v-2.2c0-.8.7-1.5 1.5-1.5z" fill="#D6D3D1"/>
|
||||
<ellipse cx="24" cy="46.5" rx="9" ry="1.6" fill="#A8A29E" opacity="0.35"/>
|
||||
`
|
||||
const soil = `<ellipse cx="24" cy="44.5" rx="8" ry="2.2" fill="#386641" opacity="0.18"/>`
|
||||
|
||||
if (level <= 0) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">
|
||||
${pot}${soil}
|
||||
<circle cx="24" cy="41.5" r="2.2" fill="#a8a29e" opacity="0.7"/>
|
||||
<path d="M24 41.5v3.5" stroke="#78716c" stroke-width="1" stroke-linecap="round"/>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
const trunkH = level >= 6 ? 16 : level >= 3 ? 14 : 10
|
||||
const trunkY = 46 - trunkH
|
||||
const trunk = `<rect x="22.2" y="${trunkY}" width="3.6" height="${trunkH}" rx="1.8" fill="#78716C"/>
|
||||
<rect x="22.6" y="${trunkY + 1}" width="2.8" height="${trunkH - 1}" rx="1.4" fill="#A8A29E" opacity="0.35"/>`
|
||||
|
||||
const leaf = (cx, cy, r, fill, opacity = 1) =>
|
||||
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}" opacity="${opacity}"/>
|
||||
<circle cx="${cx - r * 0.25}" cy="${cy - r * 0.2}" r="${r * 0.35}" fill="#eef6ef" opacity="0.55"/>`
|
||||
|
||||
const bloom =
|
||||
level >= 7
|
||||
? leaf(17, 18, 3, '#fda4af', 0.95) +
|
||||
leaf(31, 17, 2.8, '#f9a8d4', 0.9) +
|
||||
leaf(24, 13, 3.2, '#fb7185', 0.95) +
|
||||
`<circle cx="24" cy="12" r="1.3" fill="#fef3c7"/>`
|
||||
: ''
|
||||
const crown =
|
||||
level >= 9
|
||||
? leaf(12, 22, 6, '#34d399', 0.9) +
|
||||
leaf(36, 22, 6, '#34d399', 0.9) +
|
||||
leaf(24, 10, 7, '#386641', 0.95) +
|
||||
bloom
|
||||
: bloom
|
||||
|
||||
let canopy = ''
|
||||
if (level === 1) {
|
||||
canopy = leaf(24, 38, 4, '#afe2b3') + `<path d="M24 38v-5" stroke="#386641" stroke-width="1.2" stroke-linecap="round"/>`
|
||||
} else if (level === 2) {
|
||||
canopy = leaf(24, 32, 5.5, '#34d399') + leaf(20, 34, 3.5, '#6ee7b7', 0.9)
|
||||
} else if (level <= 4) {
|
||||
canopy =
|
||||
leaf(24, 28, 7, '#34d399') +
|
||||
leaf(17, 30, 5, '#6ee7b7', 0.9) +
|
||||
leaf(31, 30, 5, '#6ee7b7', 0.9)
|
||||
} else if (level <= 6) {
|
||||
canopy =
|
||||
leaf(24, 24, 9, '#22c55e') +
|
||||
leaf(14, 26, 7, '#4ade80', 0.92) +
|
||||
leaf(34, 26, 7, '#4ade80', 0.92) +
|
||||
leaf(24, 16, 6, '#386641', 0.88)
|
||||
} else {
|
||||
canopy =
|
||||
leaf(24, 20, 10, '#204e2b') +
|
||||
leaf(13, 24, 8, '#34d399', 0.95) +
|
||||
leaf(35, 24, 8, '#34d399', 0.95) +
|
||||
crown
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 56" fill="none">${pot}${soil}${trunk}${canopy}</svg>`
|
||||
}
|
||||
|
||||
const treeImageSrc = computed(() => svgToDataUrl(buildTreeSvg(clampedLevel.value)))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sugar-tree-graphic {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.size-sm { width: 64rpx; height: 72rpx; }
|
||||
.size-md { width: 80rpx; height: 88rpx; }
|
||||
.size-lg { width: 112rpx; height: 124rpx; }
|
||||
|
||||
.stg-glow {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(32, 78, 43, 0.22) 0%, transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.lv-0 .stg-glow { background: radial-gradient(circle, rgba(148, 163, 184, 0.25) 0%, transparent 70%); }
|
||||
.tier-4 .stg-glow,
|
||||
.tier-5 .stg-glow,
|
||||
.is-max .stg-glow {
|
||||
background: radial-gradient(circle, rgba(251, 191, 36, 0.3) 0%, rgba(32, 78, 43, 0.15) 55%, transparent 72%);
|
||||
}
|
||||
.stg-aura {
|
||||
position: absolute;
|
||||
inset: -8%;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid rgba(253, 224, 71, 0.35);
|
||||
animation: stg-aura-pulse 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes stg-aura-pulse {
|
||||
0%, 100% { transform: scale(0.92); opacity: 0.5; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
}
|
||||
.stg-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
.sugar-tree-graphic.watering .stg-image,
|
||||
.sugar-tree-graphic.watering .stg-emoji {
|
||||
animation: stg-water-bounce 0.65s ease;
|
||||
}
|
||||
.stg-emoji {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
font-size: 72rpx;
|
||||
}
|
||||
.size-sm .stg-emoji { font-size: 48rpx; }
|
||||
.size-md .stg-emoji { font-size: 60rpx; }
|
||||
.size-lg .stg-emoji { font-size: 88rpx; }
|
||||
@keyframes stg-water-bounce {
|
||||
0%, 100% { transform: scale(1); }
|
||||
35% { transform: scale(1.1) translateY(-6rpx); }
|
||||
60% { transform: scale(0.96) translateY(2rpx); }
|
||||
}
|
||||
.stg-sparkle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #fde68a;
|
||||
box-shadow: 0 0 6rpx rgba(253, 224, 71, 0.8);
|
||||
pointer-events: none;
|
||||
animation: stg-sparkle-twinkle 1.8s ease-in-out infinite;
|
||||
}
|
||||
.stg-sparkle-a { top: 4%; right: 16%; }
|
||||
.stg-sparkle-b { top: 12%; left: 10%; width: 6rpx; height: 6rpx; animation-delay: 0.4s; }
|
||||
.stg-sparkle-c { top: 22%; right: 28%; width: 10rpx; height: 10rpx; animation-delay: 0.8s; }
|
||||
@keyframes stg-sparkle-twinkle {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<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"/>',
|
||||
'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"/>'
|
||||
}
|
||||
|
||||
/** 图片加载失败时的 emoji 回退 */
|
||||
const ICON_FALLBACK = {
|
||||
view: '👁',
|
||||
ticket: '🎫',
|
||||
calendar: '📅',
|
||||
flame: '🔥',
|
||||
'check-circle': '✓',
|
||||
glucose: '💧',
|
||||
heart: '❤',
|
||||
activity: '🏃',
|
||||
users: '👥',
|
||||
'alert-triangle': '⚠',
|
||||
minus: '—',
|
||||
droplet: '💧',
|
||||
sparkles: '✨',
|
||||
trophy: '🏆',
|
||||
share: '↗',
|
||||
plus: '+',
|
||||
refresh: '↻',
|
||||
volume: '🔊',
|
||||
pause: '⏸',
|
||||
play: '▶',
|
||||
info: '!',
|
||||
sun: '☀',
|
||||
moon: '🌙',
|
||||
sunset: '☀'
|
||||
}
|
||||
|
||||
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%;
|
||||
}
|
||||
.tj-icon-fallback {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.tj-icon-fallback--sm {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.tj-icon-fallback--md {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.tj-icon-fallback--lg {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.tj-icon-fallback--xl {
|
||||
font-size: 42rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ref } from 'vue'
|
||||
import { formatUserMessage } from '../utils/tongjiHelpers.js'
|
||||
|
||||
/** 登录 / 就诊卡门禁(index 与 more 共用) */
|
||||
export function useTongjiAuth(proxy) {
|
||||
const authChecking = ref(false)
|
||||
let gateRedirected = false
|
||||
let authSessionPromise = null
|
||||
|
||||
function hasAuthToken() {
|
||||
return !!String(uni.getStorageSync('token') || '').trim()
|
||||
}
|
||||
|
||||
function clearAuthStorage() {
|
||||
uni.removeStorageSync('token')
|
||||
authSessionPromise = null
|
||||
}
|
||||
|
||||
function wxLoginGetCode() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
if (res && res.code) resolve(res.code)
|
||||
else reject(new Error('微信登录未返回 code'))
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function mnpLoginWithCode(code) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/login/mnpLogin',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
}, false)
|
||||
if (res && res.code === 1 && res.data && res.data.token) {
|
||||
uni.setStorageSync('token', res.data.token)
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return res.data
|
||||
}
|
||||
clearAuthStorage()
|
||||
throw new Error(formatUserMessage(res?.msg, '登录失败'))
|
||||
}
|
||||
|
||||
async function doWxLogin() {
|
||||
const code = await wxLoginGetCode()
|
||||
await mnpLoginWithCode(code)
|
||||
return hasAuthToken()
|
||||
}
|
||||
|
||||
async function verifyOrLogin() {
|
||||
if (!hasAuthToken()) {
|
||||
return doWxLogin()
|
||||
}
|
||||
try {
|
||||
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||||
if (res && res.code === 1 && res.data) {
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
/* 网络异常走重新登录 */
|
||||
}
|
||||
clearAuthStorage()
|
||||
try {
|
||||
return await doWxLogin()
|
||||
} catch (e2) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLoggedIn() {
|
||||
if (authSessionPromise) {
|
||||
return authSessionPromise
|
||||
}
|
||||
authSessionPromise = verifyOrLogin()
|
||||
.then((ok) => {
|
||||
if (!ok) authSessionPromise = null
|
||||
return !!ok
|
||||
})
|
||||
.catch(() => {
|
||||
authSessionPromise = null
|
||||
return false
|
||||
})
|
||||
return authSessionPromise
|
||||
}
|
||||
|
||||
function redirectToCardEntry(returnPath) {
|
||||
if (gateRedirected) return
|
||||
gateRedirected = true
|
||||
const returnUrl = encodeURIComponent(returnPath || '/tongji/pages/index')
|
||||
uni.redirectTo({
|
||||
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
|
||||
})
|
||||
}
|
||||
|
||||
function resetGateRedirect() {
|
||||
gateRedirected = false
|
||||
}
|
||||
|
||||
function isGateRedirected() {
|
||||
return gateRedirected
|
||||
}
|
||||
|
||||
return {
|
||||
authChecking,
|
||||
hasAuthToken,
|
||||
ensureLoggedIn,
|
||||
redirectToCardEntry,
|
||||
resetGateRedirect,
|
||||
isGateRedirected
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,993 @@
|
||||
/**
|
||||
|
||||
* 日常记录统一主题 — 与甄养堂小程序 TCM Care 品牌色一致
|
||||
|
||||
* 来源:pages/index/index.vue · tabBar selectedColor #204e2b
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
.daily-page {
|
||||
|
||||
/* 品牌主色 */
|
||||
|
||||
--primary: #204e2b;
|
||||
|
||||
--primary-dark: #163d22;
|
||||
|
||||
--primary-container: #386641;
|
||||
|
||||
--primary-grad: linear-gradient(160deg, #204e2b 0%, #386641 100%);
|
||||
|
||||
--primary-light: #eef6ef;
|
||||
|
||||
--primary-soft: #cfe8d3;
|
||||
|
||||
--primary-glow: rgba(32, 78, 43, 0.14);
|
||||
|
||||
--on-primary-container: #afe2b3;
|
||||
|
||||
|
||||
|
||||
--success: #386641;
|
||||
|
||||
--success-light: #eef6ef;
|
||||
|
||||
|
||||
|
||||
--danger: #dc2626;
|
||||
|
||||
--danger-light: #fef2f2;
|
||||
|
||||
--danger-glow: rgba(220, 38, 38, 0.1);
|
||||
|
||||
|
||||
|
||||
--warning: #d97706;
|
||||
|
||||
--warning-light: #fff7ed;
|
||||
|
||||
|
||||
|
||||
--text-primary: #1b1c1a;
|
||||
|
||||
--text-secondary: #414941;
|
||||
|
||||
--text-muted: #727970;
|
||||
|
||||
|
||||
|
||||
--surface: #ffffff;
|
||||
|
||||
--surface-muted: #f4f4f0;
|
||||
|
||||
--border: #e3e2df;
|
||||
|
||||
--border-soft: rgba(32, 78, 43, 0.12);
|
||||
|
||||
|
||||
|
||||
--slate-50: #faf9f5;
|
||||
|
||||
--slate-100: #f4f4f0;
|
||||
|
||||
--slate-200: #e9e8e4;
|
||||
|
||||
--slate-300: #e3e2df;
|
||||
|
||||
--slate-400: #727970;
|
||||
|
||||
--slate-600: #414941;
|
||||
|
||||
--slate-900: #1b1c1a;
|
||||
|
||||
|
||||
|
||||
--shadow-premium: 0 8rpx 28rpx rgba(32, 78, 43, 0.06);
|
||||
|
||||
--shadow-sm: 0 4rpx 16rpx rgba(32, 78, 43, 0.05);
|
||||
|
||||
--shadow-glow-primary: 0 8rpx 20rpx rgba(32, 78, 43, 0.18);
|
||||
|
||||
--shadow-glow-danger: 0 8rpx 20rpx rgba(220, 38, 38, 0.12);
|
||||
|
||||
--border-premium: 1rpx solid var(--border-soft);
|
||||
|
||||
|
||||
|
||||
background: var(--slate-50);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Hero:品牌绿渐变 */
|
||||
|
||||
.daily-page .hero-bg {
|
||||
|
||||
background: var(--primary-grad);
|
||||
|
||||
&::after {
|
||||
|
||||
background: radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.1) 0%, transparent 55%);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-orb {
|
||||
|
||||
display: none;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-pill.voice.speaking {
|
||||
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.95);
|
||||
|
||||
animation: none;
|
||||
|
||||
box-shadow: 0 0 0 3rpx rgba(255, 255, 255, 0.35);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-status-chip.pending {
|
||||
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .hero-status-chip.done {
|
||||
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
|
||||
border-color: rgba(255, 255, 255, 0.32);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 指标卡 */
|
||||
|
||||
.daily-page .stat-fasting,
|
||||
|
||||
.daily-page .stat-postprandial,
|
||||
|
||||
.daily-page .stat-other {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-fasting .stat-card-tag,
|
||||
|
||||
.daily-page .stat-postprandial .stat-card-tag,
|
||||
|
||||
.daily-page .stat-other .stat-card-tag {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-value {
|
||||
|
||||
background: none;
|
||||
|
||||
-webkit-background-clip: unset;
|
||||
|
||||
background-clip: unset;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-value.is-high {
|
||||
|
||||
background: none;
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .stat-card-trend.trend-down {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
.stat-card-trend-arrow,
|
||||
|
||||
.stat-card-trend-delta {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .more-entry-arrow {
|
||||
|
||||
color: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .card-chip.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .range-item.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .legend-dot-fasting {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .legend-dot-postprandial {
|
||||
|
||||
background: var(--warning);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .streak-strip {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-color: var(--border-soft);
|
||||
|
||||
&::after {
|
||||
|
||||
background: radial-gradient(circle, rgba(32, 78, 43, 0.08) 0%, transparent 70%);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .streak-num {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 日历:品牌绿深浅 */
|
||||
|
||||
.daily-page .calendar-cell.level-1 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-1 {
|
||||
|
||||
background: var(--primary-soft);
|
||||
|
||||
border-color: #b8dcc0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .calendar-cell.level-2 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-2 {
|
||||
|
||||
background: var(--on-primary-container);
|
||||
|
||||
border-color: #86c992;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .calendar-cell.level-3 .calendar-cell-mark,
|
||||
|
||||
.daily-page .calendar-legend-cell.level-3 {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
border-color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .day-block-tag.tag-blood,
|
||||
|
||||
.daily-page .day-block-tag.tag-diet,
|
||||
|
||||
.daily-page .day-block-tag.tag-exercise,
|
||||
|
||||
.daily-page .day-block-tag.tag-tracking {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
border: 1rpx solid var(--border-soft);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .day-block-tag.tag-diet,
|
||||
|
||||
.daily-page .day-block-tag.tag-exercise {
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
border-color: var(--border);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .pg-points-badge {
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
border-color: var(--border-soft);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .pg-points-num {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .record-quick-chip.pending {
|
||||
|
||||
border-color: var(--border);
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .record-quick-chip.done {
|
||||
|
||||
border-color: rgba(56, 102, 65, 0.35);
|
||||
|
||||
background: var(--success-light);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .task-action-btn.pending {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .water-btn {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .family-like-strip,
|
||||
|
||||
.daily-page .encourage-strip {
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
box-shadow: var(--shadow-premium);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .card-title,
|
||||
|
||||
.daily-page .hero-title {
|
||||
|
||||
color: inherit;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .report-share-btn,
|
||||
|
||||
.daily-page .report-share-btn::after {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
box-shadow: var(--shadow-glow-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-modal-btn.primary {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-fasting {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-postprandial {
|
||||
|
||||
background: var(--primary-container);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .input-field-dot-other {
|
||||
|
||||
background: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .voice-switch.on .voice-switch-track {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .heatmap-foot-cta {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ========== 适老极简首页(index) ========== */
|
||||
|
||||
.daily-page .elder-header {
|
||||
|
||||
padding: 48rpx 32rpx 24rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-greet {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 44rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
line-height: 1.35;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-status {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 16rpx;
|
||||
|
||||
font-size: 32rpx;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
line-height: 1.4;
|
||||
|
||||
|
||||
|
||||
&.is-done {
|
||||
|
||||
color: var(--success);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.is-pending {
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-scroll {
|
||||
|
||||
margin-top: 24rpx;
|
||||
|
||||
width: 100%;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-row {
|
||||
|
||||
display: inline-flex;
|
||||
|
||||
flex-wrap: nowrap;
|
||||
|
||||
gap: 16rpx;
|
||||
|
||||
padding: 2rpx 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-card-chip {
|
||||
|
||||
flex-shrink: 0;
|
||||
|
||||
padding: 12rpx 28rpx;
|
||||
|
||||
border-radius: 999rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 2rpx solid var(--border);
|
||||
|
||||
|
||||
|
||||
text {
|
||||
|
||||
font-size: 28rpx;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.active {
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
border-color: var(--primary);
|
||||
|
||||
|
||||
|
||||
text {
|
||||
|
||||
color: #fff;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-actions {
|
||||
|
||||
padding: 0 32rpx 24rpx;
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: row;
|
||||
|
||||
align-items: stretch;
|
||||
|
||||
gap: 16rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: center;
|
||||
|
||||
gap: 12rpx;
|
||||
|
||||
min-height: 112rpx;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-text {
|
||||
|
||||
font-size: 36rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-primary {
|
||||
|
||||
flex: 8;
|
||||
|
||||
background: var(--primary);
|
||||
|
||||
|
||||
|
||||
.elder-btn-text {
|
||||
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-btn-voice {
|
||||
|
||||
flex: 2;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 8rpx;
|
||||
|
||||
padding: 12rpx 8rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border: 2rpx solid var(--border-soft);
|
||||
|
||||
|
||||
|
||||
.elder-btn-text {
|
||||
|
||||
color: var(--primary-dark);
|
||||
|
||||
font-size: 24rpx;
|
||||
|
||||
line-height: 1.25;
|
||||
|
||||
text-align: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.speaking {
|
||||
|
||||
border-color: var(--primary);
|
||||
|
||||
background: var(--primary-light);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.disabled {
|
||||
|
||||
opacity: 0.45;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-today-panel {
|
||||
|
||||
margin: 0 32rpx 24rpx;
|
||||
|
||||
padding: 32rpx;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
box-shadow: var(--shadow-premium);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-panel-title {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 34rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-today-grid {
|
||||
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
gap: 20rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric {
|
||||
|
||||
padding: 24rpx;
|
||||
|
||||
border-radius: 20rpx;
|
||||
|
||||
background: var(--surface-muted);
|
||||
|
||||
border: 2rpx solid var(--border);
|
||||
|
||||
|
||||
|
||||
&.is-high {
|
||||
|
||||
border-color: rgba(220, 38, 38, 0.35);
|
||||
|
||||
background: var(--danger-light);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-label {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 28rpx;
|
||||
|
||||
color: var(--text-secondary);
|
||||
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-value-row {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: baseline;
|
||||
|
||||
gap: 8rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-value {
|
||||
|
||||
font-size: 56rpx;
|
||||
|
||||
font-weight: 800;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
line-height: 1.1;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric.is-high .elder-metric-value {
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-unit {
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-metric-flag {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 12rpx;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--danger);
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-link {
|
||||
|
||||
margin: 0 32rpx 16rpx;
|
||||
|
||||
padding: 28rpx 32rpx;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
background: var(--surface);
|
||||
|
||||
border-radius: 24rpx;
|
||||
|
||||
border: 1rpx solid var(--border);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-title {
|
||||
|
||||
display: block;
|
||||
|
||||
font-size: 32rpx;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
color: var(--text-primary);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-sub {
|
||||
|
||||
display: block;
|
||||
|
||||
margin-top: 8rpx;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
line-height: 1.45;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-more-arrow {
|
||||
|
||||
font-size: 48rpx;
|
||||
|
||||
color: var(--primary);
|
||||
|
||||
font-weight: 300;
|
||||
|
||||
padding-left: 16rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.daily-page .footer-tip {
|
||||
|
||||
text-align: center;
|
||||
|
||||
font-size: 26rpx;
|
||||
|
||||
color: var(--text-muted);
|
||||
|
||||
padding: 16rpx 32rpx 48rpx;
|
||||
|
||||
line-height: 1.6;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.daily-page .elder-chart-block .range-bar {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.daily-page .glucose-history-block {
|
||||
|
||||
padding-bottom: 8rpx;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 将 SVG 字符串转为小程序可用的 data URL(base64 在真机上更稳定)
|
||||
*/
|
||||
|
||||
function utf8ToBytes(str) {
|
||||
const encoded = encodeURIComponent(str)
|
||||
const bytes = []
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
if (encoded.charCodeAt(i) === 37) {
|
||||
bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16))
|
||||
i += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
|
||||
return uni.arrayBufferToBase64(bytes.buffer)
|
||||
}
|
||||
if (typeof btoa !== 'undefined') {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
let result = ''
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i]
|
||||
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||
result += chars[a >> 2]
|
||||
result += chars[((a & 3) << 4) | (b >> 4)]
|
||||
result += i + 1 < bytes.length ? chars[((b & 15) << 2) | (c >> 6)] : '='
|
||||
result += i + 2 < bytes.length ? chars[c & 63] : '='
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function svgToDataUrl(svg) {
|
||||
const normalized = String(svg || '').trim()
|
||||
if (!normalized) return ''
|
||||
const base64 = bytesToBase64(utf8ToBytes(normalized))
|
||||
return `data:image/svg+xml;base64,${base64}`
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
|
||||
export function formatUserMessage(msg, fallback = '') {
|
||||
if (msg == null || msg === '') return fallback
|
||||
if (typeof msg === 'string') return msg
|
||||
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
|
||||
if (Array.isArray(msg)) {
|
||||
const parts = msg.map((item) => {
|
||||
if (item == null) return ''
|
||||
if (typeof item === 'string') return item
|
||||
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
|
||||
if (typeof item === 'object') {
|
||||
return item.msg || item.message || item.error || item.title || item.label || ''
|
||||
}
|
||||
return String(item)
|
||||
}).filter(Boolean)
|
||||
return parts.length ? parts.join(';') : fallback
|
||||
}
|
||||
if (typeof msg === 'object') {
|
||||
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
|
||||
}
|
||||
return String(msg)
|
||||
}
|
||||
|
||||
export function showUserToast(title, options = {}) {
|
||||
const text = formatUserMessage(title, '')
|
||||
if (!text) return
|
||||
uni.showToast({
|
||||
title: text,
|
||||
icon: options.icon || 'none',
|
||||
duration: options.duration
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
export function parseDate(str) {
|
||||
if (!str) return new Date()
|
||||
const [y, m, d] = str.split('-').map(Number)
|
||||
return new Date(y, (m || 1) - 1, d || 1)
|
||||
}
|
||||
|
||||
export function toNumber(v) {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n !== 0 ? n : null
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr) {
|
||||
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return w[parseDate(dateStr).getDay()]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** 控糖树等级配置(前后端算法需保持一致:每级 50 积分,最高 Lv.9) */
|
||||
export const TREE_MAX_LEVEL = 9
|
||||
export const TREE_XP_PER_LEVEL = 50
|
||||
|
||||
export const TREE_LEVELS = [
|
||||
{ level: 0, name: '种子眠', emoji: '🫘', mood: '困困', desc: '稳糖种子在土里打盹' },
|
||||
{ level: 1, name: '破土芽', emoji: '🌱', mood: '探头', desc: '探出第一抹新绿' },
|
||||
{ level: 2, name: '展两叶', emoji: '🌿', mood: '好奇', desc: '两片嫩叶迎风展' },
|
||||
{ level: 3, name: '小树苗', emoji: '🪴', mood: '精神', desc: '身子骨硬朗起来' },
|
||||
{ level: 4, name: '青枝繁', emoji: '🌳', mood: '茁壮', desc: '枝叶渐密,元气足' },
|
||||
{ level: 5, name: '拔节高', emoji: '🌲', mood: '挺拔', desc: '一节一节往上蹿' },
|
||||
{ level: 6, name: '稳糖冠', emoji: '💚', mood: '沉稳', desc: '树冠成形,习惯成自然' },
|
||||
{ level: 7, name: '初绽香', emoji: '🌸', mood: '开心', desc: '枝头冒出第一朵花' },
|
||||
{ level: 8, name: '漫开花', emoji: '🌺', mood: '灿烂', desc: '花开满枝,越记越稳' },
|
||||
{ level: 9, name: '圆满树', emoji: '🏆', mood: '荣耀', desc: '满级大树,习惯大师' }
|
||||
]
|
||||
|
||||
const WHISPERS_BY_LEVEL = {
|
||||
0: ['种子在睡觉,记一笔就醒啦。', '今天浇第一滴水,芽就要冒出来。'],
|
||||
1: ['破土啦!再坚持几天就长高。', '小芽最喜欢规律的记录了。'],
|
||||
2: ['两片叶子为你鼓掌。', '空腹餐后都记全,我会长得更快。'],
|
||||
3: ['我已经是一棵小树啦。', '家人点赞的时候,我也会发光。'],
|
||||
4: ['枝叶越来越密,您真棒。', '连续记录,我会开出更多叶子。'],
|
||||
5: ['拔节中!习惯比完美更重要。', '再浇一点水,我就更高啦。'],
|
||||
6: ['习惯成自然,树冠成形啦。', '您今天的坚持,小树都记得。'],
|
||||
7: ['开花啦!闻到春天的味道了吗?', '稳糖花只开给坚持的人。'],
|
||||
8: ['满树花香,您已是控糖达人。', '明天继续来,花儿会更艳。'],
|
||||
9: ['满级大树陪您一路稳糖。', '圆满不是终点,习惯才是。']
|
||||
}
|
||||
|
||||
export function calcTreeFromPoints(points) {
|
||||
const pts = Math.max(0, Number(points) || 0)
|
||||
const level = Math.min(TREE_MAX_LEVEL, Math.floor(pts / TREE_XP_PER_LEVEL))
|
||||
const xpInLevel = level >= TREE_MAX_LEVEL ? TREE_XP_PER_LEVEL : pts % TREE_XP_PER_LEVEL
|
||||
const progress = level >= TREE_MAX_LEVEL ? 100 : Math.round((xpInLevel / TREE_XP_PER_LEVEL) * 100)
|
||||
const meta = TREE_LEVELS[level] || TREE_LEVELS[0]
|
||||
const nextMeta = level < TREE_MAX_LEVEL ? TREE_LEVELS[level + 1] : null
|
||||
const pointsToNext = level >= TREE_MAX_LEVEL ? 0 : TREE_XP_PER_LEVEL - xpInLevel
|
||||
return {
|
||||
level,
|
||||
progress,
|
||||
name: meta.name,
|
||||
emoji: meta.emoji,
|
||||
mood: meta.mood,
|
||||
desc: meta.desc,
|
||||
xpInLevel,
|
||||
xpNeed: TREE_XP_PER_LEVEL,
|
||||
pointsToNext,
|
||||
nextName: nextMeta?.name || '',
|
||||
isMax: level >= TREE_MAX_LEVEL
|
||||
}
|
||||
}
|
||||
|
||||
export function pickTreeWhisper(level) {
|
||||
const lv = Math.min(TREE_MAX_LEVEL, Math.max(0, Number(level) || 0))
|
||||
const list = WHISPERS_BY_LEVEL[lv] || WHISPERS_BY_LEVEL[0]
|
||||
return list[Math.floor(Math.random() * list.length)]
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export interface MetronomeOptions {
|
||||
initialBpm?: number
|
||||
bpmMin?: number
|
||||
bpmMax?: number
|
||||
clickSrc?: string
|
||||
accentSrc?: string
|
||||
accentEvery?: number
|
||||
poolSize?: number
|
||||
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。
|
||||
* 解决两个问题:
|
||||
* 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms)
|
||||
* 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失
|
||||
*/
|
||||
class AudioPool {
|
||||
private list: UniApp.InnerAudioContext[] = []
|
||||
private cursor = 0
|
||||
private warmedUp = false
|
||||
|
||||
constructor(
|
||||
private src: string,
|
||||
private size: number,
|
||||
) {}
|
||||
|
||||
private create() {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
this.list.push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热:极小音量短暂播放,让音频文件下载/解码到内存
|
||||
* 真正首次 play 不会再卡冷启动
|
||||
* 注意:iOS 上 volume = 0 不一定真正解码,所以用 0.01 极小音量
|
||||
*/
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
if (this.list.length === 0) this.create()
|
||||
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = 0.01
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = 1
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
})
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.list.length === 0) {
|
||||
this.create()
|
||||
this.warmUp()
|
||||
}
|
||||
const ctx = this.list[this.cursor]
|
||||
this.cursor = (this.cursor + 1) % this.list.length
|
||||
try {
|
||||
// iOS 上 seek(0) + play() 不一定能从头播放;stop() + play() 更稳
|
||||
ctx.stop()
|
||||
ctx.play()
|
||||
} catch (_) {
|
||||
try { ctx.play() } catch (__) {}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.destroy?.()
|
||||
} catch (_) {}
|
||||
})
|
||||
this.list = []
|
||||
this.warmedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
/* InnerAudio 兼容本地路径和网络 URL,这里默认走 CDN,跟 BgAudio 保持一致便于维护
|
||||
首次播放会下载 ~3KB,实测 100~300ms 完成,然后小程序会缓存,后续触发零延迟 */
|
||||
const DEFAULT_CLICK_SRC =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128557ef8669.mp3'
|
||||
|
||||
export function useMetronome(options: MetronomeOptions = {}) {
|
||||
const {
|
||||
initialBpm = 80,
|
||||
bpmMin = 40,
|
||||
bpmMax = 240,
|
||||
clickSrc = DEFAULT_CLICK_SRC,
|
||||
accentSrc,
|
||||
poolSize = 4,
|
||||
onBeat,
|
||||
} = options
|
||||
|
||||
const bpm = ref<number>(initialBpm)
|
||||
const isPlaying = ref<boolean>(false)
|
||||
const beatIndex = ref<number>(0)
|
||||
const isAccent = ref<boolean>(false)
|
||||
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
|
||||
|
||||
const intervalMs = computed(() => 60000 / bpm.value)
|
||||
|
||||
let clickPool: AudioPool | null = null
|
||||
let accentPool: AudioPool | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let nextTickAt = 0
|
||||
|
||||
const ensureAudio = () => {
|
||||
if (!clickPool) {
|
||||
clickPool = new AudioPool(clickSrc, poolSize)
|
||||
clickPool.warmUp()
|
||||
}
|
||||
if (accentSrc && !accentPool) {
|
||||
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)))
|
||||
accentPool.warmUp()
|
||||
}
|
||||
}
|
||||
|
||||
const playSound = (accent: boolean) => {
|
||||
if (accent && accentPool) {
|
||||
accentPool.play()
|
||||
} else if (clickPool) {
|
||||
clickPool.play()
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (!isPlaying.value) return
|
||||
|
||||
const every = Math.max(1, accentEveryRef.value)
|
||||
const accent = beatIndex.value % every === 0
|
||||
isAccent.value = accent
|
||||
|
||||
playSound(accent)
|
||||
onBeat?.(beatIndex.value, accent)
|
||||
beatIndex.value++
|
||||
|
||||
nextTickAt += intervalMs.value
|
||||
const nextDelay = Math.max(0, nextTickAt - Date.now())
|
||||
|
||||
timer = setTimeout(tick, nextDelay)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (isPlaying.value) return
|
||||
ensureAudio()
|
||||
isPlaying.value = true
|
||||
beatIndex.value = 0
|
||||
nextTickAt = Date.now()
|
||||
tick()
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
isPlaying.value = false
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const setBpm = (val: number) => {
|
||||
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
|
||||
}
|
||||
|
||||
const setAccentEvery = (n: number) => {
|
||||
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
|
||||
beatIndex.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热)
|
||||
* 同时再次设置 setInnerAudioOption,防 App.vue 全局设置失效(iOS 静音键)
|
||||
*/
|
||||
const preload = () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true,
|
||||
})
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
ensureAudio()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
clickPool?.destroy()
|
||||
accentPool?.destroy()
|
||||
clickPool = null
|
||||
accentPool = null
|
||||
})
|
||||
|
||||
return {
|
||||
bpm,
|
||||
isPlaying,
|
||||
beatIndex,
|
||||
isAccent,
|
||||
intervalMs,
|
||||
accentEvery: accentEveryRef,
|
||||
start,
|
||||
stop,
|
||||
setBpm,
|
||||
setAccentEvery,
|
||||
preload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 节拍器后台播放专版 - 基于 BackgroundAudioManager
|
||||
*
|
||||
* 解决 InnerAudioContext 在 iOS 微信小程序中无法后台播放的硬限制:
|
||||
* - iOS 真机切到后台/锁屏 InnerAudioContext 必被挂起,requiredBackgroundModes 无效
|
||||
* - BackgroundAudioManager 是微信唯一支持 iOS 真后台/锁屏播放的音频 API
|
||||
*
|
||||
* 取舍:
|
||||
* - BgAudio 是全局单例,一次只能播一个音频,不适合短促 click 高频重复
|
||||
* - 因此提前用 ffmpeg 合成 3 个档位的"完整节拍循环"音轨(80/110/130 BPM × 2 拍)
|
||||
* 每个 mp3 是 5~6 秒无缝循环,设 onEnded 重赋 src 实现永久循环
|
||||
*
|
||||
* 副作用:
|
||||
* - 播放时锁屏/通知栏会显示带封面+暂停按钮的音乐控制条(可被用户从锁屏暂停)
|
||||
* - 必须声明 requiredBackgroundModes:["audio"](已配在 manifest+pages)
|
||||
* - 切档位有 200~500ms 切换延迟,但用户主动操作时可接受
|
||||
*/
|
||||
|
||||
export type LoopId = 'slow' | 'normal' | 'brisk'
|
||||
|
||||
export interface LoopPreset {
|
||||
id: LoopId
|
||||
bpm: number
|
||||
accent: number
|
||||
src: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 重要:微信 BackgroundAudioManager.src 只接受 http/https 网络流,不能是包内资源
|
||||
* 所以必须把音频上传到 CDN(腾讯云 COS),并在小程序后台加 downloadFile 合法域名
|
||||
*
|
||||
* 当前线上文件(2026-05-26 上传到 cos.ap-guangzhou):
|
||||
* - loop_80bpm_2.mp3 循环音轨 慢走档
|
||||
* - loop_110bpm_2.mp3 循环音轨 健走档
|
||||
* - loop_130bpm_2.mp3 循环音轨 快走档
|
||||
*
|
||||
* 历史源文件(本地 training/static/audio/*.mp3) 已删除以减小包体积,
|
||||
* 如果以后要重新合成,先从 COS 下回来再用 ffmpeg 处理
|
||||
*/
|
||||
export const LOOP_PRESETS: readonly LoopPreset[] = [
|
||||
{
|
||||
id: 'slow',
|
||||
bpm: 80,
|
||||
accent: 2,
|
||||
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105129dcbc50112.mp3',
|
||||
label: '慢走 80 BPM',
|
||||
},
|
||||
{
|
||||
id: 'normal',
|
||||
bpm: 110,
|
||||
accent: 2,
|
||||
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051284f5b04928.mp3',
|
||||
label: '健走 110 BPM',
|
||||
},
|
||||
{
|
||||
id: 'brisk',
|
||||
bpm: 130,
|
||||
accent: 2,
|
||||
src: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/20260526105128164d77281.mp3',
|
||||
label: '快走 130 BPM',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 锁屏控制条封面
|
||||
* 暂时留空,微信会显示默认音乐图标,等以后有正式品牌图再上传到 COS 后填入这里
|
||||
* 注意:URL 必须是 https,且域名要加进小程序后台 downloadFile 合法域名
|
||||
*/
|
||||
const COVER_URL = ''
|
||||
|
||||
export function useMetronomeBg() {
|
||||
const isPlaying = ref<boolean>(false)
|
||||
const currentLoop = ref<LoopId | null>(null)
|
||||
|
||||
/* @dcloudio/types 没有 BackgroundAudioManager 类型,直接 any */
|
||||
let bgm: any = null
|
||||
/* 当前期望的 src,onEnded 时用它重新赋值实现循环 */
|
||||
let desiredSrc = ''
|
||||
|
||||
/* 懒初始化 BackgroundAudioManager + 绑定事件
|
||||
BgAudio 是全局单例,跨页面共享,只能在第一次需要时初始化 */
|
||||
const ensureBgm = () => {
|
||||
if (bgm) return bgm
|
||||
|
||||
const m = uni.getBackgroundAudioManager()
|
||||
|
||||
/* 必填 metadata,缺一会报错或不显示锁屏控制条 */
|
||||
m.title = '节拍器'
|
||||
m.epname = '甄养堂 · 健走'
|
||||
m.singer = '健走配速'
|
||||
if (COVER_URL) m.coverImgUrl = COVER_URL
|
||||
m.webUrl = ''
|
||||
|
||||
m.onPlay(() => {
|
||||
isPlaying.value = true
|
||||
})
|
||||
m.onPause(() => {
|
||||
/* 用户从锁屏控制条点暂停,同步 UI 状态 */
|
||||
isPlaying.value = false
|
||||
})
|
||||
m.onStop(() => {
|
||||
isPlaying.value = false
|
||||
currentLoop.value = null
|
||||
})
|
||||
|
||||
/* 实现无限循环:每段 mp3 播完时立即重赋 src 再次播放
|
||||
BgAudio 没有原生 loop 属性,只能用这招 */
|
||||
m.onEnded(() => {
|
||||
if (desiredSrc && isPlaying.value) {
|
||||
try {
|
||||
m.src = desiredSrc
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
|
||||
m.onError((err) => {
|
||||
console.error('[BgAudio] error:', err)
|
||||
isPlaying.value = false
|
||||
uni.showToast({
|
||||
title: '音频播放失败,请重试',
|
||||
icon: 'none',
|
||||
duration: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
bgm = m
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* 切到指定档位并开始播放
|
||||
* 如果已经在播同一档位 → 切到 pause/play 状态
|
||||
* 如果在播别的档位 → 切换 src(有 200~500ms 延迟)
|
||||
*/
|
||||
const playLoop = (id: LoopId) => {
|
||||
const preset = LOOP_PRESETS.find((p) => p.id === id)
|
||||
if (!preset) return
|
||||
|
||||
const m = ensureBgm()
|
||||
|
||||
/* 同档位再点一下 = 暂停 */
|
||||
if (currentLoop.value === id && isPlaying.value) {
|
||||
m.pause()
|
||||
return
|
||||
}
|
||||
|
||||
/* 切换档位或从暂停恢复 */
|
||||
currentLoop.value = id
|
||||
desiredSrc = preset.src
|
||||
|
||||
/* 重设 title 让锁屏控制条显示当前档位 */
|
||||
m.title = `节拍器 · ${preset.label}`
|
||||
|
||||
/* 赋值 src 会自动播放(微信 API 设计如此) */
|
||||
m.src = preset.src
|
||||
/* isPlaying 由 onPlay 回调置 true */
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
if (bgm && isPlaying.value) {
|
||||
bgm.pause()
|
||||
}
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
if (bgm && !isPlaying.value && desiredSrc) {
|
||||
/* 从暂停态恢复:直接 play 即可 */
|
||||
try {
|
||||
bgm.play()
|
||||
} catch (_) {
|
||||
/* 部分基础库 play() 不可用时,重赋 src */
|
||||
bgm.src = desiredSrc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完全停止 + 清掉锁屏控制条
|
||||
* 注意 BgAudio 是全局单例,stop 会影响所有页面共享的实例
|
||||
*/
|
||||
const stop = () => {
|
||||
if (bgm) {
|
||||
try {
|
||||
bgm.stop()
|
||||
} catch (_) {}
|
||||
}
|
||||
currentLoop.value = null
|
||||
desiredSrc = ''
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
/* hook 卸载时不主动 stop,因为用户离开节拍器页时
|
||||
仍希望音乐持续(走在路上拿出手机切别的页面应该不停)
|
||||
真正停止的责任在 metronome.vue 的退出按钮里 */
|
||||
onUnmounted(() => {
|
||||
/* 仅解绑回调? BgAudio 是全局单例,我们的回调还在,
|
||||
不解会导致内存中保留无用引用,但回调里都判断了 isPlaying,
|
||||
且新页面再 ensureBgm 时会覆盖回调,可接受 */
|
||||
})
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentLoop,
|
||||
playLoop,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
LOOP_PRESETS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const BGM_BASE = '/training/static/bgm'
|
||||
|
||||
export type BgmMode = 'none' | 'training' | 'resting'
|
||||
|
||||
export interface UseTrainingBgmOptions {
|
||||
trainingSrc?: string
|
||||
restingSrc?: string
|
||||
volume?: number
|
||||
fadeMs?: number
|
||||
}
|
||||
|
||||
export function useTrainingBgm(options: UseTrainingBgmOptions = {}) {
|
||||
const {
|
||||
trainingSrc = `${BGM_BASE}/train-light.mp3`,
|
||||
restingSrc = `${BGM_BASE}/rest-meditation.mp3`,
|
||||
volume = 0.6,
|
||||
fadeMs = 600,
|
||||
} = options
|
||||
|
||||
const mode = ref<BgmMode>('none')
|
||||
const enabled = ref<boolean>(true)
|
||||
|
||||
let trainCtx: UniApp.InnerAudioContext | null = null
|
||||
let restCtx: UniApp.InnerAudioContext | null = null
|
||||
let fadeTimers: ReturnType<typeof setInterval>[] = []
|
||||
|
||||
const ensureCtx = () => {
|
||||
if (!trainCtx) {
|
||||
trainCtx = uni.createInnerAudioContext()
|
||||
trainCtx.src = trainingSrc
|
||||
trainCtx.loop = true
|
||||
trainCtx.obeyMuteSwitch = false
|
||||
trainCtx.volume = 0
|
||||
}
|
||||
if (!restCtx) {
|
||||
restCtx = uni.createInnerAudioContext()
|
||||
restCtx.src = restingSrc
|
||||
restCtx.loop = true
|
||||
restCtx.obeyMuteSwitch = false
|
||||
restCtx.volume = 0
|
||||
}
|
||||
}
|
||||
|
||||
const fade = (
|
||||
ctx: UniApp.InnerAudioContext,
|
||||
from: number,
|
||||
to: number,
|
||||
duration = fadeMs,
|
||||
) => {
|
||||
const steps = 16
|
||||
const stepMs = Math.max(16, duration / steps)
|
||||
let i = 0
|
||||
const t = setInterval(() => {
|
||||
i++
|
||||
const v = from + (to - from) * (i / steps)
|
||||
ctx.volume = Math.max(0, Math.min(1, v))
|
||||
if (i >= steps) {
|
||||
clearInterval(t)
|
||||
fadeTimers = fadeTimers.filter((x) => x !== t)
|
||||
}
|
||||
}, stepMs)
|
||||
fadeTimers.push(t)
|
||||
}
|
||||
|
||||
const switchTo = (target: BgmMode) => {
|
||||
if (!enabled.value) return
|
||||
if (mode.value === target) return
|
||||
ensureCtx()
|
||||
|
||||
if (target === 'training' && trainCtx && restCtx) {
|
||||
fade(restCtx, restCtx.volume, 0)
|
||||
setTimeout(() => restCtx?.pause(), fadeMs)
|
||||
trainCtx.play()
|
||||
fade(trainCtx, 0, volume)
|
||||
} else if (target === 'resting' && trainCtx && restCtx) {
|
||||
fade(trainCtx, trainCtx.volume, 0)
|
||||
setTimeout(() => trainCtx?.pause(), fadeMs)
|
||||
restCtx.play()
|
||||
fade(restCtx, 0, volume)
|
||||
} else if (target === 'none') {
|
||||
if (trainCtx) {
|
||||
fade(trainCtx, trainCtx.volume, 0)
|
||||
setTimeout(() => trainCtx?.pause(), fadeMs)
|
||||
}
|
||||
if (restCtx) {
|
||||
fade(restCtx, restCtx.volume, 0)
|
||||
setTimeout(() => restCtx?.pause(), fadeMs)
|
||||
}
|
||||
}
|
||||
mode.value = target
|
||||
}
|
||||
|
||||
const setEnabled = (v: boolean) => {
|
||||
enabled.value = v
|
||||
if (!v) switchTo('none')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
fadeTimers.forEach((t) => clearInterval(t))
|
||||
fadeTimers = []
|
||||
trainCtx?.destroy?.()
|
||||
restCtx?.destroy?.()
|
||||
trainCtx = null
|
||||
restCtx = null
|
||||
})
|
||||
|
||||
return {
|
||||
mode,
|
||||
enabled,
|
||||
switchTo,
|
||||
setEnabled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
|
||||
|
||||
export interface TrainingSessionConfig {
|
||||
sets: number
|
||||
reps: number
|
||||
restSec: number
|
||||
beatsPerRep?: number
|
||||
onRepComplete?: (currentRep: number, totalReps: number) => void
|
||||
onSetComplete?: (currentSet: number, totalSets: number) => void
|
||||
onEnterRest?: (restSec: number) => void
|
||||
onExitRest?: () => void
|
||||
onDone?: () => void
|
||||
onCountdownTick?: (remainingSec: number) => void
|
||||
}
|
||||
|
||||
export function useTrainingSession() {
|
||||
const phase = ref<SessionPhase>('idle')
|
||||
const currentSet = ref<number>(0)
|
||||
const currentRep = ref<number>(0)
|
||||
const restRemainingSec = ref<number>(0)
|
||||
const beatCounter = ref<number>(0)
|
||||
|
||||
let config: TrainingSessionConfig | null = null
|
||||
let restTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const totalReps = computed(() => config?.reps ?? 0)
|
||||
const totalSets = computed(() => config?.sets ?? 0)
|
||||
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
|
||||
|
||||
const isTraining = computed(() => phase.value === 'training')
|
||||
const isResting = computed(() => phase.value === 'resting')
|
||||
const isDone = computed(() => phase.value === 'done')
|
||||
|
||||
const start = (cfg: TrainingSessionConfig) => {
|
||||
config = cfg
|
||||
phase.value = 'training'
|
||||
currentSet.value = 1
|
||||
currentRep.value = 0
|
||||
beatCounter.value = 0
|
||||
restRemainingSec.value = 0
|
||||
}
|
||||
|
||||
const onBeat = () => {
|
||||
if (phase.value !== 'training' || !config) return
|
||||
|
||||
beatCounter.value++
|
||||
|
||||
if (beatCounter.value % beatsPerRep.value !== 0) return
|
||||
|
||||
currentRep.value++
|
||||
config.onRepComplete?.(currentRep.value, totalReps.value)
|
||||
|
||||
if (currentRep.value >= totalReps.value) {
|
||||
config.onSetComplete?.(currentSet.value, totalSets.value)
|
||||
|
||||
if (currentSet.value >= totalSets.value) {
|
||||
phase.value = 'done'
|
||||
config.onDone?.()
|
||||
return
|
||||
}
|
||||
|
||||
enterRest()
|
||||
}
|
||||
}
|
||||
|
||||
const enterRest = () => {
|
||||
if (!config) return
|
||||
phase.value = 'resting'
|
||||
restRemainingSec.value = config.restSec
|
||||
config.onEnterRest?.(config.restSec)
|
||||
|
||||
restTimer = setInterval(() => {
|
||||
restRemainingSec.value--
|
||||
config?.onCountdownTick?.(restRemainingSec.value)
|
||||
if (restRemainingSec.value <= 0) {
|
||||
exitRest()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const exitRest = () => {
|
||||
if (restTimer) {
|
||||
clearInterval(restTimer)
|
||||
restTimer = null
|
||||
}
|
||||
if (!config) return
|
||||
|
||||
currentSet.value++
|
||||
currentRep.value = 0
|
||||
beatCounter.value = 0
|
||||
phase.value = 'training'
|
||||
config.onExitRest?.()
|
||||
}
|
||||
|
||||
const skipRest = () => {
|
||||
if (phase.value !== 'resting') return
|
||||
exitRest()
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (restTimer) {
|
||||
clearInterval(restTimer)
|
||||
restTimer = null
|
||||
}
|
||||
phase.value = 'idle'
|
||||
currentSet.value = 0
|
||||
currentRep.value = 0
|
||||
beatCounter.value = 0
|
||||
restRemainingSec.value = 0
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (restTimer) clearInterval(restTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
phase,
|
||||
currentSet,
|
||||
currentRep,
|
||||
restRemainingSec,
|
||||
totalReps,
|
||||
totalSets,
|
||||
isTraining,
|
||||
isResting,
|
||||
isDone,
|
||||
start,
|
||||
stop,
|
||||
skipRest,
|
||||
onBeat,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
const VOICE_BASE = '/training/static/voice'
|
||||
|
||||
export type VoicePromptKey =
|
||||
| 'start'
|
||||
| 'ready'
|
||||
| 'rest'
|
||||
| 'next-set'
|
||||
| 'last-rep'
|
||||
| 'keep-it-up'
|
||||
| 'good-job'
|
||||
| 'breathe-in'
|
||||
| 'breathe-out'
|
||||
|
||||
export interface UseVoiceCoachOptions {
|
||||
enabled?: boolean
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export function useVoiceCoach(options: UseVoiceCoachOptions = {}) {
|
||||
const { enabled = true, volume = 1 } = options
|
||||
|
||||
let ctx: UniApp.InnerAudioContext | null = null
|
||||
let queue: string[] = []
|
||||
let isPlayingQueue = false
|
||||
|
||||
const ensureCtx = () => {
|
||||
if (ctx) return
|
||||
ctx = uni.createInnerAudioContext()
|
||||
ctx.volume = volume
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.onEnded(() => {
|
||||
playNextInQueue()
|
||||
})
|
||||
ctx.onError(() => {
|
||||
playNextInQueue()
|
||||
})
|
||||
}
|
||||
|
||||
const playNextInQueue = () => {
|
||||
if (!ctx || queue.length === 0) {
|
||||
isPlayingQueue = false
|
||||
return
|
||||
}
|
||||
const nextSrc = queue.shift()!
|
||||
ctx.src = nextSrc
|
||||
ctx.play()
|
||||
}
|
||||
|
||||
const enqueue = (src: string) => {
|
||||
if (!enabled) return
|
||||
ensureCtx()
|
||||
queue.push(src)
|
||||
if (!isPlayingQueue) {
|
||||
isPlayingQueue = true
|
||||
playNextInQueue()
|
||||
}
|
||||
}
|
||||
|
||||
const speakNumber = (n: number) => {
|
||||
if (n < 1 || n > 30) return
|
||||
enqueue(`${VOICE_BASE}/numbers/${n}.mp3`)
|
||||
}
|
||||
|
||||
const speakPrompt = (key: VoicePromptKey) => {
|
||||
enqueue(`${VOICE_BASE}/prompts/${key}.mp3`)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
queue = []
|
||||
isPlayingQueue = false
|
||||
ctx?.stop()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
ctx?.destroy?.()
|
||||
ctx = null
|
||||
})
|
||||
|
||||
return {
|
||||
speakNumber,
|
||||
speakPrompt,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
|
||||
<view class="stage">
|
||||
<!-- 哑铃弯举:摆臂 -->
|
||||
<view v-if="animationType === 'curl'" class="curl-arm">
|
||||
<view class="curl-forearm">
|
||||
<view class="curl-dumbbell">{{ icon }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 哑铃推举:上下移动 -->
|
||||
<view v-else-if="animationType === 'press'" class="press-wrap">
|
||||
<view class="press-dumbbell left">{{ icon }}</view>
|
||||
<view class="press-dumbbell right">{{ icon }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 哑铃侧平举:双臂张开 -->
|
||||
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
|
||||
<view class="sidefly-arm sidefly-left">
|
||||
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||
</view>
|
||||
<view class="sidefly-arm sidefly-right">
|
||||
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 握力环:缩放 -->
|
||||
<view v-else-if="animationType === 'grip'" class="grip-ring">
|
||||
<view class="grip-ring-inner">{{ icon }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 卷腹:身体折叠 -->
|
||||
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
|
||||
<view class="crunch-body">{{ icon }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AnimationType } from '../exercises'
|
||||
|
||||
interface Props {
|
||||
animationType: AnimationType
|
||||
bpm: number
|
||||
beatsPerRep?: number
|
||||
icon?: string
|
||||
isPlaying?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
beatsPerRep: 2,
|
||||
icon: '🏋',
|
||||
isPlaying: false,
|
||||
})
|
||||
|
||||
const cssVars = computed(() => {
|
||||
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
|
||||
return {
|
||||
'--rep-duration': `${repDurationMs}ms`,
|
||||
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
||||
} as Record<string, string>
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exercise-anim {
|
||||
width: 100%;
|
||||
height: 480rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage {
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== 弯举 ===== */
|
||||
.curl-arm {
|
||||
width: 60rpx;
|
||||
height: 240rpx;
|
||||
background: #fbbf24;
|
||||
border-radius: 30rpx;
|
||||
position: relative;
|
||||
|
||||
.curl-forearm {
|
||||
width: 60rpx;
|
||||
height: 200rpx;
|
||||
background: #f59e0b;
|
||||
border-radius: 30rpx;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform-origin: bottom center;
|
||||
animation: curl-anim var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
}
|
||||
|
||||
.curl-dumbbell {
|
||||
position: absolute;
|
||||
top: -20rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 56rpx;
|
||||
}
|
||||
}
|
||||
@keyframes curl-anim {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-115deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 推举 ===== */
|
||||
.press-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
.press-dumbbell {
|
||||
font-size: 88rpx;
|
||||
animation: press-anim var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
}
|
||||
@keyframes press-anim {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(80rpx);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-80rpx);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 侧平举 ===== */
|
||||
.sidefly-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sidefly-arm {
|
||||
position: absolute;
|
||||
width: 140rpx;
|
||||
height: 24rpx;
|
||||
background: #f59e0b;
|
||||
border-radius: 12rpx;
|
||||
top: 50%;
|
||||
transform-origin: center right;
|
||||
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
|
||||
&.sidefly-left {
|
||||
right: 50%;
|
||||
}
|
||||
&.sidefly-right {
|
||||
left: 50%;
|
||||
transform-origin: center left;
|
||||
animation-name: sidefly-right;
|
||||
}
|
||||
}
|
||||
.sidefly-dumbbell {
|
||||
position: absolute;
|
||||
top: -36rpx;
|
||||
font-size: 56rpx;
|
||||
}
|
||||
.sidefly-left .sidefly-dumbbell {
|
||||
left: -20rpx;
|
||||
}
|
||||
.sidefly-right .sidefly-dumbbell {
|
||||
right: -20rpx;
|
||||
}
|
||||
@keyframes sidefly-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(80deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
@keyframes sidefly-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-80deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 握力环 ===== */
|
||||
.grip-ring {
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
animation: grip-anim var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
border: 16rpx solid #22c55e;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
.grip-ring-inner {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
@keyframes grip-anim {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.65);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 卷腹 ===== */
|
||||
.crunch-wrap {
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
.crunch-body {
|
||||
font-size: 120rpx;
|
||||
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
|
||||
animation-play-state: var(--anim-state);
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
@keyframes crunch-anim {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(1) translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(0.6) translateY(-10rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
export type AnimationType =
|
||||
| 'curl'
|
||||
| 'press'
|
||||
| 'sidefly'
|
||||
| 'grip'
|
||||
| 'crunch'
|
||||
|
||||
export interface ExercisePreset {
|
||||
id: string
|
||||
name: string
|
||||
equipment: '哑铃' | '握力环' | '健身环' | '徒手'
|
||||
icon: string
|
||||
animationType: AnimationType
|
||||
description: string
|
||||
tips: string[]
|
||||
contraindication?: string
|
||||
|
||||
defaultBpm: number
|
||||
bpmMin: number
|
||||
bpmMax: number
|
||||
defaultSets: number
|
||||
defaultReps: number
|
||||
defaultRestSec: number
|
||||
|
||||
beatsPerRep: number
|
||||
}
|
||||
|
||||
export const EXERCISE_PRESETS: ExercisePreset[] = [
|
||||
{
|
||||
id: 'dumbbell-curl',
|
||||
name: '哑铃弯举',
|
||||
equipment: '哑铃',
|
||||
icon: '🏋',
|
||||
animationType: 'curl',
|
||||
description: '锻炼肱二头肌,注意肘部固定不动',
|
||||
tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'],
|
||||
contraindication: '肘关节炎症急性期不宜',
|
||||
defaultBpm: 60,
|
||||
bpmMin: 40,
|
||||
bpmMax: 100,
|
||||
defaultSets: 3,
|
||||
defaultReps: 12,
|
||||
defaultRestSec: 60,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
{
|
||||
id: 'dumbbell-press',
|
||||
name: '哑铃推举',
|
||||
equipment: '哑铃',
|
||||
icon: '🏋',
|
||||
animationType: 'press',
|
||||
description: '锻炼肩部三角肌,核心保持收紧',
|
||||
tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'],
|
||||
contraindication: '肩袖损伤者请咨询医生',
|
||||
defaultBpm: 60,
|
||||
bpmMin: 40,
|
||||
bpmMax: 90,
|
||||
defaultSets: 3,
|
||||
defaultReps: 10,
|
||||
defaultRestSec: 90,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
{
|
||||
id: 'dumbbell-sidefly',
|
||||
name: '哑铃侧平举',
|
||||
equipment: '哑铃',
|
||||
icon: '🏋',
|
||||
animationType: 'sidefly',
|
||||
description: '锻炼三角肌中束,重量宜轻不宜重',
|
||||
tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'],
|
||||
defaultBpm: 60,
|
||||
bpmMin: 40,
|
||||
bpmMax: 80,
|
||||
defaultSets: 3,
|
||||
defaultReps: 12,
|
||||
defaultRestSec: 60,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
{
|
||||
id: 'grip-ring',
|
||||
name: '握力环训练',
|
||||
equipment: '握力环',
|
||||
icon: '🟢',
|
||||
animationType: 'grip',
|
||||
description: '锻炼前臂屈肌群,改善握力',
|
||||
tips: ['完全握紧停顿 1 秒', '完全张开放松', '左右手交替'],
|
||||
defaultBpm: 80,
|
||||
bpmMin: 60,
|
||||
bpmMax: 120,
|
||||
defaultSets: 4,
|
||||
defaultReps: 20,
|
||||
defaultRestSec: 45,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
{
|
||||
id: 'crunch',
|
||||
name: '集中机(仰卧卷腹)',
|
||||
equipment: '健身环',
|
||||
icon: '💪',
|
||||
animationType: 'crunch',
|
||||
description: '锻炼腹直肌,借助健身环增加阻力',
|
||||
tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'],
|
||||
contraindication: '腰椎间盘突出急性期不宜',
|
||||
defaultBpm: 50,
|
||||
bpmMin: 40,
|
||||
bpmMax: 80,
|
||||
defaultSets: 3,
|
||||
defaultReps: 15,
|
||||
defaultRestSec: 60,
|
||||
beatsPerRep: 2,
|
||||
},
|
||||
]
|
||||
|
||||
export function getPresetById(id: string): ExercisePreset | undefined {
|
||||
return EXERCISE_PRESETS.find((e) => e.id === id)
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
<template>
|
||||
<view class="training-page">
|
||||
<view class="header">
|
||||
<view class="header-main">
|
||||
<text class="title">练一练</text>
|
||||
<text class="subtitle">器械动作跟练</text>
|
||||
</view>
|
||||
<view class="header-link" @click="goMetronome">
|
||||
🎵 节拍器
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="equipment-tabs">
|
||||
<view
|
||||
v-for="eq in equipmentList"
|
||||
:key="eq"
|
||||
class="equipment-tab"
|
||||
:class="{ active: currentEquipment === eq }"
|
||||
@click="onSwitchEquipment(eq)"
|
||||
>
|
||||
{{ eq }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-x class="exercise-scroll">
|
||||
<view class="exercise-list">
|
||||
<view
|
||||
v-for="ex in filteredExercises"
|
||||
:key="ex.id"
|
||||
class="exercise-card"
|
||||
:class="{ active: selectedId === ex.id }"
|
||||
@click="onSelect(ex.id)"
|
||||
>
|
||||
<text class="exercise-icon">{{ ex.icon }}</text>
|
||||
<text class="exercise-name">{{ ex.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="selected" class="exercise-detail">
|
||||
<exercise-anim
|
||||
:animation-type="selected.animationType"
|
||||
:bpm="bpm"
|
||||
:beats-per-rep="selected.beatsPerRep"
|
||||
:icon="selected.icon"
|
||||
:is-playing="metronomeIsPlaying"
|
||||
/>
|
||||
|
||||
<view class="beat-indicator">
|
||||
<view
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="beat-dot"
|
||||
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ selected.description }}</text>
|
||||
</view>
|
||||
|
||||
<view class="settings">
|
||||
<view class="setting-row">
|
||||
<text class="setting-label">节奏</text>
|
||||
<slider
|
||||
:value="bpm"
|
||||
:min="selected.bpmMin"
|
||||
:max="selected.bpmMax"
|
||||
:step="2"
|
||||
block-size="24"
|
||||
active-color="#22c55e"
|
||||
@change="onBpmChange"
|
||||
/>
|
||||
<text class="setting-value">{{ bpm }} BPM</text>
|
||||
</view>
|
||||
|
||||
<view class="setting-row inline">
|
||||
<view class="inline-item">
|
||||
<text class="setting-label">组数</text>
|
||||
<view class="stepper">
|
||||
<text class="step-btn" @click="adjust('sets', -1)">-</text>
|
||||
<text class="step-val">{{ sets }}</text>
|
||||
<text class="step-btn" @click="adjust('sets', 1)">+</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="inline-item">
|
||||
<text class="setting-label">次数</text>
|
||||
<view class="stepper">
|
||||
<text class="step-btn" @click="adjust('reps', -1)">-</text>
|
||||
<text class="step-val">{{ reps }}</text>
|
||||
<text class="step-btn" @click="adjust('reps', 1)">+</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="inline-item">
|
||||
<text class="setting-label">休息(秒)</text>
|
||||
<view class="stepper">
|
||||
<text class="step-btn" @click="adjust('rest', -15)">-</text>
|
||||
<text class="step-val">{{ restSec }}</text>
|
||||
<text class="step-btn" @click="adjust('rest', 15)">+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
|
||||
<button class="btn-primary" @click="onStart">开始训练</button>
|
||||
</view>
|
||||
|
||||
<view v-if="isTraining" class="status-card training">
|
||||
<text class="status-title">训练中</text>
|
||||
<text class="status-line">第 {{ currentSet }} / {{ totalSets }} 组</text>
|
||||
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
|
||||
<view class="action-row">
|
||||
<button class="btn-secondary" @click="onStop">停止</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isResting" class="status-card resting">
|
||||
<text class="status-title">休息中</text>
|
||||
<text class="status-line big">{{ restRemainingSec }}s</text>
|
||||
<view class="action-row">
|
||||
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
|
||||
<button class="btn-text" @click="onStop">结束训练</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isDone" class="status-card done">
|
||||
<text class="status-title">训练完成 🎉</text>
|
||||
<text class="status-line">共 {{ totalSets }} 组 × {{ totalReps }} 次</text>
|
||||
<view class="action-row">
|
||||
<button class="btn-primary" @click="onStart">再来一次</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tips-card">
|
||||
<text class="tips-title">动作要领</text>
|
||||
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
|
||||
• {{ tip }}
|
||||
</text>
|
||||
<text v-if="selected.contraindication" class="tips-warn">
|
||||
⚠ {{ selected.contraindication }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||
import ExerciseAnim from './components/exercise-anim.vue'
|
||||
import { EXERCISE_PRESETS, getPresetById } from './exercises'
|
||||
import type { ExercisePreset } from './exercises'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
import { useTrainingSession } from '../hooks/useTrainingSession'
|
||||
import { useVoiceCoach } from '../hooks/useVoiceCoach'
|
||||
import { useTrainingBgm } from '../hooks/useTrainingBgm'
|
||||
|
||||
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
|
||||
type Equipment = (typeof equipmentList)[number]
|
||||
|
||||
const currentEquipment = ref<Equipment>('哑铃')
|
||||
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
|
||||
|
||||
const filteredExercises = computed(() =>
|
||||
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
|
||||
)
|
||||
|
||||
const selected = computed<ExercisePreset | undefined>(() =>
|
||||
getPresetById(selectedId.value),
|
||||
)
|
||||
|
||||
const bpm = ref<number>(60)
|
||||
const sets = ref<number>(3)
|
||||
const reps = ref<number>(12)
|
||||
const restSec = ref<number>(60)
|
||||
|
||||
watch(
|
||||
selected,
|
||||
(val) => {
|
||||
if (!val) return
|
||||
bpm.value = val.defaultBpm
|
||||
sets.value = val.defaultSets
|
||||
reps.value = val.defaultReps
|
||||
restSec.value = val.defaultRestSec
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const voice = useVoiceCoach()
|
||||
const bgm = useTrainingBgm()
|
||||
|
||||
const session = useTrainingSession()
|
||||
const {
|
||||
currentSet,
|
||||
currentRep,
|
||||
restRemainingSec,
|
||||
totalReps,
|
||||
totalSets,
|
||||
isTraining,
|
||||
isResting,
|
||||
isDone,
|
||||
} = session
|
||||
|
||||
const metronome = useMetronome({
|
||||
initialBpm: bpm.value,
|
||||
onBeat: () => session.onBeat(),
|
||||
})
|
||||
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
|
||||
|
||||
watch(bpm, (v) => metronome.setBpm(v))
|
||||
|
||||
const onSwitchEquipment = (eq: Equipment) => {
|
||||
if (isTraining.value || isResting.value) return
|
||||
currentEquipment.value = eq
|
||||
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
|
||||
if (first) selectedId.value = first.id
|
||||
}
|
||||
|
||||
const onSelect = (id: string) => {
|
||||
if (isTraining.value || isResting.value) return
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
const onBpmChange = (e: any) => {
|
||||
const v = e.detail?.value ?? e
|
||||
bpm.value = Number(v)
|
||||
}
|
||||
|
||||
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
|
||||
if (isTraining.value || isResting.value) return
|
||||
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
|
||||
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
|
||||
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
|
||||
}
|
||||
|
||||
const onStart = () => {
|
||||
if (!selected.value) return
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
|
||||
voice.speakPrompt('start')
|
||||
|
||||
session.start({
|
||||
sets: sets.value,
|
||||
reps: reps.value,
|
||||
restSec: restSec.value,
|
||||
beatsPerRep: selected.value.beatsPerRep,
|
||||
onRepComplete: (rep, total) => {
|
||||
if (rep <= 30) voice.speakNumber(rep)
|
||||
if (rep === total - 1) voice.speakPrompt('last-rep')
|
||||
},
|
||||
onSetComplete: (set, total) => {
|
||||
if (set < total) voice.speakPrompt('keep-it-up')
|
||||
},
|
||||
onEnterRest: () => {
|
||||
metronome.stop()
|
||||
voice.speakPrompt('rest')
|
||||
bgm.switchTo('resting')
|
||||
},
|
||||
onExitRest: () => {
|
||||
voice.speakPrompt('next-set')
|
||||
bgm.switchTo('training')
|
||||
metronome.start()
|
||||
},
|
||||
onDone: () => {
|
||||
metronome.stop()
|
||||
bgm.switchTo('none')
|
||||
voice.speakPrompt('good-job')
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
},
|
||||
})
|
||||
|
||||
bgm.switchTo('training')
|
||||
metronome.start()
|
||||
}
|
||||
|
||||
const onStop = () => {
|
||||
metronome.stop()
|
||||
session.stop()
|
||||
bgm.switchTo('none')
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
|
||||
const onSkipRest = () => {
|
||||
session.skipRest()
|
||||
}
|
||||
|
||||
const goMetronome = () => {
|
||||
uni.navigateTo({ url: '/training/pages/metronome' })
|
||||
}
|
||||
|
||||
onHide(() => {
|
||||
if (isTraining.value || isResting.value) {
|
||||
onStop()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.training-page {
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
padding: 32rpx 24rpx 80rpx;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
padding: 12rpx 24rpx;
|
||||
background: #f3f4f6;
|
||||
border-radius: 24rpx;
|
||||
font-size: 24rpx;
|
||||
color: #4b5563;
|
||||
|
||||
&:active {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-tabs {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
overflow-x: auto;
|
||||
|
||||
.equipment-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 14rpx 32rpx;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
font-size: 28rpx;
|
||||
color: #4b5563;
|
||||
border: 2rpx solid transparent;
|
||||
|
||||
&.active {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.exercise-scroll {
|
||||
width: 100%;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.exercise-list {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding-right: 16rpx;
|
||||
}
|
||||
|
||||
.exercise-card {
|
||||
flex-shrink: 0;
|
||||
width: 160rpx;
|
||||
padding: 20rpx 12rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
border: 2rpx solid transparent;
|
||||
|
||||
&.active {
|
||||
border-color: #22c55e;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.exercise-icon {
|
||||
display: block;
|
||||
font-size: 56rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.exercise-name {
|
||||
font-size: 24rpx;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.exercise-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.beat-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 8rpx 0;
|
||||
|
||||
.beat-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
|
||||
&.active {
|
||||
background: #22c55e;
|
||||
transform: scale(1.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
.meta-label {
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.settings {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
|
||||
.setting-label {
|
||||
font-size: 26rpx;
|
||||
color: #4b5563;
|
||||
min-width: 80rpx;
|
||||
}
|
||||
.setting-value {
|
||||
font-size: 26rpx;
|
||||
color: #22c55e;
|
||||
font-weight: 600;
|
||||
min-width: 120rpx;
|
||||
text-align: right;
|
||||
}
|
||||
slider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
justify-content: space-between;
|
||||
gap: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
.step-btn {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
line-height: 48rpx;
|
||||
text-align: center;
|
||||
background: #f3f4f6;
|
||||
border-radius: 24rpx;
|
||||
font-size: 32rpx;
|
||||
color: #4b5563;
|
||||
}
|
||||
.step-val {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
min-width: 56rpx;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
max-width: 320rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-radius: 999rpx;
|
||||
font-size: 30rpx;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #fff;
|
||||
color: #22c55e;
|
||||
border: 2rpx solid #22c55e;
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
height: 80rpx;
|
||||
line-height: 76rpx;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
font-size: 26rpx;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
align-items: center;
|
||||
|
||||
&.training {
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||
}
|
||||
&.resting {
|
||||
background: linear-gradient(135deg, #fef3c7, #fef9c3);
|
||||
}
|
||||
&.done {
|
||||
background: linear-gradient(135deg, #dbeafe, #ede9fe);
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 30rpx;
|
||||
color: #4b5563;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-line {
|
||||
font-size: 28rpx;
|
||||
color: #6b7280;
|
||||
&.big {
|
||||
font-size: 64rpx;
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tips-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
|
||||
.tips-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.tips-item {
|
||||
font-size: 26rpx;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.tips-warn {
|
||||
font-size: 26rpx;
|
||||
color: #ef4444;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,735 @@
|
||||
<template>
|
||||
<view class="page" :style="rootStyle">
|
||||
<!-- ===== 主舞台:节拍器圆 ===== -->
|
||||
<view class="stage" @click="onCenterTap">
|
||||
<view class="ring r1" :class="{ playing: isPlaying }" />
|
||||
<view class="ring r2" :class="{ playing: isPlaying }" />
|
||||
<view class="core" :class="{ playing: isPlaying }">
|
||||
<text class="bpm-num">{{ currentBpm }}</text>
|
||||
<text class="bpm-label">BPM</text>
|
||||
<view class="ctrl">
|
||||
<view v-if="isPlaying" class="icon-pause">
|
||||
<view class="bar" />
|
||||
<view class="bar" />
|
||||
</view>
|
||||
<view v-else class="icon-play" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 跑步小人(矩形完整显示,跟白底融合) ===== -->
|
||||
<view class="walker">
|
||||
<view class="walker-box">
|
||||
<video
|
||||
id="runner-video"
|
||||
class="runner-video"
|
||||
:src="RUNNER_VIDEO_URL"
|
||||
:muted="true"
|
||||
:loop="true"
|
||||
:autoplay="true"
|
||||
:show-controls="false"
|
||||
:show-fullscreen-btn="false"
|
||||
:show-play-btn="false"
|
||||
:show-center-play-btn="false"
|
||||
:enable-progress-gesture="false"
|
||||
:show-mute-btn="false"
|
||||
:picture-in-picture-mode="[]"
|
||||
object-fit="contain"
|
||||
@error="onVideoError"
|
||||
@loadedmetadata="onVideoLoaded"
|
||||
/>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<cover-view class="pip-mask"> </cover-view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 推荐档位(3 选 1) ===== -->
|
||||
<view class="presets">
|
||||
<view
|
||||
v-for="p in LOOP_PRESETS"
|
||||
:key="p.id"
|
||||
class="preset"
|
||||
:class="{ active: mode === 'preset' && currentLoop === p.id }"
|
||||
@click="onPresetTap(p.id)"
|
||||
>
|
||||
<text class="preset-icon">{{ presetIcon(p.id) }}</text>
|
||||
<text class="preset-name">{{ presetName(p.id) }}</text>
|
||||
<text class="preset-bpm">{{ p.bpm }} BPM</text>
|
||||
<text class="preset-desc">{{ presetDesc(p.id) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 自定义节奏(高级折叠区) ===== -->
|
||||
<view v-if="customExpanded" class="custom-panel">
|
||||
<view class="custom-warn">
|
||||
<text class="warn-dot">●</text>
|
||||
<text class="warn-text">前台模式 · 切后台会暂停</text>
|
||||
</view>
|
||||
|
||||
<!-- BPM 微调 -->
|
||||
<view class="row">
|
||||
<text class="row-label">BPM</text>
|
||||
<view class="bpm-stepper">
|
||||
<view class="step-btn" @click="onBpmDelta(-5)">−5</view>
|
||||
<view class="step-btn" @click="onBpmDelta(-1)">−1</view>
|
||||
<view class="step-val">{{ customBpm }}</view>
|
||||
<view class="step-btn" @click="onBpmDelta(1)">+1</view>
|
||||
<view class="step-btn" @click="onBpmDelta(5)">+5</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 拍号 -->
|
||||
<view class="row">
|
||||
<text class="row-label">拍号</text>
|
||||
<view class="meter-tabs">
|
||||
<view
|
||||
v-for="n in [2, 3, 4]"
|
||||
:key="n"
|
||||
class="meter-tab"
|
||||
:class="{ active: customAccent === n }"
|
||||
@click="onAccentSelect(n)"
|
||||
>{{ n }}/4</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部行:自定义入口 + 提示 -->
|
||||
<view class="bottom-row">
|
||||
<text class="bottom-link" @click="onToggleCustom">
|
||||
{{ customExpanded ? '✕ 收起自定义' : '⚙ 自定义节奏' }}
|
||||
</text>
|
||||
<text class="bottom-hint" v-if="mode === 'preset'">🔒 锁屏可继续播放</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { useMetronomeBg, type LoopId } from '../hooks/useMetronomeBg'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
|
||||
/**
|
||||
* 跑步动画视频(腾讯云 COS CDN)
|
||||
* 注意:小程序后台需将 cos.ap-guangzhou.myqcloud.com 加入 downloadFile 合法域名,
|
||||
* 否则正式发布版会被拦截(开发版默认不校验)
|
||||
*/
|
||||
const RUNNER_VIDEO_URL =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/video/20260525/20260525164014ef89f8862.mp4'
|
||||
|
||||
/** 视频原速对应的 BPM 基准, playback-rate 范围 [0.5, 2.0] */
|
||||
const VIDEO_BASE_BPM = 124
|
||||
|
||||
/* ============================================================
|
||||
* 双引擎设计
|
||||
* ------------------------------------------------------------
|
||||
* - mode='preset': 用 BgAudio 播预合成 mp3,支持后台/锁屏(走路场景)
|
||||
* - mode='custom': 用 InnerAudio 实时合成,支持任意 BPM/拍号,但仅前台
|
||||
* - 切模式时停掉对侧引擎,避免双声道叠加
|
||||
* ============================================================ */
|
||||
type Mode = 'preset' | 'custom'
|
||||
const mode = ref<Mode>('preset')
|
||||
const customExpanded = ref<boolean>(false)
|
||||
|
||||
/* click-wood.mp3 (CDN), 木鱼质感的敲击音 */
|
||||
const CLICK_WOOD_URL =
|
||||
'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file/20260526/202605261051282a0a94508.mp3'
|
||||
|
||||
const bg = useMetronomeBg()
|
||||
const fg = useMetronome({
|
||||
initialBpm: 100,
|
||||
accentEvery: 2,
|
||||
clickSrc: CLICK_WOOD_URL,
|
||||
accentSrc: CLICK_WOOD_URL,
|
||||
poolSize: 4,
|
||||
})
|
||||
|
||||
const { LOOP_PRESETS } = bg
|
||||
const currentLoop = bg.currentLoop
|
||||
|
||||
const customBpm = computed(() => fg.bpm.value)
|
||||
const customAccent = computed(() => fg.accentEvery.value)
|
||||
|
||||
/* 统一对外暴露的 isPlaying / currentBpm,根据 mode 选择信号源 */
|
||||
const isPlaying = computed(() =>
|
||||
mode.value === 'preset' ? bg.isPlaying.value : fg.isPlaying.value,
|
||||
)
|
||||
|
||||
const currentBpm = computed(() => {
|
||||
if (mode.value === 'preset') {
|
||||
const p = LOOP_PRESETS.find((p) => p.id === currentLoop.value)
|
||||
return p?.bpm ?? 110
|
||||
}
|
||||
return fg.bpm.value
|
||||
})
|
||||
|
||||
const intervalMs = computed(() => 60000 / currentBpm.value)
|
||||
|
||||
/* ============================================================
|
||||
* 档位元数据
|
||||
* ============================================================ */
|
||||
const presetIcon = (id: LoopId) =>
|
||||
id === 'slow' ? '🚶' : id === 'normal' ? '🏃♀️' : '🏃'
|
||||
const presetName = (id: LoopId) =>
|
||||
id === 'slow' ? '慢走' : id === 'normal' ? '健走' : '快走'
|
||||
const presetDesc = (id: LoopId) =>
|
||||
id === 'slow' ? '热身 · 恢复' : id === 'normal' ? '日常 · 通勤' : '提速 · 燃脂'
|
||||
|
||||
/* ============================================================
|
||||
* 中央圆按钮:在两个模式下分别工作
|
||||
* ============================================================ */
|
||||
const onCenterTap = () => {
|
||||
if (mode.value === 'preset') {
|
||||
if (!currentLoop.value) {
|
||||
bg.playLoop('normal')
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
return
|
||||
}
|
||||
if (bg.isPlaying.value) {
|
||||
bg.pause()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
} else {
|
||||
bg.resume()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
} else {
|
||||
/* custom 模式 */
|
||||
if (fg.isPlaying.value) {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
} else {
|
||||
fg.start()
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 档位卡片:点了 → 切回 preset 模式 + 停掉 custom
|
||||
* ============================================================ */
|
||||
const onPresetTap = (id: LoopId) => {
|
||||
if (mode.value === 'custom') {
|
||||
fg.stop()
|
||||
mode.value = 'preset'
|
||||
customExpanded.value = false
|
||||
}
|
||||
bg.playLoop(id)
|
||||
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义折叠区切换
|
||||
* ============================================================ */
|
||||
const onToggleCustom = () => {
|
||||
if (!customExpanded.value) {
|
||||
/* 展开:进入 custom 模式 + 停掉 preset */
|
||||
if (bg.isPlaying.value || currentLoop.value) {
|
||||
bg.stop()
|
||||
}
|
||||
mode.value = 'custom'
|
||||
/* 把当前显示 BPM 带过去当起始值 */
|
||||
const seed =
|
||||
LOOP_PRESETS.find((p) => p.id === (currentLoop.value ?? 'normal'))?.bpm ?? 100
|
||||
fg.setBpm(seed)
|
||||
fg.setAccentEvery(2)
|
||||
/* 立即预热 InnerAudio,避免首拍冷启动延迟 */
|
||||
fg.preload()
|
||||
customExpanded.value = true
|
||||
} else {
|
||||
/* 收起:回到 preset,停掉 fg */
|
||||
fg.stop()
|
||||
mode.value = 'preset'
|
||||
customExpanded.value = false
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
}
|
||||
|
||||
const onBpmDelta = (delta: number) => {
|
||||
fg.setBpm(fg.bpm.value + delta)
|
||||
}
|
||||
|
||||
const onAccentSelect = (n: number) => {
|
||||
fg.setAccentEvery(n)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 视频实例与 BPM 同步
|
||||
* ============================================================ */
|
||||
let videoCtx: UniApp.VideoContext | null = null
|
||||
|
||||
const playbackRate = computed(() => {
|
||||
const rate = currentBpm.value / VIDEO_BASE_BPM
|
||||
return Math.max(0.5, Math.min(2.0, +rate.toFixed(2)))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
videoCtx = uni.createVideoContext('runner-video')
|
||||
setTimeout(() => videoCtx?.pause(), 50)
|
||||
})
|
||||
|
||||
const onVideoError = (e: any) => {
|
||||
console.error('[runner-video] error:', e?.detail || e)
|
||||
}
|
||||
|
||||
const onVideoLoaded = (e: any) => {
|
||||
console.log('[runner-video] metadata loaded:', e?.detail)
|
||||
try {
|
||||
videoCtx?.playbackRate?.(playbackRate.value)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
watch(isPlaying, (playing) => {
|
||||
if (playing) {
|
||||
videoCtx?.play()
|
||||
} else {
|
||||
videoCtx?.pause()
|
||||
}
|
||||
})
|
||||
|
||||
watch(playbackRate, (rate) => {
|
||||
try {
|
||||
videoCtx?.playbackRate?.(rate)
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
'--beat-duration': `${intervalMs.value}ms`,
|
||||
}))
|
||||
|
||||
onShow(() => {
|
||||
/* custom 模式回到前台时主动预加载,避免冷启动延迟 */
|
||||
if (mode.value === 'custom') {
|
||||
fg.preload()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* onHide:差异化处理
|
||||
* - preset 模式: 不停,BgAudio 接管后台播放
|
||||
* - custom 模式: 立即停掉,InnerAudio 后台不工作,留着会被 iOS 挂起后状态错乱
|
||||
*/
|
||||
onHide(() => {
|
||||
if (mode.value === 'custom') {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
/* 页面销毁时全部清理 */
|
||||
if (mode.value === 'preset' && bg.isPlaying.value) bg.stop()
|
||||
if (mode.value === 'custom') fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================================
|
||||
* 设计 token —— 浅色清爽
|
||||
* ============================================================ */
|
||||
$bg-color: #f8fafc;
|
||||
$card-bg: #ffffff;
|
||||
$card-border: rgba(15, 23, 42, 0.06);
|
||||
$text-1: #0f172a;
|
||||
$text-2: #475569;
|
||||
$text-3: #94a3b8;
|
||||
$brand: #10b981;
|
||||
$brand-soft: #d1fae5;
|
||||
$brand-deep: #047857;
|
||||
$warn: #f59e0b;
|
||||
$warn-soft: #fef3c7;
|
||||
$radius-card: 24rpx;
|
||||
$shadow-sm: 0 4rpx 12rpx rgba(15, 23, 42, 0.04);
|
||||
|
||||
/* ============================================================
|
||||
* 页面容器
|
||||
* ============================================================ */
|
||||
.page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 30rpx 28rpx 50rpx;
|
||||
background: $bg-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
color: $text-1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 节拍器主舞台
|
||||
* ============================================================ */
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 480rpx;
|
||||
height: 480rpx;
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active .core {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid rgba(16, 185, 129, 0.12);
|
||||
pointer-events: none;
|
||||
|
||||
&.playing {
|
||||
animation: ring-pulse var(--beat-duration, 750ms) ease-out infinite;
|
||||
}
|
||||
&.r2.playing {
|
||||
animation-delay: calc(var(--beat-duration, 750ms) * -0.5);
|
||||
}
|
||||
}
|
||||
@keyframes ring-pulse {
|
||||
0% {
|
||||
transform: scale(0.94);
|
||||
opacity: 0.85;
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.18);
|
||||
opacity: 0;
|
||||
border-color: rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.core {
|
||||
position: relative;
|
||||
width: 380rpx;
|
||||
height: 380rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(140deg, #34d399 0%, $brand-deep 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow:
|
||||
inset 0 -16rpx 50rpx rgba(0, 0, 0, 0.18),
|
||||
inset 0 16rpx 50rpx rgba(255, 255, 255, 0.18),
|
||||
0 16rpx 40rpx rgba(16, 185, 129, 0.32);
|
||||
transition: transform 0.12s, background 0.18s, box-shadow 0.18s;
|
||||
|
||||
&.playing {
|
||||
animation: core-beat var(--beat-duration, 750ms) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bpm-num {
|
||||
font-size: 168rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
letter-spacing: -2rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.bpm-label {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 6rpx;
|
||||
margin-top: 10rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ctrl {
|
||||
margin-top: 22rpx;
|
||||
height: 64rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@keyframes core-beat {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
/* 播放/暂停 icon */
|
||||
.icon-play {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 52rpx solid #fff;
|
||||
border-top: 32rpx solid transparent;
|
||||
border-bottom: 32rpx solid transparent;
|
||||
margin-left: 12rpx;
|
||||
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
.icon-pause {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
height: 60rpx;
|
||||
|
||||
.bar {
|
||||
width: 16rpx;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 4rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 跑步小人
|
||||
* ============================================================ */
|
||||
.walker {
|
||||
width: 100%;
|
||||
height: 240rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.walker-box {
|
||||
position: relative;
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
background: #e8ecf2;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.runner-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #e8ecf2;
|
||||
}
|
||||
|
||||
.pip-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 80rpx;
|
||||
height: 56rpx;
|
||||
background-color: #dde2eb;
|
||||
border-bottom-left-radius: 18rpx;
|
||||
border-top-right-radius: 24rpx;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 三档位推荐
|
||||
* ============================================================ */
|
||||
.presets {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid $card-border;
|
||||
border-radius: $radius-card;
|
||||
padding: 18rpx 8rpx 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all 0.18s;
|
||||
|
||||
.preset-icon {
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
.preset-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-1;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.preset-bpm {
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.preset-desc {
|
||||
font-size: 18rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 1rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $brand-soft;
|
||||
border-color: $brand;
|
||||
box-shadow:
|
||||
0 8rpx 20rpx rgba(16, 185, 129, 0.18),
|
||||
inset 0 0 0 2rpx rgba(16, 185, 129, 0.4);
|
||||
|
||||
.preset-name {
|
||||
color: $brand-deep;
|
||||
}
|
||||
.preset-bpm {
|
||||
color: $brand-deep;
|
||||
}
|
||||
.preset-desc {
|
||||
color: $brand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 自定义节奏面板(仅 customExpanded 时显示)
|
||||
* ============================================================ */
|
||||
.custom-panel {
|
||||
width: 100%;
|
||||
background: $card-bg;
|
||||
border: 2rpx solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: $radius-card;
|
||||
box-shadow: 0 6rpx 18rpx rgba(245, 158, 11, 0.08);
|
||||
padding: 20rpx 24rpx 22rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.custom-warn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 4rpx;
|
||||
|
||||
.warn-dot {
|
||||
font-size: 18rpx;
|
||||
color: $warn;
|
||||
}
|
||||
.warn-text {
|
||||
font-size: 22rpx;
|
||||
color: #b45309;
|
||||
letter-spacing: 0.5rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
|
||||
.row-label {
|
||||
font-size: 24rpx;
|
||||
color: $text-2;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
flex-shrink: 0;
|
||||
width: 70rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.bpm-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
|
||||
.step-btn {
|
||||
min-width: 56rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 600;
|
||||
padding: 0 10rpx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:active {
|
||||
background: #e2e8f0;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
.step-val {
|
||||
min-width: 84rpx;
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: $text-1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: $warn-soft;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-tabs {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
|
||||
.meter-tab {
|
||||
height: 52rpx;
|
||||
line-height: 52rpx;
|
||||
padding: 0 18rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-2;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
|
||||
&.active {
|
||||
background: $warn;
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 10rpx rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 底部行(自定义入口 + 锁屏提示,横向排布)
|
||||
* ============================================================ */
|
||||
.bottom-row {
|
||||
width: 100%;
|
||||
margin-top: 6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
|
||||
.bottom-link {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
padding: 8rpx 4rpx;
|
||||
|
||||
&:active {
|
||||
color: $text-2;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-hint {
|
||||
font-size: 22rpx;
|
||||
color: $text-3;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
# 训练模块静态资源
|
||||
|
||||
本目录用于存放"练一练"功能的所有音频素材。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
training/static/
|
||||
├── audio/ # 当前为空 —— 节拍器音频已全部迁移到 COS CDN
|
||||
├── voice/ # (规划中) TTS 语音教练
|
||||
│ ├── numbers/ 数字报数 1~30
|
||||
│ └── prompts/ 开始/休息/再来 等口令
|
||||
├── bgm/ # (规划中) 训练/休息背景乐
|
||||
└── footprint.svg # 脚印图标(早期方案残留,可保留作为备用素材)
|
||||
```
|
||||
|
||||
> 节拍器音频已完全走 CDN(`gz-1349751149.cos.ap-guangzhou.myqcloud.com`),减少小程序包体积约 210 KB。`useMetronomeBg.ts` / `useMetronome.ts` 中的 URL 即源信息,更换音色只需改 hook 里的常量。
|
||||
|
||||
## 准备步骤
|
||||
|
||||
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||
|
||||
需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key:
|
||||
|
||||
- 文档: https://platform.xiaomimimo.com/
|
||||
- API Key 形如 `sk-xxxxxxxx`
|
||||
|
||||
```bash
|
||||
export MIMO_API_KEY=sk-your-key-here
|
||||
|
||||
cd uniapp
|
||||
node scripts/generate-voice.mjs
|
||||
```
|
||||
|
||||
会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
|
||||
|
||||
**可选参数**:
|
||||
|
||||
```bash
|
||||
# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean)
|
||||
node scripts/generate-voice.mjs --voice 茉莉
|
||||
|
||||
# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
|
||||
node scripts/generate-voice.mjs --format wav
|
||||
|
||||
# 强制重新生成(默认存在则跳过)
|
||||
node scripts/generate-voice.mjs --force
|
||||
```
|
||||
|
||||
**音色推荐**(中文女声更适合健身教练):
|
||||
- `冰糖`:温柔甜美,亲和力强(默认)
|
||||
- `茉莉`:清爽利落,有"运动博主"感
|
||||
- `苏打`:年轻男声,有力量感
|
||||
- `白桦`:成熟男声,沉稳
|
||||
|
||||
### 2. 节拍器 click / 循环音轨
|
||||
|
||||
**全部托管在腾讯云 COS(2026-05-26 上传)**,本地不再保留:
|
||||
|
||||
| 用途 | 文件 | 大小 | 引擎 |
|
||||
|---|---|---|---|
|
||||
| 单拍 click(基础) | `click.mp3` | 3.4 KB | InnerAudio |
|
||||
| 单拍 click(木鱼,推荐) | `click-wood.mp3` | 4.6 KB | InnerAudio (custom 模式) |
|
||||
| 循环音轨 慢走 80 BPM | `loop_80bpm_2.mp3` | 71 KB | BgAudio (preset) |
|
||||
| 循环音轨 健走 110 BPM | `loop_110bpm_2.mp3` | 65 KB | BgAudio (preset) |
|
||||
| 循环音轨 快走 130 BPM | `loop_130bpm_2.mp3` | 66 KB | BgAudio (preset) |
|
||||
|
||||
> 微信 `BackgroundAudioManager.src` 只接受 https URL,不支持包内资源,所以必须走 CDN。
|
||||
> COS 域名已加入小程序后台 `downloadFile 合法域名`,跑步视频也走同一域名。
|
||||
|
||||
更换音色:去 [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 找新素材 → 上传到 COS → 在 `useMetronomeBg.ts` / `useMetronome.ts` 里替换 URL 即可。
|
||||
|
||||
### 3. 背景音乐
|
||||
|
||||
每首 30 秒~2 分钟即可(loop 后听不出接缝)。
|
||||
|
||||
推荐来源(免费可商用):
|
||||
- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||
- [freemusicarchive.org](https://freemusicarchive.org)
|
||||
- [bensound.com](https://bensound.com)(含署名)
|
||||
|
||||
文件大小建议 < 1MB(mp3 128kbps 单声道即可)。
|
||||
|
||||
## 在代码里被引用的位置
|
||||
|
||||
- `training/hooks/useMetronome.ts` → `DEFAULT_CLICK_SRC` (CDN: click.mp3)
|
||||
- `training/hooks/useMetronomeBg.ts` → `LOOP_PRESETS[*].src` (CDN: loop_*.mp3)
|
||||
- `training/pages/metronome.vue` → `CLICK_WOOD_URL` (CDN: click-wood.mp3, custom 模式)
|
||||
- `training/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3` (规划中)
|
||||
- `training/hooks/useTrainingBgm.ts` → `bgm/*.mp3` (规划中)
|
||||
|
||||
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
||||
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
||||
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
||||
|
||||
---
|
||||
|
||||
## 后台/锁屏播放设计备忘(未来健身模块用)
|
||||
|
||||
> 节拍器目前用 `InnerAudioContext` + `requiredBackgroundModes:["audio"]` 已能覆盖 5~10 分钟健走场景。
|
||||
> 真要做长时间训练 BGM、锁屏控制条等高级能力,参考下面的 `BackgroundAudioManager` 方案。
|
||||
|
||||
### 引擎选择对照表
|
||||
|
||||
健身模块的音频天然分两类,配两套引擎不冲突:
|
||||
|
||||
| 音频类型 | 时长 | 推荐引擎 | 理由 |
|
||||
|---|---|---|---|
|
||||
| 训练/休息 BGM | 30s~2min 循环 | **BackgroundAudioManager** | 长流、需要后台/锁屏不停 |
|
||||
| 语音教练("开始/休息/再来") | 1~3s 单句 | InnerAudioContext | 短促,前台用即可 |
|
||||
| 报数(1, 2, 3...) | 0.5~1s | InnerAudioContext + 池子 | 高频短促 |
|
||||
| 节拍器 click | 50~150ms | InnerAudioContext + 池子 | 极短,BgAudio 不接受 |
|
||||
|
||||
**关键约束:BgAudio 是全局单例,一次只能播 1 个音频**。所以让它专门播 BGM 这类"长流",其他短音用 InnerAudio 配合,互不打架。
|
||||
|
||||
### BackgroundAudioManager 限制清单(踩坑预警)
|
||||
|
||||
1. **全局单例**:整个小程序同一时刻只能播一个,多场景要协调切换
|
||||
2. **音频时长 ≥ 1 秒**:太短的 click 会被微信判定异常忽略
|
||||
3. **必填 metadata**:`title` / `coverImgUrl` / `singer` / `epname` / `webUrl` 缺一会报错
|
||||
4. **切 src 有延迟**:200~500ms 初始化抖动,频繁切换会卡顿
|
||||
5. **必须声明 `requiredBackgroundModes:["audio"]`** 才能后台播放
|
||||
6. **iOS 锁屏豁免**:声明后能锁屏继续播,无 5 分钟时长限制(vs InnerAudio 的 5min)
|
||||
7. **会显示系统控制条**:锁屏/通知栏出现带封面+暂停按钮的控制条
|
||||
|
||||
### 推荐架构(健身模块上线时)
|
||||
|
||||
```
|
||||
training/hooks/
|
||||
├── useTrainingBgm.ts → BackgroundAudioManager (后台/锁屏继续放音乐)
|
||||
├── useVoiceCoach.ts → InnerAudioContext (语音指导,前台用)
|
||||
└── useMetronome.ts → InnerAudioContext (节拍器,池子方案,保持现状)
|
||||
```
|
||||
|
||||
页面 `pages.json` 声明:
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "练一练",
|
||||
"requiredBackgroundModes": ["audio"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
锁屏会显示 BGM 控制条,老人能直接在锁屏点暂停。训练页面通过 `bgm.onPause()` 监听同步暂停训练,体验连贯。
|
||||
|
||||
### 必备资产清单
|
||||
|
||||
到时候要准备的文件:
|
||||
|
||||
- **BGM 2 首**:`bgm/train-light.mp3`(训练)、`bgm/rest-meditation.mp3`(休息)
|
||||
- 每个 30s~1min,128kbps 单声道,30~80KB
|
||||
- 推荐来源:[pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||
- **锁屏封面**:`audio/cover.jpg` 200×200
|
||||
- 简洁绿色背景 + 训练 emoji 即可
|
||||
|
||||
### BackgroundAudioManager API 速查
|
||||
|
||||
```ts
|
||||
const bgm = uni.getBackgroundAudioManager()
|
||||
|
||||
/* 必填 metadata,缺一会报错 */
|
||||
bgm.title = '健走训练中'
|
||||
bgm.coverImgUrl = '/training/static/audio/cover.jpg'
|
||||
bgm.epname = '甄养堂'
|
||||
bgm.singer = '健走节拍'
|
||||
bgm.webUrl = '' // 必填,空字符串可
|
||||
|
||||
/* src 一旦赋值会自动播放 */
|
||||
bgm.src = '/training/static/audio/bgm/train-light.mp3'
|
||||
|
||||
/* 控制 */
|
||||
bgm.pause() // 暂停 (锁屏控制条仍在)
|
||||
bgm.play() // 继续
|
||||
bgm.stop() // 真停 + 隐藏控制条
|
||||
bgm.seek(30) // 跳到 30s
|
||||
|
||||
/* 事件监听 — 用户从锁屏点暂停时会触发 */
|
||||
bgm.onPause(() => { /* 同步训练 UI 状态 */ })
|
||||
bgm.onPlay(() => {})
|
||||
bgm.onStop(() => {})
|
||||
bgm.onEnded(() => { /* 不 loop 时触发,可手动接下一首 */ })
|
||||
bgm.onError((err) => { console.error(err) })
|
||||
```
|
||||
|
||||
### 切换不同场景音乐的模式
|
||||
|
||||
```ts
|
||||
function switchBgm(scene: 'training' | 'resting' | 'none') {
|
||||
if (scene === 'none') {
|
||||
bgm.stop()
|
||||
return
|
||||
}
|
||||
bgm.title = scene === 'training' ? '健走训练中' : '休息恢复中'
|
||||
bgm.src = scene === 'training'
|
||||
? '/training/static/audio/bgm/train-light.mp3'
|
||||
: '/training/static/audio/bgm/rest-meditation.mp3'
|
||||
/* 注:setSrc 会自动播放,有 200~500ms 切换延迟 */
|
||||
}
|
||||
```
|
||||
|
||||
### 节拍器要不要也升级到 BgAudio?
|
||||
|
||||
**目前不需要**。节拍器升级 BgAudio 的代价:
|
||||
- 需要预合成 3 个档位的循环 mp3(80/110/130 BPM × 2 拍)
|
||||
- 必须砍掉右下角微调按钮(预合成 mp3 改不了 BPM)
|
||||
- 切档位有 200~500ms 卡顿
|
||||
|
||||
如果未来发现"健走 30 分钟以上锁屏会停"才考虑改。当前 5~10 分钟场景 InnerAudio + `requiredBackgroundModes` 够用。
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150">
|
||||
<g fill="#e2e8f0" fill-opacity="0.92">
|
||||
<path d="M 50,30 C 38,30 28,32 24,40 C 19,50 18,60 22,70 C 26,82 26,92 28,102 C 30,122 40,140 50,140 C 60,140 70,122 72,102 C 74,92 74,82 78,70 C 82,60 81,50 76,40 C 72,32 62,30 50,30 Z"/>
|
||||
<ellipse cx="30" cy="20" rx="6.5" ry="8" transform="rotate(-18 30 20)"/>
|
||||
<ellipse cx="43" cy="11" rx="5" ry="6.8" transform="rotate(-6 43 11)"/>
|
||||
<ellipse cx="55" cy="9" rx="4.5" ry="6.2" transform="rotate(4 55 9)"/>
|
||||
<ellipse cx="66" cy="13" rx="4" ry="5.5" transform="rotate(13 66 13)"/>
|
||||
<ellipse cx="76" cy="20" rx="3.5" ry="5" transform="rotate(22 76 20)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -0,0 +1,29 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 账号管理 API
|
||||
export function apiAssetUserList(params: any) {
|
||||
return request.get({ url: '/asset.AssetUser/lists', params })
|
||||
}
|
||||
export function apiAssetUserAdd(params: any) {
|
||||
return request.post({ url: '/asset.AssetUser/add', params })
|
||||
}
|
||||
export function apiAssetUserEdit(params: any) {
|
||||
return request.post({ url: '/asset.AssetUser/edit', params })
|
||||
}
|
||||
export function apiAssetUserDelete(params: any) {
|
||||
return request.post({ url: '/asset.AssetUser/delete', params })
|
||||
}
|
||||
|
||||
// 资源管理 API
|
||||
export function apiAssetResourceList(params: any) {
|
||||
return request.get({ url: '/asset.AssetResource/lists', params })
|
||||
}
|
||||
export function apiAssetResourceAdd(params: any) {
|
||||
return request.post({ url: '/asset.AssetResource/add', params })
|
||||
}
|
||||
export function apiAssetResourceEdit(params: any) {
|
||||
return request.post({ url: '/asset.AssetResource/edit', params })
|
||||
}
|
||||
export function apiAssetResourceDelete(params: any) {
|
||||
return request.post({ url: '/asset.AssetResource/delete', params })
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getSelfInputOverview(params: any) {
|
||||
return request.get({ url: '/stats.self_input/overview', params })
|
||||
}
|
||||
|
||||
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
|
||||
export function getSelfInputMediaSourceOptions() {
|
||||
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
|
||||
}
|
||||
|
||||
export function personalYejiLists(params: any) {
|
||||
return request.get({ url: '/stats.personal_yeji/lists', params })
|
||||
}
|
||||
|
||||
export function personalYejiAdd(data: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/add', data })
|
||||
}
|
||||
|
||||
export function personalYejiEdit(data: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/edit', data })
|
||||
}
|
||||
|
||||
export function personalYejiDetail(params: any) {
|
||||
return request.get({ url: '/stats.personal_yeji/detail', params })
|
||||
}
|
||||
|
||||
export function personalYejiDelete(params: any) {
|
||||
return request.post({ url: '/stats.personal_yeji/delete', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostLists(params: any) {
|
||||
return request.get({ url: '/stats.personal_account_cost/lists', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostAdd(data: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/add', data })
|
||||
}
|
||||
|
||||
export function personalAccountCostEdit(data: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/edit', data })
|
||||
}
|
||||
|
||||
export function personalAccountCostDetail(params: any) {
|
||||
return request.get({ url: '/stats.personal_account_cost/detail', params })
|
||||
}
|
||||
|
||||
export function personalAccountCostDelete(params: any) {
|
||||
return request.post({ url: '/stats.personal_account_cost/delete', params })
|
||||
}
|
||||
@@ -179,3 +179,12 @@ export function commissionSettlementConfirmFinalize(params: Record<string, any>)
|
||||
export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||
}
|
||||
|
||||
/** 医助个人业绩概览 */
|
||||
export function assistantPerformanceOverview(params: {
|
||||
time_type?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||
}
|
||||
|
||||
+12
-1
@@ -462,15 +462,26 @@ export function prescriptionOrderAddPayOrder(params: {
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/addPayOrder', params })
|
||||
}
|
||||
|
||||
/** 为「已发货」订单关联已有支付单并重置支付审核为待审核 */
|
||||
export function prescriptionOrderLinkPayOrder(params: { id: number; pay_order_id: number }) {
|
||||
export function prescriptionOrderLinkPayOrder(params: {
|
||||
id: number
|
||||
pay_order_id?: number
|
||||
pay_order_ids?: number[]
|
||||
completion_request?: number
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/linkPayOrder', params })
|
||||
}
|
||||
|
||||
/** 已发货/已签收:仅提交完单申请(不新增/关联支付单) */
|
||||
export function prescriptionOrderRequestCompletion(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/requestCompletion', params })
|
||||
}
|
||||
|
||||
/** 将「已发货/已签收」且支付审核通过的订单结案(3=已完成 或 7-12 业务状态) */
|
||||
export function prescriptionOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/complete', params })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,8 +102,8 @@ export default defineComponent({
|
||||
const visible = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
|
||||
// 仅 video + direct 时才接管 http-request
|
||||
const useDirect = computed(() => props.direct && props.type === 'video')
|
||||
// 仅 video/voice + direct 时才接管 http-request
|
||||
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
|
||||
|
||||
const handleProgress = () => {
|
||||
visible.value = true
|
||||
@@ -151,6 +151,8 @@ export default defineComponent({
|
||||
return '.jpg,.png,.gif,.jpeg,.ico'
|
||||
case 'video':
|
||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||
case 'voice':
|
||||
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||
default:
|
||||
return '*'
|
||||
}
|
||||
@@ -162,7 +164,7 @@ export default defineComponent({
|
||||
try {
|
||||
const data = await uploadVideoDirectToCos({
|
||||
file: options.file,
|
||||
type: 'video',
|
||||
type: props.type as any,
|
||||
cid: Number((options.data as any)?.cid ?? 0),
|
||||
onProgress(info) {
|
||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="asset-resource-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="标题">
|
||||
<el-input
|
||||
placeholder="请输入标题关键词"
|
||||
v-model="searchData.title"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传时间">
|
||||
<el-date-picker
|
||||
v-model="searchData.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
style="width: 280px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">上传资源</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="图片" name="1"></el-tab-pane>
|
||||
<el-tab-pane label="视频" name="2"></el-tab-pane>
|
||||
<el-tab-pane label="语音" name="3"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="title" label="标题" min-width="80" />
|
||||
<el-table-column label="预览" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.type == 1" :src="row.file_url" style="width: 50px; height: 50px;" :preview-src-list="[row.file_url]" preview-teleported />
|
||||
<el-link v-else :href="row.file_url" target="_blank" type="primary">点击预览</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联账号" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="!row.users?.length" type="info" size="small">公开资源</el-tag>
|
||||
<span v-else>{{ row.users?.map((u: any) => u.phone).join(', ') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="上传时间" width="180" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑资源弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="computedRules" label-width="100px">
|
||||
<el-form-item label="资源类型" prop="type" v-if="!isEdit">
|
||||
<el-radio-group v-model="formData.type">
|
||||
<el-radio :label="1">图片</el-radio>
|
||||
<el-radio :label="2">视频</el-radio>
|
||||
<el-radio :label="3">语音</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="formData.title" placeholder="请输入资源标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
|
||||
<material-picker v-if="formData.type === 1" v-model="formData.file_url" :limit="1" type="image" />
|
||||
<upload v-else-if="formData.type === 2" type="video" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||
<el-button type="primary">上传视频 (OSS直传)</el-button>
|
||||
</upload>
|
||||
<upload v-else-if="formData.type === 3" type="voice" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
|
||||
<el-button type="primary">上传语音 (OSS直传)</el-button>
|
||||
</upload>
|
||||
<div v-if="formData.file_url && formData.type !== 1" class="ml-4 text-primary truncate max-w-xs" :title="formData.file_url">
|
||||
已上传: {{ formData.file_url }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联账号" prop="user_ids">
|
||||
<el-select v-model="formData.user_ids" multiple filterable placeholder="不选择则为公开资源" clearable style="width: 100%;">
|
||||
<el-option v-for="item in userOptions" :key="item.id" :label="item.phone" :value="item.id" />
|
||||
</el-select>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">不选择账号则资源为公开资源,所有人可见</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading">{{ isEdit ? '保存修改' : '确定发布' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiAssetResourceList, apiAssetResourceAdd, apiAssetResourceEdit, apiAssetResourceDelete, apiAssetUserList } from '@/api/asset'
|
||||
import MaterialPicker from '@/components/material/picker.vue'
|
||||
import Upload from '@/components/upload/index.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const searchData = reactive<any>({
|
||||
title: '',
|
||||
dateRange: []
|
||||
})
|
||||
|
||||
const queryParams = reactive<any>({
|
||||
type: '1',
|
||||
page_no: 1,
|
||||
page_size: 15
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const isEdit = ref(false)
|
||||
const editId = ref(0)
|
||||
|
||||
const userOptions = ref<any[]>([])
|
||||
|
||||
const formData = reactive<any>({
|
||||
type: 1,
|
||||
title: '',
|
||||
file_url: '',
|
||||
cover_url: '',
|
||||
user_ids: []
|
||||
})
|
||||
|
||||
const computedRules = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
file_url: [{ required: true, message: '请输入或上传文件获取链接', trigger: 'blur' }]
|
||||
}
|
||||
})
|
||||
|
||||
const buildQueryParams = () => {
|
||||
const params: any = { ...queryParams }
|
||||
if (searchData.title) {
|
||||
params.title = searchData.title
|
||||
}
|
||||
if (searchData.dateRange && searchData.dateRange.length === 2) {
|
||||
params.start_time = searchData.dateRange[0]
|
||||
params.end_time = searchData.dateRange[1]
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiAssetResourceList(buildQueryParams())
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserOptions = async () => {
|
||||
try {
|
||||
const res = await apiAssetUserList({ page_no: 1, page_size: 9999 })
|
||||
userOptions.value = res.lists
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchData.title = ''
|
||||
searchData.dateRange = []
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleTabChange = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
editId.value = 0
|
||||
Object.assign(formData, { type: parseInt(queryParams.type), title: '', file_url: '', cover_url: '', user_ids: [] })
|
||||
fetchUserOptions()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
isEdit.value = true
|
||||
editId.value = row.id
|
||||
Object.assign(formData, {
|
||||
type: row.type,
|
||||
title: row.title,
|
||||
file_url: row.file_url,
|
||||
cover_url: row.cover_url || '',
|
||||
user_ids: row.users?.map((u: any) => u.id) || []
|
||||
})
|
||||
fetchUserOptions()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleUploadSuccess = (response: any) => {
|
||||
formData.file_url = response?.data?.uri || response?.data?.url || ''
|
||||
ElMessage.success('上传成功')
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该资源吗?用户将无法再看到。', '提示', { type: 'warning' })
|
||||
await apiAssetResourceDelete({ id: row.id })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await apiAssetResourceEdit({
|
||||
id: editId.value,
|
||||
title: formData.title,
|
||||
user_ids: formData.user_ids
|
||||
})
|
||||
ElMessage.success('编辑成功')
|
||||
} else {
|
||||
await apiAssetResourceAdd(formData)
|
||||
ElMessage.success('上传分发成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
getList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="asset-user-container">
|
||||
<!-- 搜索区域 -->
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="ls-form" :model="searchData" inline>
|
||||
<el-form-item class="w-[280px]" label="手机号">
|
||||
<el-input
|
||||
placeholder="请输入手机号"
|
||||
v-model="searchData.phone"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div class="mb-4">
|
||||
<el-button type="primary" @click="handleAdd">新增账号</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading">
|
||||
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
|
||||
<el-table-column prop="phone" label="手机号" />
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
active-text="正常"
|
||||
inactive-text="禁用"
|
||||
inline-prompt
|
||||
@change="handleStatusChange(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑/新增弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="formData.phone" placeholder="请输入手机号(作为登录账号)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const searchData = reactive({
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const queryParams = reactive<any>({
|
||||
page_no: 1,
|
||||
page_size: 15
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const rules = {
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { ...queryParams, ...searchData }
|
||||
const res = await apiAssetUserList(params)
|
||||
tableData.value = res.lists
|
||||
total.value = res.count
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchData.phone = ''
|
||||
queryParams.page_no = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const handleStatusChange = async (row: any) => {
|
||||
try {
|
||||
await apiAssetUserEdit({ id: row.id, status: row.status })
|
||||
ElMessage.success(row.status === 1 ? '已启用' : '已禁用')
|
||||
} catch (e) {
|
||||
// 失败时回滚状态
|
||||
row.status = row.status === 1 ? 0 : 1
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = '新增账号'
|
||||
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑账号'
|
||||
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
|
||||
await apiAssetUserDelete({ id: row.id })
|
||||
ElMessage.success('删除成功')
|
||||
getList()
|
||||
} catch (e) {
|
||||
// canceled
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (formData.id) {
|
||||
await apiAssetUserEdit(formData)
|
||||
} else {
|
||||
await apiAssetUserAdd(formData)
|
||||
}
|
||||
ElMessage.success('操作成功')
|
||||
dialogVisible.value = false
|
||||
getList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
@@ -407,20 +407,35 @@
|
||||
</div>
|
||||
|
||||
<div class="rx-herbs">
|
||||
<div
|
||||
v-for="(h, i) in slipHerbsList"
|
||||
:key="i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
<template v-if="slipMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipMainHerbs"
|
||||
:key="'main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="slipAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipAuxHerbs"
|
||||
:key="'aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
|
||||
<div class="rx-text">
|
||||
<p>服法:{{ rxUsageText }}</p>
|
||||
<p>主方服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
@@ -649,53 +664,87 @@
|
||||
<el-form-item label="药材配方" prop="herbs" required>
|
||||
<div class="w-full">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<el-button type="primary" size="small" @click="addHerb">
|
||||
添加药材
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="openLibraryDialog">
|
||||
<el-button
|
||||
type="success"
|
||||
size="small"
|
||||
v-perms="['cf.prescription/add', 'cf.prescription/edit', 'tcm.prescriptionLibrary/lists']"
|
||||
@click="openLibraryDialog"
|
||||
>
|
||||
从处方库导入
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<el-button type="warning" size="small" plain :icon="EditPen" @click="openPasteRecipeDialog">
|
||||
导入药方
|
||||
</el-button>
|
||||
<span class="text-xs text-gray-500 ml-2 align-middle">
|
||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入
|
||||
<span class="text-xs text-gray-500">
|
||||
粘贴文本解析药名与剂量;须与药品库名称完全一致才录入(导入至主方)
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="editForm.herbs" class="mt-2" border>
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="row.name" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
<template #default="{ row, $index }">
|
||||
<el-input-number
|
||||
v-model="row.dosage"
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ $index }">
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@click="removeHerb($index)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
|
||||
<div class="herb-formula-block mt-3">
|
||||
<div class="herb-formula-block__head">
|
||||
<el-tag type="primary" size="small">主方</el-tag>
|
||||
<el-button type="primary" size="small" @click="addHerb('主方')">添加主方药材</el-button>
|
||||
</div>
|
||||
<el-table :data="mainHerbRows" class="mt-2" border empty-text="暂无主方药材">
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="editForm.herbs[row.index].dosage"
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="herb-formula-block mt-4">
|
||||
<div class="herb-formula-block__head">
|
||||
<el-tag type="warning" size="small">辅方</el-tag>
|
||||
<el-button type="primary" size="small" plain @click="addHerb('辅方')">
|
||||
添加辅方药材
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="auxHerbRows" class="mt-2" border empty-text="暂无辅方药材">
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="editForm.herbs[row.index].dosage"
|
||||
:min="0"
|
||||
:precision="1"
|
||||
:step="0.5"
|
||||
placeholder="剂量"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeHerb(row.index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="editMode === 'edit'"
|
||||
type="warning"
|
||||
@@ -778,8 +827,10 @@
|
||||
<el-option label="汤剂" value="汤剂" />
|
||||
</el-select>
|
||||
</el-form-item> </el-col>
|
||||
|
||||
<!-- 用量字段 -->
|
||||
</el-row>
|
||||
|
||||
<div class="usage-formula-title usage-formula-title--main">主方用法</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="用量" prop="dosage_amount">
|
||||
<!-- 浓缩水丸:1-10g 下拉选择 -->
|
||||
@@ -881,6 +932,97 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<template v-if="auxHerbRows.length > 0">
|
||||
<div class="usage-formula-title usage-formula-title--aux">辅方用法</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="用量">
|
||||
<el-select
|
||||
v-if="editForm.prescription_type === '浓缩水丸'"
|
||||
v-model="editForm.aux_usage.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option v-for="n in 10" :key="n" :label="n + 'g'" :value="n" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-else-if="editForm.prescription_type === '饮片'"
|
||||
v-model="editForm.aux_usage.dosage_amount"
|
||||
placeholder="请选择用量"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option label="50ml" :value="50" />
|
||||
<el-option label="100ml" :value="100" />
|
||||
<el-option label="120ml" :value="120" />
|
||||
<el-option label="150ml" :value="150" />
|
||||
<el-option label="180ml" :value="180" />
|
||||
<el-option label="200ml" :value="200" />
|
||||
<el-option label="250ml" :value="250" />
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-else
|
||||
v-model="editForm.aux_usage.dosage_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="editForm.prescription_type === '浓缩水丸'" :span="8">
|
||||
<el-form-item label=" " label-width="12px">
|
||||
<el-select
|
||||
v-model="editForm.aux_usage.dosage_bag_count"
|
||||
placeholder="请选择袋数"
|
||||
class="w-[120px]"
|
||||
>
|
||||
<el-option label="1袋" :value="1" />
|
||||
<el-option label="2袋" :value="2" />
|
||||
<el-option label="3袋" :value="3" />
|
||||
<el-option label="4袋" :value="4" />
|
||||
<el-option label="5袋" :value="5" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||
<el-form-item label="是否代煎">
|
||||
<el-radio-group v-model="editForm.aux_usage.need_decoction">
|
||||
<el-radio :label="true">代煎</el-radio>
|
||||
<el-radio :label="false">不代煎</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="editForm.prescription_type === '饮片'" :span="8">
|
||||
<el-form-item label="每贴出包数">
|
||||
<el-select v-model="editForm.aux_usage.bags_per_dose" placeholder="请选择" class="w-full">
|
||||
<el-option v-for="n in 9" :key="n" :label="n + '包'" :value="n" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="每天几次">
|
||||
<el-input-number
|
||||
v-model="editForm.aux_usage.times_per_day"
|
||||
:min="1"
|
||||
:max="6"
|
||||
class="!w-full"
|
||||
placeholder="每天服用次数"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="服用天数">
|
||||
<el-input-number
|
||||
v-model="editForm.aux_usage.usage_days"
|
||||
:min="1"
|
||||
:max="365"
|
||||
placeholder="服用天数"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<el-form-item label="用法" prop="usage_instruction">
|
||||
<el-input
|
||||
@@ -1365,16 +1507,17 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 从处方库导入(与诊间 tcm-prescription 一致,按当前处方开方医师 creator_id 筛选) -->
|
||||
<!-- 从处方库导入:当前开方医师的全部模板 + 所有人可见的公共模板 -->
|
||||
<el-dialog
|
||||
v-model="showLibraryDialog"
|
||||
title="从处方库导入"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 flex gap-3">
|
||||
<el-input
|
||||
v-model="librarySearchName"
|
||||
class="flex-1"
|
||||
placeholder="请输入处方名称搜索"
|
||||
clearable
|
||||
@keyup.enter="searchLibrary"
|
||||
@@ -1384,6 +1527,24 @@
|
||||
<el-button :icon="Search" @click="searchLibrary" />
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-model="librarySearchFormulaType"
|
||||
placeholder="处方类型"
|
||||
clearable
|
||||
class="w-[140px]"
|
||||
@change="searchLibrary"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="主方" value="主方" />
|
||||
<el-option label="辅方" value="辅方" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span class="text-sm text-gray-600 shrink-0">导入方式</span>
|
||||
<el-radio-group v-model="libraryImportMode">
|
||||
<el-radio label="replace">覆盖现有药材</el-radio>
|
||||
<el-radio label="append">追加到末尾</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
@@ -1394,6 +1555,13 @@
|
||||
@row-click="handleSelectLibraryRow"
|
||||
>
|
||||
<el-table-column label="处方名称" prop="prescription_name" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="处方类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.formula_type || '主方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="药材数量" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.herbs?.length || 0 }}味
|
||||
@@ -1407,6 +1575,13 @@
|
||||
<span v-else class="text-gray-400">暂无药材</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否公开" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_public ? 'success' : 'info'" size="small">
|
||||
{{ row.is_public ? '所有人可见' : '仅自己可见' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建人" prop="creator_name" width="100" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
@@ -1522,6 +1697,119 @@ import jsPDF from 'jspdf'
|
||||
|
||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
type FormulaType = '主方' | '辅方'
|
||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType }
|
||||
|
||||
type AuxUsageForm = {
|
||||
dosage_amount?: number
|
||||
dosage_bag_count: number
|
||||
need_decoction: boolean
|
||||
bags_per_dose: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
}
|
||||
|
||||
function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
|
||||
if (prescriptionType === '饮片') {
|
||||
return {
|
||||
dosage_amount: 50,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
if (prescriptionType === '浓缩水丸') {
|
||||
return {
|
||||
dosage_amount: 5,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
return {
|
||||
dosage_amount: 1,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAuxUsageForm(raw: unknown, prescriptionType: string): AuxUsageForm {
|
||||
const base = defaultAuxUsage(prescriptionType)
|
||||
if (!raw || typeof raw !== 'object') return { ...base }
|
||||
const o = raw as Record<string, unknown>
|
||||
return {
|
||||
dosage_amount:
|
||||
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
|
||||
? Number(o.dosage_amount)
|
||||
: base.dosage_amount,
|
||||
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
|
||||
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
|
||||
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
|
||||
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
|
||||
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
|
||||
}
|
||||
}
|
||||
|
||||
function buildUsageSegmentText(
|
||||
usage: {
|
||||
prescription_type?: string
|
||||
dosage_amount?: number | null
|
||||
dosage_unit?: string
|
||||
dosage_bag_count?: number
|
||||
times_per_day?: number
|
||||
usage_way?: string
|
||||
usage_time?: string
|
||||
},
|
||||
fallbackWay?: string,
|
||||
fallbackTime?: string
|
||||
): string {
|
||||
const pt = usage.prescription_type || '浓缩水丸'
|
||||
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
|
||||
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
|
||||
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
|
||||
const usageWay = usage.usage_way || fallbackWay || '温水送服'
|
||||
const usageTime = usage.usage_time || fallbackTime || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
if (pt === '浓缩水丸') {
|
||||
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
}
|
||||
|
||||
function normalizeFormulaType(v: unknown): FormulaType {
|
||||
return v === '辅方' ? '辅方' : '主方'
|
||||
}
|
||||
|
||||
function normalizeHerbRow(raw: any): HerbRow {
|
||||
return {
|
||||
name: String(raw?.name ?? '').trim(),
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSlipHerbs(raw: unknown): HerbRow[] {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((x: any) => normalizeHerbRow(x))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** 与 server/config/project.php prescription_audit_roles 保持一致 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
|
||||
|
||||
@@ -1923,7 +2211,13 @@ const createOrderHerbSummary = computed(() => {
|
||||
const row = createOrderPrescription.value
|
||||
const herbs = normalizeSlipHerbs(row?.herbs)
|
||||
if (!herbs.length) return '暂无药材明细'
|
||||
const s = herbs.map((h) => `${h.name} ${h.dosage}g`).join('、')
|
||||
const main = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
|
||||
const aux = herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||
const fmt = (list: HerbRow[]) => list.map((h) => `${h.name} ${h.dosage}g`).join('、')
|
||||
const parts: string[] = []
|
||||
if (main.length) parts.push(`主方:${fmt(main)}`)
|
||||
if (aux.length) parts.push(`辅方:${fmt(aux)}`)
|
||||
const s = parts.join(';')
|
||||
return s.length > 120 ? `${s.slice(0, 120)}…` : s
|
||||
})
|
||||
|
||||
@@ -2163,9 +2457,27 @@ const editReviveHint = computed(() => {
|
||||
|
||||
const slipHerbsList = computed(() => {
|
||||
const h = slipView.value?.herbs
|
||||
return Array.isArray(h) ? h : []
|
||||
return Array.isArray(h) ? (h as HerbRow[]) : []
|
||||
})
|
||||
|
||||
const slipMainHerbs = computed(() =>
|
||||
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '主方')
|
||||
)
|
||||
const slipAuxHerbs = computed(() =>
|
||||
slipHerbsList.value.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||
)
|
||||
|
||||
const mainHerbRows = computed(() =>
|
||||
editForm.herbs
|
||||
.map((herb, index) => ({ herb, index }))
|
||||
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '主方')
|
||||
)
|
||||
const auxHerbRows = computed(() =>
|
||||
editForm.herbs
|
||||
.map((herb, index) => ({ herb, index }))
|
||||
.filter(({ herb }) => normalizeFormulaType(herb.formula_type) === '辅方')
|
||||
)
|
||||
|
||||
const slipDietaryText = computed(() => {
|
||||
const d = slipView.value?.dietary_taboo
|
||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||
@@ -2251,23 +2563,26 @@ const rxUsageText = computed(() => {
|
||||
const v = slipView.value as any
|
||||
if (!v) return '—'
|
||||
if (v.usage_text) return v.usage_text
|
||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||
const unit = v.dosage_unit || 'g'
|
||||
const usageWay = v.usage_way || '温水送服'
|
||||
const usageTime = v.usage_time || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
return buildUsageSegmentText(v)
|
||||
})
|
||||
|
||||
const rxAuxUsageText = computed(() => {
|
||||
const v = slipView.value as any
|
||||
if (!v || !slipAuxHerbs.value.length) return ''
|
||||
const aux = normalizeAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
|
||||
return buildUsageSegmentText(
|
||||
{
|
||||
prescription_type: v.prescription_type,
|
||||
dosage_amount: aux.dosage_amount,
|
||||
dosage_unit: v.dosage_unit,
|
||||
dosage_bag_count: aux.dosage_bag_count,
|
||||
times_per_day: aux.times_per_day,
|
||||
usage_way: v.usage_way,
|
||||
usage_time: v.usage_time
|
||||
},
|
||||
v.usage_way,
|
||||
v.usage_time
|
||||
)
|
||||
})
|
||||
|
||||
const rxAdviceText = computed(() => {
|
||||
@@ -2462,7 +2777,7 @@ const editForm = reactive({
|
||||
pulse: '',
|
||||
pulse_condition: '',
|
||||
clinical_diagnosis: '',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
herbs: [] as HerbRow[],
|
||||
dose_count: 7,
|
||||
dose_unit: '剂',
|
||||
usage_days: 7,
|
||||
@@ -2488,7 +2803,8 @@ const editForm = reactive({
|
||||
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
|
||||
business_prescription_audit_rejected: 0,
|
||||
business_prescription_audit_remark: '',
|
||||
times_per_day: 2
|
||||
times_per_day: 2,
|
||||
aux_usage: defaultAuxUsage('浓缩水丸')
|
||||
})
|
||||
|
||||
/** 编辑页:关联业务订单上的服用天数、医助备注(与处方提示一致) */
|
||||
@@ -2579,6 +2895,7 @@ watch(() => editForm.prescription_type, (newType) => {
|
||||
editForm.need_decoction = false
|
||||
editForm.bags_per_dose = 1
|
||||
}
|
||||
Object.assign(editForm.aux_usage, defaultAuxUsage(newType))
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
@@ -2807,15 +3124,15 @@ function slipAgeText(age: unknown) {
|
||||
return `${age}岁`
|
||||
}
|
||||
|
||||
function normalizeSlipHerbs(raw: unknown): Array<{ name: string; dosage: number }> {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((x: any) => ({
|
||||
name: String(x?.name ?? '').trim(),
|
||||
dosage: Number(x?.dosage) || 0
|
||||
}))
|
||||
function herbValidationLabel(globalIndex: number): string {
|
||||
const herb = editForm.herbs[globalIndex]
|
||||
if (!herb) return `第${globalIndex + 1}味`
|
||||
const ft = normalizeFormulaType(herb.formula_type)
|
||||
let n = 0
|
||||
for (let i = 0; i <= globalIndex; i++) {
|
||||
if (normalizeFormulaType(editForm.herbs[i]?.formula_type) === ft) n++
|
||||
}
|
||||
return []
|
||||
return `${ft}第${n}味`
|
||||
}
|
||||
|
||||
function listRxOrderWarnings(row: any): string[] {
|
||||
@@ -3038,6 +3355,8 @@ const showLibraryDialog = ref(false)
|
||||
const libraryLoading = ref(false)
|
||||
const libraryList = ref<any[]>([])
|
||||
const librarySearchName = ref('')
|
||||
const librarySearchFormulaType = ref('')
|
||||
const libraryImportMode = ref<'replace' | 'append'>('replace')
|
||||
const libraryPage = ref(1)
|
||||
const libraryPageSize = ref(15)
|
||||
const libraryTotal = ref(0)
|
||||
@@ -3082,8 +3401,8 @@ const loadLibraryList = async () => {
|
||||
page_no: libraryPage.value,
|
||||
page_size: libraryPageSize.value,
|
||||
prescription_name: librarySearchName.value,
|
||||
is_public: '',
|
||||
creator_id: doctorId
|
||||
formula_type: librarySearchFormulaType.value,
|
||||
prescribing_creator_id: doctorId
|
||||
})
|
||||
libraryList.value = res?.lists || []
|
||||
libraryTotal.value = res?.count || 0
|
||||
@@ -3118,18 +3437,33 @@ const handleImportLibrary = (row: any) => {
|
||||
feedback.msgWarning('该处方没有药材信息')
|
||||
return
|
||||
}
|
||||
const imported = JSON.parse(JSON.stringify(row.herbs)) as Array<{ name: string; dosage: number }>
|
||||
editForm.herbs = imported
|
||||
const formulaType = normalizeFormulaType(row.formula_type)
|
||||
const imported = (JSON.parse(JSON.stringify(row.herbs)) as any[]).map((h) => ({
|
||||
...normalizeHerbRow(h),
|
||||
formula_type: formulaType
|
||||
}))
|
||||
if (libraryImportMode.value === 'replace') {
|
||||
const kept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) !== formulaType)
|
||||
editForm.herbs = [...kept, ...imported]
|
||||
} else {
|
||||
editForm.herbs.push(...imported)
|
||||
}
|
||||
const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : ''
|
||||
if (findDuplicateHerbNamesLocal().length) {
|
||||
feedback.msgWarning('导入的处方中存在重复药名,请合并剂量或删除多余行')
|
||||
feedback.msgWarning(
|
||||
`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材。存在重复药名,请合并剂量或删除多余行`
|
||||
)
|
||||
} else {
|
||||
feedback.msgSuccess(`已导入处方「${row.prescription_name}」${modeHint},共${imported.length}味药材`)
|
||||
}
|
||||
feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${imported.length}味药材`)
|
||||
showLibraryDialog.value = false
|
||||
}
|
||||
|
||||
watch(showLibraryDialog, (open) => {
|
||||
if (open) {
|
||||
librarySearchName.value = ''
|
||||
librarySearchFormulaType.value = ''
|
||||
libraryImportMode.value = 'replace'
|
||||
libraryPage.value = 1
|
||||
loadLibraryList()
|
||||
}
|
||||
@@ -3261,7 +3595,7 @@ async function handlePasteRecipeImport() {
|
||||
}
|
||||
pasteRecipeImportLoading.value = true
|
||||
try {
|
||||
const resolved: Array<{ name: string; dosage: number }> = []
|
||||
const resolved: HerbRow[] = []
|
||||
const skippedNames: string[] = []
|
||||
for (const row of parsed) {
|
||||
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
||||
@@ -3269,7 +3603,7 @@ async function handlePasteRecipeImport() {
|
||||
skippedNames.push(row.name.trim())
|
||||
continue
|
||||
}
|
||||
resolved.push({ name, dosage: row.dosage })
|
||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||
}
|
||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||
if (resolved.length === 0) {
|
||||
@@ -3281,7 +3615,8 @@ async function handlePasteRecipeImport() {
|
||||
return
|
||||
}
|
||||
if (pasteRecipeImportMode.value === 'replace') {
|
||||
editForm.herbs = resolved
|
||||
const auxKept = editForm.herbs.filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||
editForm.herbs = [...auxKept, ...resolved]
|
||||
} else {
|
||||
editForm.herbs.push(...resolved)
|
||||
}
|
||||
@@ -3306,11 +3641,12 @@ async function handlePasteRecipeImport() {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加药材
|
||||
const addHerb = () => {
|
||||
// 添加药材(主方 / 辅方)
|
||||
const addHerb = (formulaType: FormulaType = '主方') => {
|
||||
editForm.herbs.push({
|
||||
name: '',
|
||||
dosage: 0
|
||||
dosage: 0,
|
||||
formula_type: formulaType
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3360,6 +3696,8 @@ const resetForm = () => {
|
||||
editForm.is_system_auto = 0
|
||||
editForm.business_prescription_audit_rejected = 0
|
||||
editForm.business_prescription_audit_remark = ''
|
||||
editForm.times_per_day = 2
|
||||
Object.assign(editForm.aux_usage, defaultAuxUsage('浓缩水丸'))
|
||||
clearRxLinkedOrderHint()
|
||||
}
|
||||
|
||||
@@ -3458,7 +3796,7 @@ const handleEdit = async (row: any) => {
|
||||
editForm.pulse = src.pulse || ''
|
||||
editForm.pulse_condition = src.pulse_condition || ''
|
||||
editForm.clinical_diagnosis = src.clinical_diagnosis || ''
|
||||
editForm.herbs = src.herbs ? JSON.parse(JSON.stringify(src.herbs)) : []
|
||||
editForm.herbs = src.herbs ? (src.herbs as any[]).map((h) => normalizeHerbRow(h)) : []
|
||||
editForm.dose_count = src.dose_count ?? 7
|
||||
editForm.dose_unit = src.dose_unit || '剂'
|
||||
editForm.usage_days = src.usage_days ?? 7
|
||||
@@ -3483,6 +3821,10 @@ const handleEdit = async (row: any) => {
|
||||
editForm.is_system_auto = Number(src.is_system_auto) === 1 ? 1 : 0
|
||||
editForm.business_prescription_audit_rejected = Number(src.business_prescription_audit_rejected) === 1 ? 1 : 0
|
||||
editForm.business_prescription_audit_remark = String(src.business_prescription_audit_remark || '')
|
||||
Object.assign(
|
||||
editForm.aux_usage,
|
||||
normalizeAuxUsageForm(src.aux_usage, editForm.prescription_type)
|
||||
)
|
||||
|
||||
// 数据加载完成,重新启用watch
|
||||
// 使用 setTimeout 确保所有数据都已经渲染完成
|
||||
@@ -3517,12 +3859,13 @@ const handleSubmit = async () => {
|
||||
|
||||
for (let i = 0; i < editForm.herbs.length; i++) {
|
||||
const herb = editForm.herbs[i]
|
||||
const label = herbValidationLabel(i)
|
||||
if (!herb.name || !herb.name.trim()) {
|
||||
feedback.msgError(`第${i + 1}味药材名称不能为空`)
|
||||
feedback.msgError(`${label}药材名称不能为空`)
|
||||
return
|
||||
}
|
||||
if (!herb.dosage || herb.dosage <= 0) {
|
||||
feedback.msgError(`第${i + 1}味药材剂量必须大于0`)
|
||||
feedback.msgError(`${label}药材剂量必须大于0`)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -3946,6 +4289,38 @@ onMounted(async () => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rx-herb-section-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rx-herb-section-label--aux {
|
||||
color: #e6a23c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.herb-formula-block__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-formula-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 4px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.usage-formula-title--aux {
|
||||
color: #e6a23c;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.rx-herb-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="处方类型">
|
||||
<el-select v-model="formData.formula_type" placeholder="全部" clearable>
|
||||
<el-option label="全部" :value="''" />
|
||||
<el-option label="主方" value="主方" />
|
||||
<el-option label="辅方" value="辅方" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="是否公开">
|
||||
<el-select v-model="formData.is_public" placeholder="全部" clearable>
|
||||
<el-option label="全部" :value="''" />
|
||||
@@ -37,6 +44,13 @@
|
||||
<el-table :data="pager.lists" size="large">
|
||||
<el-table-column label="ID" prop="id" min-width="60" />
|
||||
<el-table-column label="处方名称" prop="prescription_name" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="处方类型" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.formula_type === '辅方' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.formula_type || '主方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="药材数量" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.herbs?.length || 0 }}味
|
||||
@@ -114,6 +128,13 @@
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="处方类型" prop="formula_type">
|
||||
<el-radio-group v-model="editForm.formula_type">
|
||||
<el-radio value="主方">主方</el-radio>
|
||||
<el-radio value="辅方">辅方</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="药材配方" prop="herbs" required>
|
||||
<div class="w-full">
|
||||
@@ -200,6 +221,7 @@ import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
prescription_name: '',
|
||||
formula_type: '',
|
||||
is_public: ''
|
||||
})
|
||||
|
||||
@@ -212,6 +234,7 @@ const formRef = ref<FormInstance>()
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
is_public: 0
|
||||
})
|
||||
@@ -267,6 +290,7 @@ const removeHerb = (index: number) => {
|
||||
const resetForm = () => {
|
||||
editForm.id = 0
|
||||
editForm.prescription_name = ''
|
||||
editForm.formula_type = '主方'
|
||||
editForm.herbs = []
|
||||
editForm.is_public = 0
|
||||
}
|
||||
@@ -283,6 +307,7 @@ const handleView = (row: any) => {
|
||||
editMode.value = 'view'
|
||||
editForm.id = row.id
|
||||
editForm.prescription_name = row.prescription_name || ''
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
showEdit.value = true
|
||||
@@ -293,6 +318,7 @@ const handleEdit = (row: any) => {
|
||||
editMode.value = 'edit'
|
||||
editForm.id = row.id
|
||||
editForm.prescription_name = row.prescription_name || ''
|
||||
editForm.formula_type = row.formula_type || '主方'
|
||||
editForm.herbs = row.herbs ? JSON.parse(JSON.stringify(row.herbs)) : []
|
||||
editForm.is_public = row.is_public ?? 0
|
||||
showEdit.value = true
|
||||
|
||||
@@ -216,6 +216,22 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showAuditAdminFilter" class="w-[200px]" label="下单人">
|
||||
<el-select
|
||||
v-model="queryParams.audit_admin_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部下单人"
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in auditAdminOptions"
|
||||
:key="a.id"
|
||||
:label="a.name"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="医生">
|
||||
<el-select
|
||||
v-model="queryParams.doctor_id"
|
||||
@@ -252,7 +268,7 @@
|
||||
v-model="queryParams.assistant_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="诊单医助"
|
||||
placeholder="订单创建人"
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option
|
||||
@@ -915,31 +931,75 @@
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用量">
|
||||
<template v-if="detailPrescription.dosage_amount">
|
||||
{{ detailPrescription.dosage_amount }}{{ detailPrescription.dosage_unit || 'g' }}
|
||||
<template v-if="detailPrescription.prescription_type === '浓缩水丸'">
|
||||
· {{ Number(detailPrescription.dosage_bag_count) > 0 ? Number(detailPrescription.dosage_bag_count) : 1 }}袋
|
||||
</template>
|
||||
<span v-if="detailPrescription.prescription_type === '饮片' && detailPrescription.need_decoction !== null" class="ml-2 text-gray-500">
|
||||
({{ detailPrescription.need_decoction ? '代煎' : '不代煎' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
<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 label="服用方式">
|
||||
<div class="flex flex-col gap-1.5 text-sm leading-relaxed">
|
||||
<div>
|
||||
<span class="text-gray-500">每天次数:</span>
|
||||
{{ detailPrescription.times_per_day ? detailPrescription.times_per_day + ' 次' : '—' }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">处方开立:</span>
|
||||
{{
|
||||
detailPrescription.usage_days != null && detailPrescription.usage_days !== ''
|
||||
? detailPrescription.usage_days + ' 天'
|
||||
: '—'
|
||||
}}
|
||||
</div>
|
||||
<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>
|
||||
{{
|
||||
@@ -1236,6 +1296,7 @@
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-[15px]">物流轨迹</span>
|
||||
<span class="text-xs text-gray-400 font-mono">{{ detailData.tracking_number }}</span>
|
||||
<el-tag
|
||||
v-if="detailLogisticsUrgent.show"
|
||||
type="danger"
|
||||
@@ -2052,7 +2113,7 @@
|
||||
@closed="addPayOrderFormRef?.clearValidate()"
|
||||
>
|
||||
<el-alert
|
||||
title="可以手动创建新支付单,或关联已存在但未绑定的支付单。"
|
||||
:title="addPayOrderAlertTitle"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@@ -2070,6 +2131,7 @@
|
||||
<el-radio-group v-model="addPayOrderForm.add_mode">
|
||||
<el-radio value="create">手动创建</el-radio>
|
||||
<el-radio value="link">关联已有</el-radio>
|
||||
<el-radio value="completion_only">直接完单申请</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
@@ -2090,11 +2152,11 @@
|
||||
<el-form-item label="支付金额" prop="pay_amount">
|
||||
<el-input-number
|
||||
v-model="addPayOrderForm.pay_amount"
|
||||
:min="0.01"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
placeholder="请输入支付金额(元)"
|
||||
placeholder="请输入支付金额(元,可为 0)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="pay_remark">
|
||||
@@ -2109,6 +2171,20 @@
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 直接完单申请(不创建/关联支付单) -->
|
||||
<template v-else-if="addPayOrderForm.add_mode === 'completion_only'">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="!mb-0"
|
||||
>
|
||||
<template #title>
|
||||
不新增或关联支付单,仅向审核人员提交「完成订单」申请;提交后支付审核将变为待审核,审核通过后将自动结案。
|
||||
</template>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<!-- 关联已有模式 -->
|
||||
<template v-else>
|
||||
<el-form-item label="选择支付单" prop="link_pay_order_id">
|
||||
@@ -2139,8 +2215,8 @@
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 完单申请选项 -->
|
||||
<el-form-item label="完单申请">
|
||||
<!-- 完单申请选项(创建/关联时可选) -->
|
||||
<el-form-item v-if="addPayOrderForm.add_mode !== 'completion_only'" label="完单申请">
|
||||
<el-radio-group v-model="addPayOrderForm.completion_request">
|
||||
<el-radio :value="0">不申请</el-radio>
|
||||
<el-radio :value="1">申请完成订单</el-radio>
|
||||
@@ -2152,7 +2228,13 @@
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addPayOrderVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="addPayOrderSaving" @click="submitAddPayOrder">确认新增</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="addPayOrderSaving"
|
||||
@click="submitAddPayOrder"
|
||||
>
|
||||
{{ addPayOrderForm.add_mode === 'completion_only' ? '提交申请' : '确认新增' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -2354,17 +2436,43 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-herbs">
|
||||
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
|
||||
<span class="rx-herb-name" v-if="prescriptionTabType === 'internal'">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-name" v-else>{{ h.name }}</span>
|
||||
<span class="rx-herb-total" v-if="prescriptionTabType === 'internal'">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
<span class="rx-herb-total" v-else>{{ h.dosage }}克</span>
|
||||
</div>
|
||||
<template v-if="prescriptionTabType === 'internal'">
|
||||
<template v-if="slipMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipMainHerbs"
|
||||
:key="'main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="slipAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipAuxHerbs"
|
||||
:key="'aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
|
||||
<span class="rx-herb-name">{{ h.name }}</span>
|
||||
<span class="rx-herb-total">{{ h.dosage }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-text">
|
||||
<p>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="prescriptionTabType === 'internal' || rxAuxUsageText">主服法:{{ rxUsageText }}</p>
|
||||
<p v-else>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAuxUsageText">辅服法:{{ rxAuxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
@@ -2597,6 +2705,7 @@ import {
|
||||
prescriptionOrderUpdateAmount,
|
||||
prescriptionOrderSetShipMode,
|
||||
prescriptionOrderAddPayOrder,
|
||||
prescriptionOrderRequestCompletion,
|
||||
prescriptionOrderComplete,
|
||||
prescriptionOrderRevokeRxAudit,
|
||||
prescriptionOrderRevokePayAudit,
|
||||
@@ -2639,9 +2748,39 @@ function openDiagnosisPatientDetailFromOrder() {
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 下单角色(与 server/config/project.php prescription_order_placer_role_ids 一致) */
|
||||
const ORDER_PLACER_ROLE_ID = 6
|
||||
/** 拥有以下角色时可按任意审核人筛选(与 prescription_order_placer_exempt_role_ids 一致) */
|
||||
const ORDER_PLACER_EXEMPT_ROLE_IDS = [3, 8]
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
/** 「下单」角色:下单人筛选仅允许查本人审核过的订单 */
|
||||
const isOrderPlacerAuditFilterRestricted = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return false
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(ORDER_PLACER_ROLE_ID)) return false
|
||||
return !ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 超管 / 经理 / 管理员:可按任意下单人筛选 */
|
||||
const canSearchAnyAuditAdmin = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 是否展示「下单人」筛选(下单角色 + 经理/管理员/超管) */
|
||||
const showAuditAdminFilter = computed(
|
||||
() => isOrderPlacerAuditFilterRestricted.value || canSearchAnyAuditAdmin.value
|
||||
)
|
||||
|
||||
/** 下单人下拉(lists extend.audit_admin_options,下单角色用户) */
|
||||
const auditAdminOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」筛选:纯医助(含角色 2 且不具备处方审核角色)仅能用「支付单审核」筛选
|
||||
*/
|
||||
@@ -2660,6 +2799,8 @@ const prescriptionOrderDetailButtonText = computed(() =>
|
||||
|
||||
/** 与 server/config/project.php prescription_order_finance_roles 保持一致 */
|
||||
const FINANCE_ROLE_IDS = [0, 3, 6]
|
||||
/** 与 server/config/project.php order_edit_all_roles 一致:可绕过「双审通过后创建人禁编」 */
|
||||
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
|
||||
|
||||
/** 判断当前用户是否有查看财务字段(内部成本)的权限 */
|
||||
const canViewFinanceFields = () => {
|
||||
@@ -2865,9 +3006,9 @@ const queryParams = reactive({
|
||||
express_company: '' as '' | 'sf' | 'jd',
|
||||
/** 开方医生(后台:关联处方 creator_id) */
|
||||
doctor_id: '' as number | '',
|
||||
/** 诊单医助(后台:关联诊单 assistant_id) */
|
||||
/** 订单创建人(后台:业务订单 creator_id) */
|
||||
assistant_id: '' as number | '',
|
||||
/** 诊单医助所属部门(后台:assistant_dept_id,la_admin_dept) */
|
||||
/** 创建人所属部门(后台:assistant_dept_id,la_admin_dept) */
|
||||
assistant_dept_id: '' as number | '',
|
||||
fulfillment_status: '' as number | '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
@@ -2877,7 +3018,9 @@ const queryParams = reactive({
|
||||
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
||||
service_channel: '' as '' | '0',
|
||||
/** 列表排除履约已取消(4):重点看板「待处方审核」等用 */
|
||||
exclude_fulfillment_cancelled: 0 as number
|
||||
exclude_fulfillment_cancelled: 0 as number,
|
||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||
audit_admin_id: '' as number | ''
|
||||
})
|
||||
|
||||
const rxAuditTabs = [
|
||||
@@ -2990,6 +3133,11 @@ function normalizePrescriptionOrderListQuery(params: Record<string, unknown>): R
|
||||
}
|
||||
if (!p.order_no) delete p.order_no
|
||||
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
|
||||
if (p.audit_admin_id === '' || p.audit_admin_id === undefined || p.audit_admin_id === null) {
|
||||
delete p.audit_admin_id
|
||||
} else {
|
||||
p.audit_admin_id = Number(p.audit_admin_id)
|
||||
}
|
||||
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
|
||||
if (!p.express_company || p.express_company === '') delete p.express_company
|
||||
if (p.service_channel === '' || p.service_channel === undefined || p.service_channel === null) {
|
||||
@@ -3025,6 +3173,24 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
watch(
|
||||
() => (pager.extend as Record<string, unknown> | undefined)?.audit_admin_options,
|
||||
(raw) => {
|
||||
if (!Array.isArray(raw)) return
|
||||
auditAdminOptions.value = raw
|
||||
.map((r: unknown) => {
|
||||
if (r == null || typeof r !== 'object') return null
|
||||
const row = r as Record<string, unknown>
|
||||
const id = Number(row.id)
|
||||
const name = String(row.name ?? '').trim()
|
||||
if (!Number.isFinite(id) || id <= 0 || name === '') return null
|
||||
return { id, name }
|
||||
})
|
||||
.filter((x): x is { id: number; name: string } => x != null)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 业绩看板等入口:URL 携带创建区间、诊单医助部门 / 医助 id */
|
||||
function applyRouteQueryToPrescriptionOrderList() {
|
||||
const q = route.query
|
||||
@@ -3216,6 +3382,7 @@ function handleReset() {
|
||||
queryParams.payment_slip_audit_status = ''
|
||||
queryParams.supply_mode = ''
|
||||
queryParams.service_channel = ''
|
||||
queryParams.audit_admin_id = ''
|
||||
queryParams.exclude_fulfillment_cancelled = 0
|
||||
resetParams()
|
||||
}
|
||||
@@ -3314,8 +3481,32 @@ function formatTime(v: unknown) {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function canBypassCreatorDualAuditEditLock(): boolean {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (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) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
|
||||
}
|
||||
|
||||
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
|
||||
const uid = Number(userStore.userInfo?.id)
|
||||
return uid > 0 && Number(row.creator_id) === uid
|
||||
}
|
||||
|
||||
/** 处方审核 + 支付单审核均已通过 */
|
||||
function isDualAuditPassed(row: {
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
}) {
|
||||
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canEditRow(row: {
|
||||
creator_id?: number
|
||||
fulfillment_status?: number
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
gancao_reciperl_order_no?: string | null
|
||||
gancao_submit_time?: number | null
|
||||
}) {
|
||||
@@ -3323,6 +3514,15 @@ function canEditRow(row: {
|
||||
// 已完成(3)、已取消(4) 不可编辑
|
||||
if (fs === 3 || fs === 4) return false
|
||||
|
||||
// 双审均已通过:仅订单创建人不可再编辑(超管/全量编辑角色仍可改)
|
||||
if (
|
||||
isCurrentUserOrderCreator(row) &&
|
||||
isDualAuditPassed(row) &&
|
||||
!canBypassCreatorDualAuditEditLock()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
@@ -3700,6 +3900,20 @@ const detailLinkedAppointmentChannelText = computed(() => {
|
||||
|
||||
const detailRxHerbs = computed(() => normalizeSlipHerbs(detailPrescription.value?.herbs))
|
||||
|
||||
/** 详情:是否含辅方药材(用于决定是否展示辅方用量 / 用法) */
|
||||
const detailHasAuxHerbs = computed(() => {
|
||||
const herbs = detailPrescription.value?.herbs
|
||||
if (!Array.isArray(herbs)) return false
|
||||
return herbs.some((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
|
||||
})
|
||||
|
||||
/** 详情:辅方用法(aux_usage JSON 经规范化后的对象,与处方类型一致) */
|
||||
const detailAuxUsage = computed(() => {
|
||||
const rx = detailPrescription.value as any
|
||||
if (!rx) return null
|
||||
return normalizeSlipAuxUsageForm(rx.aux_usage, rx.prescription_type || '浓缩水丸')
|
||||
})
|
||||
|
||||
/** false=无权限;true/缺省兼容旧接口(旧版未下发该字段时仍展示药材) */
|
||||
const detailHerbsVisible = computed(() => detailData.value?.prescription_detail_herbs_visible !== false)
|
||||
|
||||
@@ -3755,6 +3969,40 @@ const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
|
||||
let logisticsTraceRequestSeq = 0
|
||||
|
||||
type LogisticsTraceRequestContext = {
|
||||
requestSeq: number
|
||||
orderId: number
|
||||
trackingNumber: string
|
||||
}
|
||||
|
||||
function bumpLogisticsTraceRequestToken() {
|
||||
logisticsTraceRequestSeq += 1
|
||||
logisticsTracePayload.value = null
|
||||
}
|
||||
|
||||
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
|
||||
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
|
||||
if (Number(detailData.value?.id) !== ctx.orderId) return false
|
||||
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
|
||||
}
|
||||
|
||||
function parseLogisticsTracePayload(
|
||||
res: unknown,
|
||||
expectedTrackingNumber: string,
|
||||
expectedOrderId: number
|
||||
): Record<string, any> | null {
|
||||
const raw = (res as { data?: unknown })?.data ?? res
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const payload = raw as Record<string, any>
|
||||
const respNum = String(payload.tracking_number || '').trim()
|
||||
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||
const respOrderId = Number(payload.order_id)
|
||||
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
const detailLogs = ref<any[]>([])
|
||||
const updateAmountVisible = ref(false)
|
||||
@@ -3811,6 +4059,8 @@ function logActionText(act: string) {
|
||||
ship: '确认发货',
|
||||
withdraw: '撤销',
|
||||
link_pay_order: '关联支付单',
|
||||
completion_request: '完单申请',
|
||||
auto_complete: '自动完成',
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
@@ -3918,24 +4168,33 @@ function expressCompanyLabel(v: unknown) {
|
||||
}
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
const orderId = Number(detailData.value?.id)
|
||||
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
|
||||
if (!orderId || !trackingNumber) return
|
||||
|
||||
const requestSeq = ++logisticsTraceRequestSeq
|
||||
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
|
||||
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id,
|
||||
id: orderId,
|
||||
express_company: detailLogisticsExpress.value
|
||||
}
|
||||
if (digits.length >= 4) {
|
||||
params.phone_tail = digits
|
||||
}
|
||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
|
||||
} catch {
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
if (isCurrentLogisticsTraceContext(ctx)) {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4061,7 +4320,7 @@ async function openDetail(id: number) {
|
||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
logisticsTracePayload.value = null
|
||||
bumpLogisticsTraceRequestToken()
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
logisticsTracePhoneTail.value = ''
|
||||
detailLogs.value = []
|
||||
@@ -4313,7 +4572,25 @@ async function goEditOrderNextStep() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(row: { id: number }) {
|
||||
async function openEdit(row: {
|
||||
id: number
|
||||
creator_id?: number
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
gancao_reciperl_order_no?: string | null
|
||||
gancao_submit_time?: number | null
|
||||
}) {
|
||||
if (!canEditRow(row)) {
|
||||
if (
|
||||
isCurrentUserOrderCreator(row) &&
|
||||
isDualAuditPassed(row) &&
|
||||
!canBypassCreatorDualAuditEditLock()
|
||||
) {
|
||||
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
|
||||
}
|
||||
return
|
||||
}
|
||||
editOrderStep.value = 0
|
||||
editOrderPrescription.value = null
|
||||
editVisible.value = true
|
||||
@@ -4904,35 +5181,47 @@ const completeOrderDialogVisible = ref(false)
|
||||
const completeOrderId = ref(0)
|
||||
const completeFulfillmentStatus = ref<number>(3)
|
||||
const completeOrderSubmitting = ref(false)
|
||||
let completeOrderLogisticsRequestSeq = 0
|
||||
|
||||
function onCompleteOrderDialogClosed() {
|
||||
completeOrderId.value = 0
|
||||
completeOrderLogisticsRequestSeq += 1
|
||||
completeOrderLogisticsPayload.value = null
|
||||
completeOrderLogisticsLoading.value = false
|
||||
}
|
||||
|
||||
async function fetchCompleteOrderLogisticsForDialog(row: {
|
||||
id: number
|
||||
tracking_number?: unknown
|
||||
express_company?: unknown
|
||||
recipient_phone?: unknown
|
||||
}) {
|
||||
const orderId = Number(row.id)
|
||||
const trackingNumber = String(row.tracking_number || '').trim()
|
||||
if (!orderId || !trackingNumber) return
|
||||
|
||||
const requestSeq = ++completeOrderLogisticsRequestSeq
|
||||
completeOrderLogisticsLoading.value = true
|
||||
completeOrderLogisticsPayload.value = null
|
||||
try {
|
||||
const digits = String(row.recipient_phone || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id: row.id,
|
||||
id: orderId,
|
||||
express_company: String(row.express_company || 'auto') || 'auto'
|
||||
}
|
||||
if (digits.length >= 4) {
|
||||
params.phone_tail = digits
|
||||
}
|
||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
||||
completeOrderLogisticsPayload.value = (res?.data ?? res) as Record<string, any>
|
||||
if (requestSeq !== completeOrderLogisticsRequestSeq || completeOrderId.value !== orderId) return
|
||||
completeOrderLogisticsPayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
|
||||
} catch {
|
||||
if (requestSeq !== completeOrderLogisticsRequestSeq || completeOrderId.value !== orderId) return
|
||||
completeOrderLogisticsPayload.value = null
|
||||
} finally {
|
||||
completeOrderLogisticsLoading.value = false
|
||||
if (requestSeq === completeOrderLogisticsRequestSeq && completeOrderId.value === orderId) {
|
||||
completeOrderLogisticsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4966,8 +5255,15 @@ const addPayOrderRowId = ref(0)
|
||||
const addPayOrderFormRef = ref<FormInstance>()
|
||||
const addPayOrderAvailableLoading = ref(false)
|
||||
const addPayOrderAvailableList = ref<any[]>([])
|
||||
const addPayOrderAlertTitle = computed(() => {
|
||||
if (addPayOrderForm.add_mode === 'completion_only') {
|
||||
return '不创建或关联支付单,仅提交完单申请,由审核人员在支付审核时处理。'
|
||||
}
|
||||
return '可以手动创建新支付单,或关联已存在但未绑定的支付单。'
|
||||
})
|
||||
|
||||
const addPayOrderForm = reactive({
|
||||
add_mode: 'create' as 'create' | 'link',
|
||||
add_mode: 'create' as 'create' | 'link' | 'completion_only',
|
||||
order_type: 3,
|
||||
pay_amount: undefined as number | undefined,
|
||||
pay_remark: '',
|
||||
@@ -4975,38 +5271,47 @@ const addPayOrderForm = reactive({
|
||||
completion_request: 0
|
||||
})
|
||||
|
||||
const addPayOrderRules: FormRules = {
|
||||
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }],
|
||||
order_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }],
|
||||
pay_amount: [
|
||||
{ required: true, message: '请输入支付金额', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, v, cb) => {
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
cb(new Error('金额须大于 0'))
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
link_pay_order_ids: [
|
||||
{
|
||||
required: true,
|
||||
message: '请至少选择一个支付单',
|
||||
trigger: 'change',
|
||||
validator: (_rule, v, cb) => {
|
||||
if (!Array.isArray(v) || v.length === 0) {
|
||||
cb(new Error('请至少选择一个支付单'))
|
||||
} else {
|
||||
cb()
|
||||
const addPayOrderRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {
|
||||
add_mode: [{ required: true, message: '请选择添加方式', trigger: 'change' }]
|
||||
}
|
||||
if (addPayOrderForm.add_mode === 'create') {
|
||||
rules.order_type = [{ required: true, message: '请选择费用类别', trigger: 'change' }]
|
||||
rules.pay_amount = [
|
||||
{
|
||||
validator: (_rule, v, cb) => {
|
||||
if (v === undefined || v === null || v === '') {
|
||||
cb(new Error('请输入支付金额'))
|
||||
return
|
||||
}
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
cb(new Error('金额不能小于 0'))
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
} else if (addPayOrderForm.add_mode === 'link') {
|
||||
rules.link_pay_order_ids = [
|
||||
{
|
||||
required: true,
|
||||
message: '请至少选择一个支付单',
|
||||
trigger: 'change',
|
||||
validator: (_rule, v, cb) => {
|
||||
if (!Array.isArray(v) || v.length === 0) {
|
||||
cb(new Error('请至少选择一个支付单'))
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return rules
|
||||
})
|
||||
|
||||
async function loadAddPayOrderAvailable(diagnosisId: number, currentLinkedIds: number[] = []) {
|
||||
if (!diagnosisId) {
|
||||
@@ -5054,7 +5359,10 @@ async function submitAddPayOrder() {
|
||||
await addPayOrderFormRef.value.validate()
|
||||
addPayOrderSaving.value = true
|
||||
try {
|
||||
if (addPayOrderForm.add_mode === 'create') {
|
||||
if (addPayOrderForm.add_mode === 'completion_only') {
|
||||
await prescriptionOrderRequestCompletion({ id: addPayOrderRowId.value })
|
||||
feedback.msgSuccess('完单申请已提交,请等待支付审核')
|
||||
} else if (addPayOrderForm.add_mode === 'create') {
|
||||
// 手动创建新支付单
|
||||
await prescriptionOrderAddPayOrder({
|
||||
id: addPayOrderRowId.value,
|
||||
@@ -5176,12 +5484,116 @@ const prescriptionTabType = ref('internal')
|
||||
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
|
||||
const prescriptionSlipExporting = ref(false)
|
||||
|
||||
type SlipFormulaType = '主方' | '辅方'
|
||||
|
||||
type SlipAuxUsageForm = {
|
||||
dosage_amount?: number
|
||||
dosage_bag_count: number
|
||||
need_decoction: boolean
|
||||
bags_per_dose: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
}
|
||||
|
||||
function normalizeSlipFormulaType(v: unknown): SlipFormulaType {
|
||||
return v === '辅方' ? '辅方' : '主方'
|
||||
}
|
||||
|
||||
function defaultSlipAuxUsage(prescriptionType = '浓缩水丸'): SlipAuxUsageForm {
|
||||
if (prescriptionType === '饮片') {
|
||||
return {
|
||||
dosage_amount: 50,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
if (prescriptionType === '浓缩水丸') {
|
||||
return {
|
||||
dosage_amount: 5,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
return {
|
||||
dosage_amount: 1,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSlipAuxUsageForm(raw: unknown, prescriptionType: string): SlipAuxUsageForm {
|
||||
const base = defaultSlipAuxUsage(prescriptionType)
|
||||
if (!raw || typeof raw !== 'object') return { ...base }
|
||||
const o = raw as Record<string, unknown>
|
||||
return {
|
||||
dosage_amount:
|
||||
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
|
||||
? Number(o.dosage_amount)
|
||||
: base.dosage_amount,
|
||||
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
|
||||
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
|
||||
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
|
||||
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
|
||||
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days
|
||||
}
|
||||
}
|
||||
|
||||
function buildSlipUsageSegmentText(
|
||||
usage: {
|
||||
prescription_type?: string
|
||||
dosage_amount?: number | null
|
||||
dosage_unit?: string
|
||||
dosage_bag_count?: number
|
||||
times_per_day?: number
|
||||
usage_way?: string
|
||||
usage_time?: string
|
||||
},
|
||||
fallbackWay?: string,
|
||||
fallbackTime?: string
|
||||
): string {
|
||||
const pt = usage.prescription_type || '浓缩水丸'
|
||||
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
|
||||
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
|
||||
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
|
||||
const usageWay = usage.usage_way || fallbackWay || '温水送服'
|
||||
const usageTime = usage.usage_time || fallbackTime || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
if (pt === '浓缩水丸') {
|
||||
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
}
|
||||
|
||||
// 处方单辅助函数
|
||||
const slipHerbsList = computed(() => {
|
||||
const h = prescriptionViewData.value?.herbs
|
||||
return Array.isArray(h) ? h : []
|
||||
})
|
||||
|
||||
const slipMainHerbs = computed(() =>
|
||||
slipHerbsList.value.filter((h: any) => normalizeSlipFormulaType(h?.formula_type) === '主方')
|
||||
)
|
||||
|
||||
const slipAuxHerbs = computed(() =>
|
||||
slipHerbsList.value.filter((h: any) => normalizeSlipFormulaType(h?.formula_type) === '辅方')
|
||||
)
|
||||
|
||||
const slipDietaryText = computed(() => {
|
||||
const d = prescriptionViewData.value?.dietary_taboo
|
||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||
@@ -5217,6 +5629,22 @@ const slipPillGrams = computed(() => {
|
||||
return days * times * doseG * bags
|
||||
})
|
||||
|
||||
/** 辅方出丸克数:仅含辅方药材且为浓缩水丸时计算;公式同主方,用 aux_usage 字段 */
|
||||
const slipAuxPillGrams = computed(() => {
|
||||
const d = prescriptionViewData.value as any
|
||||
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
|
||||
if (!slipAuxHerbs.value.length) return null
|
||||
const aux = normalizeSlipAuxUsageForm(d.aux_usage, d.prescription_type || '浓缩水丸')
|
||||
/** 辅方疗程优先用业务订单 medication_days(与主方共享一次配药周期),缺省回退辅方 usage_days */
|
||||
const days = Number(d.medication_days ?? aux.usage_days)
|
||||
const times = Number(aux.times_per_day)
|
||||
const doseG = Number(aux.dosage_amount)
|
||||
const bags = Number(aux.dosage_bag_count) > 0 ? Number(aux.dosage_bag_count) : 1
|
||||
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
|
||||
if (days <= 0 || times <= 0 || doseG <= 0) return null
|
||||
return days * times * doseG * bags
|
||||
})
|
||||
|
||||
/* ============================================================
|
||||
* A4 处方笺(药房联)相关计算 —— 与 consumer/prescription/index.vue 保持一致
|
||||
* ============================================================ */
|
||||
@@ -5308,29 +5736,36 @@ const rxUsageText = computed(() => {
|
||||
const v = prescriptionViewData.value as any
|
||||
if (!v) return '—'
|
||||
if (v.usage_text) return v.usage_text
|
||||
const times = Number(v.times_per_day) > 0 ? Number(v.times_per_day) : 3
|
||||
const amount = v.dosage_amount != null ? Number(v.dosage_amount) : 10
|
||||
const unit = v.dosage_unit || 'g'
|
||||
const usageWay = v.usage_way || '温水送服'
|
||||
const usageTime = v.usage_time || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
if ((v.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const bags = Number(v.dosage_bag_count) > 0 ? Number(v.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
return buildSlipUsageSegmentText(v)
|
||||
})
|
||||
|
||||
/** 出丸:优先用 slipPillGrams(每袋用量×袋数×每天次数×服用天数),否则按药材总量×剂数 */
|
||||
const rxAuxUsageText = computed(() => {
|
||||
const v = prescriptionViewData.value as any
|
||||
if (!v || !slipAuxHerbs.value.length) return ''
|
||||
const aux = normalizeSlipAuxUsageForm(v.aux_usage, v.prescription_type || '浓缩水丸')
|
||||
return buildSlipUsageSegmentText(
|
||||
{
|
||||
prescription_type: v.prescription_type,
|
||||
dosage_amount: aux.dosage_amount,
|
||||
dosage_unit: v.dosage_unit,
|
||||
dosage_bag_count: aux.dosage_bag_count,
|
||||
times_per_day: aux.times_per_day,
|
||||
usage_way: v.usage_way,
|
||||
usage_time: v.usage_time
|
||||
},
|
||||
v.usage_way,
|
||||
v.usage_time
|
||||
)
|
||||
})
|
||||
|
||||
/** 出丸:优先用 slipPillGrams / slipAuxPillGrams(含辅方时相加),否则按药材总量×剂数 */
|
||||
const rxOutPelletGrams = computed(() => {
|
||||
const pill = slipPillGrams.value
|
||||
if (typeof pill === 'number' && isFinite(pill) && pill > 0) return formatNum(pill)
|
||||
const main = slipPillGrams.value
|
||||
const aux = slipAuxPillGrams.value
|
||||
const mainNum = typeof main === 'number' && isFinite(main) && main > 0 ? main : 0
|
||||
const auxNum = typeof aux === 'number' && isFinite(aux) && aux > 0 ? aux : 0
|
||||
const sum = mainNum + auxNum
|
||||
if (sum > 0) return formatNum(sum)
|
||||
const v = prescriptionViewData.value as any
|
||||
if (!v) return ''
|
||||
const explicit = v.out_pellet || v.total_weight
|
||||
@@ -5366,7 +5801,17 @@ const rxPharmacyRemarkText = computed(() => {
|
||||
|
||||
const rxOutPelletText = computed(() => {
|
||||
const out = rxOutPelletGrams.value
|
||||
return out ? `${out}克` : ''
|
||||
if (!out) return ''
|
||||
/** 含辅方时附带「主方 X克 + 辅方 Y克」明细,便于药房核对 */
|
||||
const main = slipPillGrams.value
|
||||
const aux = slipAuxPillGrams.value
|
||||
if (
|
||||
typeof main === 'number' && isFinite(main) && main > 0 &&
|
||||
typeof aux === 'number' && isFinite(aux) && aux > 0
|
||||
) {
|
||||
return `${out}克(主方 ${formatNum(main)}克 + 辅方 ${formatNum(aux)}克)`
|
||||
}
|
||||
return `${out}克`
|
||||
})
|
||||
|
||||
function formatNum(n: number): string {
|
||||
@@ -6127,6 +6572,19 @@ async function downloadPrescriptionSlipPdf() {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rx-herb-section-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rx-herb-section-label--aux {
|
||||
color: #e6a23c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rx-herb-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
|
||||
@@ -234,6 +234,22 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showAuditAdminFilter" class="w-[200px]" label="下单人">
|
||||
<el-select
|
||||
v-model="queryParams.audit_admin_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部下单人"
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="a in auditAdminOptions"
|
||||
:key="a.id"
|
||||
:label="a.name"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[200px]" label="医生">
|
||||
<el-select
|
||||
v-model="queryParams.doctor_id"
|
||||
@@ -2440,9 +2456,39 @@ function openDiagnosisPatientDetailFromOrder() {
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 下单角色(与 server/config/project.php prescription_order_placer_role_ids 一致) */
|
||||
const ORDER_PLACER_ROLE_ID = 6
|
||||
/** 拥有以下角色时可按任意审核人筛选(与 prescription_order_placer_exempt_role_ids 一致) */
|
||||
const ORDER_PLACER_EXEMPT_ROLE_IDS = [3, 8]
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
/** 「下单」角色:下单人筛选仅允许查本人审核过的订单 */
|
||||
const isOrderPlacerAuditFilterRestricted = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return false
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(ORDER_PLACER_ROLE_ID)) return false
|
||||
return !ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 超管 / 经理 / 管理员:可按任意下单人筛选 */
|
||||
const canSearchAnyAuditAdmin = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return ORDER_PLACER_EXEMPT_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
})
|
||||
|
||||
/** 是否展示「下单人」筛选(下单角色 + 经理/管理员/超管) */
|
||||
const showAuditAdminFilter = computed(
|
||||
() => isOrderPlacerAuditFilterRestricted.value || canSearchAnyAuditAdmin.value
|
||||
)
|
||||
|
||||
/** 下单人下拉(lists extend.audit_admin_options,下单角色用户) */
|
||||
const auditAdminOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」筛选:纯医助(含角色 2 且不具备处方审核角色)仅能用「支付单审核」筛选
|
||||
*/
|
||||
@@ -2673,7 +2719,9 @@ const queryParams = reactive({
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self'
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||
audit_admin_id: '' as number | ''
|
||||
})
|
||||
|
||||
const rxAuditTabs = [
|
||||
@@ -2767,6 +2815,11 @@ async function fetchLists(params: Record<string, unknown>) {
|
||||
}
|
||||
if (!p.order_no) delete p.order_no
|
||||
if (!String(p.patient_keyword || '').trim()) delete p.patient_keyword
|
||||
if (p.audit_admin_id === '' || p.audit_admin_id === undefined || p.audit_admin_id === null) {
|
||||
delete p.audit_admin_id
|
||||
} else {
|
||||
p.audit_admin_id = Number(p.audit_admin_id)
|
||||
}
|
||||
if (!String(p.express_keyword || '').trim()) delete p.express_keyword
|
||||
if (!p.express_company || p.express_company === '') delete p.express_company
|
||||
if (!String(p.start_time || '').trim() || !String(p.end_time || '').trim()) {
|
||||
@@ -2789,6 +2842,24 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
watch(
|
||||
() => (pager.extend as Record<string, unknown> | undefined)?.audit_admin_options,
|
||||
(raw) => {
|
||||
if (!Array.isArray(raw)) return
|
||||
auditAdminOptions.value = raw
|
||||
.map((r: unknown) => {
|
||||
if (r == null || typeof r !== 'object') return null
|
||||
const row = r as Record<string, unknown>
|
||||
const id = Number(row.id)
|
||||
const name = String(row.name ?? '').trim()
|
||||
if (!Number.isFinite(id) || id <= 0 || name === '') return null
|
||||
return { id, name }
|
||||
})
|
||||
.filter((x): x is { id: number; name: string } => x != null)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 选医助部门后,医助下拉仅显示该部门及子部门成员(与后台含子级一致) */
|
||||
const filteredAssistantOptions = computed(() => {
|
||||
const deptRaw = queryParams.assistant_dept_id
|
||||
@@ -2950,6 +3021,7 @@ function handleReset() {
|
||||
queryParams.prescription_audit_status = ''
|
||||
queryParams.payment_slip_audit_status = ''
|
||||
queryParams.supply_mode = ''
|
||||
queryParams.audit_admin_id = ''
|
||||
resetParams()
|
||||
}
|
||||
|
||||
@@ -3047,14 +3119,48 @@ function formatTime(v: unknown) {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
/** 与 server/config/project.php order_edit_all_roles 一致 */
|
||||
const ORDER_EDIT_ALL_ROLE_IDS = [0, 3]
|
||||
|
||||
function canBypassCreatorDualAuditEditLock(): boolean {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (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) => ORDER_EDIT_ALL_ROLE_IDS.includes(id))
|
||||
}
|
||||
|
||||
function isCurrentUserOrderCreator(row: { creator_id?: number }) {
|
||||
const uid = Number(userStore.userInfo?.id)
|
||||
return uid > 0 && Number(row.creator_id) === uid
|
||||
}
|
||||
|
||||
function isDualAuditPassed(row: {
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
}) {
|
||||
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canEditRow(row: {
|
||||
creator_id?: number
|
||||
fulfillment_status?: number
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
gancao_reciperl_order_no?: string | null
|
||||
gancao_submit_time?: number | null
|
||||
}) {
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4) return false
|
||||
|
||||
if (
|
||||
isCurrentUserOrderCreator(row) &&
|
||||
isDualAuditPassed(row) &&
|
||||
!canBypassCreatorDualAuditEditLock()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
@@ -3406,6 +3512,40 @@ const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
/** 顺丰/快递100:与运单一致的收件手机后四位(可覆盖订单收货手机) */
|
||||
const logisticsTracePhoneTail = ref('')
|
||||
/** 物流轨迹请求序号:同手机多运单或快速切换订单时,丢弃过期的异步响应 */
|
||||
let logisticsTraceRequestSeq = 0
|
||||
|
||||
type LogisticsTraceRequestContext = {
|
||||
requestSeq: number
|
||||
orderId: number
|
||||
trackingNumber: string
|
||||
}
|
||||
|
||||
function bumpLogisticsTraceRequestToken() {
|
||||
logisticsTraceRequestSeq += 1
|
||||
logisticsTracePayload.value = null
|
||||
}
|
||||
|
||||
function isCurrentLogisticsTraceContext(ctx: LogisticsTraceRequestContext): boolean {
|
||||
if (ctx.requestSeq !== logisticsTraceRequestSeq) return false
|
||||
if (Number(detailData.value?.id) !== ctx.orderId) return false
|
||||
return String(detailData.value?.tracking_number || '').trim() === ctx.trackingNumber
|
||||
}
|
||||
|
||||
function parseLogisticsTracePayload(
|
||||
res: unknown,
|
||||
expectedTrackingNumber: string,
|
||||
expectedOrderId: number
|
||||
): Record<string, any> | null {
|
||||
const raw = (res as { data?: unknown })?.data ?? res
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const payload = raw as Record<string, any>
|
||||
const respNum = String(payload.tracking_number || '').trim()
|
||||
if (respNum && respNum !== expectedTrackingNumber) return null
|
||||
const respOrderId = Number(payload.order_id)
|
||||
if (respOrderId > 0 && respOrderId !== expectedOrderId) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
const detailLogs = ref<any[]>([])
|
||||
const updateAmountVisible = ref(false)
|
||||
@@ -3500,24 +3640,33 @@ function expressCompanyLabel(v: unknown) {
|
||||
}
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
const orderId = Number(detailData.value?.id)
|
||||
const trackingNumber = String(detailData.value?.tracking_number || '').trim()
|
||||
if (!orderId || !trackingNumber) return
|
||||
|
||||
const requestSeq = ++logisticsTraceRequestSeq
|
||||
const ctx: LogisticsTraceRequestContext = { requestSeq, orderId, trackingNumber }
|
||||
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const digits = String(logisticsTracePhoneTail.value || '').replace(/\D/g, '')
|
||||
const params: { id: number; express_company?: string; phone_tail?: string } = {
|
||||
id,
|
||||
id: orderId,
|
||||
express_company: detailLogisticsExpress.value
|
||||
}
|
||||
if (digits.length >= 4) {
|
||||
params.phone_tail = digits
|
||||
}
|
||||
const res: any = await prescriptionOrderLogisticsTrace(params)
|
||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = parseLogisticsTracePayload(res, trackingNumber, orderId)
|
||||
} catch {
|
||||
if (!isCurrentLogisticsTraceContext(ctx)) return
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
if (isCurrentLogisticsTraceContext(ctx)) {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3643,7 +3792,7 @@ async function openDetail(id: number) {
|
||||
// 修复 Bug:显式彻底清空缓存,防止前一次弹窗的数据残留
|
||||
detailData.value = null
|
||||
detailUnlinkedPayOrders.value = []
|
||||
logisticsTracePayload.value = null
|
||||
bumpLogisticsTraceRequestToken()
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
logisticsTracePhoneTail.value = ''
|
||||
detailLogs.value = []
|
||||
@@ -3659,7 +3808,7 @@ async function openDetail(id: number) {
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace()
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
fetchLogs(id)
|
||||
|
||||
@@ -3889,7 +4038,25 @@ async function goEditOrderNextStep() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(row: { id: number }) {
|
||||
async function openEdit(row: {
|
||||
id: number
|
||||
creator_id?: number
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
gancao_reciperl_order_no?: string | null
|
||||
gancao_submit_time?: number | null
|
||||
}) {
|
||||
if (!canEditRow(row)) {
|
||||
if (
|
||||
isCurrentUserOrderCreator(row) &&
|
||||
isDualAuditPassed(row) &&
|
||||
!canBypassCreatorDualAuditEditLock()
|
||||
) {
|
||||
feedback.msgWarning('处方与支付单均已审核通过,创建人不可再编辑')
|
||||
}
|
||||
return
|
||||
}
|
||||
editOrderStep.value = 0
|
||||
editOrderPrescription.value = null
|
||||
editVisible.value = true
|
||||
|
||||
+238
-48
@@ -127,10 +127,10 @@
|
||||
<span class="yeji-table-tabs-head__title">表格统计</span>
|
||||
<span class="yeji-table-tabs-head__hint">列较多时可横向滚动浏览;「部门 / 排名」列在滚动时保持固定。</span>
|
||||
</div>
|
||||
<el-tabs v-model="yejiDisplayTab" class="yeji-view-tabs">
|
||||
<el-tab-pane label="医生统计" name="doctor" lazy>
|
||||
<el-tabs v-if="hasAnyYejiTableTab" v-model="yejiDisplayTab" class="yeji-view-tabs">
|
||||
<el-tab-pane v-if="canViewDoctorTab" label="医生统计" name="doctor" lazy>
|
||||
<el-card
|
||||
v-if="canViewDoctorDailyStats"
|
||||
v-if="canViewDoctorTab"
|
||||
class="yeji-panel doctor-daily-card doctor-daily-card--nested"
|
||||
shadow="never"
|
||||
>
|
||||
@@ -189,7 +189,7 @@
|
||||
<el-empty v-else description="当前账号无医生统计权限" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="医助排行榜" name="leaderboard" lazy>
|
||||
<el-tab-pane v-if="canViewLeaderboardTab" label="医助排行榜" name="leaderboard" lazy>
|
||||
<div
|
||||
v-if="leaderboardBlock || leaderboardsLoading"
|
||||
class="leaderboards-wrap"
|
||||
@@ -316,7 +316,7 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
||||
<el-tab-pane v-if="canViewZyytTab" label="甄养堂互联网医院诊金" name="zyyt" lazy>
|
||||
<!-- ── 业绩表(4 张/N 张);目标看板见「目标看板(卡片)」Tab ── -->
|
||||
<div v-loading="loading" class="tables-wrap">
|
||||
<div v-if="!loading && tables.length === 0" class="empty-tip">
|
||||
@@ -445,12 +445,8 @@
|
||||
<el-tooltip placement="top" effect="dark" :show-after="200">
|
||||
<template #content>
|
||||
<div style="max-width: 320px; line-height: 1.7; font-size: 12px">
|
||||
<<<<<<< HEAD
|
||||
<b>业务订单条数</b>(计业绩):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ∉ {4,9,10}</b>(<b>NULL 计入</b>);按<b>订单创建人</b>的人事部门落在该部门子树即计入(表格多行命中时取最深的展示部门)。与<b>合计业绩 / 医助排行榜接诊诊单</b>同口径。选定渠道时本列仍为全量。
|
||||
=======
|
||||
<b>业务订单条数</b>(计业绩):订单 <b>create_time</b> 落入区间、<b>fulfillment_status ∉ {4,9,10}</b>;与列表筛选
|
||||
<b>assistant_dept_id</b> 时一致——<b>创建人</b>人事部门优先,无创建人则诊单 <b>医助</b>;落在该部门子树即计入(表格多行命中时取最深的展示部门)。与侧栏勾选「与表格业绩对齐」时的集合可能略有差异。选定渠道时本列仍为全量。
|
||||
>>>>>>> master
|
||||
</div>
|
||||
</template>
|
||||
<el-icon class="col-info"><InfoFilled /></el-icon>
|
||||
@@ -636,7 +632,7 @@
|
||||
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="目标看板(卡片)" name="zyyt_matrix" lazy>
|
||||
<el-tab-pane v-if="canViewTargetMatrixTab" label="目标看板(卡片)" name="zyyt_matrix" lazy>
|
||||
<div v-loading="targetProgressLoading" class="tp-matrix-wrap">
|
||||
<template v-if="targetProgressCard">
|
||||
<h2 class="tp-matrix__title">{{ targetProgressCard.title }}</h2>
|
||||
@@ -702,18 +698,19 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-empty v-else description="当前账号无表格统计 Tab 权限" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="yeji-panel yeji-charts-only-card" shadow="never">
|
||||
<div class="yeji-charts-only-head">统计图</div>
|
||||
<div
|
||||
v-loading="loading || (canViewDoctorDailyStats && doctorDailyLoading) || leaderboardsLoading"
|
||||
v-loading="loading || (canViewDoctorTab && doctorDailyLoading) || (canViewLeaderboardTab && leaderboardsLoading)"
|
||||
class="yeji-charts-stack"
|
||||
>
|
||||
<template v-if="chartsHasAnyData">
|
||||
<section
|
||||
v-if="
|
||||
canViewDoctorDailyStats &&
|
||||
canViewDoctorTab &&
|
||||
(doctorDealBarHasData ||
|
||||
doctorRxStackHasData ||
|
||||
doctorAppointmentPieHasData ||
|
||||
@@ -855,7 +852,7 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="leaderboardBlock">
|
||||
<template v-if="canViewLeaderboardTab && leaderboardBlock">
|
||||
<section
|
||||
v-for="lb in leaderboardChartBlocks"
|
||||
:key="'lb-chart-' + lb.dept_id"
|
||||
@@ -898,8 +895,8 @@
|
||||
<el-empty
|
||||
v-else-if="
|
||||
!loading &&
|
||||
(!canViewDoctorDailyStats || !doctorDailyLoading) &&
|
||||
!leaderboardsLoading
|
||||
(!canViewDoctorTab || !doctorDailyLoading) &&
|
||||
(!canViewLeaderboardTab || !leaderboardsLoading)
|
||||
"
|
||||
class="yeji-charts-empty"
|
||||
description="当前筛选下暂无图表数据,请调整条件后查询"
|
||||
@@ -1159,9 +1156,19 @@
|
||||
class="yeji-leadlines-dialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="yeji-unassigned-dialog__head">
|
||||
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
|
||||
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
|
||||
<div class="yeji-unassigned-dialog__head yeji-leadlines-dialog__head">
|
||||
<div class="yeji-leadlines-dialog__head-main">
|
||||
<span class="yeji-unassigned-dialog__title">进线数据明细</span>
|
||||
<p class="yeji-unassigned-dialog__sub">{{ leadLinesSubtitle }}</p>
|
||||
</div>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="leadLinesExporting"
|
||||
:disabled="leadLinesLoading || leadLinesExporting || leadLinesCount <= 0"
|
||||
@click="exportLeadLines"
|
||||
>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="leadLinesApiNote" class="yeji-unassigned-dialog__note">{{ leadLinesApiNote }}</p>
|
||||
@@ -1318,7 +1325,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { Search, RefreshRight, InfoFilled } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -1337,7 +1344,14 @@ import {
|
||||
} from '@/api/stats'
|
||||
import { deptPerformanceTargetMonthMatrix } from '@/api/finance'
|
||||
import { prescriptionOrderLists } from '@/api/tcm'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
|
||||
type YejiDisplayTab = 'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'
|
||||
|
||||
/** 任一权限命中即可(兼容原有接口按钮权限 + 新增 Tab 独立权限) */
|
||||
function hasAnyPermission(perms: string[]): boolean {
|
||||
return perms.some(p => hasPermission([p]))
|
||||
}
|
||||
|
||||
interface DeptOption {
|
||||
id: number
|
||||
@@ -1626,6 +1640,7 @@ const revisitBreakdownMeta = ref<{ start: string; end: string; revisitSlot: numb
|
||||
|
||||
const leadLinesDialogVisible = ref(false)
|
||||
const leadLinesLoading = ref(false)
|
||||
const leadLinesExporting = ref(false)
|
||||
const leadLinesSubtitle = ref('')
|
||||
const leadLinesApiNote = ref('')
|
||||
const leadLinesRows = ref<YejiLeadLineRow[]>([])
|
||||
@@ -1664,14 +1679,60 @@ const appointmentLinesShowChannelColumn = computed(() =>
|
||||
)
|
||||
|
||||
/** 图表 / 数据表切换 */
|
||||
const yejiDisplayTab = ref<'doctor' | 'leaderboard' | 'zyyt' | 'zyyt_matrix'>('zyyt')
|
||||
const yejiDisplayTab = ref<YejiDisplayTab>('zyyt')
|
||||
|
||||
/** 与菜单 stats.doctorDailyStats/overview 一致;无权限则不展示医生统计表/图、不请求接口 */
|
||||
const userStore = useUserStore()
|
||||
const DOCTOR_DAILY_STATS_PERM = 'stats.doctorDailyStats/overview'
|
||||
const canViewDoctorDailyStats = computed(() => {
|
||||
const perms = userStore.perms || []
|
||||
return perms.some(p => p === '*' || p === DOCTOR_DAILY_STATS_PERM)
|
||||
/** Tab 可见性:优先认 Tab 独立权限;未配置时兼容原有接口按钮权限,避免破坏已有角色 */
|
||||
const YEJI_TAB_DOCTOR_PERM = 'stats.yejiStats/tabDoctor'
|
||||
const YEJI_TAB_LEADERBOARD_PERM = 'stats.yejiStats/tabLeaderboard'
|
||||
const YEJI_TAB_ZYYT_PERM = 'stats.yejiStats/tabZyyt'
|
||||
const YEJI_TAB_TARGET_MATRIX_PERM = 'stats.yejiStats/tabTargetMatrix'
|
||||
|
||||
const canViewDoctorTab = computed(() =>
|
||||
hasAnyPermission([YEJI_TAB_DOCTOR_PERM, 'stats.doctorDailyStats/overview'])
|
||||
)
|
||||
const canViewLeaderboardTab = computed(() =>
|
||||
hasAnyPermission([YEJI_TAB_LEADERBOARD_PERM, 'stats.yejiStats/leaderboard'])
|
||||
)
|
||||
const canViewZyytTab = computed(() =>
|
||||
hasAnyPermission([
|
||||
YEJI_TAB_ZYYT_PERM,
|
||||
'stats.yejiStats/multi',
|
||||
'stats.yejiStats/overview',
|
||||
])
|
||||
)
|
||||
const canViewTargetMatrixTab = computed(() =>
|
||||
hasAnyPermission([
|
||||
YEJI_TAB_TARGET_MATRIX_PERM,
|
||||
'stats.yejiStats/targetMatrix',
|
||||
'stats.yejiStats/multi',
|
||||
])
|
||||
)
|
||||
const hasAnyYejiTableTab = computed(
|
||||
() =>
|
||||
canViewDoctorTab.value ||
|
||||
canViewLeaderboardTab.value ||
|
||||
canViewZyytTab.value ||
|
||||
canViewTargetMatrixTab.value
|
||||
)
|
||||
|
||||
function pickInitialYejiTab(): YejiDisplayTab {
|
||||
if (canViewZyytTab.value) return 'zyyt'
|
||||
if (canViewDoctorTab.value) return 'doctor'
|
||||
if (canViewLeaderboardTab.value) return 'leaderboard'
|
||||
if (canViewTargetMatrixTab.value) return 'zyyt_matrix'
|
||||
return 'zyyt'
|
||||
}
|
||||
|
||||
watch(yejiDisplayTab, tab => {
|
||||
if (tab === 'doctor' && !canViewDoctorTab.value) {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
} else if (tab === 'leaderboard' && !canViewLeaderboardTab.value) {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
} else if (tab === 'zyyt' && !canViewZyytTab.value) {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
} else if (tab === 'zyyt_matrix' && !canViewTargetMatrixTab.value) {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
}
|
||||
})
|
||||
|
||||
const leaderboardChartBlocks = computed(() =>
|
||||
@@ -1715,7 +1776,7 @@ const doctorRxStackHasData = computed(() =>
|
||||
)
|
||||
|
||||
const doctorChartsHasData = computed(() => {
|
||||
if (!canViewDoctorDailyStats.value) {
|
||||
if (!canViewDoctorTab.value) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
@@ -1731,12 +1792,14 @@ const chartsHasAnyData = computed(() => {
|
||||
if (doctorChartsHasData.value) {
|
||||
return true
|
||||
}
|
||||
for (const tb of tables.value) {
|
||||
if (yejiDeptChartHasData(tb)) {
|
||||
return true
|
||||
if (canViewZyytTab.value) {
|
||||
for (const tb of tables.value) {
|
||||
if (yejiDeptChartHasData(tb)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (leaderboardChartBlocks.value.length > 0) {
|
||||
if (canViewLeaderboardTab.value && leaderboardChartBlocks.value.length > 0) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -2073,6 +2136,12 @@ function getDoctorDailySummaries(param: { columns: any[] }) {
|
||||
}
|
||||
|
||||
async function loadDoctorDailyStats() {
|
||||
if (!canViewDoctorTab.value) {
|
||||
doctorDailyRows.value = []
|
||||
doctorDailyTotal.value = {}
|
||||
doctorDailyRange.value = null
|
||||
return
|
||||
}
|
||||
doctorDailyLoading.value = true
|
||||
try {
|
||||
const res: any = await doctorDailyStatsOverview(buildDoctorDailyRequestParams())
|
||||
@@ -2211,6 +2280,10 @@ function daysInMonth(ym: string): number {
|
||||
}
|
||||
|
||||
async function loadTargetProgress() {
|
||||
if (!canViewTargetMatrixTab.value) {
|
||||
targetProgressCard.value = null
|
||||
return
|
||||
}
|
||||
targetProgressLoading.value = true
|
||||
targetProgressCard.value = null
|
||||
try {
|
||||
@@ -2458,6 +2531,10 @@ async function loadChannelOptions() {
|
||||
}
|
||||
|
||||
async function loadLeaderboard(range: { start: string; end: string }) {
|
||||
if (!canViewLeaderboardTab.value) {
|
||||
leaderboardBlock.value = null
|
||||
return
|
||||
}
|
||||
leaderboardsLoading.value = true
|
||||
try {
|
||||
const p: Record<string, any> = {
|
||||
@@ -2491,6 +2568,18 @@ async function loadLeaderboard(range: { start: string; end: string }) {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
leaderboardBlock.value = null
|
||||
if (!canViewZyytTab.value) {
|
||||
tables.value = []
|
||||
loading.value = false
|
||||
if (canViewDoctorTab.value) {
|
||||
void loadDoctorDailyStats()
|
||||
} else {
|
||||
doctorDailyRows.value = []
|
||||
doctorDailyTotal.value = {}
|
||||
doctorDailyRange.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const baseParams: Record<string, any> = {}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
@@ -2542,7 +2631,7 @@ async function loadData() {
|
||||
ElMessage.error(any?.msg || any?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (canViewDoctorDailyStats.value) {
|
||||
if (canViewDoctorTab.value) {
|
||||
void loadDoctorDailyStats()
|
||||
} else {
|
||||
doctorDailyRows.value = []
|
||||
@@ -2661,6 +2750,9 @@ function buildOrderDrawerListParams(): Record<string, any> | null {
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
params.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (f.yejiTableRowDeptIds && f.yejiTableRowDeptIds.length > 0) {
|
||||
params.yeji_table_row_dept_ids = f.yejiTableRowDeptIds.join(',')
|
||||
}
|
||||
} else {
|
||||
params.assistant_id = f.assistantId
|
||||
if (f.erCenterRevisitSlot !== undefined) {
|
||||
@@ -3047,28 +3139,79 @@ function onLeadCountCellClick(ev: MouseEvent, tb: YejiTable, row: YejiRow) {
|
||||
void openLeadLinesDialog(tb, row)
|
||||
}
|
||||
|
||||
const LEAD_LINES_EXPORT_COLUMNS: { key: keyof YejiLeadLineRow; label: string }[] = [
|
||||
{ key: 'event_time_text', label: '进线时间' },
|
||||
{ key: 'reception_admin_name', label: '接待' },
|
||||
{ key: 'external_contact_name', label: '客户' },
|
||||
{ key: 'external_userid', label: '外部联系人ID' },
|
||||
{ key: 'user_id', label: '企微成员ID' },
|
||||
{ key: 'state', label: '渠道参数' },
|
||||
]
|
||||
|
||||
function buildLeadLinesRequestParams(
|
||||
ctx: { tb: YejiTable; row: YejiRow },
|
||||
page: number,
|
||||
pageSize: number
|
||||
): Record<string, string | number> {
|
||||
const { tb, row } = ctx
|
||||
const p: Record<string, string | number> = {
|
||||
start_date: tb.start_date,
|
||||
end_date: tb.end_date,
|
||||
dept_id: row.dept_id,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
function escapeLeadLinesCsvCell(value: unknown): string {
|
||||
const s = value == null ? '' : String(value)
|
||||
if (/[",\n\r]/.test(s)) {
|
||||
return `"${s.replace(/"/g, '""')}"`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function buildLeadLinesCsv(rows: YejiLeadLineRow[]): string {
|
||||
const header = LEAD_LINES_EXPORT_COLUMNS.map((c) => escapeLeadLinesCsvCell(c.label)).join(',')
|
||||
const body = rows
|
||||
.map((row) => LEAD_LINES_EXPORT_COLUMNS.map((c) => escapeLeadLinesCsvCell(row[c.key])).join(','))
|
||||
.join('\n')
|
||||
return `\uFEFF${header}\n${body}`
|
||||
}
|
||||
|
||||
function downloadLeadLinesCsv(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function buildLeadLinesExportFilename(ctx: { tb: YejiTable; row: YejiRow }): string {
|
||||
const { tb, row } = ctx
|
||||
const safeDept = (row.dept_name || `dept${row.dept_id}`).replace(/[\\/:*?"<>|]/g, '_')
|
||||
return `进线数据明细_${safeDept}_${tb.start_date}_${tb.end_date}.csv`
|
||||
}
|
||||
|
||||
async function fetchLeadLinesPage() {
|
||||
const ctx = leadLinesContext.value
|
||||
if (!ctx) {
|
||||
return
|
||||
}
|
||||
const { tb, row } = ctx
|
||||
leadLinesLoading.value = true
|
||||
try {
|
||||
const p: Record<string, string | number> = {
|
||||
start_date: tb.start_date,
|
||||
end_date: tb.end_date,
|
||||
dept_id: row.dept_id,
|
||||
page: leadLinesPage.value,
|
||||
page_size: leadLinesPageSize.value,
|
||||
}
|
||||
if (selectedDeptIds.value.length > 0) {
|
||||
p.dept_ids = selectedDeptIds.value.join(',')
|
||||
}
|
||||
if (selectedChannel.value) {
|
||||
p.channel_code = selectedChannel.value
|
||||
}
|
||||
const res: any = await yejiStatsLeadLines(p as any)
|
||||
const res: any = await yejiStatsLeadLines(
|
||||
buildLeadLinesRequestParams(ctx, leadLinesPage.value, leadLinesPageSize.value) as any
|
||||
)
|
||||
leadLinesRows.value = Array.isArray(res?.lists) ? res.lists : []
|
||||
leadLinesCount.value = Number(res?.count ?? 0)
|
||||
leadLinesApiNote.value = typeof res?.note === 'string' ? res.note : ''
|
||||
@@ -3107,6 +3250,40 @@ function onLeadLinesPageSizeChange(size: number) {
|
||||
void fetchLeadLinesPage()
|
||||
}
|
||||
|
||||
async function exportLeadLines() {
|
||||
const ctx = leadLinesContext.value
|
||||
if (!ctx || leadLinesExporting.value) {
|
||||
return
|
||||
}
|
||||
leadLinesExporting.value = true
|
||||
try {
|
||||
const pageSize = 100
|
||||
const firstRes: any = await yejiStatsLeadLines(buildLeadLinesRequestParams(ctx, 1, pageSize) as any)
|
||||
const total = Number(firstRes?.count ?? 0)
|
||||
if (total <= 0) {
|
||||
ElMessage.warning('暂无进线明细可导出')
|
||||
return
|
||||
}
|
||||
const allRows: YejiLeadLineRow[] = Array.isArray(firstRes?.lists) ? [...firstRes.lists] : []
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
for (let page = 2; page <= totalPages; page++) {
|
||||
const res: any = await yejiStatsLeadLines(buildLeadLinesRequestParams(ctx, page, pageSize) as any)
|
||||
const lists = Array.isArray(res?.lists) ? res.lists : []
|
||||
allRows.push(...lists)
|
||||
}
|
||||
downloadLeadLinesCsv(buildLeadLinesExportFilename(ctx), buildLeadLinesCsv(allRows))
|
||||
ElMessage.success(`已导出 ${allRows.length} 条进线明细`)
|
||||
} catch (e: unknown) {
|
||||
if (axios.isCancel(e)) {
|
||||
return
|
||||
}
|
||||
const any = e as any
|
||||
ElMessage.error(any?.msg || any?.message || '导出进线明细失败')
|
||||
} finally {
|
||||
leadLinesExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function appointmentLinesIndexMethod(index: number) {
|
||||
return (appointmentLinesPage.value - 1) * appointmentLinesPageSize.value + index + 1
|
||||
}
|
||||
@@ -3561,6 +3738,7 @@ function formatLocalYmd(d: Date): string {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
yejiDisplayTab.value = pickInitialYejiTab()
|
||||
await Promise.all([loadDeptOptions(), loadChannelOptions()])
|
||||
loadData()
|
||||
loadTargetProgress()
|
||||
@@ -4832,6 +5010,18 @@ tbody tr.total-row:hover .yeji-lead-cell--link {
|
||||
color: color-mix(in srgb, var(--yj-brand, #2563eb) 88%, #000);
|
||||
}
|
||||
|
||||
.yeji-leadlines-dialog__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.yeji-leadlines-dialog__head-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.yeji-leadlines-dialog__pager {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
|
||||
@@ -89,7 +89,15 @@
|
||||
|
||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||
<el-table-column label="自媒体渠道" prop="media_channel_name" min-width="120" />
|
||||
<el-table-column label="自媒体渠道" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="getChannelGroupLabel(row.media_channel_code)">
|
||||
<el-tag size="small" class="mr-1">{{ getChannelGroupLabel(row.media_channel_code) }}</el-tag>
|
||||
{{ row.media_channel_name }}
|
||||
</span>
|
||||
<span v-else>{{ row.media_channel_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="部门" prop="dept_name" min-width="140" />
|
||||
<el-table-column label="账户消耗" min-width="120">
|
||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||
@@ -213,6 +221,17 @@ const deptOptions = computed<DeptOption[]>(() => {
|
||||
|
||||
const defaultMediaChannelCode = computed(() => String(pager.extend.default_media_channel_code || ''))
|
||||
|
||||
const getChannelGroupLabel = (channelCode: string): string => {
|
||||
if (!channelCode) return ''
|
||||
for (const group of mediaChannelGroups.value) {
|
||||
const channel = group.channels.find(ch => ch.channel_code === channelCode)
|
||||
if (channel) {
|
||||
return group.group_name
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const treeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="assistant-performance-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" class="stats-filter-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">最近30天</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 260px"
|
||||
@change="handleCustomDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="fetchData">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="stats-kpi-grid">
|
||||
<div v-for="card in summaryCards" :key="card.key" class="stats-kpi-card" :class="card.cardClass">
|
||||
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||
{{ formatValue(card.key, card.type) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">业绩趋势</span>
|
||||
<span class="card-hint">{{ dateRangeText }} · 履约完成业绩</span>
|
||||
</div>
|
||||
</template>
|
||||
<v-charts
|
||||
v-if="chartHasData"
|
||||
class="stats-chart"
|
||||
:option="chartOption"
|
||||
autoresize
|
||||
/>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="assistantPerformancePage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { assistantPerformanceOverview } from '@/api/stats'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
const queryParams = reactive({
|
||||
time_type: 'month',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const overview = reactive<Record<string, any>>({
|
||||
date_range: [],
|
||||
summary: {
|
||||
total_amount: 0,
|
||||
total_count: 0
|
||||
},
|
||||
chart: {
|
||||
dates: [],
|
||||
amounts: [],
|
||||
counts: []
|
||||
}
|
||||
})
|
||||
|
||||
const summaryCards = [
|
||||
{ key: 'total_amount', label: '业绩', type: 'money', cardClass: '' },
|
||||
{ key: 'total_count', label: '有效订单数', type: 'count', cardClass: '' }
|
||||
]
|
||||
|
||||
const dateRangeText = computed(() => {
|
||||
if (!overview.date_range?.length) return '未选择'
|
||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||
})
|
||||
|
||||
const chartHasData = computed(() => {
|
||||
return overview.chart.dates.length > 0 && overview.chart.amounts.some((v: number) => v > 0)
|
||||
})
|
||||
|
||||
const chartOption = computed(() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const p = params[0]
|
||||
return `${p.axisValue}<br/>${p.marker}${p.seriesName}: ¥${Number(p.value).toFixed(2)}`
|
||||
}
|
||||
},
|
||||
grid: { left: 60, right: 24, top: 36, bottom: 36 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: overview.chart.dates,
|
||||
axisLabel: {
|
||||
interval: overview.chart.dates.length > 15 ? 'auto' : 0,
|
||||
rotate: overview.chart.dates.length > 10 ? 30 : 0
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '金额(元)',
|
||||
axisLabel: {
|
||||
formatter: (val: number) => val >= 10000 ? (val / 10000).toFixed(1) + '万' : String(val)
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '业绩',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 3, color: '#4a78ff' },
|
||||
itemStyle: { color: '#4a78ff' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(74, 120, 255, 0.25)' },
|
||||
{ offset: 1, color: 'rgba(74, 120, 255, 0.02)' }
|
||||
]
|
||||
}
|
||||
},
|
||||
data: overview.chart.amounts
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const formatValue = (key: string, type: string) => {
|
||||
const value = overview.summary?.[key] ?? 0
|
||||
if (type === 'money') return `¥${Number(value).toFixed(2)}`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { time_type: queryParams.time_type }
|
||||
if (queryParams.time_type === 'custom') {
|
||||
params.start_date = dateRange.value[0] || ''
|
||||
params.end_date = dateRange.value[1] || ''
|
||||
}
|
||||
const res = await assistantPerformanceOverview(params)
|
||||
Object.assign(overview, res || {})
|
||||
} catch (error: any) {
|
||||
console.error('获取业绩数据失败:', error)
|
||||
ElMessage.error(error?.msg || '获取业绩数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeTypeChange = () => {
|
||||
if (queryParams.time_type !== 'custom') {
|
||||
dateRange.value = []
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomDateChange = () => {
|
||||
if (dateRange.value?.length === 2) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.time_type = 'month'
|
||||
dateRange.value = []
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.assistant-performance-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stats-kpi-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||
border: 1px solid #ebf1ff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||
}
|
||||
|
||||
.stats-kpi-card.is-cancelled {
|
||||
background: linear-gradient(145deg, #ffffff, #fff5f5);
|
||||
border-color: #ffe0e0;
|
||||
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
.stats-kpi-label {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
margin-top: 12px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stats-kpi-value.is-money {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stats-kpi-card.is-cancelled .stats-kpi-value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-chart {
|
||||
height: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<el-select
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:allow-create="allowCreate"
|
||||
:default-first-option="allowCreate"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
:class="selectClass"
|
||||
:style="selectStyle"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@change="emit('change', $event)"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in normalizedOptions"
|
||||
:key="item.name"
|
||||
:label="item.name"
|
||||
:value="item.name"
|
||||
/>
|
||||
<!-- 旧记录可能存在不在当前字典内的值:保留显示,避免编辑时反查不到 -->
|
||||
<el-option
|
||||
v-if="modelValue && !hasCurrentValue"
|
||||
:key="`__legacy_${modelValue}`"
|
||||
:label="`${modelValue}(已停用)`"
|
||||
:value="modelValue"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
interface OptionItem {
|
||||
name: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
options: Array<OptionItem | string>
|
||||
loading?: boolean
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
filterable?: boolean
|
||||
allowCreate?: boolean
|
||||
disabled?: boolean
|
||||
selectClass?: string
|
||||
selectStyle?: string | Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
loading: false,
|
||||
placeholder: '请选择自媒体来源',
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
allowCreate: false,
|
||||
disabled: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
change: [value: string]
|
||||
'visible-change': [visible: boolean]
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed<OptionItem[]>(() => {
|
||||
return (props.options || []).map((item) =>
|
||||
typeof item === 'string' ? { name: item } : { name: item.name, value: item.value }
|
||||
)
|
||||
})
|
||||
|
||||
const hasCurrentValue = computed(() => {
|
||||
return normalizedOptions.value.some((item) => item.name === props.modelValue)
|
||||
})
|
||||
|
||||
const onVisibleChange = (visible: boolean) => {
|
||||
emit('visible-change', visible)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="personal-account-cost-page">
|
||||
<el-card class="!border-none mb-4" shadow="never">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/2 md:w-1/3">
|
||||
<div class="leading-10">当前筛选天数</div>
|
||||
<div class="text-4xl">{{ pager.extend.days_count ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="w-1/2 md:w-1/3">
|
||||
<div class="leading-10">累计账户消耗 (元)</div>
|
||||
<div class="text-4xl">¥{{ pager.extend.total_amount ?? '0.00' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="mb-[-16px]" :model="queryParams" inline>
|
||||
<el-form-item label="日期范围">
|
||||
<daterange-picker
|
||||
v-model:startTime="queryParams.start_date"
|
||||
v-model:endTime="queryParams.end_date"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="queryParams.dept_id"
|
||||
:data="deptOptions"
|
||||
node-key="id"
|
||||
:props="deptTreeProps"
|
||||
:default-expand-all="true"
|
||||
check-strictly
|
||||
placeholder="全部部门"
|
||||
clearable
|
||||
filterable
|
||||
class="w-[220px]"
|
||||
@change="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自媒体来源">
|
||||
<media-source-select
|
||||
v-model="queryParams.media_source"
|
||||
:options="mediaSourceOptions"
|
||||
:loading="mediaSourceLoading"
|
||||
placeholder="全部来源"
|
||||
:allow-create="false"
|
||||
select-class="w-[220px]"
|
||||
@change="resetPage"
|
||||
@visible-change="onMediaSourceDropdownVisible"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[280px]" label="备注/录入人">
|
||||
<el-input
|
||||
v-model="queryParams.remark"
|
||||
placeholder="备注 / 创建人"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="!border-none mt-4" shadow="never">
|
||||
<div>
|
||||
<el-button v-perms="['stats.personal_account_cost/add']" type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table class="mt-4" size="large" v-loading="pager.loading" :data="pager.lists">
|
||||
<el-table-column label="日期" prop="cost_date" min-width="120" />
|
||||
<el-table-column label="自媒体来源" prop="media_source" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="账户消耗" min-width="120">
|
||||
<template #default="{ row }">¥{{ row.amount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.dept_path"
|
||||
:content="row.dept_path"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
>
|
||||
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||
<span v-else class="text-gray-400">未分配</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
|
||||
<el-table-column label="更新时间" prop="update_time" min-width="180" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['stats.personal_account_cost/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['stats.personal_account_cost/delete']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<cost-edit-popup
|
||||
v-if="showEdit"
|
||||
ref="editRef"
|
||||
:media-source-options="mediaSourceOptions"
|
||||
:media-source-loading="mediaSourceLoading"
|
||||
@success="handleCostSuccess"
|
||||
@close="showEdit = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="personalAccountCost">
|
||||
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
|
||||
import { deptAll } from '@/api/org/department'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { getRoutePath } from '@/router'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
import CostEditPopup from './cost-edit.vue'
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||
|
||||
const router = useRouter()
|
||||
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
|
||||
const showEdit = ref(false)
|
||||
const deptOptions = ref<any[]>([])
|
||||
const {
|
||||
mediaSourceOptions,
|
||||
mediaSourceLoading,
|
||||
loadMediaSourceOptions,
|
||||
refreshMediaSourceOptions,
|
||||
} = useMediaSourceOptions()
|
||||
|
||||
const deptTreeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children',
|
||||
}
|
||||
|
||||
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
|
||||
|
||||
const goSelfInput = () => {
|
||||
if (selfInputPath.value) {
|
||||
router.push(selfInputPath.value)
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
media_source: '',
|
||||
dept_id: undefined as number | undefined,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: personalAccountCostLists,
|
||||
params: queryParams,
|
||||
})
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.start_date = ''
|
||||
queryParams.end_date = ''
|
||||
queryParams.media_source = ''
|
||||
queryParams.dept_id = undefined
|
||||
queryParams.remark = ''
|
||||
resetPage()
|
||||
}
|
||||
|
||||
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||
if (visible) {
|
||||
refreshMediaSourceOptions()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCostSuccess = async () => {
|
||||
await refreshMediaSourceOptions()
|
||||
getLists()
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
await refreshMediaSourceOptions()
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('add')
|
||||
}
|
||||
|
||||
const handleEdit = async (row: Record<string, any>) => {
|
||||
showEdit.value = true
|
||||
await nextTick()
|
||||
editRef.value?.open('edit')
|
||||
editRef.value?.setFormData(row)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await feedback.confirm('确定删除该账户消耗记录?')
|
||||
await personalAccountCostDelete({ id })
|
||||
getLists()
|
||||
}
|
||||
|
||||
const loadDeptOptions = async () => {
|
||||
try {
|
||||
const res = await deptAll({ apply_data_scope: 1 })
|
||||
if (Array.isArray(res)) {
|
||||
deptOptions.value = res
|
||||
}
|
||||
} catch (e) {
|
||||
deptOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDeptOptions()
|
||||
loadMediaSourceOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.personal-account-cost-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dept-cell {
|
||||
cursor: help;
|
||||
border-bottom: 1px dashed #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="edit-popup">
|
||||
<popup
|
||||
ref="popupRef"
|
||||
:title="popupTitle"
|
||||
:async="true"
|
||||
width="520px"
|
||||
@confirm="handleSubmit"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||
<el-form-item label="日期" prop="cost_date">
|
||||
<el-date-picker
|
||||
v-model="formData.cost_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择日期"
|
||||
:disabled="mode === 'edit'"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自媒体来源" prop="media_source">
|
||||
<media-source-select
|
||||
v-model="formData.media_source"
|
||||
:options="mediaSourceOptions"
|
||||
:loading="mediaSourceLoading"
|
||||
placeholder="请选择自媒体来源"
|
||||
:allow-create="false"
|
||||
:disabled="mode === 'edit'"
|
||||
select-class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="账户消耗" prop="amount">
|
||||
<el-input-number
|
||||
v-model="formData.amount"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
import {
|
||||
personalAccountCostAdd,
|
||||
personalAccountCostEdit,
|
||||
} from '@/api/self_input_stats'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
mediaSourceOptions?: MediaSourceOption[]
|
||||
mediaSourceLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
mediaSourceOptions: () => [],
|
||||
mediaSourceLoading: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||
const mode = ref<'add' | 'edit'>('add')
|
||||
|
||||
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑账户消耗' : '新增账户消耗'))
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
cost_date: '',
|
||||
media_source: '',
|
||||
amount: 0,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
cost_date: [{ required: true, message: '请选择日期', trigger: ['change'] }],
|
||||
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||
amount: [{ required: true, message: '请输入账户消耗金额', trigger: ['blur', 'change'] }],
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.id = ''
|
||||
formData.cost_date = ''
|
||||
formData.media_source = ''
|
||||
formData.amount = 0
|
||||
formData.remark = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
if (mode.value === 'edit') {
|
||||
await personalAccountCostEdit(formData)
|
||||
} else {
|
||||
await personalAccountCostAdd(formData)
|
||||
}
|
||||
popupRef.value?.close()
|
||||
emit('success')
|
||||
}
|
||||
|
||||
const open = (type: 'add' | 'edit' = 'add') => {
|
||||
mode.value = type
|
||||
resetForm()
|
||||
popupRef.value?.open()
|
||||
}
|
||||
|
||||
const setFormData = (data: Record<string, any>) => {
|
||||
formData.id = data.id ?? ''
|
||||
formData.cost_date = data.cost_date ?? ''
|
||||
formData.media_source = data.media_source ?? ''
|
||||
formData.amount = Number(data.amount ?? 0)
|
||||
formData.remark = data.remark ?? ''
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
setFormData,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="self-input-stats-page">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="yesterday">昨天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">最近30天</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 260px"
|
||||
@change="handleCustomDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="部门">
|
||||
<el-tree-select
|
||||
v-model="queryParams.dept_id"
|
||||
:data="deptOptions"
|
||||
node-key="id"
|
||||
:props="deptTreeProps"
|
||||
:default-expand-all="true"
|
||||
check-strictly
|
||||
placeholder="全部部门"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 220px"
|
||||
@change="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="自媒体来源">
|
||||
<media-source-select
|
||||
v-model="queryParams.media_source"
|
||||
:options="mediaSourceOptions"
|
||||
:loading="mediaSourceLoading"
|
||||
placeholder="全部来源"
|
||||
:allow-create="false"
|
||||
select-style="width: 220px"
|
||||
@change="handleQuery"
|
||||
@visible-change="onMediaSourceDropdownVisible"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="stats-kpi-grid">
|
||||
<div v-for="card in visibleSummaryCards" :key="card.key" class="stats-kpi-card">
|
||||
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||
{{ renderMetric(card.key, overview.summary, card.type) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="!border-none mt-4 stats-detail-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header-main">
|
||||
<span class="card-title">业绩明细</span>
|
||||
<span class="card-hint">{{ dateRangeText }}</span>
|
||||
</div>
|
||||
<div class="card-header-actions">
|
||||
<el-button v-perms="['stats.personal_yeji/add']" type="primary" @click="handleAddYeji">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增业绩
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="accountCostEntryPath"
|
||||
v-perms="['stats.personal_account_cost/lists']"
|
||||
@click="goAccountCostEntry"
|
||||
>
|
||||
账户消耗录入
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
border
|
||||
size="large"
|
||||
max-height="640"
|
||||
>
|
||||
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
|
||||
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="录入人" prop="creator_name" min-width="100" />
|
||||
<el-table-column label="部门" prop="dept_name" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.dept_path"
|
||||
:content="row.dept_path"
|
||||
placement="top"
|
||||
effect="dark"
|
||||
>
|
||||
<span class="dept-cell">{{ row.dept_name || '未分配' }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else-if="row.dept_name">{{ row.dept_name }}</span>
|
||||
<span v-else class="text-gray-400">未分配</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-for="col in visibleTableColumns"
|
||||
:key="col.key"
|
||||
:label="col.label"
|
||||
:min-width="col.minWidth || 100"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ renderMetric(col.key, row, col.type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['stats.personal_yeji/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEditYeji(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['stats.personal_yeji/delete']"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDeleteYeji(row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<pagination v-model="pager" @change="fetchOverview" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<yeji-edit-popup
|
||||
v-if="showYejiEdit"
|
||||
ref="yejiEditRef"
|
||||
:media-source-options="mediaSourceOptions"
|
||||
:media-source-loading="mediaSourceLoading"
|
||||
@success="handleYejiSuccess"
|
||||
@close="showYejiEdit = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="selfInputStats">
|
||||
import {
|
||||
getSelfInputOverview,
|
||||
personalYejiDelete,
|
||||
} from '@/api/self_input_stats'
|
||||
import { deptAll } from '@/api/org/department'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { getRoutePath } from '@/router'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import YejiEditPopup from './yeji-edit.vue'
|
||||
import { useMediaSourceOptions } from './useMediaSourceOptions'
|
||||
|
||||
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
|
||||
|
||||
interface MetricCard {
|
||||
key: string
|
||||
label: string
|
||||
type: MetricType
|
||||
}
|
||||
|
||||
interface MetricColumn {
|
||||
key: string
|
||||
label: string
|
||||
type: MetricType
|
||||
minWidth?: number
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
|
||||
const showYejiEdit = ref(false)
|
||||
const dateRange = ref<string[]>([])
|
||||
const deptOptions = ref<any[]>([])
|
||||
const {
|
||||
mediaSourceOptions,
|
||||
mediaSourceLoading,
|
||||
loadMediaSourceOptions,
|
||||
refreshMediaSourceOptions,
|
||||
} = useMediaSourceOptions()
|
||||
|
||||
const deptTreeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children',
|
||||
}
|
||||
|
||||
const overview = reactive<Record<string, any>>({
|
||||
date_range: [],
|
||||
summary: {},
|
||||
can_view_finance: false,
|
||||
})
|
||||
|
||||
const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi'])
|
||||
const canViewFinance = computed(() => Boolean(overview.can_view_finance))
|
||||
|
||||
const queryParams = reactive({
|
||||
media_source: '',
|
||||
dept_id: undefined as number | undefined,
|
||||
time_type: 'today',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
})
|
||||
|
||||
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
|
||||
|
||||
const goAccountCostEntry = () => {
|
||||
if (accountCostEntryPath.value) {
|
||||
router.push(accountCostEntryPath.value)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchOverviewList = async (params: Record<string, any>) => {
|
||||
if (queryParams.time_type === 'custom') {
|
||||
params.start_date = dateRange.value[0] || ''
|
||||
params.end_date = dateRange.value[1] || ''
|
||||
}
|
||||
const res = await getSelfInputOverview(params)
|
||||
Object.assign(overview, res?.extend || {})
|
||||
return res
|
||||
}
|
||||
|
||||
const loadDeptOptions = async () => {
|
||||
try {
|
||||
const res = await deptAll({ apply_data_scope: 1 })
|
||||
if (Array.isArray(res)) {
|
||||
deptOptions.value = res
|
||||
}
|
||||
} catch (e) {
|
||||
deptOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const onMediaSourceDropdownVisible = (visible: boolean) => {
|
||||
if (visible) {
|
||||
refreshMediaSourceOptions()
|
||||
}
|
||||
}
|
||||
|
||||
const handleYejiSuccess = async () => {
|
||||
await refreshMediaSourceOptions()
|
||||
await fetchOverview()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDeptOptions()
|
||||
loadMediaSourceOptions()
|
||||
})
|
||||
|
||||
const { pager, getLists } = usePaging({
|
||||
fetchFun: fetchOverviewList,
|
||||
params: queryParams,
|
||||
firstLoading: true,
|
||||
})
|
||||
|
||||
const summaryCards: MetricCard[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||
{ key: 'order_amount', label: '订单金额', type: 'money' },
|
||||
{ key: 'account_cost', label: '账户消耗', type: 'money' },
|
||||
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent' },
|
||||
{ key: 'interview_rate', label: '面诊率', type: 'percent' },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent' },
|
||||
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent' },
|
||||
{ key: 'avg_unit_price', label: '平均单价', type: 'money' },
|
||||
{ key: 'cash_cost', label: '现金成本', type: 'money' },
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio' },
|
||||
]
|
||||
|
||||
const tableColumns: MetricColumn[] = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||
{ key: 'total_open_count', label: '总开口', type: 'count' },
|
||||
{ key: 'unreplied_count', label: '未回复', type: 'count' },
|
||||
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
|
||||
{ key: 'order_amount', label: '订单金额', type: 'money', minWidth: 110 },
|
||||
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 110 },
|
||||
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 110 },
|
||||
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', minWidth: 110 },
|
||||
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
|
||||
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
|
||||
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 120 },
|
||||
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', minWidth: 110 },
|
||||
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 100 },
|
||||
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 100 },
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 },
|
||||
]
|
||||
|
||||
const visibleSummaryCards = computed(() =>
|
||||
canViewFinance.value
|
||||
? summaryCards
|
||||
: summaryCards.filter((card) => !FINANCE_KEYS.has(card.key))
|
||||
)
|
||||
|
||||
const visibleTableColumns = computed(() =>
|
||||
canViewFinance.value
|
||||
? tableColumns
|
||||
: tableColumns.filter((col) => !FINANCE_KEYS.has(col.key))
|
||||
)
|
||||
|
||||
const dateRangeText = computed(() => {
|
||||
if (!overview.date_range?.length) return '未选择'
|
||||
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||
})
|
||||
|
||||
const fetchOverview = async () => {
|
||||
try {
|
||||
await getLists()
|
||||
} catch (error: any) {
|
||||
console.error('获取自录转化统计失败:', error)
|
||||
ElMessage.error(error?.msg || '获取统计数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeTypeChange = () => {
|
||||
if (queryParams.time_type !== 'custom') {
|
||||
dateRange.value = []
|
||||
pager.page = 1
|
||||
fetchOverview()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomDateChange = () => {
|
||||
if (dateRange.value.length === 2) {
|
||||
pager.page = 1
|
||||
fetchOverview()
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
pager.page = 1
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.media_source = ''
|
||||
queryParams.dept_id = undefined
|
||||
queryParams.time_type = 'today'
|
||||
dateRange.value = []
|
||||
pager.page = 1
|
||||
pager.size = 15
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
const renderMetric = (key: string, source: Record<string, any>, type = 'count') => {
|
||||
const value = source?.[key] ?? 0
|
||||
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||
return String(value ?? 0)
|
||||
}
|
||||
|
||||
const handleAddYeji = async () => {
|
||||
await refreshMediaSourceOptions()
|
||||
showYejiEdit.value = true
|
||||
await nextTick()
|
||||
yejiEditRef.value?.open('add')
|
||||
}
|
||||
|
||||
const handleEditYeji = async (row: Record<string, any>) => {
|
||||
showYejiEdit.value = true
|
||||
await nextTick()
|
||||
yejiEditRef.value?.open('edit')
|
||||
yejiEditRef.value?.setFormData(row)
|
||||
}
|
||||
|
||||
const handleDeleteYeji = async (id: number) => {
|
||||
await feedback.confirm('确定删除该业绩记录?')
|
||||
await personalYejiDelete({ id })
|
||||
fetchOverview()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOverview()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.self-input-stats-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stats-kpi-card {
|
||||
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||
border: 1px solid #ebf1ff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 16px;
|
||||
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||
}
|
||||
|
||||
.dept-cell {
|
||||
cursor: help;
|
||||
border-bottom: 1px dashed #c0c4cc;
|
||||
}
|
||||
|
||||
.stats-kpi-label {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
margin-top: 12px;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stats-kpi-value.is-money {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-header-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stats-detail-card :deep(.el-card__body) {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
|
||||
|
||||
/**
|
||||
* 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。
|
||||
* 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。
|
||||
*/
|
||||
export interface MediaSourceOption {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function useMediaSourceOptions() {
|
||||
const mediaSourceOptions = ref<MediaSourceOption[]>([])
|
||||
const mediaSourceLoading = ref(false)
|
||||
|
||||
const normalize = (raw: any): MediaSourceOption[] => {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const list: MediaSourceOption[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of raw) {
|
||||
if (typeof item === 'string') {
|
||||
const name = item.trim()
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name)
|
||||
list.push({ name, value: '' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (item && typeof item === 'object') {
|
||||
const name = String(item.name ?? '').trim()
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name)
|
||||
list.push({ name, value: String(item.value ?? '') })
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
const loadMediaSourceOptions = async (force = false) => {
|
||||
if (!force && mediaSourceOptions.value.length > 0) {
|
||||
return
|
||||
}
|
||||
mediaSourceLoading.value = true
|
||||
try {
|
||||
const res = await getSelfInputMediaSourceOptions()
|
||||
mediaSourceOptions.value = normalize(res)
|
||||
} catch {
|
||||
mediaSourceOptions.value = []
|
||||
} finally {
|
||||
mediaSourceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
|
||||
|
||||
return {
|
||||
mediaSourceOptions,
|
||||
mediaSourceLoading,
|
||||
loadMediaSourceOptions,
|
||||
refreshMediaSourceOptions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="edit-popup">
|
||||
<popup
|
||||
ref="popupRef"
|
||||
:title="popupTitle"
|
||||
:async="true"
|
||||
width="640px"
|
||||
@confirm="handleSubmit"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" label-width="110px" :rules="formRules">
|
||||
<el-form-item label="业绩日期" prop="yeji_date">
|
||||
<el-date-picker
|
||||
v-model="formData.yeji_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择日期"
|
||||
:disabled="mode === 'edit'"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="自媒体来源" prop="media_source">
|
||||
<media-source-select
|
||||
v-model="formData.media_source"
|
||||
:options="mediaSourceOptions"
|
||||
:loading="mediaSourceLoading"
|
||||
placeholder="请选择自媒体来源"
|
||||
:allow-create="false"
|
||||
:disabled="mode === 'edit'"
|
||||
select-class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="加粉数" prop="add_fans_count">
|
||||
<el-input-number
|
||||
v-model="formData.add_fans_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="总开口" prop="total_open_count">
|
||||
<el-input-number
|
||||
v-model="formData.total_open_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="未回复" prop="unreplied_count">
|
||||
<el-input-number
|
||||
v-model="formData.unreplied_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="付费挂号" prop="paid_appointment_count">
|
||||
<el-input-number
|
||||
v-model="formData.paid_appointment_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="免费挂号" prop="free_appointment_count">
|
||||
<el-input-number
|
||||
v-model="formData.free_appointment_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="面诊" prop="interview_count">
|
||||
<el-input-number
|
||||
v-model="formData.interview_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="订单金额" prop="order_amount">
|
||||
<el-input-number
|
||||
v-model="formData.order_amount"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="接诊诊单" prop="completed_order_count">
|
||||
<el-input-number
|
||||
v-model="formData.completed_order_count"
|
||||
:min="0"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
placeholder="选填"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
|
||||
import Popup from '@/components/popup/index.vue'
|
||||
|
||||
import MediaSourceSelect from './MediaSourceSelect.vue'
|
||||
import type { MediaSourceOption } from './useMediaSourceOptions'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
mediaSourceOptions?: MediaSourceOption[]
|
||||
mediaSourceLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
mediaSourceOptions: () => [],
|
||||
mediaSourceLoading: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['success', 'close'])
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const popupRef = shallowRef<InstanceType<typeof Popup>>()
|
||||
const mode = ref<'add' | 'edit'>('add')
|
||||
|
||||
const popupTitle = computed(() => (mode.value === 'edit' ? '编辑业绩' : '新增业绩'))
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
yeji_date: '',
|
||||
media_source: '',
|
||||
add_fans_count: 0,
|
||||
total_open_count: 0,
|
||||
unreplied_count: 0,
|
||||
paid_appointment_count: 0,
|
||||
free_appointment_count: 0,
|
||||
interview_count: 0,
|
||||
order_amount: 0,
|
||||
completed_order_count: 0,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
yeji_date: [{ required: true, message: '请选择业绩日期', trigger: ['change'] }],
|
||||
media_source: [{ required: true, message: '请填写自媒体来源', trigger: ['blur'] }],
|
||||
add_fans_count: [{ required: true, message: '请填写加粉数', trigger: ['blur', 'change'] }],
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.id = ''
|
||||
formData.yeji_date = ''
|
||||
formData.media_source = ''
|
||||
formData.add_fans_count = 0
|
||||
formData.total_open_count = 0
|
||||
formData.unreplied_count = 0
|
||||
formData.paid_appointment_count = 0
|
||||
formData.free_appointment_count = 0
|
||||
formData.interview_count = 0
|
||||
formData.order_amount = 0
|
||||
formData.completed_order_count = 0
|
||||
formData.remark = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
if (mode.value === 'edit') {
|
||||
await personalYejiEdit(formData)
|
||||
} else {
|
||||
await personalYejiAdd(formData)
|
||||
}
|
||||
popupRef.value?.close()
|
||||
emit('success')
|
||||
}
|
||||
|
||||
const open = (type: 'add' | 'edit' = 'add') => {
|
||||
mode.value = type
|
||||
resetForm()
|
||||
popupRef.value?.open()
|
||||
}
|
||||
|
||||
const setFormData = (data: Record<string, any>) => {
|
||||
formData.id = data.id ?? ''
|
||||
formData.yeji_date = data.yeji_date ?? ''
|
||||
formData.media_source = data.media_source ?? ''
|
||||
formData.add_fans_count = Number(data.add_fans_count ?? 0)
|
||||
formData.total_open_count = Number(data.total_open_count ?? 0)
|
||||
formData.unreplied_count = Number(data.unreplied_count ?? 0)
|
||||
formData.paid_appointment_count = Number(data.paid_appointment_count ?? 0)
|
||||
formData.free_appointment_count = Number(data.free_appointment_count ?? 0)
|
||||
formData.interview_count = Number(data.interview_count ?? 0)
|
||||
formData.order_amount = Number(data.order_amount ?? 0)
|
||||
formData.completed_order_count = Number(data.completed_order_count ?? 0)
|
||||
formData.remark = data.remark ?? ''
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
setFormData,
|
||||
})
|
||||
</script>
|
||||
@@ -23,6 +23,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="canEditDailyRecord" class="daily-matrix__toolbar-right">
|
||||
<span v-if="hasPatientSelfRecord" class="daily-matrix__legend">
|
||||
<span class="daily-matrix__cell-patient">自录</span>
|
||||
<span class="daily-matrix__legend-text">= 患者在小程序自录</span>
|
||||
</span>
|
||||
<el-button type="primary" @click="openQuickAdd('blood')">+血糖</el-button>
|
||||
<el-button @click="openQuickAdd('diet')">+饮食</el-button>
|
||||
<el-button @click="openQuickAdd('exercise')">+运动</el-button>
|
||||
@@ -30,6 +34,10 @@
|
||||
<el-button @click="fetchTracking">刷新</el-button>
|
||||
</div>
|
||||
<div v-else class="daily-matrix__toolbar-right">
|
||||
<span v-if="hasPatientSelfRecord" class="daily-matrix__legend">
|
||||
<span class="daily-matrix__cell-patient">自录</span>
|
||||
<span class="daily-matrix__legend-text">= 患者在小程序自录</span>
|
||||
</span>
|
||||
<el-button @click="fetchTracking">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +65,8 @@
|
||||
:class="{
|
||||
'is-clickable': isCellClickable(row.key, date),
|
||||
'is-empty': !getCell(row.key, date).hasRecord,
|
||||
'is-high': getCell(row.key, date).isHigh
|
||||
'is-high': getCell(row.key, date).isHigh,
|
||||
'is-patient-self': getCell(row.key, date).isPatientSelf && getCell(row.key, date).hasRecord
|
||||
}"
|
||||
@click="handleCellClick(row.key, date)"
|
||||
>
|
||||
@@ -82,6 +91,14 @@
|
||||
<template v-else-if="getCell(row.key, date).value">
|
||||
<span>{{ getCell(row.key, date).value }}</span>
|
||||
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</span>
|
||||
<el-tooltip
|
||||
v-if="getCell(row.key, date).isPatientSelf"
|
||||
content="患者在小程序自录的数据"
|
||||
placement="top"
|
||||
:show-after="120"
|
||||
>
|
||||
<span class="daily-matrix__cell-patient">自录</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
@@ -345,6 +362,10 @@ interface BloodRecord {
|
||||
western_medicine?: string
|
||||
insulin?: string
|
||||
remark?: string
|
||||
/** 来源:0-医生录入 1-患者自录(小程序日常记录页) */
|
||||
source?: number
|
||||
/** 合并后该日是否含有「患者自录」记录 */
|
||||
has_patient_self?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -558,6 +579,10 @@ const visibleDates = computed(() => {
|
||||
return dates
|
||||
})
|
||||
|
||||
const hasPatientSelfRecord = computed(() => {
|
||||
return bloodRecords.value.some((r) => Number((r as any).source) === 1)
|
||||
})
|
||||
|
||||
const bloodByDate = computed(() => {
|
||||
const map = new Map<string, BloodRecord>()
|
||||
for (const record of bloodRecords.value) {
|
||||
@@ -570,7 +595,8 @@ const bloodByDate = computed(() => {
|
||||
patient_id: record.patient_id,
|
||||
record_date: date,
|
||||
record_time: record.record_time || '',
|
||||
record_date_ts: record.record_date_ts
|
||||
record_date_ts: record.record_date_ts,
|
||||
has_patient_self: false
|
||||
})
|
||||
}
|
||||
const merged = map.get(date)!
|
||||
@@ -594,6 +620,10 @@ const bloodByDate = computed(() => {
|
||||
;(merged as any)[field] = record[field]
|
||||
}
|
||||
}
|
||||
// 当天有任意 source=1 的记录则标记为患者自录
|
||||
if (Number(record.source) === 1) {
|
||||
merged.has_patient_self = true
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
@@ -723,35 +753,40 @@ function parseTrendNumber(value: unknown): number | null {
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean } {
|
||||
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean; isPatientSelf?: boolean } {
|
||||
const blood = bloodByDate.value.get(date)
|
||||
const diet = dietByDate.value.get(date)
|
||||
const exercise = exerciseByDate.value.get(date)
|
||||
const tracking = trackingByDate.value.get(date)
|
||||
const isPatientSelf = !!blood?.has_patient_self
|
||||
switch (metric) {
|
||||
case 'fasting':
|
||||
return {
|
||||
value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '',
|
||||
isHigh: isHighFastingBloodSugar(blood?.fasting_blood_sugar, props.age),
|
||||
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== ''
|
||||
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '',
|
||||
isPatientSelf
|
||||
}
|
||||
case 'postprandial':
|
||||
return {
|
||||
value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '',
|
||||
isHigh: isHighPostprandialBloodSugar(blood?.postprandial_blood_sugar, props.age),
|
||||
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== ''
|
||||
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '',
|
||||
isPatientSelf
|
||||
}
|
||||
case 'other':
|
||||
return {
|
||||
value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '',
|
||||
isHigh: isHighOtherBloodSugar(blood?.other_blood_sugar, props.age),
|
||||
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== ''
|
||||
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== '',
|
||||
isPatientSelf
|
||||
}
|
||||
case 'bp':
|
||||
return {
|
||||
value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '',
|
||||
isHigh: isHighBloodPressure(blood),
|
||||
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure)
|
||||
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure),
|
||||
isPatientSelf
|
||||
}
|
||||
case 'western':
|
||||
return {
|
||||
@@ -1115,6 +1150,12 @@ defineExpose({ refresh: fetchTracking })
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.is-patient-self {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, rgba(139, 92, 246, 0.04) 0%, rgba(139, 92, 246, 0.10) 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__cell-up {
|
||||
@@ -1122,6 +1163,38 @@ defineExpose({ refresh: fetchTracking })
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__cell-patient {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6d28d9;
|
||||
background: #ede9fe;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 999px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: 12px;
|
||||
padding: 2px 10px 2px 6px;
|
||||
background: #f8f6ff;
|
||||
border: 1px dashed #ddd6fe;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&__legend-text {
|
||||
font-size: 12px;
|
||||
color: #6d28d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__todo {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="临床诊断" :span="2">{{ detailPrescription.clinical_diagnosis || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="剂数 / 用法" :span="2">
|
||||
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
|
||||
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '—' }} {{ detailData.dose_unit || '剂' }} ·
|
||||
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '—' }}
|
||||
</el-descriptions-item>
|
||||
@@ -379,6 +379,22 @@ const buildParams = () => {
|
||||
queryParams.scene = 'diagnosis_edit'
|
||||
}
|
||||
|
||||
/** 诊间医助角色(与 DiagnosisLists 等一致) */
|
||||
const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
/**
|
||||
* 是否展示「处方审核」相关字段
|
||||
*/
|
||||
const showPrescriptionAuditFilter = computed(() => {
|
||||
const u = userStore.userInfo
|
||||
if (!u || Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
if (!ids.includes(TCM_ASSISTANT_ROLE_ID)) return true
|
||||
return ids.some((id) => PRESCRIPTION_AUDIT_ROLE_IDS.includes(id))
|
||||
})
|
||||
|
||||
// ─── 详情抽屉 ───
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
namespace app\adminapi\controller\asset;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\common\model\AssetResource;
|
||||
use app\common\model\AssetUserResource;
|
||||
use think\facade\Db;
|
||||
|
||||
class AssetResourceController extends BaseAdminController
|
||||
{
|
||||
/**
|
||||
* @notes 资源列表
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
$pageNo = $this->request->get('page_no', 1);
|
||||
$pageSize = $this->request->get('page_size', 15);
|
||||
$type = $this->request->get('type');
|
||||
$title = $this->request->get('title', '');
|
||||
$startTime = $this->request->get('start_time');
|
||||
$endTime = $this->request->get('end_time');
|
||||
|
||||
$where = [];
|
||||
if ($type) {
|
||||
$where[] = ['type', '=', $type];
|
||||
}
|
||||
if ($title) {
|
||||
$where[] = ['title', 'like', '%' . $title . '%'];
|
||||
}
|
||||
if ($startTime) {
|
||||
$where[] = ['create_time', '>=', strtotime($startTime)];
|
||||
}
|
||||
if ($endTime) {
|
||||
$where[] = ['create_time', '<=', strtotime($endTime) + 86399];
|
||||
}
|
||||
|
||||
$count = AssetResource::where($where)->count();
|
||||
$lists = AssetResource::with('users')
|
||||
->where($where)
|
||||
->order('id', 'desc')
|
||||
->page($pageNo, $pageSize)
|
||||
->select();
|
||||
|
||||
return $this->data([
|
||||
'count' => $count,
|
||||
'lists' => $lists,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 添加资源及分配账号
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
if (empty($params['type']) || empty($params['title']) || empty($params['file_url'])) {
|
||||
return $this->fail('请填写完整的资源信息');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$resource = AssetResource::create([
|
||||
'type' => $params['type'],
|
||||
'title' => $params['title'],
|
||||
'file_url' => $params['file_url'],
|
||||
'cover_url' => $params['cover_url'] ?? '',
|
||||
]);
|
||||
|
||||
// 绑定用户
|
||||
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
|
||||
$userResources = [];
|
||||
foreach ($params['user_ids'] as $userId) {
|
||||
$userResources[] = [
|
||||
'user_id' => $userId,
|
||||
'resource_id' => $resource->id,
|
||||
'create_time' => time(),
|
||||
];
|
||||
}
|
||||
(new AssetUserResource())->saveAll($userResources);
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
return $this->success('添加并分配成功', ['id' => $resource->id]);
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return $this->fail('操作失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 编辑资源(修改标题、关联账号)
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$id = $params['id'] ?? 0;
|
||||
if (empty($id)) {
|
||||
return $this->fail('缺少参数');
|
||||
}
|
||||
|
||||
$resource = AssetResource::find($id);
|
||||
if (!$resource) {
|
||||
return $this->fail('资源不存在');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
// 更新标题
|
||||
if (!empty($params['title'])) {
|
||||
$resource->title = $params['title'];
|
||||
$resource->save();
|
||||
}
|
||||
|
||||
// 重新绑定用户(先删后加)
|
||||
if (isset($params['user_ids'])) {
|
||||
AssetUserResource::where('resource_id', $id)->delete();
|
||||
$userIds = is_array($params['user_ids']) ? $params['user_ids'] : [];
|
||||
if (!empty($userIds)) {
|
||||
$userResources = [];
|
||||
foreach ($userIds as $userId) {
|
||||
$userResources[] = [
|
||||
'user_id' => $userId,
|
||||
'resource_id' => $id,
|
||||
'create_time' => time(),
|
||||
];
|
||||
}
|
||||
(new AssetUserResource())->saveAll($userResources);
|
||||
}
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
return $this->success('编辑成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return $this->fail('操作失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除资源
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$id = $this->request->post('id');
|
||||
if (empty($id)) {
|
||||
return $this->fail('缺少参数');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
AssetResource::destroy($id);
|
||||
AssetUserResource::where('resource_id', $id)->delete();
|
||||
Db::commit();
|
||||
return $this->success('删除成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return $this->fail('删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
namespace app\adminapi\controller\asset;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\common\model\AssetUser;
|
||||
|
||||
class AssetUserController extends BaseAdminController
|
||||
{
|
||||
/**
|
||||
* @notes 账号列表
|
||||
*/
|
||||
public function lists()
|
||||
{
|
||||
$pageNo = $this->request->get('page_no', 1);
|
||||
$pageSize = $this->request->get('page_size', 15);
|
||||
$phone = $this->request->get('phone', '');
|
||||
|
||||
$where = [];
|
||||
if ($phone) {
|
||||
$where[] = ['phone', 'like', '%' . $phone . '%'];
|
||||
}
|
||||
|
||||
$count = AssetUser::where($where)->count();
|
||||
$lists = AssetUser::where($where)
|
||||
->order('id', 'desc')
|
||||
->page($pageNo, $pageSize)
|
||||
->select();
|
||||
|
||||
return $this->data([
|
||||
'count' => $count,
|
||||
'lists' => $lists,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 添加账号
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
if (empty($params['phone'])) {
|
||||
return $this->fail('手机号不能为空');
|
||||
}
|
||||
|
||||
$exist = AssetUser::where('phone', $params['phone'])->find();
|
||||
if ($exist) {
|
||||
return $this->fail('手机号已存在');
|
||||
}
|
||||
|
||||
// Default password 123456
|
||||
$password = empty($params['password']) ? '123456' : $params['password'];
|
||||
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
$user = AssetUser::create([
|
||||
'phone' => $params['phone'],
|
||||
'password' => $passwordHash,
|
||||
'status' => $params['status'] ?? 1,
|
||||
]);
|
||||
|
||||
return $this->success('添加成功', ['id' => $user->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 编辑账号
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
if (empty($params['id'])) {
|
||||
return $this->fail('缺少参数');
|
||||
}
|
||||
|
||||
$user = AssetUser::find($params['id']);
|
||||
if (!$user) {
|
||||
return $this->fail('账号不存在');
|
||||
}
|
||||
|
||||
if (!empty($params['phone']) && $params['phone'] != $user->phone) {
|
||||
$exist = AssetUser::where('phone', $params['phone'])->find();
|
||||
if ($exist) {
|
||||
return $this->fail('手机号已存在');
|
||||
}
|
||||
$user->phone = $params['phone'];
|
||||
}
|
||||
|
||||
if (!empty($params['password'])) {
|
||||
$user->password = password_hash($params['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
if (isset($params['status'])) {
|
||||
$user->status = $params['status'];
|
||||
}
|
||||
|
||||
$user->save();
|
||||
return $this->success('修改成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 删除账号
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$id = $this->request->post('id');
|
||||
if (empty($id)) {
|
||||
return $this->fail('缺少参数');
|
||||
}
|
||||
|
||||
AssetUser::destroy($id);
|
||||
// 也需要删除关联的资源记录
|
||||
\app\common\model\AssetUserResource::where('user_id', $id)->delete();
|
||||
|
||||
return $this->success('删除成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\stats\AssistantPerformanceLogic;
|
||||
|
||||
/**
|
||||
* 医助个人业绩
|
||||
*
|
||||
* - GET stats.assistantPerformance/overview 个人业绩概览
|
||||
*/
|
||||
class AssistantPerformanceController extends BaseAdminController
|
||||
{
|
||||
public function overview()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
return $this->data(AssistantPerformanceLogic::overview($params, $this->adminId, $this->adminInfo));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\stats\PersonalAccountCostLists;
|
||||
use app\adminapi\logic\stats\PersonalAccountCostLogic;
|
||||
use app\adminapi\validate\stats\PersonalAccountCostValidate;
|
||||
|
||||
class PersonalAccountCostController extends BaseAdminController
|
||||
{
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new PersonalAccountCostLists());
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
$params = (new PersonalAccountCostValidate())->post()->goCheck('add');
|
||||
$result = PersonalAccountCostLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
|
||||
if ($result === false) {
|
||||
return $this->fail(PersonalAccountCostLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('添加成功', [], 1, 1);
|
||||
}
|
||||
|
||||
public function edit()
|
||||
{
|
||||
$params = (new PersonalAccountCostValidate())->post()->goCheck('edit');
|
||||
$result = PersonalAccountCostLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PersonalAccountCostLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('编辑成功', [], 1, 1);
|
||||
}
|
||||
|
||||
public function detail()
|
||||
{
|
||||
$params = (new PersonalAccountCostValidate())->goCheck('detail');
|
||||
$detail = PersonalAccountCostLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($detail === []) {
|
||||
return $this->fail('记录不存在或无权查看');
|
||||
}
|
||||
|
||||
return $this->data($detail);
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
$params = (new PersonalAccountCostValidate())->post()->goCheck('delete');
|
||||
$result = PersonalAccountCostLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PersonalAccountCostLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('删除成功', [], 1, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\stats\PersonalYejiLists;
|
||||
use app\adminapi\logic\stats\PersonalYejiLogic;
|
||||
use app\adminapi\validate\stats\PersonalYejiValidate;
|
||||
|
||||
class PersonalYejiController extends BaseAdminController
|
||||
{
|
||||
public function lists()
|
||||
{
|
||||
return $this->dataLists(new PersonalYejiLists());
|
||||
}
|
||||
|
||||
public function add()
|
||||
{
|
||||
$params = (new PersonalYejiValidate())->post()->goCheck('add');
|
||||
$result = PersonalYejiLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''));
|
||||
if ($result === false) {
|
||||
return $this->fail(PersonalYejiLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('添加成功', [], 1, 1);
|
||||
}
|
||||
|
||||
public function edit()
|
||||
{
|
||||
$params = (new PersonalYejiValidate())->post()->goCheck('edit');
|
||||
$result = PersonalYejiLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PersonalYejiLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('编辑成功', [], 1, 1);
|
||||
}
|
||||
|
||||
public function detail()
|
||||
{
|
||||
$params = (new PersonalYejiValidate())->goCheck('detail');
|
||||
$detail = PersonalYejiLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($detail === []) {
|
||||
return $this->fail('记录不存在或无权查看');
|
||||
}
|
||||
|
||||
return $this->data($detail);
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
$params = (new PersonalYejiValidate())->post()->goCheck('delete');
|
||||
$result = PersonalYejiLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PersonalYejiLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('删除成功', [], 1, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\stats;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\stats\SelfInputLogic;
|
||||
|
||||
class SelfInputController extends BaseAdminController
|
||||
{
|
||||
public function overview()
|
||||
{
|
||||
$result = SelfInputLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自媒体来源下拉:从已录入业绩/账户消耗去重提取(随录入动态变化)
|
||||
*/
|
||||
public function mediaSourceOptions()
|
||||
{
|
||||
$list = SelfInputLogic::mediaSourceOptions($this->adminId, $this->adminInfo);
|
||||
|
||||
return $this->data($list);
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,20 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
return $this->success('关联支付单成功', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发货/已签收:仅提交完单申请(不新增/关联支付单),并重置支付审核为待审核
|
||||
*/
|
||||
public function requestCompletion()
|
||||
{
|
||||
$params = (new PrescriptionOrderValidate())->post()->goCheck('requestCompletion');
|
||||
$result = PrescriptionOrderLogic::requestCompletion($params, $this->adminId, $this->adminInfo);
|
||||
if ($result === false) {
|
||||
return $this->fail(PrescriptionOrderLogic::getError());
|
||||
}
|
||||
|
||||
return $this->success('完单申请已提交', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将「已发货」且支付审核已通过的订单标记为「已完成」
|
||||
*/
|
||||
|
||||
@@ -132,9 +132,55 @@ class AuthMiddleware
|
||||
return true;
|
||||
}
|
||||
|
||||
// 自录转化统计:自媒体来源下拉,复用总览或账户消耗列表权限
|
||||
if ($accessUri === 'stats.selfinput/mediasourceoptions') {
|
||||
$selfInputAliases = [
|
||||
'stats.selfinput/overview',
|
||||
'stats.self_input/overview',
|
||||
'stats.personalaccountcost/lists',
|
||||
'stats.personal_account_cost/lists',
|
||||
];
|
||||
if (count(array_intersect($selfInputAliases, $AdminUris)) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 处方库列表:消费者开方页/处方库页导入共用,复用开方或处方库菜单权限
|
||||
if ($accessUri === 'tcm.prescriptionlibrary/lists'
|
||||
&& $this->matchPrescriptionLibraryListsPermission($adminUris)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处方库 lists:与开方、处方库维护菜单权限互通(避免开方页「从处方库导入」403)
|
||||
*/
|
||||
private function matchPrescriptionLibraryListsPermission(array $adminUris): bool
|
||||
{
|
||||
$aliases = [
|
||||
'tcm.prescriptionlibrary/lists',
|
||||
'tcm.prescription/lists',
|
||||
'tcm.prescription/add',
|
||||
'tcm.prescription/edit',
|
||||
'tcm.prescription/detail',
|
||||
'cf.prescription/lists',
|
||||
'cf.prescription/add',
|
||||
'cf.prescription/edit',
|
||||
'cf.prescription/read',
|
||||
'cf.prescription/del',
|
||||
'cf.prescription/audit',
|
||||
'wcf.prescription/lists',
|
||||
'wcf.prescription/read',
|
||||
'wcf.prescription/add',
|
||||
'wcf.prescription/edit',
|
||||
'wcf.prescription/delete',
|
||||
];
|
||||
|
||||
return count(array_intersect($aliases, $adminUris)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 面诊进度专用:auth.admin/lists、doctor.appointment/lists + progress_board=1,不校验菜单权限
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\stats;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\stats\PersonalStatsScopeTrait;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\lists\Traits\HasDataScopeFilter;
|
||||
use app\common\model\stats\PersonalAccountCost;
|
||||
|
||||
class PersonalAccountCostLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
|
||||
{
|
||||
use HasDataScopeFilter;
|
||||
use PersonalStatsScopeTrait;
|
||||
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'%like%' => ['media_source', 'creator_name', 'remark'],
|
||||
];
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
{
|
||||
$query = PersonalAccountCost::where($this->searchWhere);
|
||||
$visibleIds = $this->getDataScopeVisibleAdminIds();
|
||||
$deptId = (int) ($this->params['dept_id'] ?? 0);
|
||||
|
||||
$finalIds = self::intersectVisibleByDept($visibleIds, $deptId);
|
||||
if ($finalIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
} elseif ($finalIds !== null) {
|
||||
$query->whereIn('creator_id', $finalIds);
|
||||
}
|
||||
|
||||
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
|
||||
$query->whereBetween('cost_date', [$this->params['start_date'], $this->params['end_date']]);
|
||||
} elseif (!empty($this->params['start_date'])) {
|
||||
$query->where('cost_date', '>=', $this->params['start_date']);
|
||||
} elseif (!empty($this->params['end_date'])) {
|
||||
$query->where('cost_date', '<=', $this->params['end_date']);
|
||||
}
|
||||
|
||||
if (!empty($this->params['media_source'])) {
|
||||
$query->where('media_source', trim((string) $this->params['media_source']));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->baseQuery()
|
||||
->order(['cost_date' => 'desc', 'id' => 'desc'])
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return self::attachDeptInfoToRows($rows);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->baseQuery()->count();
|
||||
}
|
||||
|
||||
public function extend(): array
|
||||
{
|
||||
return [
|
||||
'total_amount' => round((float) $this->baseQuery()->sum('amount'), 2),
|
||||
'days_count' => (int) $this->baseQuery()->distinct(true)->count('cost_date'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\stats;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\adminapi\logic\stats\PersonalStatsScopeTrait;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\lists\Traits\HasDataScopeFilter;
|
||||
use app\common\model\stats\PersonalYeji;
|
||||
|
||||
class PersonalYejiLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
{
|
||||
use HasDataScopeFilter;
|
||||
use PersonalStatsScopeTrait;
|
||||
|
||||
public function setSearch(): array
|
||||
{
|
||||
return [
|
||||
'%like%' => ['media_source', 'creator_name', 'remark'],
|
||||
];
|
||||
}
|
||||
|
||||
private function baseQuery()
|
||||
{
|
||||
$query = PersonalYeji::where($this->searchWhere);
|
||||
$visibleIds = $this->getDataScopeVisibleAdminIds();
|
||||
$deptId = (int) ($this->params['dept_id'] ?? 0);
|
||||
|
||||
$finalIds = self::intersectVisibleByDept($visibleIds, $deptId);
|
||||
if ($finalIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
} elseif ($finalIds !== null) {
|
||||
$query->whereIn('creator_id', $finalIds);
|
||||
}
|
||||
|
||||
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
|
||||
$query->whereBetween('yeji_date', [$this->params['start_date'], $this->params['end_date']]);
|
||||
} elseif (!empty($this->params['start_date'])) {
|
||||
$query->where('yeji_date', '>=', $this->params['start_date']);
|
||||
} elseif (!empty($this->params['end_date'])) {
|
||||
$query->where('yeji_date', '<=', $this->params['end_date']);
|
||||
}
|
||||
|
||||
if (!empty($this->params['media_source'])) {
|
||||
$query->where('media_source', trim((string) $this->params['media_source']));
|
||||
}
|
||||
|
||||
if (!empty($this->params['creator_id'])) {
|
||||
$query->where('creator_id', (int) $this->params['creator_id']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function lists(): array
|
||||
{
|
||||
$rows = $this->baseQuery()
|
||||
->order(['yeji_date' => 'desc', 'id' => 'desc'])
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return self::attachDeptInfoToRows($rows);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->baseQuery()->count();
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
|
||||
{
|
||||
return [
|
||||
'%like%' => ['prescription_name'],
|
||||
'=' => ['is_public', 'creator_id']
|
||||
'=' => ['is_public', 'creator_id', 'formula_type']
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
|
||||
return $query;
|
||||
}
|
||||
|
||||
// 开方页导入专用参数(勿与搜索项 creator_id 混用,否则无法 OR 出他人公开模板)
|
||||
$prescribingCreatorId = (int) ($this->params['prescribing_creator_id'] ?? 0);
|
||||
if ($prescribingCreatorId > 0
|
||||
&& PrescriptionLibraryLogic::canListLibraryForCreator($this->adminId, $this->adminInfo, $prescribingCreatorId)) {
|
||||
return $query->where(function ($q) use ($prescribingCreatorId) {
|
||||
$q->where('creator_id', $prescribingCreatorId)
|
||||
->whereOr('is_public', 1);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->where(function ($q) {
|
||||
$q->where('creator_id', $this->adminId)
|
||||
->whereOr('is_public', 1);
|
||||
@@ -46,7 +56,7 @@ class PrescriptionLibraryLists extends BaseAdminDataLists implements ListsSearch
|
||||
public function lists(): array
|
||||
{
|
||||
$field = [
|
||||
'id', 'prescription_name', 'herbs', 'is_public',
|
||||
'id', 'prescription_name', 'formula_type', 'herbs', 'is_public',
|
||||
'creator_id', 'creator_name', 'create_time', 'update_time'
|
||||
];
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ use app\common\lists\ListsExtendInterface;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\lists\Traits\HasDataScopeFilter;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\Prescription;
|
||||
use app\common\model\tcm\PrescriptionOrder;
|
||||
use app\common\model\tcm\PrescriptionOrderLog;
|
||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||
use app\common\model\ExpressTracking;
|
||||
use app\common\service\gancao\GancaoScmRecipelService;
|
||||
@@ -106,8 +108,12 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$this->applyExpressKeywordFilter($query);
|
||||
$this->applyServiceChannelFilter($query);
|
||||
$this->applySupplyModeFilter($query);
|
||||
$this->applyAuditAdminFilter($query);
|
||||
if (!$this->shouldBypassListVisibilityForDiagnosisEdit()) {
|
||||
$this->applyCreatorOrOwnPrescriptionVisibility($query);
|
||||
// 业绩看板按部门点「合计业绩」/复诊下钻:部门或数据域内医助筛选已收口,勿再叠「仅本人订单」
|
||||
if (!$this->shouldSkipCreatorOnlyForYejiDrawer()) {
|
||||
$this->applyCreatorOrOwnPrescriptionVisibility($query);
|
||||
}
|
||||
$this->applyDataScopeForPrescriptionOrder($query);
|
||||
}
|
||||
// 业绩看板侧栏等:不展示履约 4/9/10,与 stats 业绩口径一致
|
||||
@@ -118,6 +124,105 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
YejiStatsLogic::applyYejiDrawerChannelFilterToPrescriptionOrderQuery($query, $this->params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按下单人筛选:关联操作日志中该用户执行的处方/支付单审核记录
|
||||
*/
|
||||
private function applyAuditAdminFilter($query): void
|
||||
{
|
||||
$auditAdminId = (int) ($this->params['audit_admin_id'] ?? 0);
|
||||
$keyword = trim((string) ($this->params['audit_admin_keyword'] ?? ''));
|
||||
|
||||
if ($auditAdminId <= 0 && $keyword === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$restricted = PrescriptionOrderLogic::isOrderPlacerAuditFilterRestricted($this->adminInfo);
|
||||
if ($restricted) {
|
||||
$selfId = (int) $this->adminId;
|
||||
if ($auditAdminId > 0 && $auditAdminId !== $selfId) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$selfName = trim((string) ($this->adminInfo['name'] ?? ''));
|
||||
if ($selfName === ''
|
||||
|| (mb_stripos($selfName, $keyword) === false && mb_stripos($keyword, $selfName) === false)) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$auditAdminId = $selfId;
|
||||
} elseif ($auditAdminId <= 0 && $keyword !== '') {
|
||||
$roleIds = Config::get('project.prescription_order_placer_role_ids', [6]);
|
||||
$roleIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
is_array($roleIds) ? $roleIds : []
|
||||
), static fn(int $id): bool => $id > 0)));
|
||||
if ($roleIds === []) {
|
||||
$roleIds = [6];
|
||||
}
|
||||
$adminIds = Admin::alias('a')
|
||||
->join('admin_role ar', 'a.id = ar.admin_id')
|
||||
->whereLike('a.name', '%' . $keyword . '%')
|
||||
->whereIn('ar.role_id', $roleIds)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->column('a.id');
|
||||
$adminIds = array_values(array_filter(array_map('intval', $adminIds), static function (int $id): bool {
|
||||
return $id > 0;
|
||||
}));
|
||||
if ($adminIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->applyAuditedByAdminIdsFilter($query, $adminIds);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($auditAdminId <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!PrescriptionOrderLogic::adminHasPlacerRole($auditAdminId)) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyAuditedByAdminIdsFilter($query, [$auditAdminId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $adminIds
|
||||
*/
|
||||
private function applyAuditedByAdminIdsFilter($query, array $adminIds): void
|
||||
{
|
||||
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static function (int $id): bool {
|
||||
return $id > 0;
|
||||
})));
|
||||
if ($adminIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$logTbl = (new PrescriptionOrderLog())->getTable();
|
||||
$poTbl = (new PrescriptionOrder())->getTable();
|
||||
$actions = PrescriptionOrderLogic::prescriptionOrderAuditLogActions();
|
||||
$actionIn = implode(',', array_map(static function (string $a): string {
|
||||
return "'" . addslashes($a) . "'";
|
||||
}, $actions));
|
||||
$idIn = implode(',', $adminIds);
|
||||
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM `{$logTbl}` l WHERE l.`prescription_order_id` = `{$poTbl}`.`id`"
|
||||
. " AND l.`admin_id` IN ({$idIn}) AND l.`action` IN ({$actionIn})"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩看板:仅列出二中心复诊口径下的业务订单(须同时传 assistant_id、start_time、end_time;revisit_slot=0 为全部复诊分项合计)。
|
||||
* admin 维度按订单创建人 o.creator_id(与部门表复诊列、排行榜复诊列同口径)。
|
||||
@@ -243,9 +348,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
|
||||
/**
|
||||
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则包含关联处方开方人为本人的订单;
|
||||
* 另含「关联诊单医助为本人」以便医助跟进出单与履约。
|
||||
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按「诊单医助 / 订单创建人」命中(与 applyDoctorAssistantFilters 一致)。
|
||||
* 非「业务订单全量」角色:默认仅本人创建;若持有 viewOrdersForOwnPrescription 则额外包含关联处方开方人为本人的订单。
|
||||
* 显式筛选 assistant_id 时:若该医助落在当前账号数据域内,则允许按订单创建人命中(与 applyDoctorAssistantFilters 一致)。
|
||||
*/
|
||||
private function applyCreatorOrOwnPrescriptionVisibility($query): void
|
||||
{
|
||||
@@ -255,7 +359,6 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$adminId = (int) $this->adminId;
|
||||
$poTbl = (new PrescriptionOrder())->getTable();
|
||||
$rxTbl = (new Prescription())->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
|
||||
$explicitAssistant = (int) ($this->params['assistant_id'] ?? 0);
|
||||
$allowExplicitAssistantVisibility = false;
|
||||
@@ -273,69 +376,33 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$query->where(function ($q) use (
|
||||
$poTbl,
|
||||
$rxTbl,
|
||||
$diagTbl,
|
||||
$adminId,
|
||||
$explicitAssistant,
|
||||
$allowExplicitAssistantVisibility
|
||||
) {
|
||||
if (PrescriptionOrderLogic::canViewOrdersForOwnPrescription($this->adminInfo)) {
|
||||
$q->where(function ($qq) use ($poTbl, $rxTbl, $diagTbl, $adminId) {
|
||||
$q->where(function ($qq) use ($poTbl, $rxTbl, $adminId) {
|
||||
$qq->where('creator_id', $adminId);
|
||||
$qq->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$poTbl}`.`prescription_id`"
|
||||
. " AND rx.`delete_time` IS NULL AND rx.`creator_id` = {$adminId})"
|
||||
);
|
||||
$qq->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
|
||||
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$adminId})"
|
||||
);
|
||||
});
|
||||
} else {
|
||||
$q->where(function ($qq) use ($poTbl, $diagTbl, $adminId) {
|
||||
$qq->where('creator_id', $adminId);
|
||||
$qq->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
|
||||
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$adminId})"
|
||||
);
|
||||
});
|
||||
$q->where('creator_id', $adminId);
|
||||
}
|
||||
if ($allowExplicitAssistantVisibility) {
|
||||
$q->whereOr('creator_id', $explicitAssistant);
|
||||
$q->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
|
||||
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$explicitAssistant})"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据隔离:创建者 ∈ 可见 admin,或 关联诊单医助 ∈ 可见 admin
|
||||
* 数据隔离:订单创建人 ∈ 可见 admin
|
||||
*/
|
||||
private function applyDataScopeForPrescriptionOrder($query): void
|
||||
{
|
||||
if (!$this->dataScopeShouldApply()) {
|
||||
return;
|
||||
}
|
||||
$ids = $this->getDataScopeVisibleAdminIds();
|
||||
if ($ids === null) {
|
||||
return;
|
||||
}
|
||||
if ($ids === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$poTbl = (new PrescriptionOrder())->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$inList = implode(',', $ids);
|
||||
$query->where(function ($q) use ($poTbl, $diagTbl, $inList) {
|
||||
$q->whereIn('creator_id', explode(',', $inList));
|
||||
$q->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` "
|
||||
. "AND dg.`delete_time` IS NULL AND dg.`assistant_id` IN ({$inList}))"
|
||||
);
|
||||
});
|
||||
$this->applyDataScopeByOwner($query, 'creator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,16 +429,55 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助:仅「诊单医助=本人」的订单计业绩
|
||||
* 业绩看板侧栏:按部门行点合计业绩/接诊诊单(assistant_dept_id),组长等应看到组内全员订单。
|
||||
*/
|
||||
private function applyAssistantDiagnosisOnlyFilter($query): void
|
||||
private function isYejiDrawerDeptPerformanceScope(): bool
|
||||
{
|
||||
$poTbl = (new PrescriptionOrder())->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$aid = (int) $this->adminId;
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$aid}"
|
||||
);
|
||||
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
if ((int) ($this->params['yeji_drawer_match_table_performance'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isset($this->params['assistant_dept_id']) && (int) $this->params['assistant_dept_id'] > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩看板侧栏:部门业绩或复诊下钻查看数据域内其他医助订单时,跳过「仅本人可见」。
|
||||
*/
|
||||
private function shouldSkipCreatorOnlyForYejiDrawer(): bool
|
||||
{
|
||||
if ($this->isYejiDrawerDeptPerformanceScope()) {
|
||||
return true;
|
||||
}
|
||||
if ((int) ($this->params['yeji_order_drawer'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
if ((int) ($this->params['yeji_er_center_revisit_only'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
$assistantId = (int) ($this->params['assistant_id'] ?? 0);
|
||||
if ($assistantId <= 0 || $assistantId === (int) $this->adminId) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->dataScopeShouldApply()) {
|
||||
return true;
|
||||
}
|
||||
$visibleIds = $this->getDataScopeVisibleAdminIds();
|
||||
if ($visibleIds === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($assistantId, $visibleIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助角色统计:仅「订单创建人=本人」计入
|
||||
*/
|
||||
private function applyCreatorOnlyFilter($query): void
|
||||
{
|
||||
$query->where('creator_id', (int) $this->adminId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,13 +490,16 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$query = PrescriptionOrder::where($this->searchWhereWithoutCreateTimeRange())->whereNull('delete_time');
|
||||
$this->applyDoctorAssistantFilters($query);
|
||||
$this->applyPatientKeywordFilter($query);
|
||||
if (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
|
||||
$this->applyAuditAdminFilter($query);
|
||||
if ($this->shouldSkipCreatorOnlyForYejiDrawer()) {
|
||||
$this->applyDataScopeForPrescriptionOrder($query);
|
||||
} elseif (PrescriptionOrderLogic::canViewOrderListStatsAllScope($this->adminInfo)) {
|
||||
$this->applyDataScopeForPrescriptionOrder($query);
|
||||
} else {
|
||||
$assistantRid = (int) Config::get('project.prescription_order_stats_assistant_role_id', 2);
|
||||
$myRoles = array_map('intval', $this->adminInfo['role_id'] ?? []);
|
||||
if ($assistantRid > 0 && in_array($assistantRid, $myRoles, true)) {
|
||||
$this->applyAssistantDiagnosisOnlyFilter($query);
|
||||
$this->applyCreatorOnlyFilter($query);
|
||||
$this->applyDataScopeForPrescriptionOrder($query);
|
||||
} else {
|
||||
if (!PrescriptionOrderLogic::canSeeAllPrescriptionOrders($this->adminInfo)) {
|
||||
@@ -628,6 +737,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
'focus_pending_pay_count' => $focus['pending_pay'],
|
||||
'focus_pending_ship_count' => $focus['pending_ship'],
|
||||
'focus_risk_count' => $focus['risk'],
|
||||
/** 1=「下单」角色审核人筛选仅本人 */
|
||||
'list_placer_audit_filter_restricted' => PrescriptionOrderLogic::isOrderPlacerAuditFilterRestricted($this->adminInfo) ? 1 : 0,
|
||||
/** 下单人下拉(下单角色用户) */
|
||||
'audit_admin_options' => PrescriptionOrderLogic::listAuditAdminOptions($this->adminId, $this->adminInfo),
|
||||
];
|
||||
$yejiConsult = $this->computeYejiDrawerConsultCountIfRequested();
|
||||
if ($yejiConsult !== null) {
|
||||
@@ -959,9 +1072,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
|
||||
/**
|
||||
* 按开方医生(关联处方 creator_id)/ 诊单医助筛选
|
||||
*
|
||||
* 医助:诊单 assistant_id 与业务订单 creator_id 并举——医助常代建单但诊单未写 assistant_id。
|
||||
* 按开方医生(关联处方 creator_id)/ 医助(订单 creator_id)筛选
|
||||
*/
|
||||
private function applyDoctorAssistantFilters($query): void
|
||||
{
|
||||
@@ -973,23 +1084,8 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.id = {$poTbl}.prescription_id AND rx.delete_time IS NULL AND rx.creator_id = {$doctorId}");
|
||||
}
|
||||
|
||||
/** 业绩看板入口(yeji_order_drawer=1)所有筛选统一按订单创建人;其它入口(处方订单列表 / 患者跟进等)保留旧口径(创建人 ∪ 诊单医助) */
|
||||
$yejiPanel = (int) ($this->params['yeji_order_drawer'] ?? 0) === 1;
|
||||
|
||||
if (isset($this->params['assistant_id']) && (int) $this->params['assistant_id'] > 0) {
|
||||
$assistantId = (int) $this->params['assistant_id'];
|
||||
if ($yejiPanel) {
|
||||
$query->where('creator_id', $assistantId);
|
||||
} else {
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$query->where(function ($q) use ($poTbl, $diagTbl, $assistantId) {
|
||||
$q->where('creator_id', $assistantId);
|
||||
$q->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
|
||||
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})"
|
||||
);
|
||||
});
|
||||
}
|
||||
$query->where('creator_id', (int) $this->params['assistant_id']);
|
||||
}
|
||||
|
||||
if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
|
||||
@@ -1039,24 +1135,10 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
}
|
||||
$inList = implode(',', $deptIds);
|
||||
$adTbl = (new AdminDept())->getTable();
|
||||
if ($yejiPanel) {
|
||||
/** 业绩看板部门侧栏:仅订单创建人人事部门 ∈ 子树(与「合计业绩」/「接诊诊单」/「复诊」聚合同口径) */
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})"
|
||||
);
|
||||
} else {
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$query->where(function ($q) use ($poTbl, $diagTbl, $adTbl, $inList) {
|
||||
$q->whereExists(
|
||||
"SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
|
||||
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})"
|
||||
);
|
||||
$q->whereExists(
|
||||
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})",
|
||||
'OR'
|
||||
);
|
||||
});
|
||||
}
|
||||
/** 按订单创建人人事部门 ∈ 子树(与业绩看板「合计业绩」/「接诊诊单」同口径) */
|
||||
$query->whereExists(
|
||||
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,8 @@ class DeptLogic extends BaseLogic
|
||||
private static function computeErCenterRevisitFullPack(
|
||||
array $subtreeDeptIds,
|
||||
?int $orderStartTs = null,
|
||||
?int $orderEndTs = null
|
||||
?int $orderEndTs = null,
|
||||
?array $visibleCreatorIds = null
|
||||
): array {
|
||||
$emptySlots = [];
|
||||
$emptyTotals = [];
|
||||
@@ -284,6 +285,14 @@ class DeptLogic extends BaseLogic
|
||||
/** 复用 buildAssistantCanonicalDeptInSubtree —— 该方法实际是 admin → 子树内 canonical 部门,与 admin 角色无关 */
|
||||
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($creatorIds, $subtreeSet);
|
||||
|
||||
$creatorFlip = null;
|
||||
if ($visibleCreatorIds !== null && $visibleCreatorIds !== []) {
|
||||
$creatorFlip = array_flip(array_values(array_unique(array_filter(
|
||||
array_map('intval', $visibleCreatorIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
))));
|
||||
}
|
||||
|
||||
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ],slot≥2 */
|
||||
$leafByDeptSlot = [];
|
||||
/** @var array<int, array<int, int>> $byCreatorSlot 复诊按订单创建人聚合(与诊金/接诊诊单口径一致) */
|
||||
@@ -293,6 +302,9 @@ class DeptLogic extends BaseLogic
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$slot = $i + 1;
|
||||
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
|
||||
if ($creatorFlip !== null && ($cid <= 0 || !isset($creatorFlip[$cid]))) {
|
||||
continue;
|
||||
}
|
||||
$deptId = $canonicalDept[$cid] ?? null;
|
||||
if ($deptId === null || !isset($subtreeSet[$deptId])) {
|
||||
continue;
|
||||
@@ -337,14 +349,17 @@ class DeptLogic extends BaseLogic
|
||||
*
|
||||
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
|
||||
*/
|
||||
public static function getErCenterRevisitRollupIndexedByDept(?int $orderStartTs = null, ?int $orderEndTs = null): array
|
||||
{
|
||||
public static function getErCenterRevisitRollupIndexedByDept(
|
||||
?int $orderStartTs = null,
|
||||
?int $orderEndTs = null,
|
||||
?array $visibleCreatorIds = null
|
||||
): array {
|
||||
$erRoots = self::findErCenterRootDeptIds();
|
||||
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
|
||||
if ($subtreeIds === []) {
|
||||
return ['totals' => [], 'slots' => []];
|
||||
}
|
||||
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
|
||||
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs, $visibleCreatorIds);
|
||||
|
||||
return ['totals' => $pack['totals'], 'slots' => $pack['slots']];
|
||||
}
|
||||
@@ -354,14 +369,17 @@ class DeptLogic extends BaseLogic
|
||||
*
|
||||
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
|
||||
*/
|
||||
public static function getErCenterRevisitCountsByCreator(?int $orderStartTs = null, ?int $orderEndTs = null): array
|
||||
{
|
||||
public static function getErCenterRevisitCountsByCreator(
|
||||
?int $orderStartTs = null,
|
||||
?int $orderEndTs = null,
|
||||
?array $visibleCreatorIds = null
|
||||
): array {
|
||||
$erRoots = self::findErCenterRootDeptIds();
|
||||
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
|
||||
if ($subtreeIds === []) {
|
||||
return ['totals' => [], 'slots' => []];
|
||||
}
|
||||
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
|
||||
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs, $visibleCreatorIds);
|
||||
$by = $pack['by_creator_slots'];
|
||||
$totals = [];
|
||||
$slots = [];
|
||||
@@ -436,7 +454,8 @@ class DeptLogic extends BaseLogic
|
||||
int $rootDeptId,
|
||||
int $revisitSlot,
|
||||
int $orderStartTs,
|
||||
int $orderEndTs
|
||||
int $orderEndTs,
|
||||
?array $visibleCreatorIds = null
|
||||
): array {
|
||||
if ($rootDeptId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
|
||||
return ['rows' => []];
|
||||
@@ -479,6 +498,13 @@ class DeptLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($creatorNeed), $erSubtreeSet);
|
||||
$creatorFlip = null;
|
||||
if ($visibleCreatorIds !== null && $visibleCreatorIds !== []) {
|
||||
$creatorFlip = array_flip(array_values(array_unique(array_filter(
|
||||
array_map('intval', $visibleCreatorIds),
|
||||
static fn (int $id): bool => $id > 0
|
||||
))));
|
||||
}
|
||||
/** @var array<int, int> $counts */
|
||||
$counts = [];
|
||||
foreach ($byPatient as $orders) {
|
||||
@@ -492,6 +518,9 @@ class DeptLogic extends BaseLogic
|
||||
if ($cid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($creatorFlip !== null && !isset($creatorFlip[$cid])) {
|
||||
continue;
|
||||
}
|
||||
$canon = $canonicalDept[$cid] ?? null;
|
||||
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
|
||||
continue;
|
||||
|
||||
@@ -77,6 +77,9 @@ class AccountCostLogic extends BaseLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
$mediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
|
||||
$mediaChannelName = MediaChannelService::getNameByCode($mediaChannelCode);
|
||||
|
||||
$supportsDeptBinding = AccountCost::supportsDeptBinding();
|
||||
$deptId = $supportsDeptBinding ? (int) ($params['dept_id'] ?? 0) : 0;
|
||||
$deptName = $supportsDeptBinding ? self::resolveDeptName($deptId) : '';
|
||||
@@ -89,7 +92,7 @@ class AccountCostLogic extends BaseLogic
|
||||
if ($supportsDeptBinding) {
|
||||
if (AccountCost::where('id', '<>', (int) $params['id'])
|
||||
->where('cost_date', (string) $model->cost_date)
|
||||
->where('media_channel_code', (string) $model->media_channel_code)
|
||||
->where('media_channel_code', $mediaChannelCode)
|
||||
->where('dept_id', $deptId)
|
||||
->count() > 0) {
|
||||
self::setError('该日期下所选渠道和部门的账户消耗已存在,请直接编辑');
|
||||
@@ -98,6 +101,8 @@ class AccountCostLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
$model->media_channel_code = $mediaChannelCode;
|
||||
$model->media_channel_name = $mediaChannelName;
|
||||
if ($supportsDeptBinding) {
|
||||
$model->dept_id = $deptId;
|
||||
$model->dept_name = $deptName;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
class AssistantPerformanceLogic
|
||||
{
|
||||
/** 履约完成 */
|
||||
private const FULFILLMENT_COMPLETED = 3;
|
||||
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
// 时间范围解析
|
||||
$timeType = $params['time_type'] ?? 'month';
|
||||
$today = date('Y-m-d');
|
||||
|
||||
switch ($timeType) {
|
||||
case 'today':
|
||||
$startDate = $today;
|
||||
$endDate = $today;
|
||||
break;
|
||||
case 'yesterday':
|
||||
$startDate = date('Y-m-d', strtotime('-1 day'));
|
||||
$endDate = $startDate;
|
||||
break;
|
||||
case 'week':
|
||||
$startDate = date('Y-m-d', strtotime('-6 days'));
|
||||
$endDate = $today;
|
||||
break;
|
||||
case 'month':
|
||||
$startDate = date('Y-m-d', strtotime('-29 days'));
|
||||
$endDate = $today;
|
||||
break;
|
||||
case 'custom':
|
||||
$startDate = $params['start_date'] ?? $today;
|
||||
$endDate = $params['end_date'] ?? $today;
|
||||
break;
|
||||
default:
|
||||
$startDate = date('Y-m-d', strtotime('-29 days'));
|
||||
$endDate = $today;
|
||||
}
|
||||
|
||||
$startTs = strtotime($startDate . ' 00:00:00');
|
||||
$endTs = strtotime($endDate . ' 23:59:59');
|
||||
|
||||
// 查询当前医助创建的、履约已完成的处方业务订单
|
||||
$baseQuery = Db::name('tcm_prescription_order')
|
||||
->where('delete_time IS NULL')
|
||||
->where('diagnosis_id', '>', 0)
|
||||
->where('creator_id', $adminId)
|
||||
->where('fulfillment_status', self::FULFILLMENT_COMPLETED)
|
||||
->where('create_time', '>=', $startTs)
|
||||
->where('create_time', '<=', $endTs);
|
||||
|
||||
// 业绩总额
|
||||
$totalAmount = (clone $baseQuery)->sum('amount');
|
||||
|
||||
// 有效订单数
|
||||
$totalCount = (clone $baseQuery)->count();
|
||||
|
||||
// 按日期分组的折线图数据
|
||||
$dailyData = (clone $baseQuery)
|
||||
->field("FROM_UNIXTIME(create_time, '%Y-%m-%d') as date_label, SUM(amount) as daily_amount, COUNT(*) as daily_count")
|
||||
->group('date_label')
|
||||
->order('date_label', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 补全日期范围内的空日期
|
||||
$dateMap = [];
|
||||
foreach ($dailyData as $row) {
|
||||
$dateMap[$row['date_label']] = [
|
||||
'amount' => round((float)$row['daily_amount'], 2),
|
||||
'count' => (int)$row['daily_count'],
|
||||
];
|
||||
}
|
||||
|
||||
$dates = [];
|
||||
$amounts = [];
|
||||
$counts = [];
|
||||
$cursor = strtotime($startDate);
|
||||
$endCursor = strtotime($endDate);
|
||||
while ($cursor <= $endCursor) {
|
||||
$d = date('Y-m-d', $cursor);
|
||||
$dates[] = substr($d, 5); // MM-DD
|
||||
$amounts[] = $dateMap[$d]['amount'] ?? 0;
|
||||
$counts[] = $dateMap[$d]['count'] ?? 0;
|
||||
$cursor = strtotime('+1 day', $cursor);
|
||||
}
|
||||
|
||||
return [
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'summary' => [
|
||||
'total_amount' => round((float)$totalAmount, 2),
|
||||
'total_count' => (int)$totalCount,
|
||||
],
|
||||
'chart' => [
|
||||
'dates' => $dates,
|
||||
'amounts' => $amounts,
|
||||
'counts' => $counts,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\stats\PersonalAccountCost;
|
||||
|
||||
class PersonalAccountCostLogic extends BaseLogic
|
||||
{
|
||||
use PersonalStatsScopeTrait;
|
||||
|
||||
public static function add(array $params, int $adminId, string $adminName): bool
|
||||
{
|
||||
try {
|
||||
$costDate = (string) $params['cost_date'];
|
||||
$mediaSource = self::normalizeMediaSource((string) ($params['media_source'] ?? ''));
|
||||
if ($mediaSource === '') {
|
||||
self::setError('请填写自媒体来源');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$dupId = self::findCostDuplicateId($adminId, $costDate, $mediaSource);
|
||||
if ($dupId > 0) {
|
||||
self::setError("您在 {$costDate} 已录入过【{$mediaSource}】账户消耗(记录#{$dupId}),请直接编辑该记录");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
PersonalAccountCost::create([
|
||||
'cost_date' => $costDate,
|
||||
'media_source' => $mediaSource,
|
||||
'amount' => round((float) ($params['amount'] ?? 0), 2),
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'creator_id' => $adminId,
|
||||
'creator_name' => $adminName,
|
||||
'updater_id' => $adminId,
|
||||
'updater_name' => $adminName,
|
||||
'dept_id' => self::resolvePrimaryDeptId($adminId),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
if (self::isUniqueConstraintViolation($e)) {
|
||||
self::setError('该日期下该渠道的账户消耗已存在(唯一索引冲突),请刷新列表后直接编辑');
|
||||
} else {
|
||||
self::setError($e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool
|
||||
{
|
||||
try {
|
||||
$model = PersonalAccountCost::find($params['id']);
|
||||
if (!$model) {
|
||||
self::setError('记录不存在');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||
self::setError('无权操作该记录');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$model->amount = round((float) ($params['amount'] ?? 0), 2);
|
||||
$model->remark = (string) ($params['remark'] ?? '');
|
||||
$model->updater_id = $adminId;
|
||||
$model->updater_name = $adminName;
|
||||
$model->save();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function delete(int $id, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
try {
|
||||
$model = PersonalAccountCost::find($id);
|
||||
if (!$model) {
|
||||
self::setError('记录不存在');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||
self::setError('无权操作该记录');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$model->delete();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function detail(int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$model = PersonalAccountCost::find($id);
|
||||
if (!$model) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $model->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\dept\Dept;
|
||||
use app\common\model\stats\PersonalAccountCost;
|
||||
use app\common\model\stats\PersonalYeji;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Config;
|
||||
|
||||
trait PersonalStatsScopeTrait
|
||||
{
|
||||
protected static function resolvePrimaryDeptId(int $adminId): int
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$deptId = AdminDept::where('admin_id', $adminId)->value('dept_id');
|
||||
|
||||
return (int) ($deptId ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $creatorIds
|
||||
* @return array{0: array<int, int>, 1: array<int, string>, 2: array<int, string>}
|
||||
* [adminId => deptId, deptId => name, deptId => "祖/父/当前"]
|
||||
*/
|
||||
protected static function loadAdminDeptMap(array $creatorIds): array
|
||||
{
|
||||
$creatorIds = array_values(array_unique(array_filter(array_map('intval', $creatorIds))));
|
||||
if ($creatorIds === []) {
|
||||
return [[], [], []];
|
||||
}
|
||||
$rows = AdminDept::whereIn('admin_id', $creatorIds)
|
||||
->field('admin_id, dept_id')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$adminToDeptId = [];
|
||||
foreach ($rows as $row) {
|
||||
$adminId = (int) ($row['admin_id'] ?? 0);
|
||||
$deptId = (int) ($row['dept_id'] ?? 0);
|
||||
if ($adminId <= 0 || $deptId <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($adminToDeptId[$adminId])) {
|
||||
$adminToDeptId[$adminId] = $deptId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($adminToDeptId === []) {
|
||||
return [[], [], []];
|
||||
}
|
||||
|
||||
$allDeptRows = Dept::field('id, pid, name')->select()->toArray();
|
||||
$deptIndex = [];
|
||||
foreach ($allDeptRows as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$deptIndex[$id] = [
|
||||
'pid' => (int) ($row['pid'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$deptNameMap = [];
|
||||
$deptPathMap = [];
|
||||
foreach (array_unique(array_values($adminToDeptId)) as $deptId) {
|
||||
$deptId = (int) $deptId;
|
||||
if ($deptId <= 0 || !isset($deptIndex[$deptId])) {
|
||||
continue;
|
||||
}
|
||||
$deptNameMap[$deptId] = $deptIndex[$deptId]['name'];
|
||||
$deptPathMap[$deptId] = self::resolveDeptPath($deptId, $deptIndex);
|
||||
}
|
||||
|
||||
return [$adminToDeptId, $deptNameMap, $deptPathMap];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从根节点到当前部门的完整链路(用 / 分隔)。
|
||||
*
|
||||
* @param array<int, array{pid: int, name: string}> $deptIndex
|
||||
*/
|
||||
private static function resolveDeptPath(int $deptId, array $deptIndex): string
|
||||
{
|
||||
$names = [];
|
||||
$guard = 0;
|
||||
$cursor = $deptId;
|
||||
while ($cursor > 0 && isset($deptIndex[$cursor]) && $guard++ < 32) {
|
||||
$node = $deptIndex[$cursor];
|
||||
$name = trim($node['name']);
|
||||
if ($name !== '') {
|
||||
array_unshift($names, $name);
|
||||
}
|
||||
$cursor = $node['pid'];
|
||||
}
|
||||
|
||||
return implode(' / ', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected static function attachDeptInfoToRows(array $rows): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return $rows;
|
||||
}
|
||||
$creatorIds = array_map(static fn (array $row): int => (int) ($row['creator_id'] ?? 0), $rows);
|
||||
[$adminToDeptId, $deptNameMap, $deptPathMap] = self::loadAdminDeptMap($creatorIds);
|
||||
foreach ($rows as &$row) {
|
||||
$creatorId = (int) ($row['creator_id'] ?? 0);
|
||||
$deptId = $adminToDeptId[$creatorId] ?? 0;
|
||||
$row['dept_id'] = $deptId;
|
||||
$row['dept_name'] = $deptId > 0 ? (string) ($deptNameMap[$deptId] ?? '') : '';
|
||||
$row['dept_path'] = $deptId > 0 ? (string) ($deptPathMap[$deptId] ?? '') : '';
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 dept_id(包含其子部门)收窄可见 admin id 集合。
|
||||
* 与 visibleAdminIds 取交集。
|
||||
*
|
||||
* @param array<int>|null $visibleAdminIds null 表示不限
|
||||
* @return array<int>|null 返回 null 表示外部条件无需附加;返回 [] 表示无可见 admin
|
||||
*/
|
||||
protected static function intersectVisibleByDept(?array $visibleAdminIds, int $deptId): ?array
|
||||
{
|
||||
if ($deptId <= 0) {
|
||||
return $visibleAdminIds;
|
||||
}
|
||||
$deptIds = DeptLogic::getSelfAndDescendantIds($deptId);
|
||||
if ($deptIds === []) {
|
||||
$deptIds = [$deptId];
|
||||
}
|
||||
$adminIds = AdminDept::whereIn('dept_id', $deptIds)->column('admin_id');
|
||||
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0)));
|
||||
|
||||
if ($visibleAdminIds === null) {
|
||||
return $adminIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($visibleAdminIds, $adminIds));
|
||||
}
|
||||
|
||||
protected static function normalizeMediaSource(string $mediaSource): string
|
||||
{
|
||||
return trim($mediaSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 project.self_input_stats_view_all_roles 一致:超管或白名单角色可见全部录入人数据。
|
||||
*/
|
||||
protected static function canViewAllSelfInputStats(array $adminInfo): bool
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
$allow = Config::get('project.self_input_stats_view_all_roles', []);
|
||||
$allow = array_map('intval', is_array($allow) ? $allow : []);
|
||||
if ($allow === []) {
|
||||
return false;
|
||||
}
|
||||
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
|
||||
|
||||
return count(array_intersect($myRoles, $allow)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>|null null=全部录入人;[]=无可见;int[]=可见 creator_id 集合
|
||||
*/
|
||||
protected static function getVisibleCreatorIds(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
if (self::canViewAllSelfInputStats($adminInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
}
|
||||
|
||||
protected static function assertRecordVisible(int $adminId, array $adminInfo, int $creatorId): bool
|
||||
{
|
||||
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
|
||||
if ($visibleIds === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($creatorId, $visibleIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一录入人 + 同一天 + 同一渠道唯一(不同录入人可同日同渠道各录一条)。
|
||||
* 命中返回冲突记录 ID,未命中返回 0。
|
||||
*/
|
||||
protected static function findYejiDuplicateId(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): int
|
||||
{
|
||||
$query = PersonalYeji::where('creator_id', $creatorId)
|
||||
->where('yeji_date', $yejiDate)
|
||||
->where('media_source', $mediaSource)
|
||||
->whereNull('delete_time');
|
||||
if ($excludeId > 0) {
|
||||
$query->where('id', '<>', $excludeId);
|
||||
}
|
||||
|
||||
return (int) ($query->value('id') ?? 0);
|
||||
}
|
||||
|
||||
protected static function isYejiDuplicate(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): bool
|
||||
{
|
||||
return self::findYejiDuplicateId($creatorId, $yejiDate, $mediaSource, $excludeId) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一录入人 + 同一天 + 同一渠道唯一(不同录入人可同日同渠道各录一条)。
|
||||
*/
|
||||
protected static function findCostDuplicateId(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): int
|
||||
{
|
||||
$query = PersonalAccountCost::where('creator_id', $creatorId)
|
||||
->where('cost_date', $costDate)
|
||||
->where('media_source', $mediaSource)
|
||||
->whereNull('delete_time');
|
||||
if ($excludeId > 0) {
|
||||
$query->where('id', '<>', $excludeId);
|
||||
}
|
||||
|
||||
return (int) ($query->value('id') ?? 0);
|
||||
}
|
||||
|
||||
protected static function isCostDuplicate(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): bool
|
||||
{
|
||||
return self::findCostDuplicateId($creatorId, $costDate, $mediaSource, $excludeId) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 唯一索引冲突 1062 兜底转友好提示(避免裸 SQL 异常)。
|
||||
*/
|
||||
protected static function isUniqueConstraintViolation(\Throwable $e): bool
|
||||
{
|
||||
return (int) $e->getCode() === 23000 || str_contains($e->getMessage(), '1062');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\stats\PersonalYeji;
|
||||
|
||||
class PersonalYejiLogic extends BaseLogic
|
||||
{
|
||||
use PersonalStatsScopeTrait;
|
||||
|
||||
public static function add(array $params, int $adminId, string $adminName): bool
|
||||
{
|
||||
try {
|
||||
$yejiDate = (string) $params['yeji_date'];
|
||||
$mediaSource = self::normalizeMediaSource((string) ($params['media_source'] ?? ''));
|
||||
if ($mediaSource === '') {
|
||||
self::setError('请填写自媒体来源');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$dupId = self::findYejiDuplicateId($adminId, $yejiDate, $mediaSource);
|
||||
if ($dupId > 0) {
|
||||
self::setError("您在 {$yejiDate} 已录入过【{$mediaSource}】业绩(记录#{$dupId}),请直接编辑该记录");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
PersonalYeji::create([
|
||||
'yeji_date' => $yejiDate,
|
||||
'media_source' => $mediaSource,
|
||||
'add_fans_count' => (int) ($params['add_fans_count'] ?? 0),
|
||||
'total_open_count' => (int) ($params['total_open_count'] ?? 0),
|
||||
'unreplied_count' => (int) ($params['unreplied_count'] ?? 0),
|
||||
'paid_appointment_count' => (int) ($params['paid_appointment_count'] ?? 0),
|
||||
'free_appointment_count' => (int) ($params['free_appointment_count'] ?? 0),
|
||||
'interview_count' => (int) ($params['interview_count'] ?? 0),
|
||||
'order_amount' => round((float) ($params['order_amount'] ?? 0), 2),
|
||||
'completed_order_count' => (int) ($params['completed_order_count'] ?? 0),
|
||||
'remark' => (string) ($params['remark'] ?? ''),
|
||||
'creator_id' => $adminId,
|
||||
'creator_name' => $adminName,
|
||||
'updater_id' => $adminId,
|
||||
'updater_name' => $adminName,
|
||||
'dept_id' => self::resolvePrimaryDeptId($adminId),
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
if (self::isUniqueConstraintViolation($e)) {
|
||||
self::setError('该日期下该渠道的业绩已存在(唯一索引冲突),请刷新列表后直接编辑');
|
||||
} else {
|
||||
self::setError($e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool
|
||||
{
|
||||
try {
|
||||
$model = PersonalYeji::find($params['id']);
|
||||
if (!$model) {
|
||||
self::setError('记录不存在');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||
self::setError('无权操作该记录');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$model->add_fans_count = (int) ($params['add_fans_count'] ?? 0);
|
||||
$model->total_open_count = (int) ($params['total_open_count'] ?? 0);
|
||||
$model->unreplied_count = (int) ($params['unreplied_count'] ?? 0);
|
||||
$model->paid_appointment_count = (int) ($params['paid_appointment_count'] ?? 0);
|
||||
$model->free_appointment_count = (int) ($params['free_appointment_count'] ?? 0);
|
||||
$model->interview_count = (int) ($params['interview_count'] ?? 0);
|
||||
$model->order_amount = round((float) ($params['order_amount'] ?? 0), 2);
|
||||
$model->completed_order_count = (int) ($params['completed_order_count'] ?? 0);
|
||||
$model->remark = (string) ($params['remark'] ?? '');
|
||||
$model->updater_id = $adminId;
|
||||
$model->updater_name = $adminName;
|
||||
$model->save();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function delete(int $id, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
try {
|
||||
$model = PersonalYeji::find($id);
|
||||
if (!$model) {
|
||||
self::setError('记录不存在');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||
self::setError('无权操作该记录');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$model->delete();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function detail(int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$model = PersonalYeji::find($id);
|
||||
if (!$model) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $model->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\stats;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\dict\DictData;
|
||||
use app\common\model\stats\PersonalAccountCost;
|
||||
use app\common\model\stats\PersonalYeji;
|
||||
|
||||
class SelfInputLogic extends BaseLogic
|
||||
{
|
||||
use PersonalStatsScopeTrait;
|
||||
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
[$startDate, $endDate] = self::resolveTimeRange($params);
|
||||
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||||
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 15)));
|
||||
$mediaSource = trim((string) ($params['media_source'] ?? ''));
|
||||
$deptId = (int) ($params['dept_id'] ?? 0);
|
||||
|
||||
$effectiveAdminIds = self::resolveEffectiveAdminIds($adminId, $adminInfo, $deptId);
|
||||
|
||||
$yejiQuery = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
|
||||
if ($mediaSource !== '') {
|
||||
$yejiQuery->where('media_source', $mediaSource);
|
||||
}
|
||||
|
||||
$count = (int) (clone $yejiQuery)->count();
|
||||
$rows = (clone $yejiQuery)
|
||||
->order(['yeji_date' => 'desc', 'id' => 'desc'])
|
||||
->page($pageNo, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$costMap = self::loadAccountCostMap($startDate, $endDate, $effectiveAdminIds, $mediaSource);
|
||||
|
||||
$lists = [];
|
||||
foreach ($rows as $row) {
|
||||
$entity = self::normalizeYejiRow($row);
|
||||
$costKey = self::buildCostKey(
|
||||
(int) $entity['creator_id'],
|
||||
(string) $entity['yeji_date'],
|
||||
(string) $entity['media_source']
|
||||
);
|
||||
$entity['account_cost'] = round((float) ($costMap[$costKey] ?? 0), 2);
|
||||
$lists[] = self::finalizeMetrics($entity);
|
||||
}
|
||||
$lists = self::attachDeptInfoToRows($lists);
|
||||
|
||||
$allYejiRows = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
|
||||
if ($mediaSource !== '') {
|
||||
$allYejiRows->where('media_source', $mediaSource);
|
||||
}
|
||||
$allYeji = $allYejiRows->select()->toArray();
|
||||
|
||||
$summaryCostMap = $costMap;
|
||||
$summaryBase = self::emptyMetrics();
|
||||
foreach ($allYeji as $row) {
|
||||
$entity = self::normalizeYejiRow($row);
|
||||
$costKey = self::buildCostKey(
|
||||
(int) $entity['creator_id'],
|
||||
(string) $entity['yeji_date'],
|
||||
(string) $entity['media_source']
|
||||
);
|
||||
$entity['account_cost'] = round((float) ($summaryCostMap[$costKey] ?? 0), 2);
|
||||
unset($summaryCostMap[$costKey]);
|
||||
$entity = self::finalizeMetrics($entity);
|
||||
$summaryBase = self::accumulateMetrics($summaryBase, $entity);
|
||||
}
|
||||
|
||||
foreach ($summaryCostMap as $amount) {
|
||||
$summaryBase['account_cost'] += round((float) $amount, 2);
|
||||
}
|
||||
|
||||
$summary = self::finalizeMetrics($summaryBase);
|
||||
$canViewFinance = self::canViewAllSelfInputStats($adminInfo);
|
||||
|
||||
if (!$canViewFinance) {
|
||||
$summary = self::maskFinanceFields($summary);
|
||||
foreach ($lists as &$item) {
|
||||
$item = self::maskFinanceFields($item);
|
||||
}
|
||||
unset($item);
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'extend' => [
|
||||
'summary' => $summary,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
'can_view_finance' => $canViewFinance,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务相关字段(账户消耗 / 现金成本 / ROI):非豁免角色不可见,剔除字段避免被嗅探。
|
||||
*
|
||||
* @param array<string, mixed> $entity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function maskFinanceFields(array $entity): array
|
||||
{
|
||||
foreach (['account_cost', 'cash_cost', 'roi'] as $key) {
|
||||
unset($entity[$key]);
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自媒体来源选项:来自字典「推广渠道」(type_value=channels),按 sort/id 排序。
|
||||
* 用 dict_data.name 作为存储值(与历史录入兼容;保留 value 仅作展示标识)。
|
||||
*
|
||||
* @return array<int, array{name: string, value: string}>
|
||||
*/
|
||||
public static function mediaSourceOptions(int $adminId, array $adminInfo): array
|
||||
{
|
||||
unset($adminId, $adminInfo);
|
||||
|
||||
$rows = DictData::where('type_value', 'channels')
|
||||
->where('status', 1)
|
||||
->order(['sort' => 'desc', 'id' => 'asc'])
|
||||
->field('name, value')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = [];
|
||||
$seen = [];
|
||||
foreach ($rows as $row) {
|
||||
$name = self::normalizeMediaSource((string) ($row['name'] ?? ''));
|
||||
if ($name === '' || isset($seen[$name])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$name] = true;
|
||||
$list[] = [
|
||||
'name' => $name,
|
||||
'value' => (string) ($row['value'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>|null null = 不限;[] = 无可见
|
||||
*/
|
||||
private static function resolveEffectiveAdminIds(int $adminId, array $adminInfo, int $deptId): ?array
|
||||
{
|
||||
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
|
||||
|
||||
return self::intersectVisibleByDept($visibleIds, $deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $effectiveAdminIds
|
||||
*/
|
||||
private static function buildYejiQuery(string $startDate, string $endDate, ?array $effectiveAdminIds)
|
||||
{
|
||||
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
|
||||
if ($effectiveAdminIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
} elseif ($effectiveAdminIds !== null) {
|
||||
$query->whereIn('creator_id', $effectiveAdminIds);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $effectiveAdminIds
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private static function loadAccountCostMap(
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $effectiveAdminIds,
|
||||
string $mediaSource
|
||||
): array {
|
||||
$query = PersonalAccountCost::whereBetween('cost_date', [$startDate, $endDate]);
|
||||
if ($effectiveAdminIds === []) {
|
||||
return [];
|
||||
}
|
||||
if ($effectiveAdminIds !== null) {
|
||||
$query->whereIn('creator_id', $effectiveAdminIds);
|
||||
}
|
||||
if ($mediaSource !== '') {
|
||||
$query->where('media_source', $mediaSource);
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->fieldRaw('creator_id, cost_date, media_source, SUM(amount) AS total_amount')
|
||||
->group('creator_id, cost_date, media_source')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = self::buildCostKey(
|
||||
(int) $row['creator_id'],
|
||||
(string) $row['cost_date'],
|
||||
(string) $row['media_source']
|
||||
);
|
||||
$map[$key] = round((float) ($row['total_amount'] ?? 0), 2);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private static function buildCostKey(int $creatorId, string $date, string $mediaSource): string
|
||||
{
|
||||
return $creatorId . '|' . $date . '|' . self::normalizeMediaSource($mediaSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeYejiRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'yeji_date' => (string) ($row['yeji_date'] ?? ''),
|
||||
'media_source' => (string) ($row['media_source'] ?? ''),
|
||||
'creator_id' => (int) ($row['creator_id'] ?? 0),
|
||||
'creator_name' => (string) ($row['creator_name'] ?? ''),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
'add_fans_count' => (int) ($row['add_fans_count'] ?? 0),
|
||||
'total_open_count' => (int) ($row['total_open_count'] ?? 0),
|
||||
'unreplied_count' => (int) ($row['unreplied_count'] ?? 0),
|
||||
'paid_appointment_count' => (int) ($row['paid_appointment_count'] ?? 0),
|
||||
'free_appointment_count' => (int) ($row['free_appointment_count'] ?? 0),
|
||||
'interview_count' => (int) ($row['interview_count'] ?? 0),
|
||||
'order_amount' => round((float) ($row['order_amount'] ?? 0), 2),
|
||||
'completed_order_count' => (int) ($row['completed_order_count'] ?? 0),
|
||||
'account_cost' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function emptyMetrics(): array
|
||||
{
|
||||
return [
|
||||
'add_fans_count' => 0,
|
||||
'total_open_count' => 0,
|
||||
'unreplied_count' => 0,
|
||||
'paid_appointment_count' => 0,
|
||||
'free_appointment_count' => 0,
|
||||
'appointment_total_count' => 0,
|
||||
'interview_count' => 0,
|
||||
'order_amount' => 0.0,
|
||||
'completed_order_count' => 0,
|
||||
'account_cost' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $base
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function accumulateMetrics(array $base, array $row): array
|
||||
{
|
||||
$base['add_fans_count'] += (int) ($row['add_fans_count'] ?? 0);
|
||||
$base['total_open_count'] += (int) ($row['total_open_count'] ?? 0);
|
||||
$base['unreplied_count'] += (int) ($row['unreplied_count'] ?? 0);
|
||||
$base['paid_appointment_count'] += (int) ($row['paid_appointment_count'] ?? 0);
|
||||
$base['free_appointment_count'] += (int) ($row['free_appointment_count'] ?? 0);
|
||||
$base['interview_count'] += (int) ($row['interview_count'] ?? 0);
|
||||
$base['order_amount'] = round((float) $base['order_amount'] + (float) ($row['order_amount'] ?? 0), 2);
|
||||
$base['completed_order_count'] += (int) ($row['completed_order_count'] ?? 0);
|
||||
$base['account_cost'] = round((float) $base['account_cost'] + (float) ($row['account_cost'] ?? 0), 2);
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entity
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function finalizeMetrics(array $entity): array
|
||||
{
|
||||
$paidAppointmentCount = (int) ($entity['paid_appointment_count'] ?? 0);
|
||||
$freeAppointmentCount = (int) ($entity['free_appointment_count'] ?? 0);
|
||||
$appointmentTotalCount = $paidAppointmentCount + $freeAppointmentCount;
|
||||
$interviewCount = (int) ($entity['interview_count'] ?? 0);
|
||||
$addFansCount = (int) ($entity['add_fans_count'] ?? 0);
|
||||
$totalOpenCount = (int) ($entity['total_open_count'] ?? 0);
|
||||
$completedOrderCount = (int) ($entity['completed_order_count'] ?? 0);
|
||||
$orderAmount = round((float) ($entity['order_amount'] ?? 0), 2);
|
||||
$accountCost = round((float) ($entity['account_cost'] ?? 0), 2);
|
||||
|
||||
$entity['appointment_total_count'] = $appointmentTotalCount;
|
||||
$entity['order_amount'] = $orderAmount;
|
||||
$entity['account_cost'] = $accountCost;
|
||||
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
||||
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||
$entity['avg_unit_price'] = self::safeDivideMoney($orderAmount, $completedOrderCount);
|
||||
$entity['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount);
|
||||
$entity['roi'] = self::safeDivideRatio($orderAmount, $accountCost);
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private static function resolveTimeRange(array $params): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$timeType = (string) ($params['time_type'] ?? 'today');
|
||||
|
||||
switch ($timeType) {
|
||||
case 'yesterday':
|
||||
$startDate = date('Y-m-d', strtotime('-1 day'));
|
||||
$endDate = $startDate;
|
||||
break;
|
||||
case 'week':
|
||||
$startDate = date('Y-m-d', strtotime('-6 days'));
|
||||
$endDate = $today;
|
||||
break;
|
||||
case 'month':
|
||||
$startDate = date('Y-m-d', strtotime('-29 days'));
|
||||
$endDate = $today;
|
||||
break;
|
||||
case 'custom':
|
||||
$startDate = trim((string) ($params['start_date'] ?? ''));
|
||||
$endDate = trim((string) ($params['end_date'] ?? ''));
|
||||
if ($startDate === '' || $endDate === '') {
|
||||
$startDate = $today;
|
||||
$endDate = $today;
|
||||
} elseif ($startDate > $endDate) {
|
||||
[$startDate, $endDate] = [$endDate, $startDate];
|
||||
}
|
||||
break;
|
||||
case 'today':
|
||||
default:
|
||||
$startDate = $today;
|
||||
$endDate = $today;
|
||||
}
|
||||
|
||||
return [$startDate, $endDate];
|
||||
}
|
||||
|
||||
private static function percent(int $numerator, int $denominator): float
|
||||
{
|
||||
if ($denominator <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(($numerator / $denominator) * 100, 2);
|
||||
}
|
||||
|
||||
private static function safeDivideMoney(float $numerator, int $denominator): float
|
||||
{
|
||||
if ($denominator <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round($numerator / $denominator, 2);
|
||||
}
|
||||
|
||||
private static function safeDivideRatio(float $numerator, float $denominator): float
|
||||
{
|
||||
if ($denominator <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round($numerator / $denominator, 2);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,8 @@ class YejiStatsLogic
|
||||
/**
|
||||
* 按角色「数据范围」收窄业绩看板上下文(adminToPrimary、tableRowDeptIds)。
|
||||
* 与 DataScopeService / 列表 HasDataScopeFilter 一致:ALL 不处理;SELF/本部门/本部门及下级 仅保留可见 admin_id。
|
||||
*
|
||||
* 表格仍保持默认「中心」层级(图1);合计业绩等数值在聚合层按 dataScopeVisibleAdminIds 收窄。
|
||||
*/
|
||||
private static function applyYejiDataScope(array &$c, int $adminId, array $adminInfo): void
|
||||
{
|
||||
@@ -178,6 +180,17 @@ class YejiStatsLogic
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::narrowYejiContextTableRowsByVisibleAdmins($c, $visibleIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在现有展示行(默认中心层级)上按可见 admin 收窄表格行与台账归属。
|
||||
*
|
||||
* @param int[] $visibleIds
|
||||
*/
|
||||
private static function narrowYejiContextTableRowsByVisibleAdmins(array &$c, array $visibleIds): void
|
||||
{
|
||||
$flip = array_flip($visibleIds);
|
||||
$filtered = [];
|
||||
foreach ($c['adminToPrimary'] as $aid => $pid) {
|
||||
@@ -402,7 +415,10 @@ class YejiStatsLogic
|
||||
null,
|
||||
null,
|
||||
$tableRowDeptIds,
|
||||
$deptById
|
||||
$deptById,
|
||||
null,
|
||||
[],
|
||||
$dataScopeAdminIds
|
||||
)
|
||||
: null;
|
||||
// 业绩:部门行「合计业绩」与列表部门摊行一致;「{渠道}业绩」同列表摊行 + 渠道 EXISTS(与抽屉一致)
|
||||
@@ -417,7 +433,8 @@ class YejiStatsLogic
|
||||
$tableRowDeptIds,
|
||||
$deptById,
|
||||
$listAlignedPack !== null ? $listAlignedPack['amount'] : null,
|
||||
$channelInfo
|
||||
$channelInfo,
|
||||
$dataScopeAdminIds
|
||||
);
|
||||
$performance = $perfBoth['all'];
|
||||
$channelPerformance = $perfBoth['scoped'];
|
||||
@@ -470,7 +487,8 @@ class YejiStatsLogic
|
||||
}
|
||||
|
||||
@set_time_limit(120);
|
||||
$revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs);
|
||||
$revisitCreatorFilter = self::resolveYejiRevisitCreatorFilter($c);
|
||||
$revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs, $revisitCreatorFilter);
|
||||
$revisitTotals = $revisitPack['totals'];
|
||||
$revisitSlotsByDept = $revisitPack['slots'];
|
||||
|
||||
@@ -807,7 +825,10 @@ class YejiStatsLogic
|
||||
|
||||
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
|
||||
/** 医助排行榜复诊:按订单创建人归属(与诊金 / 接诊诊单同口径),不再用 dg.assistant_id */
|
||||
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByCreator($startTs, $endTs);
|
||||
$revisitCreatorFilter = ($dataScopeRestricted && $dataScopeAdminIds !== null && $dataScopeAdminIds !== [])
|
||||
? $dataScopeAdminIds
|
||||
: null;
|
||||
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByCreator($startTs, $endTs, $revisitCreatorFilter);
|
||||
|
||||
$assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id');
|
||||
$assistantSet = [];
|
||||
@@ -1416,6 +1437,16 @@ class YejiStatsLogic
|
||||
* @param int[] $deptIdsForAdmin
|
||||
* @param array<int, true> $tableRowSet
|
||||
* @param array<int, array<string, mixed>> $deptById
|
||||
*
|
||||
* 多链路 admin 的归属选择:按**原始 admin_dept 链路的深度**择优(更深 = 更具体的岗位归属),
|
||||
* 而非按解析出的表格行深度。否则同层中心兄弟节点在父视图(如 [郑州一中心, 郑州二中心])会
|
||||
* 以 id 大小决胜,而子视图(钻取到某中心后行集变为其子组)则只剩唯一候选——同一条挂号在两个
|
||||
* 视图被路由到不同行,导致 admin「已完成挂号」父行 < 子行合计。
|
||||
*
|
||||
* 向下兜底(resolveFirstTableRowUnderDept)按「链路深度 ≥ 最浅表格行深度 - 1」收窄:
|
||||
* 仅当链路本身就是用户筛选根(与表格行同层或刚刚高一层)时才允许向下归到首个子表格行;
|
||||
* 否则像「只挂在甄养堂(root) / 一中心」等过宽链路,会在不同视图被任意攀附到第一个子节点,
|
||||
* 同一挂号父视图归到第一个中心、子视图归到该中心第一个子组 → 父子合计错位。
|
||||
*/
|
||||
private static function mapAdminDeptLinksToTableRow(array $deptIdsForAdmin, array $tableRowDeptIds, array $tableRowSet, array $deptById): int
|
||||
{
|
||||
@@ -1428,7 +1459,7 @@ class YejiStatsLogic
|
||||
}
|
||||
$hit = self::resolveDeptToNearestTableRowUpward($d, $tableRowSet, $deptById);
|
||||
if ($hit > 0) {
|
||||
$depth = self::deptDepthFromRoot($hit, $deptById);
|
||||
$depth = self::deptDepthFromRoot($d, $deptById);
|
||||
if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $hit < $best))) {
|
||||
$best = $hit;
|
||||
$bestDepth = $depth;
|
||||
@@ -1438,11 +1469,22 @@ class YejiStatsLogic
|
||||
if ($best > 0) {
|
||||
return $best;
|
||||
}
|
||||
$minTableRowDepth = PHP_INT_MAX;
|
||||
foreach ($tableRowDeptIds as $tid) {
|
||||
$td = self::deptDepthFromRoot((int) $tid, $deptById);
|
||||
if ($td > 0 && $td < $minTableRowDepth) {
|
||||
$minTableRowDepth = $td;
|
||||
}
|
||||
}
|
||||
$linkDepthFloor = $minTableRowDepth === PHP_INT_MAX ? PHP_INT_MAX : ($minTableRowDepth - 1);
|
||||
foreach ($deptIdsForAdmin as $d) {
|
||||
$d = (int) $d;
|
||||
if ($d <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (self::deptDepthFromRoot($d, $deptById) < $linkDepthFloor) {
|
||||
continue;
|
||||
}
|
||||
$hit = self::resolveFirstTableRowUnderDept($d, $tableRowDeptIds, $deptById);
|
||||
if ($hit > 0) {
|
||||
return $hit;
|
||||
@@ -1597,10 +1639,31 @@ class YejiStatsLogic
|
||||
$aids[] = $d;
|
||||
}
|
||||
}
|
||||
$aids = array_values(array_unique($aids));
|
||||
$adminToTable = ($tableRowDeptIds !== [] && $deptById !== [])
|
||||
? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById)
|
||||
: [];
|
||||
|
||||
/**
|
||||
* 视图无关的「该 admin 是否有 admin_dept 归属」。
|
||||
* 用途:仅当 admin 完全无部门归属时才允许由"医助"回退到"接诊医生"。
|
||||
* 否则父视图能解析到 admin 的中心行 → 计入 assistant;子视图(钻取另一中心)
|
||||
* adminToTable / adminToPrimary 双双 0 时会回退到 doctor,造成同一挂号在父视图归 assistant、
|
||||
* 在子视图归 doctor,父行 < 子行合计。
|
||||
*
|
||||
* @var array<int, true> $adminHasAnyDept
|
||||
*/
|
||||
$adminHasAnyDept = [];
|
||||
if ($aids !== []) {
|
||||
$linkedAids = Db::name('admin_dept')->whereIn('admin_id', $aids)->column('admin_id');
|
||||
foreach ($linkedAids as $lid) {
|
||||
$lid = (int) $lid;
|
||||
if ($lid > 0) {
|
||||
$adminHasAnyDept[$lid] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$byDept = [];
|
||||
foreach ($rows as $r) {
|
||||
$effId = (int) ($r['eff_a'] ?? 0);
|
||||
@@ -1636,6 +1699,14 @@ class YejiStatsLogic
|
||||
$primary = $p;
|
||||
break;
|
||||
}
|
||||
/**
|
||||
* 视图无关地有部门归属,仅是当前视图行集 / primaryDeptIds 不含 →
|
||||
* 视为该 admin 的归属(视图外不可见),不再回退到下一候选(医生),
|
||||
* 以避免父子视图归属分裂。narrowTotalsToTable 视图下 unmapped 会被自动隐藏。
|
||||
*/
|
||||
if (isset($adminHasAnyDept[$aid])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($primary > 0) {
|
||||
@@ -1938,7 +2009,8 @@ class YejiStatsLogic
|
||||
$tableRowDeptIds,
|
||||
$deptById,
|
||||
$pack[0],
|
||||
$pack[1]
|
||||
$pack[1],
|
||||
$dataScopeAdminIds
|
||||
);
|
||||
$scopedByDept = $scopedAligned['count'];
|
||||
} else {
|
||||
@@ -3011,25 +3083,16 @@ class YejiStatsLogic
|
||||
return $out;
|
||||
}
|
||||
|
||||
$revisitCreatorFilter = self::resolveYejiRevisitCreatorFilter($c);
|
||||
$pack = DeptLogic::getErCenterRevisitAssistantBreakdownForDept(
|
||||
$deptId,
|
||||
$revisitSlot,
|
||||
(int) $c['startTs'],
|
||||
(int) $c['endTs']
|
||||
(int) $c['endTs'],
|
||||
$revisitCreatorFilter
|
||||
);
|
||||
$rows = $pack['rows'];
|
||||
|
||||
if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) {
|
||||
$flip = array_flip($c['dataScopeVisibleAdminIds']);
|
||||
$filtered = [];
|
||||
foreach ($rows as $rw) {
|
||||
if (isset($flip[(int) ($rw['admin_id'] ?? 0)])) {
|
||||
$filtered[] = $rw;
|
||||
}
|
||||
}
|
||||
$rows = $filtered;
|
||||
}
|
||||
|
||||
$out['rows'] = $rows;
|
||||
if ($rows === []) {
|
||||
$out['note'] = '当前部门与区间下无符合条件的复诊业务订单(口径与看板「二中心复诊」列一致)。';
|
||||
@@ -3038,6 +3101,28 @@ class YejiStatsLogic
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二中心复诊统计:数据范围下仅统计可见创建人的复诊笔数(与合计业绩一致)。
|
||||
*
|
||||
* @param array<string, mixed> $c
|
||||
*
|
||||
* @return int[]|null null=不限制;[]=无可见创建人
|
||||
*/
|
||||
private static function resolveYejiRevisitCreatorFilter(array $c): ?array
|
||||
{
|
||||
if (empty($c['dataScopeRestricted']) || !isset($c['dataScopeVisibleAdminIds'])) {
|
||||
return null;
|
||||
}
|
||||
if (!\is_array($c['dataScopeVisibleAdminIds']) || $c['dataScopeVisibleAdminIds'] === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map('intval', $c['dataScopeVisibleAdminIds']),
|
||||
static fn (int $v): bool => $v > 0
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
|
||||
* 归属固定为 sqlPerformanceAttributionAdminExpr()——按订单创建人。
|
||||
@@ -3711,7 +3796,8 @@ class YejiStatsLogic
|
||||
array $tableRowDeptIds = [],
|
||||
array $deptById = [],
|
||||
?array $precomputedListAlignedAmountByDept = null,
|
||||
?array $channelInfo = null
|
||||
?array $channelInfo = null,
|
||||
?array $dataScopeAdminIds = null
|
||||
): array {
|
||||
if ($adminToPrimary === []) {
|
||||
return ['all' => [], 'scoped' => []];
|
||||
@@ -3730,7 +3816,10 @@ class YejiStatsLogic
|
||||
null,
|
||||
null,
|
||||
$tableRowDeptIds,
|
||||
$deptById
|
||||
$deptById,
|
||||
null,
|
||||
[],
|
||||
$dataScopeAdminIds
|
||||
);
|
||||
$allByDept = [];
|
||||
foreach ($aligned['amount'] as $deptId => $v) {
|
||||
@@ -3770,7 +3859,8 @@ class YejiStatsLogic
|
||||
$tableRowDeptIds,
|
||||
$deptById,
|
||||
$pack[0],
|
||||
$pack[1]
|
||||
$pack[1],
|
||||
$dataScopeAdminIds
|
||||
);
|
||||
$scopedByDept = [];
|
||||
foreach ($scopedAligned['amount'] as $deptId => $v) {
|
||||
@@ -4018,7 +4108,14 @@ class YejiStatsLogic
|
||||
if ($candidates === []) {
|
||||
return 0;
|
||||
}
|
||||
/** @var int[] $matching */
|
||||
/**
|
||||
* 每个命中的表格行追加 ['rid'=>表格行 id, 'link_depth'=>原始 admin_dept 链路深度]。
|
||||
* 用链路深度(而非表格行深度)择优,保证父视图(同层中心兄弟节点行集)与子视图(钻取单中心后行集为其子组)
|
||||
* 对同一 admin 的归属选择一致——否则同 admin 在父行被路由到「错」的中心,在子行被路由到正确中心子组,
|
||||
* 表现为父行计数 < 子行合计。
|
||||
*
|
||||
* @var array<int, array{rid: int, link_depth: int}> $matching
|
||||
*/
|
||||
$matching = [];
|
||||
foreach ($tableRowDeptIds as $rid) {
|
||||
$rid = (int) $rid;
|
||||
@@ -4029,6 +4126,7 @@ class YejiStatsLogic
|
||||
if ($flip === []) {
|
||||
continue;
|
||||
}
|
||||
$bestLinkDepth = -1;
|
||||
foreach ($candidates as $aid => $_) {
|
||||
$aid = (int) $aid;
|
||||
if ($aid <= 0) {
|
||||
@@ -4037,11 +4135,16 @@ class YejiStatsLogic
|
||||
foreach ($adminDeptIdList[$aid] ?? [] as $d) {
|
||||
$d = (int) $d;
|
||||
if ($d > 0 && isset($flip[$d])) {
|
||||
$matching[] = $rid;
|
||||
break 2;
|
||||
$linkDepth = self::deptDepthFromRoot($d, $deptById);
|
||||
if ($linkDepth > $bestLinkDepth) {
|
||||
$bestLinkDepth = $linkDepth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($bestLinkDepth >= 0) {
|
||||
$matching[] = ['rid' => $rid, 'link_depth' => $bestLinkDepth];
|
||||
}
|
||||
}
|
||||
if ($matching === []) {
|
||||
foreach ($candidates as $aid => $_) {
|
||||
@@ -4059,8 +4162,9 @@ class YejiStatsLogic
|
||||
}
|
||||
$best = 0;
|
||||
$bestDepth = -1;
|
||||
foreach ($matching as $rid) {
|
||||
$depth = self::deptDepthFromRoot($rid, $deptById);
|
||||
foreach ($matching as $m) {
|
||||
$rid = $m['rid'];
|
||||
$depth = $m['link_depth'];
|
||||
if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $rid < $best))) {
|
||||
$best = $rid;
|
||||
$bestDepth = $depth;
|
||||
@@ -4085,7 +4189,8 @@ class YejiStatsLogic
|
||||
array $tableRowDeptIds,
|
||||
array $deptById,
|
||||
?string $channelScopedExistsSql = null,
|
||||
array $channelScopedExistsBindings = []
|
||||
array $channelScopedExistsBindings = [],
|
||||
?array $dataScopeAdminIds = null
|
||||
): array {
|
||||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||||
return ['count' => [], 'amount' => []];
|
||||
@@ -4115,6 +4220,9 @@ class YejiStatsLogic
|
||||
$in = implode(',', $ids);
|
||||
$query->whereRaw("o.creator_id IN ({$in})");
|
||||
}
|
||||
if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) {
|
||||
$query->whereIn('o.creator_id', $dataScopeAdminIds);
|
||||
}
|
||||
if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') {
|
||||
$query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\common\cache\AdminAuthCache;
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\tcm\PrescriptionLibrary;
|
||||
@@ -33,12 +34,64 @@ class PrescriptionLibraryLogic extends BaseLogic
|
||||
return count(array_intersect($myRoles, $allowRoles)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 是否具备消费者/诊间开方相关菜单权限(用于处方库列表鉴权别名)
|
||||
*/
|
||||
public static function hasPrescriptionOperatePermission(int $adminId): bool
|
||||
{
|
||||
$cache = new AdminAuthCache($adminId);
|
||||
$uris = $cache->getAdminUri() ?? [];
|
||||
$normalized = array_map(static fn ($item) => strtolower((string) $item), $uris);
|
||||
$allowed = [
|
||||
'tcm.prescription/lists',
|
||||
'tcm.prescription/add',
|
||||
'tcm.prescription/edit',
|
||||
'tcm.prescription/detail',
|
||||
'cf.prescription/lists',
|
||||
'cf.prescription/add',
|
||||
'cf.prescription/edit',
|
||||
'cf.prescription/read',
|
||||
'cf.prescription/del',
|
||||
'cf.prescription/audit',
|
||||
'wcf.prescription/lists',
|
||||
'wcf.prescription/read',
|
||||
'wcf.prescription/add',
|
||||
'wcf.prescription/edit',
|
||||
'wcf.prescription/delete',
|
||||
'tcm.prescriptionlibrary/lists',
|
||||
];
|
||||
|
||||
return count(array_intersect($allowed, $normalized)) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 开方页按医师 creator_id 拉取处方库:本人 / 超管角色 / 有开方菜单权限(医助代开方)
|
||||
*/
|
||||
public static function canListLibraryForCreator(int $adminId, array $adminInfo, int $targetCreatorId): bool
|
||||
{
|
||||
if ($targetCreatorId <= 0) {
|
||||
return false;
|
||||
}
|
||||
if ($targetCreatorId === $adminId) {
|
||||
return true;
|
||||
}
|
||||
if (self::canManageAllPrescriptions($adminId, $adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::hasPrescriptionOperatePermission($adminId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 添加处方库
|
||||
*/
|
||||
public static function add(array $params): ?int
|
||||
{
|
||||
try {
|
||||
$params['formula_type'] = in_array($params['formula_type'] ?? '', ['主方', '辅方'], true)
|
||||
? $params['formula_type']
|
||||
: '主方';
|
||||
|
||||
// 处理药材数据
|
||||
if (isset($params['herbs']) && is_array($params['herbs'])) {
|
||||
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
|
||||
@@ -69,6 +122,12 @@ class PrescriptionLibraryLogic extends BaseLogic
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($params['formula_type'])) {
|
||||
$params['formula_type'] = in_array($params['formula_type'], ['主方', '辅方'], true)
|
||||
? $params['formula_type']
|
||||
: '主方';
|
||||
}
|
||||
|
||||
// 处理药材数据
|
||||
if (isset($params['herbs']) && is_array($params['herbs'])) {
|
||||
$params['herbs'] = json_encode($params['herbs'], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
@@ -159,6 +159,35 @@ class PrescriptionLogic
|
||||
return $ts ? date('Y-m-d', $ts) : date('Y-m-d');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 辅方用法 JSON(与主方 dosage/times/days 字段结构一致)
|
||||
*/
|
||||
private static function normalizeAuxUsage(array $params): ?array
|
||||
{
|
||||
$raw = $params['aux_usage'] ?? null;
|
||||
if ($raw === null || $raw === '' || $raw === []) {
|
||||
return null;
|
||||
}
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
$raw = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
if (!is_array($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'dosage_amount' => isset($raw['dosage_amount']) && $raw['dosage_amount'] !== ''
|
||||
? (float) $raw['dosage_amount']
|
||||
: null,
|
||||
'dosage_bag_count' => self::normalizeDosageBagCount($raw['dosage_bag_count'] ?? 1),
|
||||
'need_decoction' => (int) ($raw['need_decoction'] ?? 0),
|
||||
'bags_per_dose' => isset($raw['bags_per_dose']) ? (int) $raw['bags_per_dose'] : 1,
|
||||
'times_per_day' => (int) ($raw['times_per_day'] ?? 3),
|
||||
'usage_days' => (int) ($raw['usage_days'] ?? 7),
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeDosageBagCount($raw): int
|
||||
{
|
||||
$count = (int) $raw;
|
||||
@@ -273,6 +302,7 @@ class PrescriptionLogic
|
||||
'dose_unit' => $params['dose_unit'] ?? '剂',
|
||||
'usage_days' => (int)($params['usage_days'] ?? 7),
|
||||
'times_per_day' => (int)($params['times_per_day'] ?? 2),
|
||||
'aux_usage' => self::normalizeAuxUsage($params),
|
||||
'usage_instruction' => $params['usage_instruction'] ?? '水煎服一日二次',
|
||||
'usage_time' => $params['usage_time'] ?? '饭前',
|
||||
'usage_way' => $params['usage_way'] ?? '温水送服',
|
||||
@@ -398,6 +428,9 @@ class PrescriptionLogic
|
||||
'dose_unit' => $params['dose_unit'] ?? $prescription->dose_unit,
|
||||
'usage_days' => (int)($params['usage_days'] ?? $prescription->usage_days),
|
||||
'times_per_day' => (int)($params['times_per_day'] ?? $prescription->times_per_day ?? 2),
|
||||
'aux_usage' => array_key_exists('aux_usage', $params)
|
||||
? self::normalizeAuxUsage($params)
|
||||
: $prescription->aux_usage,
|
||||
'usage_instruction' => $params['usage_instruction'] ?? $prescription->usage_instruction,
|
||||
'usage_time' => $params['usage_time'] ?? $prescription->usage_time,
|
||||
'usage_way' => $params['usage_way'] ?? $prescription->usage_way,
|
||||
@@ -930,6 +963,8 @@ class PrescriptionLogic
|
||||
$visitNo = '1K' . str_pad((string) $diagnosisId, 8, '0', STR_PAD_LEFT);
|
||||
|
||||
$herbs = [];
|
||||
// 与手动开方一致:保存诊单详情快照,供病历打印/查看
|
||||
$caseRecord = DiagnosisLogic::detail(['id' => $diagnosisId]);
|
||||
|
||||
$data = [
|
||||
'sn' => $sn,
|
||||
@@ -954,7 +989,7 @@ class PrescriptionLogic
|
||||
'tongue' => (string) ($diagnosis->tongue ?? ''),
|
||||
'tongue_image' => '',
|
||||
'clinical_diagnosis' => $clinical,
|
||||
'case_record' => [],
|
||||
'case_record' => $caseRecord ?: [],
|
||||
'herbs' => $herbs,
|
||||
'dose_count' => 1,
|
||||
'dose_unit' => '剂',
|
||||
|
||||
@@ -115,6 +115,123 @@ class PrescriptionOrderLogic
|
||||
return self::roleIntersect($adminInfo, is_array($roles) ? $roles : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「下单」角色:列表「审核人」筛选仅允许查本人审核过的订单(经理/管理员等豁免)
|
||||
*/
|
||||
public static function isOrderPlacerAuditFilterRestricted(array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return false;
|
||||
}
|
||||
$placer = Config::get('project.prescription_order_placer_role_ids', [6]);
|
||||
if (!self::roleIntersect($adminInfo, is_array($placer) ? $placer : [])) {
|
||||
return false;
|
||||
}
|
||||
$exempt = Config::get('project.prescription_order_placer_exempt_role_ids', [3, 8]);
|
||||
|
||||
return !self::roleIntersect($adminInfo, is_array($exempt) ? $exempt : []);
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public static function prescriptionOrderAuditLogActions(): array
|
||||
{
|
||||
return ['audit_rx_approve', 'audit_rx_reject', 'audit_pay_approve', 'audit_pay_reject'];
|
||||
}
|
||||
|
||||
/** 是否展示「下单人」筛选(下单 / 经理 / 管理员 / 超管) */
|
||||
public static function shouldShowAuditAdminFilter(array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
if (self::isOrderPlacerAuditFilterRestricted($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
$exempt = Config::get('project.prescription_order_placer_exempt_role_ids', [3, 8]);
|
||||
|
||||
return self::roleIntersect($adminInfo, is_array($exempt) ? $exempt : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下单人下拉:直接取「下单」角色下的后台用户(project.prescription_order_placer_role_ids)
|
||||
*
|
||||
* @return array<int, array{id: int, name: string}>
|
||||
*/
|
||||
public static function listAuditAdminOptions(int $adminId, array $adminInfo): array
|
||||
{
|
||||
if ($adminId <= 0 || !self::shouldShowAuditAdminFilter($adminInfo)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$roleIds = Config::get('project.prescription_order_placer_role_ids', [6]);
|
||||
$roleIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
is_array($roleIds) ? $roleIds : []
|
||||
), static fn(int $id): bool => $id > 0)));
|
||||
if ($roleIds === []) {
|
||||
$roleIds = [6];
|
||||
}
|
||||
|
||||
if (self::isOrderPlacerAuditFilterRestricted($adminInfo)) {
|
||||
$name = trim((string) ($adminInfo['name'] ?? ''));
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => $adminId,
|
||||
'name' => $name !== '' ? $name : ('管理员#' . $adminId),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$rows = Db::name('admin')
|
||||
->alias('a')
|
||||
->join('admin_role ar', 'a.id = ar.admin_id')
|
||||
->whereIn('ar.role_id', $roleIds)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->field(['a.id', 'a.name'])
|
||||
->distinct(true)
|
||||
->order('a.name', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$id = (int) ($r['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($r['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = '管理员#' . $id;
|
||||
}
|
||||
$out[] = ['id' => $id, 'name' => $name];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 指定后台账号是否拥有「下单」角色 */
|
||||
public static function adminHasPlacerRole(int $adminId): bool
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$roleIds = Config::get('project.prescription_order_placer_role_ids', [6]);
|
||||
$roleIds = array_values(array_unique(array_filter(array_map(
|
||||
'intval',
|
||||
is_array($roleIds) ? $roleIds : []
|
||||
), static fn(int $id): bool => $id > 0)));
|
||||
if ($roleIds === []) {
|
||||
$roleIds = [6];
|
||||
}
|
||||
|
||||
return Db::name('admin_role')
|
||||
->where('admin_id', $adminId)
|
||||
->whereIn('role_id', $roleIds)
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单权限:非全量角色可额外查看「关联处方开方人为本账号」的业务订单(医助代建单)
|
||||
*
|
||||
@@ -141,7 +258,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 PrescriptionOrderLists 数据域一致:创建人或关联诊单医助属于当前账号可见 admin 集合
|
||||
* 与 PrescriptionOrderLists 数据域一致:订单创建人属于当前账号可见 admin 集合
|
||||
*/
|
||||
private static function orderWithinDataScopeOwners(PrescriptionOrder $row, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
@@ -152,16 +269,26 @@ class PrescriptionOrderLogic
|
||||
if ($ids === null || $ids === []) {
|
||||
return false;
|
||||
}
|
||||
if (in_array((int) $row->creator_id, $ids, true)) {
|
||||
|
||||
return in_array((int) $row->creator_id, $ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已分配「处方业务订单」相关菜单/按钮权限(lists、detail、审核等任一子权限即可)
|
||||
*/
|
||||
public static function hasPrescriptionOrderMenuAccess(array $adminInfo): bool
|
||||
{
|
||||
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
|
||||
return true;
|
||||
}
|
||||
$did = (int) $row->diagnosis_id;
|
||||
if ($did <= 0) {
|
||||
return false;
|
||||
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
|
||||
foreach ($perms as $p) {
|
||||
if (is_string($p) && str_starts_with($p, 'tcm.prescriptionOrder/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$da = (int) Diagnosis::where('id', $did)->whereNull('delete_time')->value('assistant_id');
|
||||
|
||||
return $da > 0 && in_array($da, $ids, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,15 +299,11 @@ class PrescriptionOrderLogic
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
if ((int) $row->creator_id === $adminId) {
|
||||
if (self::hasPrescriptionOrderMenuAccess($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
$did = (int) $row->diagnosis_id;
|
||||
if ($did > 0) {
|
||||
$da = (int) Diagnosis::where('id', $did)->whereNull('delete_time')->value('assistant_id');
|
||||
if ($da === $adminId) {
|
||||
return true;
|
||||
}
|
||||
if ((int) $row->creator_id === $adminId) {
|
||||
return true;
|
||||
}
|
||||
if (self::canViewOrdersForOwnPrescription($adminInfo)) {
|
||||
if (self::isPrescriptionOrderPrescriber((int) $row->prescription_id, $adminId)) {
|
||||
@@ -288,7 +411,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 提成结算等与列表对齐:按开方医生 / 诊单医助 ∪ 订单创建人 筛选(与 PrescriptionOrderLists::applyDoctorAssistantFilters 在非业绩抽屉场景一致)。
|
||||
* 提成结算等与列表对齐:按开方医生 / 订单创建人 筛选。
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
@@ -304,15 +427,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
if (isset($params['assistant_id']) && (int) $params['assistant_id'] > 0) {
|
||||
$assistantId = (int) $params['assistant_id'];
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$query->where(function ($q) use ($alias, $diagTbl, $assistantId): void {
|
||||
$q->where("{$alias}.creator_id", $assistantId);
|
||||
$q->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id`"
|
||||
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})"
|
||||
);
|
||||
});
|
||||
$query->where("{$alias}.creator_id", (int) $params['assistant_id']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +456,6 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
$rxTbl = (new Prescription())->getTable();
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
|
||||
$explicitAssistant = (int) ($params['assistant_id'] ?? 0);
|
||||
$allowExplicitAssistantVisibility = false;
|
||||
@@ -359,39 +473,24 @@ class PrescriptionOrderLogic
|
||||
$query->where(function ($q) use (
|
||||
$alias,
|
||||
$rxTbl,
|
||||
$diagTbl,
|
||||
$viewerAdminId,
|
||||
$explicitAssistant,
|
||||
$allowExplicitAssistantVisibility,
|
||||
$viewerAdminInfo
|
||||
): void {
|
||||
if (self::canViewOrdersForOwnPrescription($viewerAdminInfo)) {
|
||||
$q->where(function ($qq) use ($alias, $rxTbl, $diagTbl, $viewerAdminId): void {
|
||||
$q->where(function ($qq) use ($alias, $rxTbl, $viewerAdminId): void {
|
||||
$qq->where("{$alias}.creator_id", $viewerAdminId);
|
||||
$qq->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$rxTbl}` rx WHERE rx.`id` = `{$alias}`.`prescription_id`"
|
||||
. " AND rx.`delete_time` IS NULL AND rx.`creator_id` = {$viewerAdminId})"
|
||||
);
|
||||
$qq->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id`"
|
||||
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$viewerAdminId})"
|
||||
);
|
||||
});
|
||||
} else {
|
||||
$q->where(function ($qq) use ($alias, $diagTbl, $viewerAdminId): void {
|
||||
$qq->where("{$alias}.creator_id", $viewerAdminId);
|
||||
$qq->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id`"
|
||||
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$viewerAdminId})"
|
||||
);
|
||||
});
|
||||
$q->where("{$alias}.creator_id", $viewerAdminId);
|
||||
}
|
||||
if ($allowExplicitAssistantVisibility) {
|
||||
$q->whereOr("{$alias}.creator_id", $explicitAssistant);
|
||||
$q->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id`"
|
||||
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$explicitAssistant})"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -399,7 +498,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表数据域:创建人 ∈ 可见 或 诊单医助 ∈ 可见。
|
||||
* 列表数据域:订单创建人 ∈ 可见 admin。
|
||||
*/
|
||||
private static function applyCommissionOrderDataScope(
|
||||
Query $query,
|
||||
@@ -419,15 +518,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
return;
|
||||
}
|
||||
$diagTbl = (new Diagnosis())->getTable();
|
||||
$inList = implode(',', array_map('intval', $ids));
|
||||
$query->where(function ($q) use ($alias, $diagTbl, $inList): void {
|
||||
$q->whereIn("{$alias}.creator_id", explode(',', $inList));
|
||||
$q->whereOrRaw(
|
||||
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$alias}`.`diagnosis_id` "
|
||||
. "AND dg.`delete_time` IS NULL AND dg.`assistant_id` IN ({$inList}))"
|
||||
);
|
||||
});
|
||||
$query->whereIn("{$alias}.creator_id", $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -677,7 +768,7 @@ class PrescriptionOrderLogic
|
||||
$rows = Order::whereIn('id', $ids)->whereNull('delete_time')
|
||||
->field(['id', 'order_no', 'order_type', 'amount', 'status', 'create_time', 'creator_id', 'remark', 'is_exempt'])
|
||||
->order('id', 'asc')
|
||||
->where('status', 2)
|
||||
->whereIn('status', [2, 4, 5])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
@@ -735,7 +826,10 @@ class PrescriptionOrderLogic
|
||||
$arr['linked_pay_orders'] = self::linkedPayOrdersPayload($ids);
|
||||
$sum = 0.0;
|
||||
foreach ($arr['linked_pay_orders'] as $o) {
|
||||
$sum += (float) ($o['amount'] ?? 0);
|
||||
$st = (int) ($o['status'] ?? 0);
|
||||
if ($st === 2 || $st === 5) {
|
||||
$sum += (float) ($o['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
$arr['linked_pay_paid_total'] = round($sum, 2);
|
||||
$arr['deposit_min_amount'] = self::depositMinAmount();
|
||||
@@ -1413,6 +1507,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
$payload['official_urls'] = ExpressTrackService::officialUrls($num);
|
||||
$payload['tracking_number'] = $num;
|
||||
$payload['order_id'] = $id;
|
||||
$payload['express_company_used'] = $ec;
|
||||
|
||||
return $payload;
|
||||
@@ -1437,6 +1532,16 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(int) $order->creator_id === $adminId
|
||||
&& (int) $order->prescription_audit_status === 1
|
||||
&& (int) $order->payment_slip_audit_status === 1
|
||||
&& !self::canSeeAllPrescriptionOrders($adminInfo)
|
||||
) {
|
||||
self::$error = '处方与支付单均已审核通过,创建人不可再编辑';
|
||||
|
||||
return false;
|
||||
}
|
||||
$fs = (int) $order->fulfillment_status;
|
||||
if ($fs === 3 || $fs === 4) {
|
||||
self::$error = '已完成或已取消的订单不可编辑';
|
||||
@@ -2206,8 +2311,8 @@ class PrescriptionOrderLogic
|
||||
$remark = mb_substr(trim((string) ($params['pay_remark'] ?? '')), 0, 200);
|
||||
$completionRequest = (int) ($params['completion_request'] ?? 0);
|
||||
|
||||
if ($amount <= 0) {
|
||||
self::$error = '支付单金额须大于 0';
|
||||
if ($amount < 0) {
|
||||
self::$error = '支付单金额不能为负数';
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2380,6 +2485,54 @@ class PrescriptionOrderLogic
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发货/已签收订单:不新增/关联支付单,仅提交完单申请并将支付审核置为待审核。
|
||||
*
|
||||
* @param array<string,mixed> $params id
|
||||
* @return array<string,mixed>|false
|
||||
*/
|
||||
public static function requestCompletion(array $params, int $adminId, array $adminInfo)
|
||||
{
|
||||
self::$error = '';
|
||||
$id = (int) $params['id'];
|
||||
$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;
|
||||
}
|
||||
if (!in_array((int) $order->fulfillment_status, [5, 6], true)) {
|
||||
self::$error = '仅「已发货」或「已签收」状态的订单可申请完单';
|
||||
return false;
|
||||
}
|
||||
|
||||
$order->completion_request = 1;
|
||||
$order->completion_request_time = time();
|
||||
$order->completion_request_by = $adminId;
|
||||
$order->completion_request_by_name = (string) ($adminInfo['name'] ?? '');
|
||||
$order->payment_slip_audit_status = 0;
|
||||
$order->payment_slip_audit_remark = '';
|
||||
|
||||
try {
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
|
||||
self::writeLog($id, $adminId, $adminInfo, 'completion_request', '提交完单申请(未新增/关联支付单),等待支付审核');
|
||||
|
||||
$out = $order->toArray();
|
||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||
self::attachLinkedPayOrders($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 完成订单时可选的履约终态:3=已完成,7-12=业务结案类状态 */
|
||||
private const COMPLETE_FULFILLMENT_TARGETS = [3, 7, 8, 9, 10, 11, 12];
|
||||
|
||||
@@ -2424,7 +2577,10 @@ class PrescriptionOrderLogic
|
||||
$linkedPayOrders = self::linkedPayOrdersPayload($linkedPayOrderIds);
|
||||
$linkedPayPaidTotal = 0.0;
|
||||
foreach ($linkedPayOrders as $o) {
|
||||
$linkedPayPaidTotal += (float) ($o['amount'] ?? 0);
|
||||
$st = (int) ($o['status'] ?? 0);
|
||||
if ($st === 2 || $st === 5) {
|
||||
$linkedPayPaidTotal += (float) ($o['amount'] ?? 0);
|
||||
}
|
||||
}
|
||||
$linkedPayPaidTotal = round($linkedPayPaidTotal, 2);
|
||||
|
||||
@@ -2435,7 +2591,13 @@ class PrescriptionOrderLogic
|
||||
$order->fulfillment_status = $targetFulfillmentStatus;
|
||||
}
|
||||
|
||||
$refundedPayCount = 0;
|
||||
try {
|
||||
if ($targetFulfillmentStatus === 10 && $linkedPayOrderIds !== []) {
|
||||
$refundedPayCount = Order::whereIn('id', $linkedPayOrderIds)
|
||||
->whereNull('delete_time')
|
||||
->update(['status' => 4]);
|
||||
}
|
||||
$order->save();
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = $e->getMessage();
|
||||
@@ -2451,6 +2613,9 @@ class PrescriptionOrderLogic
|
||||
$logLine = '订单完成,状态变更为「' . $label . '」,实付金额更新为 ¥' . $linkedPayPaidTotal . $unassignSummary;
|
||||
} else {
|
||||
$logLine = '订单处理完成,状态变更为「' . $label . '」' . $unassignSummary;
|
||||
if ($targetFulfillmentStatus === 10 && $refundedPayCount > 0) {
|
||||
$logLine .= ',关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
|
||||
}
|
||||
}
|
||||
self::writeLog(
|
||||
$id,
|
||||
@@ -2508,7 +2673,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:诊单医助在 admin_dept 中的部门路径(多部门「;」、路径内「 / 」)
|
||||
* 导出用:管理员在 admin_dept 中的部门路径(多部门「;」、路径内「 / 」;业务订单导出取 creator_id)
|
||||
*/
|
||||
public static function formatAssistantDeptPathForExport(int $assistantAdminId): string
|
||||
{
|
||||
@@ -2798,21 +2963,21 @@ class PrescriptionOrderLogic
|
||||
$item['export_patient_gender'] = $g === 1 ? '男' : '女';
|
||||
$item['export_patient_age'] = (string) ($dg['age'] ?? '');
|
||||
$item['export_patient_phone'] = (string) ($dg['phone'] ?? '');
|
||||
$rxAst = \is_array($rx) ? (int) ($rx['assistant_id'] ?? 0) : 0;
|
||||
$diagAst = (int) ($dg['assistant_id'] ?? 0);
|
||||
$astId = self::resolveAssistantAdminIdForPrescriptionOrderRow($rxAst, $diagAst);
|
||||
if ($astId > 0) {
|
||||
if (!isset($assistantDeptCache[$astId])) {
|
||||
$assistantDeptCache[$astId] = self::formatAssistantDeptPathForExport($astId);
|
||||
}
|
||||
$item['export_assistant_dept'] = $assistantDeptCache[$astId];
|
||||
} else {
|
||||
$item['export_assistant_dept'] = '';
|
||||
}
|
||||
} else {
|
||||
$item['export_patient_gender'] = '';
|
||||
$item['export_patient_age'] = '';
|
||||
$item['export_patient_phone'] = '';
|
||||
}
|
||||
|
||||
// 导出「医助名字 / 医助部门」:与详情 order_creator 一致,按业务订单 creator_id(非诊单二诊 assistant_id)
|
||||
$creatorId = (int) ($item['creator_id'] ?? 0);
|
||||
$item['assistant_name'] = (string) ($item['creator_name'] ?? '');
|
||||
if ($creatorId > 0) {
|
||||
if (!isset($assistantDeptCache[$creatorId])) {
|
||||
$assistantDeptCache[$creatorId] = self::formatAssistantDeptPathForExport($creatorId);
|
||||
}
|
||||
$item['export_assistant_dept'] = $assistantDeptCache[$creatorId];
|
||||
} else {
|
||||
$item['export_assistant_dept'] = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\validate\stats;
|
||||
|
||||
use app\common\model\stats\PersonalAccountCost;
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
class PersonalAccountCostValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'id' => 'require|checkExists',
|
||||
'cost_date' => 'require|dateFormat:Y-m-d',
|
||||
'media_source' => 'require|max:120',
|
||||
'amount' => 'require|float|egt:0',
|
||||
'remark' => 'max:255',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'id.require' => '参数缺失',
|
||||
'cost_date.require' => '请选择日期',
|
||||
'cost_date.dateFormat' => '日期格式错误',
|
||||
'media_source.require' => '请填写自媒体来源',
|
||||
'media_source.max' => '自媒体来源不能超过120个字符',
|
||||
'amount.require' => '请输入账户消耗金额',
|
||||
'amount.float' => '账户消耗金额格式错误',
|
||||
'amount.egt' => '账户消耗金额不能小于0',
|
||||
'remark.max' => '备注不能超过255个字符',
|
||||
];
|
||||
|
||||
public function sceneAdd()
|
||||
{
|
||||
return $this->remove('id', true);
|
||||
}
|
||||
|
||||
public function sceneEdit()
|
||||
{
|
||||
return $this->remove('cost_date', true)->remove('media_source', true);
|
||||
}
|
||||
|
||||
public function sceneDetail()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function sceneDelete()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function checkExists($value)
|
||||
{
|
||||
$model = PersonalAccountCost::find($value);
|
||||
if (!$model) {
|
||||
return '记录不存在';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\validate\stats;
|
||||
|
||||
use app\common\model\stats\PersonalYeji;
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
class PersonalYejiValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'id' => 'require|checkExists',
|
||||
'yeji_date' => 'require|dateFormat:Y-m-d',
|
||||
'media_source' => 'require|max:120',
|
||||
'add_fans_count' => 'require|integer|egt:0',
|
||||
'total_open_count' => 'integer|egt:0',
|
||||
'unreplied_count' => 'integer|egt:0',
|
||||
'paid_appointment_count' => 'integer|egt:0',
|
||||
'free_appointment_count' => 'integer|egt:0',
|
||||
'interview_count' => 'integer|egt:0',
|
||||
'order_amount' => 'float|egt:0',
|
||||
'completed_order_count' => 'integer|egt:0',
|
||||
'remark' => 'max:255',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'id.require' => '参数缺失',
|
||||
'yeji_date.require' => '请选择业绩日期',
|
||||
'yeji_date.dateFormat' => '业绩日期格式错误',
|
||||
'media_source.require' => '请填写自媒体来源',
|
||||
'media_source.max' => '自媒体来源不能超过120个字符',
|
||||
'add_fans_count.require' => '请填写加粉数',
|
||||
'add_fans_count.integer' => '加粉数格式错误',
|
||||
'add_fans_count.egt' => '加粉数不能小于0',
|
||||
'remark.max' => '备注不能超过255个字符',
|
||||
];
|
||||
|
||||
public function sceneAdd()
|
||||
{
|
||||
return $this->remove('id', true);
|
||||
}
|
||||
|
||||
public function sceneEdit()
|
||||
{
|
||||
return $this->remove('yeji_date', true)->remove('media_source', true);
|
||||
}
|
||||
|
||||
public function sceneDetail()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function sceneDelete()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function checkExists($value)
|
||||
{
|
||||
$model = PersonalYeji::find($value);
|
||||
if (!$model) {
|
||||
return '记录不存在';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class PrescriptionLibraryValidate extends BaseValidate
|
||||
protected $rule = [
|
||||
'id' => 'require|number',
|
||||
'prescription_name' => 'require|max:100',
|
||||
'formula_type' => 'in:主方,辅方',
|
||||
'herbs' => 'require|array',
|
||||
'is_public' => 'in:0,1'
|
||||
];
|
||||
@@ -23,6 +24,7 @@ class PrescriptionLibraryValidate extends BaseValidate
|
||||
'id.number' => '处方ID必须为数字',
|
||||
'prescription_name.require' => '处方名称不能为空',
|
||||
'prescription_name.max' => '处方名称最多100个字符',
|
||||
'formula_type.in' => '处方类型只能是主方或辅方',
|
||||
'herbs.require' => '药材列表不能为空',
|
||||
'herbs.array' => '药材列表格式错误',
|
||||
'is_public.in' => '是否公开参数错误'
|
||||
@@ -33,7 +35,7 @@ class PrescriptionLibraryValidate extends BaseValidate
|
||||
*/
|
||||
public function sceneAdd()
|
||||
{
|
||||
return $this->only(['prescription_name', 'herbs', 'is_public']);
|
||||
return $this->only(['prescription_name', 'formula_type', 'herbs', 'is_public']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +43,7 @@ class PrescriptionLibraryValidate extends BaseValidate
|
||||
*/
|
||||
public function sceneEdit()
|
||||
{
|
||||
return $this->only(['id', 'prescription_name', 'herbs', 'is_public']);
|
||||
return $this->only(['id', 'prescription_name', 'formula_type', 'herbs', 'is_public']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'fee_type' => 'require|in:1,2,3,4,5,6,7,8',
|
||||
'amount' => 'require|float',
|
||||
'order_type' => 'require|in:1,2,3,4,5,6,7,8',
|
||||
'pay_amount' => 'require|float|gt:0',
|
||||
'pay_amount' => 'require|float|egt:0',
|
||||
'pay_remark' => 'max:200',
|
||||
'remark_extra' => 'max:500',
|
||||
'remark_assistant' => 'max:500',
|
||||
@@ -73,6 +73,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'paidPayOrders' => ['diagnosis_id'],
|
||||
'addPayOrder' => ['id', 'order_type', 'pay_amount', 'pay_remark'],
|
||||
'linkPayOrder' => ['id', 'pay_order_id'],
|
||||
'requestCompletion' => ['id'],
|
||||
'complete' => ['id', 'fulfillment_status'],
|
||||
'submitGancaoRecipel' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
|
||||
@@ -41,7 +41,7 @@ class PrescriptionValidate extends BaseValidate
|
||||
'patient_name', 'gender', 'age',
|
||||
'visit_no', 'prescription_date', 'tongue', 'tongue_image', 'pulse',
|
||||
'pulse_condition', 'clinical_diagnosis', 'herbs', 'dose_count', 'dose_unit',
|
||||
'usage_days', 'times_per_day', 'usage_instruction', 'usage_time', 'usage_way', 'dietary_taboo',
|
||||
'usage_days', 'times_per_day', 'aux_usage', 'usage_instruction', 'usage_time', 'usage_way', 'dietary_taboo',
|
||||
'usage_notes', 'doctor_name', 'is_shared', 'visible_role_ids',
|
||||
'diagnosis_id', 'appointment_id', 'audit_status',
|
||||
]);
|
||||
@@ -54,7 +54,7 @@ class PrescriptionValidate extends BaseValidate
|
||||
'patient_name', 'gender', 'age',
|
||||
'visit_no', 'prescription_date', 'tongue', 'tongue_image', 'pulse',
|
||||
'pulse_condition', 'clinical_diagnosis', 'herbs', 'dose_count', 'dose_unit',
|
||||
'usage_days', 'times_per_day', 'usage_instruction', 'usage_time', 'usage_way', 'dietary_taboo',
|
||||
'usage_days', 'times_per_day', 'aux_usage', 'usage_instruction', 'usage_time', 'usage_way', 'dietary_taboo',
|
||||
'usage_notes', 'doctor_name', 'is_shared', 'visible_role_ids', 'diagnosis_id',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,15 @@
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\api\logic\tcm\DailyGamifyLogic;
|
||||
use app\api\logic\tcm\DailyFamilyLikeLogic;
|
||||
use app\api\logic\tcm\DailyShareLogic;
|
||||
use app\adminapi\logic\ConfigLogic;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
use app\common\model\tcm\DietRecord;
|
||||
use app\common\model\tcm\ExerciseRecord;
|
||||
|
||||
/**
|
||||
* 中医诊断控制器(供小程序调用)
|
||||
@@ -28,7 +36,7 @@ class TcmController extends BaseApiController
|
||||
* @notes 不需要登录的方法
|
||||
* @var array
|
||||
*/
|
||||
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo'];
|
||||
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
|
||||
|
||||
/**
|
||||
* @notes 获取患者签名(供小程序调用)
|
||||
@@ -332,6 +340,640 @@ class TcmController extends BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录窗口数据(血糖血压 / 饮食 / 运动)—— 患者端
|
||||
*
|
||||
* 路由:GET /api/tcm/dailyTrackingWindow?diagnosis_id=:id&patient_id=:pid&start_date=&end_date=
|
||||
* 说明:供小程序「日常记录」页面拉取一段时间内的跟踪数据,复用后台 fetchTrackingWindow。
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyTrackingWindow()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
|
||||
$patientId = (int) $this->request->get('patient_id', 0);
|
||||
$startDate = (string) $this->request->get('start_date', '');
|
||||
$endDate = (string) $this->request->get('end_date', '');
|
||||
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
$diagnosis = $check['diagnosis'];
|
||||
|
||||
try {
|
||||
if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) {
|
||||
return $this->fail('无权查看该诊单的日常记录');
|
||||
}
|
||||
|
||||
$data = DiagnosisLogic::fetchTrackingWindow($diagnosisId, $startDate, $endDate);
|
||||
|
||||
// 把诊断的基础信息一并返回,便于小程序展示头部摘要
|
||||
$data['diagnosis'] = [
|
||||
'id' => (int) $diagnosis['id'],
|
||||
'patient_id' => (int) $diagnosis['patient_id'],
|
||||
'patient_name' => (string) ($diagnosis['patient_name'] ?? ''),
|
||||
'age' => (int) ($diagnosis['age'] ?? 0),
|
||||
'gender' => (int) ($diagnosis['gender'] ?? 0),
|
||||
];
|
||||
|
||||
return $this->data($data);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录-跟踪备注列表(患者端)
|
||||
*
|
||||
* 路由:GET /api/tcm/dailyTrackingNotes?diagnosis_id=:id&patient_id=:pid
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyTrackingNotes()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
|
||||
$patientId = (int) $this->request->get('patient_id', 0);
|
||||
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
$diagnosis = $check['diagnosis'];
|
||||
|
||||
try {
|
||||
if ($patientId > 0 && (int) $diagnosis['patient_id'] !== $patientId) {
|
||||
return $this->fail('无权查看该诊单的跟踪备注');
|
||||
}
|
||||
|
||||
$notes = TrackingNoteLogic::getByDiagnosis($diagnosisId);
|
||||
return $this->data($notes);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析小程序 form-urlencoded 提交的 JSON 字段
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @param string $arrayKey ThinkPHP 数组字段名,如 badges/a
|
||||
* @return array
|
||||
*/
|
||||
private function decodeJsonPostField($raw, string $arrayKey): array
|
||||
{
|
||||
if (is_string($raw) && $raw !== '') {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
$arr = $this->request->post($arrayKey, []);
|
||||
return is_array($arr) ? $arr : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 校验当前登录患者是否拥有指定诊单(通过 diagnosis_view_records 关联)
|
||||
* @param int $diagnosisId
|
||||
* @return array{ok:bool,diagnosis?:\app\common\model\tcm\Diagnosis,error?:string}
|
||||
*/
|
||||
private function ensurePatientOwnsDiagnosis(int $diagnosisId): array
|
||||
{
|
||||
if (!$this->userId) {
|
||||
return ['ok' => false, 'error' => '请先登录'];
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return ['ok' => false, 'error' => '诊单ID不能为空'];
|
||||
}
|
||||
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->find();
|
||||
if (!$diagnosis) {
|
||||
return ['ok' => false, 'error' => '诊单不存在'];
|
||||
}
|
||||
|
||||
// 通过 diagnosis_view_records 校验当前小程序用户与该诊单的归属关系
|
||||
// 该表是「就诊卡列表」的来源(参考 DiagnosisLogic::getCardList)
|
||||
$owned = \think\facade\Db::name('diagnosis_view_records')
|
||||
->where('user_id', $this->userId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->find();
|
||||
|
||||
if (!$owned) {
|
||||
return ['ok' => false, 'error' => '无权操作该诊单'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'diagnosis' => $diagnosis];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录-获取今日已录入的血糖血压记录(患者端)
|
||||
*
|
||||
* 路由:GET /api/tcm/dailyTodayBloodRecord?diagnosis_id=:id
|
||||
*
|
||||
* 用于「日常记录」页打开「录入今日」表单时预填数据;只返回 source=1 的当天记录。
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyTodayBloodRecord()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
|
||||
try {
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
|
||||
$record = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('source', 1)
|
||||
->where('record_date', '>=', $todayStart)
|
||||
->where('record_date', '<=', $todayEnd)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$record) {
|
||||
return $this->data(['record' => null, 'today' => date('Y-m-d')]);
|
||||
}
|
||||
|
||||
$data = $record->toArray();
|
||||
$data['record_date'] = date('Y-m-d', (int) $data['record_date']);
|
||||
return $this->data(['record' => $data, 'today' => date('Y-m-d')]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录-保存今日的血糖血压记录(患者端)
|
||||
*
|
||||
* 路由:POST /api/tcm/dailySaveBloodRecord
|
||||
* 必填:diagnosis_id
|
||||
* 可选数值:fasting_blood_sugar, postprandial_blood_sugar, other_blood_sugar,
|
||||
* systolic_pressure, diastolic_pressure
|
||||
* 可选字符串:remark
|
||||
*
|
||||
* 语义:同一天同一诊单只有一条 source=1 的记录;存在则 update,不存在则 create。
|
||||
* 日期强制为「今天」;任何 record_date 入参被忽略,避免补录历史。
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailySaveBloodRecord()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
$diagnosis = $check['diagnosis'];
|
||||
|
||||
// 解析并校验数值字段;空字符串当 null 处理
|
||||
$parseDecimal = function ($v) {
|
||||
if ($v === null || $v === '' || $v === '0' || $v === 0) return null;
|
||||
if (!is_numeric($v)) return null;
|
||||
$f = (float) $v;
|
||||
return $f > 0 ? $f : null;
|
||||
};
|
||||
$parseInt = function ($v) {
|
||||
if ($v === null || $v === '') return null;
|
||||
if (!is_numeric($v)) return null;
|
||||
$i = (int) $v;
|
||||
return $i > 0 ? $i : null;
|
||||
};
|
||||
|
||||
$fasting = $parseDecimal($this->request->post('fasting_blood_sugar'));
|
||||
$postprandial = $parseDecimal($this->request->post('postprandial_blood_sugar'));
|
||||
$other = $parseDecimal($this->request->post('other_blood_sugar'));
|
||||
$systolic = $parseInt($this->request->post('systolic_pressure'));
|
||||
$diastolic = $parseInt($this->request->post('diastolic_pressure'));
|
||||
$remark = trim((string) $this->request->post('remark', ''));
|
||||
|
||||
// 至少要填一个有效字段
|
||||
if ($fasting === null && $postprandial === null && $other === null &&
|
||||
$systolic === null && $diastolic === null && $remark === '') {
|
||||
return $this->fail('请至少填写一项血糖或血压数据');
|
||||
}
|
||||
|
||||
// 边界校验,避免误录天文数字
|
||||
if ($fasting !== null && ($fasting < 0.1 || $fasting > 50)) return $this->fail('空腹血糖数值异常');
|
||||
if ($postprandial !== null && ($postprandial < 0.1 || $postprandial > 50)) return $this->fail('餐后血糖数值异常');
|
||||
if ($other !== null && ($other < 0.1 || $other > 50)) return $this->fail('其他血糖数值异常');
|
||||
if ($systolic !== null && ($systolic < 40 || $systolic > 300)) return $this->fail('收缩压数值异常');
|
||||
if ($diastolic !== null && ($diastolic < 20 || $diastolic > 200)) return $this->fail('舒张压数值异常');
|
||||
|
||||
try {
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
|
||||
$existing = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('source', 1)
|
||||
->where('record_date', '>=', $todayStart)
|
||||
->where('record_date', '<=', $todayEnd)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'patient_id' => (int) $diagnosis['patient_id'],
|
||||
'record_date' => $todayStart,
|
||||
'record_time' => date('H:i'),
|
||||
'fasting_blood_sugar' => $fasting,
|
||||
'postprandial_blood_sugar' => $postprandial,
|
||||
'other_blood_sugar' => $other,
|
||||
'systolic_pressure' => $systolic,
|
||||
'diastolic_pressure' => $diastolic,
|
||||
'remark' => $remark,
|
||||
'source' => 1,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$payload['id'] = $existing['id'];
|
||||
BloodRecord::update($payload);
|
||||
return $this->success('已更新今日记录', ['id' => $existing['id']]);
|
||||
}
|
||||
|
||||
$created = BloodRecord::create($payload);
|
||||
return $this->success('录入成功', ['id' => $created->id]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录-删除今日的血糖血压记录(患者端)
|
||||
*
|
||||
* 路由:POST /api/tcm/dailyDeleteBloodRecord
|
||||
* 必填:diagnosis_id, id
|
||||
*
|
||||
* 只允许删除当前用户拥有的诊单 + 当天 + source=1 的记录;其他情况一律拒绝。
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyDeleteBloodRecord()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
|
||||
$recordId = (int) $this->request->post('id', 0);
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
|
||||
if ($recordId <= 0) {
|
||||
return $this->fail('记录ID不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
|
||||
$record = BloodRecord::where('id', $recordId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('source', 1)
|
||||
->find();
|
||||
|
||||
if (!$record) {
|
||||
return $this->fail('记录不存在或无权删除');
|
||||
}
|
||||
|
||||
$ts = (int) $record['record_date'];
|
||||
if ($ts < $todayStart || $ts > $todayEnd) {
|
||||
return $this->fail('只能删除当天的记录');
|
||||
}
|
||||
|
||||
BloodRecord::destroy($recordId);
|
||||
return $this->success('已删除');
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取稳糖乐园状态(积分、今日任务、可领取积分)
|
||||
*
|
||||
* 路由:GET /api/tcm/dailyGetGamify?diagnosis_id=:id
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyGetGamify()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
|
||||
$result = DailyGamifyLogic::getState((int) $this->userId, $diagnosisId);
|
||||
if ($result === false) {
|
||||
return $this->fail(DailyGamifyLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 给小树浇水(领取今日已完成任务的积分,服务端校验)
|
||||
*
|
||||
* 路由:POST /api/tcm/dailyWaterTree
|
||||
* 必填:diagnosis_id
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyWaterTree()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
|
||||
$result = DailyGamifyLogic::waterTree((int) $this->userId, $diagnosisId);
|
||||
if ($result === false) {
|
||||
return $this->fail(DailyGamifyLogic::getError());
|
||||
}
|
||||
// 统一走 success,保证 msg 与 data 同时返回,前端可提示且刷新状态
|
||||
return $this->success($result['message'] ?? '操作成功', $result, 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录-获取今日饮食记录
|
||||
*
|
||||
* 路由:GET /api/tcm/dailyTodayDietRecord?diagnosis_id=:id
|
||||
*/
|
||||
public function dailyTodayDietRecord()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
|
||||
try {
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
$record = DietRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $todayStart)
|
||||
->where('record_date', '<=', $todayEnd)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$record) {
|
||||
return $this->data(['record' => null, 'today' => date('Y-m-d')]);
|
||||
}
|
||||
|
||||
$data = $record->toArray();
|
||||
$data['record_date'] = date('Y-m-d', (int) $data['record_date']);
|
||||
return $this->data(['record' => $data, 'today' => date('Y-m-d')]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录-保存今日饮食
|
||||
*
|
||||
* 路由:POST /api/tcm/dailySaveDietRecord
|
||||
*/
|
||||
public function dailySaveDietRecord()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
$diagnosis = $check['diagnosis'];
|
||||
|
||||
$breakfast = trim((string) $this->request->post('breakfast_foods', ''));
|
||||
$lunch = trim((string) $this->request->post('lunch_foods', ''));
|
||||
$dinner = trim((string) $this->request->post('dinner_foods', ''));
|
||||
$note = trim((string) $this->request->post('note', ''));
|
||||
|
||||
if ($breakfast === '' && $lunch === '' && $dinner === '') {
|
||||
return $this->fail('请至少填写一餐饮食');
|
||||
}
|
||||
|
||||
try {
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
$existing = DietRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $todayStart)
|
||||
->where('record_date', '<=', $todayEnd)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'patient_id' => (int) $diagnosis['patient_id'],
|
||||
'record_date' => $todayStart,
|
||||
'breakfast_foods' => $breakfast,
|
||||
'lunch_foods' => $lunch,
|
||||
'dinner_foods' => $dinner,
|
||||
'note' => $note,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$payload['id'] = $existing['id'];
|
||||
DietRecord::update($payload);
|
||||
return $this->success('已更新今日饮食', ['id' => $existing['id']]);
|
||||
}
|
||||
|
||||
$created = DietRecord::create($payload);
|
||||
return $this->success('饮食记录已保存', ['id' => $created->id]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录-获取今日运动记录
|
||||
*
|
||||
* 路由:GET /api/tcm/dailyTodayExerciseRecord?diagnosis_id=:id
|
||||
*/
|
||||
public function dailyTodayExerciseRecord()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
|
||||
try {
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
$record = ExerciseRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $todayStart)
|
||||
->where('record_date', '<=', $todayEnd)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$record) {
|
||||
return $this->data(['record' => null, 'today' => date('Y-m-d')]);
|
||||
}
|
||||
|
||||
$data = $record->toArray();
|
||||
$data['record_date'] = date('Y-m-d', (int) $data['record_date']);
|
||||
return $this->data(['record' => $data, 'today' => date('Y-m-d')]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 日常记录-保存今日运动
|
||||
*
|
||||
* 路由:POST /api/tcm/dailySaveExerciseRecord
|
||||
*/
|
||||
public function dailySaveExerciseRecord()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
|
||||
$check = $this->ensurePatientOwnsDiagnosis($diagnosisId);
|
||||
if (!$check['ok']) {
|
||||
return $this->fail($check['error']);
|
||||
}
|
||||
$diagnosis = $check['diagnosis'];
|
||||
|
||||
$exerciseType = trim((string) $this->request->post('exercise_type', ''));
|
||||
$durationRaw = $this->request->post('duration', '');
|
||||
$intensity = (int) $this->request->post('intensity', 2);
|
||||
$note = trim((string) $this->request->post('note', ''));
|
||||
|
||||
$duration = null;
|
||||
if ($durationRaw !== '' && $durationRaw !== null && is_numeric($durationRaw)) {
|
||||
$d = (int) $durationRaw;
|
||||
if ($d > 0) {
|
||||
$duration = $d;
|
||||
}
|
||||
}
|
||||
|
||||
if ($exerciseType === '' && $duration === null) {
|
||||
return $this->fail('请填写运动类型或时长');
|
||||
}
|
||||
|
||||
if ($intensity < 1 || $intensity > 3) {
|
||||
$intensity = 2;
|
||||
}
|
||||
|
||||
try {
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
|
||||
$existing = ExerciseRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $todayStart)
|
||||
->where('record_date', '<=', $todayEnd)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$payload = [
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'patient_id' => (int) $diagnosis['patient_id'],
|
||||
'record_date' => $todayStart,
|
||||
'exercise_type' => $exerciseType,
|
||||
'duration' => $duration,
|
||||
'intensity' => $intensity,
|
||||
'note' => $note,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$payload['id'] = $existing['id'];
|
||||
ExerciseRecord::update($payload);
|
||||
return $this->success('已更新今日运动', ['id' => $existing['id']]);
|
||||
}
|
||||
|
||||
$created = ExerciseRecord::create($payload);
|
||||
return $this->success('运动记录已保存', ['id' => $created->id]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 保存稳糖分 / 勋章 / 任务领奖状态
|
||||
*
|
||||
* 路由:POST /api/tcm/dailySaveGamify
|
||||
* 参数:diagnosis_id, points, badges(数组), task_awards(对象)
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailySaveGamify()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
|
||||
$points = (int) $this->request->post('points', 0);
|
||||
$badges = $this->decodeJsonPostField($this->request->post('badges', ''), 'badges/a');
|
||||
$taskAwards = $this->decodeJsonPostField($this->request->post('task_awards', ''), 'task_awards/a');
|
||||
|
||||
$result = DailyGamifyLogic::saveState((int) $this->userId, $diagnosisId, $points, $badges, $taskAwards);
|
||||
if ($result === false) {
|
||||
return $this->fail(DailyGamifyLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 生成日常记录分享邀请码(仅当天有效)
|
||||
*
|
||||
* 路由:POST /api/tcm/dailyCreateShareInvite
|
||||
* 必填:diagnosis_id
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyCreateShareInvite()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->post('diagnosis_id', 0);
|
||||
$result = DailyShareLogic::createInvite((int) $this->userId, $diagnosisId);
|
||||
if ($result === false) {
|
||||
return $this->fail(DailyShareLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 凭邀请码查看分享战报(无需登录,含近7日血糖/血压记录)
|
||||
*
|
||||
* 路由:GET /api/tcm/dailySharePreview?invite_code=XXXXXXXX
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailySharePreview()
|
||||
{
|
||||
$inviteCode = (string) $this->request->get('invite_code', '');
|
||||
$viewerKey = (string) $this->request->get('viewer_key', '');
|
||||
$result = DailyShareLogic::previewByInviteCode($inviteCode, $viewerKey);
|
||||
if ($result === false) {
|
||||
return $this->fail(DailyShareLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 家人点赞(邀请观看页,无需登录)
|
||||
*
|
||||
* 路由:POST /api/tcm/dailyFamilyLike
|
||||
* 必填:invite_code, viewer_key
|
||||
* 可选:nickname(家人/亲友/老伴 等)
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyFamilyLike()
|
||||
{
|
||||
$inviteCode = (string) $this->request->post('invite_code', '');
|
||||
$viewerKey = (string) $this->request->post('viewer_key', '');
|
||||
$nickname = (string) $this->request->post('nickname', '');
|
||||
$result = DailyFamilyLikeLogic::addLike($inviteCode, $viewerKey, $nickname);
|
||||
if ($result === false) {
|
||||
return $this->fail(DailyFamilyLikeLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 患者查看今日家人点赞汇总
|
||||
*
|
||||
* 路由:GET /api/tcm/dailyFamilyLikeSummary?diagnosis_id=
|
||||
*
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function dailyFamilyLikeSummary()
|
||||
{
|
||||
$diagnosisId = (int) $this->request->get('diagnosis_id', 0);
|
||||
$result = DailyFamilyLikeLogic::summaryForPatient((int) $this->userId, $diagnosisId);
|
||||
if ($result === false) {
|
||||
return $this->fail(DailyFamilyLikeLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 根据订单号获取订单详情(供小程序支付页调用)
|
||||
* @return \think\response\Json
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
namespace app\api\controller\asset;
|
||||
|
||||
use app\api\controller\BaseApiController;
|
||||
use app\common\model\AssetUser;
|
||||
use app\common\model\AssetResource;
|
||||
use app\common\model\AssetUserResource;
|
||||
use think\facade\Db;
|
||||
|
||||
class AssetAppController extends BaseApiController
|
||||
{
|
||||
// 所有接口都免框架登录验证(使用独立 asset_token 体系,在方法内自行校验)
|
||||
public array $notNeedLogin = ['login', 'getResourceList', 'changePassword', 'recordDownload'];
|
||||
|
||||
/** token 有效期:7 天 */
|
||||
const TOKEN_EXPIRE = 86400 * 7;
|
||||
|
||||
/**
|
||||
* @notes 通过 token 获取当前用户(不检查 status,仅查 token 有效性)
|
||||
*/
|
||||
private function getAssetUserRaw(): ?AssetUser
|
||||
{
|
||||
$token = $this->request->header('token');
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 方式1: 从数据库 token 字段查找
|
||||
try {
|
||||
$user = AssetUser::where('token', $token)
|
||||
->where('token_expire_time', '>', time())
|
||||
->find();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// token 字段不存在时忽略,走 cache 兜底
|
||||
}
|
||||
|
||||
// 方式2: 从 file cache 查找
|
||||
$userId = cache('asset_token_' . $token);
|
||||
if ($userId) {
|
||||
$user = AssetUser::where('id', $userId)->find();
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取当前有效用户(status=1 才返回)
|
||||
*/
|
||||
private function getAssetUser(): ?AssetUser
|
||||
{
|
||||
$user = $this->getAssetUserRaw();
|
||||
if ($user && $user->status == 1) {
|
||||
return $user;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 小程序端登录
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$phone = $this->request->post('phone');
|
||||
$password = $this->request->post('password');
|
||||
|
||||
if (empty($phone) || empty($password)) {
|
||||
return $this->fail('手机号或密码不能为空');
|
||||
}
|
||||
|
||||
$user = AssetUser::where('phone', $phone)->find();
|
||||
if (!$user) {
|
||||
return $this->fail('账号不存在');
|
||||
}
|
||||
|
||||
if ($user->status != 1) {
|
||||
return $this->fail('账号已被禁用');
|
||||
}
|
||||
|
||||
if (!password_verify($password, $user->password)) {
|
||||
return $this->fail('密码错误');
|
||||
}
|
||||
|
||||
// 生成 token 并双写(DB + cache 兜底)
|
||||
$token = md5($user->id . time() . uniqid('asset', true));
|
||||
|
||||
// 写入 file cache(始终可用)
|
||||
cache('asset_token_' . $token, $user->id, self::TOKEN_EXPIRE);
|
||||
|
||||
// 尝试写入数据库(需要已执行 SQL 迁移)
|
||||
try {
|
||||
$user->token = $token;
|
||||
$user->token_expire_time = time() + self::TOKEN_EXPIRE;
|
||||
$user->save();
|
||||
} catch (\Throwable $e) {
|
||||
// token 字段不存在时忽略,cache 已经写入
|
||||
}
|
||||
|
||||
return $this->success('登录成功', [
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'phone' => $user->phone
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取用户关联的资源列表 (或公开资源)
|
||||
*/
|
||||
public function getResourceList()
|
||||
{
|
||||
$token = $this->request->header('token');
|
||||
$rawUser = !empty($token) ? $this->getAssetUserRaw() : null;
|
||||
$user = ($rawUser && $rawUser->status == 1) ? $rawUser : null;
|
||||
$isDisabled = ($rawUser && $rawUser->status != 1); // 用户存在但被禁用
|
||||
$tokenInvalid = (!empty($token) && !$rawUser); // token 过期或无效
|
||||
|
||||
$type = $this->request->get('type', 1);
|
||||
$pageNo = $this->request->get('page_no', 1);
|
||||
$pageSize = $this->request->get('page_size', 20);
|
||||
$days = (int)$this->request->get('days', 0);
|
||||
$usageStatus = (int)$this->request->get('usage_status', 0); // 0=全部, 1=未使用, 2=已使用
|
||||
|
||||
$query = AssetResource::where('type', $type);
|
||||
$usedResourceIds = [];
|
||||
|
||||
if ($user) {
|
||||
// 登录用户:自己的专属资源 + 所有公开资源
|
||||
$exclusiveIds = AssetUserResource::where('user_id', $user->id)->column('resource_id');
|
||||
$associatedResourceIds = AssetUserResource::column('resource_id');
|
||||
|
||||
$query = $query->where(function ($q) use ($exclusiveIds, $associatedResourceIds) {
|
||||
if (!empty($exclusiveIds)) {
|
||||
$q->whereIn('id', $exclusiveIds);
|
||||
}
|
||||
if (!empty($associatedResourceIds)) {
|
||||
if (!empty($exclusiveIds)) {
|
||||
$q->whereOr('id', 'not in', $associatedResourceIds);
|
||||
} else {
|
||||
$q->whereNotIn('id', $associatedResourceIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 获取该用户的使用记录
|
||||
$usedResourceIds = \think\facade\Db::name('asset_resource_usage')
|
||||
->where('user_id', $user->id)
|
||||
->column('resource_id');
|
||||
|
||||
// 使用状态过滤
|
||||
if ($usageStatus === 1) { // 未使用
|
||||
if (!empty($usedResourceIds)) {
|
||||
$query = $query->whereNotIn('id', $usedResourceIds);
|
||||
}
|
||||
} elseif ($usageStatus === 2) { // 已使用
|
||||
if (empty($usedResourceIds)) {
|
||||
return $this->data([
|
||||
'lists' => [],
|
||||
'count' => 0,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'is_disabled' => $isDisabled,
|
||||
'token_invalid' => $tokenInvalid,
|
||||
]);
|
||||
}
|
||||
$query = $query->whereIn('id', $usedResourceIds);
|
||||
}
|
||||
} else {
|
||||
// 游客(未登录):只看公开资源(没关联给任何用户的资源)
|
||||
$associatedResourceIds = AssetUserResource::column('resource_id');
|
||||
if (!empty($associatedResourceIds)) {
|
||||
$query = $query->whereNotIn('id', $associatedResourceIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 时间过滤
|
||||
if ($days > 0) {
|
||||
$startTime = strtotime("-{$days} days", strtotime(date('Y-m-d')));
|
||||
$query = $query->where('create_time', '>=', $startTime);
|
||||
}
|
||||
|
||||
$count = (clone $query)->count();
|
||||
$lists = (clone $query)
|
||||
->order('id', 'desc')
|
||||
->page($pageNo, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// 附加 is_used 字段
|
||||
foreach ($lists as &$item) {
|
||||
$item['is_used'] = in_array($item['id'], $usedResourceIds);
|
||||
}
|
||||
|
||||
return $this->data([
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'is_disabled' => $isDisabled, // 用户被禁用
|
||||
'token_invalid' => $tokenInvalid, // token 过期或无效
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 记录资源下载/使用
|
||||
*/
|
||||
public function recordDownload()
|
||||
{
|
||||
$user = $this->getAssetUser();
|
||||
if (!$user) {
|
||||
return $this->fail('请先登录', [], -1);
|
||||
}
|
||||
|
||||
$resourceId = $this->request->post('resource_id');
|
||||
if (empty($resourceId)) {
|
||||
return $this->fail('参数缺失');
|
||||
}
|
||||
|
||||
// 检查资源是否存在
|
||||
$resource = AssetResource::find($resourceId);
|
||||
if (!$resource) {
|
||||
return $this->fail('资源不存在');
|
||||
}
|
||||
|
||||
// 检查是否有关联权限 (或者是公开资源)
|
||||
$isAssociated = AssetUserResource::where('user_id', $user->id)->where('resource_id', $resourceId)->find();
|
||||
$isPublic = !AssetUserResource::where('resource_id', $resourceId)->find();
|
||||
|
||||
if (!$isAssociated && !$isPublic) {
|
||||
return $this->fail('无权操作此资源');
|
||||
}
|
||||
|
||||
// 记录下载
|
||||
$exists = \think\facade\Db::name('asset_resource_usage')
|
||||
->where('user_id', $user->id)
|
||||
->where('resource_id', $resourceId)
|
||||
->find();
|
||||
|
||||
if (!$exists) {
|
||||
\think\facade\Db::name('asset_resource_usage')->insert([
|
||||
'user_id' => $user->id,
|
||||
'resource_id' => $resourceId,
|
||||
'create_time' => time()
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success('记录成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 修改密码
|
||||
*/
|
||||
public function changePassword()
|
||||
{
|
||||
$user = $this->getAssetUser();
|
||||
if (!$user) {
|
||||
return $this->fail('登录已过期,请重新登录', [], -1);
|
||||
}
|
||||
|
||||
$oldPassword = $this->request->post('old_password');
|
||||
$password = $this->request->post('password');
|
||||
|
||||
if (empty($oldPassword)) {
|
||||
return $this->fail('请输入原密码');
|
||||
}
|
||||
if (empty($password)) {
|
||||
return $this->fail('请输入新密码');
|
||||
}
|
||||
if (strlen($password) < 6) {
|
||||
return $this->fail('新密码至少6位');
|
||||
}
|
||||
|
||||
if (!password_verify($oldPassword, $user->password)) {
|
||||
return $this->fail('原密码不正确');
|
||||
}
|
||||
|
||||
$user->password = password_hash($password, PASSWORD_DEFAULT);
|
||||
$user->save();
|
||||
|
||||
return $this->success('密码修改成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\DailyFamilyLike;
|
||||
use app\common\model\tcm\DailyShareInvite;
|
||||
|
||||
/**
|
||||
* 家人点赞(邀请观看页 → 患者日常页展示)
|
||||
*/
|
||||
class DailyFamilyLikeLogic
|
||||
{
|
||||
protected static string $error = '';
|
||||
|
||||
/** @var string[] */
|
||||
protected static array $praisePool = [
|
||||
'坚持得很好,为您点赞!',
|
||||
'每天记录真棒,继续保持!',
|
||||
'您的自律让人佩服!',
|
||||
'加油,家人一直支持您!',
|
||||
'稳糖路上,您并不孤单!',
|
||||
'好习惯正在养成,真为您高兴!',
|
||||
];
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function setError(string $msg): bool
|
||||
{
|
||||
self::$error = $msg;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当日邀请码
|
||||
*
|
||||
* @return array{diagnosis_id:int,invite_code:string}|false
|
||||
*/
|
||||
protected static function resolveInvite(string $inviteCode): array|false
|
||||
{
|
||||
$inviteCode = strtoupper(trim($inviteCode));
|
||||
if ($inviteCode === '') {
|
||||
return self::setError('邀请码不能为空') ? false : false;
|
||||
}
|
||||
|
||||
$row = DailyShareInvite::where('invite_code', $inviteCode)->find();
|
||||
if (!$row) {
|
||||
return self::setError('邀请码无效或已失效') ? false : false;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
if ((string) $row['invite_date'] !== $today) {
|
||||
return self::setError('邀请码已过期,仅可在分享当天点赞') ? false : false;
|
||||
}
|
||||
|
||||
return [
|
||||
'diagnosis_id' => (int) $row['diagnosis_id'],
|
||||
'invite_code' => $inviteCode,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function normalizeViewerKey(string $viewerKey): string
|
||||
{
|
||||
$viewerKey = preg_replace('/[^\w\-]/', '', trim($viewerKey)) ?? '';
|
||||
if (strlen($viewerKey) < 8) {
|
||||
return '';
|
||||
}
|
||||
return substr($viewerKey, 0, 64);
|
||||
}
|
||||
|
||||
protected static function normalizeNickname(string $nickname): string
|
||||
{
|
||||
$nickname = trim($nickname);
|
||||
if ($nickname === '') {
|
||||
return '家人';
|
||||
}
|
||||
$nickname = mb_substr($nickname, 0, 8, 'UTF-8');
|
||||
return $nickname !== '' ? $nickname : '家人';
|
||||
}
|
||||
|
||||
public static function countToday(int $diagnosisId, ?string $likeDate = null): int
|
||||
{
|
||||
$likeDate = $likeDate ?: date('Y-m-d');
|
||||
return (int) DailyFamilyLike::where('diagnosis_id', $diagnosisId)
|
||||
->where('like_date', $likeDate)
|
||||
->count();
|
||||
}
|
||||
|
||||
public static function likedByViewer(int $diagnosisId, string $viewerKey, ?string $likeDate = null): bool
|
||||
{
|
||||
$viewerKey = self::normalizeViewerKey($viewerKey);
|
||||
if ($viewerKey === '') {
|
||||
return false;
|
||||
}
|
||||
$likeDate = $likeDate ?: date('Y-m-d');
|
||||
return DailyFamilyLike::where('diagnosis_id', $diagnosisId)
|
||||
->where('like_date', $likeDate)
|
||||
->where('viewer_key', $viewerKey)
|
||||
->find() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 邀请预览附加点赞信息
|
||||
*/
|
||||
public static function metaForInvite(string $inviteCode, string $viewerKey = ''): array
|
||||
{
|
||||
$resolved = self::resolveInvite($inviteCode);
|
||||
if ($resolved === false) {
|
||||
return [
|
||||
'like_count' => 0,
|
||||
'liked_by_me' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$diagnosisId = $resolved['diagnosis_id'];
|
||||
$viewerKey = self::normalizeViewerKey($viewerKey);
|
||||
|
||||
return [
|
||||
'like_count' => self::countToday($diagnosisId),
|
||||
'liked_by_me' => $viewerKey !== '' && self::likedByViewer($diagnosisId, $viewerKey),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 家人点赞(无需登录)
|
||||
*/
|
||||
public static function addLike(string $inviteCode, string $viewerKey, string $nickname = ''): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
$resolved = self::resolveInvite($inviteCode);
|
||||
if ($resolved === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$viewerKey = self::normalizeViewerKey($viewerKey);
|
||||
if ($viewerKey === '') {
|
||||
return self::setError('设备标识无效,请重试') ? false : false;
|
||||
}
|
||||
|
||||
$diagnosisId = $resolved['diagnosis_id'];
|
||||
$likeDate = date('Y-m-d');
|
||||
$nickname = self::normalizeNickname($nickname);
|
||||
|
||||
$exists = DailyFamilyLike::where('diagnosis_id', $diagnosisId)
|
||||
->where('like_date', $likeDate)
|
||||
->where('viewer_key', $viewerKey)
|
||||
->find();
|
||||
|
||||
if ($exists) {
|
||||
return [
|
||||
'already_liked' => true,
|
||||
'like_count' => self::countToday($diagnosisId, $likeDate),
|
||||
'praise_message' => '您今天已经点过赞啦,谢谢您的鼓励',
|
||||
'nickname' => (string) ($exists['nickname'] ?? '家人'),
|
||||
];
|
||||
}
|
||||
|
||||
$todayCount = self::countToday($diagnosisId, $likeDate);
|
||||
if ($todayCount >= 50) {
|
||||
return self::setError('今日点赞已满,明天再来吧') ? false : false;
|
||||
}
|
||||
|
||||
DailyFamilyLike::create([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'like_date' => $likeDate,
|
||||
'invite_code' => $resolved['invite_code'],
|
||||
'viewer_key' => $viewerKey,
|
||||
'nickname' => $nickname,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
$likeCount = self::countToday($diagnosisId, $likeDate);
|
||||
$idx = $likeCount > 0 ? ($likeCount - 1) % count(self::$praisePool) : 0;
|
||||
|
||||
return [
|
||||
'already_liked' => false,
|
||||
'like_count' => $likeCount,
|
||||
'praise_message' => self::$praisePool[$idx],
|
||||
'nickname' => $nickname,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者查看今日家人点赞(需登录且拥有诊单)
|
||||
*/
|
||||
public static function summaryForPatient(int $userId, int $diagnosisId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::setError('请先登录') ? false : false;
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return self::setError('诊单ID不能为空') ? false : false;
|
||||
}
|
||||
|
||||
$owned = \think\facade\Db::name('diagnosis_view_records')
|
||||
->where('user_id', $userId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->find();
|
||||
if (!$owned) {
|
||||
return self::setError('无权查看该诊单') ? false : false;
|
||||
}
|
||||
|
||||
$likeDate = date('Y-m-d');
|
||||
$count = self::countToday($diagnosisId, $likeDate);
|
||||
|
||||
$rows = DailyFamilyLike::where('diagnosis_id', $diagnosisId)
|
||||
->where('like_date', $likeDate)
|
||||
->order('id', 'desc')
|
||||
->limit(8)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$recent = [];
|
||||
foreach ($rows as $r) {
|
||||
$recent[] = [
|
||||
'nickname' => (string) ($r['nickname'] ?? '家人'),
|
||||
'time_label' => self::timeLabel((int) ($r['create_time'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
$summaryLine = $count > 0
|
||||
? "今日有 {$count} 位家人为您点赞,继续加油!"
|
||||
: '分享战报给家人,邀请他们为您点赞鼓劲';
|
||||
|
||||
return [
|
||||
'like_count' => $count,
|
||||
'summary_line' => $summaryLine,
|
||||
'recent' => $recent,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function timeLabel(int $ts): string
|
||||
{
|
||||
if ($ts <= 0) {
|
||||
return '刚刚';
|
||||
}
|
||||
$diff = time() - $ts;
|
||||
if ($diff < 60) {
|
||||
return '刚刚';
|
||||
}
|
||||
if ($diff < 3600) {
|
||||
return (int) floor($diff / 60) . '分钟前';
|
||||
}
|
||||
return date('H:i', $ts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
use app\common\model\tcm\DailyGamify;
|
||||
use app\common\model\tcm\DietRecord;
|
||||
use app\common\model\tcm\ExerciseRecord;
|
||||
|
||||
/**
|
||||
* 稳糖分 / 勋章 / 浇水领奖
|
||||
*/
|
||||
class DailyGamifyLogic
|
||||
{
|
||||
protected static string $error = '';
|
||||
|
||||
/** @var array<string,array{name:string,points:int}> */
|
||||
protected static array $taskDefs = [
|
||||
'blood' => ['name' => '测血糖血压', 'points' => 10],
|
||||
'diet' => ['name' => '记今日饮食', 'points' => 10],
|
||||
'exercise' => ['name' => '记今日运动', 'points' => 10],
|
||||
];
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function setError(string $msg): bool
|
||||
{
|
||||
self::$error = $msg;
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static function assertOwned(int $userId, int $diagnosisId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return self::setError('请先登录') ? false : false;
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return self::setError('诊单ID不能为空') ? false : false;
|
||||
}
|
||||
$owned = \think\facade\Db::name('diagnosis_view_records')
|
||||
->where('user_id', $userId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->find();
|
||||
if (!$owned) {
|
||||
return self::setError('无权操作该诊单') ? false : false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected static function todayRange(): array
|
||||
{
|
||||
return [
|
||||
strtotime(date('Y-m-d 00:00:00')),
|
||||
strtotime(date('Y-m-d 23:59:59')),
|
||||
];
|
||||
}
|
||||
|
||||
protected static function hasValue($v): bool
|
||||
{
|
||||
if ($v === null || $v === '' || $v === '0' || $v === 0) {
|
||||
return false;
|
||||
}
|
||||
if (is_string($v)) {
|
||||
return trim($v) !== '';
|
||||
}
|
||||
return is_numeric($v) && (float) $v > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日任务是否已完成(依据真实业务记录)
|
||||
*/
|
||||
public static function evaluateTaskCompletion(int $diagnosisId): array
|
||||
{
|
||||
[$start, $end] = self::todayRange();
|
||||
|
||||
$blood = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('source', 1)
|
||||
->where('record_date', '>=', $start)
|
||||
->where('record_date', '<=', $end)
|
||||
->find();
|
||||
|
||||
$bloodDone = false;
|
||||
if ($blood) {
|
||||
$bloodDone = self::hasValue($blood['fasting_blood_sugar'] ?? null)
|
||||
|| self::hasValue($blood['postprandial_blood_sugar'] ?? null)
|
||||
|| self::hasValue($blood['other_blood_sugar'] ?? null)
|
||||
|| self::hasValue($blood['systolic_pressure'] ?? null)
|
||||
|| self::hasValue($blood['diastolic_pressure'] ?? null);
|
||||
}
|
||||
|
||||
$diet = DietRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $start)
|
||||
->where('record_date', '<=', $end)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$dietDone = false;
|
||||
if ($diet) {
|
||||
$dietDone = self::hasValue($diet['breakfast_foods'] ?? null)
|
||||
|| self::hasValue($diet['lunch_foods'] ?? null)
|
||||
|| self::hasValue($diet['dinner_foods'] ?? null);
|
||||
}
|
||||
|
||||
$exercise = ExerciseRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $start)
|
||||
->where('record_date', '<=', $end)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
$exerciseDone = false;
|
||||
if ($exercise) {
|
||||
$exerciseDone = self::hasValue($exercise['exercise_type'] ?? null)
|
||||
|| self::hasValue($exercise['duration'] ?? null);
|
||||
}
|
||||
|
||||
return [
|
||||
'blood' => $bloodDone,
|
||||
'diet' => $dietDone,
|
||||
'exercise' => $exerciseDone,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,bool>> $taskAwards
|
||||
* @return array<int,array{id:string,name:string,points:int,completed:bool,claimed:bool}>
|
||||
*/
|
||||
public static function buildTodayTasks(int $diagnosisId, array $taskAwards): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$awards = isset($taskAwards[$today]) && is_array($taskAwards[$today]) ? $taskAwards[$today] : [];
|
||||
$completion = self::evaluateTaskCompletion($diagnosisId);
|
||||
$list = [];
|
||||
|
||||
foreach (self::$taskDefs as $id => $def) {
|
||||
$list[] = [
|
||||
'id' => $id,
|
||||
'name' => $def['name'],
|
||||
'points' => $def['points'],
|
||||
'completed' => !empty($completion[$id]),
|
||||
'claimed' => !empty($awards[$id]),
|
||||
];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/** 与前端 tongji/utils/treeLevels.js 保持一致 */
|
||||
protected const TREE_MAX_LEVEL = 9;
|
||||
protected const TREE_XP_PER_LEVEL = 50;
|
||||
|
||||
protected static function treeMeta(int $points): array
|
||||
{
|
||||
$points = max(0, (int) $points);
|
||||
$level = min(self::TREE_MAX_LEVEL, (int) floor($points / self::TREE_XP_PER_LEVEL));
|
||||
$names = ['种子眠', '破土芽', '展两叶', '小树苗', '青枝繁', '拔节高', '稳糖冠', '初绽香', '漫开花', '圆满树'];
|
||||
$xpIn = $level >= self::TREE_MAX_LEVEL ? self::TREE_XP_PER_LEVEL : ($points % self::TREE_XP_PER_LEVEL);
|
||||
$progress = $level >= self::TREE_MAX_LEVEL
|
||||
? 100
|
||||
: (int) round(($xpIn / self::TREE_XP_PER_LEVEL) * 100);
|
||||
$nextName = $level < self::TREE_MAX_LEVEL ? ($names[$level + 1] ?? '') : '';
|
||||
$pointsToNext = $level >= self::TREE_MAX_LEVEL
|
||||
? 0
|
||||
: (self::TREE_XP_PER_LEVEL - $xpIn);
|
||||
|
||||
return [
|
||||
'tree_level' => $level,
|
||||
'tree_progress' => $progress,
|
||||
'tree_level_name' => $names[$level] ?? '种子眠',
|
||||
'tree_xp_in_level' => $xpIn,
|
||||
'tree_xp_need' => self::TREE_XP_PER_LEVEL,
|
||||
'tree_points_next' => $pointsToNext,
|
||||
'tree_next_name' => $nextName,
|
||||
'tree_is_max' => $level >= self::TREE_MAX_LEVEL,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取稳糖乐园状态(含今日任务)
|
||||
*/
|
||||
public static function getState(int $userId, int $diagnosisId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if (!self::assertOwned($userId, $diagnosisId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
$points = $row ? (int) $row['points'] : 0;
|
||||
$badges = $row ? self::decodeJsonArray((string) ($row['badges'] ?? '')) : [];
|
||||
$taskAwards = $row ? self::decodeJsonObject((string) ($row['task_awards'] ?? '')) : [];
|
||||
|
||||
$todayTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
|
||||
$claimable = 0;
|
||||
foreach ($todayTasks as $t) {
|
||||
if ($t['completed'] && !$t['claimed']) {
|
||||
$claimable += (int) $t['points'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge([
|
||||
'points' => $points,
|
||||
'badges' => $badges,
|
||||
'task_awards' => $taskAwards,
|
||||
'today_tasks' => $todayTasks,
|
||||
'claimable_points' => $claimable,
|
||||
], self::treeMeta($points));
|
||||
}
|
||||
|
||||
/**
|
||||
* 浇水:领取今日已完成且未领取的任务积分
|
||||
*/
|
||||
public static function waterTree(int $userId, int $diagnosisId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if (!self::assertOwned($userId, $diagnosisId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
$points = $row ? (int) $row['points'] : 0;
|
||||
$badges = $row ? self::decodeJsonArray((string) ($row['badges'] ?? '')) : [];
|
||||
$taskAwards = $row ? self::decodeJsonObject((string) ($row['task_awards'] ?? '')) : [];
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$todayTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
|
||||
$addedPoints = 0;
|
||||
$claimedIds = [];
|
||||
$pending = [];
|
||||
|
||||
if (!isset($taskAwards[$today]) || !is_array($taskAwards[$today])) {
|
||||
$taskAwards[$today] = [];
|
||||
}
|
||||
|
||||
foreach ($todayTasks as $task) {
|
||||
if ($task['completed'] && !$task['claimed']) {
|
||||
$id = (string) $task['id'];
|
||||
$taskAwards[$today][$id] = true;
|
||||
$addedPoints += (int) $task['points'];
|
||||
$claimedIds[] = $id;
|
||||
} elseif (!$task['completed']) {
|
||||
$pending[] = [
|
||||
'id' => $task['id'],
|
||||
'name' => $task['name'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($addedPoints <= 0) {
|
||||
$claimable = 0;
|
||||
foreach ($todayTasks as $t) {
|
||||
if ($t['completed'] && !$t['claimed']) {
|
||||
$claimable += (int) $t['points'];
|
||||
}
|
||||
}
|
||||
$refreshedTasks = self::buildTodayTasks($diagnosisId, $taskAwards);
|
||||
return [
|
||||
'added_points' => 0,
|
||||
'claimed_tasks' => [],
|
||||
'claimable_points' => $claimable,
|
||||
'pending_tasks' => $pending,
|
||||
'points' => $points,
|
||||
'badges' => $badges,
|
||||
'task_awards' => $taskAwards,
|
||||
'today_tasks' => $refreshedTasks,
|
||||
'message' => $claimable > 0 ? '请先点击浇水领取积分' : (count($pending) ? '请先完成今日任务再浇水' : '今日奖励已全部领取'),
|
||||
] + self::treeMeta($points);
|
||||
}
|
||||
|
||||
$newPoints = $points + $addedPoints;
|
||||
$saved = self::saveState($userId, $diagnosisId, $newPoints, $badges, $taskAwards);
|
||||
if ($saved === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$refreshed = self::buildTodayTasks($diagnosisId, $taskAwards);
|
||||
|
||||
return [
|
||||
'added_points' => $addedPoints,
|
||||
'claimed_tasks' => $claimedIds,
|
||||
'claimable_points' => 0,
|
||||
'pending_tasks' => $pending,
|
||||
'points' => $newPoints,
|
||||
'badges' => $badges,
|
||||
'task_awards' => $taskAwards,
|
||||
'today_tasks' => $refreshed,
|
||||
'message' => "浇水成功,获得 {$addedPoints} 稳糖积分",
|
||||
] + self::treeMeta($newPoints);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $badges
|
||||
* @param array<string,array<string,bool>> $taskAwards
|
||||
*/
|
||||
public static function saveState(int $userId, int $diagnosisId, int $points, array $badges, array $taskAwards): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if (!self::assertOwned($userId, $diagnosisId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$points = max(0, (int) $points);
|
||||
$badges = array_values(array_unique(array_filter(array_map('strval', $badges))));
|
||||
if (!is_array($taskAwards)) {
|
||||
$taskAwards = [];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$row = DailyGamify::where('diagnosis_id', $diagnosisId)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
$data = [
|
||||
'points' => $points,
|
||||
'badges' => json_encode($badges, JSON_UNESCAPED_UNICODE),
|
||||
'task_awards' => json_encode($taskAwards, JSON_UNESCAPED_UNICODE),
|
||||
'update_time' => $now,
|
||||
];
|
||||
|
||||
if ($row) {
|
||||
DailyGamify::where('id', (int) $row['id'])->update($data);
|
||||
} else {
|
||||
$data['diagnosis_id'] = $diagnosisId;
|
||||
$data['user_id'] = $userId;
|
||||
$data['create_time'] = $now;
|
||||
DailyGamify::create($data);
|
||||
}
|
||||
|
||||
return [
|
||||
'points' => $points,
|
||||
'badges' => $badges,
|
||||
'task_awards' => $taskAwards,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地缓存迁到服务端:取 points/badges/task_awards 的较大合并
|
||||
*/
|
||||
public static function mergeFromClient(int $userId, int $diagnosisId, int $points, array $badges, array $taskAwards): array|false
|
||||
{
|
||||
$server = self::getState($userId, $diagnosisId);
|
||||
if ($server === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mergedPoints = max((int) $server['points'], max(0, $points));
|
||||
$mergedBadges = array_values(array_unique(array_merge($server['badges'], $badges)));
|
||||
$mergedAwards = $server['task_awards'];
|
||||
foreach ($taskAwards as $date => $tasks) {
|
||||
if (!is_array($tasks)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($mergedAwards[$date]) || !is_array($mergedAwards[$date])) {
|
||||
$mergedAwards[$date] = [];
|
||||
}
|
||||
foreach ($tasks as $taskId => $flag) {
|
||||
if ($flag) {
|
||||
$mergedAwards[$date][(string) $taskId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::saveState($userId, $diagnosisId, $mergedPoints, $mergedBadges, $mergedAwards);
|
||||
}
|
||||
|
||||
protected static function decodeJsonArray(string $json): array
|
||||
{
|
||||
if ($json === '') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($json, true);
|
||||
return is_array($data) ? array_values(array_map('strval', $data)) : [];
|
||||
}
|
||||
|
||||
protected static function decodeJsonObject(string $json): array
|
||||
{
|
||||
if ($json === '') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($json, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
use app\common\model\tcm\DailyShareInvite;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
|
||||
/**
|
||||
* 日常记录分享邀请码(当日有效、脱敏预览)
|
||||
*/
|
||||
class DailyShareLogic
|
||||
{
|
||||
protected static string $error = '';
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function setError(string $msg): bool
|
||||
{
|
||||
self::$error = $msg;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成当日邀请码(分享人须已拥有诊单)
|
||||
*/
|
||||
public static function createInvite(int $userId, int $diagnosisId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::setError('请先登录') ? false : false;
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return self::setError('诊单ID不能为空') ? false : false;
|
||||
}
|
||||
|
||||
$owned = \think\facade\Db::name('diagnosis_view_records')
|
||||
->where('user_id', $userId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('delete_time', null)
|
||||
->find();
|
||||
if (!$owned) {
|
||||
return self::setError('无权分享该诊单') ? false : false;
|
||||
}
|
||||
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if (!$diagnosis) {
|
||||
return self::setError('诊单不存在') ? false : false;
|
||||
}
|
||||
|
||||
$inviteDate = date('Y-m-d');
|
||||
$code = self::generateUniqueCode();
|
||||
|
||||
DailyShareInvite::create([
|
||||
'invite_code' => $code,
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'user_id' => $userId,
|
||||
'invite_date' => $inviteDate,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'invite_code' => $code,
|
||||
'invite_date' => $inviteDate,
|
||||
'expires_hint' => '邀请码仅今日有效,明日将无法查看',
|
||||
'share_path' => '/tongji/pages/index?from=share&invite_code=' . $code,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭邀请码查看分享战报(含近 7 日血糖/血压记录,仅当日邀请码有效)
|
||||
*/
|
||||
public static function previewByInviteCode(string $inviteCode, string $viewerKey = ''): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
$inviteCode = strtoupper(trim($inviteCode));
|
||||
if ($inviteCode === '') {
|
||||
return self::setError('邀请码不能为空') ? false : false;
|
||||
}
|
||||
|
||||
$row = DailyShareInvite::where('invite_code', $inviteCode)->find();
|
||||
if (!$row) {
|
||||
return self::setError('邀请码无效或已失效') ? false : false;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
if ((string) $row['invite_date'] !== $today) {
|
||||
return self::setError('邀请码已过期,仅可在分享当天查看') ? false : false;
|
||||
}
|
||||
|
||||
$diagnosisId = (int) $row['diagnosis_id'];
|
||||
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
|
||||
if (!$diagnosis) {
|
||||
return self::setError('诊单不存在') ? false : false;
|
||||
}
|
||||
|
||||
$age = (int) ($diagnosis['age'] ?? 0);
|
||||
$stats = self::buildDesensitizedWeekStats($diagnosisId, $age);
|
||||
$name = trim((string) ($diagnosis['patient_name'] ?? ''));
|
||||
$label = self::maskPatientName($name);
|
||||
|
||||
$likeMeta = DailyFamilyLikeLogic::metaForInvite($inviteCode, $viewerKey);
|
||||
|
||||
return array_merge($stats, $likeMeta, [
|
||||
'invite_code' => $inviteCode,
|
||||
'invite_date' => $today,
|
||||
'expires_hint' => '本邀请码仅今日有效,明日将无法查看',
|
||||
'viewer_notice' => '您正在查看家人分享的近7日血糖记录',
|
||||
'patient_label' => $label,
|
||||
]);
|
||||
}
|
||||
|
||||
protected static function generateUniqueCode(): string
|
||||
{
|
||||
for ($i = 0; $i < 8; $i++) {
|
||||
$code = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
|
||||
if (!DailyShareInvite::where('invite_code', $code)->find()) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
return strtoupper(substr(uniqid('', true), -8));
|
||||
}
|
||||
|
||||
protected static function maskPatientName(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return '家人';
|
||||
}
|
||||
$len = mb_strlen($name, 'UTF-8');
|
||||
if ($len <= 1) {
|
||||
return $name . '*';
|
||||
}
|
||||
if ($len === 2) {
|
||||
return mb_substr($name, 0, 1, 'UTF-8') . '*';
|
||||
}
|
||||
return mb_substr($name, 0, 1, 'UTF-8') . '*' . mb_substr($name, -1, 1, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 近 7 天习惯统计 + 每日血糖/血压记录(供家人分享页展示)
|
||||
*/
|
||||
public static function buildDesensitizedWeekStats(int $diagnosisId, int $age = 0): array
|
||||
{
|
||||
$today = new \DateTime('today');
|
||||
$dayKeys = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$d = clone $today;
|
||||
$d->modify("-{$i} days");
|
||||
$dayKeys[] = $d->format('Y-m-d');
|
||||
}
|
||||
|
||||
$startTs = strtotime($dayKeys[0] . ' 00:00:00');
|
||||
$endTs = strtotime($dayKeys[6] . ' 23:59:59');
|
||||
|
||||
$rows = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $startTs)
|
||||
->where('record_date', '<=', $endTs)
|
||||
->field('record_date,fasting_blood_sugar,postprandial_blood_sugar,other_blood_sugar,systolic_pressure,diastolic_pressure')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$byDate = [];
|
||||
foreach ($rows as $r) {
|
||||
$key = date('Y-m-d', (int) $r['record_date']);
|
||||
if (!isset($byDate[$key])) {
|
||||
$byDate[$key] = $r;
|
||||
} else {
|
||||
foreach (['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar', 'systolic_pressure', 'diastolic_pressure'] as $f) {
|
||||
if (self::hasValue($r[$f]) && !self::hasValue($byDate[$key][$f])) {
|
||||
$byDate[$key][$f] = $r[$f];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$recordDays = 0;
|
||||
$completeDays = 0;
|
||||
foreach ($dayKeys as $key) {
|
||||
$b = $byDate[$key] ?? null;
|
||||
if (!$b || !self::dayHasBlood($b)) {
|
||||
continue;
|
||||
}
|
||||
$recordDays++;
|
||||
if (self::hasValue($b['fasting_blood_sugar']) && self::hasValue($b['postprandial_blood_sugar'])) {
|
||||
$completeDays++;
|
||||
}
|
||||
}
|
||||
|
||||
$streakDays = self::calcStreakDays($diagnosisId);
|
||||
|
||||
$tree = self::treeMeta($recordDays);
|
||||
$quote = self::buildQuote($recordDays, $completeDays);
|
||||
|
||||
$weekdayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
$dailyRecords = [];
|
||||
foreach ($dayKeys as $key) {
|
||||
$b = $byDate[$key] ?? null;
|
||||
$dt = new \DateTime($key);
|
||||
$w = (int) $dt->format('w');
|
||||
$has = $b && self::dayHasBlood($b);
|
||||
|
||||
$fasting = $has ? self::formatSugarValue($b['fasting_blood_sugar'] ?? null) : null;
|
||||
$post = $has ? self::formatSugarValue($b['postprandial_blood_sugar'] ?? null) : null;
|
||||
$other = $has ? self::formatSugarValue($b['other_blood_sugar'] ?? null) : null;
|
||||
$sys = $has ? self::formatSugarValue($b['systolic_pressure'] ?? null) : null;
|
||||
$dia = $has ? self::formatSugarValue($b['diastolic_pressure'] ?? null) : null;
|
||||
|
||||
$dailyRecords[] = [
|
||||
'date' => $key,
|
||||
'date_label' => $dt->format('n') . '/' . $dt->format('j'),
|
||||
'weekday' => '周' . $weekdayLabels[$w],
|
||||
'has_record' => $has,
|
||||
'fasting' => $fasting,
|
||||
'fasting_high' => self::isHighFasting($fasting, $age),
|
||||
'postprandial' => $post,
|
||||
'postprandial_high' => self::isHighPostprandial($post, $age),
|
||||
'other' => $other,
|
||||
'other_high' => self::isHighPostprandial($other, $age),
|
||||
'systolic' => $sys,
|
||||
'diastolic' => $dia,
|
||||
'bp_high' => self::isHighBp($sys, $dia),
|
||||
'bp_text' => self::formatBpText($sys, $dia),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'week_label' => $today->format('Y') . '年 第' . self::weekOfYear($today) . '周',
|
||||
'record_days' => $recordDays,
|
||||
'complete_days' => $completeDays,
|
||||
'streak_days' => $streakDays,
|
||||
'quote' => $quote,
|
||||
'tree_emoji' => $tree['emoji'],
|
||||
'tree_title' => $tree['title'],
|
||||
'tree_desc' => $tree['desc'],
|
||||
'tree_level' => $tree['level'],
|
||||
'daily_records' => $dailyRecords,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function formatSugarValue($v): ?float
|
||||
{
|
||||
if (!self::hasValue($v)) {
|
||||
return null;
|
||||
}
|
||||
return round((float) $v, 1);
|
||||
}
|
||||
|
||||
protected static function getBloodSugarThresholds(int $age): ?array
|
||||
{
|
||||
if ($age <= 0) {
|
||||
return null;
|
||||
}
|
||||
if ($age < 50) {
|
||||
return ['fasting' => 7.0, 'postprandial' => 9.0];
|
||||
}
|
||||
return ['fasting' => 8.0, 'postprandial' => 10.0];
|
||||
}
|
||||
|
||||
protected static function isHighFasting(?float $v, int $age): bool
|
||||
{
|
||||
$t = self::getBloodSugarThresholds($age);
|
||||
return $v !== null && $t !== null && $v >= $t['fasting'];
|
||||
}
|
||||
|
||||
protected static function isHighPostprandial(?float $v, int $age): bool
|
||||
{
|
||||
$t = self::getBloodSugarThresholds($age);
|
||||
return $v !== null && $t !== null && $v >= $t['postprandial'];
|
||||
}
|
||||
|
||||
protected static function isHighBp(?float $systolic, ?float $diastolic): bool
|
||||
{
|
||||
return ($systolic !== null && $systolic > 140)
|
||||
|| ($diastolic !== null && $diastolic > 90);
|
||||
}
|
||||
|
||||
protected static function formatBpText(?float $systolic, ?float $diastolic): string
|
||||
{
|
||||
if ($systolic === null && $diastolic === null) {
|
||||
return '';
|
||||
}
|
||||
$s = $systolic !== null ? (string) (int) round($systolic) : '—';
|
||||
$d = $diastolic !== null ? (string) (int) round($diastolic) : '—';
|
||||
return $s . '/' . $d;
|
||||
}
|
||||
|
||||
protected static function hasValue($v): bool
|
||||
{
|
||||
if ($v === null || $v === '' || $v === '0' || $v === 0) {
|
||||
return false;
|
||||
}
|
||||
return is_numeric($v) && (float) $v > 0;
|
||||
}
|
||||
|
||||
protected static function dayHasBlood(array $b): bool
|
||||
{
|
||||
return self::hasValue($b['fasting_blood_sugar'] ?? null)
|
||||
|| self::hasValue($b['postprandial_blood_sugar'] ?? null)
|
||||
|| self::hasValue($b['other_blood_sugar'] ?? null)
|
||||
|| self::hasValue($b['systolic_pressure'] ?? null)
|
||||
|| self::hasValue($b['diastolic_pressure'] ?? null);
|
||||
}
|
||||
|
||||
protected static function calcStreakDays(int $diagnosisId): int
|
||||
{
|
||||
$today = new \DateTime('today');
|
||||
$count = 0;
|
||||
for ($i = 0; $i < 90; $i++) {
|
||||
$d = clone $today;
|
||||
$d->modify("-{$i} days");
|
||||
$key = $d->format('Y-m-d');
|
||||
$start = strtotime($key . ' 00:00:00');
|
||||
$end = strtotime($key . ' 23:59:59');
|
||||
$exists = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $start)
|
||||
->where('record_date', '<=', $end)
|
||||
->whereRaw('(fasting_blood_sugar > 0 OR postprandial_blood_sugar > 0 OR other_blood_sugar > 0 OR systolic_pressure > 0 OR diastolic_pressure > 0)')
|
||||
->find();
|
||||
if ($exists) {
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
if ($i === 0) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
protected static function treeMeta(int $recordDays): array
|
||||
{
|
||||
if ($recordDays >= 7) {
|
||||
return ['level' => 4, 'emoji' => '🌸', 'title' => '控糖树 · 开花啦', 'desc' => '本周每天都记录了'];
|
||||
}
|
||||
if ($recordDays >= 5) {
|
||||
return ['level' => 3, 'emoji' => '🌳', 'title' => '控糖树 · 枝繁叶茂', 'desc' => "本周 {$recordDays}/7 天有记录"];
|
||||
}
|
||||
if ($recordDays >= 3) {
|
||||
return ['level' => 2, 'emoji' => '🌿', 'title' => '控糖树 · 茁壮成长', 'desc' => "本周 {$recordDays}/7 天有记录"];
|
||||
}
|
||||
if ($recordDays >= 1) {
|
||||
return ['level' => 1, 'emoji' => '🌱', 'title' => '控糖树 · 破土发芽', 'desc' => '本周已开始记录'];
|
||||
}
|
||||
return ['level' => 0, 'emoji' => '🪴', 'title' => '控糖树 · 等待浇水', 'desc' => '本周暂无记录'];
|
||||
}
|
||||
|
||||
protected static function buildQuote(int $recordDays, int $completeDays): string
|
||||
{
|
||||
if ($recordDays >= 7) {
|
||||
return '本周每天都留下了记录,这份自律值得骄傲!';
|
||||
}
|
||||
if ($completeDays >= 5) {
|
||||
return "本周有 {$completeDays} 天完成了空腹+餐后记录,习惯越来越稳。";
|
||||
}
|
||||
if ($recordDays >= 4) {
|
||||
return "本周已记录 {$recordDays} 天,坚持就是胜利。";
|
||||
}
|
||||
if ($recordDays > 0) {
|
||||
return '好的开始!继续记录会更稳。';
|
||||
}
|
||||
return '分享者本周尚未记录,鼓励 Ta 每天记一笔。';
|
||||
}
|
||||
|
||||
protected static function weekOfYear(\DateTime $date): int
|
||||
{
|
||||
return (int) $date->format('W');
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use think\console\Output;
|
||||
* php think gancao:sync-logistics 默认拉 100 单
|
||||
* php think gancao:sync-logistics --limit=200 自定义拉取上限
|
||||
* php think gancao:sync-logistics --order-id=123 只跑指定订单
|
||||
* php think gancao:sync-logistics -t SF1234567890 按快递单号走快递100 查询并落库
|
||||
* php think gancao:sync-logistics --detail 打印每单详细
|
||||
*
|
||||
* 建议 crontab(每 30 分钟执行一次):
|
||||
@@ -40,6 +41,7 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
->setDescription('同步甘草订单的物流路由(GET_TASK_ROUTE_LIST)到本地物流追踪表')
|
||||
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '本次最多处理多少条订单', 100)
|
||||
->addOption('order-id', null, Option::VALUE_OPTIONAL, '只同步指定 prescription_order.id', null)
|
||||
->addOption('tracking-number', 't', Option::VALUE_REQUIRED, '按快递单号走快递100 查询并落库')
|
||||
->addOption('detail', 'd', Option::VALUE_NONE, '打印每单详细结果');
|
||||
}
|
||||
|
||||
@@ -48,26 +50,43 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$onlyOrderId = $input->getOption('order-id');
|
||||
$onlyOrderId = $onlyOrderId !== null ? (int) $onlyOrderId : null;
|
||||
$trackingNumber = trim((string) $input->getOption('tracking-number'));
|
||||
$verbose = (bool) $input->getOption('detail');
|
||||
|
||||
if ($trackingNumber !== '' && $onlyOrderId !== null) {
|
||||
$output->error('请勿同时使用 --tracking-number 与 --order-id');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$output->writeln('========================================');
|
||||
$output->writeln('甘草物流路由同步');
|
||||
$output->writeln($trackingNumber !== '' ? '快递100 物流查询' : '甘草物流路由同步');
|
||||
$output->writeln('========================================');
|
||||
|
||||
if (!GancaoScmRecipelService::isConfigured()) {
|
||||
if ($trackingNumber === '' && !GancaoScmRecipelService::isConfigured()) {
|
||||
$output->error('甘草 SCM 未配置:' . GancaoScmRecipelService::whyNotConfigured());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
$output->writeln('开始同步...(limit=' . $limit . ($onlyOrderId ? ', order_id=' . $onlyOrderId : '') . ')');
|
||||
if ($trackingNumber !== '') {
|
||||
$output->writeln('开始查询...(快递100,tracking_number=' . $trackingNumber . ')');
|
||||
} else {
|
||||
$output->writeln('开始同步...(limit=' . $limit . ($onlyOrderId ? ', order_id=' . $onlyOrderId : '') . ')');
|
||||
}
|
||||
|
||||
try {
|
||||
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
|
||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(200);
|
||||
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
|
||||
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
|
||||
$stats['reconcile_lines'] = $recon['lines'] ?? [];
|
||||
if ($trackingNumber !== '') {
|
||||
$stats = ExpressTrackingService::queryKuaidiByTrackingNumber($trackingNumber);
|
||||
$stats['reconcile_cleared'] = 0;
|
||||
$stats['reconcile_scanned'] = 0;
|
||||
$stats['reconcile_lines'] = [];
|
||||
} else {
|
||||
$stats = GancaoLogisticsRouteService::syncBatch($limit, $onlyOrderId);
|
||||
$recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(2000);
|
||||
$stats['reconcile_cleared'] = (int) ($recon['cleared'] ?? 0);
|
||||
$stats['reconcile_scanned'] = (int) ($recon['scanned'] ?? 0);
|
||||
$stats['reconcile_lines'] = $recon['lines'] ?? [];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->error('同步异常:' . $e->getMessage());
|
||||
return 1;
|
||||
@@ -81,7 +100,7 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
foreach ($stats['details'] as $row) {
|
||||
$tag = !empty($row['success']) ? '[OK]' : '[FAIL]';
|
||||
$line = sprintf(
|
||||
'%s order_id=%s order_no=%s app_order_no=%s tn=%s state=%s traces=+%s msg=%s',
|
||||
'%s order_id=%s order_no=%s app_order_no=%s tn=%s state=%s traces=+%s source=%s msg=%s',
|
||||
$tag,
|
||||
$row['order_id'] ?? '',
|
||||
$row['order_no'] ?? '',
|
||||
@@ -89,6 +108,7 @@ class GancaoSyncLogisticsRoute extends Command
|
||||
$row['tracking_number'] ?? '',
|
||||
$row['state'] ?? '',
|
||||
$row['traces'] ?? 0,
|
||||
$row['source'] ?? '',
|
||||
$row['message'] ?? ''
|
||||
);
|
||||
$output->writeln($line);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use app\common\service\FileService;
|
||||
|
||||
class AssetResource extends BaseModel
|
||||
{
|
||||
protected $name = 'asset_resource';
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
public function getFileUrlAttr($value)
|
||||
{
|
||||
return trim($value) ? FileService::getFileUrl($value) : '';
|
||||
}
|
||||
|
||||
public function setFileUrlAttr($value)
|
||||
{
|
||||
return trim($value) ? FileService::setFileUrl($value) : '';
|
||||
}
|
||||
|
||||
public function getCoverUrlAttr($value)
|
||||
{
|
||||
return trim($value) ? FileService::getFileUrl($value) : '';
|
||||
}
|
||||
|
||||
public function setCoverUrlAttr($value)
|
||||
{
|
||||
return trim($value) ? FileService::setFileUrl($value) : '';
|
||||
}
|
||||
|
||||
// Relation to users via asset_user_resource
|
||||
public function users()
|
||||
{
|
||||
return $this->belongsToMany(AssetUser::class, AssetUserResource::class, 'user_id', 'resource_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
class AssetUser extends BaseModel
|
||||
{
|
||||
protected $name = 'asset_user';
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
// Optional: Hide password when returning array/json
|
||||
protected $hidden = ['password'];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace app\common\model;
|
||||
use think\model\Pivot;
|
||||
|
||||
class AssetUserResource extends Pivot
|
||||
{
|
||||
protected $name = 'asset_user_resource';
|
||||
protected $autoWriteTimestamp = 'create_time';
|
||||
protected $updateTime = false;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\stats;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
class PersonalAccountCost extends BaseModel
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
protected $name = 'personal_account_cost';
|
||||
|
||||
protected $deleteTime = 'delete_time';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
protected $createTime = 'create_time';
|
||||
|
||||
protected $updateTime = 'update_time';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\stats;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
class PersonalYeji extends BaseModel
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
protected $name = 'personal_yeji';
|
||||
|
||||
protected $deleteTime = 'delete_time';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
protected $createTime = 'create_time';
|
||||
|
||||
protected $updateTime = 'update_time';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 日常记录家人点赞
|
||||
*/
|
||||
class DailyFamilyLike extends BaseModel
|
||||
{
|
||||
protected $name = 'tcm_daily_family_like';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 日常记录游戏化(稳糖分 / 勋章 / 任务领奖)
|
||||
*/
|
||||
class DailyGamify extends BaseModel
|
||||
{
|
||||
protected $name = 'tcm_daily_gamify';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 日常记录分享邀请码
|
||||
*/
|
||||
class DailyShareInvite extends BaseModel
|
||||
{
|
||||
protected $name = 'tcm_daily_share_invite';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class Prescription extends BaseModel
|
||||
protected $deleteTime = 'delete_time';
|
||||
protected $dateFormat = false;
|
||||
|
||||
protected $json = ['herbs', 'case_record'];
|
||||
protected $json = ['herbs', 'case_record', 'aux_usage'];
|
||||
protected $jsonAssoc = true;
|
||||
|
||||
// 字段类型转换
|
||||
|
||||
@@ -19,6 +19,7 @@ class DirectUploadService
|
||||
{
|
||||
/** 视频允许的扩展名(沿用 config/project.file_video) */
|
||||
public const TYPE_VIDEO = 'video';
|
||||
public const TYPE_VOICE = 'voice';
|
||||
|
||||
/** 默认凭证有效期 30 分钟 */
|
||||
public const DEFAULT_DURATION = 1800;
|
||||
@@ -26,6 +27,7 @@ class DirectUploadService
|
||||
/** 允许扩展类型 → 大小上限(字节) */
|
||||
private const MAX_SIZE = [
|
||||
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
|
||||
self::TYPE_VOICE => 500 * 1024 * 1024, // 500MB
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -144,6 +146,7 @@ class DirectUploadService
|
||||
{
|
||||
return match ($type) {
|
||||
self::TYPE_VIDEO => FileEnum::VIDEO_TYPE,
|
||||
self::TYPE_VOICE => FileEnum::FILE_TYPE,
|
||||
default => FileEnum::FILE_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user