Compare commits
89
Commits
dd-5-20
...
master-5-27
| 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 | ||
|
|
61d14d1b45 | ||
|
|
a83c583a28 | ||
|
|
485ba7a380 | ||
|
|
3e4039efb0 |
@@ -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 })
|
||||
}
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -2375,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">
|
||||
@@ -2661,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 且不具备处方审核角色)仅能用「支付单审核」筛选
|
||||
*/
|
||||
@@ -2901,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 = [
|
||||
@@ -3014,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) {
|
||||
@@ -3049,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
|
||||
@@ -3240,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()
|
||||
}
|
||||
@@ -3757,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)
|
||||
|
||||
@@ -3812,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)
|
||||
@@ -3977,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4120,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 = []
|
||||
@@ -4981,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5272,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('、')
|
||||
@@ -5313,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 保持一致
|
||||
* ============================================================ */
|
||||
@@ -5404,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
|
||||
@@ -5462,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 {
|
||||
@@ -6223,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()
|
||||
}
|
||||
|
||||
@@ -3440,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)
|
||||
@@ -3534,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3677,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 = []
|
||||
@@ -3693,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)
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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(与部门表复诊列、排行榜复诊列同口径)。
|
||||
@@ -323,6 +428,50 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
||||
return $diagnosisPatientId > 0 && $diagnosisPatientId === $patientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业绩看板侧栏:按部门行点合计业绩/接诊诊单(assistant_dept_id),组长等应看到组内全员订单。
|
||||
*/
|
||||
private function isYejiDrawerDeptPerformanceScope(): bool
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助角色统计:仅「订单创建人=本人」计入
|
||||
*/
|
||||
@@ -341,7 +490,10 @@ 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);
|
||||
@@ -585,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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单权限:非全量角色可额外查看「关联处方开方人为本账号」的业务订单(医助代建单)
|
||||
*
|
||||
@@ -156,6 +273,24 @@ class PrescriptionOrderLogic
|
||||
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;
|
||||
}
|
||||
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
|
||||
foreach ($perms as $p) {
|
||||
if (is_string($p) && str_starts_with($p, 'tcm.prescriptionOrder/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PrescriptionOrder $row
|
||||
*/
|
||||
@@ -164,6 +299,9 @@ class PrescriptionOrderLogic
|
||||
if (self::canSeeAllPrescriptionOrders($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
if (self::hasPrescriptionOrderMenuAccess($adminInfo)) {
|
||||
return true;
|
||||
}
|
||||
if ((int) $row->creator_id === $adminId) {
|
||||
return true;
|
||||
}
|
||||
@@ -630,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')
|
||||
->whereIn('status', [2,5])
|
||||
->whereIn('status', [2, 4, 5])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
@@ -688,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();
|
||||
@@ -1366,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;
|
||||
@@ -2435,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);
|
||||
|
||||
@@ -2446,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();
|
||||
@@ -2462,6 +2613,9 @@ class PrescriptionOrderLogic
|
||||
$logLine = '订单完成,状态变更为「' . $label . '」,实付金额更新为 ¥' . $linkedPayPaidTotal . $unassignSummary;
|
||||
} else {
|
||||
$logLine = '订单处理完成,状态变更为「' . $label . '」' . $unassignSummary;
|
||||
if ($targetFulfillmentStatus === 10 && $refundedPayCount > 0) {
|
||||
$logLine .= ',关联支付单 ' . $refundedPayCount . ' 笔已标记为已退款';
|
||||
}
|
||||
}
|
||||
self::writeLog(
|
||||
$id,
|
||||
@@ -2519,7 +2673,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:诊单医助在 admin_dept 中的部门路径(多部门「;」、路径内「 / 」)
|
||||
* 导出用:管理员在 admin_dept 中的部门路径(多部门「;」、路径内「 / 」;业务订单导出取 creator_id)
|
||||
*/
|
||||
public static function formatAssistantDeptPathForExport(int $assistantAdminId): string
|
||||
{
|
||||
@@ -2809,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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class ExpressTrackService
|
||||
$carrier = $resolved['carrier'];
|
||||
$kuaidiCom = $resolved['kuaidi_com'];
|
||||
$label = $resolved['label'];
|
||||
$comCandidates = self::kuaidiComCandidates($kuaidiCom, $num);
|
||||
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
|
||||
@@ -75,6 +76,74 @@ class ExpressTrackService
|
||||
return $out;
|
||||
}
|
||||
|
||||
$lastFail = null;
|
||||
foreach ($comCandidates as $tryCom) {
|
||||
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
|
||||
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
|
||||
return $tryOut;
|
||||
}
|
||||
$lastFail = $tryOut;
|
||||
}
|
||||
|
||||
return $lastFail ?? $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据运单号形态纠正承运商(避免 express_tracking 误存 sf 导致京东单查不出)
|
||||
*/
|
||||
public static function normalizeExpressCompanyCode(string $trackingNumber, string $storedCompany = 'auto'): string
|
||||
{
|
||||
$byNumber = self::detectCarrierFromNumber($trackingNumber);
|
||||
if ($byNumber === null) {
|
||||
$ec = strtolower(trim($storedCompany));
|
||||
|
||||
return in_array($ec, ['sf', 'jd', 'jt', 'jtexpress', 'auto'], true) ? $ec : 'auto';
|
||||
}
|
||||
|
||||
$ec = strtolower(trim($storedCompany));
|
||||
$byEc = self::carrierFromExpressCode($ec);
|
||||
if ($byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
|
||||
return $byNumber['carrier'];
|
||||
}
|
||||
|
||||
return $byNumber['carrier'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $cfg
|
||||
* @return array{
|
||||
* carrier: string,
|
||||
* carrier_label: string,
|
||||
* kuaidi_com: string,
|
||||
* traces: list<array{time:string,context:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* official_url: string
|
||||
* }
|
||||
*/
|
||||
private static function queryKuaidiOnce(
|
||||
array $cfg,
|
||||
string $kuaidiCom,
|
||||
string $num,
|
||||
string $phoneForKuaidi,
|
||||
string $carrier,
|
||||
string $label
|
||||
): array {
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
$out = [
|
||||
'carrier' => $carrier,
|
||||
'carrier_label' => $label,
|
||||
'kuaidi_com' => $kuaidiCom,
|
||||
'traces' => [],
|
||||
'state' => '',
|
||||
'state_text' => '',
|
||||
'source' => 'kuaidi100',
|
||||
'hint' => '',
|
||||
'official_url' => $officialUrl,
|
||||
];
|
||||
|
||||
$paramArr = [
|
||||
'com' => $kuaidiCom,
|
||||
'num' => $num,
|
||||
@@ -98,7 +167,7 @@ class ExpressTrackService
|
||||
$raw = self::httpPostForm($url, $postBody);
|
||||
if ($raw === null || $raw === '') {
|
||||
$out['hint'] = '快递100接口无响应,请稍后重试或使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 empty response', ['num' => $num]);
|
||||
Log::warning('ExpressTrackService kuaidi100 empty response', ['num' => $num, 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
@@ -106,7 +175,7 @@ class ExpressTrackService
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
$out['hint'] = '快递100返回异常,请使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 invalid json', ['raw' => mb_substr($raw, 0, 500)]);
|
||||
Log::warning('ExpressTrackService kuaidi100 invalid json', ['raw' => mb_substr($raw, 0, 500), 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
@@ -114,19 +183,16 @@ class ExpressTrackService
|
||||
if (isset($json['result']) && $json['result'] === false) {
|
||||
$msg = (string) ($json['message'] ?? '查询失败');
|
||||
$returnCode = (string) ($json['returnCode'] ?? '');
|
||||
|
||||
// 特殊处理"找不到对应公司"错误
|
||||
if ($msg === '找不到对应公司' || $returnCode === '400') {
|
||||
$out['hint'] = '快递100暂不支持该快递公司或编码错误,请使用下方官网链接查询';
|
||||
} else {
|
||||
$out['hint'] = $msg;
|
||||
}
|
||||
|
||||
Log::info('ExpressTrackService kuaidi100 business fail', [
|
||||
'message' => $msg,
|
||||
'returnCode' => $returnCode,
|
||||
'num' => $num,
|
||||
'com' => $kuaidiCom
|
||||
'com' => $kuaidiCom,
|
||||
]);
|
||||
|
||||
return $out;
|
||||
@@ -138,7 +204,7 @@ class ExpressTrackService
|
||||
}
|
||||
if (($json['message'] ?? '') !== 'ok' && $data === []) {
|
||||
$out['hint'] = (string) ($json['message'] ?? '未查到轨迹');
|
||||
Log::info('ExpressTrackService kuaidi100 no data', ['json' => $json]);
|
||||
Log::info('ExpressTrackService kuaidi100 no data', ['json' => $json, 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
@@ -159,12 +225,35 @@ class ExpressTrackService
|
||||
$out['traces'] = $traces;
|
||||
$out['state'] = (string) ($json['state'] ?? '');
|
||||
$out['state_text'] = self::stateText($out['state']);
|
||||
$out['source'] = 'kuaidi100';
|
||||
$out['hint'] = $traces === [] ? '暂无轨迹节点,单号可能尚未揽收' : '';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function kuaidiComCandidates(string $primaryCom, string $num): array
|
||||
{
|
||||
$list = [$primaryCom];
|
||||
$byNumber = self::detectCarrierFromNumber($num);
|
||||
if ($byNumber !== null && !in_array($byNumber['kuaidi_com'], $list, true)) {
|
||||
$list[] = $byNumber['kuaidi_com'];
|
||||
}
|
||||
if (preg_match('/^JDVE/i', strtoupper($num)) && !in_array('jd', $list, true)) {
|
||||
$list[] = 'jd';
|
||||
}
|
||||
if (preg_match('/^(JD|JDV|JDK|JDEX)/i', strtoupper($num))) {
|
||||
foreach (['jingdong', 'jd'] as $c) {
|
||||
if (!in_array($c, $list, true)) {
|
||||
$list[] = $c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($list, static fn ($c) => $c !== '' && $c !== 'auto')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 快递100「phone」入参:有手动覆盖且不少于 4 位时用覆盖;否则用订单收货号码。
|
||||
* 对 11 位及以上数字取后 11 位作为手机号(去掉可能的前缀符号位)。
|
||||
@@ -191,32 +280,75 @@ class ExpressTrackService
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}|null
|
||||
*/
|
||||
private static function resolveCarrier(string $expressCompany, string $num): array
|
||||
private static function carrierFromExpressCode(string $expressCompany): ?array
|
||||
{
|
||||
$ec = strtolower(trim($expressCompany));
|
||||
if ($ec === 'sf') {
|
||||
if ($ec === 'sf' || $ec === 'shunfeng') {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运'];
|
||||
}
|
||||
if ($ec === 'jd') {
|
||||
if ($ec === 'jd' || $ec === 'jingdong') {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递'];
|
||||
}
|
||||
if ($ec === 'jt' || $ec === 'jtexpress') {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递'];
|
||||
}
|
||||
|
||||
$u = strtoupper($num);
|
||||
if (preg_match('/^SF\d/i', $num)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}|null
|
||||
*/
|
||||
private static function detectCarrierFromNumber(string $num): ?array
|
||||
{
|
||||
$n = trim($num);
|
||||
if ($n === '') {
|
||||
return null;
|
||||
}
|
||||
$u = strtoupper($n);
|
||||
if (preg_match('/^SF\d/i', $n)) {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^(JD|JDV|JDK|JDEX|JD[A-Z0-9])/i', $u)) {
|
||||
if (preg_match('/^JDVE/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JT\d{13}$/i', $num)) {
|
||||
if (preg_match('/^(JDK|JDV|JDEX)/i', $u) || preg_match('/^JD[A-Z0-9]{10,}/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东物流(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JT\d{13}$/i', $n)) {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递(单号识别)'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}
|
||||
*/
|
||||
private static function resolveCarrier(string $expressCompany, string $num): array
|
||||
{
|
||||
$byNumber = self::detectCarrierFromNumber($num);
|
||||
$byEc = self::carrierFromExpressCode($expressCompany);
|
||||
|
||||
if ($byNumber !== null && $byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
|
||||
Log::info('ExpressTrackService carrier mismatch, prefer tracking number', [
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_number' => $num,
|
||||
'stored_carrier' => $byEc['carrier'],
|
||||
'detected_carrier' => $byNumber['carrier'],
|
||||
]);
|
||||
|
||||
return $byNumber;
|
||||
}
|
||||
if ($byEc !== null) {
|
||||
return $byEc;
|
||||
}
|
||||
if ($byNumber !== null) {
|
||||
return $byNumber;
|
||||
}
|
||||
|
||||
return ['carrier' => 'auto', 'kuaidi_com' => 'auto', 'label' => '自动识别'];
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,10 @@ class ExpressTrackingService
|
||||
|
||||
$tracking->order_id = (int) ($params['order_id'] ?? 0);
|
||||
$tracking->order_type = (string) ($params['order_type'] ?? 'prescription');
|
||||
$tracking->express_company = (string) ($params['express_company'] ?? 'auto');
|
||||
$tracking->express_company = ExpressTrackService::normalizeExpressCompanyCode(
|
||||
$trackingNumber,
|
||||
(string) ($params['express_company'] ?? 'auto')
|
||||
);
|
||||
$tracking->recipient_phone = (string) ($params['recipient_phone'] ?? '');
|
||||
$tracking->recipient_name = (string) ($params['recipient_name'] ?? '');
|
||||
$tracking->recipient_address = (string) ($params['recipient_address'] ?? '');
|
||||
@@ -78,6 +81,177 @@ class ExpressTrackingService
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按快递单号走快递100 查询并落库(CLI:gancao:sync-logistics -t)
|
||||
*
|
||||
* @return array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>}
|
||||
*/
|
||||
public static function queryKuaidiByTrackingNumber(string $trackingNumber): array
|
||||
{
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'assistant_cleared' => 0,
|
||||
'assistant_skipped_assign_log' => 0,
|
||||
'assistant_lines' => [],
|
||||
'details' => [],
|
||||
];
|
||||
|
||||
$tn = trim($trackingNumber);
|
||||
if ($tn === '') {
|
||||
$stats['failed'] = 1;
|
||||
$stats['details'][] = [
|
||||
'success' => false,
|
||||
'tracking_number' => '',
|
||||
'message' => '快递单号不能为空',
|
||||
];
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$tracking = self::resolveTrackingByNumber($tn);
|
||||
if (!$tracking) {
|
||||
$stats['failed'] = 1;
|
||||
$stats['details'][] = [
|
||||
'success' => false,
|
||||
'tracking_number' => $tn,
|
||||
'message' => '未找到该快递单号对应的业务订单或物流追踪记录',
|
||||
];
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$stats['total'] = 1;
|
||||
self::backfillRecipientPhoneFromPrescriptionOrder($tracking);
|
||||
self::syncTrackingExpressCompanyFromNumber($tracking);
|
||||
|
||||
try {
|
||||
$result = self::queryAndUpdate((int) $tracking->id, false);
|
||||
$ok = self::isKuaidiQuerySuccessful($result);
|
||||
$detail = [
|
||||
'order_id' => (int) $tracking->order_id,
|
||||
'tracking_number' => (string) $tracking->tracking_number,
|
||||
'success' => $ok,
|
||||
'message' => trim((string) ($result['hint'] ?? '')) ?: 'ok',
|
||||
'traces' => count($result['traces'] ?? []),
|
||||
'state' => (string) ($result['state_text'] ?? $tracking->current_state_text ?? ''),
|
||||
'source' => (string) ($result['source'] ?? ''),
|
||||
];
|
||||
if ($ok) {
|
||||
$stats['success'] = 1;
|
||||
} else {
|
||||
$stats['failed'] = 1;
|
||||
}
|
||||
$stats['details'][] = $detail;
|
||||
} catch (\Throwable $e) {
|
||||
$stats['failed'] = 1;
|
||||
$stats['details'][] = [
|
||||
'order_id' => (int) $tracking->order_id,
|
||||
'tracking_number' => (string) $tracking->tracking_number,
|
||||
'success' => false,
|
||||
'message' => '异常:' . $e->getMessage(),
|
||||
];
|
||||
Log::error('queryKuaidiByTrackingNumber failed', [
|
||||
'tracking_number' => $tn,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 express_tracking 存在(不发起查询)
|
||||
*/
|
||||
private static function resolveTrackingByNumber(string $trackingNumber): ?ExpressTracking
|
||||
{
|
||||
$tn = trim($trackingNumber);
|
||||
if ($tn === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tracking = ExpressTracking::whereRaw('TRIM(`tracking_number`) = ?', [$tn])
|
||||
->whereNull('delete_time')
|
||||
->find();
|
||||
|
||||
$order = PrescriptionOrder::whereNull('delete_time')
|
||||
->whereRaw('TRIM(`tracking_number`) = ?', [$tn])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if ($tracking && $order) {
|
||||
$dirty = false;
|
||||
if ((int) $tracking->order_id !== (int) $order->id) {
|
||||
$tracking->order_id = (int) $order->id;
|
||||
$dirty = true;
|
||||
}
|
||||
if (trim((string) ($tracking->recipient_phone ?? '')) === '') {
|
||||
$tracking->recipient_phone = (string) ($order->recipient_phone ?? '');
|
||||
$dirty = true;
|
||||
}
|
||||
if ($dirty) {
|
||||
$tracking->update_time = time();
|
||||
$tracking->save();
|
||||
}
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
if ($tracking) {
|
||||
self::backfillRecipientPhoneFromPrescriptionOrder($tracking);
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
if (!$order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$tracking = new ExpressTracking();
|
||||
$tracking->tracking_number = mb_substr($tn, 0, 80);
|
||||
$tracking->create_time = $now;
|
||||
$tracking->order_id = (int) $order->id;
|
||||
$tracking->order_type = 'prescription';
|
||||
$tracking->express_company = ExpressTrackService::normalizeExpressCompanyCode(
|
||||
$tn,
|
||||
(string) ($order->express_company ?? 'auto')
|
||||
);
|
||||
$tracking->recipient_phone = (string) ($order->recipient_phone ?? '');
|
||||
$tracking->recipient_name = (string) ($order->recipient_name ?? '');
|
||||
$tracking->recipient_address = (string) ($order->shipping_address ?? '');
|
||||
$tracking->next_update_time = $now;
|
||||
$tracking->update_time = $now;
|
||||
$tracking->save();
|
||||
|
||||
return $tracking;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $result
|
||||
*/
|
||||
private static function isKuaidiQuerySuccessful(?array $result): bool
|
||||
{
|
||||
if (!is_array($result)) {
|
||||
return false;
|
||||
}
|
||||
$source = (string) ($result['source'] ?? '');
|
||||
if ($source !== 'kuaidi100') {
|
||||
return false;
|
||||
}
|
||||
$hint = trim((string) ($result['hint'] ?? ''));
|
||||
if ($hint === '') {
|
||||
return true;
|
||||
}
|
||||
if (str_contains($hint, '暂无轨迹')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询并更新物流信息
|
||||
*
|
||||
@@ -96,13 +270,14 @@ class ExpressTrackingService
|
||||
}
|
||||
|
||||
self::backfillRecipientPhoneFromPrescriptionOrder($tracking);
|
||||
self::syncTrackingExpressCompanyFromNumber($tracking);
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 调用快递100查询(顺丰等单号需带收件人手机号后四位,见 ExpressTrackService)
|
||||
$result = ExpressTrackService::query(
|
||||
$tracking->express_company,
|
||||
$tracking->tracking_number,
|
||||
(string) $tracking->express_company,
|
||||
(string) $tracking->tracking_number,
|
||||
(string) $tracking->recipient_phone
|
||||
);
|
||||
|
||||
@@ -547,6 +722,27 @@ class ExpressTrackingService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单号与 express_company 不一致时按单号纠正(如京东单误存 sf)
|
||||
*/
|
||||
private static function syncTrackingExpressCompanyFromNumber(ExpressTracking $tracking): void
|
||||
{
|
||||
$num = trim((string) $tracking->tracking_number);
|
||||
if ($num === '') {
|
||||
return;
|
||||
}
|
||||
$normalized = ExpressTrackService::normalizeExpressCompanyCode(
|
||||
$num,
|
||||
(string) ($tracking->express_company ?? 'auto')
|
||||
);
|
||||
if ($normalized === (string) $tracking->express_company) {
|
||||
return;
|
||||
}
|
||||
$tracking->express_company = $normalized;
|
||||
$tracking->update_time = time();
|
||||
$tracking->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存轨迹明细
|
||||
*/
|
||||
|
||||
@@ -470,54 +470,62 @@ final class GancaoLogisticsRouteService
|
||||
|
||||
$orders = $q->order('id', 'desc')->limit($limit)->select();
|
||||
foreach ($orders as $order) {
|
||||
$stats['total']++;
|
||||
try {
|
||||
$r = self::syncOne($order);
|
||||
$detail = [
|
||||
'order_id' => (int) $order->id,
|
||||
'order_no' => (string) $order->order_no,
|
||||
'app_order_no' => (string) $order->gancao_reciperl_order_no,
|
||||
'tracking_number' => (string) $order->tracking_number,
|
||||
'success' => $r['success'],
|
||||
'message' => $r['message'],
|
||||
'traces' => $r['traces_count'],
|
||||
'state' => $r['state'],
|
||||
];
|
||||
if ($r['success']) {
|
||||
$stats['success']++;
|
||||
if (!empty($r['assistant_sync']) && is_array($r['assistant_sync'])) {
|
||||
$sync = $r['assistant_sync'];
|
||||
$act = (string) ($sync['action'] ?? '');
|
||||
if ($act === 'cleared') {
|
||||
$stats['assistant_cleared']++;
|
||||
$stats['assistant_lines'][] = sprintf(
|
||||
'[移除医助+指派日志][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
|
||||
(int) ($sync['diagnosis_id'] ?? 0),
|
||||
(int) ($sync['prescription_order_id'] ?? 0),
|
||||
(string) ($sync['tracking_number'] ?? ''),
|
||||
(string) ($sync['former_assistant_id'] ?? ''),
|
||||
(int) ($sync['fulfillment_status'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
}
|
||||
$stats['details'][] = $detail;
|
||||
} catch (\Throwable $e) {
|
||||
$stats['failed']++;
|
||||
$stats['details'][] = [
|
||||
'order_id' => (int) $order->id,
|
||||
'success' => false,
|
||||
'message' => '异常:' . $e->getMessage(),
|
||||
];
|
||||
Log::error('Gancao route sync exception: ' . $e->getMessage(), [
|
||||
'order_id' => (int) $order->id,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
self::appendSyncOneResult($stats, $order);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{total:int, success:int, failed:int, skipped:int, assistant_cleared:int, assistant_skipped_assign_log:int, assistant_lines:list<string>, details:array<int, array<string,mixed>>} $stats
|
||||
*/
|
||||
private static function appendSyncOneResult(array &$stats, PrescriptionOrder $order): void
|
||||
{
|
||||
$stats['total']++;
|
||||
try {
|
||||
$r = self::syncOne($order);
|
||||
$detail = [
|
||||
'order_id' => (int) $order->id,
|
||||
'order_no' => (string) $order->order_no,
|
||||
'app_order_no' => (string) $order->gancao_reciperl_order_no,
|
||||
'tracking_number' => (string) $order->tracking_number,
|
||||
'success' => $r['success'],
|
||||
'message' => $r['message'],
|
||||
'traces' => $r['traces_count'],
|
||||
'state' => $r['state'],
|
||||
];
|
||||
if ($r['success']) {
|
||||
$stats['success']++;
|
||||
if (!empty($r['assistant_sync']) && is_array($r['assistant_sync'])) {
|
||||
$sync = $r['assistant_sync'];
|
||||
$act = (string) ($sync['action'] ?? '');
|
||||
if ($act === 'cleared') {
|
||||
$stats['assistant_cleared']++;
|
||||
$stats['assistant_lines'][] = sprintf(
|
||||
'[移除医助+指派日志][甘草路由] 诊单=%d 业务订单=%d 运单=%s 原医助ID=%s 履约状态=%d',
|
||||
(int) ($sync['diagnosis_id'] ?? 0),
|
||||
(int) ($sync['prescription_order_id'] ?? 0),
|
||||
(string) ($sync['tracking_number'] ?? ''),
|
||||
(string) ($sync['former_assistant_id'] ?? ''),
|
||||
(int) ($sync['fulfillment_status'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
}
|
||||
$stats['details'][] = $detail;
|
||||
} catch (\Throwable $e) {
|
||||
$stats['failed']++;
|
||||
$stats['details'][] = [
|
||||
'order_id' => (int) $order->id,
|
||||
'success' => false,
|
||||
'message' => '异常:' . $e->getMessage(),
|
||||
];
|
||||
Log::error('Gancao route sync exception: ' . $e->getMessage(), [
|
||||
'order_id' => (int) $order->id,
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ return [
|
||||
|
||||
//上传文件的格式 (文件)
|
||||
'file_file' => [
|
||||
'zip','rar','txt','pdf','doc','docx','xls','xlsx','ppt','pptx','csv','txt','ftr','7z','gz'
|
||||
'zip','rar','txt','pdf','doc','docx','xls','xlsx','ppt','pptx','csv','txt','ftr','7z','gz',
|
||||
'mp3', 'wav', 'm4a', 'aac', 'amr', 'wma', // 音频
|
||||
],
|
||||
|
||||
// 登录设置
|
||||
@@ -123,9 +124,19 @@ return [
|
||||
// 订单管理列表(order/index):以下角色 ID + root 可查看全部订单,其余仅本人创建
|
||||
'order_list_view_all_roles' => [0, 3, 4, 9, 6],
|
||||
|
||||
// 自录转化统计(stats.self_input / personal_yeji / personal_account_cost):
|
||||
// 以下角色 ID + root 在列表与总览中可见全部录入人的业绩/账户消耗(不受角色 data_scope 收窄)。
|
||||
// 编辑/删除由菜单按钮权限(stats.personal_yeji/edit 等)控制,不在此配置。
|
||||
'self_input_stats_view_all_roles' => [0, 3, 4, 9, 6],
|
||||
|
||||
// 业务订单列表金额统计:医助角色 ID;非支付审核白名单的医助仅统计其诊单 assistant_id=本人 的业绩
|
||||
'prescription_order_stats_assistant_role_id' => 2,
|
||||
|
||||
// 业务订单列表:「下单」角色(id=6)可按审核人姓名筛选本人审核过的订单
|
||||
'prescription_order_placer_role_ids' => [6],
|
||||
// 同时拥有以下角色时可按任意审核人筛选(如经理、管理员)
|
||||
'prescription_order_placer_exempt_role_ids' => [3, 8],
|
||||
|
||||
/*
|
||||
* 数据隔离(按部门):全局可用,角色上的 data_scope 控制范围
|
||||
* 1=全部 2=本部门及下级 3=仅本部门 4=仅本人
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
CREATE TABLE IF NOT EXISTS `zyt_prescription_library` (
|
||||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`prescription_name` varchar(100) NOT NULL DEFAULT '' COMMENT '处方名称',
|
||||
`formula_type` varchar(20) NOT NULL DEFAULT '主方' COMMENT '处方类型:主方、辅方',
|
||||
`herbs` text COMMENT '药材列表JSON:[{name, dosage}]',
|
||||
`is_public` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否公开:0-仅自己可见 1-所有人可见',
|
||||
`creator_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建人ID',
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import r from"./error-wyGGmxC3.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-DFTWCWyi.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-D2tYw7RM.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
|
||||
import r from"./error-BQhoqSxS.js";import{f as p,ak as i,I as m,a as e,aN as s,J as o}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-lurTijPg.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-BtuN-JX0.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const a="/admin/assets/no_perms-jDxcYpYC.png",n={class:"error404"},W=p({__name:"403",setup(c){return(_,t)=>(i(),m("div",n,[e(r,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:s(()=>[...t[0]||(t[0]=[o("div",{class:"flex justify-center"},[o("img",{class:"w-[150px] h-[150px]",src:a,alt:""})],-1)])]),_:1})]))}});export{W as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import o from"./error-wyGGmxC3.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-DFTWCWyi.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-D2tYw7RM.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
|
||||
import o from"./error-BQhoqSxS.js";import{f as r,ak as t,I as m,a as p}from"./@vue/runtime-core-C6bnekPw.js";import"./element-plus-lurTijPg.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./@vue/shared-mAAVTE9n.js";import"./@vue/reactivity-DiY1c2vO.js";import"./@element-plus/icons-vue-HOEUG8sr.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./vue-router-QlpZ4wdW.js";import"./index-BtuN-JX0.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const i={class:"error404"},T=r({__name:"404",setup(e){return(a,s)=>(t(),m("div",i,[p(o,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{T as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{M as x,N as y,r as I,d as C,L as $}from"./element-plus-DFTWCWyi.js";import{W as D}from"./@element-plus/icons-vue-HOEUG8sr.js";import{a3 as L}from"./tcm-C3dQ3CSx.js";import{f as T,w as P,ak as g,I as A,aP as E,G as k,aN as n,a,O as m,J as B}from"./@vue/runtime-core-C6bnekPw.js";import{Q as p}from"./@vue/shared-mAAVTE9n.js";import{y as F,n as b}from"./@vue/reactivity-DiY1c2vO.js";import{_ as M}from"./index-D2tYw7RM.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const V={class:"assign-log-panel"},K=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(h,{expose:w}){const c=h,d=b(!1),_=b([]);function v(o){const e=o.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function N(o){const e=o.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const i=new Date(t*1e3);if(Number.isNaN(i.getTime()))return"—";const r=s=>String(s).padStart(2,"0");return`${i.getFullYear()}-${r(i.getMonth()+1)}-${r(i.getDate())} ${r(i.getHours())}:${r(i.getMinutes())}:${r(i.getSeconds())}`}function u(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",i=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const s=Number(o[i]);return Number.isFinite(s)&&s>0?`ID:${s}`:"—"}const f=async()=>{if(c.diagnosisId){d.value=!0;try{const o=await L({id:c.diagnosisId}),e=Array.isArray(o)?o:[];_.value=e}catch(o){console.error(o),_.value=[]}finally{d.value=!1}}};return P(()=>c.diagnosisId,()=>{f()},{immediate:!0}),w({refresh:f}),(o,e)=>{const t=y,i=C,r=I,s=x,S=$;return g(),A("div",V,[E((g(),k(s,{data:_.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[a(t,{label:"操作时间",width:"175",prop:"create_time_text"}),a(t,{label:"原医助","min-width":"120"},{default:n(({row:l})=>[m(p(u(l,"from")),1)]),_:1}),a(t,{label:"新医助","min-width":"120"},{default:n(({row:l})=>[m(p(u(l,"to")),1)]),_:1}),a(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:l})=>[m(p(v(l)),1)]),_:1}),a(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[0]||(e[0]=B("span",null,"快照·业务单创建时间",-1)),a(r,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[a(i,{class:"assign-log-col-hint"},{default:n(()=>[a(F(D))]),_:1})]),_:1})]),default:n(({row:l})=>[m(p(N(l)),1)]),_:1}),a(t,{label:"操作人",width:"110",prop:"operator_name"}),a(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),a(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[S,d.value]])])}}}),yt=M(K,[["__scopeId","data-v-5465d5eb"]]);export{yt as default};
|
||||
import{M as x,N as y,r as I,d as C,L as $}from"./element-plus-lurTijPg.js";import{W as D}from"./@element-plus/icons-vue-HOEUG8sr.js";import{a3 as L}from"./tcm-CXOqniC5.js";import{f as T,w as P,ak as g,I as A,aP as E,G as k,aN as n,a,O as m,J as B}from"./@vue/runtime-core-C6bnekPw.js";import{Q as p}from"./@vue/shared-mAAVTE9n.js";import{y as F,n as b}from"./@vue/reactivity-DiY1c2vO.js";import{_ as M}from"./index-BtuN-JX0.js";import"./@vue/runtime-dom-DDAG46FW.js";import"./lodash-D3kF6u-c.js";import"./lodash-es-C2A-Pj28.js";import"./@popperjs/core-C5az9QE8.js";import"./dayjs-DG77mNTn.js";import"./@ctrl/tinycolor-BuyEbX8F.js";import"./async-validator-CFA_igpM.js";import"./normalize-wheel-es-BQoi3Ox2.js";import"./jspdf-BsthCvR5.js";import"./@babel/runtime-BanGtE2-.js";import"./fflate-_ayOYiT1.js";import"./vue-router-QlpZ4wdW.js";import"./pinia-B8cvXbiZ.js";import"./axios-C80V62Fs.js";import"./@vueuse/core-1S4fJlii.js";import"./@vueuse/shared-Cl3lVqjz.js";import"./css-color-function-DI01JAaZ.js";import"./balanced-match-BdS7OldZ.js";import"./color-B2zxaWkI.js";import"./clone-DBZ6_OiU.js";import"./color-convert-OrFsTA_V.js";import"./color-name-Dju3oUBS.js";import"./color-string-pUQDDJII.js";import"./ms-CzQ2E3wO.js";import"./vue-clipboard3-DByT_rEQ.js";import"./clipboard-CHgxszqY.js";import"./echarts-CNLwa9wG.js";import"./tslib-BDyQ-Jie.js";import"./zrender-DRNXw7y3.js";import"./highlight.js-Bxt7hFFy.js";import"./@highlightjs/vue-plugin-C4onkBvW.js";const V={class:"assign-log-panel"},K=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(h,{expose:w}){const c=h,d=b(!1),_=b([]);function v(o){const e=o.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function N(o){const e=o.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(o.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const i=new Date(t*1e3);if(Number.isNaN(i.getTime()))return"—";const r=s=>String(s).padStart(2,"0");return`${i.getFullYear()}-${r(i.getMonth()+1)}-${r(i.getDate())} ${r(i.getHours())}:${r(i.getMinutes())}:${r(i.getSeconds())}`}function u(o,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",i=e==="from"?"from_assistant_id":"to_assistant_id",r=o[t];if(r!=null&&String(r).trim()!==""&&String(r)!=="—")return String(r);const s=Number(o[i]);return Number.isFinite(s)&&s>0?`ID:${s}`:"—"}const f=async()=>{if(c.diagnosisId){d.value=!0;try{const o=await L({id:c.diagnosisId}),e=Array.isArray(o)?o:[];_.value=e}catch(o){console.error(o),_.value=[]}finally{d.value=!1}}};return P(()=>c.diagnosisId,()=>{f()},{immediate:!0}),w({refresh:f}),(o,e)=>{const t=y,i=C,r=I,s=x,S=$;return g(),A("div",V,[E((g(),k(s,{data:_.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:n(()=>[a(t,{label:"操作时间",width:"175",prop:"create_time_text"}),a(t,{label:"原医助","min-width":"120"},{default:n(({row:l})=>[m(p(u(l,"from")),1)]),_:1}),a(t,{label:"新医助","min-width":"120"},{default:n(({row:l})=>[m(p(u(l,"to")),1)]),_:1}),a(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:n(({row:l})=>[m(p(v(l)),1)]),_:1}),a(t,{label:"快照·业务单创建时间",width:"190"},{header:n(()=>[e[0]||(e[0]=B("span",null,"快照·业务单创建时间",-1)),a(r,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:n(()=>[a(i,{class:"assign-log-col-hint"},{default:n(()=>[a(F(D))]),_:1})]),_:1})]),default:n(({row:l})=>[m(p(N(l)),1)]),_:1}),a(t,{label:"操作人",width:"110",prop:"operator_name"}),a(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),a(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[S,d.value]])])}}}),yt=M(K,[["__scopeId","data-v-5465d5eb"]]);export{yt as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user